/** * @license * SPDX-License-Identifier: Apache-2.0 */ /** * POS REST client — the counter-sales surface served at * `https://fiesta.nearle.app/live/api/v1/pos`. * * This is a *different base* from `./fiestaApi` (which is `/live/api/v1/web`), * so it gets its own client rather than a helper on that one. * * Two things about this API shape every function below: * * 1. **There is no tenant parameter.** `locationid` is required on every read * and is the authorisation boundary server-side. A tenant-wide view is * therefore a fan-out, one request per outlet, merged here — see `mapLimit`. * * 2. **The health endpoints return a Redis hash**, so every value is a string — * `"today_amount": "170"`, `"printer_reachable": "0"`. Nothing may be used * as a number or a boolean without going through the parsers here. * * Components call the hooks in `./posQueries`, not these functions. */ /** * The console reads counter sales from `/v1/web/pos`, not `/v1/pos`. * * `/v1/pos` is the terminal group and sits behind `PosAuth`, which pins a * request to the outlet inside a till's token. A browser has no such token and * cannot get one — `/pos/login` refuses any account that is not a till account. * So the day `POS_AUTH_REQUIRED=true` is set, every read on that group would * 401 and this page would go blank. * * `/v1/web/pos` serves the same five handlers outside the guard. Identical * responses — they read their scope from query parameters and never touch the * session — so nothing else in this client changes. */ const POS_BASE = import.meta.env.VITE_FIESTA_POS_URL || 'https://fiesta.nearle.app/live/api/v1/web/pos'; type QueryParams = Record; // ── Wire types ─────────────────────────────────────────────────────────────── /** One counter bill. Mirrors `models.PosOrders` — the list endpoint is a * `SELECT *`, so every column arrives whether the doc's example shows it or not. */ export interface PosBill { posorderid: number; terminalorderid: string; invoicenumber: string; tenantid: number; locationid: number; terminalid: string; cashiername: string; customerid: number; customermobile: string; customername: string; billedat: string; businessdate: string; subtotal: number; discount: number; taxamount: number; roundoff: number; total: number; pointsearned: number; pointsredeemed: number; itemcount: number; paymentmode: string; paymentsjson: string; promosjson: string; taxbreakdownjson: string; batchid: string; receivedat: string; created?: string; updated?: string; /** Populated only by `/sales/detail`. The list endpoint sends `items: null` * (the Go field is `gorm:"-"` with no `omitempty`), so this is nullable. */ items?: PosBillItem[] | null; } export interface PosBillItem { posorderitemid: number; posorderid: number; tenantid: number; locationid: number; productid: number; productname: string; barcode: string; unitname: string; /** Fractional — a counter sells 1.5 kg. */ quantity: number; unitprice: number; discountamount: number; /** A fraction (0.18), not a percentage (18). */ gstrate: number; taxamount: number; linetotal: number; } export interface PosSalesPage { total: number; pageno: number; pagesize: number; bills: PosBill[]; } export interface PosPaymentTotal { paymentmode: string; billcount: number; amount: number } export interface PosDayTotal { businessdate: string; billcount: number; amount: number } export interface PosTerminalTotal { terminalid: string; billcount: number; amount: number } export interface PosSalesSummary { locationid: number; fromdate: string; todate: string; billcount: number; itemcount: number; /** Named "gross" by the API but computed as `SUM(total)` — i.e. **net of * discount and round-off**. `grosssales + discountgiven` is the gross figure. * Labelled honestly in the UI as "Net collected". */ grosssales: number; taxcollected: number; discountgiven: number; roundoff: number; averagebill: number; bypaymentmode: PosPaymentTotal[]; byday: PosDayTotal[]; byterminal: PosTerminalTotal[]; } /** Raw Redis hash for one till. Every value is a string; use `parseTerminal`. */ export type PosTerminalRaw = Record; export interface PosLocationHealth { location_id: string; total: number; online: number; terminals: PosTerminalRaw[]; } export interface PosSalesFilter { locationid: number; fromdate?: string; todate?: string; terminalid?: string; /** A real filter the handler reads, applied as an exact case-sensitive match. */ cashiername?: string; paymentmode?: string; pageno?: number; pagesize?: number; } /** * The terminal group, used only until the console base exists on a server. * * TRANSITIONAL. The frontend and the backend deploy separately and the backend * is currently behind, so shipping this build first would point every read at a * route that answers 404 and blank the page. Rather than make the two deploys * ordered — a constraint nobody would remember in six months — the base is * resolved once at runtime: try the console group, and if the server is old * enough not to have it, fall back to the terminal group for this session. * * Safe to delete once every environment is past the deploy that added * `/v1/web/pos`. It is a no-op there: the first probe succeeds and the fallback * is never reached. */ const POS_BASE_LEGACY = POS_BASE.replace('/v1/web/pos', '/v1/pos').replace('/v1/mob/pos', '/v1/pos'); let resolvedBase: string | null = POS_BASE === POS_BASE_LEGACY ? POS_BASE : null; let baseProbe: Promise | null = null; /** Resolves the base once per session; concurrent callers share one probe. */ async function posBase(): Promise { if (resolvedBase) return resolvedBase; if (!baseProbe) { baseProbe = (async () => { try { // `sales/summary` needs no data to exist — a missing route 404s, a // present one answers 200 even for an outlet with no bills. const res = await fetch(`${POS_BASE}/sales/summary?locationid=0`, { headers: { Accept: 'application/json' }, }); resolvedBase = res.status === 404 ? POS_BASE_LEGACY : POS_BASE; } catch { // A network failure says nothing about which routes exist; assume the // current one and let the real request surface the error. resolvedBase = POS_BASE; } return resolvedBase; })(); } return baseProbe; } // ── Transport ──────────────────────────────────────────────────────────────── async function posGet(endpoint: string, params: QueryParams = {}): Promise { const qs = new URLSearchParams(); Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') qs.append(k, String(v)); }); const query = qs.toString(); const base = await posBase(); const res = await fetch(`${base}/${endpoint}${query ? `?${query}` : ''}`, { headers: { Accept: 'application/json' }, }); if (!res.ok) { // 404 from /sales/detail is a legitimate "no such bill at this outlet", // not a transport failure — surfaced as null by getPosSaleDetail. throw new Error(`POS ${endpoint} failed: ${res.status} ${res.statusText}`); } return res.json() as Promise; } /** The sales reads use the `{code, status, details}` envelope. Note there is no * `message` key on success — only on errors. */ function details(json: unknown): T | null { if (json && typeof json === 'object' && 'details' in json) { return (json as { details: T }).details ?? null; } return null; } /** * Run `fn` over `items` with at most `limit` in flight. * * The tenant views fan out one request per outlet. The largest tenant on the * platform has 22 outlets, so an unbounded fan-out would open 44 sockets at * once (health + summary) on every refresh. Six keeps it civil without making * the page feel serial. */ export async function mapLimit( items: T[], limit: number, fn: (item: T, index: number) => Promise, ): Promise { const out = new Array(items.length); let cursor = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { for (;;) { const i = cursor++; if (i >= items.length) return; out[i] = await fn(items[i], i); } }); await Promise.all(workers); return out; } // ── Reads ──────────────────────────────────────────────────────────────────── /** * A page of bills for one outlet, newest first. * * `pagesize` is clamped to 500 here because the server does *not* clamp — it * resets anything over 500 back to 50, so asking for 1000 quietly returns 50. */ export async function getPosSales(filter: PosSalesFilter): Promise { const pagesize = Math.min(Math.max(filter.pagesize ?? 50, 1), 500); const json = await posGet('sales', { locationid: filter.locationid, fromdate: filter.fromdate, todate: filter.todate, terminalid: filter.terminalid, cashiername: filter.cashiername, paymentmode: filter.paymentmode, pageno: filter.pageno ?? 0, pagesize, }); return ( details(json) ?? { total: 0, pageno: 0, pagesize, bills: [] } ); } /** * One bill with its lines. `reference` matches any of three columns server-side * — the terminal's UUID, the invoice number, or the posorderid — so a support * call can start from whichever the caller is looking at. * * Returns null on 404, which is what the server sends when the reference is * real but belongs to a different outlet. */ export async function getPosSaleDetail( locationid: number, reference: string, ): Promise { try { const json = await posGet('sales/detail', { locationid, reference }); return details(json); } catch { return null; } } export async function getPosSalesSummary(filter: PosSalesFilter): Promise { const json = await posGet('sales/summary', { locationid: filter.locationid, fromdate: filter.fromdate, todate: filter.todate, terminalid: filter.terminalid, cashiername: filter.cashiername, paymentmode: filter.paymentmode, }); return details(json); } /** One product exactly as a till stores it. */ export interface PosCatalogueProduct { id: string; name: string; barcode: string; sku: string; category: string; price: number; mrp?: number; stock: number; unit: string; /** A fraction (0.05), matching how the till holds it. */ gst_rate: number; hsn_code?: string; brand?: string; /** False for anything a till must not be able to ring up — chiefly unpriced * products, which would otherwise sell at ₹0. */ is_active: boolean; } export interface PosCatalogueCustomer { id: string; name: string; mobile: string; loyalty_points: number; lifetime_spend: number; visit_count: number; last_visit_at?: string; } export interface PosCatalogue { revision: string; is_delta: boolean; has_more: boolean; products: PosCatalogueProduct[]; customers: PosCatalogueCustomer[]; retired_product_ids: string[]; } /** * What a till at this store currently sees. * * Answered as a **bare body**, not the `{code, status, details}` envelope the * sales reads use — it is a terminal-facing endpoint, so it is parsed directly. * * Always called without `since`, which yields a full snapshot. A delta would be * cheaper but describes only what changed, and this panel is asking what the * till can sell *right now*. */ export async function getPosCatalogue(storeId: number, pageSize = 500): Promise { const json = await posGet('catalogue', { store_id: storeId, page_size: pageSize }); return { revision: json?.revision ?? '', is_delta: Boolean(json?.is_delta), has_more: Boolean(json?.has_more), products: json?.products ?? [], customers: json?.customers ?? [], retired_product_ids: json?.retired_product_ids ?? [], }; } export async function getPosLocationHealth(locationid: number): Promise { const json = await posGet('health/location', { location_id: locationid }); return ( details(json) ?? { location_id: String(locationid), total: 0, online: 0, terminals: [], } ); } // ── Parsing the health hash ────────────────────────────────────────────────── /** * Four states, not two. * * The heartbeat is every 30s under a 90s TTL, so between those a live till has * no fresh reading and is *not* dead — showing two states would make healthy * tills blink red. And a key can exist while saying "offline", because the * broker's Last Will overwrites the status when a till loses power mid-shift. * That is the only way to tell *closed for the night* from *unplugged*, so it * is kept distinct from a key that simply expired. */ export type TerminalState = 'online' | 'stale' | 'offline_declared' | 'offline_vanished'; export interface PosTerminal { terminalId: string; locationId: string; storeName: string; appVersion: string; state: TerminalState; /** Server-supplied explanation on the expired-key stub. */ reason: string; pendingBills: number; pendingRegistrations: number; oldestPendingAt: string; todayBills: number; todayAmount: number; lastBillAt: string; reportedAt: string; receivedAt: string; /** Seconds since the *server* stamped the reading. Deliberately measured from * `received_at`, never `reported_at` — a till with a wrong clock must not be * able to make itself look fresh. */ ageSeconds: number | null; /** `reported_at` minus `received_at`, in seconds. A large value means the * till's clock is wrong, which matters because bills are filed under the * business date the till decided. */ clockDriftSeconds: number | null; /** Absent when the till does not collect them — never coerced to zero, or a * board would show every terminal on a flat battery. */ batteryLevel: number | null; batteryCharging: boolean | null; storageFreeMb: number | null; printerReachable: boolean | null; drawerStatus: string | null; } const s = (h: PosTerminalRaw, k: string): string => (h[k] ?? '').trim(); function optNum(h: PosTerminalRaw, k: string): number | null { const raw = s(h, k); if (raw === '') return null; const n = Number(raw); return Number.isFinite(n) ? n : null; } /** Redis stores a Go bool as "1"/"0"; tolerate "true"/"false" too. */ function optBool(h: PosTerminalRaw, k: string): boolean | null { const raw = s(h, k).toLowerCase(); if (raw === '') return null; return raw === '1' || raw === 'true'; } function secondsSince(iso: string, now: number): number | null { if (!iso) return null; const t = Date.parse(iso); return Number.isFinite(t) ? Math.round((now - t) / 1000) : null; } export function parseTerminal(h: PosTerminalRaw, now: number = Date.now()): PosTerminal { const status = s(h, 'status').toLowerCase(); const reason = s(h, 'reason'); const receivedAt = s(h, 'received_at'); const reportedAt = s(h, 'reported_at'); const ageSeconds = secondsSince(receivedAt, now); // The expired-key stub is exactly {terminal_id, location_id, status, reason} // — it carries no heartbeat fields at all, which is how it's told apart from // a till that is present and declaring itself offline. const isStub = status === 'offline' && reason !== '' && receivedAt === ''; let state: TerminalState; if (isStub) state = 'offline_vanished'; else if (status !== 'online') state = 'offline_declared'; else if (ageSeconds == null || ageSeconds <= 40) state = 'online'; else state = 'stale'; const reportedT = reportedAt ? Date.parse(reportedAt) : NaN; const receivedT = receivedAt ? Date.parse(receivedAt) : NaN; return { terminalId: s(h, 'terminal_id'), locationId: s(h, 'location_id'), storeName: s(h, 'store_name'), appVersion: s(h, 'app_version'), state, reason, pendingBills: optNum(h, 'pending_bills') ?? 0, pendingRegistrations: optNum(h, 'pending_registrations') ?? 0, oldestPendingAt: s(h, 'oldest_pending_at'), todayBills: optNum(h, 'today_bills') ?? 0, todayAmount: optNum(h, 'today_amount') ?? 0, lastBillAt: s(h, 'last_bill_at'), reportedAt, receivedAt, ageSeconds, clockDriftSeconds: Number.isFinite(reportedT) && Number.isFinite(receivedT) ? Math.round((reportedT - receivedT) / 1000) : null, batteryLevel: optNum(h, 'battery_level'), batteryCharging: optBool(h, 'battery_charging'), storageFreeMb: optNum(h, 'storage_free_mb'), printerReachable: optBool(h, 'printer_reachable'), drawerStatus: s(h, 'drawer_status') || null, }; } export const TERMINAL_STATE_LABEL: Record = { online: 'Online', stale: 'Stale', offline_declared: 'Offline', offline_vanished: 'No heartbeat', }; export const TERMINAL_STATE_COLOR: Record = { online: '#10b981', stale: '#f59e0b', offline_declared: '#ef4444', offline_vanished: '#94a3b8', }; // ── Merging a fan-out ──────────────────────────────────────────────────────── export interface PosOutletSummary { locationid: number; name: string; summary: PosSalesSummary | null; } /** * Fold per-outlet summaries into one. * * `averagebill` is **recomputed** as `Σtotal / Σbills`, never averaged. Taking * the mean of twelve outlets' averages weights a four-bill kiosk the same as a * nine-hundred-bill store, which is not a number anyone should act on. */ export function mergeSummaries(parts: Array): PosSalesSummary { const merged: PosSalesSummary = { locationid: 0, fromdate: '', todate: '', billcount: 0, itemcount: 0, grosssales: 0, taxcollected: 0, discountgiven: 0, roundoff: 0, averagebill: 0, bypaymentmode: [], byday: [], byterminal: [], }; const byMode = new Map(); const byDay = new Map(); const byTerm = new Map(); for (const p of parts) { if (!p) continue; merged.billcount += p.billcount || 0; merged.itemcount += p.itemcount || 0; merged.grosssales += p.grosssales || 0; merged.taxcollected += p.taxcollected || 0; merged.discountgiven += p.discountgiven || 0; merged.roundoff += p.roundoff || 0; merged.fromdate ||= p.fromdate; merged.todate ||= p.todate; for (const m of p.bypaymentmode ?? []) { const key = m.paymentmode || ''; const at = byMode.get(key) ?? { paymentmode: key, billcount: 0, amount: 0 }; at.billcount += m.billcount || 0; at.amount += m.amount || 0; byMode.set(key, at); } for (const d of p.byday ?? []) { const at = byDay.get(d.businessdate) ?? { businessdate: d.businessdate, billcount: 0, amount: 0 }; at.billcount += d.billcount || 0; at.amount += d.amount || 0; byDay.set(d.businessdate, at); } for (const t of p.byterminal ?? []) { const key = t.terminalid || ''; const at = byTerm.get(key) ?? { terminalid: key, billcount: 0, amount: 0 }; at.billcount += t.billcount || 0; at.amount += t.amount || 0; byTerm.set(key, at); } } merged.averagebill = merged.billcount > 0 ? merged.grosssales / merged.billcount : 0; merged.bypaymentmode = [...byMode.values()].sort((a, b) => b.amount - a.amount); merged.byday = [...byDay.values()].sort((a, b) => a.businessdate.localeCompare(b.businessdate)); merged.byterminal = [...byTerm.values()].sort((a, b) => b.amount - a.amount); return merged; } /** Average bill for one outlet, recomputed rather than trusted — same trap as * the merge above, and the store leaderboard is where it would bite. */ export const averageBill = (sm: PosSalesSummary | null): number => sm && sm.billcount > 0 ? sm.grosssales / sm.billcount : 0; // ── Bill helpers ───────────────────────────────────────────────────────────── export interface PosPaymentSplit { method: string; amount: number; reference?: string } export interface PosPromo { id?: string; name?: string; type?: string; amount?: number } function parseJsonField(raw: string | undefined, fallback: T): T { if (!raw) return fallback; try { const v = JSON.parse(raw); return (v ?? fallback) as T; } catch { return fallback; } } /** The full tender split. A bill can be part cash, part card, part loyalty, and * `paymentmode` only names the largest of them. */ export const billPayments = (b: PosBill): PosPaymentSplit[] => parseJsonField(b.paymentsjson, []); /** GST per slab, as printed on the tax invoice. Keys are rate fractions * ("0.05"), values are the tax amount at that slab. */ export const billTaxBreakdown = (b: PosBill): Array<{ rate: number; amount: number }> => { const raw = parseJsonField>(b.taxbreakdownjson, {}); return Object.entries(raw) .map(([rate, amount]) => ({ rate: Number(rate), amount: Number(amount) })) .filter((r) => Number.isFinite(r.rate) && Number.isFinite(r.amount)) // Tills send a `{"0.0": 0}` placeholder on zero-rated bills. Rendering it // as a "0.00% — ₹0.00" slab reads like a real tax line for an untaxed sale. .filter((r) => r.amount !== 0 || r.rate !== 0) .sort((a, b2) => a.rate - b2.rate); }; /** Who the bill was rung for. A till can attach a customer id without a name or * mobile, so "Walk-in" is reserved for bills with no customer at all. */ export function billCustomer(b: PosBill): string { const named = (b.customername || '').trim() || (b.customermobile || '').trim(); if (named) return named; return b.customerid > 0 ? `Customer #${b.customerid}` : 'Walk-in'; } export const billPromos = (b: PosBill): PosPromo[] => parseJsonField(b.promosjson, []); export type SyncLag = | { kind: 'legacy'; seconds: number } | { kind: 'ok'; seconds: number } | { kind: 'delayed'; seconds: number } | { kind: 'unknown' }; /** * How long a bill took to reach us. * * A negative value is **not** a fast upload — it is a bill written before the * till started sending a UTC offset, whose `billedat` holds IST wall-clock * stamped as though it were UTC. Those come back exactly 5h30m "early". They * are classed `legacy` rather than alerted on, because flooring the panel at a * date would still leak the ones rung on the morning of the fix. * * `businessdate` is unaffected by that skew (storing local wall-clock as UTC * preserves the date), so day bucketing and the date filters stay correct — the * damage is confined to this one comparison. */ export function syncLag(b: PosBill): SyncLag { const billed = Date.parse(b.billedat); const received = Date.parse(b.receivedat); if (!Number.isFinite(billed) || !Number.isFinite(received)) return { kind: 'unknown' }; const seconds = Math.round((received - billed) / 1000); if (seconds < 0) return { kind: 'legacy', seconds }; if (seconds > 900) return { kind: 'delayed', seconds }; return { kind: 'ok', seconds }; } /** Terminal ids are blank on bills ingested before the fallback landed. Shown * as a named bucket rather than an empty cell so the column reads honestly. */ export const terminalLabel = (t: string): string => (t && t.trim()) || 'Unassigned'; export const todayISO = (): string => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; export const daysAgoISO = (days: number): string => { const d = new Date(); d.setDate(d.getDate() - days); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; export const inr = (n: number): string => `₹${(Number.isFinite(n) ? n : 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; export const inrCompact = (n: number): string => `₹${(Number.isFinite(n) ? n : 0).toLocaleString('en-IN', { maximumFractionDigits: 0 })}`; /** Duration in the units a person reading a sync board actually wants. */ export function humanDuration(seconds: number): string { const abs = Math.abs(seconds); if (abs < 60) return `${abs}s`; if (abs < 3600) return `${Math.round(abs / 60)}m`; if (abs < 86400) return `${(abs / 3600).toFixed(1)}h`; return `${(abs / 86400).toFixed(1)}d`; } /** `2026-08-05T17:29:00Z` → `17:29`. Rendered as sent; no timezone maths, since * the stored value's offset is not trustworthy across the fix boundary. */ export function billTime(iso: string): string { if (!iso) return '—'; const m = iso.match(/T(\d{2}:\d{2})/); return m ? m[1] : '—'; }