</>CodeWithKarani

M-Pesa Reconciliation That Scales: From Paybill Statement to Trial Balance

Karani GeoffreyKarani Geoffrey6 min read

Month end. Finance sends you a spreadsheet. "The M-Pesa statement says we received KES 4,182,300 but the system says KES 4,176,850. Please investigate." You spend two days in a CSV, find eleven mismatched rows, fix three by hand, and shrug at the rest.

This happens because of one architectural mistake: you built a payments table, not a ledger. A payments table can tell you what you think happened. Only a double-entry ledger plus a three-way match can prove it.

Here is the reconciliation design I now use on every Kenyan project.

The three sources that must agree

SourceWhat it provesHow you get it
Safaricom statementMoney actually moved in the floatDaily export from M-Pesa Business Hub (Excel/CSV/PDF)
Callback eventsYour system was told about itYour payment_events table
Ledger entriesYour books reflect itYour ledger_entries table

A break in statement-versus-events means a lost callback. A break in events-versus-ledger means a bug in your settlement worker. Those are entirely different incidents and you want your report to say which one it is.

Normalising the statement

The Business Hub export gives you columns like Receipt No., Completion Time, Initiation Time, Details, Transaction Status, Paid In, Withdrawn, Balance, Reason Type, Other Party Info and A/C No.. It is human-formatted, so the amounts carry thousands separators and the dates are locale-flavoured.

import pandas as pd
from decimal import Decimal

def load_statement(path: str) -> pd.DataFrame:
    df = pd.read_csv(path, dtype=str).fillna("")
    df.columns = [c.strip().lower().replace(" ", "_").replace(".", "") for c in df.columns]

    def money(col):
        cleaned = df[col].str.replace(",", "", regex=False).str.strip()
        cleaned = cleaned.replace("", "0")
        # Withdrawals arrive negative already; keep the sign.
        return cleaned.map(lambda v: int(Decimal(v) * 100))

    out = pd.DataFrame({
        "receipt":    df["receipt_no"].str.strip().str.upper(),
        "completed":  pd.to_datetime(df["completion_time"], dayfirst=True, errors="coerce")
                        .dt.tz_localize("Africa/Nairobi"),
        "paid_in":    money("paid_in"),
        "withdrawn":  money("withdrawn"),
        "balance":    money("balance"),
        "status":     df["transaction_status"].str.strip().str.upper(),
        "reason":     df.get("reason_type", "").str.strip(),
        "account":    df.get("a/c_no", df.get("ac_no", "")).str.strip(),
        "party":      df.get("other_party_info", "").str.strip(),
    })
    out["net_minor"] = out["paid_in"] + out["withdrawn"]
    return out[out["status"] == "COMPLETED"]

Two rules I enforce in code review. Amounts are integers of cents, always. Timestamps are timezone-aware in Africa/Nairobi, always. Break either and your daily boundaries will drift and you will chase phantom breaks at 21:00 and 03:00 UTC.

The gap detector nobody uses: the Balance column

Every statement row carries the running float balance. That means the statement is self-verifying. Sort by completion time and assert that each row's balance equals the previous balance plus the net movement. If it does not, a transaction is missing from your extract - usually because the export window clipped it.

def find_statement_gaps(df):
    df = df.sort_values("completed").reset_index(drop=True)
    expected = df["balance"].shift(1) + df["net_minor"]
    bad = df[(expected.notna()) & (expected != df["balance"])]
    return bad[["receipt", "completed", "net_minor", "balance"]]

The same trick works live: the C2B confirmation callback contains OrgAccountBalance. Store it on every event row. If today's first callback balance does not equal yesterday's last callback balance plus everything in between, you missed a callback while you were asleep and you now know it before finance does.

Schema: a real ledger

