Everything in part 4 worked because sales are facts the till can assert on its own. Payment is different, and no amount of architecture fixes it.
A KHQR payment cannot be verified without a connection. The customer's bank tells your bank, your bank tells your server, and your server tells the till. Break any link and the till simply does not know whether money moved.
This part is about being honest about that instead of engineering around it.
What KHQR actually is
Bakong KHQR is Cambodia's national QR standard, run by the National Bank of Cambodia. One QR code works across essentially every bank and wallet in the country — which is why it has become the default way to pay for anything.
The flow:
- Merchant generates a QR encoding amount and a merchant reference
- Customer scans with their banking app and confirms
- Their bank moves the money and notifies the acquiring bank
- The merchant is notified — webhook, or by polling a status endpoint
Step 4 is the problem. It is the only step that tells you the sale is paid, and it is the one that requires your server to be reachable.
The three offline payment cases
Cash
The easy one, and the reason offline-first is viable at all in this market.
Cash needs no third party. The sale is paid the moment it is written, exactly
as part 3 does it.
For a great many Cambodian shops this covers the overwhelming majority of transactions, which is precisely why an offline till is worth building.
KHQR — generate offline, verify later
A static merchant QR can be printed and stuck to the counter; it works with no connectivity at all. What you lose is the amount, the reference, and any automatic reconciliation — the cashier watches the customer's screen and takes their word for it.
A dynamic QR encodes the amount and a reference for this specific sale. You can generate it offline — it is a deterministic string built to the EMVCo standard, not something fetched from a server:
/**
* Builds a dynamic KHQR payload locally. No network involved — the format is a
* specification, not an API call, so a till can produce a correct code with the
* router unplugged.
*
* What it cannot do offline is find out whether anyone paid it. That is the
* whole subject of this article.
*/
export function buildKhqrPayload(input: {
merchantId: string;
merchantName: string;
merchantCity: string;
amountUsd: number;
reference: string;
}): string {
const field = (id: string, value: string) =>
id + String(value.length).padStart(2, "0") + value;
const body = [
field("00", "01"),
field("01", "12"), // 12 = dynamic (single use); 11 = static (reusable)
field("29", field("00", "kh.gov.nbc.bakong") + field("01", input.merchantId)),
field("52", "5999"),
field("53", "840"), // USD. 116 for KHR
field("54", input.amountUsd.toFixed(2)),
field("58", "KH"),
field("59", input.merchantName.slice(0, 25)),
field("60", input.merchantCity.slice(0, 15)),
field("62", field("01", input.reference)),
].join("");
const withCrcTag = body + "6304";
return withCrcTag + crc16(withCrcTag).toString(16).toUpperCase().padStart(4, "0");
}Use the sale id from part 1 as the reference. When connectivity returns you can match the bank's records to the exact sale without any extra bookkeeping.
Check the current NBC/Bakong specification before shipping this. Field requirements and merchant onboarding change, and a payload that is subtly wrong fails at the counter.
Cards
Do not attempt an offline card path. Offline card authorisation exists in aviation and transit, and it works because those operators absorb the fraud loss deliberately. A shop cannot.
Offline, cards are simply unavailable. Say so on screen.
The state machine
This is the part worth getting right:
┌──────────────────────┐
cash offline ───►│ paid │
└──────────────────────┘
┌──────────────────────┐
khqr offline ───►│ pending_verification │
└──────────┬───────────┘
│ sync + bank check
┌─────────────┴─────────────┐
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ paid │ │ payment_failed │
└────────────────┘ └────────┬─────────┘
│
alert a human/**
* Runs when a pending_verification sale arrives from a till.
*
* Note what this does NOT do: it never voids the sale. The goods left the shop.
* An unverified payment is a debt to chase, not a transaction to erase — and
* deleting it destroys the only record that the shop is owed money.
*/
export async function verifyPendingSale(saleId: string) {
const sale = await sales.findOneOrFail({ where: { id: saleId } });
if (sale.paymentState !== "pending_verification") return;
const result = await bakong.checkTransaction({
reference: sale.id,
amountCents: sale.totalCents,
});
if (result.status === "paid") {
await sales.update(saleId, {
paymentState: "paid",
verifiedAt: new Date(),
bankReference: result.bankReference,
});
return;
}
if (result.status === "not_found") {
// Could be a genuine non-payment, or the bank has not settled yet. Do not
// conclude anything for a grace period.
const ageMinutes = (Date.now() - sale.occurredAt.getTime()) / 60000;
if (ageMinutes < 30) return; // check again later
}
await sales.update(saleId, { paymentState: "payment_failed" });
await raiseAlert({
type: "unverified_payment",
saleId,
amountCents: sale.totalCents,
message:
`KHQR payment for sale ${saleId.slice(0, 8)} could not be verified. ` +
`Goods have left the shop. Check the bank statement and follow up.`,
});
}The grace period matters. Settlement is not instantaneous, and a system that declares fraud thirty seconds after a legitimate payment will produce so many false alerts that staff learn to ignore all of them — including the real ones.
What the cashier must be told
Design decisions with real money attached:
Offline, KHQR should be discouraged but not blocked. Show it clearly:
⚠️ Offline — payment cannot be confirmed Take cash if you can. If you accept KHQR, ask the customer to show the confirmation screen before they leave.
The receipt says so too, as in part 3. If it later fails, that printed line is the shop's only evidence it flagged the risk at the time.
Set a limit. Above some amount — say $20 — require cash while offline. A shop can absorb an unverified $3 sale. It cannot absorb an unverified $300 one.
/**
* Offline KHQR risk is proportional to value, so the rule is a threshold rather
* than a blanket ban. Blocking every offline card payment is correct because
* there is no path to verification at any price; KHQR at least leaves a bank
* record to reconcile against.
*/
const OFFLINE_KHQR_LIMIT_CENTS = 2_000;
export function allowedPaymentMethods(opts: {
online: boolean;
totalCents: number;
}): { method: PaymentMethod; enabled: boolean; note?: string }[] {
if (opts.online) {
return [
{ method: "cash", enabled: true },
{ method: "khqr", enabled: true },
{ method: "card", enabled: true },
];
}
return [
{ method: "cash", enabled: true },
{
method: "khqr",
enabled: opts.totalCents <= OFFLINE_KHQR_LIMIT_CENTS,
note:
opts.totalCents <= OFFLINE_KHQR_LIMIT_CENTS
? "Cannot be confirmed until the connection returns"
: "Too large to accept unverified — please take cash",
},
{ method: "card", enabled: false, note: "Needs a connection" },
];
}Two currencies in one drawer
Cambodian shops run USD and KHR side by side, usually with a shop rate rather than the official one, and change often comes back in the other currency.
Three rules that save a lot of pain:
- Store the currency actually tendered, and the rate used. Not just a
converted total.
paid_currency,paid_amount_cents,exchange_rate. - The rate is a property of the sale, not a global setting. Rates change; last month's receipts must not silently re-price.
- Round to the note. KHR has no coins in practice — the smallest note in circulation is 100 riel. A total of 4,150 KHR is not payable. Round at the point of tender and record the rounding as its own field so the books balance.
ALTER TABLE sales
ADD COLUMN paid_currency char(3) NOT NULL DEFAULT 'USD',
ADD COLUMN paid_amount_cents integer,
ADD COLUMN exchange_rate numeric(10,4),
ADD COLUMN rounding_cents integer NOT NULL DEFAULT 0;That rounding_cents column looks trivial. Without it, a month of riel sales
produces a discrepancy nobody can explain and everyone assumes is theft.
The end-of-day report that matters
One screen, and it is the reason to build all of this:
Sales today 142 $1,847.50
Cash 118 $1,402.00
KHQR verified 21 $398.50
KHQR pending 2 $34.00 ← chase
Payment failed 1 $13.00 ← investigate
Queued on tills 0
Oversells 1 (Product #4412, short by 1)
Suspect timestamps 0The bottom four lines are the ones staff should read first. Everything above is accounting; those are the things that need a person today.
What we built
Across five parts:
- A till that sells with no connection, because the sale is complete when it is written locally
- Stock derived from append-only movements, so sync order never matters
- Idempotency that costs nothing, because the client owns identity
- Oversells detected and escalated rather than hidden
- Payments that are honest about what can and cannot be verified
The recurring theme is worth naming: almost all of this is achieved by refusing to store mutable state. Facts, appended. Everything else is derived. The complexity people associate with offline sync mostly comes from trying to merge things that were never designed to be merged.
This series documents the approach behind SuiteWright POS, built for Cambodian retail — offline-first, KHQR, dual currency.
Building something with these constraints? Tell us about it — we will tell you honestly whether it needs a system this size.





Comments
Loading comments...