Skip to content

POS local-first (desktop)

Phased rollout to reduce checkout latency for card payments and improve receipt behavior on Lion POS desktop.

KeyPhaseDefault (Electron)
vendor_pos_local_ecr1 — local EcrHubon
vendor_pos_local_db2 — SQLiteon
vendor_pos_local_place_order3 — order outboxon (no manual setup)

Set to 0 / false to fall back to server bridge relay + polling.

  1. POST /vendor/point-of-sale/orders with terminal_payment_context.client_ecr_dispatch: true creates the order but does not enqueue ecr_relay_request on the server bridge.
  2. Renderer calls window.electron.ecrDispatch → main process EcrHub → PAX TCP or CodePay WS.
  3. Renderer posts POST .../orders/:id/terminal-client-capture with the EcrHubResult JSON.
  4. Server applies the same webhook path as bridge completion (handleTerminalPaymentWebhook).

Database file: {userData}/pos-local/pos.sqlite (sql.js embedded SQLite in Electron main — no native addon)

  • catalog_cache — POS catalog snapshots plus lane/register list, device settings, store configuration, and current open work shift (keyed by store + cache key).
  • orders_outbox — place-order payloads for retry / audit; settings rows queue open work shift while offline.

IPC: posLocalDb:* via preload (see apps/vendor-web/src/lib/electron.ts).

Cashiers with Settings access can open Point of sale → Settings → Local data (desktop only). The flow:

  1. WAL checkpoint and copy pos.sqlite (+ -wal / -shm when present) to {userData}/pos-local/backups/{appVersion}-{timestamp}/ with a manifest.json.
  2. Append a JSON line to {userData}/pos-local/operations.jsonl (action: reset_local_data, store id, backup version, row counts).
  3. Delete catalog_cache rows for the current store (always). Optionally delete orders_outbox rows for that store when the user chooses Also clear order outbox.
  4. Renderer invalidates TanStack Query, re-hydrates from SQLite, and pulls catalog from the server when online.

IPC channel: posLocalDb:resetLocalData → preload posLocalDbResetLocalData.

Before HTTP place-order, the payload is written to orders_outbox. On success the row is marked synced with server_order_id; on failure failed with last_error.

Dual-key strategy (system id + display id)

Section titled “Dual-key strategy (system id + display id)”
KeyWherePurpose
System idorders_outbox.id (UUID) + client_order_id on HTTP pushIdempotent sync, foreign keys, terminal numeric refs
Display idorders_outbox.order_code + local_order_code in payload metadataHuman-readable receipt / orders list while offline

Offline display codes use {store_prefix}-{register_prefix}-{YYMMDD}-{daily_seq} (e.g. PBH-Q1-260526-0001). Prefixes come from cached store document rules (sales) and POS register code. The daily counter is stored per store + lane in catalog_cache (sync:order_display_counter:{yymmdd}:{register}) inside a SQLite transaction; allocation also seeds from existing orders_outbox.order_code rows and skips any code already taken locally.

On sync, the server still allocates its own orders.entity_key via allocateStoreDocumentNumber (authoritative for finance). The local display code remains on the outbox row for audit until server_order_id is set.

ColumnRole
idSystem UUID (primary key)
order_codeDenormalized display id for list/search
total_amountDenormalized cart total
payload_jsonFull place-order JSON (raw_payload — server normalizes on push)
needs_sync / statusPush queue state

Offline write = one INSERT with flat index fields + serialized cart JSON. No local auto-increment order ids.

FieldMeaning
idLocal outbox UUID — generated on device before any server response
order_codeHuman display id — allocated locally before sync
server_order_idServer orders.id — set when sync succeeds
statuspendingsynced or failed
needs_sync1 while the row must be pushed; cleared (0) after a successful sync. Set again when the payload is updated before sync
created_at / updated_atRow lifecycle timestamps (ISO-8601)
synced_atWhen the server accepted the order (null while pending/failed)
place_apiWhich HTTP endpoint will retry the row: pos_checkout, vendor_retail, or vendor_wholesale

Store-level sync metadata in catalog_cache key sync:meta includes pulledAt (last catalog pull), ordersPushedAt (last outbox push attempt), and resourceVersions (per-slice server updated_at stamps from catalog-sync-versions).

IPC posLocalDb:updateOutboxPayload replaces the JSON payload on pending/failed rows and sets needs_sync = 1 (renderer: maybeUpdatePosPlaceOrderOutboxPayload).

