You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/pos/src/offline.ts

212 lines
8.6 KiB
TypeScript

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<IDBDatabase> {
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<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
return openIdb().then((db) => new Promise<T>((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<void> {
await run(BILLS, 'readwrite', (s) => s.put(bill))
}
/** All queued bills, oldest-first (the drain order). */
export async function listQueuedBills(): Promise<QueuedBill[]> {
const all = await run<QueuedBill[]>(BILLS, 'readonly', (s) => s.getAll() as IDBRequest<QueuedBill[]>)
return all.sort((a, b) => a.seq - b.seq)
}
export async function removeQueuedBill(id: string): Promise<void> {
await run(BILLS, 'readwrite', (s) => s.delete(id) as unknown as IDBRequest<undefined>)
}
export async function countQueuedBills(): Promise<number> {
return run<number>(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<CatalogCache, 'key'>): Promise<void> {
await run(CACHE, 'readwrite', (s) => s.put({ key: 'catalog', ...data }))
}
export async function loadCatalogCache(): Promise<CatalogCache | undefined> {
return run<CatalogCache | undefined>(CACHE, 'readonly', (s) => s.get('catalog') as IDBRequest<CatalogCache | undefined>)
}
/**
* 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<void> {
await run(CACHE, 'readwrite', (s) => s.clear() as unknown as IDBRequest<undefined>)
}