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.
212 lines
8.6 KiB
TypeScript
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>)
|
|
}
|