A payment desk cannot stop because the internet did. That was the starting point for Chams Pay, the deployed bilingual payment system I built for a Moroccan school with about 500 students. A small admin team uses Android phones and Windows laptops to handle registration fees, tuition, partial payments, advance payments, discounts, waivers, cheques, transfers, receipts, reversals, and daily reporting.

The hard part was not adding an offline badge. I had to decide what success means when a device loses its connection between recording money and printing proof. I solved that problem by making the local payment commit authoritative for the operator, keeping financial history immutable, synchronizing exact signed records, and giving recovery its own visible workflow.

I mapped the desk before I chose the storage

I wrote the real sequences first. A parent pays cash for part of one month. Another pays several months in advance by transfer. A staff member records a cheque that has not cleared. A device loses its connection after the operator confirms the amount. The team closes the day and needs totals it can trace back to individual records.

Those sequences gave me the product rules. A payment and its allocations are different records. One payment can cover registration and several tuition periods. Cash settles now. A cheque or transfer starts as pending. A correction must preserve the original event. The Arabic and French documents must describe the same financial state.

This work stopped the database from dictating the product. I had a list of invariants before I had tables or screens. Every later architecture choice had to protect them.

I made local completion a strict transaction

The most dangerous failure sits between the button press and the durable record. If the interface says success before the device finishes its write, a crash can erase a payment the operator already acknowledged. I prepare the payment, allocations, document snapshot, audit event, signature, and encrypted sync envelope before I open the local transaction.

I then write the counter, payment, allocations, document, envelope, queue item, and audit entry in one strict IndexedDB transaction. The interface waits for the transaction to complete before it shows success. If any part fails, the whole local commit fails. The operator never has to guess which half survived.

I also keep student data encrypted at rest. Chams Pay stores ciphertext in IndexedDB and builds the searchable index in memory only after the user unlocks the app. A timed lock clears that readable state. This design keeps offline search useful without leaving the full student list exposed in browser storage.

You own your data, in spite of the cloud.

Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan

I separated proof of payment from proof of settlement

The document has to tell the truth at the exact moment staff issue it. After a durable local cash payment, Chams Pay can issue an official receipt because the school has received the money and the device has preserved the record. A cheque or transfer receives a pending acknowledgement. It does not claim that the funds settled.

Both languages render from one immutable issuance snapshot. The operator cannot print French from one state and Arabic from a later state. The snapshot carries the values, status, identifiers, and labels needed to reproduce the document after the network returns or the device restarts.

This distinction solved a product problem that a generic receipt component would hide. The words on the page now follow the payment method and state. Staff get useful proof without making a stronger financial claim than the record supports.

I made retries boring and conflicts visible

Networks fail in awkward places. The server can accept a payment while the response disappears. If the device creates a new request on retry, it can create a duplicate. Chams Pay retries the same signed bytes with the same identity. The server returns the same result for the same record.

If a device reuses an identity with different bytes, the server rejects the intake and keeps the evidence. If a record fails validation, the system quarantines it without writing partial ledger rows. One bad queue item does not stop unrelated payments from synchronizing.

I avoid last write wins for financial events. Payments, reversals, and adjustments stay append only. When two devices produce a state that needs attention, the product creates a specific reconciliation task. Staff can see what happened and choose the correction instead of inheriting a silent overwrite.

  • Commit every related local record together.
  • Retry the exact same signed payment envelope.
  • Keep rejected evidence without changing the ledger.
  • Append reversals and adjustments instead of overwriting history.

I made offline readiness visible

A browser saying online does not prove that a device can safely accept money. I check the conditions Chams Pay needs: an initial sync, persistent storage, enough space, strict durability, healthy recovery state, an authenticated operator, a valid local counter, and a signed offline lease.

The lease lets an approved device capture payments for seven days without contacting the server. The interface warns staff as the limit approaches. When the lease expires, Chams Pay blocks new payment capture. It still preserves local history, documents, pending items, and delivery work. The failure stays narrow.

The screen shows facts the operator can act on. It shows the last successful sync, pending work, recovery state, lease state, and whether capture is available. This is more useful than a decorative connection icon.

I designed recovery before I trusted sync

Recovery cannot erase the evidence it is supposed to protect. Chams Pay rebuilds local state from an ordered server journal with a monotonic cursor. It never starts a full replacement while the device still holds unsynchronized financial rows. If recovery detects a gap or corrupt data, it blocks new capture and preserves the queue and documents for inspection.

I tested sequences that reflect the desk. I cut the network after the local commit, retried after a lost response, submitted a conflicting identity, let the offline lease age, restored state, rendered both document languages, and checked the same flows on Android and Windows. These tests found gaps that a single offline toggle never reveals.

That is the method I now use for offline products. Define the local promise. Make it durable. Preserve identity across retries. Give each failure a visible state. Then test the sequence around the outage, not only the outage itself.

Offline first means keeping a precise promise

Chams Pay works offline because it treats the device as part of the financial system, not as a temporary screen. The device can make a durable local commitment, issue the right document, preserve a pending queue, and reconcile the same signed event when connectivity returns.

If you build an offline product, start with the promise your user makes to another person. Decide what evidence must survive that moment. Build one atomic local operation around it, then design sync and recovery to preserve it. For the wider delivery method, read How I Turn AI Prototypes Into Working Products.