wip(store-product): returns, GST returns, offline queue, security hardening + docs
Snapshot of the store-product workstream's in-progress work (apps/pos, apps/backoffice, apps/store-server, packages): returns + credit notes, GST returns exports, offline bill queue, rate-limiting, session policy, print guard, batch tracking, and the SEC-C6/SEC-A5 auth fixes; plus docs/18 red-team review. Committed to preserve work for the repo push. HQ console already committed separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
4559958fa4
commit
3d3487c85f
@ -0,0 +1,351 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatINR } from '@sims/domain'
|
||||
import { taxOf, type CreditNote, type Gstr1SectionTotals, type HsnRow, type Reconciliation, type TaxAmounts } from '@sims/billing-engine'
|
||||
import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
|
||||
import { getGstHsn, getGstr1, getGstr1Json, getGstr3b, type Gstr1Response } from '../api'
|
||||
|
||||
/**
|
||||
* GST returns — LIVE pages (GSTR-1, GSTR-3B, HSN summary). Every number is folded server-side by
|
||||
* the shared engine from the period's immutable documents and re-verified by a reconciliation proof
|
||||
* shown at the top of each page. These are decision-support / export views for a CA or GSP, NOT a
|
||||
* certified filing (stated on every page). Replaces the registry wireframes via the CUSTOM map.
|
||||
*/
|
||||
|
||||
const inr = (p: unknown): string => formatINR(Number(p), { symbol: false })
|
||||
const rateLabel = (bp: number): string => (bp === 0 ? '0%' : `${bp / 100}%`)
|
||||
const thisMonth = (): string => new Date().toISOString().slice(0, 7)
|
||||
|
||||
function useData<T>(loader: () => Promise<T>, deps: unknown[]): { data?: T; error?: string } {
|
||||
const [data, setData] = useState<T | undefined>()
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
useEffect(() => {
|
||||
setData(undefined)
|
||||
setError(undefined)
|
||||
loader().then(setData).catch((e: Error) => setError(e.message))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
return { data, error }
|
||||
}
|
||||
|
||||
function MonthInput(props: { period: string; onChange: (p: string) => void }) {
|
||||
return (
|
||||
<input
|
||||
type="month" className="wf" style={{ maxWidth: 170 }}
|
||||
value={props.period} max={thisMonth()} onChange={(e) => props.onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** The reconciliation proof — green when sections == source == HSN == 3B, red (loud) otherwise. */
|
||||
function ReconBanner(props: { recon: Reconciliation }) {
|
||||
const r = props.recon
|
||||
if (r.ok) {
|
||||
return (
|
||||
<Notice tone="ok">
|
||||
Reconciled: GSTR-1 sections = source documents = HSN summary = GSTR-3B outward.
|
||||
Net taxable {formatINR(r.sourceTaxablePaise)}, net tax {formatINR(r.sourceTaxPaise)} (returns reduce liability at their original rate).
|
||||
</Notice>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Notice tone="err">
|
||||
DOES NOT RECONCILE — do not file. {r.mismatches.join('; ')}. Source taxable {formatINR(r.sourceTaxablePaise)} vs
|
||||
GSTR-1 {formatINR(r.gstr1NetTaxablePaise)} vs HSN {formatINR(r.hsnTaxablePaise)} vs 3B {formatINR(r.gstr3bTaxablePaise)}.
|
||||
</Notice>
|
||||
)
|
||||
}
|
||||
|
||||
function NotCertified() {
|
||||
return (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<Notice tone="warn">
|
||||
Not a certified filing. These figures are computed from your counters' immutable documents for review and
|
||||
hand-off to your CA / GSP; real e-filing goes through a GSP. Nil-rated/exempt/export splits, ITC (GSTR-2A/2B),
|
||||
amendments and the cross-period filing-freeze are follow-ups — see the deferred list.
|
||||
</Notice>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function downloadJson(obj: unknown, filename: string): void {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000)
|
||||
}
|
||||
|
||||
const AMOUNT_COLS = [
|
||||
{ key: 'taxable', label: 'Taxable', numeric: true }, { key: 'igst', label: 'IGST', numeric: true },
|
||||
{ key: 'cgst', label: 'CGST', numeric: true }, { key: 'sgst', label: 'SGST', numeric: true },
|
||||
{ key: 'cess', label: 'Cess', numeric: true },
|
||||
]
|
||||
const amountCells = (a: TaxAmounts): Record<string, string> => ({
|
||||
taxable: inr(a.taxablePaise), igst: inr(a.igstPaise), cgst: inr(a.cgstPaise), sgst: inr(a.sgstPaise), cess: inr(a.cessPaise),
|
||||
})
|
||||
|
||||
/** GSTR-1 section-total summary (returns shown as reductions), ending in the reconciled Net row. */
|
||||
function SectionSummary(props: { totals: Gstr1SectionTotals }) {
|
||||
const t = props.totals
|
||||
const neg = (a: TaxAmounts): TaxAmounts => ({ taxablePaise: -a.taxablePaise, igstPaise: -a.igstPaise, cgstPaise: -a.cgstPaise, sgstPaise: -a.sgstPaise, cessPaise: -a.cessPaise })
|
||||
const rows = [
|
||||
{ section: 'B2B (4A) — registered', ...amountCells(t.b2b) },
|
||||
{ section: 'B2C Large (5) — inter-state', ...amountCells(t.b2cl) },
|
||||
{ section: 'B2C Small (7)', ...amountCells(t.b2cs) },
|
||||
{ section: 'CDNR (9B) — reg. credit notes', ...amountCells(neg(t.cdnr)) },
|
||||
{ section: 'CDNUR (9B) — unreg. credit notes', ...amountCells(neg(t.cdnur)) },
|
||||
{ section: 'Net outward', ...amountCells(t.net) },
|
||||
]
|
||||
return (
|
||||
<>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>Section summary</h3>
|
||||
<DataTable columns={[{ key: 'section', label: 'Section' }, ...AMOUNT_COLS]} rows={rows} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function B2BTable(props: { groups: Gstr1Response['b2b'] }) {
|
||||
const rows = props.groups.flatMap((g) =>
|
||||
g.rates.map((r, i) => ({
|
||||
gstin: i === 0 ? g.ctin : '', invoices: i === 0 ? String(g.invoiceCount) : '',
|
||||
rate: rateLabel(r.rateBp), ...amountCells(r),
|
||||
})),
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>B2B — invoices to registered buyers (Table 4A)</h3>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'gstin', label: 'Buyer GSTIN' }, { key: 'invoices', label: 'Invoices', numeric: true },
|
||||
{ key: 'rate', label: 'Rate' }, ...AMOUNT_COLS,
|
||||
]}
|
||||
rows={rows}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function B2CSTable(props: { buckets: Gstr1Response['b2cs'] }) {
|
||||
const rows = props.buckets.map((b) => ({
|
||||
pos: b.pos, type: b.supplyType === 'INTER' ? 'Inter' : 'Intra', rate: rateLabel(b.rateBp), ...amountCells(b),
|
||||
}))
|
||||
return (
|
||||
<>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>B2C Small — aggregated by place of supply + rate (Table 7)</h3>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'pos', label: 'Place of supply' }, { key: 'type', label: 'Type' },
|
||||
{ key: 'rate', label: 'Rate' }, ...AMOUNT_COLS,
|
||||
]}
|
||||
rows={rows}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function B2CLTable(props: { invoices: Gstr1Response['b2cl'] }) {
|
||||
const rows = props.invoices.flatMap((inv) =>
|
||||
inv.rates.map((r, i) => ({
|
||||
no: i === 0 ? inv.docNo : '', date: i === 0 ? inv.businessDate : '', pos: inv.pos,
|
||||
value: i === 0 ? inr(inv.invoiceValuePaise) : '', rate: rateLabel(r.rateBp),
|
||||
taxable: inr(r.taxablePaise), igst: inr(r.igstPaise), cess: inr(r.cessPaise),
|
||||
})),
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>B2C Large — inter-state invoices above ₹2,50,000 (Table 5)</h3>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'no', label: 'Invoice' }, { key: 'date', label: 'Date' }, { key: 'pos', label: 'POS' },
|
||||
{ key: 'value', label: 'Value', numeric: true }, { key: 'rate', label: 'Rate' },
|
||||
{ key: 'taxable', label: 'Taxable', numeric: true }, { key: 'igst', label: 'IGST', numeric: true },
|
||||
{ key: 'cess', label: 'Cess', numeric: true },
|
||||
]}
|
||||
rows={rows}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CDNTable(props: { title: string; notes: CreditNote[]; showCtin?: boolean }) {
|
||||
const rows = props.notes.flatMap((n) =>
|
||||
n.rates.map((r, i) => ({
|
||||
no: i === 0 ? n.noteDocNo : '', ctin: i === 0 ? (n.ctin ?? '—') : '',
|
||||
against: i === 0 ? (n.originalDocNo ?? '—') : '', rate: rateLabel(r.rateBp), ...amountCells(r),
|
||||
})),
|
||||
)
|
||||
const cols = [
|
||||
{ key: 'no', label: 'Credit Note' },
|
||||
...(props.showCtin === true ? [{ key: 'ctin', label: 'Buyer GSTIN' }] : []),
|
||||
{ key: 'against', label: 'Against Bill' }, { key: 'rate', label: 'Rate' }, ...AMOUNT_COLS,
|
||||
]
|
||||
return (
|
||||
<>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>{props.title}</h3>
|
||||
<DataTable columns={cols} rows={rows} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function HsnTable(props: { rows: HsnRow[]; totals: TaxAmounts }) {
|
||||
const rows: Record<string, string>[] = props.rows.map((h) => ({
|
||||
hsn: h.hsn, rate: rateLabel(h.rateBp), uqc: h.uqc,
|
||||
qty: String(Math.round(h.qty * 1000) / 1000), ...amountCells(h),
|
||||
}))
|
||||
rows.push({ hsn: 'Total', rate: '', uqc: '', qty: '', ...amountCells(props.totals) })
|
||||
return (
|
||||
<>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>HSN summary (Table 12)</h3>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'hsn', label: 'HSN' }, { key: 'rate', label: 'Rate' }, { key: 'uqc', label: 'UQC' },
|
||||
{ key: 'qty', label: 'Qty', numeric: true }, ...AMOUNT_COLS,
|
||||
]}
|
||||
rows={rows}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DocsTable(props: { docs: Gstr1Response['docs'] }) {
|
||||
if (props.docs.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>Documents issued (Table 13)</h3>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'nature', label: 'Nature' }, { key: 'from', label: 'From' }, { key: 'to', label: 'To' },
|
||||
{ key: 'total', label: 'Total', numeric: true }, { key: 'cancel', label: 'Cancelled', numeric: true },
|
||||
{ key: 'net', label: 'Net', numeric: true },
|
||||
]}
|
||||
rows={props.docs.map((d) => ({
|
||||
nature: d.natureOfDocument, from: d.fromDocNo, to: d.toDocNo,
|
||||
total: String(d.totalNumber), cancel: String(d.cancelled), net: String(d.net),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Gstr1Page() {
|
||||
const [period, setPeriod] = useState(thisMonth())
|
||||
const { data, error } = useData(() => getGstr1(period), [period])
|
||||
const [jsonErr, setJsonErr] = useState<string | undefined>()
|
||||
const download = (): void => {
|
||||
setJsonErr(undefined)
|
||||
getGstr1Json(period).then((obj) => downloadJson(obj, `GSTR1-${period}.json`)).catch((e: Error) => setJsonErr(e.message))
|
||||
}
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title="GSTR-1" badge="LIVE"
|
||||
desc="Outward supplies for the month — B2B, B2C, credit notes and HSN — folded live from the counters' immutable documents. A return reduces liability at its original bill-date rate."
|
||||
actions={<Button tone="primary" onClick={download}>Download JSON</Button>}
|
||||
/>
|
||||
<Toolbar>
|
||||
<MonthInput period={period} onChange={setPeriod} />
|
||||
<Badge tone="accent">Period {period}</Badge>
|
||||
{data !== undefined && <Badge>{data.counts.saleDocs} invoices · {data.counts.returnDocs} credit notes</Badge>}
|
||||
</Toolbar>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{jsonErr !== undefined && <Notice tone="warn">{jsonErr}</Notice>}
|
||||
{data === undefined && error === undefined ? <EmptyState>Loading…</EmptyState> : data !== undefined && (
|
||||
<>
|
||||
<ReconBanner recon={data.reconciliation} />
|
||||
<Stats>
|
||||
<StatCard label="Net taxable value" value={formatINR(data.totals.net.taxablePaise)} hint="net of credit notes" />
|
||||
<StatCard label="Net tax" value={formatINR(taxOf(data.totals.net))} hint="IGST + CGST + SGST + cess" />
|
||||
<StatCard label="B2B · B2CS · B2CL" value={`${data.counts.b2bInvoices} · ${data.b2cs.length} · ${data.counts.b2clInvoices}`} hint={`${data.counts.b2bParties} registered buyers`} />
|
||||
</Stats>
|
||||
<SectionSummary totals={data.totals} />
|
||||
{data.b2b.length > 0 && <B2BTable groups={data.b2b} />}
|
||||
{data.b2cs.length > 0 && <B2CSTable buckets={data.b2cs} />}
|
||||
{data.b2cl.length > 0 && <B2CLTable invoices={data.b2cl} />}
|
||||
{data.cdnr.length > 0 && <CDNTable title="CDNR — credit notes to registered buyers (Table 9B)" notes={data.cdnr} showCtin />}
|
||||
{data.cdnur.length > 0 && <CDNTable title="CDNUR — credit notes, unregistered inter-state large (Table 9B)" notes={data.cdnur} />}
|
||||
<HsnTable rows={data.hsn} totals={data.totals.hsn} />
|
||||
<DocsTable docs={data.docs} />
|
||||
<NotCertified />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Gstr3bPage() {
|
||||
const [period, setPeriod] = useState(thisMonth())
|
||||
const { data, error } = useData(() => getGstr3b(period), [period])
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title="GSTR-3B" badge="LIVE"
|
||||
desc="Summary return — Table 3.1(a) outward taxable supplies, net of credit notes at their original rates. ITC / inward (Table 4) is a follow-up."
|
||||
/>
|
||||
<Toolbar>
|
||||
<MonthInput period={period} onChange={setPeriod} />
|
||||
<Badge tone="accent">Period {period}</Badge>
|
||||
</Toolbar>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{data === undefined && error === undefined ? <EmptyState>Loading…</EmptyState> : data !== undefined && (
|
||||
<>
|
||||
<ReconBanner recon={data.reconciliation} />
|
||||
<Stats>
|
||||
<StatCard label="Total taxable value" value={formatINR(data.outward.taxablePaise)} hint="3.1(a), net of returns" />
|
||||
<StatCard label="Total tax" value={formatINR(taxOf(data.outward))} hint="IGST + CGST + SGST + cess" />
|
||||
<StatCard label="Net liability" value={formatINR(taxOf(data.outward))} hint="before ITC set-off (Table 4 — follow-up)" />
|
||||
</Stats>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>3.1 Details of outward supplies</h3>
|
||||
<DataTable
|
||||
columns={[{ key: 'nature', label: 'Nature of supply' }, ...AMOUNT_COLS]}
|
||||
rows={[{ nature: '(a) Outward taxable supplies (other than zero-rated, nil, exempt)', ...amountCells(data.outward) }]}
|
||||
/>
|
||||
<h3 style={{ margin: '18px 0 8px' }}>How 3.1(a) is derived</h3>
|
||||
<DataTable
|
||||
columns={[{ key: 'step', label: '' }, ...AMOUNT_COLS]}
|
||||
rows={[
|
||||
{ step: 'Gross outward (sales)', ...amountCells(data.salesTax) },
|
||||
{ step: 'Less: credit notes (returns)', ...amountCells({ taxablePaise: -data.returnsTax.taxablePaise, igstPaise: -data.returnsTax.igstPaise, cgstPaise: -data.returnsTax.cgstPaise, sgstPaise: -data.returnsTax.sgstPaise, cessPaise: -data.returnsTax.cessPaise }) },
|
||||
{ step: '= Net outward (3.1a)', ...amountCells(data.outward) },
|
||||
]}
|
||||
/>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Notice tone="warn">{data.itc.note}</Notice>
|
||||
</div>
|
||||
<NotCertified />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HsnPage() {
|
||||
const [period, setPeriod] = useState(thisMonth())
|
||||
const { data, error } = useData(() => getGstHsn(period), [period])
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title="HSN Summary" badge="LIVE"
|
||||
desc="HSN-wise outward summary (GSTR-1 Table 12), net of returns. Quantity and amounts are derived from the period's immutable document lines."
|
||||
/>
|
||||
<Toolbar>
|
||||
<MonthInput period={period} onChange={setPeriod} />
|
||||
<Badge tone="accent">Period {period}</Badge>
|
||||
{data !== undefined && <Badge>{data.rows.length} HSN rows</Badge>}
|
||||
</Toolbar>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{data === undefined && error === undefined ? <EmptyState>Loading…</EmptyState> : data !== undefined && (
|
||||
<>
|
||||
<ReconBanner recon={data.reconciliation} />
|
||||
<Stats>
|
||||
<StatCard label="Taxable value" value={formatINR(data.totals.taxablePaise)} hint="all HSN, net of returns" />
|
||||
<StatCard label="Total tax" value={formatINR(taxOf(data.totals))} hint="IGST + CGST + SGST + cess" />
|
||||
</Stats>
|
||||
{data.rows.length === 0 ? <EmptyState>No outward supply in {period}.</EmptyState>
|
||||
: <HsnTable rows={data.rows} totals={data.totals} />}
|
||||
<NotCertified />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SiMS POS app-shell cache (S4 deliverable 4). Dead-simple cache-first for
|
||||
* same-origin GET so /pos/ still loads when the store-server is down.
|
||||
*
|
||||
* Deliberate limitations (documented, not bugs):
|
||||
* - The data API is NEVER cached: any /api/* request goes straight to the network
|
||||
* and is allowed to fail when the server is down, so the app's offline queue and
|
||||
* the health-ping drain logic stay in control (a cached /api/health would break
|
||||
* the drain). Only GET is handled; bill POSTs pass through untouched.
|
||||
* - Hashed assets are cached on first fetch (runtime), not precached at build time
|
||||
* — the first load must happen online. The navigation root is best-effort
|
||||
* precached on install.
|
||||
* - Cache-first means a new deploy is picked up only after CACHE is bumped below;
|
||||
* for a counter that values offline resilience over instant updates this is the
|
||||
* right trade, and hashed asset names make stale bundles self-correcting.
|
||||
*/
|
||||
const CACHE = 'sims-pos-shell-v1'
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((c) => c.addAll(['./', './index.html']).catch(() => undefined)),
|
||||
)
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))),
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request
|
||||
if (req.method !== 'GET') return
|
||||
const url = new URL(req.url)
|
||||
if (url.origin !== self.location.origin) return
|
||||
if (url.pathname.startsWith('/api/')) return // never cache the data/print/health API
|
||||
|
||||
event.respondWith(
|
||||
caches.match(req).then((hit) => {
|
||||
if (hit !== undefined) return hit
|
||||
return fetch(req)
|
||||
.then((res) => {
|
||||
if (res.ok && res.type === 'basic') {
|
||||
const copy = res.clone()
|
||||
void caches.open(CACHE).then((c) => c.put(req, copy))
|
||||
}
|
||||
return res
|
||||
})
|
||||
.catch(() => hit ?? Response.error())
|
||||
}),
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,211 @@
|
||||
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>)
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BillTotals } from '@sims/domain'
|
||||
import type { LineInput } from '@sims/billing-engine'
|
||||
import { buildQueuedBill, nextProvisionalToken, toOfflineBillBody, type QueuedBill } from '../src/offline'
|
||||
|
||||
const TOTALS: BillTotals = {
|
||||
grossPaise: 14_500, discountPaise: 0, taxablePaise: 12_288,
|
||||
cgstPaise: 1_106, sgstPaise: 1_106, igstPaise: 0, cessPaise: 0,
|
||||
roundOffPaise: 0, payablePaise: 14_500, savingsVsMrpPaise: 500,
|
||||
}
|
||||
const LINES: LineInput[] = [{
|
||||
itemId: 'i-103', name: 'Surf Excel 1kg', hsn: '3402', qty: 1,
|
||||
unitCode: 'PCS', unitPricePaise: 14_500, priceIncludesTax: true, taxClassCode: 'GST18',
|
||||
}]
|
||||
|
||||
describe('S4 offline queue — provisional tokens', () => {
|
||||
it('formats OFF-n from the sequence', () => {
|
||||
expect(nextProvisionalToken(1)).toBe('OFF-1')
|
||||
expect(nextProvisionalToken(42)).toBe('OFF-42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('S4 offline queue — buildQueuedBill', () => {
|
||||
const base = {
|
||||
seq: 2, storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
||||
businessDate: '2026-07-10', lines: LINES,
|
||||
payments: [{ mode: 'CASH', amountPaise: 14_500 }],
|
||||
totals: TOTALS, now: 1_700_000_000_000,
|
||||
}
|
||||
|
||||
it('assigns a client uuidv7 id, a matching provisional token, and an ISO createdAt', () => {
|
||||
const q = buildQueuedBill(base)
|
||||
expect(q.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
||||
expect(q.provisionalToken).toBe('OFF-2')
|
||||
expect(q.seq).toBe(2)
|
||||
expect(q.createdAt).toBe(new Date(1_700_000_000_000).toISOString())
|
||||
expect(q.totals.payablePaise).toBe(14_500)
|
||||
})
|
||||
|
||||
it('omits optional customer / override fields when absent', () => {
|
||||
const q = buildQueuedBill(base)
|
||||
expect('customerId' in q).toBe(false)
|
||||
expect('customerName' in q).toBe(false)
|
||||
expect('buyerGstin' in q).toBe(false)
|
||||
expect('overrides' in q).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps only the customer id and non-empty overrides — never customer PII (M11)', () => {
|
||||
const q = buildQueuedBill({
|
||||
...base,
|
||||
customerId: 'p1',
|
||||
overrides: [{ itemId: 'i-103', kind: 'price', before: 15_000, after: 14_500, approvalId: 'a1' }],
|
||||
})
|
||||
expect(q.customerId).toBe('p1')
|
||||
// M11: the queued record must NOT carry the buyer's name or GSTIN — the server
|
||||
// re-resolves both from customerId at drain, so no PII lands on the shared counter PC.
|
||||
expect('customerName' in q).toBe(false)
|
||||
expect('buyerGstin' in q).toBe(false)
|
||||
expect(q.overrides).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops an empty overrides array rather than persisting it', () => {
|
||||
const q = buildQueuedBill({ ...base, overrides: [] })
|
||||
expect('overrides' in q).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('S4 offline queue — toOfflineBillBody', () => {
|
||||
it('maps to the drain payload: keeps the client id, sets clientPayablePaise, drops display-only fields', () => {
|
||||
const q: QueuedBill = buildQueuedBill({
|
||||
seq: 1, storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
||||
customerId: 'p1',
|
||||
businessDate: '2026-07-10', lines: LINES,
|
||||
payments: [{ mode: 'CASH', amountPaise: 14_500 }], totals: TOTALS,
|
||||
})
|
||||
const body = toOfflineBillBody(q)
|
||||
expect(body.id).toBe(q.id)
|
||||
expect(body.clientPayablePaise).toBe(14_500)
|
||||
expect(body.customerId).toBe('p1')
|
||||
// SEC fix: cashier/tenant come from the server session, never the body
|
||||
expect('cashierId' in body).toBe(false)
|
||||
// M11: no customer PII is stored offline, so none can travel in the drain body
|
||||
expect('customerName' in body).toBe(false)
|
||||
expect('buyerGstin' in body).toBe(false)
|
||||
expect('totals' in body).toBe(false)
|
||||
expect('provisionalToken' in body).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,6 @@
|
||||
// Dev launcher: enables the demo seed (M10) then runs the built server. Production must NOT use
|
||||
// this — it runs `node dist/server.cjs` directly with SIMS_SEED_DEMO unset, so a real empty DB is
|
||||
// never auto-seeded with the known-credential demo owner. Pure Node so it works on Windows + POSIX
|
||||
// without a cross-env dependency. An explicit SIMS_SEED_DEMO (e.g. '0') is respected.
|
||||
process.env.SIMS_SEED_DEMO = process.env.SIMS_SEED_DEMO ?? '1'
|
||||
await import('./dist/server.cjs')
|
||||
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* /api/print SSRF hardening (red-team H1). The print relay opens a raw TCP socket to a
|
||||
* caller-named host:port and writes caller-supplied bytes — an authenticated cashier could
|
||||
* turn it into an internal port scanner or an egress channel to any public host. These pure
|
||||
* guards constrain the destination to a LAN printer:
|
||||
* (a) the target IP must be private/loopback (127/8, 10/8, 172.16/12, 192.168/16) —
|
||||
* public IPs and link-local (169.254/16) are rejected; a hostname is resolved by the
|
||||
* caller (server.ts) and every resolved address is run through `isLanIPv4` here;
|
||||
* (b) the port must be on a small allowlist (ESC/POS raw ports, env-overridable);
|
||||
* (c) the payload is capped so the socket can't be used to stream unbounded data.
|
||||
* No new deps — all of this is a few string/number checks.
|
||||
*/
|
||||
|
||||
/** Max ESC/POS payload accepted by /api/print (64 KiB — a receipt is a few KB). */
|
||||
export const MAX_PRINT_BYTES = 64 * 1024
|
||||
|
||||
/** ESC/POS raw-printing ports allowed by default (JetDirect 9100 and its two siblings). */
|
||||
export const DEFAULT_PRINTER_PORTS: readonly number[] = [9100, 9101, 9102]
|
||||
|
||||
export interface PrintGuardOk { ok: true }
|
||||
export interface PrintGuardErr { ok: false; status: number; code: string; error: string }
|
||||
export type PrintGuardResult = PrintGuardOk | PrintGuardErr
|
||||
|
||||
const ok: PrintGuardOk = { ok: true }
|
||||
const err = (code: string, error: string, status = 400): PrintGuardErr => ({ ok: false, status, code, error })
|
||||
|
||||
/**
|
||||
* Parse SIMS_PRINTER_PORTS ("9100,9101,9200") into an allowlist, falling back to the
|
||||
* default set when unset/empty/garbage. Only 1..65535 integers survive.
|
||||
*/
|
||||
export function parsePrinterPorts(raw: string | undefined): Set<number> {
|
||||
if (raw === undefined || raw.trim() === '') return new Set(DEFAULT_PRINTER_PORTS)
|
||||
const ports = raw.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 65535)
|
||||
return ports.length > 0 ? new Set(ports) : new Set(DEFAULT_PRINTER_PORTS)
|
||||
}
|
||||
|
||||
/** True only for a dotted-quad IPv4 literal (no hostnames, no IPv6). */
|
||||
function parseIPv4(ip: string): [number, number, number, number] | undefined {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
|
||||
if (m === null) return undefined
|
||||
const parts = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])] as [number, number, number, number]
|
||||
return parts.every((n) => n >= 0 && n <= 255) ? parts : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `ip` is a private/loopback IPv4 address a store printer can legitimately have:
|
||||
* 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. Everything else — public
|
||||
* addresses, link-local 169.254/16, 0.0.0.0, broadcast — is rejected. IPv6 loopback ::1
|
||||
* is treated as LAN by the caller; every other non-IPv4 string is off-LAN here.
|
||||
*/
|
||||
export function isLanIPv4(ip: string): boolean {
|
||||
const p = parseIPv4(ip)
|
||||
if (p === undefined) return false
|
||||
const [a, b] = p
|
||||
if (a === 127) return true // loopback
|
||||
if (a === 10) return true // private class A
|
||||
if (a === 172 && b >= 16 && b <= 31) return true // private class B
|
||||
if (a === 192 && b === 168) return true // private class C
|
||||
return false // public / link-local / everything else
|
||||
}
|
||||
|
||||
/** ::1 is loopback; a bracketless IPv6 loopback string also passes. Nothing else IPv6 is LAN. */
|
||||
export function isLanAddress(ip: string): boolean {
|
||||
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true
|
||||
return isLanIPv4(ip)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the parts of a print request that need no DNS: payload size and destination port.
|
||||
* Host reachability is checked separately (an IP literal via `isLanAddress`, a hostname via
|
||||
* DNS resolution in the route). Returns the first violation as a safe { status, code, error }.
|
||||
*/
|
||||
export function checkPrintEnvelope(bytesLen: number, port: number, allowedPorts: ReadonlySet<number>): PrintGuardResult {
|
||||
if (!Number.isInteger(bytesLen) || bytesLen < 0) return err('E-2201', 'Bad print payload')
|
||||
if (bytesLen > MAX_PRINT_BYTES) return err('E-2201', `Print payload too large (max ${MAX_PRINT_BYTES} bytes)`)
|
||||
if (!Number.isInteger(port) || !allowedPorts.has(port)) return err('E-2202', 'Printer port not allowed')
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Validate a resolved IP as a permitted LAN printer address. */
|
||||
export function checkPrintAddress(ip: string): PrintGuardResult {
|
||||
return isLanAddress(ip) ? ok : err('E-2203', 'Printer host is not a private/LAN address')
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* A tiny in-memory sliding-window rate limiter (red-team M2/M4 rt-F7). It layers on top of
|
||||
* the existing per-user scrypt lockout to blunt *parallel* credential grinding across the
|
||||
* roster from one source: the lockout stops N failures against ONE user, this stops a flood
|
||||
* of attempts from one IP (or globally) regardless of which user each one targets. Pure and
|
||||
* clock-injectable so it unit-tests without timers; no new deps.
|
||||
*/
|
||||
export interface RateLimitPolicy {
|
||||
windowMs: number
|
||||
max: number
|
||||
}
|
||||
|
||||
export interface RateLimiter {
|
||||
/** Record an attempt for `key`; returns true if it is within the limit, false if it exceeds it. */
|
||||
hit(key: string, now?: number): boolean
|
||||
/** Test/inspection helper: attempts remaining in the current window for `key`. */
|
||||
remaining(key: string, now?: number): number
|
||||
reset(): void
|
||||
}
|
||||
|
||||
export function createRateLimiter(policy: RateLimitPolicy): RateLimiter {
|
||||
const hits = new Map<string, number[]>()
|
||||
const prune = (arr: number[], now: number): number[] => arr.filter((t) => now - t < policy.windowMs)
|
||||
return {
|
||||
hit(key, now = Date.now()) {
|
||||
const arr = prune(hits.get(key) ?? [], now)
|
||||
if (arr.length >= policy.max) {
|
||||
hits.set(key, arr) // keep the pruned window; do NOT count the rejected attempt
|
||||
return false
|
||||
}
|
||||
arr.push(now)
|
||||
hits.set(key, arr)
|
||||
return true
|
||||
},
|
||||
remaining(key, now = Date.now()) {
|
||||
const arr = prune(hits.get(key) ?? [], now)
|
||||
return Math.max(0, policy.max - arr.length)
|
||||
},
|
||||
reset() { hits.clear() },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default auth throttle: 20 attempts/min/IP with a 200/min global ceiling. Comfortably above
|
||||
* an honest counter's login + in-flow supervisor-PIN rate, far below what a scripted roster
|
||||
* grind needs. Env-overridable (SIMS_AUTH_RATE_PER_MIN / SIMS_AUTH_RATE_GLOBAL_PER_MIN).
|
||||
*/
|
||||
export const DEFAULT_AUTH_RATE_PER_IP: RateLimitPolicy = { windowMs: 60_000, max: 20 }
|
||||
export const DEFAULT_AUTH_RATE_GLOBAL: RateLimitPolicy = { windowMs: 60_000, max: 200 }
|
||||
|
||||
export function authRatePerIpFromEnv(env: NodeJS.ProcessEnv = process.env): RateLimitPolicy {
|
||||
const max = Number(env['SIMS_AUTH_RATE_PER_MIN'])
|
||||
return { windowMs: 60_000, max: Number.isFinite(max) && max > 0 ? Math.floor(max) : DEFAULT_AUTH_RATE_PER_IP.max }
|
||||
}
|
||||
|
||||
export function authRateGlobalFromEnv(env: NodeJS.ProcessEnv = process.env): RateLimitPolicy {
|
||||
const max = Number(env['SIMS_AUTH_RATE_GLOBAL_PER_MIN'])
|
||||
return { windowMs: 60_000, max: Number.isFinite(max) && max > 0 ? Math.floor(max) : DEFAULT_AUTH_RATE_GLOBAL.max }
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
import {
|
||||
B2CL_THRESHOLD_PAISE, computeGstr1, computeGstr3b, reconcile, taxOf,
|
||||
type GstDocLine, type GstDocument, type Gstr1, type Gstr3b, type Reconciliation,
|
||||
} from '@sims/billing-engine'
|
||||
import type { DB } from './db'
|
||||
|
||||
/**
|
||||
* GST returns — the READ-ONLY reporting repo (COMPLIANCE-CRITICAL). It reads the period's
|
||||
* IMMUTABLE outward documents (SALE + SALE_RETURN) straight from the `bill` table and folds them
|
||||
* through the shared pure engine (@sims/billing-engine/gst-returns). It writes nothing and
|
||||
* recomputes no tax — every amount is the one captured on the document (R5/R6, GST-2/GST-3).
|
||||
*
|
||||
* Not certified filing: the JSON export is a pragmatic approximation of the GSTN GSTR-1 schema for
|
||||
* a CA / GSP to consume, NOT a signed return. Real e-filing goes via a GSP (doc 17 §S2), deferred.
|
||||
*/
|
||||
|
||||
interface GstBillRow {
|
||||
doc_type: string
|
||||
doc_no: string
|
||||
business_date: string
|
||||
buyer_gstin: string | null
|
||||
place_of_supply: string | null
|
||||
payable_paise: number
|
||||
store_state: string
|
||||
customer_name: string | null
|
||||
original_doc_no: string | null
|
||||
payload: string
|
||||
}
|
||||
|
||||
interface StoredLine {
|
||||
hsn?: string; qty?: number; unitCode?: string
|
||||
taxablePaise?: number; cgstPaise?: number; sgstPaise?: number
|
||||
igstPaise?: number; cessPaise?: number; taxRateBp?: number
|
||||
}
|
||||
|
||||
/** [start, end) ISO-date bounds for a 'YYYY-MM' period (returns are filed by calendar month). */
|
||||
export function periodBounds(period: string): { start: string; end: string } {
|
||||
const y = Number(period.slice(0, 4))
|
||||
const m = Number(period.slice(5, 7))
|
||||
const nextY = m === 12 ? y + 1 : y
|
||||
const nextM = m === 12 ? 1 : m + 1
|
||||
return {
|
||||
start: `${period}-01`,
|
||||
end: `${String(nextY).padStart(4, '0')}-${String(nextM).padStart(2, '0')}-01`,
|
||||
}
|
||||
}
|
||||
|
||||
/** The B2CL threshold as a dated tenant setting (R9/R10); default ₹2,50,000 if no row. */
|
||||
export function resolveB2clThresholdPaise(db: DB, tenantId: string): number {
|
||||
const row = db.prepare(
|
||||
`SELECT value FROM setting WHERE key='gst.b2clThresholdPaise'
|
||||
AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
|
||||
ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
|
||||
).get(tenantId) as { value: string } | undefined
|
||||
const n = row !== undefined ? Number(row.value) : NaN
|
||||
return Number.isFinite(n) && n > 0 ? n : B2CL_THRESHOLD_PAISE
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the period's outward documents as engine `GstDocument[]`. Each bill carries its own store
|
||||
* state (so intra/inter is decided per document) and its snapshotted buyer GSTIN + place of supply.
|
||||
*/
|
||||
export function loadGstDocuments(
|
||||
db: DB, tenantId: string, period: string,
|
||||
): { docs: GstDocument[]; storeStateCode: string } {
|
||||
const { start, end } = periodBounds(period)
|
||||
const rows = db.prepare(
|
||||
`SELECT b.doc_type, b.doc_no, b.business_date, b.buyer_gstin, b.place_of_supply,
|
||||
b.payable_paise, s.state_code AS store_state, p.name AS customer_name,
|
||||
o.doc_no AS original_doc_no, b.payload
|
||||
FROM bill b
|
||||
JOIN store s ON s.id = b.store_id
|
||||
LEFT JOIN party p ON p.id = b.customer_id
|
||||
LEFT JOIN bill o ON o.id = b.ref_doc_id
|
||||
WHERE b.tenant_id = ? AND b.doc_type IN ('SALE','SALE_RETURN')
|
||||
AND b.business_date >= ? AND b.business_date < ?
|
||||
ORDER BY b.business_date, b.doc_no`,
|
||||
).all(tenantId, start, end) as GstBillRow[]
|
||||
|
||||
const storeStateRow = db.prepare(
|
||||
`SELECT state_code FROM store WHERE tenant_id=? ORDER BY code LIMIT 1`,
|
||||
).get(tenantId) as { state_code: string } | undefined
|
||||
const storeStateCode = storeStateRow?.state_code ?? '00'
|
||||
|
||||
const docs: GstDocument[] = rows.map((r) => {
|
||||
let lines: GstDocLine[] = []
|
||||
try {
|
||||
const payload = JSON.parse(r.payload) as { lines?: StoredLine[] }
|
||||
lines = (payload.lines ?? []).map((l) => ({
|
||||
hsn: typeof l.hsn === 'string' && l.hsn !== '' ? l.hsn : 'UNCLASSIFIED',
|
||||
qty: Number(l.qty ?? 0),
|
||||
unitCode: typeof l.unitCode === 'string' && l.unitCode !== '' ? l.unitCode : 'NA',
|
||||
taxablePaise: Number(l.taxablePaise ?? 0),
|
||||
cgstPaise: Number(l.cgstPaise ?? 0),
|
||||
sgstPaise: Number(l.sgstPaise ?? 0),
|
||||
igstPaise: Number(l.igstPaise ?? 0),
|
||||
cessPaise: Number(l.cessPaise ?? 0),
|
||||
taxRateBp: Number(l.taxRateBp ?? 0),
|
||||
}))
|
||||
} catch { lines = [] }
|
||||
return {
|
||||
docType: r.doc_type === 'SALE_RETURN' ? 'SALE_RETURN' : 'SALE',
|
||||
docNo: r.doc_no,
|
||||
businessDate: r.business_date,
|
||||
...(r.buyer_gstin !== null && r.buyer_gstin !== '' ? { buyerGstin: r.buyer_gstin } : {}),
|
||||
placeOfSupply: r.place_of_supply !== null && r.place_of_supply !== '' ? r.place_of_supply : r.store_state,
|
||||
supplyStateCode: r.store_state,
|
||||
...(r.customer_name !== null ? { customerName: r.customer_name } : {}),
|
||||
invoiceValuePaise: Number(r.payable_paise),
|
||||
...(r.original_doc_no !== null ? { originalDocNo: r.original_doc_no } : {}),
|
||||
lines,
|
||||
}
|
||||
})
|
||||
return { docs, storeStateCode }
|
||||
}
|
||||
|
||||
export interface Gstr1Report extends Gstr1 {
|
||||
period: string
|
||||
storeStateCode: string
|
||||
reconciliation: Reconciliation
|
||||
}
|
||||
|
||||
/** GSTR-1 for a period: all sections + the reconciliation proof. */
|
||||
export function gstr1(db: DB, tenantId: string, period: string): Gstr1Report {
|
||||
const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
|
||||
const opts = { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) }
|
||||
const g1 = computeGstr1(docs, opts)
|
||||
const g3 = computeGstr3b(docs)
|
||||
return { period, storeStateCode, ...g1, reconciliation: reconcile(docs, g1, g3) }
|
||||
}
|
||||
|
||||
export interface Gstr3bReport extends Gstr3b {
|
||||
period: string
|
||||
storeStateCode: string
|
||||
reconciliation: Reconciliation
|
||||
}
|
||||
|
||||
/** GSTR-3B 3.1(a) for a period + the same reconciliation proof. */
|
||||
export function gstr3b(db: DB, tenantId: string, period: string): Gstr3bReport {
|
||||
const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
|
||||
const opts = { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) }
|
||||
const g1 = computeGstr1(docs, opts)
|
||||
const g3 = computeGstr3b(docs)
|
||||
return { period, storeStateCode, ...g3, reconciliation: reconcile(docs, g1, g3) }
|
||||
}
|
||||
|
||||
/** HSN summary projection (GSTR-1 Table 12) for the dedicated /gst/hsn page. */
|
||||
export function hsnSummary(db: DB, tenantId: string, period: string): {
|
||||
period: string; storeStateCode: string; rows: Gstr1['hsn']; totals: Gstr1['totals']['hsn']; reconciliation: Reconciliation
|
||||
} {
|
||||
const rep = gstr1(db, tenantId, period)
|
||||
return { period, storeStateCode: rep.storeStateCode, rows: rep.hsn, totals: rep.totals.hsn, reconciliation: rep.reconciliation }
|
||||
}
|
||||
|
||||
// ---------- GST-portal-shaped JSON (pragmatic approximation, NOT a certified filing) ----------
|
||||
|
||||
interface JsonItm { num: number; itm_det: { rt: number; txval: number; iamt: number; camt: number; samt: number; csamt: number } }
|
||||
interface RateAcc { txval: number; iamt: number; camt: number; samt: number; csamt: number }
|
||||
|
||||
/**
|
||||
* A best-effort GSTN GSTR-1 JSON. Amounts are in RUPEES with 2 decimals (the portal convention),
|
||||
* derived once from the integer-paise ledger at the edge — the internal math stays in paise (R4).
|
||||
* Schema keys mirror the offline-tool format (b2b/b2cl/b2cs/cdnr/cdnur/hsn/doc_issue) but this is
|
||||
* an APPROXIMATION for a CA / GSP to import and validate, not a signed return.
|
||||
*/
|
||||
export function gstr1PortalJson(db: DB, tenantId: string, period: string): Record<string, unknown> {
|
||||
const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
|
||||
const g1 = computeGstr1(docs, { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) })
|
||||
const tenant = db.prepare(`SELECT gstin FROM tenant WHERE id=?`).get(tenantId) as { gstin: string | null } | undefined
|
||||
|
||||
const rs = (paise: number): number => Number((paise / 100).toFixed(2))
|
||||
const rt = (bp: number): number => bp / 100
|
||||
const idt = (iso: string): string => `${iso.slice(8, 10)}-${iso.slice(5, 7)}-${iso.slice(0, 4)}`
|
||||
const fp = `${period.slice(5, 7)}${period.slice(0, 4)}`
|
||||
|
||||
const ratesToItms = (m: Map<number, RateAcc>): JsonItm[] =>
|
||||
[...m.entries()].sort((a, b) => a[0] - b[0]).map(([bp, a], i) => ({
|
||||
num: i + 1,
|
||||
itm_det: { rt: rt(bp), txval: rs(a.txval), iamt: rs(a.iamt), camt: rs(a.camt), samt: rs(a.samt), csamt: rs(a.csamt) },
|
||||
}))
|
||||
const accLines = (d: GstDocument): Map<number, RateAcc> => {
|
||||
const m = new Map<number, RateAcc>()
|
||||
for (const l of d.lines) {
|
||||
let a = m.get(l.taxRateBp)
|
||||
if (a === undefined) { a = { txval: 0, iamt: 0, camt: 0, samt: 0, csamt: 0 }; m.set(l.taxRateBp, a) }
|
||||
a.txval += l.taxablePaise; a.iamt += l.igstPaise; a.camt += l.cgstPaise; a.samt += l.sgstPaise; a.csamt += l.cessPaise
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// b2b: per counterparty GSTIN → per invoice → rate-wise items (built from raw docs).
|
||||
const b2bByCtin = new Map<string, Record<string, unknown>[]>()
|
||||
for (const d of docs) {
|
||||
if (d.docType !== 'SALE' || d.buyerGstin === undefined) continue
|
||||
const inv = { inum: d.docNo, idt: idt(d.businessDate), val: rs(d.invoiceValuePaise), pos: d.placeOfSupply, rchrg: 'N', inv_typ: 'R', itms: ratesToItms(accLines(d)) }
|
||||
const arr = b2bByCtin.get(d.buyerGstin) ?? []
|
||||
arr.push(inv)
|
||||
b2bByCtin.set(d.buyerGstin, arr)
|
||||
}
|
||||
const b2b = [...b2bByCtin.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([ctin, inv]) => ({ ctin, inv }))
|
||||
|
||||
// b2cl: inter-state B2C invoices above threshold, grouped by place of supply.
|
||||
const b2clByPos = new Map<string, Record<string, unknown>[]>()
|
||||
for (const d of docs) {
|
||||
if (d.docType !== 'SALE' || d.buyerGstin !== undefined) continue
|
||||
if (!g1.b2cl.some((i) => i.docNo === d.docNo)) continue
|
||||
const inv = { inum: d.docNo, idt: idt(d.businessDate), val: rs(d.invoiceValuePaise), itms: ratesToItms(accLines(d)) }
|
||||
const arr = b2clByPos.get(d.placeOfSupply) ?? []
|
||||
arr.push(inv)
|
||||
b2clByPos.set(d.placeOfSupply, arr)
|
||||
}
|
||||
const b2cl = [...b2clByPos.entries()].map(([pos, inv]) => ({ pos, inv }))
|
||||
|
||||
// b2cs: aggregated by supply type + place of supply + rate.
|
||||
const b2cs = g1.b2cs.map((b) => ({
|
||||
sply_ty: b.supplyType, pos: b.pos, typ: 'OE', rt: rt(b.rateBp),
|
||||
txval: rs(b.taxablePaise), iamt: rs(b.igstPaise), camt: rs(b.cgstPaise), samt: rs(b.sgstPaise), csamt: rs(b.cessPaise),
|
||||
}))
|
||||
|
||||
// cdnr: registered credit notes, grouped by counterparty GSTIN.
|
||||
const cdnrByCtin = new Map<string, Record<string, unknown>[]>()
|
||||
for (const n of g1.cdnr) {
|
||||
const rateMap = new Map<number, RateAcc>()
|
||||
for (const r of n.rates) rateMap.set(r.rateBp, { txval: r.taxablePaise, iamt: r.igstPaise, camt: r.cgstPaise, samt: r.sgstPaise, csamt: r.cessPaise })
|
||||
const nt = { ntty: 'C', nt_num: n.noteDocNo, nt_dt: idt(n.noteDate), val: rs(n.noteValuePaise), itms: ratesToItms(rateMap) }
|
||||
const ctin = n.ctin ?? ''
|
||||
const arr = cdnrByCtin.get(ctin) ?? []
|
||||
arr.push(nt)
|
||||
cdnrByCtin.set(ctin, arr)
|
||||
}
|
||||
const cdnr = [...cdnrByCtin.entries()].map(([ctin, nt]) => ({ ctin, nt }))
|
||||
|
||||
// cdnur: unregistered inter-state large credit notes.
|
||||
const cdnur = g1.cdnur.map((n) => {
|
||||
const rateMap = new Map<number, RateAcc>()
|
||||
for (const r of n.rates) rateMap.set(r.rateBp, { txval: r.taxablePaise, iamt: r.igstPaise, camt: r.cgstPaise, samt: r.sgstPaise, csamt: r.cessPaise })
|
||||
return { typ: 'B2CL', ntty: 'C', nt_num: n.noteDocNo, nt_dt: idt(n.noteDate), pos: n.pos, val: rs(n.noteValuePaise), itms: ratesToItms(rateMap) }
|
||||
})
|
||||
|
||||
// hsn: Table 12 data rows.
|
||||
const hsn = {
|
||||
data: g1.hsn.map((h, i) => ({
|
||||
num: i + 1, hsn_sc: h.hsn, uqc: h.uqc, qty: Number(h.qty.toFixed(3)), rt: rt(h.rateBp),
|
||||
txval: rs(h.taxablePaise), iamt: rs(h.igstPaise), camt: rs(h.cgstPaise), samt: rs(h.sgstPaise), csamt: rs(h.cessPaise),
|
||||
})),
|
||||
}
|
||||
|
||||
// doc_issue: Table 13 document ranges.
|
||||
const doc_issue = {
|
||||
doc_det: g1.docs.map((d, i) => ({
|
||||
doc_num: i + 1,
|
||||
docs: [{ num: 1, from: d.fromDocNo, to: d.toDocNo, totnum: d.totalNumber, cancel: d.cancelled, net_issue: d.net }],
|
||||
})),
|
||||
}
|
||||
|
||||
return {
|
||||
_note: 'Pragmatic approximation of the GSTN GSTR-1 offline-tool JSON — for CA / GSP import and review, NOT a certified/signed filing. Amounts in rupees. Real e-filing rides a GSP (doc 17 §S2).',
|
||||
gstin: tenant?.gstin ?? '',
|
||||
fp,
|
||||
gt: rs(taxOf(g1.totals.net) + g1.totals.net.taxablePaise),
|
||||
b2b, b2cl, b2cs, cdnr, cdnur, hsn, doc_issue,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,307 @@
|
||||
import {
|
||||
CREDIT_NOTE_SEQ_WIDTH, creditNotePrefix, formatDocNo, fyOf, uuidv7, type BillLine, type BillTotals,
|
||||
} from '@sims/domain'
|
||||
import { computeReturn, type ReturnLineRequest } from '@sims/billing-engine'
|
||||
import type { DB } from './db'
|
||||
import { PAYMENT_MODES, assertBusinessDate } from './validate'
|
||||
import { writeAudit } from './repos'
|
||||
|
||||
/**
|
||||
* Returns / credit notes — the money/commit half of the SALE_RETURN document. This rides the
|
||||
* same trust boundary the red-team hardened (docs/18): it anchors EVERYTHING to OUR stored
|
||||
* original bill, never the client. Prices, tax rates and the CGST/SGST/IGST split come from the
|
||||
* original bill's payload, so a return reverses tax at the rate on the bill (R5/R10). The refund
|
||||
* is computed server-side and re-verified against any client-declared total (409 on mismatch,
|
||||
* mirroring commitBill). Over-return is blocked. Everything lands in ONE transaction (R17): the
|
||||
* immutable SALE_RETURN doc, a per-counter credit-note series number, stock ADDED BACK, a refund
|
||||
* leg, a dormant outbox row, and a hash-chained audit row.
|
||||
*/
|
||||
|
||||
/** Tag an error with an HTTP status + a safe client code (the error handler never leaks a stack). */
|
||||
const httpErr = (status: number, message: string, code: string): Error =>
|
||||
Object.assign(new Error(message), { status, code })
|
||||
|
||||
interface BillRow {
|
||||
id: string; tenant_id: string; store_id: string; counter_id: string
|
||||
doc_no: string; business_date: string; cashier_id: string
|
||||
customer_id: string | null; buyer_gstin: string | null; place_of_supply: string | null
|
||||
payload: string; created_at_wall: string
|
||||
}
|
||||
|
||||
interface StoredPayload {
|
||||
lines: BillLine[]
|
||||
payments?: { mode: string; amountPaise: number }[]
|
||||
totals: BillTotals
|
||||
}
|
||||
|
||||
/** Load an original SALE bill for the tenant (never cross-tenant, never a credit note), or 400. */
|
||||
function loadOriginalSale(db: DB, tenantId: string, billId: string): { row: BillRow; payload: StoredPayload } {
|
||||
const row = db.prepare(
|
||||
`SELECT * FROM bill WHERE id=? AND tenant_id=? AND doc_type='SALE'`,
|
||||
).get(billId, tenantId) as BillRow | undefined
|
||||
if (row === undefined) {
|
||||
throw httpErr(400, 'Original bill not found for this store (E-1320)', 'E-1320')
|
||||
}
|
||||
let payload: StoredPayload
|
||||
try {
|
||||
payload = JSON.parse(row.payload) as StoredPayload
|
||||
} catch {
|
||||
throw httpErr(400, 'Original bill has no stored line detail to return (E-1320)', 'E-1320')
|
||||
}
|
||||
if (!Array.isArray(payload.lines) || payload.lines.length === 0) {
|
||||
throw httpErr(400, 'Original bill has no returnable lines (E-1320)', 'E-1320')
|
||||
}
|
||||
return { row, payload }
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of each original line has ALREADY been credited by prior SALE_RETURN documents
|
||||
* referencing this bill — summed per original line index (each credit note's payload lines
|
||||
* carry `originalLineIndex`). This is the returnable-quantity ledger: returned-so-far + a new
|
||||
* return's qty must never exceed the quantity originally sold.
|
||||
*/
|
||||
function returnedByIndex(db: DB, tenantId: string, originalBillId: string): Map<number, number> {
|
||||
const rows = db.prepare(
|
||||
`SELECT payload FROM bill WHERE tenant_id=? AND ref_doc_id=? AND doc_type='SALE_RETURN'`,
|
||||
).all(tenantId, originalBillId) as { payload: string }[]
|
||||
const m = new Map<number, number>()
|
||||
for (const r of rows) {
|
||||
let p: { lines?: { originalLineIndex?: number; qty?: number }[] }
|
||||
try { p = JSON.parse(r.payload) as typeof p } catch { continue }
|
||||
for (const l of p.lines ?? []) {
|
||||
if (typeof l.originalLineIndex === 'number' && typeof l.qty === 'number') {
|
||||
m.set(l.originalLineIndex, (m.get(l.originalLineIndex) ?? 0) + l.qty)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface ReturnableBill {
|
||||
id: string; docNo: string; businessDate: string
|
||||
customerId?: string; customerName?: string
|
||||
lines: ReturnableLine[]
|
||||
/** The raw original bill lines, so the POS can run the SHARED computeReturn for a live
|
||||
* refund preview that matches the server to the paisa (R5). */
|
||||
originalLines: BillLine[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The original bill's lines annotated with sold / already-returned / still-returnable quantities
|
||||
* — drives the POS return screen and the back-office return detail. Read-only; the authoritative
|
||||
* returnable check is re-run inside commitReturn against the same ledger.
|
||||
*/
|
||||
export function returnableLines(db: DB, tenantId: string, billId: string): ReturnableBill {
|
||||
const { row, payload } = loadOriginalSale(db, tenantId, billId)
|
||||
const returned = returnedByIndex(db, tenantId, billId)
|
||||
const customer = row.customer_id !== null
|
||||
? db.prepare(`SELECT name FROM party WHERE id=? AND tenant_id=?`).get(row.customer_id, tenantId) as { name: string } | undefined
|
||||
: undefined
|
||||
const lines: ReturnableLine[] = payload.lines.map((o, i) => {
|
||||
const returnedQty = returned.get(i) ?? 0
|
||||
return {
|
||||
lineIndex: i, itemId: o.itemId, name: o.name, hsn: o.hsn, unitCode: o.unitCode,
|
||||
unitPricePaise: o.unitPricePaise, taxRateBp: o.taxRateBp, lineTotalPaise: o.lineTotalPaise,
|
||||
soldQty: o.qty, returnedQty, returnableQty: o.qty - returnedQty,
|
||||
...(o.batchId !== undefined ? { batchId: o.batchId } : {}),
|
||||
}
|
||||
})
|
||||
return {
|
||||
id: row.id, docNo: row.doc_no, businessDate: row.business_date,
|
||||
...(row.customer_id !== null ? { customerId: row.customer_id } : {}),
|
||||
...(customer !== undefined ? { customerName: customer.name } : {}),
|
||||
lines, originalLines: payload.lines,
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommitReturnInput {
|
||||
tenantId: string; storeId: string; counterId: string
|
||||
cashierId: string; shiftId: string; businessDate: string
|
||||
originalBillId: string
|
||||
lines: ReturnLineRequest[]
|
||||
reason: string
|
||||
refundMode: string
|
||||
/** If present, must equal the SERVER-computed refund or the commit is rejected 409 (E-1325). */
|
||||
clientRefundPaise?: number
|
||||
}
|
||||
|
||||
export function commitReturn(
|
||||
db: DB, input: CommitReturnInput,
|
||||
): { id: string; docNo: string; refundPaise: number } {
|
||||
assertBusinessDate(input.businessDate)
|
||||
if (!PAYMENT_MODES.has(input.refundMode)) throw httpErr(400, 'Unknown refund mode (E-1326)', 'E-1326')
|
||||
|
||||
// Tenant-scope the store/counter exactly like commitBill (M5): unknown-for-tenant → 400 E-1110.
|
||||
const store = db.prepare(`SELECT * FROM store WHERE id=? AND tenant_id=?`).get(input.storeId, input.tenantId) as { code: string } | undefined
|
||||
const counter = db.prepare(`SELECT * FROM counter WHERE id=? AND tenant_id=?`).get(input.counterId, input.tenantId) as { code: string } | undefined
|
||||
if (store === undefined || counter === undefined) {
|
||||
throw httpErr(400, 'Unknown store or counter for this tenant (E-1110)', 'E-1110')
|
||||
}
|
||||
const committer = db.prepare(`SELECT display_name FROM app_user WHERE id=? AND tenant_id=?`).get(input.cashierId, input.tenantId) as { display_name: string } | undefined
|
||||
if (committer === undefined) throw httpErr(403, 'Unknown cashier (E-6103)', 'E-6103')
|
||||
|
||||
// Anchor to OUR original bill (cross-tenant / nonexistent / non-sale → 400 E-1320).
|
||||
const { row: orig, payload } = loadOriginalSale(db, input.tenantId, input.originalBillId)
|
||||
const returned = returnedByIndex(db, input.tenantId, input.originalBillId)
|
||||
|
||||
if (!Array.isArray(input.lines) || input.lines.length === 0) {
|
||||
throw httpErr(400, 'Select at least one line to return (E-1322)', 'E-1322')
|
||||
}
|
||||
// Validate each requested line against the original: a real, unique index; a positive qty;
|
||||
// a whole quantity on a whole-unit line; and returned-so-far + this qty ≤ sold (over-return).
|
||||
const seen = new Set<number>()
|
||||
const requested: ReturnLineRequest[] = input.lines.map((r) => {
|
||||
const lineIndex = r.lineIndex
|
||||
if (!Number.isInteger(lineIndex) || lineIndex < 0 || lineIndex >= payload.lines.length) {
|
||||
throw httpErr(400, 'Return references a line that is not on the bill (E-1321)', 'E-1321')
|
||||
}
|
||||
if (seen.has(lineIndex)) throw httpErr(400, 'A line can appear only once in a return (E-1321)', 'E-1321')
|
||||
seen.add(lineIndex)
|
||||
const o = payload.lines[lineIndex]!
|
||||
const qty = r.qty
|
||||
if (typeof qty !== 'number' || !Number.isFinite(qty) || qty <= 0) {
|
||||
throw httpErr(400, `Return quantity for "${o.name}" must be positive (E-1323)`, 'E-1323')
|
||||
}
|
||||
// The original line qty tells us the unit shape: a whole-unit item was sold as an integer,
|
||||
// so it must be returned whole; a weighed (KG) line was sold fractional and may be partial.
|
||||
if (Number.isInteger(o.qty) && !Number.isInteger(qty)) {
|
||||
throw httpErr(400, `"${o.name}" is sold whole — return a whole quantity (E-1323)`, 'E-1323')
|
||||
}
|
||||
const already = returned.get(lineIndex) ?? 0
|
||||
if (already + qty > o.qty) {
|
||||
throw httpErr(400, `Cannot return ${qty} of "${o.name}" — only ${o.qty - already} left to return (E-1324)`, 'E-1324')
|
||||
}
|
||||
return { lineIndex, qty }
|
||||
})
|
||||
|
||||
// The refund is computed by the SHARED engine from the ORIGINAL bill's amounts (R5). The client
|
||||
// is never trusted for it: if it declared a total, it must match to the paisa.
|
||||
const computed = computeReturn(payload.lines, requested, { roundToRupee: true })
|
||||
if (input.clientRefundPaise !== undefined && input.clientRefundPaise !== computed.totals.payablePaise) {
|
||||
throw httpErr(409, 'Refund total does not match the original bill — refresh and retry (E-1325)', 'E-1325')
|
||||
}
|
||||
|
||||
const reason = typeof input.reason === 'string' && input.reason.trim() !== ''
|
||||
? input.reason.trim().slice(0, 60) : 'Unspecified'
|
||||
const id = uuidv7()
|
||||
const now = new Date().toISOString()
|
||||
const fy = fyOf(input.businessDate)
|
||||
const t = computed.totals
|
||||
|
||||
const txn = db.transaction(() => {
|
||||
// Per-counter, per-FY credit-note series, allocated inside the commit transaction — the same
|
||||
// gap-free, offline-safe pattern as the sale series, on its own CN-prefixed doc_type.
|
||||
let series = db.prepare(
|
||||
`SELECT prefix, next_seq FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE_RETURN' AND fy=?`,
|
||||
).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined
|
||||
if (series === undefined) {
|
||||
series = { prefix: creditNotePrefix(store.code, counter.code, fy), next_seq: 1 }
|
||||
db.prepare(
|
||||
`INSERT INTO doc_series (tenant_id, store_id, counter_id, doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 'SALE_RETURN', ?, ?, 1)`,
|
||||
).run(input.tenantId, input.storeId, input.counterId, fy, series.prefix)
|
||||
}
|
||||
const docNo = formatDocNo(series.prefix, series.next_seq, CREDIT_NOTE_SEQ_WIDTH)
|
||||
db.prepare(
|
||||
`UPDATE doc_series SET next_seq = next_seq + 1 WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE_RETURN' AND fy=?`,
|
||||
).run(input.tenantId, input.storeId, input.counterId, fy)
|
||||
|
||||
const storedPayload = JSON.stringify({
|
||||
lines: computed.lines,
|
||||
// The refund leg: the amount paid back, in the chosen tender.
|
||||
payments: [{ mode: input.refundMode, amountPaise: t.payablePaise }],
|
||||
totals: t, reason, refundMode: input.refundMode, originalDocNo: orig.doc_no,
|
||||
})
|
||||
// The immutable SALE_RETURN document: its own row, refDocId → the original bill, the buyer +
|
||||
// place-of-supply snapshotted FROM THE ORIGINAL (so the reversal is attributed to the same
|
||||
// place of supply the sale used).
|
||||
db.prepare(
|
||||
`INSERT INTO bill (id, tenant_id, store_id, counter_id, doc_type, doc_no, business_date, cashier_id, shift_id, customer_id, ref_doc_id,
|
||||
gross_paise, discount_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, cess_paise, round_off_paise, payable_paise, savings_paise,
|
||||
buyer_gstin, place_of_supply, client_id, payload, created_at_wall)
|
||||
VALUES (?, ?, ?, ?, 'SALE_RETURN', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate,
|
||||
input.cashierId, input.shiftId, orig.customer_id, input.originalBillId,
|
||||
t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise,
|
||||
t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise,
|
||||
orig.buyer_gstin, orig.place_of_supply, null, storedPayload, now,
|
||||
)
|
||||
// Stock is ADDED BACK (positive qty_delta) so on-hand derives correctly (R7); each movement
|
||||
// carries the ORIGINAL line's batch id (from our stored payload, not the client) so a
|
||||
// batch-tracked return credits the exact lot it was sold from.
|
||||
const move = db.prepare(
|
||||
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall)
|
||||
VALUES (?, ?, ?, ?, ?, 'SALE_RETURN', ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const l of computed.lines) {
|
||||
move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, l.batchId ?? null, input.businessDate, now)
|
||||
}
|
||||
// Dormant outbox row (D14/R15), same transaction.
|
||||
db.prepare(
|
||||
`INSERT INTO outbox (op_id, tenant_id, device_id, device_seq, doc_type, doc_id, envelope, created_at_wall, lamport)
|
||||
VALUES (?, ?, 'store-server', 0, 'SALE_RETURN', ?, ?, ?, 0)`,
|
||||
).run(uuidv7(), input.tenantId, id, storedPayload, now)
|
||||
// Hash-chained audit row (H2/R17), in the same transaction.
|
||||
writeAudit(db, input.tenantId, input.cashierId, 'RETURN_COMMIT', 'bill', id,
|
||||
{ originalDocNo: orig.doc_no, originalBillId: input.originalBillId },
|
||||
{ docNo, refundPaise: t.payablePaise, reason, refundMode: input.refundMode })
|
||||
return docNo
|
||||
})
|
||||
|
||||
const docNo = txn()
|
||||
return { id, docNo, refundPaise: t.payablePaise }
|
||||
}
|
||||
|
||||
/**
|
||||
* List credit notes (SALE_RETURN docs), tenant-scoped, newest-first, paginated. The original
|
||||
* sale's doc no is joined in for the "against bill" column. A caller without REPORT_VIEW is
|
||||
* pinned to their own credit notes (cashierId set) — the same scoping the sales list uses.
|
||||
*/
|
||||
export function listReturns(
|
||||
db: DB, tenantId: string, limit = 100, offset = 0, cashierId?: string,
|
||||
): Record<string, unknown>[] {
|
||||
let sql = `SELECT b.*, u.display_name AS cashier_name, p.name AS customer_name, o.doc_no AS original_doc_no
|
||||
FROM bill b
|
||||
LEFT JOIN app_user u ON u.id=b.cashier_id
|
||||
LEFT JOIN party p ON p.id=b.customer_id
|
||||
LEFT JOIN bill o ON o.id=b.ref_doc_id
|
||||
WHERE b.tenant_id=? AND b.doc_type='SALE_RETURN'`
|
||||
const args: unknown[] = [tenantId]
|
||||
if (cashierId !== undefined) { sql += ` AND b.cashier_id=?`; args.push(cashierId) }
|
||||
sql += ` ORDER BY b.created_at_wall DESC LIMIT ? OFFSET ?`
|
||||
args.push(limit, offset)
|
||||
return db.prepare(sql).all(...args) as Record<string, unknown>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find one original SALE bill by its exact doc no (the POS return lookup). Tenant-scoped and
|
||||
* SALE-only — a targeted lookup for issuing a return, not the cross-counter enumeration the
|
||||
* bills-list scoping guards against.
|
||||
*/
|
||||
export function lookupBillByDocNo(db: DB, tenantId: string, docNo: string): Record<string, unknown> | undefined {
|
||||
return db.prepare(
|
||||
`SELECT b.id, b.doc_no, b.business_date, b.payable_paise, b.customer_id,
|
||||
u.display_name AS cashier_name, p.name AS customer_name
|
||||
FROM bill b LEFT JOIN app_user u ON u.id=b.cashier_id LEFT JOIN party p ON p.id=b.customer_id
|
||||
WHERE b.tenant_id=? AND b.doc_type='SALE' AND b.doc_no=?`,
|
||||
).get(tenantId, docNo) as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
/** Recent original SALE bills for customers matching a phone (the POS phone-lookup return path). */
|
||||
export function lookupBillsByPhone(db: DB, tenantId: string, phone: string, limit = 20): Record<string, unknown>[] {
|
||||
return db.prepare(
|
||||
`SELECT b.id, b.doc_no, b.business_date, b.payable_paise,
|
||||
u.display_name AS cashier_name, p.name AS customer_name
|
||||
FROM bill b JOIN party p ON p.id=b.customer_id LEFT JOIN app_user u ON u.id=b.cashier_id
|
||||
WHERE b.tenant_id=? AND b.doc_type='SALE' AND p.phone LIKE ?
|
||||
ORDER BY b.created_at_wall DESC LIMIT ?`,
|
||||
).all(tenantId, `%${phone}%`, limit) as Record<string, unknown>[]
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Session lifetime policy (red-team M4). Sessions were valid until the process restarted —
|
||||
* a leaked bearer token never expired and a fired employee could not be cut off. Every
|
||||
* session now carries a creation time and a last-seen time; `requireAuth` evicts it once it
|
||||
* passes either bound. Pure + clock-injectable so the check unit-tests without waiting.
|
||||
*/
|
||||
export interface SessionTtl {
|
||||
/** Hard cap from login, regardless of activity. */
|
||||
absoluteMs: number
|
||||
/** Idle cap since the last authenticated request. */
|
||||
idleMs: number
|
||||
}
|
||||
|
||||
export interface SessionTiming {
|
||||
createdMs: number
|
||||
lastSeenMs: number
|
||||
}
|
||||
|
||||
/** 12-hour absolute lifetime, 30-minute idle timeout — a trading shift with a lunch gap. */
|
||||
export const DEFAULT_SESSION_TTL: SessionTtl = {
|
||||
absoluteMs: 12 * 60 * 60 * 1000,
|
||||
idleMs: 30 * 60 * 1000,
|
||||
}
|
||||
|
||||
/** True once a session has outlived its absolute lifetime OR sat idle past the idle timeout. */
|
||||
export function isSessionExpired(s: SessionTiming, now: number, ttl: SessionTtl): boolean {
|
||||
if (now - s.createdMs >= ttl.absoluteMs) return true
|
||||
if (now - s.lastSeenMs >= ttl.idleMs) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Read TTL overrides from env (ms), else the defaults. Lets prod/tests tighten without code. */
|
||||
export function sessionTtlFromEnv(env: NodeJS.ProcessEnv = process.env): SessionTtl {
|
||||
const abs = Number(env['SIMS_SESSION_ABS_MS'])
|
||||
const idle = Number(env['SIMS_SESSION_IDLE_MS'])
|
||||
return {
|
||||
absoluteMs: Number.isFinite(abs) && abs > 0 ? abs : DEFAULT_SESSION_TTL.absoluteMs,
|
||||
idleMs: Number.isFinite(idle) && idle > 0 ? idle : DEFAULT_SESSION_TTL.idleMs,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Hand-rolled request validators (SEC / red-team M6, M7, M8, M12, H6). No new runtime
|
||||
* deps by design (16-PROJECT-RULES: keep the slice lean) — small guards that each throw
|
||||
* a `{ status: 400, code }` error with a SAFE, generic message. The Express error handler
|
||||
* (server.ts) turns any un-tagged throw into a generic 500 with no stack, so a wrong type
|
||||
* can never leak an install path again (M7).
|
||||
*/
|
||||
|
||||
export class ValidationError extends Error {
|
||||
status = 400
|
||||
code: string
|
||||
constructor(message: string, code: string) {
|
||||
super(message)
|
||||
this.name = 'ValidationError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (message: string, code = 'E-1001'): never => {
|
||||
throw new ValidationError(message, code)
|
||||
}
|
||||
|
||||
export function assertString(v: unknown, field: string, code = 'E-1001'): string {
|
||||
if (typeof v !== 'string' || v === '') fail(`${field} is required`, code)
|
||||
return v as string
|
||||
}
|
||||
|
||||
export function optString(v: unknown, field: string, code = 'E-1001'): string | undefined {
|
||||
if (v === undefined || v === null) return undefined
|
||||
if (typeof v !== 'string') fail(`${field} must be text`, code)
|
||||
return v as string
|
||||
}
|
||||
|
||||
export function assertInt(v: unknown, field: string, code = 'E-1001'): number {
|
||||
if (typeof v !== 'number' || !Number.isInteger(v)) fail(`${field} must be a whole number`, code)
|
||||
return v as number
|
||||
}
|
||||
|
||||
export function assertNonNegInt(v: unknown, field: string, code = 'E-1001'): number {
|
||||
const n = assertInt(v, field, code)
|
||||
if (n < 0) fail(`${field} must not be negative`, code)
|
||||
return n
|
||||
}
|
||||
|
||||
export function assertPositiveInt(v: unknown, field: string, code = 'E-1001'): number {
|
||||
const n = assertInt(v, field, code)
|
||||
if (n <= 0) fail(`${field} must be positive`, code)
|
||||
return n
|
||||
}
|
||||
|
||||
/** A finite, positive quantity, clamped well under 2^53 so ledger math never loses precision (M8). */
|
||||
export function assertQty(v: unknown, field: string, code = 'E-1001'): number {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) fail(`${field} must be a positive number`, code)
|
||||
if ((v as number) > 1e12) fail(`${field} is implausibly large`, code)
|
||||
return v as number
|
||||
}
|
||||
|
||||
/** A paise amount: a finite integer within a sane ledger range (no NaN, no 2^53 blow-ups). */
|
||||
export function assertPaise(v: unknown, field: string, code = 'E-1001'): number {
|
||||
const n = assertInt(v, field, code)
|
||||
if (n < 0) fail(`${field} must not be negative`, code)
|
||||
if (n > 1e15) fail(`${field} is out of range`, code)
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* An ISO YYYY-MM-DD business date within a sane window (H6): a real calendar date, not
|
||||
* before 2015, not after tomorrow. Rejects "not-a-date", "2050-07-10", and NaN-FY inputs.
|
||||
*/
|
||||
export function assertBusinessDate(v: unknown, field = 'businessDate', code = 'E-1106'): string {
|
||||
const s = assertString(v, field, code)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) fail(`${field} must be an ISO date (YYYY-MM-DD)`, code)
|
||||
const t = Date.parse(`${s}T00:00:00Z`)
|
||||
if (Number.isNaN(t)) fail(`${field} is not a real date`, code)
|
||||
// Round-trip to reject impossible days like 2026-02-31 that Date.parse would roll over.
|
||||
if (new Date(t).toISOString().slice(0, 10) !== s) fail(`${field} is not a real date`, code)
|
||||
const tomorrow = Date.now() + 36 * 60 * 60 * 1000 // today + ~1 day of slack for TZ
|
||||
if (t > tomorrow) fail(`${field} is in the future`, code)
|
||||
if (t < Date.parse('2015-01-01T00:00:00Z')) fail(`${field} is implausibly old`, code)
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* A GST return period 'YYYY-MM' within a sane window (a real month, 2017-07..next month). GST
|
||||
* returns are filed monthly, so the period is the unit of a GSTR-1/3B run.
|
||||
*/
|
||||
export function assertPeriod(v: unknown, field = 'period', code = 'E-1330'): string {
|
||||
const s = assertString(v, field, code)
|
||||
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(s)) fail(`${field} must be a month (YYYY-MM)`, code)
|
||||
// Not before GST (July 2017); not more than ~one month ahead of today.
|
||||
if (s < '2017-07') fail(`${field} is before GST commenced`, code)
|
||||
const now = new Date()
|
||||
const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString().slice(0, 7)
|
||||
if (s > next) fail(`${field} is in the future`, code)
|
||||
return s
|
||||
}
|
||||
|
||||
export function assertArray<T>(v: unknown, field: string, max: number, code = 'E-1001'): T[] {
|
||||
if (!Array.isArray(v)) fail(`${field} must be a list`, code)
|
||||
const a = v as T[]
|
||||
if (a.length === 0) fail(`${field} must not be empty`, code)
|
||||
if (a.length > max) fail(`${field} has too many entries (max ${max})`, code)
|
||||
return a
|
||||
}
|
||||
|
||||
/** Clamp a client-supplied limit to [1, max] (M6/M13): NaN/negative/huge never dump a table. */
|
||||
export function clampLimit(raw: unknown, def: number, max = 1000): number {
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) return def
|
||||
return Math.min(Math.max(Math.floor(n), 1), max)
|
||||
}
|
||||
|
||||
/** Clamp a client-supplied offset to >= 0 (M6): NaN/negative floor to 0. */
|
||||
export function clampOffset(raw: unknown): number {
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || n < 0) return 0
|
||||
return Math.floor(n)
|
||||
}
|
||||
|
||||
export const PAYMENT_MODES = new Set(['CASH', 'UPI', 'CARD', 'KHATA'])
|
||||
@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { commitBill } from '../src/repos'
|
||||
|
||||
/**
|
||||
* Item-master anchoring (red-team C2, C3, H5, H7 + MRP/unit guards). commitBill is the
|
||||
* trust boundary: it rebuilds every line from OUR item row and treats any price the client
|
||||
* couldn't have gotten from the master as a deviation that needs a bound token or the
|
||||
* committer's own authority. Hermetic in-memory DB; seed items: 107 Amul Butter (master
|
||||
* sale 27500, MRP 28000, GST12, whole unit), 42 Tomato loose (KG, 3 decimals).
|
||||
*/
|
||||
function fresh(): DB {
|
||||
const db = openDb(':memory:')
|
||||
seedIfEmpty(db)
|
||||
return db
|
||||
}
|
||||
const itemId = (db: DB, code: string): string =>
|
||||
(db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
|
||||
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
|
||||
|
||||
const line = (id: string, over: Partial<{ unitPricePaise: number; taxClassCode: string; qty: number }> = {}) => ({
|
||||
itemId: id, name: 'Amul Butter 500g', hsn: '0405', qty: over.qty ?? 1, unitCode: 'PCS',
|
||||
unitPricePaise: over.unitPricePaise ?? 27_500, priceIncludesTax: true, taxClassCode: over.taxClassCode ?? 'GST12',
|
||||
})
|
||||
const bill = (cashierId: string, l: ReturnType<typeof line>, payable: number): Parameters<typeof commitBill>[1] => ({
|
||||
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId, shiftId: 'sh1', businessDate: '2026-07-10',
|
||||
lines: [l], payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
|
||||
})
|
||||
|
||||
describe('item-master anchoring', () => {
|
||||
it('commits a normal bill at the master price (happy path)', () => {
|
||||
const db = fresh()
|
||||
const out = commitBill(db, bill('u1', line(itemId(db, '107')), 27_500))
|
||||
expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9); business date 2026-07-10 → FY 2026-27
|
||||
expect(billCount(db)).toBe(1)
|
||||
})
|
||||
|
||||
it('C2: a cashier underpricing a ₹275 item to ₹1 without approval is rejected (4xx)', () => {
|
||||
const db = fresh()
|
||||
const err = (() => { try { commitBill(db, bill('u1', line(itemId(db, '107'), { unitPricePaise: 100 }), 100)); return undefined } catch (e) { return e as Error & { status?: number } } })()
|
||||
expect(err).toBeDefined()
|
||||
expect(err!.status).toBeGreaterThanOrEqual(400)
|
||||
expect(err!.status).toBeLessThan(500)
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('C3: a forged ZERO tax class is ignored — the bill is taxed at the master GST12 rate', () => {
|
||||
const db = fresh()
|
||||
// Inclusive price, so the ₹275 total is identical under ZERO or GST12; only the split
|
||||
// differs. The server anchors GST12, so the committed bill still carries CGST+SGST.
|
||||
commitBill(db, bill('u1', line(itemId(db, '107'), { taxClassCode: 'ZERO' }), 27_500))
|
||||
const b = db.prepare(`SELECT cgst_paise, sgst_paise, taxable_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number; taxable_paise: number }
|
||||
expect(b.cgst_paise + b.sgst_paise).toBeGreaterThan(0)
|
||||
expect(b.taxable_paise).toBeLessThan(27_500) // some of the sticker price is GST, not taxable base
|
||||
})
|
||||
|
||||
it('H5: a line for a non-existent item is rejected, nothing committed', () => {
|
||||
const db = fresh()
|
||||
expect(() => commitBill(db, bill('u1', line('DOES-NOT-EXIST'), 27_500))).toThrow(/E-1105|catalogue/i)
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('H7: an owner self-authorises a price change and it is audited from their identity', () => {
|
||||
const db = fresh()
|
||||
commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 25_000 }), 25_000)) // u3 = owner
|
||||
const audit = db.prepare(`SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'`).get() as { after_json: string } | undefined
|
||||
expect(audit).toBeDefined()
|
||||
const after = JSON.parse(audit!.after_json) as { approvedByUserId: string; selfAuthorized?: boolean }
|
||||
expect(after.approvedByUserId).toBe('u3')
|
||||
expect(after.selfAuthorized).toBe(true)
|
||||
expect(billCount(db)).toBe(1)
|
||||
})
|
||||
|
||||
it('hard-blocks a price above MRP even for an owner (E-1301)', () => {
|
||||
const db = fresh()
|
||||
expect(() => commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 30_000 }), 30_000))).toThrow(/E-1301|MRP/i)
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a fractional quantity on a whole-unit item (E-1104)', () => {
|
||||
const db = fresh()
|
||||
expect(() => commitBill(db, bill('u1', line(itemId(db, '107'), { qty: 1.5 }), 41_300))).toThrow(/E-1104|whole/i)
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,207 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import express from 'express'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { Server } from 'node:http'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { apiRouter } from '../src/api'
|
||||
import { commitBill, createItem } from '../src/repos'
|
||||
|
||||
/**
|
||||
* Cashier draft-item quick-add guards (red-team follow-up: the draft path reproduced C2/C3).
|
||||
* A cashier (BILL_CREATE, no MASTER_EDIT) could POST /items status:'draft' with an arbitrary
|
||||
* price AND an arbitrary taxClassCode, then bill it with the server anchoring to that just-
|
||||
* authored master — arbitrary price + GST evasion (ZERO tax on a taxable good) with no
|
||||
* deviation audit row. These tests drive REAL HTTP against a mounted apiRouter (the exact
|
||||
* surface of the hole) plus commitBill directly for the audit-row assertion.
|
||||
*
|
||||
* Seed users: u1 Ramesh/cashier (PIN 4728), u4 Divya/cashier (PIN 3591), u3 Thomas/owner
|
||||
* (PIN 9174, MASTER_EDIT). Store s1, counter c2. Surf Excel (103) master sale 14500, GST18.
|
||||
*/
|
||||
|
||||
interface Harness { db: DB; url: string; server: Server }
|
||||
|
||||
async function start(): Promise<Harness> {
|
||||
const db = openDb(':memory:')
|
||||
seedIfEmpty(db)
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
app.use('/api', apiRouter(db))
|
||||
// Mirror server.ts's generic error handler so tagged throws surface as { error, code }.
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const e = (err ?? {}) as { status?: number; code?: string; message?: string }
|
||||
const status = typeof e.status === 'number' ? e.status : 500
|
||||
if (res.headersSent) return
|
||||
res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
|
||||
})
|
||||
const server: Server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, () => resolve(s))
|
||||
})
|
||||
const port = (server.address() as AddressInfo).port
|
||||
return { db, url: `http://127.0.0.1:${port}`, server }
|
||||
}
|
||||
|
||||
async function login(url: string, userId: string, pin: string): Promise<string> {
|
||||
const res = await fetch(`${url}/api/auth/pin`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ userId, pin }),
|
||||
})
|
||||
const body = (await res.json()) as { token?: string }
|
||||
if (body.token === undefined) throw new Error(`login failed for ${userId}`)
|
||||
return body.token
|
||||
}
|
||||
|
||||
const post = (url: string, path: string, token: string, body: unknown) =>
|
||||
fetch(`${url}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const get = (url: string, path: string, token: string) =>
|
||||
fetch(`${url}${path}`, { headers: { authorization: `Bearer ${token}` } })
|
||||
|
||||
const itemIdOf = (db: DB, code: string): string =>
|
||||
(db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
|
||||
|
||||
const sellLine = (itemId: string, price: number) =>
|
||||
({ itemId, name: 'x', hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: price, priceIncludesTax: true, taxClassCode: 'GST18' })
|
||||
|
||||
let h: Harness
|
||||
beforeEach(async () => { h = await start() })
|
||||
afterEach(() => { h.server.close() })
|
||||
|
||||
describe('cashier draft tax-class forcing (C3)', () => {
|
||||
it('IGNORES a cashier-chosen ZERO tax class and assigns the store default (GST18)', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
const res = await post(h.url, '/api/items', token, {
|
||||
code: 'RT-WHISKEY', name: 'Redteam Whiskey', taxClassCode: 'ZERO',
|
||||
salePricePaise: 200_000, status: 'draft',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const item = (await res.json()) as { taxClassCode: string; status: string }
|
||||
expect(item.taxClassCode).toBe('GST18') // server default, NOT the client's ZERO
|
||||
expect(item.status).toBe('draft')
|
||||
})
|
||||
|
||||
it('lets a MASTER_EDIT role (owner) keep its chosen tax class', async () => {
|
||||
const token = await login(h.url, 'u3', '9174') // owner
|
||||
const res = await post(h.url, '/api/items', token, {
|
||||
code: 'OWN-ZERO', name: 'Owner Zero Good', taxClassCode: 'ZERO',
|
||||
salePricePaise: 5_000, status: 'draft',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const item = (await res.json()) as { taxClassCode: string }
|
||||
expect(item.taxClassCode).toBe('ZERO') // owner's explicit choice honoured
|
||||
})
|
||||
|
||||
it('a ZERO-forged draft, once billed, is taxed at the forced GST18 (GST evasion closed)', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
const created = await (await post(h.url, '/api/items', token, {
|
||||
code: 'RT-TAXED', name: 'Redteam Taxed Good', taxClassCode: 'ZERO',
|
||||
salePricePaise: 10_000, status: 'draft',
|
||||
})).json() as { id: string }
|
||||
const bill = await post(h.url, '/api/bills', token, {
|
||||
storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-13',
|
||||
lines: [sellLine(created.id, 10_000)],
|
||||
payments: [{ mode: 'CASH', amountPaise: 10_000 }], clientPayablePaise: 10_000,
|
||||
})
|
||||
expect(bill.status).toBe(200)
|
||||
const row = h.db.prepare(`SELECT cgst_paise, sgst_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number }
|
||||
expect(row.cgst_paise + row.sgst_paise).toBeGreaterThan(0) // real GST charged, not ₹0
|
||||
})
|
||||
})
|
||||
|
||||
describe('cashier draft price cap (C2 bound)', () => {
|
||||
it('rejects a draft priced above the ₹5,000 cap with E-1108', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
const res = await post(h.url, '/api/items', token, {
|
||||
code: 'RT-EXP', name: 'Expensive Unknown', taxClassCode: 'GST18',
|
||||
salePricePaise: 999_900, status: 'draft', // ₹9,999 > ₹5,000
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { code: string }
|
||||
expect(body.code).toBe('E-1108')
|
||||
expect(h.db.prepare(`SELECT COUNT(*) n FROM item WHERE code='RT-EXP'`).get()).toMatchObject({ n: 0 })
|
||||
})
|
||||
|
||||
it('allows a reasonable draft under the cap', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
const res = await post(h.url, '/api/items', token, {
|
||||
code: 'RT-OK', name: 'Reasonable Unknown', taxClassCode: 'GST18',
|
||||
salePricePaise: 45_000, status: 'draft', // ₹450 < ₹5,000
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('lets a MASTER_EDIT role (owner) create above the cap', async () => {
|
||||
const token = await login(h.url, 'u3', '9174') // owner
|
||||
const res = await post(h.url, '/api/items', token, {
|
||||
code: 'OWN-EXP', name: 'Owner Expensive', taxClassCode: 'GST18',
|
||||
salePricePaise: 999_900, status: 'draft',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('draft sale is audited DRAFT_SALE', () => {
|
||||
it('writes one DRAFT_SALE row (itemId, name, price) in the commit transaction', () => {
|
||||
// Direct repos: a draft item sold at its own price is not a deviation, so absent this
|
||||
// flag the only trace would be ITEM_CREATE — prove the explicit sale trail exists.
|
||||
const draft = createItem(h.db, 't1', {
|
||||
code: 'RT-DRAFT', name: 'Redteam Draft', hsn: '', taxClassCode: 'GST18',
|
||||
unitCode: 'PCS', unitDecimals: 0, salePricePaise: 10_000, status: 'draft',
|
||||
})
|
||||
commitBill(h.db, {
|
||||
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
||||
businessDate: '2026-07-13',
|
||||
lines: [{ itemId: draft.id, name: draft.name, hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: 10_000, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
||||
payments: [{ mode: 'CASH', amountPaise: 10_000 }], clientPayablePaise: 10_000,
|
||||
})
|
||||
const audit = h.db.prepare(`SELECT after_json FROM audit_log WHERE action='DRAFT_SALE'`).get() as { after_json: string } | undefined
|
||||
expect(audit).toBeDefined()
|
||||
const after = JSON.parse(audit!.after_json) as { itemId: string; name: string; pricePaise: number }
|
||||
expect(after.itemId).toBe(draft.id)
|
||||
expect(after.name).toBe('Redteam Draft')
|
||||
expect(after.pricePaise).toBe(10_000)
|
||||
})
|
||||
|
||||
it('an active (non-draft) item sale writes NO DRAFT_SALE row', () => {
|
||||
const surf = itemIdOf(h.db, '103')
|
||||
commitBill(h.db, {
|
||||
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
||||
businessDate: '2026-07-13',
|
||||
lines: [{ itemId: surf, name: 'Surf', hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: 14_500, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
||||
payments: [{ mode: 'CASH', amountPaise: 14_500 }], clientPayablePaise: 14_500,
|
||||
})
|
||||
expect(h.db.prepare(`SELECT COUNT(*) n FROM audit_log WHERE action='DRAFT_SALE'`).get()).toMatchObject({ n: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /bills is scoped for a cashier (cross-counter read residual)', () => {
|
||||
it('a cashier sees only their OWN bills; an owner (REPORT_VIEW) sees all', async () => {
|
||||
const surf = itemIdOf(h.db, '103')
|
||||
const t1 = await login(h.url, 'u1', '4728') // Ramesh
|
||||
const t4 = await login(h.url, 'u4', '3591') // Divya
|
||||
const billBody = {
|
||||
storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-13',
|
||||
lines: [sellLine(surf, 14_500)],
|
||||
payments: [{ mode: 'CASH', amountPaise: 14_500 }], clientPayablePaise: 14_500,
|
||||
}
|
||||
expect((await post(h.url, '/api/bills', t1, billBody)).status).toBe(200) // Ramesh's bill
|
||||
expect((await post(h.url, '/api/bills', t4, billBody)).status).toBe(200) // Divya's bill
|
||||
|
||||
const rameshView = (await (await get(h.url, '/api/bills', t1)).json()) as { cashier_id: string }[]
|
||||
expect(rameshView.length).toBe(1)
|
||||
expect(rameshView.every((b) => b.cashier_id === 'u1')).toBe(true)
|
||||
|
||||
const divyaView = (await (await get(h.url, '/api/bills', t4)).json()) as { cashier_id: string }[]
|
||||
expect(divyaView.length).toBe(1)
|
||||
expect(divyaView.every((b) => b.cashier_id === 'u4')).toBe(true)
|
||||
|
||||
// Owner has REPORT_VIEW → full cross-counter/cashier view (both bills).
|
||||
const owner = await login(h.url, 'u3', '9174')
|
||||
const ownerView = (await (await get(h.url, '/api/bills', owner)).json()) as unknown[]
|
||||
expect(ownerView.length).toBe(2)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isLanIPv4, isLanAddress, parsePrinterPorts, checkPrintEnvelope, checkPrintAddress,
|
||||
DEFAULT_PRINTER_PORTS, MAX_PRINT_BYTES,
|
||||
} from '../src/print-guard'
|
||||
import { createRateLimiter } from '../src/rate-limit'
|
||||
import { isSessionExpired, DEFAULT_SESSION_TTL } from '../src/session-policy'
|
||||
|
||||
/**
|
||||
* Pure guards behind the P1 edge hardening (red-team H1, M2, M4): the LAN/port/byte print
|
||||
* validator, the sliding-window rate limiter, and the session-TTL check. Clock-injectable, so
|
||||
* they test without timers or sockets. The HTTP-surface behaviour lives in edge.test.ts.
|
||||
*/
|
||||
|
||||
// ---- H1: /api/print SSRF allowlist ----------------------------------------------------------
|
||||
describe('print-guard: LAN address classification (H1)', () => {
|
||||
it('accepts loopback + RFC-1918 private ranges', () => {
|
||||
for (const ip of ['127.0.0.1', '127.255.255.254', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.100']) {
|
||||
expect(isLanIPv4(ip)).toBe(true)
|
||||
}
|
||||
})
|
||||
it('rejects public IPs, link-local, and the 172.16/12 edges outside the block', () => {
|
||||
for (const ip of ['8.8.8.8', '1.1.1.1', '169.254.1.1', '172.15.255.255', '172.32.0.1', '0.0.0.0', '255.255.255.255', 'not-an-ip', '']) {
|
||||
expect(isLanIPv4(ip)).toBe(false)
|
||||
}
|
||||
})
|
||||
it('treats IPv6 loopback ::1 as LAN, other IPv6 as off-LAN', () => {
|
||||
expect(isLanAddress('::1')).toBe(true)
|
||||
expect(isLanAddress('2001:4860:4860::8888')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('print-guard: port allowlist + payload cap (H1)', () => {
|
||||
it('defaults to {9100,9101,9102} and parses SIMS_PRINTER_PORTS', () => {
|
||||
expect([...parsePrinterPorts(undefined)].sort()).toEqual([...DEFAULT_PRINTER_PORTS])
|
||||
expect([...parsePrinterPorts('')].sort()).toEqual([...DEFAULT_PRINTER_PORTS])
|
||||
expect([...parsePrinterPorts('abc,,-1,70000')].sort()).toEqual([...DEFAULT_PRINTER_PORTS]) // all invalid → default
|
||||
expect([...parsePrinterPorts('9100, 9200')].sort()).toEqual([9100, 9200])
|
||||
})
|
||||
it('checkPrintEnvelope enforces byte cap and port allowlist', () => {
|
||||
const ports = new Set(DEFAULT_PRINTER_PORTS)
|
||||
expect(checkPrintEnvelope(100, 9100, ports)).toEqual({ ok: true })
|
||||
expect(checkPrintEnvelope(MAX_PRINT_BYTES + 1, 9100, ports)).toMatchObject({ ok: false, code: 'E-2201' })
|
||||
expect(checkPrintEnvelope(100, 80, ports)).toMatchObject({ ok: false, code: 'E-2202' })
|
||||
})
|
||||
it('checkPrintAddress rejects a public IP, accepts a private one', () => {
|
||||
expect(checkPrintAddress('127.0.0.1')).toEqual({ ok: true })
|
||||
expect(checkPrintAddress('8.8.8.8')).toMatchObject({ ok: false, code: 'E-2203' })
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Rate limiter (M2/M4) -------------------------------------------------------------------
|
||||
describe('rate limiter: sliding window (M2/M4)', () => {
|
||||
it('allows up to max within the window then rejects, and slides', () => {
|
||||
const rl = createRateLimiter({ windowMs: 1000, max: 2 })
|
||||
expect(rl.hit('ip', 0)).toBe(true)
|
||||
expect(rl.hit('ip', 100)).toBe(true)
|
||||
expect(rl.hit('ip', 200)).toBe(false) // 3rd within window blocked
|
||||
expect(rl.remaining('ip', 200)).toBe(0)
|
||||
expect(rl.hit('ip', 1101)).toBe(true) // first attempt (t=0) aged out → allowed again
|
||||
})
|
||||
it('keys are independent', () => {
|
||||
const rl = createRateLimiter({ windowMs: 1000, max: 1 })
|
||||
expect(rl.hit('a', 0)).toBe(true)
|
||||
expect(rl.hit('b', 0)).toBe(true)
|
||||
expect(rl.hit('a', 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Session TTL (M4) -----------------------------------------------------------------------
|
||||
describe('session TTL check (M4)', () => {
|
||||
const ttl = { absoluteMs: 1000, idleMs: 100 }
|
||||
it('is live within both bounds', () => {
|
||||
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 0 }, 50, ttl)).toBe(false)
|
||||
})
|
||||
it('expires past the idle timeout even with recent creation', () => {
|
||||
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 0 }, 150, ttl)).toBe(true)
|
||||
})
|
||||
it('expires past the absolute lifetime even when recently seen', () => {
|
||||
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 990 }, 1001, ttl)).toBe(true)
|
||||
})
|
||||
it('ships sane defaults (12h absolute / 30m idle)', () => {
|
||||
expect(DEFAULT_SESSION_TTL.absoluteMs).toBe(12 * 60 * 60 * 1000)
|
||||
expect(DEFAULT_SESSION_TTL.idleMs).toBe(30 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,174 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import express from 'express'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { Server } from 'node:http'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { apiRouter, type ApiRouterOpts } from '../src/api'
|
||||
import { MAX_PRINT_BYTES } from '../src/print-guard'
|
||||
|
||||
/**
|
||||
* P1 edge / transport hardening (red-team H1, M1, M2, M4) over the REAL HTTP surface: a mounted
|
||||
* apiRouter, driven with fetch — the exact surface each finding lives on. Pure guards are in
|
||||
* edge-guards.test.ts. Seed users: u1 Ramesh/cashier (4728), u2 Suresh/supervisor (8265),
|
||||
* u3 Thomas/owner (9174).
|
||||
*/
|
||||
interface Harness { db: DB; url: string; server: Server }
|
||||
|
||||
async function start(opts?: ApiRouterOpts): Promise<Harness> {
|
||||
const db = openDb(':memory:')
|
||||
seedIfEmpty(db)
|
||||
const app = express()
|
||||
app.use(express.json({ limit: '1mb' }))
|
||||
app.use('/api', apiRouter(db, opts))
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const e = (err ?? {}) as { status?: number; code?: string; message?: string }
|
||||
const status = typeof e.status === 'number' ? e.status : 500
|
||||
if (res.headersSent) return
|
||||
res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
|
||||
})
|
||||
const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) })
|
||||
const port = (server.address() as AddressInfo).port
|
||||
return { db, url: `http://127.0.0.1:${port}`, server }
|
||||
}
|
||||
|
||||
async function login(url: string, userId: string, pin: string): Promise<string> {
|
||||
const res = await fetch(`${url}/api/auth/pin`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }),
|
||||
})
|
||||
const body = (await res.json()) as { token?: string }
|
||||
if (body.token === undefined) throw new Error(`login failed for ${userId}`)
|
||||
return body.token
|
||||
}
|
||||
|
||||
const post = (url: string, path: string, token: string | undefined, body: unknown) =>
|
||||
fetch(`${url}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const get = (url: string, path: string, token?: string) =>
|
||||
fetch(`${url}${path}`, { headers: token !== undefined ? { authorization: `Bearer ${token}` } : {} })
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
let h: Harness
|
||||
afterEach(() => { h.server.close() })
|
||||
|
||||
describe('M1 — /bootstrap minimize', () => {
|
||||
beforeEach(async () => { h = await start() })
|
||||
it('unauthenticated payload leaks NO role and NO gstin', async () => {
|
||||
const boot = (await (await get(h.url, '/api/bootstrap')).json()) as { store: Record<string, unknown>; users: Record<string, unknown>[] }
|
||||
expect(boot.store['gstin']).toBeUndefined()
|
||||
expect(boot.users.length).toBeGreaterThan(0)
|
||||
for (const u of boot.users) {
|
||||
expect(u['role']).toBeUndefined()
|
||||
expect(u['name']).toBeDefined() // display name kept
|
||||
expect(u['id']).toBeDefined() // id kept (PIN login target)
|
||||
}
|
||||
})
|
||||
it('an authenticated caller gets role + gstin back', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
const boot = (await (await get(h.url, '/api/bootstrap', token)).json()) as { store: Record<string, unknown>; users: Record<string, unknown>[] }
|
||||
expect(boot.store['gstin']).toBeDefined()
|
||||
expect(boot.users.every((u) => u['role'] !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('M2 — /auth/verify-pin behind auth', () => {
|
||||
beforeEach(async () => { h = await start() })
|
||||
const override = { itemId: 'i-x', kind: 'price', before: 10000, after: 9000 }
|
||||
it('is 401 UNAUTHENTICATED (no caller session)', async () => {
|
||||
const res = await post(h.url, '/api/auth/verify-pin', undefined, { userId: 'u2', pin: '8265', override })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
it('still mints a bound token when the caller is a logged-in cashier', async () => {
|
||||
const cashier = await login(h.url, 'u1', '4728')
|
||||
const res = await post(h.url, '/api/auth/verify-pin', cashier, { userId: 'u2', pin: '8265', override })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { approvalId?: string }
|
||||
expect(body.approvalId).toBeDefined()
|
||||
})
|
||||
it('a wrong approver PIN is still rejected (401) even with a valid caller', async () => {
|
||||
const cashier = await login(h.url, 'u1', '4728')
|
||||
const res = await post(h.url, '/api/auth/verify-pin', cashier, { userId: 'u2', pin: '0000', override })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('M4 — session TTL + logout', () => {
|
||||
it('evicts a session past its idle TTL (401 E-6110)', async () => {
|
||||
h = await start({ sessionTtl: { absoluteMs: 3_600_000, idleMs: 20 } })
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
expect((await get(h.url, '/api/items', token)).status).toBe(200) // fresh: allowed
|
||||
await sleep(80)
|
||||
const res = await get(h.url, '/api/items', token)
|
||||
expect(res.status).toBe(401)
|
||||
expect(((await res.json()) as { code: string }).code).toBe('E-6110')
|
||||
})
|
||||
it('logout makes the token stop working immediately', async () => {
|
||||
h = await start()
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
expect((await get(h.url, '/api/items', token)).status).toBe(200)
|
||||
expect((await post(h.url, '/api/auth/logout', token, {})).status).toBe(200)
|
||||
expect((await get(h.url, '/api/items', token)).status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('M4/M2 — auth rate limit', () => {
|
||||
it('429s once the per-IP cap is exceeded, before the handler runs', async () => {
|
||||
h = await start({ authRatePerIp: { windowMs: 60_000, max: 2 } })
|
||||
const r1 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
||||
const r2 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
||||
const r3 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
||||
expect(r1.status).toBe(401) // wrong pin, but processed
|
||||
expect(r2.status).toBe(401)
|
||||
expect(r3.status).toBe(429) // rate-limited before reaching the handler
|
||||
})
|
||||
})
|
||||
|
||||
describe('H1 — /api/print allowlist over HTTP', () => {
|
||||
beforeEach(async () => { h = await start() })
|
||||
it('rejects an unauthenticated caller (401)', async () => {
|
||||
const res = await post(h.url, '/api/print', undefined, { host: '127.0.0.1', port: 9100, bytes: [1] })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
it('rejects a PUBLIC IP even on an allowed port (E-2203, no socket opened)', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
const res = await post(h.url, '/api/print', token, { host: '8.8.8.8', port: 9100, bytes: [1, 2, 3] })
|
||||
expect(res.status).toBe(400)
|
||||
expect(((await res.json()) as { code: string }).code).toBe('E-2203')
|
||||
})
|
||||
it('rejects a disallowed port (E-2202)', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 80, bytes: [1] })
|
||||
expect(((await res.json()) as { code: string }).code).toBe('E-2202')
|
||||
})
|
||||
it('rejects an over-cap payload (E-2201)', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 9100, bytes: new Array(MAX_PRINT_BYTES + 1).fill(0) })
|
||||
expect(((await res.json()) as { code: string }).code).toBe('E-2201')
|
||||
})
|
||||
it('ALLOWS a LAN target on an allowed port through the guard (reaches the socket → connection refused)', async () => {
|
||||
const token = await login(h.url, 'u1', '4728')
|
||||
// Nothing is listening on 127.0.0.1:9100 in the test, so the guard passing is proven by the
|
||||
// response being a socket outcome (502 refused / 504 timeout), NOT a 400 guard rejection.
|
||||
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 9100, bytes: [27, 64] })
|
||||
expect([502, 504, 200]).toContain(res.status)
|
||||
})
|
||||
})
|
||||
|
||||
describe('H2 — GET /audit/verify (AUDIT_VIEW gated)', () => {
|
||||
beforeEach(async () => { h = await start() })
|
||||
it('an owner gets ok:true on an untampered chain (with a real audit row); a cashier is 403', async () => {
|
||||
// a wrong-PIN attempt writes a real LOGIN_FAILED row, so the chain isn't empty
|
||||
await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
||||
const owner = await login(h.url, 'u3', '9174')
|
||||
const res = await get(h.url, '/api/audit/verify', owner)
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as { ok: boolean }).ok).toBe(true)
|
||||
const cashier = await login(h.url, 'u1', '4728')
|
||||
expect((await get(h.url, '/api/audit/verify', cashier)).status).toBe(403)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
|
||||
/**
|
||||
* H3 (red-team): the store DB is encrypted at rest with SQLCipher (better-sqlite3-multiple-ciphers)
|
||||
* when SIMS_DB_KEY is set. These tests write a customer PII row (a phone number) through the real
|
||||
* openDb() path, then prove against the ON-DISK bytes that:
|
||||
* - a keyed file's header is NOT the plaintext "SQLite format 3\0" magic, and the phone number
|
||||
* never appears in cleartext ANYWHERE in the file (main db + WAL);
|
||||
* - the keyed data round-trips (reopen WITH the key reads the phone back);
|
||||
* - opening WITHOUT the key, or with the WRONG key, fails ("file is not a database");
|
||||
* - with SIMS_DB_KEY UNSET the file IS plaintext (the dev fallback) AND the phone IS present in
|
||||
* cleartext — a positive control proving the byte-scan above genuinely detects plaintext.
|
||||
* Tamper-evidence (H2) proves the file wasn't edited; H3 proves a stolen copy is unreadable.
|
||||
*/
|
||||
const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1') // 16 bytes
|
||||
const PHONE = '9998887776' // stand-in customer PII we hunt for in the raw file
|
||||
const GSTIN = '27ABCDE1234F1Z0'
|
||||
|
||||
const tmpFiles: string[] = []
|
||||
function tmpDbPath(): string {
|
||||
const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sims-enc-')), 'sims.db')
|
||||
tmpFiles.push(p)
|
||||
return p
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const p of tmpFiles.splice(0)) {
|
||||
for (const f of [p, `${p}-wal`, `${p}-shm`]) { try { fs.unlinkSync(f) } catch { /* ignore */ } }
|
||||
try { fs.rmdirSync(path.dirname(p)) } catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
|
||||
/** Run fn with SIMS_DB_KEY = key (or unset when key === null), restoring the prior env after. */
|
||||
function withKey<T>(key: string | null, fn: () => T): T {
|
||||
const prev = process.env['SIMS_DB_KEY']
|
||||
if (key === null) delete process.env['SIMS_DB_KEY']
|
||||
else process.env['SIMS_DB_KEY'] = key
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['SIMS_DB_KEY']
|
||||
else process.env['SIMS_DB_KEY'] = prev
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert one party carrying PII, checkpoint so it lands on disk, close. */
|
||||
function writePartyAndClose(db: DB): void {
|
||||
db.prepare(
|
||||
"INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code) "
|
||||
+ "VALUES ('p-enc', 't1', 'C-ENC', 'Encrypted Customer', 'customer', ?, ?, '27')",
|
||||
).run(PHONE, GSTIN)
|
||||
db.pragma('wal_checkpoint(TRUNCATE)') // force the row out of the WAL into the main file we read
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** Concatenated raw bytes of the main db file and any WAL sidecar. */
|
||||
function rawBytes(file: string): Buffer {
|
||||
const parts = [fs.readFileSync(file)]
|
||||
if (fs.existsSync(`${file}-wal`)) parts.push(fs.readFileSync(`${file}-wal`))
|
||||
return Buffer.concat(parts)
|
||||
}
|
||||
|
||||
describe('H3 — full-DB encryption at rest (SQLCipher)', () => {
|
||||
it('a keyed DB is ciphertext on disk: no SQLite magic header, no cleartext PII', () => {
|
||||
const file = tmpDbPath()
|
||||
withKey('unit-test-secret-key', () => {
|
||||
writePartyAndClose(openDb(file))
|
||||
})
|
||||
const head = fs.readFileSync(file).subarray(0, 16)
|
||||
expect(head.equals(SQLITE_MAGIC)).toBe(false) // not "SQLite format 3\0"
|
||||
const raw = rawBytes(file)
|
||||
expect(raw.includes(Buffer.from(PHONE, 'latin1'))).toBe(false) // phone never in cleartext
|
||||
expect(raw.includes(Buffer.from(GSTIN, 'latin1'))).toBe(false) // nor the GSTIN
|
||||
})
|
||||
|
||||
it('the keyed data round-trips — reopening WITH the key reads the PII back', () => {
|
||||
const file = tmpDbPath()
|
||||
withKey('unit-test-secret-key', () => {
|
||||
writePartyAndClose(openDb(file))
|
||||
const db2 = openDb(file)
|
||||
const row = db2.prepare("SELECT phone, gstin FROM party WHERE id='p-enc'").get() as
|
||||
{ phone: string; gstin: string } | undefined
|
||||
db2.close()
|
||||
expect(row?.phone).toBe(PHONE)
|
||||
expect(row?.gstin).toBe(GSTIN)
|
||||
})
|
||||
})
|
||||
|
||||
it('opening a keyed file WITHOUT the key fails (a stolen copy is unreadable)', () => {
|
||||
const file = tmpDbPath()
|
||||
withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) })
|
||||
expect(() => withKey(null, () => openDb(file))).toThrow(/not a database/i)
|
||||
})
|
||||
|
||||
it('opening a keyed file with the WRONG key fails', () => {
|
||||
const file = tmpDbPath()
|
||||
withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) })
|
||||
expect(() => withKey('the-wrong-key', () => openDb(file))).toThrow(/not a database/i)
|
||||
})
|
||||
|
||||
it('dev fallback (key UNSET) writes plaintext — positive control for the byte-scan', () => {
|
||||
const file = tmpDbPath()
|
||||
withKey(null, () => { writePartyAndClose(openDb(file)) })
|
||||
const head = fs.readFileSync(file).subarray(0, 16)
|
||||
expect(head.equals(SQLITE_MAGIC)).toBe(true) // unencrypted: real SQLite magic
|
||||
expect(rawBytes(file).includes(Buffer.from(PHONE, 'latin1'))).toBe(true) // scan really finds plaintext
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { commitBill, createOverrideApproval } from '../src/repos'
|
||||
|
||||
/**
|
||||
* SEC-2 / H4: supervisor-override approvals are server-minted, single-use tokens BOUND to
|
||||
* the exact override they authorise. The client sends an `approvalId`, never an approver
|
||||
* identity — commitBill re-detects the deviation from the item master, redeems the token
|
||||
* only if item/kind/before/after match, and reads the approver from its own row. These
|
||||
* tests drive commitBill against a hermetic in-memory DB (seed users: u1 Ramesh/cashier,
|
||||
* u2 Suresh/supervisor). Surf Excel (103) has master sale 14500, MRP 15000.
|
||||
*/
|
||||
function fresh(): DB {
|
||||
const db = openDb(':memory:')
|
||||
seedIfEmpty(db)
|
||||
return db
|
||||
}
|
||||
const surfId = (db: DB): string =>
|
||||
(db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='103'").get() as { id: string }).id
|
||||
|
||||
/** A bill that sells Surf Excel at 12000 (a deviation from the 14500 master), overridden. */
|
||||
const billWith = (db: DB, approvalId: string): Parameters<typeof commitBill>[1] => {
|
||||
const itemId = surfId(db)
|
||||
return {
|
||||
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
||||
businessDate: '2026-07-10',
|
||||
lines: [{ itemId, name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS', unitPricePaise: 12_000, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
||||
payments: [{ mode: 'CASH', amountPaise: 12_000 }],
|
||||
clientPayablePaise: 12_000,
|
||||
overrides: [{ itemId, kind: 'price', before: 14_500, after: 12_000, approvalId }],
|
||||
}
|
||||
}
|
||||
const priceCtx = (db: DB) => ({ itemId: surfId(db), kind: 'price' as const, beforePaise: 14_500, afterPaise: 12_000 })
|
||||
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
|
||||
|
||||
describe('SEC-2 / H4 bound override approval tokens', () => {
|
||||
it('rejects a commit whose override carries a fabricated approvalId — nothing is committed', () => {
|
||||
const db = fresh()
|
||||
expect(() => commitBill(db, billWith(db, '00000000-0000-7000-8000-000000000000'))).toThrow(/approval/i)
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a genuine bound token, audits the SERVER-side approver, and rejects a replay', () => {
|
||||
const db = fresh()
|
||||
const approvalId = createOverrideApproval(db, 't1', 'u2', priceCtx(db)) // Suresh (supervisor)
|
||||
const out = commitBill(db, billWith(db, approvalId))
|
||||
expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9)
|
||||
const audit = db.prepare("SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'").get() as { after_json: string }
|
||||
const after = JSON.parse(audit.after_json) as { approvedBy: string; approvedByUserId: string }
|
||||
expect(after.approvedByUserId).toBe('u2') // read from the server's own row, not the client
|
||||
expect(after.approvedBy).toBe('Suresh')
|
||||
// token is single-use: a replay is rejected and no second bill is written
|
||||
expect(() => commitBill(db, billWith(db, approvalId))).toThrow(/already used/i)
|
||||
expect(billCount(db)).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects a token minted for a non-approver role (a cashier)', () => {
|
||||
const db = fresh()
|
||||
const cashierToken = createOverrideApproval(db, 't1', 'u1', priceCtx(db)) // u1 is a cashier
|
||||
expect(() => commitBill(db, billWith(db, cashierToken))).toThrow(/not authorised/i)
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an override with no approvalId at all (deviation unauthorised)', () => {
|
||||
const db = fresh()
|
||||
const itemId = surfId(db)
|
||||
const input = billWith(db, 'placeholder')
|
||||
input.overrides = [{ itemId, kind: 'price', before: 14_500, after: 12_000 }]
|
||||
expect(() => commitBill(db, input)).toThrow(/supervisor approval|E-1302/i)
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('H4: a token minted for item A cannot be redirected to item B', () => {
|
||||
const db = fresh()
|
||||
// Mint a genuine supervisor token bound to a DIFFERENT item (105 Maggi 14000→10000)...
|
||||
const maggiId = (db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='105'").get() as { id: string }).id
|
||||
const strayToken = createOverrideApproval(db, 't1', 'u2', { itemId: maggiId, kind: 'price', beforePaise: 14_000, afterPaise: 10_000 })
|
||||
// ...and try to spend it on the Surf Excel deviation. The bound item mismatches → reject.
|
||||
expect(() => commitBill(db, billWith(db, strayToken))).toThrow(/does not match|not recognised|approval/i)
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { commitBill } from '../src/repos'
|
||||
|
||||
/**
|
||||
* M5 (red-team): commitBill's store/counter lookup is tenant-scoped — `WHERE id=? AND tenant_id=?`
|
||||
* for both — and an unknown-for-tenant store or counter is rejected 400 (E-1110) BEFORE any money
|
||||
* logic, which also removes the old undefined-deref 500. (E-1110, not the task's suggested E-1109,
|
||||
* because E-1109 is already the "payments do not sum to payable" code — reusing it would be
|
||||
* ambiguous.) The /bills and /bills/offline routes both funnel through commitBill, so both paths
|
||||
* are covered. Hermetic in-memory DB; Carry Bag (108) has master sale 500, no MRP, GST18.
|
||||
*/
|
||||
function fresh(): DB {
|
||||
const db = openDb(':memory:')
|
||||
seedIfEmpty(db)
|
||||
return db
|
||||
}
|
||||
const carryBag = (db: DB): string =>
|
||||
(db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='108'").get() as { id: string }).id
|
||||
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
|
||||
const base = (db: DB): Omit<Parameters<typeof commitBill>[1], 'storeId'> => ({
|
||||
tenantId: 't1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
|
||||
lines: [{ itemId: carryBag(db), name: 'Carry Bag', hsn: '3923', qty: 1, unitCode: 'PCS', unitPricePaise: 500, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
||||
payments: [{ mode: 'CASH', amountPaise: 500 }], clientPayablePaise: 500,
|
||||
})
|
||||
const thrown = (fn: () => unknown): (Error & { status?: number; code?: string }) | undefined => {
|
||||
try { fn(); return undefined } catch (e) { return e as Error & { status?: number; code?: string } }
|
||||
}
|
||||
|
||||
describe('M5 — commitBill store/counter are tenant-scoped', () => {
|
||||
it('a nonexistent storeId is rejected 400 (E-1110) — no 500, nothing committed', () => {
|
||||
const db = fresh()
|
||||
const err = thrown(() => commitBill(db, { ...base(db), storeId: 'does-not-exist' }))
|
||||
expect(err).toBeDefined()
|
||||
expect(err!.status).toBe(400)
|
||||
expect(err!.code).toBe('E-1110')
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('a nonexistent counterId is rejected 400 (E-1110)', () => {
|
||||
const db = fresh()
|
||||
const err = thrown(() => commitBill(db, { ...base(db), storeId: 's1', counterId: 'nope' }))
|
||||
expect(err!.status).toBe(400)
|
||||
expect(err!.code).toBe('E-1110')
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('a store/counter belonging to ANOTHER tenant is rejected (true cross-tenant scoping)', () => {
|
||||
const db = fresh()
|
||||
db.prepare("INSERT INTO tenant (id, code, name, gst_scheme, fy_start_month, created_at) VALUES ('t2','T2','Other','regular',4,?)").run(new Date().toISOString())
|
||||
db.prepare("INSERT INTO store (id, tenant_id, code, name, state_code) VALUES ('s2','t2','ST9','Other Store','29')").run()
|
||||
db.prepare("INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('cc2','t2','s2','C1','C')").run()
|
||||
// commit as tenant t1 but point at t2's store/counter → must reject, never bill against t2's store
|
||||
const err = thrown(() => commitBill(db, { ...base(db), storeId: 's2', counterId: 'cc2' }))
|
||||
expect(err!.code).toBe('E-1110')
|
||||
expect(billCount(db)).toBe(0)
|
||||
})
|
||||
|
||||
it('the honest same-tenant store/counter still commits (4-digit-FY doc no)', () => {
|
||||
const db = fresh()
|
||||
const out = commitBill(db, { ...base(db), storeId: 's1' })
|
||||
expect(out.docNo).toMatch(/^ST1C22026-/)
|
||||
expect(out.docNo.length).toBeLessThanOrEqual(16)
|
||||
expect(billCount(db)).toBe(1)
|
||||
})
|
||||
})
|
||||
@ -1,2 +1,4 @@
|
||||
export * from './tax'
|
||||
export * from './compute'
|
||||
export * from './returns'
|
||||
export * from './gst-returns'
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
import type { BillLine, BillTotals, Paise } from '@sims/domain'
|
||||
|
||||
/**
|
||||
* The credit-note (return) engine — the money half of a SALE_RETURN, shared byte-for-byte
|
||||
* by the POS (refund preview) and the store-server (authoritative commit) so the two agree
|
||||
* to the paisa (R5), exactly like `computeBill`.
|
||||
*
|
||||
* A return REVERSES an original sale line PROPORTIONALLY and at the ORIGINAL captured rate:
|
||||
* it scales the original line's already-computed amounts (taxable, CGST/SGST/IGST, cess) by
|
||||
* `returnQty / soldQty`, never re-reading today's tax master. So a return of a good whose GST
|
||||
* rate changed after the sale still reverses tax at the rate on the bill (R5/R10 anchoring).
|
||||
*
|
||||
* Integer paise throughout (R4). Every component is rounded independently, then the line total
|
||||
* is DERIVED as taxable + taxes so `taxable + CGST + SGST + IGST + cess == lineTotal` and
|
||||
* `CGST + SGST == tax` hold by construction — the same invariants `computeBill` produces. A
|
||||
* full-line return (returnQty == soldQty) reproduces the original line's amounts exactly.
|
||||
*/
|
||||
export interface ReturnLineRequest {
|
||||
/** Index into the original bill's stored `lines` array. */
|
||||
lineIndex: number
|
||||
/** Units being returned on this line (≤ the still-returnable quantity — enforced server-side). */
|
||||
qty: number
|
||||
}
|
||||
|
||||
/** A credit-note line: the proportional reversal of one sale line, tagged with the original
|
||||
* line index it reverses so returned-so-far can be summed across prior credit notes. */
|
||||
export interface ReturnLine extends BillLine {
|
||||
originalLineIndex: number
|
||||
}
|
||||
|
||||
export interface ComputedReturn {
|
||||
lines: ReturnLine[]
|
||||
totals: BillTotals
|
||||
}
|
||||
|
||||
/** Proportional integer-paise scale: round(x · qty / soldQty). At qty == soldQty this is x. */
|
||||
const scale = (x: number, qty: number, soldQty: number): Paise =>
|
||||
Math.round((x * qty) / soldQty)
|
||||
|
||||
/**
|
||||
* Compute a credit note from the original bill's stored lines and the requested per-line
|
||||
* return quantities. Callers (the server) validate the line indexes and that each qty is within
|
||||
* the still-returnable quantity BEFORE calling this — here we only guard against a malformed
|
||||
* index/qty so a bad call fails loudly rather than minting a bogus refund.
|
||||
*/
|
||||
export function computeReturn(
|
||||
originalLines: BillLine[],
|
||||
requested: ReturnLineRequest[],
|
||||
opts: { roundToRupee: boolean },
|
||||
): ComputedReturn {
|
||||
if (requested.length === 0) throw new Error('Cannot compute an empty return')
|
||||
const lines: ReturnLine[] = requested.map((req) => {
|
||||
const o = originalLines[req.lineIndex]
|
||||
if (o === undefined) throw new Error(`No original line at index ${req.lineIndex}`)
|
||||
const soldQty = o.qty
|
||||
const qty = req.qty
|
||||
if (!(qty > 0) || qty > soldQty) {
|
||||
throw new Error(`Return quantity ${qty} is out of range for "${o.name}" (sold ${soldQty})`)
|
||||
}
|
||||
const grossPaise = scale(o.grossPaise, qty, soldQty)
|
||||
const discountPaise = scale(o.discountPaise, qty, soldQty)
|
||||
const taxablePaise = scale(o.taxablePaise, qty, soldQty)
|
||||
const cgstPaise = scale(o.cgstPaise, qty, soldQty)
|
||||
const sgstPaise = scale(o.sgstPaise, qty, soldQty)
|
||||
const igstPaise = scale(o.igstPaise, qty, soldQty)
|
||||
const cessPaise = scale(o.cessPaise, qty, soldQty)
|
||||
// Derived so the reversed line stays internally consistent (taxable + taxes == total).
|
||||
const lineTotalPaise = taxablePaise + cgstPaise + sgstPaise + igstPaise + cessPaise
|
||||
return {
|
||||
originalLineIndex: req.lineIndex,
|
||||
itemId: o.itemId,
|
||||
name: o.name,
|
||||
hsn: o.hsn,
|
||||
qty,
|
||||
unitCode: o.unitCode,
|
||||
unitPricePaise: o.unitPricePaise,
|
||||
...(o.mrpPaise !== undefined ? { mrpPaise: o.mrpPaise } : {}),
|
||||
grossPaise,
|
||||
discountPaise,
|
||||
taxablePaise,
|
||||
taxRateBp: o.taxRateBp,
|
||||
cgstPaise,
|
||||
sgstPaise,
|
||||
igstPaise,
|
||||
cessPaise,
|
||||
lineTotalPaise,
|
||||
...(o.batchId !== undefined ? { batchId: o.batchId } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const sum = (f: (l: ReturnLine) => number): number => lines.reduce((a, l) => a + f(l), 0)
|
||||
const total = sum((l) => l.lineTotalPaise)
|
||||
// Mirror the sale's rupee round-off so a full return of a rounded bill refunds the rounded
|
||||
// amount; the round-off is recorded so taxable + taxes + roundOff == payable holds.
|
||||
const payable = opts.roundToRupee ? Math.round(total / 100) * 100 : total
|
||||
|
||||
const totals: BillTotals = {
|
||||
grossPaise: sum((l) => l.grossPaise),
|
||||
discountPaise: sum((l) => l.discountPaise),
|
||||
taxablePaise: sum((l) => l.taxablePaise),
|
||||
cgstPaise: sum((l) => l.cgstPaise),
|
||||
sgstPaise: sum((l) => l.sgstPaise),
|
||||
igstPaise: sum((l) => l.igstPaise),
|
||||
cessPaise: sum((l) => l.cessPaise),
|
||||
roundOffPaise: payable - total,
|
||||
payablePaise: payable,
|
||||
savingsVsMrpPaise: 0,
|
||||
}
|
||||
return { lines, totals }
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import type { IsoDate } from './business-day'
|
||||
|
||||
/**
|
||||
* S5 batch/expiry — a batch is an item's lot, optionally dated. Per-batch on-hand
|
||||
* is derived from stock movements (R7), so the store server supplies it here.
|
||||
* These functions are pure and framework-free so both surfaces (POS pick, expiry
|
||||
* dashboard) and the tests share one FEFO rule.
|
||||
*/
|
||||
export interface BatchStock {
|
||||
id: string
|
||||
itemId: string
|
||||
batchNo: string
|
||||
/** ISO yyyy-mm-dd; undefined = no expiry recorded (a non-perishable lot). */
|
||||
expiryDate?: IsoDate
|
||||
/** Derived from movements by the caller; the picker only lists on-hand > 0. */
|
||||
onHand: number
|
||||
}
|
||||
|
||||
/** A dated batch whose expiry is strictly before the business date has expired. */
|
||||
export function isExpired(batch: Pick<BatchStock, 'expiryDate'>, today: IsoDate): boolean {
|
||||
return batch.expiryDate !== undefined && batch.expiryDate < today
|
||||
}
|
||||
|
||||
/**
|
||||
* FEFO order — First Expiring, First Out: earliest expiry first (so a stale lot
|
||||
* sells before a fresh one), undated lots last, batch_no breaking exact ties. ISO
|
||||
* date strings compare lexicographically, so no Date parsing is needed. Pure — it
|
||||
* returns a new array and never mutates its input.
|
||||
*/
|
||||
export function sortFefo(batches: BatchStock[]): BatchStock[] {
|
||||
const key = (b: BatchStock): string => b.expiryDate ?? '9999-12-31'
|
||||
return [...batches].sort((a, b) => {
|
||||
const ka = key(a)
|
||||
const kb = key(b)
|
||||
if (ka !== kb) return ka < kb ? -1 : 1
|
||||
return a.batchNo < b.batchNo ? -1 : a.batchNo > b.batchNo ? 1 : 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch the counter should default to: FEFO order, but skipping expired lots
|
||||
* so the preselected row is one the cashier can actually sell without a supervisor
|
||||
* override. If every lot is expired, the earliest-expiring one is returned (the
|
||||
* cursor still lands on it, flagged, and selling it raises the E-1310 gate).
|
||||
* `undefined` only when the list is empty.
|
||||
*/
|
||||
export function pickFefoBatch(batches: BatchStock[], today: IsoDate): BatchStock | undefined {
|
||||
const sorted = sortFefo(batches)
|
||||
return sorted.find((b) => !isExpired(b, today)) ?? sorted[0]
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isExpired, pickFefoBatch, sortFefo, type BatchStock } from '@sims/domain'
|
||||
|
||||
/**
|
||||
* S5 FEFO — the single batch-pick rule shared by the POS picker and the expiry
|
||||
* dashboard. Dates are ISO strings so ordering is lexicographic; "today" here is
|
||||
* 2026-07-10 (the demo business date).
|
||||
*/
|
||||
const TODAY = '2026-07-10'
|
||||
|
||||
const b = (id: string, batchNo: string, expiryDate: string | undefined, onHand = 10): BatchStock => ({
|
||||
id, itemId: 'i-107', batchNo, ...(expiryDate !== undefined ? { expiryDate } : {}), onHand,
|
||||
})
|
||||
|
||||
describe('S5 FEFO — sortFefo', () => {
|
||||
it('orders earliest expiry first, undated lots last', () => {
|
||||
const sorted = sortFefo([
|
||||
b('c', 'B-C', '2026-12-31'),
|
||||
b('u', 'B-U', undefined),
|
||||
b('a', 'B-A', '2026-07-15'),
|
||||
b('x', 'B-X', '2026-07-05'), // already expired — still earliest
|
||||
])
|
||||
expect(sorted.map((x) => x.id)).toEqual(['x', 'a', 'c', 'u'])
|
||||
})
|
||||
|
||||
it('breaks an exact expiry tie by batch_no and does not mutate the input', () => {
|
||||
const input = [b('2', 'B-Z', '2026-07-20'), b('1', 'B-A', '2026-07-20')]
|
||||
const sorted = sortFefo(input)
|
||||
expect(sorted.map((x) => x.batchNo)).toEqual(['B-A', 'B-Z'])
|
||||
expect(input.map((x) => x.id)).toEqual(['2', '1']) // original order intact
|
||||
})
|
||||
})
|
||||
|
||||
describe('S5 FEFO — isExpired', () => {
|
||||
it('is true only for a dated lot strictly before today', () => {
|
||||
expect(isExpired({ expiryDate: '2026-07-09' }, TODAY)).toBe(true)
|
||||
expect(isExpired({ expiryDate: TODAY }, TODAY)).toBe(false) // expires today = still sellable
|
||||
expect(isExpired({ expiryDate: '2026-07-11' }, TODAY)).toBe(false)
|
||||
expect(isExpired({ expiryDate: undefined }, TODAY)).toBe(false) // undated never expires
|
||||
})
|
||||
})
|
||||
|
||||
describe('S5 FEFO — pickFefoBatch', () => {
|
||||
it('preselects the earliest NON-expired lot, skipping expired ones', () => {
|
||||
const pick = pickFefoBatch([
|
||||
b('x', 'B-X', '2026-07-05'), // expired — skipped
|
||||
b('a', 'B-A', '2026-07-15'), // earliest sellable
|
||||
b('c', 'B-C', '2026-12-31'),
|
||||
], TODAY)
|
||||
expect(pick?.id).toBe('a')
|
||||
})
|
||||
|
||||
it('falls back to the earliest-expiring lot when every lot is expired', () => {
|
||||
const pick = pickFefoBatch([
|
||||
b('y', 'B-Y', '2026-07-08'),
|
||||
b('x', 'B-X', '2026-07-01'), // earliest of the expired
|
||||
], TODAY)
|
||||
expect(pick?.id).toBe('x')
|
||||
})
|
||||
|
||||
it('returns undefined for an empty list', () => {
|
||||
expect(pickFefoBatch([], TODAY)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue