Zana: billing, collections and reconciliation on one ledger
2026-09-22 / 4 min / billing / reconciliation / m-pesa / laravel / saas
A billing system for businesses that invoice the same customers every cycle. Metered tariffs, M-PESA and card collection, and the part almost nobody builds: reading the bank statement back and settling the invoices it matches.
The problem is not the invoice
Every business that bills the same customers on a cycle ends up with the same three systems: something that raises the invoice, something that takes the money, and a spreadsheet that tries to reconcile the two. The first two are easy. The third is where the month goes.
Zana is the system we built to run that whole loop. It is live at zanahq.com, and we run our own billing on it.
The thing it has to get right is boring and absolute: at any moment, what a customer owes you is a single number. Not a number in the invoicing tool, a different one in the M-PESA statement, and a third in somebody’s spreadsheet.
Eight kinds of business, one ledger
Water utilities, estates, schools, ISPs, clinics, waste collectors. They look like different products until you write down what they actually do, and then they are the same product with different tariffs:
- Someone is billed on a cycle.
- The amount is either flat, or derived from a reading.
- The money arrives by M-PESA, card, or bank, often days later and often short.
- Somebody has to chase whoever has not paid.
Building eight products would have been the obvious mistake. The ledger is shared; the tariff is configuration. A metered water account and a monthly service charge are the same row with a different way of arriving at the amount.
The reconciliation nobody else does
This is the part that justifies the product.
You already receive a statement from M-PESA or your bank. It is a CSV, an Excel file, or a scanned PDF, depending on who sent it. Zana reads every deposit in it, proposes the invoice that each one settles, and leaves only what it cannot prove for a person to decide.
Two properties matter more than the matching itself:
Re-uploading an overlapping statement never double-posts a payment. Statements overlap constantly. Somebody uploads last month again by accident, or the bank sends a corrected file. Every deposit carries a natural key from the statement, and a second sighting of the same deposit is recognised rather than re-applied. Without this the feature is worse than useless, because it silently corrupts the one number the whole system exists to protect.
What it cannot prove, it does not guess. A partial payment, a payment with no reference, a payment that spans two invoices: these go to a human with the evidence attached. A reconciliation tool that guesses is a tool you stop trusting after the first wrong guess, and then you are back on the spreadsheet.
A billing run is a distributed transaction wearing a disguise
Raising invoices in bulk is not a loop. Partway through, one account has a broken tariff, the SMS provider rate-limits you, and someone closes the browser tab.
A run is a durable object with its own state, not a request. Every account within it is processed independently and idempotently, so a run can be resumed rather than restarted, and resuming it cannot bill anyone twice. The invoice, the notification and the payment link are separate steps against that account, because the failure modes are separate: a tariff error is the operator’s problem and an SMS failure is not.
That shape is why the ceiling is high. Zana is built to bill up to five million connections, and it gets there by never treating a run as one transaction: work is claimed in chunks by workers that can be added, and a failure anywhere affects one account rather than the run. The limit is throughput you can pay for, not a number baked into the design.
This matters to a buyer with four hundred accounts for one reason only. It means the system will not become the thing that has to be replaced when they reach four thousand.
How it is built
A Laravel API on PHP 8.5 owns the ledger, the jobs and the scheduler, and also renders the marketing site. A React SPA on Vite is the operator surface. They are one origin and two deployables: zanahq.com and zanahq.com/app.
The split matters for an unglamorous reason. Billing runs, statement imports and bulk sends are queue work with real durations. Putting them behind the same process that serves the dashboard means one long statement import makes the whole product feel broken.
What I would protect next time
The single-number rule. Every feature request that would have created a second place where a balance lives got pushed back into the ledger instead. It is the constraint that keeps the reconciliation honest, and it is the first thing that gets traded away when somebody wants a quick report.
If you bill the same customers every month and the reconciliation is eating your month end, Zana probably already does it. If your billing is stranger than that, send me a brief.
Read next
- Noria Pay: one payments service instead of twenty-one integrations
Every product that took money had rebuilt the same layer: persist the transaction, host the callback, normalise the payload, reconcile the settlement. Roughly 4,800 lines of it in one codebase and 12,000 in another. This is that layer, written once.
- Noria Send: email and SMS behind one API
A Resend-shaped service over Amazon SES and OnFon Media. One outbox, one event stream, one suppression list, and a careful answer to the hardest question in delivery: what to do when a provider never replies at all.
