Notes

Matching M-Pesa payments to invoices when nothing carries a reference

Kenyan SMEs get paid on M-Pesa first and by bank transfer second, and neither arrives with an invoice number attached. How we narrow a payment to a single open invoice, and where we still hand the decision back to a human.

A Kenyan business gets paid on M-Pesa first and by bank transfer second. Neither rail was designed to carry your invoice number. M-Pesa C2B gives you a BillRefNumber that the payer types themselves on a phone keypad, usually in a hurry, often from memory. Bank transfers arrive with whatever the payer's banking app allowed them to put in a narration field. Cash arrives with nothing at all.

So the interesting problem in receivables software is not storing invoices. It is deciding which open invoice a KES 65,000 payment from 0712345678 at 09:14 on the 3rd was meant to settle, and being honest when you cannot tell.

Start by making the reference worth matching on

The cheapest win is upstream. Every Rentisha invoice carries a reference unique to that invoice, and it is the account number the tenant is told to use on the paybill. When a C2B callback lands, the first attempt is the boring one: find the single open invoice whose reference is exactly the string the payer typed.

When that hits, there is nothing clever to do and no ambiguity to resolve. A meaningful share of payments land here, and the value of the rest of the cascade is entirely in how gracefully it degrades from this line.

There is a related trap worth naming, because it is easy to ship and expensive to find. Every mobile-money sandbox hands you a working test shortcode, and a test shortcode is still a real destination. If an organisation has no paybill configured in production, the only safe behaviour is to send nothing and raise a warning. A fallback that quietly reaches for whatever number was convenient during development is a fallback that eventually routes somebody's rent somewhere nobody can recover it from.

Then normalise the things humans format inconsistently

When the reference is missing or wrong, the payer's phone number is the next-best signal, except that a phone number in Kenya has at least three written forms, and your database and the payment feed will not agree on which one to use.

The same subscriber reaches you as 0712345678 from one source, 254712345678 from another and +254712345678 from a third. All three are one person. So both sides of the comparison are reduced to a single canonical form before anything is compared at all, and an entire category of false negatives disappears in a few lines.

The lesson generalises: before you reach for fuzzy matching, check whether the thing you are comparing simply has more than one canonical spelling. Most of the apparent fuzziness in Kenyan payment data is formatting, not ambiguity.

The cascade, and the point where it stops guessing

CashTrace runs the same idea as an explicit three-tier cascade, documented in its architecture repo:

  1. Exact match: payment reference equals invoice number. Highest confidence, applied automatically.
  2. Fuzzy match: normalised counterparty name or phone, combined with amount. Medium confidence.
  3. Amount-window: a unique open invoice matching the amount inside a date window. Suggested only, and it needs a one-click confirm.

The third tier is the one that matters, because it is where most systems quietly go wrong. An amount-window match is a good guess. It is not knowledge. If two tenants owe KES 65,000 and one of them pays, an amount match tells you a payment happened, not who paid it. Applying that automatically produces a ledger that looks reconciled and is not, which is worse than an obviously unreconciled one; the error is now invisible and it compounds every month.

So anything the engine cannot resolve with confidence is surfaced as a ranked suggestion a person confirms, rather than being silently applied.

Unmatched is a first-class state, not an error

In Rentisha this is a table of its own. When a C2B callback cannot be resolved to an open invoice, the raw payload is queued and an accountant maps it from a UI. It is not dropped, not retried into oblivion, and not force-fitted to the nearest invoice.

There is a second, distinct state that took us a while to separate properly: money that has definitely arrived from a known resident but is not yet tied to a specific invoice. Cash collected at the gate. A bank transfer with no bill reference. That is not an unmatched callback; the payment record already exists and is complete, it simply is not yet tied to an invoice. Conflating the two produces a queue where some rows are questions about identity and others are questions about allocation, and staff cannot tell which is which.

Keeping them apart made both queues legible. One asks "who is this?", the other asks "which invoice?".

Allocation is where the float bugs live

Once you know the invoice, applying the money is arithmetic, which is exactly why it is worth isolating from the database. Allocation is a pure function: what arrived, what was owed and what had already been paid go in; what was applied, what was left over and the invoice's resulting status come out. It rounds to cents at every step rather than once at the end, and it is unit-tested without a database anywhere near it.

Overpayment is not an error condition. It becomes a credit on the resident's wallet, which the ordinary apply-to-invoice path can then spread across whatever else is open. That gives you split payments without a separate junction table, which is the kind of trade you make when you would rather have one well-understood mechanism than two half-used ones.

What we would tell someone building this

  • Fix the reference upstream before you improve the matcher. A unique per-invoice account number is worth more than any heuristic.
  • Separate formatting differences from genuine ambiguity. Most of the latter turns out to be the former.
  • Give unmatched money a real state with a real queue and a real owner.
  • Never let a confidence tier below "certain" write to the ledger unattended. A ledger that is wrong but looks right is the expensive failure.
Keep reading