Skip to content

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 clientId idempotency 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).

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. */}
  1. Reads serve from a local cache (Room/SQLite), hydrated from Appwrite Databases.listDocuments.
  2. While online, a Realtime subscription pushes document changes that refresh the cache.
  3. 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.

  1. 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.
  2. On reconnect, the queue drains to the server, which dedups on clientId so a sale is never double-recorded.
  3. 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.
  4. 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 is a client-generated UUID stamped on every terminal-originated write (notably sales/orders).
  • It is the idempotency key: the same clientId arriving 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.) carry clientId from the start (unique index).
  • Idempotency depends on the server honoring clientId dedup; clients must send it on every money-path call.
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).

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.

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.

  • 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 clientId idempotency 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.