Back to Projects
In Development

Recurring Billing Platform

Naira recurring-billing engine on Paystack: auto-debit, invoicing, dunning and payout splits

~/recurring-billing

$ deploy --project recurring-billing

BUILD IN PROGRESS

SAAS

Key Technical Highlights

12 tables with uniqueness constraints as the idempotency backbone (one invoice per client per period, unique payment references, unique webhook event keys): duplicate webhooks, cron re-runs and concurrent callers cannot double-settle.

One function, markInvoicePaid, is the only code path that marks an invoice paid; it runs in a row-locked transaction and is called by webhook, return page, auto-charge, cron sweep and reconciliation, so there is a single place to reason about money state.

Webhook handler verifies an HMAC-SHA512 signature over the raw body (constant-time compare), dedupes by event key, and re-verifies the transaction with the payment provider before crediting, rather than trusting the payload.

All money is integer kobo end to end; payout and VAT maths live in a pure, IO-free billing module with 32 unit-test cases covering month-end anchors, leap years, rounding that always sums to the total, and VAT kept out of the payout split.

Mismatched amounts are never auto-settled: the payment is stored as unmatched and the invoice stays open for owner review, instead of guessing.

The Case Study

The Problem

A business needed to bill recurring clients in naira on different cycles, some by saved card and some by invoice and bank transfer, and to route a defined share of each payment to a separate bank account. Paystack provides the payment rail, but the schedule, retries, receipts and reconciliation had to be mine. In billing software the dangerous failures are quiet ones: a double charge after a timeout, a webhook delivered twice, an invoice marked paid for the wrong amount.

The Numbers

  • 12 database tables, 3 Drizzle migrations, and unique indexes on invoice period, payment reference, attempt reference and webhook event key.
  • 32 unit-test cases on the billing domain and infrastructure helpers (cycle maths, payout split, VAT, dunning, state transitions, signature verification, encryption round-trip), plus 1 Playwright e2e spec.
  • One daily cron run handles every client and is built to be re-run safely.
  • 7 commits, all mine; docs date the build from late September 2026. Production domain is live; real-money use has not started.

Decisions and Trade-offs

Own billing engine instead of Paystack Subscriptions. Subscriptions would have meant less code, but I needed per-client cycles, a custom retry and fallback policy, and one code path for card and invoice clients. Cost: I own the scheduler and its failure modes.

Subaccount split instead of the Transfer API. The split is applied atomically at payment time, with no balance, transfer OTP or second failure point. I verified in the provider's test mode that the destination receives an exact figure, including on saved-card charges.

Reference-first, verify-before-retry. Every money-moving call persists a unique reference before the request. After a timeout the code verifies by reference instead of retrying blind. A partial unique index also allows only one pending auto-charge per invoice. I rejected simple retry-on-error because it is the classic double-charge path.

One idempotent settlement function. I rejected settling in each caller (webhook, return page, cron). Four copies of that logic would drift. It is one function, with a row lock and a payment-reference check.

Pure domain layer. Schedule, payout, VAT and dunning logic does no IO, so it is cheap to test thoroughly. Due dates are computed from the anchor date and period index, not from the previous due date, so a 31st-of-month anchor does not drift.

How It Was Verified

Unit tests cover the pure logic. Against Paystack test mode I ran a spike script that confirmed card tokenisation, charging with no customer present, the exact split on both initialise and charge, and rejection of an impossible split. Signature checks (valid, tampered, missing) and duplicate delivery were tested against the running webhook route with signed synthetic events. That testing also found a real bug: returning an error status for an unknown reference makes the provider retry for days, so unknown references are logged and acknowledged instead.

Honest Limits and What I'd Change

  • It is not yet live with real money. The webhook is not registered for live mode, the payout account still needs switching from test to live, and the planned live dry run has not happened.
  • The e2e suite is thin (one spec); the plan describes a fuller one, and checkout itself sits behind a bot wall so it cannot be automated. Declined and insufficient-funds responses were not reproducible in test mode, so they are handled in code but unconfirmed.
  • Dedicated virtual accounts are blocked on the provider enabling the feature.

My Role

Sole engineer: design, schema, billing domain, payment integration, admin dashboard, email and PDF documents, deployment and cron.