Push worker lists rows with needs_sync = 1 (oldest first), not only status = 'pending'.

The renderer shows checkout success immediately from the cart snapshot; background finalize syncs receipt metadata without blocking the success screen.

Place-order payloads may include __pos_local_list_meta (stripped before HTTP retry). It stores display fields for the POS orders table while a row is still pending or failed:

  • Customer name / email / id from checkout selection
  • Salesperson (logged-in cashier)
  • payment_status: paid for cash, card, wallet, transfer, etc.; unpaid for draft / debt
  • Line count and note

The orders page merges outbox rows with the server list so payment shows Paid when money was collected locally, even if sync is still pending or failed. Sync status stays separate (pending / synced / failed).

Why a sale might not appear on Orders

SituationWhat you should see
Just checked out (cash/wallet, online)Outbox row at top until push finishes, then server row after vendor-orders refetch
Offline / push pendingOutbox row with Not synced (needs vendor_pos_local_db + same store in header)
Date filterOutbox created_at is matched on local calendar day (same as the date picker), not UTC YYYY-MM-DD slice
Status tab Draft / CancelledOnly submit_as: draft or server tabs apply; normal checkout uses All or Completed
Row already syncedOutbox hidden (needs_sync = 0); order must appear from GET /vendor/orders

After checkout, the sale screen invalidates pos-local-outbox-orders and, when online, runs pushPendingPosLocalOutbox then invalidates vendor-orders so the server list updates.

Delete local-only orders (POS orders list)

On the POS Order list, long-press a row that is still not synced (needs_sync = 1, no server_order_id) to open a confirm dialog. Confirming runs posLocalDb:deleteOutbox, which removes the SQLite outbox row only when it has not synced. Use this to discard duplicate or mistaken local checkouts without affecting server orders. Legacy browser localStorage offline rows (when local DB is off) are removed the same way from device storage.

Minimal outbox row (SQLite orders_outbox)

FieldExampleRole
idUUIDclient_order_id on HTTP push (idempotency)
store_idstore pkMust match POS store selector
order_codePBH-Q1-260526-0001Display id in list / receipt while offline
total_amount150000Denormalized total for list
payload_jsonsee belowFull PlacePosOrderPayload + desktop-only keys
place_apipos_checkoutRetry target: POST /vendor/point-of-sale/orders
needs_sync1Listed on Orders until push succeeds

payload_json (place order) — required for server sync

Stripped before HTTP (peelOutboxPlacePayloadForServer): __outbox_place_api, __pos_local_list_meta, local_order_code.

Sent to server on push (added in pos-local-sync-push.ts):

  • client_order_id = outbox id
  • sync_source = offline_push
  • client_device_id = lane device when known

Core body (same as online checkout): store_id, paid_amount, payment_method, cart[], tax, discount, order_type (pos for retail sale workspace), submit_as, optional customer_id, vendor_pos_register_id, terminal_payment_context, invoice, etc. See PlacePosOrderPayload in apps/vendor-web/src/services/pos.api.ts.

Desktop-only __pos_local_list_meta (for Orders table before sync): customer_name, salesperson_name, payment_status (paid | unpaid), line_count, local_order_code.

Push processes one outbox row at a time (oldest first), not a single batch payload.

After each sync cycle the desktop reports state to the API; place-order retries send the outbox UUID for idempotency.

Server artifactPurpose
orders.pos_client_order_idSame compact key as outbox client_order_id (≤16 chars, derived from orders_outbox.id); unique per store_id
orders.pos_client_device_idLane / store_devices.id from the client
orders.pos_sync_sourceonline or offline_push
orders.pos_client_synced_atWhen the server accepted an offline push
pos_device_sync_statesPer store_id + device_key: catalog/orders/customers pull & push timestamps, pending/failed outbox counts

API:

  • POST /vendor/point-of-sale/sync-state — upsert device sync row (renderer: reportPosLocalSyncToServer)
  • GET /vendor/point-of-sale/sync-state?device_key= — read last report
  • Place-order body fields: client_order_id, client_device_id, sync_source — duplicate push returns existing order_id (idempotent: true)

Run migration 1783700000000-PosLocalSyncServerTracking before deploying backend + desktop.

  • Store receipt mode 3 (both) prints two HTML jobs (merchant + customer) per enabled receipt printer.
  • Device receipt mode 0 means inherit store default (fixes merchant-only when store is set to both).
  • After silent HTML print, desktop sends ESC/POS partial cut via lp -o raw (same path as cash drawer pulse).
  • When two jobs target the same printer, a short delay separates merchant and customer copies.

