Offline-first reads, tender-scoped offline sales & idempotency
Purpose: the rules for what the terminal does without network — reads always serve from cache; cash sales may complete offline and sync later; card sales require connectivity — plus the
clientIdidempotency contract that protects online retries, server replays, and offline-sale sync. Status: as of 2026-08-22 (supersedes the 2026-07-06 “no offline sales ever” policy — see Decision history).
The policy — by tender
Section titled “The policy — by tender”Offline behavior now depends on how the customer pays (PO decision, 2026-08-22 — binding):
| Tender | Offline? | Why |
|---|---|---|
| Cash | Allowed offline. The sale is captured and finalized on the device and synced when connectivity returns. | Cash needs no external authorization — the money is physically in the drawer, so the record can be reconciled later. |
| Card | Online required. The UI blocks a card payment while offline. | A card charge needs live authorization from the PSP (AngelPay) — it is impossible to authorize a card without the network. |
| Mixed (any card leg) | Online required. | If any portion is card, the card leg forces the whole sale online. |
Reads are always offline-first (catalog/product lookups, cached reports/history, notifications, theming) from a local store that is the source of truth for reads.
Open edges to confirm with the PO/eng before implementation (do not assume):
- Offline cash sales assume an already-open shift — shift open/close, cash movements and approvals are not covered by this decision and remain online for now. Confirm whether a cashier may also open/close a shift or record cash movements offline.
- Confirm the mixed-payment ruling above (any card leg ⇒ online) is the intended behavior. {/* TODO(manual): @area(terminales) @kind(flow) offline cash-sale queue: device-side capture + finalize for cash tender, drain+dedup on reconnect; card path stays online-blocked. Needs eng design ticket. */}
Read path (Android) — unchanged
Section titled “Read path (Android) — unchanged”- Reads serve from a local cache (Room/SQLite), hydrated from Appwrite
Databases.listDocuments. - While online, a Realtime subscription pushes document changes that refresh the cache.
- With the network disabled, reads continue from the cache unchanged.
This is exactly what shipped in mobile #125 (Room read cache + Realtime refresh) and it continues as-is.
Cash sales offline — the write path
Section titled “Cash sales offline — the write path”- When offline, a cash sale is captured and finalized locally and enqueued for sync with its
clientId(see below). The UI clearly indicates it is pending sync. - On reconnect, the queue drains to the server, which dedups on
clientIdso a sale is never double-recorded. - Card / mixed-card sales are blocked offline — a clear “Sin conexión — el cobro con tarjeta requiere conexión” state (banner + disabled card CTA + reintentar). Cash remains available.
- Server-side resiliency still applies for online sales. The card charge and its settlement side-effects run as NestJS/DBOS durable workflows (see
nestjs-dbos); the client makes one online call and the server owns retries/compensation.
Implementation status: this is a decided policy, not yet built. The current code still blocks all offline sales (the 2026-07-06 design). Enabling offline cash sales is a mobile feature change tracked separately — until it ships, the terminal blocks every offline sale.
clientId idempotency
Section titled “clientId idempotency”clientIdis a client-generated UUID stamped on every terminal-originated write (notably sales/orders).- It is the idempotency key: the same
clientIdarriving twice produces one record, not two — whether the duplicate comes from an online timeout→retry, a DBOS workflow replay, or an offline cash-sale re-sync. - Sales tables (
sales_orders,transactions, etc.) carryclientIdfrom the start (unique index). - Idempotency depends on the server honoring
clientIddedup; clients must send it on every money-path call.
Conflict policy
Section titled “Conflict policy”| Data | Policy |
|---|---|
| Catalog reads (products/prices) | Last-write-wins; the cache is refreshed from server/Realtime. |
Sales (sales_orders, transactions, stock_movements) |
Append-only — new immutable records; concurrent online terminals, DBOS replays, and offline cash-sale sync all reconcile through clientId dedup. |
| Stock levels | Derived from the append-only stock_movements ledger; decrements applied server-side by the sale’s durable workflow (offline cash sales reconcile stock on sync). |
Durable / must-succeed flows
Section titled “Durable / must-succeed flows”Anything that must not be left half-done — multi-tender settlement, stock decrement on sale, PSP capture + retry, fiscal stamping — runs as a NestJS/DBOS durable workflow, not a best-effort client call. See nestjs-dbos and pos-domain-model.md.
Tenant scoping still applies offline
Section titled “Tenant scoping still applies offline”The local cache holds only the session’s tenant (and store) data — never cross-merchant rows. The cache is populated through the authenticated session, which can only read permitted documents. See auth-and-multitenancy.md.
Decision history
Section titled “Decision history”- 2026-08-22 (PO, binding): offline sales are re-enabled for cash tender only — cash sales may complete offline and sync idempotently on reconnect; card sales require connectivity (PSP authorization can’t happen offline). This supersedes the 2026-07-06 “no offline sales ever” policy for the cash path. The
clientIdidempotency contract now also protects offline cash-sale re-sync. Implementation (device-side cash-sale queue) is a pending mobile feature; until it ships, the terminal still blocks all offline sales. - 2026-07-06 (PO): operational writes went from “queue offline, sync later” to online-only — the server had to hold the sales record at the moment of sale (settlement, stock and PSP capture are server-owned via DBOS). Now partially superseded (cash) by the 2026-08-22 decision above; the online path for card and the DBOS ownership of server-side resiliency remain.
- ≤ 2026-06-19: an earlier design described a general offline sales queue for all tenders; superseded by the tender-scoped policy above.