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/api.ts

230 lines
10 KiB
TypeScript

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<T>(path: string, init?: RequestInit): Promise<T> {
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<boolean> =>
fetch('/api/health').then((r) => r.ok).catch(() => false)
export const fetchBootstrap = (counter: string): Promise<Bootstrap> =>
call(`/bootstrap?counter=${encodeURIComponent(counter)}`)
export async function pinLogin(userId: string, pin: string): Promise<void> {
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<void> {
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<Item[]> => 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<CommitResult> => 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<PosBatchRow[]> => 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<CommitResult & { duplicate?: boolean }> =>
call('/bills/offline', { method: 'POST', body: JSON.stringify(body) })
export const fetchBills = (date: string, counterId: string): Promise<Record<string, unknown>[]> =>
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<Item> => 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<PosCustomer[]> =>
call(`/parties?phone=${encodeURIComponent(phone)}`)
export const createCustomer = (name: string, phone: string, gstin?: string): Promise<PosCustomer> =>
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<BillLookupResult[]> =>
call(`/bills/lookup?docNo=${encodeURIComponent(docNo)}`)
/** Look up recent original SALE bills for a customer phone. */
export const lookupBillsByPhone = (phone: string): Promise<BillLookupResult[]> =>
call(`/bills/lookup?phone=${encodeURIComponent(phone)}`)
/** Fetch the original bill's returnable lines (sold / returned / returnable + raw lines). */
export const fetchReturnable = (billId: string): Promise<ReturnableBill> =>
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<ReturnResult> => call('/returns', { method: 'POST', body: JSON.stringify(body) })