import type { BillLine, Item } from '@sims/domain' import type { LineInput, TaxClassRow } from '@sims/billing-engine' /** POS ↔ store-server client. Same origin in production (served under /pos/). */ export interface BootstrapStore { id: string; name: string; storeCode: string; stateCode: string gstin: string; counterId: string; counterCode: string } export interface PosUser { id: string; name: string; role: string } export interface Bootstrap { store: BootstrapStore; users: PosUser[]; taxClasses: TaxClassRow[] } /** * Thrown ONLY when the store-server is physically unreachable (fetch rejected) — * as opposed to a server that answered with an error (4xx/5xx). The counter's * offline path (S4) keys off this: a NetworkError means "queue the bill and keep * billing"; any other error means the server rejected the bill and the cart is * kept on screen instead (R18: the cart is never lost, either way). */ export class NetworkError extends Error { constructor(message = 'Store-server unreachable (E-3301)') { super(message) this.name = 'NetworkError' } } // The POS session token survives a browser refresh in sessionStorage (S4): a // mid-shift reload while the server is down can resume the already-authenticated // session. sessionStorage (not localStorage) so it dies with the tab, never // outlives the shift. Guarded for non-browser contexts (unit tests). const TOKEN_KEY = 'pos.token' const readStoredToken = (): string => { try { return sessionStorage.getItem(TOKEN_KEY) ?? '' } catch { return '' } } let token = readStoredToken() function setToken(t: string): void { token = t try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* non-browser: in-memory only */ } } /** True when an authenticated session token is present (used by offline resume). */ export const hasSession = (): boolean => token !== '' async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { ...init, headers: { 'content-type': 'application/json', ...(token !== '' ? { authorization: `Bearer ${token}` } : {}), }, }).catch(() => undefined) if (res === undefined) throw new NetworkError() const body = (await res.json().catch(() => ({}))) as T & { error?: string } if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`) return body } /** Unauthenticated liveness probe — the drain loop pings this before draining. */ export const pingHealth = (): Promise => fetch('/api/health').then((r) => r.ok).catch(() => false) export const fetchBootstrap = (counter: string): Promise => call(`/bootstrap?counter=${encodeURIComponent(counter)}`) export async function pinLogin(userId: string, pin: string): Promise { const s = await call<{ token: string }>('/auth/pin', { method: 'POST', body: JSON.stringify({ userId, pin }), }) setToken(s.token) } /** * End the session server-side (M4) then drop the local token. Best-effort: if the store-server * is unreachable we still forget the token locally so the counter locks. Called from "Lock". */ export async function logout(): Promise { try { await call('/auth/logout', { method: 'POST' }) } catch { /* server down: drop the token anyway */ } setToken('') } /** * The exact deviation a supervisor is approving. It is sent to /auth/verify-pin so the * minted token is BOUND to this item+kind+before+after (H4 fix) — the server re-checks it * against the line's own server-computed deviation at commit, so an approval for one line * can never be redirected to another. */ export interface OverrideContext { itemId: string kind: 'price' | 'qty' | 'discount' | 'batch' /** Server-anchored "before" value (the item master price for a price override; 0 otherwise). */ before: number after: number } /** * In-flow supervisor gate (spec P0-2): verify a supervisor's PIN without touching * the module-level `token` — the cashier stays logged in and no session is created. * Distinct from `pinLogin` on purpose; reusing that would hijack the cashier's session. * The bound override context rides along so the server mints a token pinned to it. */ export const verifySupervisorPin = (userId: string, pin: string, override: OverrideContext): Promise<{ ok: true; userId: string; displayName: string; approvalId: string }> => call('/auth/verify-pin', { method: 'POST', body: JSON.stringify({ userId, pin, override }) }) // The counter caches the whole sellable catalog (no silent cap — R13); ?all=1 // returns every non-inactive item unpaginated. export const fetchItemsCache = (): Promise => call('/items?all=1') export const fetchStock = (): Promise<{ code: string; on_hand: number }[]> => call('/stock') export interface CommitResult { id: string; docNo: string; payablePaise: number } /** * Supervisor-approved override, audited server-side in the commit transaction (spec * P0-2). `approvalId` is the single-use token returned by verifySupervisorPin — the * server redeems it and records the approver from its own row, so the client never * asserts who approved (SEC fix). */ export interface BillOverride { itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string } export const postBill = (body: { storeId: string; counterId: string; shiftId: string; customerId?: string businessDate: string; lines: LineInput[] payments: { mode: string; amountPaise: number }[] clientPayablePaise: number overrides?: BillOverride[] }): Promise => call('/bills', { method: 'POST', body: JSON.stringify(body) }) /** * Batches with derived on-hand > 0 for the FEFO picker (S5). The POS caches these * keyed by item at login and refreshes after each commit so per-batch on-hand stays * live. Raw server rows; the billing screen maps them to the domain BatchStock shape. */ export interface PosBatchRow { id: string; item_id: string; batch_no: string; expiry_date: string | null; on_hand: number } export const fetchBatches = (): Promise => call('/batches') /** * The drain payload for a bill queued offline (S4). Unlike the online postBill it * carries its own `id` (client uuidv7, the idempotency key). Tenant and cashier are * NOT in the body — the drain runs under the cashier's live session and the server * takes both from it (SEC fix); if the session has expired the drain 401s and the * queue waits for re-login. */ export interface OfflineBillBody { id: string storeId: string; counterId: string; shiftId: string; customerId?: string businessDate: string; lines: LineInput[] payments: { mode: string; amountPaise: number }[] clientPayablePaise: number overrides?: BillOverride[] } /** Drain one queued bill; the server assigns/returns its real GST doc no. */ export const postBillOffline = (body: OfflineBillBody): Promise => call('/bills/offline', { method: 'POST', body: JSON.stringify(body) }) export const fetchBills = (date: string, counterId: string): Promise[]> => call(`/bills?date=${date}&counterId=${encodeURIComponent(counterId)}`) export const createDraftItem = (body: { code: string; name: string; hsn: string; taxClassCode: string unitCode: string; unitDecimals: number; salePricePaise: number barcode?: string; status: string }): Promise => call('/items', { method: 'POST', body: JSON.stringify(body) }) /** Attached-customer identity the counter needs: name plus B2B place-of-supply inputs. */ export interface PosCustomer { id: string; name: string; phone?: string; gstin?: string; stateCode?: string } export const findCustomers = (phone: string): Promise => call(`/parties?phone=${encodeURIComponent(phone)}`) export const createCustomer = (name: string, phone: string, gstin?: string): Promise => call('/parties', { method: 'POST', body: JSON.stringify({ name, phone, ...(gstin !== undefined && gstin !== '' ? { gstin } : {}) }), }) // ---------- returns / credit notes ---------- /** A found original SALE bill for the return lookup (doc-no or phone search). */ export interface BillLookupResult { id: string; doc_no: string; business_date: string; payable_paise: number cashier_name?: string; customer_name?: string | null } /** One original line annotated with sold / already-returned / still-returnable quantities. */ export interface ReturnableLine { lineIndex: number itemId: string; name: string; hsn: string; unitCode: string unitPricePaise: number; taxRateBp: number; lineTotalPaise: number soldQty: number; returnedQty: number; returnableQty: number batchId?: string } /** The original bill + its returnable lines + the raw lines for the shared refund preview. */ export interface ReturnableBill { id: string; docNo: string; businessDate: string customerId?: string; customerName?: string lines: ReturnableLine[] originalLines: BillLine[] } export interface ReturnResult { id: string; docNo: string; refundPaise: number } /** Look up an original SALE bill by its exact doc no (returns 0 or 1 result). */ export const lookupBillByDocNo = (docNo: string): Promise => call(`/bills/lookup?docNo=${encodeURIComponent(docNo)}`) /** Look up recent original SALE bills for a customer phone. */ export const lookupBillsByPhone = (phone: string): Promise => call(`/bills/lookup?phone=${encodeURIComponent(phone)}`) /** Fetch the original bill's returnable lines (sold / returned / returnable + raw lines). */ export const fetchReturnable = (billId: string): Promise => call(`/bills/${encodeURIComponent(billId)}/returnable`) /** * Commit a return / credit note. The server anchors everything to OUR stored original bill, * blocks over-return, computes the refund itself and re-verifies clientRefundPaise (409 on * mismatch). Tenant + cashier come from the session, never the body. */ export const postReturn = (body: { storeId: string; counterId: string; shiftId: string; businessDate: string originalBillId: string; lines: { lineIndex: number; qty: number }[] reason: string; refundMode: string; clientRefundPaise: number }): Promise => call('/returns', { method: 'POST', body: JSON.stringify(body) })