All posts

/5 min/Moffat Munene

Feature-based billing for African SaaS moffhub/billing v1

A Laravel billing engine built around how money actually moves in East and West Africa twelve payment providers, fallback chains, idempotent usage metering, multi-currency from day one. The thing Stripe Billing isn't.

  • #laravel
  • #billing
  • #saas
  • #mpesa
  • #africa

If your SaaS lives in Kenya, Nigeria or Ghana, "subscription billing" doesn't mean what your npm install stripe tutorial thinks it means. It means STK pushes that occasionally don't arrive, callbacks that fire twice, customers who paid via Pesapal yesterday because Paystack was down for an hour, and finance teams who reconcile by spreadsheet on the 31st.

moffhub/billing is the Laravel package we built so we'd stop solving this problem from scratch every six months.

Twelve providers, native not adapters

Stripe-first packages model M-Pesa as a "third-party adapter" a leaky bag glued onto a Visa-shaped engine. We took the inverse: M-Pesa, Airtel Money, KCB BUNI, Equity Jenga, Co-op Bank, Stanbic, NCBA, IntaSend, Paystack, Flutterwave, Pesapal and a manual provider all live behind the same PaymentProviderInterface. They're peers.

Each implements the same five methods:

interface PaymentProviderInterface
{
    public function charge(int $amount, string $currency, array $options): array;
    public function refund(string $providerPaymentId, ?int $amount, array $options): array;
    public function getPaymentStatus(string $providerPaymentId): string;
    public function verifyWebhook(Request $request): bool;
    public function parseWebhook(Request $request): array;
}

Adding a thirteenth Tigo Pesa, Vodafone Cash, Wave is a class implementing that contract, not a fork.

Provider fallback chains

The single biggest reliability win in the package, and it's a one-line config:

'providers' => [
    'mpesa' => [
        'consumer_key' => env('MPESA_CONSUMER_KEY'),
        // ...
        'fallback' => 'paystack',
    ],
],

When Mpesa::charge() throws ProviderException (timeout, Daraja 500, callback never arrives), the package logs, switches to Paystack, retries the charge, and returns the result to your code. Your controller doesn't know a fallback happened. Your customer's STK push silently became a Paystack card prompt instead of a Payment failed toast.

It's one level deep on purpose we want a retry, not a rabbit hole.

Feature-gated, not plan-gated

Most billing packages teach $user->onPlan('pro'). That's a trap: the day product splits the pro plan into pro and business, every authorization check in the codebase needs editing.

moffhub/billing gates on features:

if (! $user->hasFeature('ocr_scanning')) {
    abort(402, 'Upgrade to unlock OCR.');
}

if (! $user->canConsume('api_calls', 1)) {
    abort(402, 'Quota exhausted.');
}

Plans are JSON containers of feature slugs. Re-shape plans, re-name plans, A/B test plans every hasFeature() check in your code keeps working. Authorization decouples from packaging.

Idempotent usage metering

Webhooks fire twice. Frontends double-click. Mobile clients retry on flaky 4G. If your usage counter increments on every call, you over-bill customers and lose the trust war.

UsageService::record() takes a transactionId:

app(UsageService::class)->record(
    billable: $user,
    featureSlug: 'pdf_exports',
    quantity: 1,
    transactionId: "export-{$invoice->ulid}",
);

The same transaction id never double-counts enforced by a unique constraint on usage_events, not application logic. Replay the webhook ten times; the row is recorded once.

Mid-cycle upgrades, proration done right

Customer on starter (KES 1,000/mo, 12 days into the cycle) upgrades to growth (KES 3,000/mo). What do you charge them?

The package computes it atomically:

prepaid remaining = 1,000 × (18/30) = 600 KES credit
new plan remaining = 3,000 × (18/30) = 1,800 KES owed
delta = 1,800 - 600 = 1,200 KES charged now
new period ends = original cycle end (preserves alignment)

All inside one DB transaction with the new plan attachment. No drift, no overlap, no "double-billed for the upgrade" tickets.

Multi-currency, no FX

One company can be on a KES subscription, another on USD, another on NGN. There's no exchange-rate table, no daily rate sync, no rounding bugs. Each provider charges in its own native currency; the invoice line items store the same. KCB BUNI charges KES; Paystack charges NGN; Flutterwave charges whatever it likes. Reconciliation is per-currency.

Polymorphic billables

The Billable trait uses morphMany for subscriptions, payments, usage and invoices. So this works:

class Company extends Model {
    use Billable;
}

class User extends Model {
    use Billable;
}

$company->subscribeTo('enterprise')->using('mpesa')->initiate();
$user->subscribeTo('pro')->using('paystack')->initiate();

Zero coupling to any one model. Single subscription engine, two billing dimensions.

Events not coupling

The package fires domain events and stops there. Wiring SMS, Slack, USSD or maker-checker into billing happens in your EventServiceProvider, not by patching the package:

  • SubscriptionCreated, SubscriptionRenewed, SubscriptionCancelled, SubscriptionPaused, SubscriptionResumed, SubscriptionExpired
  • PaymentReceived, PaymentFailed
  • UsageLimitApproaching (fires at 80, 90, 100% of quota configurable)
  • FeatureAccessDenied
  • InvoiceGenerated, PlanChanged

Send the welcome SMS in a listener. Trigger the maker-checker review in another. Compose; don't fork.

A worked example

Define plans in config/billing.php:

return [
    'plans' => [
        'free' => [
            'price' => 0,
            'features' => ['api_calls' => 100, 'pdf_exports' => 5],
        ],
        'pro' => [
            'price' => 1500,
            'currency' => 'KES',
            'cycle' => 'monthly',
            'features' => ['api_calls' => 50_000, 'pdf_exports' => 'unlimited'],
        ],
    ],
];

Subscribe:

$user->subscribeTo('pro')
     ->using('mpesa')
     ->withPhone('+254712345678')
     ->create();

Returns a pending subscription; STK push fires on the customer's phone; the package's webhook handler confirms on success. Cycle billing then runs as a queued job failed renewals retry on the dunning schedule (subscriptions.dunning_schedule = [1, 3, 7] days).

Gate + meter:

if (! $user->canConsume('api_calls', 1)) {
    abort(402, 'Quota exhausted  upgrade your plan.');
}

DB::transaction(function () use ($user, $request) {
    $user->consume('api_calls', 1, transactionId: $request->ulid);
    return doTheThing($request);
});

If doTheThing() throws, the transaction rolls back the consumption. If the request retries, the transaction id deduplicates the consume.

What it doesn't do

  • It doesn't run your tax engine there's a TaxCalculatorInterface and a Kenya-VAT default; pluggable for VAT MOSS or Nigerian VAT.
  • It doesn't ship its own UI pricing pages, plan-pickers and self-service portals are yours.
  • It doesn't replace finance but it gives them a clean payments table with provider IDs, statuses and JSON responses. Reconciliation is a SQL query, not a screen-scrape.

Install

composer require moffhub/billing
php artisan migrate

Production in two of our internal apps. Hardening for public release. If you're shipping a SaaS for African customers and your billing layer is held together with hope and a shared payments table, give us a shout we'd rather harden this together than watch everyone re-invent it.

Package for this post

Feature-based billing for Laravel SaaS

Stripe-style billing, but for African SaaS M-Pesa, Paystack, Flutterwave, Pesapal with feature gating, usage metering and invoicing.

composer require moffhub/billing