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.
230 lines
10 KiB
TypeScript
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) })
|