Local ECR + outbox are wired on:

  • vendor-pos-sale-workspace (main POS sale)
  • app/dashboard/pos/page.tsx (multi-tab POS)
  • features/orders/create-order-page.tsx (retail/wholesale create order; local ECR only, no outbox)

Pending outbox rows sync on a 60 second background loop in the POS shell (PosLocalSyncWorker in point-of-sale-app-shell.tsx):

  1. Push — submit pending orders_outbox rows (place_order, return) to the server.
  2. Pull — refresh local SQLite catalog_cache for catalog + settings. Before downloading, the client calls GET /vendor/point-of-sale/catalog-sync-versions?store_id= and compares each slice’s server updated_at with sync:meta.resourceVersions in SQLite; unchanged slices are skipped. Store configuration (catalog:store_settings), devices (settings:devices), registers, items, customers, categories, units (pos-custom-item scope), customer-display banners (catalog:customer_display_banners, SECONDSCREEN title filter), warehouses, and retail price lists participate; promotions and work-shift snapshots still refresh every cycle.

On POS shell mount, PosLocalSettingsProvider hydrates TanStack Query from SQLite before page queries run so receipt prefs, devices, registers, and open shift match the last sync even when offline.

Lane / shift offline behavior

  • Saved checkout lane (vendor-pos-lane zustand per store) is used as soon as register rows exist in cache — lane auto-select no longer waits for a successful network refetch.
  • Employee work-shift gate reads cached current shift when offline; opening a shift offline writes a local shift snapshot + settings outbox row, then syncs on the next push cycle.

Local-first service wrappers live in apps/vendor-web/src/services/pos-local-data.api.ts (fetch*LocalFirst). Pages should call these instead of raw HTTP helpers when reading POS data on Electron.

“Online” means API reachable

POS does not use navigator.onLine. The renderer probes GET /api/v1/health on an interval (usePosServerOnline). Slow or failing business APIs still count as offline after consecutive probe failures, while cached SQLite data remains visible.

Catalog list pages (items, customers, barcode) read SQLite first, paint immediately, then refresh from the server when probes succeed.

When local DB is enabled, list/table load error banners are suppressed for catalog and order lists that already have SQLite or outbox data (resolvePosLocalCatalogListError / resolvePosServerOrdersListError in app/point-of-sale/lib/pos-local-first-list-error.ts). The sale screen hides product/customer search dropdown errors under the same rule (shouldShowPosLocalCatalogFetchError).

POS work shift, hardware settings (terminal / printer / cash drawer), and related flows use the same probe for banners and load devices, lanes, and current open shift from SQLite when the API is down. Shift history still requires the server; open/close shift offline uses the existing outbox path.

Hardware settings (POS → terminal, printer, cash drawer)

On the point-of-sale hardware pages (settings-hardware-terminal-page, settings-hardware-printer-page, settings-hardware-cash-drawer-page), saving a device while local DB is enabled:

  1. Merges the row into TanStack Query and SQLite catalog_cache key settings:devices immediately.
  2. When the API probe is down, shows a local-only saved toast and does not require POS bridge activation before checkout or printing.
  3. On Electron:
    • Terminals: card payments use ecrDispatch with LAN / WebSocket fields from cached settings:devices (resolveClientPaymentTerminalRouting / resolveVendorPaymentTerminalForLocalEcr when the in-memory row is incomplete offline).
    • Receipt printers: printHtml uses the saved printer name (USB) or LAN row (TCP); bridge pairing is optional.
    • Cash drawers: kick-out uses the linked receipt printer via the same local print path.

Place-order / outbox payloads include optional terminal_payment_context LAN fields (terminal_manufacturer, terminal_host, terminal_port, terminal_ws_url, terminal_app_id, local_device_key, …) so the server can persist routing on orders.terminal_payment_meta when the outbox syncs after reconnect. Printer and drawer rows are read from the same cached settings:devices snapshot at checkout time.

Electron checkout (cash/debt/wallet/check) writes to the outbox first and completes the success UI immediately.

Card while the API probe is down (desktop + local ECR + lane terminal configured):

  1. The order is written to orders_outbox (local id).
  2. The renderer runs ecrDispatch → pinpad SALE using that local id.
  3. The ECR result is stored under catalog_cache (pending_terminal_capture:{outboxId}) until sync.
  4. When the server accepts the outbox row, the renderer posts terminal-client-capture with the real server_order_id, then marks the row synced.