CREATE TABLE ledger_entries (
    id            BIGSERIAL PRIMARY KEY,
    entry_group   UUID        NOT NULL,        -- one journal, many lines
    account       TEXT        NOT NULL,        -- 'mpesa_float', 'revenue', 'fees', 'customer_liability'
    direction     CHAR(2)     NOT NULL CHECK (direction IN ('DR','CR')),
    amount_minor  BIGINT      NOT NULL CHECK (amount_minor > 0),
    currency      CHAR(3)     NOT NULL DEFAULT 'KES',
    event_id      BIGINT      REFERENCES payment_events(id),
    booked_on     DATE        NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE statement_lines (
    id             BIGSERIAL PRIMARY KEY,
    shortcode      TEXT        NOT NULL,
    receipt        TEXT        NOT NULL,
    completed_at   TIMESTAMPTZ NOT NULL,
    net_minor      BIGINT      NOT NULL,
    balance_minor  BIGINT      NOT NULL,
    reason_type    TEXT,
    account_ref    TEXT,
    match_state    TEXT        NOT NULL DEFAULT 'UNMATCHED',
    event_id       BIGINT      REFERENCES payment_events(id),
    UNIQUE (shortcode, receipt)
);

Every journal must balance. Enforce it, do not hope for it:

SELECT entry_group,
       SUM(CASE WHEN direction = 'DR' THEN amount_minor ELSE -amount_minor END) AS imbalance
FROM ledger_entries
WHERE booked_on = CURRENT_DATE
GROUP BY entry_group
HAVING SUM(CASE WHEN direction = 'DR' THEN amount_minor ELSE -amount_minor END) <> 0;

That query returning zero rows is a hard gate in my nightly job. If it fails, the reconciliation does not run at all, because reconciling against broken books is worse than not reconciling.

Matching, in three passes

Pass 1 - exact receipt match. This clears 95 percent. MpesaReceiptNumber from the STK callback and TransID from C2B are the same identifier that appears in the statement's Receipt No. column.

UPDATE statement_lines sl
SET match_state = 'MATCHED', event_id = pe.id
FROM payment_events pe
WHERE sl.match_state = 'UNMATCHED'
  AND pe.provider = 'daraja'
  AND pe.provider_ref = sl.receipt
  AND pe.amount_minor = sl.net_minor;

Pass 2 - amount mismatch on the same receipt. Same receipt, different amount, means your settlement logic mis-parsed something. Flag as AMOUNT_MISMATCH, never auto-fix.

Pass 3 - fuzzy, for statement lines with no event at all. Match on amount plus a time window plus the last four digits of the payer number. Anything matched here is a lost callback, and it should raise a ticket even though it auto-resolves, because the underlying delivery problem is still there.

def fuzzy_match(unmatched_lines, orphan_intents, window_minutes=15):
    results = []
    for line in unmatched_lines:
        candidates = [
            i for i in orphan_intents
            if i["amount_minor"] == line["net_minor"]
            and abs((i["created_at"] - line["completed_at"]).total_seconds()) <= window_minutes * 60
            and i["msisdn"][-4:] == line["party"][-4:]
        ]
        if len(candidates) == 1:
            results.append(("LOST_CALLBACK", line, candidates[0]))
        elif len(candidates) > 1:
            results.append(("AMBIGUOUS", line, candidates))
        else:
            results.append(("UNIDENTIFIED_RECEIPT", line, None))
    return results

The break categories you must name

Generic "mismatch" is useless. Give every break a name and an owner:

CategoryCauseAction
LOST_CALLBACKMoney in the statement, no eventAuto-settle from statement, ticket the delivery failure
UNIDENTIFIED_RECEIPTCustomer paid with a wrong or blank account referenceSuspense account, customer support queue
AMOUNT_MISMATCHPartial or over paymentBook the actual, raise a balance or credit
ORPHAN_INTENTYour system thinks it was paid, statement disagreesUrgent. Possible forged callback or a bug. Reverse.
CHARGESafaricom transaction charge withdrawalBook to a fees expense account, not to revenue
REVERSALReason Type indicates a reversalContra-entry against the original journal
FLOAT_MOVEMENTUtility-to-working transfer, bank settlementBalance sheet movement only, never P&L

That fees row deserves emphasis. Paybill charges appear on the statement as separate withdrawal lines. If you book gross receipts as revenue and ignore the charge lines, your statement will never tie and your gross margin will be quietly overstated all year.

Run it daily, not monthly

The single highest-leverage change most Kenyan SMEs can make is moving from a monthly reconciliation to a daily one. A break found today has context: you remember the deploy, the outage, the customer. A break found in thirty days is archaeology.

def daily_close(day):
    assert no_unbalanced_journals(day), "books do not balance, aborting"

    stmt = load_statement(fetch_statement(day))
    gaps = find_statement_gaps(stmt)
    upsert_statement_lines(stmt)

    exact_match()
    breaks = classify_breaks(day)

    opening = closing_balance(day - timedelta(days=1))
    movement = sum(l["net_minor"] for l in stmt.to_dict("records"))
    closing = stmt.iloc[-1]["balance"]

    report = {
        "date": str(day),
        "statement_gaps": len(gaps),
        "lines": len(stmt),
        "matched": count_by_state("MATCHED"),
        "breaks": {k: len(v) for k, v in breaks.items()},
        "opening_minor": opening,
        "movement_minor": movement,
        "closing_minor": closing,
        "ties": opening + movement == closing,
    }
    publish(report)
    if not report["ties"] or report["breaks"].get("ORPHAN_INTENT"):
        page_oncall(report)

Two habits that pay for themselves

First, make your account reference machine-readable. INV-88421 beats "John's order". Add a check character so a typo fails loudly rather than matching someone else's invoice. Customers do mistype, and a self-validating reference converts a mystery into a rejection you can act on.

Second, store the raw statement file forever, alongside a hash. When a dispute goes to Safaricom support eight months later, "here is the file we ingested, here is its SHA-256, here is the ledger journal it produced" ends the conversation in one email.

Reconciliation is not an accounting chore you bolt on at the end. It is the test suite for your money system. If it is not automated and not running daily, you do not actually know how much money you have.

#m-pesa#reconciliation#ledger#accounting#python
Keep reading

Related articles