import { uuidv7 } from '@sims/domain' import type { BillTotals, Item } from '@sims/domain' import type { LineInput, TaxClassRow } from '@sims/billing-engine' import type { BillOverride, BootstrapStore, OfflineBillBody, PosUser } from './api' /** * S4 counter resilience — the browser POS survives the store-server dying mid-shift. * Two responsibilities, one IndexedDB database (`sims-pos-offline`): * * 1. `bills` — bills committed while the server was unreachable. Each is a client- * UUIDv7 fact with a provisional token (OFF-1, OFF-2…); the reconnect drain * POSTs them oldest-first and the server assigns the real GST doc no. * 2. `cache` — the catalog snapshot (items + tax classes + store/users) persisted * at login so a mid-shift refresh with the server down still reaches billing. * * Raw IndexedDB, no new dependency (R-parity with the zero-runtime-dep discipline). * The pure helpers below (token/record/body shaping) hold the serialization logic * and are unit-tested without a browser; everything that touches `indexedDB` stays * inside a function body so this module still imports in Node (vitest). * * M11 (offline PII): the queued bill persists only `customerId` — never the buyer's * name or GSTIN — and the server re-resolves buyer identity from that id at drain, so * no customer PII is written to a shared counter PC. The catalog `cache` (which also * holds the staff roster) is wiped on Lock/logout via `clearCatalogCache`. NOTE: this * removes the standing PII, not the queue itself — true at-rest ENCRYPTION of the * remaining offline queue is a follow-up (server-tier SQLCipher is finding H3). */ // ---------- queued bill record ---------- export interface QueuedBill { /** Client-generated bill id (uuidv7) — the server's idempotency key on drain. */ id: string /** Monotonic provisional sequence (1, 2, 3…) — drives OFF-n and drain order. */ seq: number /** Human token shown on the provisional receipt and in drain notices ("OFF-1"). */ provisionalToken: string storeId: string counterId: string /** Local provenance only — who rang it up on this device. The server attributes the * drained bill to the session that drains it, never to this field (SEC fix). */ cashierId: string shiftId: string /** The ONLY buyer identity persisted offline (M11): the server re-resolves the name, * GSTIN and place-of-supply from this id + tenant at drain, exactly as for an online * bill — so no customer PII (name/GSTIN) is ever written to this device. A walk-in * bill has no customerId and stores nothing extra. */ customerId?: string businessDate: string lines: LineInput[] payments: { mode: string; amountPaise: number }[] /** Full totals snapshot (immutable fact); `payablePaise` becomes clientPayablePaise on drain. */ totals: BillTotals overrides?: BillOverride[] createdAt: string } // ---------- pure helpers (unit-tested; no IndexedDB) ---------- /** The provisional token printed instead of a GST doc no while offline. */ export function nextProvisionalToken(seq: number): string { return `OFF-${seq}` } /** Build the immutable queued-bill record from the cart at commit time. */ export function buildQueuedBill(input: { seq: number storeId: string; counterId: string; cashierId: string; shiftId: string customerId?: string businessDate: string lines: LineInput[] payments: { mode: string; amountPaise: number }[] totals: BillTotals overrides?: BillOverride[] /** Injectable clock for deterministic tests; defaults to now. */ now?: number }): QueuedBill { const at = input.now ?? Date.now() return { id: uuidv7(at), seq: input.seq, provisionalToken: nextProvisionalToken(input.seq), storeId: input.storeId, counterId: input.counterId, cashierId: input.cashierId, shiftId: input.shiftId, ...(input.customerId !== undefined ? { customerId: input.customerId } : {}), businessDate: input.businessDate, lines: input.lines, payments: input.payments, totals: input.totals, ...(input.overrides !== undefined && input.overrides.length > 0 ? { overrides: input.overrides } : {}), createdAt: new Date(at).toISOString(), } } /** * Shape a queued bill into the /api/bills/offline drain payload. Drops display-only * fields AND the local cashierId — the server takes tenant + cashier from the live * session, never from the body (SEC fix). */ export function toOfflineBillBody(q: QueuedBill): OfflineBillBody { return { id: q.id, storeId: q.storeId, counterId: q.counterId, shiftId: q.shiftId, ...(q.customerId !== undefined ? { customerId: q.customerId } : {}), businessDate: q.businessDate, lines: q.lines, payments: q.payments, clientPayablePaise: q.totals.payablePaise, ...(q.overrides !== undefined ? { overrides: q.overrides } : {}), } } /** * The provisional-token sequence, persisted in localStorage so OFF-n stays * monotonic across refreshes and never reuses a number after a bill drains and * leaves the queue (deriving from the queue's max seq would recycle numbers). */ const SEQ_KEY = 'pos.offSeq' export function nextOffSeq(): number { let n = 0 try { n = Number(localStorage.getItem(SEQ_KEY) ?? '0') } catch { n = 0 } const next = (Number.isFinite(n) ? n : 0) + 1 try { localStorage.setItem(SEQ_KEY, String(next)) } catch { /* non-browser: caller supplies seq */ } return next } // ---------- IndexedDB access (browser-only) ---------- const DB_NAME = 'sims-pos-offline' const DB_VERSION = 1 const BILLS = 'bills' const CACHE = 'cache' function openIdb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION) req.onupgradeneeded = () => { const db = req.result if (!db.objectStoreNames.contains(BILLS)) db.createObjectStore(BILLS, { keyPath: 'id' }) if (!db.objectStoreNames.contains(CACHE)) db.createObjectStore(CACHE, { keyPath: 'key' }) } req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed')) }) } /** Run one request in its own transaction; resolves on transaction commit (durable). */ function run(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest): Promise { return openIdb().then((db) => new Promise((resolve, reject) => { const tx = db.transaction(store, mode) const req = fn(tx.objectStore(store)) let result: T req.onsuccess = () => { result = req.result } tx.oncomplete = () => { db.close(); resolve(result) } tx.onerror = () => { db.close(); reject(tx.error ?? new Error('IndexedDB tx failed')) } tx.onabort = () => { db.close(); reject(tx.error ?? new Error('IndexedDB tx aborted')) } })) } export async function enqueueBill(bill: QueuedBill): Promise { await run(BILLS, 'readwrite', (s) => s.put(bill)) } /** All queued bills, oldest-first (the drain order). */ export async function listQueuedBills(): Promise { const all = await run(BILLS, 'readonly', (s) => s.getAll() as IDBRequest) return all.sort((a, b) => a.seq - b.seq) } export async function removeQueuedBill(id: string): Promise { await run(BILLS, 'readwrite', (s) => s.delete(id) as unknown as IDBRequest) } export async function countQueuedBills(): Promise { return run(BILLS, 'readonly', (s) => s.count()) } // ---------- catalog cache (browser-only) ---------- export interface CatalogCache { key: 'catalog' store: BootstrapStore users: PosUser[] taxClasses: TaxClassRow[] items: Item[] savedAt: string } export async function saveCatalogCache(data: Omit): Promise { await run(CACHE, 'readwrite', (s) => s.put({ key: 'catalog', ...data })) } export async function loadCatalogCache(): Promise { return run(CACHE, 'readonly', (s) => s.get('catalog') as IDBRequest) } /** * Wipe the catalog/roster cache on Lock/logout (M11). Clears the whole `cache` store — * store details, staff roster and item master — so nothing about the shop or its staff * lingers on a shared counter PC once the cashier locks. Un-drained queued bills live in * a SEPARATE store (`bills`) and are intentionally left intact: a lock must never lose a * sale (R18); the catalog is re-fetched fresh on the next login. Post-lock the resume * context is cleared too (App.onLock), so this cache would never be read again anyway. */ export async function clearCatalogCache(): Promise { await run(CACHE, 'readwrite', (s) => s.clear() as unknown as IDBRequest) }