Terminal payment reference (digits only): card flows allocate a numeric merchant reference (Unix ms + 6 random digits) via @indochina/shared generatePosTerminalNumericReference / resolvePosTerminalPaymentReference. UUID outbox ids are not sent to pinpads as order refs; local ECR maps them to the numeric payment reference (resolvePosTerminalEcrOrderId). The pending-payment UI and customer display show the local display code (e.g. PBH-Q1-260526-0001) when allocated, otherwise the numeric ref — not the raw outbox UUID. Online place-order sets the same style ref on orders.transaction_reference and returns it in payment.transaction_ref for bridge / ECR dispatch.

Without local ECR or a configured terminal, card checkout still shows pos.cardPaymentRequiresOnline when offline. When online, card uses the normal HTTP place-order path and local ECR capture with the server order_id.

Cashiers with order / pos module access (not only settings) can load lane devices (terminals, receipt printers, cash drawer prefs) via GET /vendor/settings/devices — see canLoadVendorDevicesForPosCheckout in vendor-module-access.ts. Without a configured card terminal on the lane, checkout shows pos.cardPaymentNoTerminalConfigured.

The POS Items page (point-of-sale/items-page.tsx) loads the active catalog from grouped SQLite (itemsGrouped) and refreshes via fetchPosItemsGroupedLocalFirst when online. Vendor Desktop shows last sync time and a Sync catalog button (PosLocalSyncControl) that runs pullPosLocalCatalogFromServer.

The POS Orders page (point-of-sale/orders-page.tsx) shows a Sync status column on Vendor Desktop: server rows display Synced; pending/failed outbox rows appear at the top of page 1 with Not synced / Sync failed badges and a per-row Sync action that retries the outbox push. The toolbar adds Sync all (N) / Sync to server, which runs runPosLocalSyncCycle (push outbox, then pull catalog) and reports how many rows were attempted vs still pending.

Receipt print / preview before server sync

Receipt HTML is rendered from a single shared module (packages/shared/src/lib/order-receipt-html.renderer.ts), re-exported by the backend and used in vendor-web for offline paths.

On Vendor Desktop, tapping an outbox row (or Print receipt on that row) builds PosOrderReceiptData from the stored orders_outbox.payload_json (buildPosOrderReceiptDataFromOutboxPayload in apps/vendor-web/src/lib/pos-outbox-receipt-data.ts), renders HTML locally (renderPosOrderReceiptHtmlLocal), and opens the receipt preview modal with prefetched iframes — no GET …/receipt-html until the order exists on the server. Silent print uses the same HTML via attemptPrintReceiptInvoice when localReceiptData is set (Electron printHtml).

The printed Invoice line and barcode use the server EAN-13 (invoice_barcode, derived from orders.id + store_id when synced) — not the offline client submit id or #LOCAL-… placeholder. The human order code (PBH-… / local_order_code) stays on the sale reference line only.

Offline cancel/return actions on Electron enqueue a return outbox row and show a “will sync” toast.

  • Web flags: apps/vendor-web/src/lib/vendor-pos-local-first.ts
  • Global settings bootstrap: apps/vendor-web/src/components/point-of-sale/pos-local-settings-provider.tsx
  • Local-first services: apps/vendor-web/src/services/pos-local-data.api.ts
  • Sync worker: apps/vendor-web/src/hooks/use-pos-local-sync-worker.ts
  • Push/pull cycle: apps/vendor-web/src/lib/pos-local-sync-cycle.ts
  • Desktop ECR: apps/vendor-desktop/src/ecr-local-hub.ts
  • Backend capture: PosService.applyTerminalPaymentFromVendorClientCapture
  • Outbox push: apps/vendor-web/src/lib/pos-local-sync-push.ts
  • Display codes: packages/shared/src/lib/pos-local-order-display-code.ts (web via Vite), apps/vendor-desktop/src/pos-local-order-display-code.ts (Electron main — mirrored copy, not @indochina/shared import), apps/vendor-web/src/lib/pos-local-order-display-code.ts
  • Receipt HTML (shared): packages/shared/src/lib/order-receipt-html.renderer.ts, snapshot bridge pos-receipt-html-snapshot.ts; web offline: pos-outbox-receipt-data.ts, pos-receipt-html-local.ts, pos-offline-receipt-context.ts
  • Desktop allocation: apps/vendor-desktop/src/pos-local-db.ts (allocateLocalOrderDisplayCode)