Choose a sync state machine
Use states such as queued, syncing, accepted, rejected, and needs review. Show each state to the customer. Give every action one stable ID so a reconnect retry cannot create a second transfer. At sync time, recheck account lock, balance, limits, and approval. If the action is now invalid, do not silently rewrite its amount or recipient. Keep the original queued intent and the final decision in the audit trail.
Separate a saved intent from a payment
A transfer entered offline is a saved request, not a completed payment. Show that state in the app. When the phone reconnects, send the original intent with its stable action ID. The server then checks the current account state and either accepts it once or returns a clear rejection. Never let the phone mark the transfer paid before the server commits it. If the reply is lost, retry with the same ID and read the server's first result.
Run a two-phone test with a fake balance. Queue a transfer on phone A, lock the account on phone B, and reconnect A. Keep the queued record, sync requests, server decisions, and ledger rows. Then unlock the account and send the same queued item again. It must not turn into a new payment without a fresh customer action. Repeat with two queued transfers sent in reverse order; each needs its own final state and proof.
Test cases and proof
| Case | Expected result | Proof to keep |
|---|---|---|
| Queue syncs twice | One business action | Ledger records |
| Account locked before reconnect | Reject or hold pending action | Sync result |
| Queued amount edited locally | Detect tampering | Server decision |
Do not guess the final result after a timeout
A phone may send an action, lose the response, and reconnect. It cannot know whether the server committed the transfer from the timeout alone. Query by stable action ID or retry the exact action under an idempotency rule. A new ID may post a second transfer. A changed amount under the old ID should be rejected. The ledger is the final proof; the app should move from syncing to accepted only after it receives a confirmed server state. A rejected item needs a clear customer recovery step.
Prove that replay cannot double-send
Queue a fake 1,000 NGN transfer while offline with local ID Q1 and idempotency key K1. Reconnect, cut the network after the server accepts the request, then reopen the app. The retry must use K1 and settle to the same provider reference. Cancel Q1 before reconnect and confirm it never sends. Also change the beneficiary while Q1 waits and define whether the queued item is blocked or reconfirmed. Save queue state transitions, key, request ID, provider result, and ledger entry. A “sent” flag saved only after the response leaves a crash window. (Stripe idempotency guidance).
Make cancel and submit share one decision
Queue synthetic action Q1 but do not send it. Cancel it, restart the app, and reconnect: no request may leave the queue. In a second run, cancel just after the request reaches the server. A local canceled label cannot prove that money did not move. Query Q1 and show the confirmed result; a completed transfer needs a separate reversal or refund path if policy permits one. Editing an unsent draft is not automatically tampering. Freeze its fields at approval or create a fresh approved intent after an edit. A server cannot compare offline fields with a trusted original record that was never stored.