Payments: quotes, ZamPay and one finalisation path
Money is where integrations are attacked first. In this lesson you follow a payment from the quote to the receipt, learn why there is exactly one way for an invoice to become paid, and practise the rules that stop double payments, wrong amounts and replays.
The path of a payment
quote (server) -> invoice intent -> ZamPay invoice -> citizen pays on ZamPay
-> callback / poll / sweep -> finaliser asks ZamPay -> paid once -> receipt, case advances, events
ZamPay (DotGovPay, reached through the GSB ZamConnect gateway) takes the money. The platform decides how much is owed, raises the invoice, and records the payment when ZamPay confirms it.
Step 1: the quote is the server's
The amount never comes from the browser. A service's fee comes from its fee configuration. A module that prices something itself answers the payment.quote extension point:
// a module's contribution (sketch): price a case from the platform's own data, never from the form
contributions['payment.quote'] = async ({ kind, case: c, service, params, transaction }) => {
if (!isMine(service)) return undefined; // not my business: core uses the service's fee
const unit = await loadUnitForCase(c, { transaction });
if (!unit || unit.rent == null) throw httpError(409, 'UNIT_NOT_PRICED');
return { amount: unit.rent, lines: [{ title: 'Monthly rent', amount: unit.rent }] };
};
Shops, pay-points and POS sales likewise take prices from the platform's product lists and tariffs.
Step 2: the invoice
The platform describes the invoice once (an invoice intent) and the ZamPay adapter turns it into ZamPay's request. Three protections live here:
- a unique external reference per invoice,
<case number>/<12 hex>, so a retried request cannot quietly create asecond invoice;
- idempotent creation: asking again for the same fee returns the open invoice of the same amount, as long as it has
at least a day left;
- one payable invoice per fee: an expired or changed invoice is cancelled at ZamPay before a new one is raised.
Step 3: checkout and the callback
The citizen pays on ZamPay's pages. ZamPay then calls POST /api/zampay/callback through ZamConnect. ZamPay does not sign callbacks, so the platform authenticates the caller (HTTP Basic or a Registration Authority JWT), records each callback in a replay table, and treats it only as a prompt: the finaliser asks ZamPay what really happened.
Callbacks get lost, so a sweep asks ZamPay about pending invoices every 10 minutes, and a nightly reconciliation compares records with ZamPay's completed list.
Step 4: one finaliser
Every path to "paid" goes through the payment finaliser: the citizen's poll, the POS poll, the callback, the sweep and a finance officer's manual check. It:
- checks the invoice belongs to the named case and that the caller may see the case;
- asks ZamPay and reads the receipt;
- compares the amount settled with the amount invoiced (short = not paid; over = flagged);
- in one transaction, with the payment and case rows locked, applies the side effects **only on the first transition
to paid**: receipt, module effects through
payment.onFinalised, case status; - after commit, writes one audit row and sends one receipt notice.
Call it twice and the second call just reports the state. Two concurrent polls cannot both apply side effects.
Manual validation needs a finance role and, by default, a second approver. Money collected twice (online and at the counter) becomes a collected_twice exception for a refund.
Pay-points, POS and receipts
- Pay-points are QR codes for small fixed charges. The portal's generic
/pay/<token>page uses/api/pay-points/{ref},/orderand/ticket. One browser session gets one ticket invoice, even when two orders race. The owning module prices it and is told when it is paid; it never marks anything paid. - POS terminals sell through
/api/pos; mobile money goes through ZamPay and the same finaliser. Cash takenoffline is synchronised later and reconciled by a supervisor.
- Receipts are checked at
GET /api/receipts/verify/{ref}?sig=...; anything but a genuine receipt of a paid casegets the same
404 RECEIPT_NOT_FOUND.
Across the federation
A service on a ministry node is paid to the node's own ZamPay merchant; the callback goes to the node, which finalises. The hub never holds the node's ZamPay credentials. The node tells the hub with signed payment.invoiced, payment.settled and payment.refunded events. "Pay" on the national portal asks the node for a signed 15-minute pay link bound to the citizen. If the node is down, the hub shows the last known state and says so; nothing is written.
Modules switched off
Switching a module off never loses money. payment.onFinalised is a bypass point: a payment that arrives for a module switched off since is still finalised by that module, and that run is audited.
Testing with the ZamPay stub
Never test against real ZamPay. The platform's test doubles include a contract-faithful ZamPay stub with a control API. In an integration test you can play the payer:
curl -s http://localhost:5199/__control/invoices # invoices the platform raised
curl -s -X POST "http://localhost:5199/__control/invoices/$INV/pay" \
-H "Content-Type: application/json" -d '{"outcome":"success","callback":true}'
curl -s -X POST "http://localhost:5199/__control/invoices/$INV/callback" # send the callback again: a replay
The abuse cases every money change must cover:
| Case | What the test proves |
|---|---|
| Double payment | A second poll or callback after "paid" changes nothing; money taken twice is flagged |
| Wrong amount | A short payment is not paid |
| Replay | A repeated callback is a duplicate, with no new side effect or event |
| Race | Two concurrent finalisations apply side effects once |
Exercise
In a development stack, raise an invoice for a sandbox case, pay it through the stub with callback: false, and wait for the sweep (or poll the status endpoint). Then send the callback twice. Count the receipts, audit rows and payment.confirmed events: there should be one of each.
Check your understanding
- Where does the amount of an invoice come from, and where must it never come from?
- Why does the platform ask ZamPay again after a callback?
- What does the finaliser do on the first transition to paid, and what does a second call do?
- How does the platform keep only one invoice payable for a fee?
- On a federated deployment, which merchant receives the money for a node's service?
- Name the four abuse cases a money change must be tested against.
Sign in to record your progress.
Previous: Signing people in: Pamodzi Pass and ZamPass Next: Going to production