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
Thomas Joise 1 week ago
parent 4559958fa4
commit 3d3487c85f

@ -13,6 +13,226 @@ engine re-verification** (client and server must agree to the paisa) → bill vi
Back Office Bills, stock derived correctly, audit trail written. Verified by driving
both UIs in a real browser: bill `ST1C2/26-000001`, ₹600.00.
## P0 security remediation — DONE 2026-07-10 (red-team close-out)
The store-server now enforces the trust boundary the red-team review (docs/18) found
missing: it no longer trusts the client for **identity, price, tax, item existence, or
input shape**. One coherent change, three server-side moves + client wiring:
| Move | What changed | Closes |
|---|---|---|
| **Item-master anchoring** | `commitBill` rebuilds every line from OUR `item` row — tax class, HSN, MRP, unit, inclusive-price flag are read from the master and the request's copies ignored; the unit price is accepted only if it equals the master sale price, else it is a *deviation* needing approval; price > MRP is a hard block (E-1301); fractional qty on a whole unit is rejected (E-1104); non-existent/foreign item rejected (E-1105) | C2, C3, H5, M8 |
| **Per-route authorization** | `requireAnyPerm(...ActionCode)` middleware in `api.ts` maps each mutating/sensitive route to `permissions.ts` grants, `can(session, action)`, deny-by-default 403 (E-6105). Cashier keeps billing + draft quick-add + own reads; loses `/audit`, `/purchases*`, cost endpoints, master edits, purchase posting | C1, C4 |
| **Input validation + generic errors** | hand-rolled `validate.ts` guards (no new deps) on every route: ISO+windowed `businessDate` (H6), clamped `limit`/`offset` (M6), capped `lines` ≤ 500 (M12), payment legs > 0 with an allowlisted mode summing to payable (M8). Express 4-arg error handler returns `{error, code}` with no stack/path (M7); `x-powered-by` disabled (M14) | H6, M6, M7, M8, M12 |
| **Bound approval tokens** | `override_approval` rows carry `item_id/kind/before/after`; `/auth/verify-pin` binds the override context at mint; `commitBill` re-verifies the token against the line's server-computed deviation (H4). Self-authorised (owner/manager/supervisor) deviations are audited from the session identity, so **every** deviation leaves a trail (H7) | H4, H7 |
**Proven on a fresh DB by re-running the red-team PoCs (all now blocked) and a real-browser
E2E** (Ramesh normal bill → commits; Ramesh F3 rate override → Suresh PIN → bound-token
commit + audit; Thomas owner self-price-change → commit + audit). 207 tests green,
typecheck + all three builds clean. Details + evidence table in docs/18-RED-TEAM-REVIEW.md.
P1 (print allowlist, TLS, session TTL, bootstrap/verify-pin hardening) and P2
(encryption-at-rest, hash chain, demo-seed gate) remain **open** by design.
### HIGH follow-up — cashier draft quick-add path CLOSED 2026-07-13
The independent verifier found the C1 remediation left one HIGH hole inside the quick-add
convenience it kept open: a cashier could `POST /items status:'draft'` with an **arbitrary
price AND tax class**, then bill it — reproducing **C2 (arbitrary price)** and **C3 (GST
evasion: a taxable good self-added as `ZERO`, sold at ₹0 GST)** with only an `ITEM_CREATE`
trace. Closed while keeping the convenience (an unknown barcode must not stop the queue):
| Move | What changed | Closes |
|---|---|---|
| **Force the draft tax class** | On a non-`MASTER_EDIT` caller's `POST /items` draft, the server **ignores** the client `taxClassCode` and assigns the store default (`catalog.defaultTaxClass` setting, system→tenant, default `GST18`). A manager/owner still sets it; back office completes the real HSN/class later | C3-via-draft |
| **Cap the draft price** | A non-`MASTER_EDIT` draft over `catalog.draftMaxPricePaise` (default ₹5,000) is denied **E-1108** ("call a supervisor"); a `MASTER_EDIT` role may exceed it | C2-via-draft |
| **Audit every draft sale** | `commitBill` writes a **`DRAFT_SALE`** audit row (entity `bill`, `{itemId,name,pricePaise,docNo}`) in the commit transaction for each line whose master row is still `status:'draft'` — a self-authored-item sale is no longer invisible in a review | review gap |
| **Scope cross-counter reads** | `GET /bills` for a caller without `REPORT_VIEW` is pinned server-side to the session user id — a cashier sees only their **own** bills; a manager keeps the full view | residual LOW/MED |
Two new settings seed at `system` scope (`catalog.defaultTaxClass=GST18`,
`catalog.draftMaxPricePaise=500000`), tenant-overridable per R9. **Proven live on a fresh DB**
(re-fired as cashier u1/4728): "Redteam Whiskey" `ZERO`@₹2000 → server forced **GST18**, billed
**CGST+SGST 30508 paise** (was ₹0); draft @ ₹9,999 → **400 E-1108**; two draft sales wrote **2
`DRAFT_SALE`** rows; Ramesh/Divya each saw only their own `/bills` while the owner saw all; and
the earlier C1/C2/C3 PoCs on existing items still fail closed. Legit quick-add still bills
end-to-end. **220 tests green** (9 new), typecheck + all three builds clean, dev DB reset and
store-server restarted on `:5181`. No git commits.
### M11 (MEDIUM) — offline customer PII on shared counter PCs CLOSED (POS client) 2026-07-13
Red-team M11 (docs/18): the POS offline IndexedDB (`sims-pos-offline`) held, unencrypted on
shared counter PCs, (a) queued offline bills carrying `customerName` + `buyerGstin`, and (b) a
catalog cache including the staff roster — readable by the next cashier or any same-origin script.
**Closed on the POS client (store-server untouched — a separate agent owns that tier):**
| Move | What changed | File |
|---|---|---|
| **No PII in the queued bill** | `QueuedBill` / `buildQueuedBill` no longer model or store `customerName`/`buyerGstin`; the queued record keeps **only `customerId`**. The drain (`POST /api/bills/offline`) already carried id-only, and the server re-resolves the buyer's name/GSTIN/place-of-supply from `customerId` + tenant at commit (`repos.ts:296-298`), identical to an online bill — so correctness is unchanged and no buyer PII is written to the device | `apps/pos/src/offline.ts`, `BillingScreen.tsx` `queueOffline` |
| **Wipe cache/roster on Lock & logout** | new `clearCatalogCache()` clears the whole `cache` store (store, staff roster, item master) from `App.onLock`; **un-drained queued bills are deliberately preserved** (a lock must never lose a sale) and the catalog is re-fetched on next login | `apps/pos/src/offline.ts`, `App.tsx` `onLock` |
**Verified** (server was mid-restart / down on :5181, so no full E2E — did the offline path instead):
typecheck clean + POS builds clean; **6 offline unit tests green** (asserting the PII-free queued shape);
and a **real-browser IndexedDB run** (Vite dev + dynamic import of the actual `offline.ts`) confirmed the
persisted queued record has `customerId` only — injected `customerName`/`buyerGstin` were dropped, nothing
PII in the serialized record — the catalog+roster cache was present then **cleared after lock**, while the
un-drained queued bill **survived** (count 1→1). **Residual:** the queued lines/amounts still sit in a plain
IndexedDB until they drain — true at-rest **encryption** of that remaining queue rolls into H3 (server-tier
SQLCipher), a separate finding. This fix removes the standing PII, not the queue itself. No git commits.
## P1 edge & transport hardening — DONE 2026-07-13 (red-team close-out)
The P1 "lock down the edges + TLS" workstream (docs/18) is **REMEDIATED**: **H1, M1, M2, M4,
M13, M14** closed, **M15** noted. Server changes stayed in `server.ts` + `api.ts` plus three
pure, unit-tested modules; the only new runtime dep is **helmet** (justified). Client edits were
limited to logout wiring + the POS post-login bootstrap re-fetch. (M5 — tenant-scoping
`commitBill`'s store/counter — belongs to the money-logic workstream and is untouched here.)
| Finding | What changed | File(s) |
|---|---|---|
| **H1** print SSRF | `/api/print` now an authenticated `apiRouter` route constrained to a **LAN IP** (127/8, 10/8, 172.16/12, 192.168/16; public/link-local rejected) on a **port allowlist** ({9100,9101,9102}, `SIMS_PRINTER_PORTS`) with a **≤64 KB** cap; the host is resolved and every resolved IP LAN-checked, then we connect to the resolved IP (no DNS-rebind) | `apps/store-server/src/print-guard.ts`, `api.ts` |
| **M1** `/bootstrap` minimize | the UNAUTHENTICATED payload drops each user's **role** and the tenant **GSTIN** (keeps id+name for PIN login); both restored when the SAME endpoint is called WITH a session — the POS re-fetches after login, back office is always authed | `api.ts` `/bootstrap`, `apps/pos/src/App.tsx` `onLogin` |
| **M2** verify-pin behind auth | `/auth/verify-pin` now requires a logged-in **caller** (`requireAuth`) and is rate-limited; the approver PIN still rides in the body — it is no longer an unauthenticated PIN oracle / token mint | `api.ts` |
| **M4** session TTL + logout + rate limit | sessions carry created+last-seen and are evicted past a **12 h absolute / 30 m idle** TTL (401 E-6110); **`POST /api/auth/logout`** deletes the session (POS "Lock" + back-office "Logout" now call it); a **global + per-IP `/auth/*`** limiter (20/min/IP, 200/min global, env-tunable) layers on the per-user lockout | `api.ts`, `rate-limit.ts`, `session-policy.ts`; POS `App.tsx`/`api.ts`, back office `main.tsx`/`api.ts` |
| **M13** TLS | HTTPS available: `SIMS_TLS_CERT`+`SIMS_TLS_KEY` ⇒ `https.createServer`; unset ⇒ HTTP dev (`:5181` unchanged). Dev-cert one-liner + "prod MUST supply certs" in `server.ts`; HSTS + `upgrade-insecure-requests` only under TLS | `server.ts` |
| **M14** security headers | **helmet** with a SPA-compatible CSP (`'self'` scripts + `'unsafe-inline'` styles + `blob:`/`data:` for the invoice/label tabs + `data:` fonts; the tabs' only inline script — `window.print()` — allowed by a single `'unsafe-hashes'` sha256) + explicit **`X-Frame-Options: DENY`** + `frame-ancestors 'none'` + nosniff + `Referrer-Policy: no-referrer` | `server.ts` |
| **M15** SW poisoning | noted — closes once prod is HTTPS (M13) | (doc) |
**Verified**: **245 tests green** (was 220; new `edge-guards.test.ts` pure cases +
`edge.test.ts` HTTP cases), typecheck clean on root + all three apps, all three apps build,
store-server rebuilt + restarted on `:5181` (HTTP, `/api/health` ok, helmet headers present, no
`X-Powered-By`). Live PoCs re-fired: unauth `/bootstrap` carries no role/gstin (authed does);
`/api/print` → public `8.8.8.8:9100` **400 E-2203** (no socket), `8.8.8.8:80` **400 E-2202**,
LAN `127.0.0.1:9100` **502 ECONNREFUSED** (guard passed), unauth **401**; unauth verify-pin
**401**, authed cashier caller **200 + approvalId**; logout then reuse **401**; a 1 s-idle
instance **401 E-6110** after the timeout; a cap-3 instance **429** on the 4th auth attempt.
Real browser: back office + POS login screens render with **zero CSP console violations** (the
POS tiles now show no role subtitle — the expected M1 effect). No git commits.
## P2 data-integrity & crypto — DONE 2026-07-13 (red-team close-out)
The P2 "harden-before-cloud" data-integrity slice (docs/18) is **REMEDIATED**: **H2, M3, M5, M9,
M10** closed; only **H3** (encryption at rest) stays open, owned by a separate agent next. Confined
to `apps/store-server/src/{db.ts,repos.ts,api.ts,server.ts}`, `packages/auth/src/pin.ts`,
`packages/domain/src/doc-series.ts`, + `dev-start.mjs` and `.env.example`. No new runtime deps.
| Finding | What changed | File(s) |
|---|---|---|
| **H2** tamper-evident audit log | `audit_log` gained `prev_hash`+`entry_hash`; `writeAudit` links each row into the tenant's SHA-256 hash chain **inside the existing commit transactions** (genesis `''`, rowid insertion order); `verifyAuditChain` + **`GET /api/audit/verify`** (AUDIT_VIEW) recompute it and name the first broken row. Tamper-EVIDENCE, not prevention (the file is still writable — that is H3) | `db.ts`, `repos.ts`, `api.ts` |
| **M3** PIN pepper | `hashPin`/`verifyPin` HMAC-SHA256 the secret with a server-held pepper (`SIMS_PIN_PEPPER`) **before scrypt**, so a stolen DB can't be brute-forced against the 10k PIN keyspace without the env-only pepper; unset ⇒ raw-secret fallback (dev unchanged) + one-time warning; `.env.example` added | `packages/auth/src/pin.ts`, `.env.example` |
| **M5** tenant-scope commit | `commitBill` selects store AND counter `WHERE id=? AND tenant_id=?`**400 E-1110** before any money logic (kills the undefined-deref 500 + any cross-tenant reference); `/bills` + `/bills/offline` both covered. (E-1110, not the task's E-1109 — that code is already payments-≠-payable) | `repos.ts` (already present from the H5/anchoring pass; verified + tested) |
| **M9** 4-digit FY series | `seriesPrefix` embeds the 4-digit FY start year (`ST1C22026-000123`); FY 2026-27 vs 2126-27 no longer collide. The counter↔FY `/` is dropped to fit the GST 16-char rule at 6-digit sequences (store+counter must total ≤ 5 chars) | `packages/domain/src/doc-series.ts`, `repos.ts` |
| **M10** demo-seed gate | `seedIfEmpty`/`ensureBatchTrackingSeed` run only under `SIMS_SEED_DEMO=1`; a prod empty DB never auto-creates the known-credential owner (thomas/9174/sims); dev `npm start``dev-start.mjs` sets the flag | `server.ts`, `dev-start.mjs`, `package.json` |
**Verified**: **260 tests green** (was 245; +5 audit-chain, +4 tenant-scope, +1 `/audit/verify` HTTP,
+4 PIN-pepper, +1 4-digit-FY collision), typecheck clean on root + all three apps, all three apps build.
Dev DB reset and store-server restarted on `:5181` with `SIMS_SEED_DEMO=1` (`/api/health` ok, seeded).
Live PoCs: a fresh bill's doc no **`ST1C22026-000001`** (16 chars); owner `GET /api/audit/verify` **`ok:true`**,
then a direct `UPDATE` of one `audit_log` row → **`ok:false`** with `brokenAtId` = that row; a bill with a
foreign `storeId`**400 E-1110** (not 500); an isolated instance with `SIMS_SEED_DEMO` **unset** → **0
tenants**, thomas login **401**. **H3 (encryption at rest) is the only open P2 item.** No git commits.
### H3 (HIGH) — encryption at rest CLOSED 2026-07-13 (red-team close-out)
The **last** open red-team finding is **REMEDIATED**: the store DB is **full-DB encrypted at rest
with SQLCipher**, closing H3, the F5 at-rest concern, and the M11 residual (the offline queue drains
into a now-encrypted server tier). `better-sqlite3` was swapped for the API-compatible drop-in
**`better-sqlite3-multiple-ciphers`** (same API + `pragma('key=…')`; it ships its own
DefinitelyTyped-derived types, so the `DB` type and every call are unchanged — **no shim, no
loosened strictness**). Confined to `apps/store-server/src/db.ts`, `package.json`,
`build-server.mjs`, `.env.example`; no other hardening touched.
| Move | What changed | File(s) |
|---|---|---|
| **Key the DB from the environment** | `openDb()` reads **`SIMS_DB_KEY`**; when set it keys the DB (`cipher='sqlcipher'`, `key=…`) **before any read**, so `sims.db` + its WAL/SHM sidecars are ciphertext. The key is held ONLY in the server env (machine-scoped DPAPI/keychain), never in the repo or DB; quotes in it are escaped | `apps/store-server/src/db.ts` |
| **Dev fallback preserved** | Key UNSET ⇒ open unencrypted exactly as before + a **one-time** warning that prod MUST set it (mirrors the `SIMS_PIN_PEPPER` pattern), so `:5181` keeps working with no key | `apps/store-server/src/db.ts`, `.env.example` |
| **Native module externalled** | esbuild externals `better-sqlite3-multiple-ciphers` (as it did `better-sqlite3`) so the server bundle stays buildable | `apps/store-server/build-server.mjs` |
**Path taken: full-DB encryption** — the native package installed from a **prebuilt binary** (no
node-gyp) on this Windows / Node 22 box, so the app-level field-encryption fallback was not needed.
**Proven live** on the reset, keyed `:5181`: (a) `head -c 16 data/sims.db` → random `28 85 83 94…`,
**not** `SQLite format 3\0`, and grep of db/WAL/SHM for `MegaMart`/`Lakshmi`/the phone/the GSTIN found
**no cleartext**; (b) opening `data/sims.db` **without** the key (and with a **wrong** key) → `file is
not a database`, while the **correct** key decrypts it; (c) the app works end-to-end through the
cipher — cashier login **200**, `GET /items?all=1` **200** (11 items), `POST /bills`**`ST1C22026-000001`**,
owner `GET /audit/verify`**`{ok:true}`**. **265 tests green** (was 260; +5 new `encryption.test.ts`),
typecheck clean on root + store-server, all three apps build. **Production still must**: supply
`SIMS_DB_KEY` via a machine-scoped secret (DPAPI/keychain, never a file beside the DB); migrate any
existing plaintext `sims.db` once offline (`sqlcipher_export`) before the first encrypted boot; treat
rotation as a re-key; back up the key with the DB; and tighten data-dir ACLs. No git commits. **All
red-team findings are now closed.**
## Returns / credit notes — LIVE 2026-07-13 (first increment: return against an original bill)
The **RETURNS / credit-note flow** ships end-to-end (POS F9 → server → back office), built
security-first so it rides — never weakens — the money/commit boundary the red-team hardened
(docs/18). A return is a new **immutable `SALE_RETURN` document** referencing the original bill
(R6): everything anchors to OUR stored original bill, the refund is server-computed, over-return
is blocked, and the whole commit (credit-note series, stock-back, refund leg, outbox, hash-chained
audit) lands in ONE transaction (R15/R17). Scope this increment: **return against an original
sale**; exchanges and split-tender refunds are deferred (below).
| Move | What changed | File(s) |
|---|---|---|
| **Shared return engine (R5)** | new pure `computeReturn` — the money half of a credit note, shared BYTE-FOR-BYTE by the POS (refund preview) and the server (authoritative commit), exactly like `computeBill`. It REVERSES the original line PROPORTIONALLY by `returnQty/soldQty` and at the **ORIGINAL captured rate** (it scales the stored taxable/CGST/SGST/IGST/cess, never today's tax master — so a return reverses tax at the rate on the bill even if the master changed since, R5/R10). Integer paise; `taxable + taxes == lineTotal` by construction; a full-line return reproduces the original exactly | `packages/billing-engine/src/returns.ts` |
| **Credit-note series (M9 reuse)** | `creditNotePrefix` — a **`CN`-marked** per-counter, 4-digit-FY series so a credit note can never collide with a sale in `bill`'s `UNIQUE(tenant, doc_no)`; the CN marker costs 2 chars, so the sequence is 4 digits to hold the GST 16-char rule: `CNST1C22026-0001` (16) | `packages/domain/src/doc-series.ts` |
| **Server `commitReturn` (the trust boundary)** | loads the original bill by **id AND tenant_id AND doc_type='SALE'** (cross-tenant / nonexistent / non-sale → **400 E-1320**); reads prices/tax/HSN/splits **from the stored original payload, never the client**; enforces **returnable qty** = returned-so-far (Σ prior `SALE_RETURN` refs) + this qty ≤ sold (over-return → **400 E-1324**); rejects qty ≤ 0 / fractional-on-whole-unit (**E-1323**); computes the refund server-side and **409 E-1325** if a client-declared total disagrees (mirrors `commitBill`). In one txn: the `SALE_RETURN` bill row (refDocId = original, buyer/place-of-supply snapshotted FROM the original), the CN series no, **stock ADDED BACK** (positive `qty_delta`, reason `SALE_RETURN`, carrying the original line's `batch_id`), the refund payment leg, the outbox row, and a **`RETURN_COMMIT`** audit row hash-chained in-txn (H2) | `apps/store-server/src/repos-returns.ts` |
| **API** | `POST /api/returns` (gated **`RETURN_CREATE`** per-route authz — cashiers have it), `GET /api/returns` (credit notes, tenant-scoped, paginated, cashier pinned to own per the M-fix scoping), `GET /api/bills/:id/returnable` (sold/returned/returnable per line + raw lines for the preview), `GET /api/bills/lookup?docNo|phone` (targeted SALE lookup for the return screen). `listBills` now filters to `doc_type='SALE'` so credit notes never pollute the sales list or POS history | `apps/store-server/src/api.ts`, `repos.ts` |
| **POS — F9 returns** | F9 opens a keyboard-first returns overlay: look up the original by **bill no or customer phone**, a line grid with sold/returned/**returnable-qty inputs**, a **reason** dropdown (Damaged / Wrong item / Expired / Customer changed mind / Other), a **live server-matching refund preview** (client `computeReturn`), commit → prints a **CREDIT NOTE** receipt. Esc cancels, focus discipline kept, over-return + zero-qty blocked inline | `apps/pos/src/BillingScreen.tsx`, `api.ts` |
| **Back office — `/sales/returns` LIVE** | the registry wireframe is replaced (CUSTOM map) by a live, tenant-scoped, paginated credit-note list (CN no, against-bill, date, cashier, customer, reason, refund) — pattern-copied from `BillsPage` | `apps/backoffice/src/pages/live.tsx`, `main.tsx`, `api.ts` |
| **Printing** | `receiptLines`/`renderReceipt` gained a `variant:'credit-note'` — a **CREDIT NOTE** banner, the original bill ref, REFUND/Refund-via labels and the reason, on the one shared content model | `packages/printing/src/receipt.ts` |
**Verified (R23 — not just unit tests): 333 tests green** across all suites, 0 failing (+12 for this
flow — 4 in a new engine `returns.test.ts`, 8 in a new server `returns.test.ts` covering over-return,
refund-from-original-not-client 409, tax reversed at the original rate, cross-tenant/non-sale
reject, the `RETURN_CREATE` gate, doc_no ≤16), typecheck clean on root + all 3 apps, all 3 apps
build, store-server rebuilt + restarted on `:5181` (plaintext
dev DB, `SIMS_SEED_DEMO=1`). **Real-HTTP E2E on `:5181`** (17/17 checks): cashier `u1/4728` billed
3× Surf Excel → `ST1C22026-000001` (stock 120→117) → returned 2 → **`CNST1C22026-0001`** (16 chars),
**refund ₹290.00 = 2/3 of the taxed line**, **stock 117→119 (+2)**, **GST reversed CGST+SGST 4424 paise
at the original GST18 rate** (not ₹0), back-office `/returns` lists it against the original; a second
2-unit return (only 1 left) → **400 E-1324**; abuse cases all fail closed — client-inflated refund →
**409 E-1325**, out-of-range line → **400 E-1321**, qty > sold → **400 E-1324**, nonexistent/cross-tenant
original → **400 E-1320**; `GET /audit/verify` stays **`{ok:true}`** with the `RETURN_COMMIT` row chained
in. **Real browser (Chrome, both surfaces):** back office `/sales/returns` renders the credit note LIVE;
POS **F9** → looked up `ST1C22026-000002` → line grid (Sold 3 / Returned 0 / Left 3) → qty 2, reason
"Wrong item" → **live REFUND ₹290.00** → committed **`CNST1C22026-0002`** (gap-free series), Surf Excel
on-hand settled at 118 (sold 6, returned 4). No git commits. **Deferred (follow-ups):** exchanges
(return + re-sell in one document); partial refund split to a **different** tender than the original;
an A4 credit-note document (the thermal CREDIT NOTE receipt ships now); offline-queued returns (the
return path needs the server up — the sale's S4 queue is unchanged); a physical thermal print of the
credit note (unit- + browser-verified, no 80 mm printer here).
## GST RETURNS — LIVE 2026-07-13 (GSTR-1 + GSTR-3B + HSN from real transaction data)
The **GST returns** phase ships: GSTR-1, GSTR-3B (3.1a) and the HSN summary are generated
**live from the counters' immutable SALE + SALE_RETURN documents** and the wireframe GST pages are
now real. It is **READ-ONLY reporting** — no writes, no new money-commit surface — but
**compliance-critical**, so it never recomputes tax: every amount is read straight off the stored
document line **as captured**, so a return reduces liability at the **original bill-date rate** even
after a slab change (GST-1/GST-2/GST-10). Integer paise throughout (R4); the only math is addition
of already-rounded amounts, so books == report == JSON to the paise (GST-3). Reconciliation is a
first-class, tested output.
| Move | What changed | File(s) |
|---|---|---|
| **Shared pure GST-return engine (R5)** | new `computeGstr1` / `computeGstr3b` / `reconcile` over a list of documents. **GSTR-1 sections**: B2B (Table 4A, grouped by buyer GSTIN + rate), B2CL (Table 5, inter-state B2C invoices above the **₹2,50,000** dated threshold, per invoice), B2CS (Table 7, aggregated by place-of-supply + rate, **net of small B2C credit notes**), CDNR (Table 9B, registered credit notes) + CDNUR (unregistered inter-state large), HSN summary (Table 12, net of returns) and documents-issued (Table 13). **GSTR-3B 3.1(a)**: outward taxable value + IGST/CGST/SGST/cess, **net of credit notes** (ITC/inward is an explicit stub). Each line lands in exactly one section, so the reconciliation identity holds by construction | `packages/billing-engine/src/gst-returns.ts` |
| **Reconciliation as a compliance gate** | `reconcile()` proves **Σ GSTR-1 section taxables (returns negative) == Σ source == HSN total == GSTR-3B outward**, and likewise for total tax; any inequality is named and `ok:false`. Surfaced on every page and in the API response — a non-reconciling period says "do not file" loudly rather than papering over | same |
| **Server repo (read-only)** | `gstr1` / `gstr3b` / `hsnSummary` / `gstr1PortalJson` read the period's SALE+SALE_RETURN by `business_date` (each bill carries its own store state so intra/inter is per-document), fold through the engine, and attach the reconciliation. B2CL threshold is a **dated tenant setting** `gst.b2clThresholdPaise` (R9/R10, seeded ₹2,50,000). The `.json` builder emits a **pragmatic GSTN-shaped** export (b2b/b2cl/b2cs/cdnr/cdnur/hsn/doc_issue, rupee amounts) documented in-payload as **NOT a certified filing** | `apps/store-server/src/repos-gst.ts`, `validate.ts` (`assertPeriod`), `seed.ts` |
| **API** | `GET /api/gst/gstr1?period=YYYY-MM`, `/gstr3b`, `/hsn`, `/gstr1.json` — all gated **REPORT_VIEW**, tenant-scoped, period-validated (E-1330) | `apps/store-server/src/api.ts` |
| **Back office LIVE pages** | `/gst/gstr1`, `/gst/gstr3b`, `/gst/hsn` replace the registry wireframes (CUSTOM map): month picker, a green/red **reconciliation banner**, stat cards, every section as a table (tables-rule R1), a **Download JSON** button, and a clear **"not a certified filing — export for your CA / GSP"** note on each | `apps/backoffice/src/pages/gst.tsx`, `main.tsx`, `api.ts` |
**Verified (R23): 351 tests green** (was 333; +12 engine `gst-returns.test.ts` — hand-computed B2B/B2CL/B2CS/CDNR/HSN, threshold behaviour, and the reconciliation identity incl. a deliberate-drift catch; +6 server `gst.test.ts` — sections over a known month, 3B hand-checked, reconciliation, empty period, valid JSON, and the REPORT_VIEW gate). Typecheck clean on root + all 3 apps; all 3 apps build. Dev DB reset + store-server rebuilt and restarted on `:5181` (plaintext, `SIMS_SEED_DEMO=1`, no db key).
**Live proof on `:5181`** — a known single-month set created via the API (period 2026-07): a **B2B** invoice `ST1C22026-000001` (2× Surf Excel to Anand Traders `27ABCDE1234F1Z0`, inter-state IGST), two **B2C** cash bills (`-000002` Surf+Amul, `-000003` 3× Parle), and a **credit note** `CNST1C22026-0001` (full Amul line of bill 2). The reconciliation ties out exactly:
| Figure (paise) | Source docs | GSTR-1 net | HSN summary | GSTR-3B outward |
|---|--:|--:|--:|--:|
| **Taxable** | 62,578 | 62,578 | 62,578 | 62,578 |
| **Total tax** | 7,922 | 7,922 | 7,922 | 7,922 |
3B hand-check: **Σ bill tax ₹108.68 Σ credit-note tax ₹29.46 = ₹79.22** == 3B total tax. GSTR-1 B2B shows the GSTIN invoice (taxable ₹245.76, IGST ₹44.24); B2CS aggregates the cash bills by rate (5% ₹257.14, 18% ₹122.88) with the returned Amul (GST12) bucket **netting to zero and dropping**; HSN totals ₹625.78; the `.json` export is valid (gstin `32ABCDE1234F1Z5`, fp `072026`). **Real browser (login thomas/sims):** `/gst/gstr1` and `/gst/gstr3b` render the LIVE numbers with the green **Reconciled** banner, all section tables, and the not-certified note (screenshots captured; MCP browser was wedged so driven via Puppeteer, per the S5 precedent). No git commits.
**Deferred (follow-ups, noted for compliance honesty):** ITC / inward-supply reconciliation (GSTR-2A/2B, 3B Table 4 — purchase data exists); GSP e-filing (the JSON is an approximation, not a signed return — doc 17 §S2/D4); dedicated nil-rated / exempt / non-GST split tables (GST-4; 0%-rated supply currently sits as rate-0 buckets in the taxable sections); amendments (B2BA/CDNRA…); the cross-period **filing-freeze / sync-completeness** gate + soft-lock (GST-11); the 30-Nov credit-note guillotine alerting (GST-10); HSN Table-12 dropdown validation (GST-13); composition-scheme (Bill of Supply) returns (GST-6). Multi-store tenants are handled per-document (each bill's own store state drives intra/inter).
## The foundation (packages/ — real, tested code, not wireframe)
| Package | What it does | Tests |
@ -49,9 +269,41 @@ audio feedback (ok/warn/err beeps), ok-notices auto-clear, stock chips in sugges
(live counts, out-of-stock warn-not-block). Bill ST1C2/26-000003 proves the chain.
Purchase Entry P1: crash-proof localStorage draft with resume banner; glance strip on
cell focus (stock · sale · MRP · live margin · last-3 costs via /purchases/cost-history).
Still pending: returns flow, supervisor PIN overlay (needs /auth/verify-pin),
Returns/credit notes now LIVE (F9 — see the Returns section above). Still pending:
purchase schema P1s (free-qty, disc%, interstate split — one migration pass together),
history-R repeat, customer display, offline queue.
history-R repeat, customer display.
**Supervisor PIN gate + override audit (P0-2 / P1-3) — done 2026-07-10.** In-flow price/
discount overrides now raise a supervisor PIN instead of applying silently (R20, 09-UX §5.5).
New `POST /api/auth/verify-pin` reuses the existing `attempt()` scrypt + lockout but creates
**no session** (never pollutes `sessions`): success audits `PIN_VERIFY`, a wrong PIN audits
`PIN_VERIFY_FAILED` and increments the same per-user lockout (5 fails → locked 60 s, E-6104),
and only supervisor/manager/owner roles may approve — a valid-PIN cashier is rejected (E-6105).
`POST /api/bills` gained an `overrides[]` body; `commitBill` writes one audit row per entry
(`PRICE_OVERRIDE`/`QTY_OVERRIDE`/`DISCOUNT_OVERRIDE`, entity `bill`, before/after JSON with the
approver resolved to a display name) **inside the existing commit transaction** (R17). POS: a
`POS_LIMITS` const (`PRICE_BAND_PCT=10`, `DISC_WARN_PCT=10` — module consts today, DB `setting`
rows later per R9) tops `BillingScreen`; a reusable `supervisorGate(desc)` promise renders an
**inline** panel (never a modal) replacing the notice area — supervisor picker (bootstrap
`users`, cashiers excluded), 46 digit PIN, 3 wrong PINs closes + cancels the edit;
`verifySupervisorPin` verifies without touching the module token (the cashier stays logged in).
F3 price: >MRP stays hard-blocked; a cashier's change (beyond ±band or any change) raises the
gate, owner/supervisor/manager self-authorise via `gateFor` (@sims/auth). F4 discount > 10% of
line value raises the same gate. Approved edits accumulate in an `overrides` state array (paise
+ approvedByUserId), ride with `postBill`, and clear on commit/cart-clear. Verified 2026-07-10:
**117 tests green**, typecheck clean on root + all three apps, all three apps build, store-server
rebuilt + restarted. Browser E2E: Ramesh (cashier) billed Surf Excel → F3 145.00→120.00 (beyond
10% band) → inline panel appeared (picker showed only Suresh + Thomas) → a wrong PIN showed
"attempt 1 of 3" → Suresh PIN 8265 approved → F4 20% discount (₹24 > 10%) raised the gate again
→ approved → F12 committed **ST1C2/26-000006** (₹96.00, engine recomputed both edits) → back
office (thomas/sims) Audit Log shows **PRICE_OVERRIDE** `before 14500 → after 12000, approvedBy
Suresh` and **DISCOUNT_OVERRIDE** `before 0 → after 2400, approvedBy Suresh`. Also confirmed via
API: a cashier picked as approver is rejected (E-6105) and 5 wrong PINs lock the user (E-6104).
Back-office polish: the Audit detail column now renders `before → after` for override rows.
**Caveats:** override records clear on hold/cart-clear (a resumed held cart re-approves);
`POS_LIMITS` awaits the DB settings surface (R9 follow-up); the web bundle imports the pure
`@sims/auth/permissions` subpath because the barrel re-exports scrypt (node:crypto) — added a
matching vite alias + tsconfig path. No git commits.
**Catalog scale (S1 + S6) — done 2026-07-10.** New `@sims/search-core` (pure TS, no
deps) replaces BillingScreen's linear name filter: `phoneticKey` folds the Indic
@ -138,6 +390,202 @@ T.Nagar — a seed-data quirk unrelated to S3; the e-Invoice/IRN (GSP) hook rema
at the document, per doc 17 §S3). Unrelated pre-existing failure: `apps/hq/test/gmail.test.ts`
imports a not-yet-added `../src/gmail` (HQ-console in-flight work, untouched by S3).
**Counter resilience / offline queue (S4) — done 2026-07-10.** A browser POS now
survives the store-server dying mid-shift: billing continues, nothing is lost, the queue
drains on reconnect (02-ARCHITECTURE §3, R18 cart-never-lost). New pure
`apps/pos/src/offline.ts` (**raw IndexedDB, zero new deps**): when `postBill` fails with a
**network** error — the server unreachable, distinguished from a server that *answered*
with a 4xx/5xx (which still just keeps the cart on screen) via a new `NetworkError`
type — the sale is persisted to an IndexedDB `bills` store with a **client UUIDv7** id and
a provisional token **OFF-n**, the cart clears, and billing continues; the receipt prints
**"PROVISIONAL OFF-n (offline)"** instead of a GST doc no, and an amber
**"OFFLINE · n queued"** chip shows in the status bar. A background **reconnect drain**
(every 15 s ping `/api/health`, also on the window `online` event) POSTs queued bills
**oldest-first** to a new **idempotent `POST /api/bills/offline`**: the client bill id is the
idempotency key, stored on a new `bill.client_id` column (PRAGMA-guarded `ALTER` + a
partial `CREATE UNIQUE INDEX … (tenant_id, client_id) WHERE client_id IS NOT NULL`), and
`commitBill` short-circuits on a known client id, so a **re-drained payload returns the
already-assigned doc no — never a second bill or stock movement**. The **real GST series
number is allocated at drain time** and engine re-verification (R5) still runs; the POS
maps each **OFF-n → the real doc no** in a notice and deletes it from IDB. **The drain
endpoint is authenticated** (security review, SEC-1): it sits below `requireAuth` and takes
tenant + cashier from the cashier's **live session**, never from the body — an earlier
session-independent variant that trusted a body `cashierId` was an unauthenticated
bill-commit hole and was removed. If the reconnected server has restarted (its in-memory
sessions gone) the drain simply **401s and the queue waits until the counter re-logs in**
nothing is lost; a transient blip that leaves the process alive drains seamlessly. **Override
approvals are unforgeable** (security review, SEC-2): `/auth/verify-pin` now mints a
**single-use token** (new `override_approval` table) that the POS sends with each override
(`approvalId`, replacing the old client-supplied `approvedByUserId`); `commitBill` redeems
it **inside the commit transaction** (must be unused, < 5 min old, approver still holds a
supervisor/manager/owner role), marks it used, writes the audit row from the **server-side**
approver identity, and **rejects the whole commit** (rolls back) on any mismatch — the client
can no longer claim an approver it never got. **Item-cache persistence:**
the catalog (items + tax classes + store/users) is saved to IndexedDB at login and the
session token to `sessionStorage`, so a **mid-shift browser refresh with the server down
resumes straight to billing** behind an **"OFFLINE — using cached catalog"** banner (a
fresh login still needs the server — a locally-cached PIN-hash login is deliberately out
of scope). **App shell:** a minimal cache-first service worker (`apps/pos/public/sw.js`,
registered in `main.tsx`, scope `/pos/`) serves the built assets from cache when the
server is down; `/api/*` is **never** cached, so the queue + drain logic stay in control.
Verified 2026-07-10: **164 tests green** (6 offline-serialization cases —
`nextProvisionalToken` / `buildQueuedBill` / `toOfflineBillBody` — plus 4 new server-side
`override_approval` security cases: fabricated / replayed / non-approver-role tokens and a
missing-approval override are each rejected, a genuine token audits the server-side
approver), typecheck clean on root + all three apps, all three apps build, store-server
rebuilt and restarted (migration added `bill.client_id` + the partial unique index + the
`override_approval` table to the running dev DB). **Full browser E2E:** Ramesh/4728 → billed Surf Excel online (**ST1C2/26-000007**)
**killed the store-server** → billed Parle-G then Maggi (each showed **"saved OFF-n"**, the
amber **OFFLINE · n queued** chip, cart cleared; both persisted to IndexedDB with valid
uuidv7 ids) → **restarted the server** → the drain mapped **OFF-1 → ST1C2/26-000008** and
**OFF-2 → ST1C2/26-000009**; back-office **Bills** shows both with real doc nos and Parle-G
carries exactly one `SALE -1` movement (**no duplication**). **Idempotency proven** by
re-POSTing a drained payload manually — the server returned the same doc no with
`duplicate:true`, bill count unchanged. **Offline resume proven** by killing the server and
reloading `/pos/` — the app shell loaded from the SW cache and resumed to the billing
screen with the cached catalog + banner (quick keys populated from IndexedDB, no server).
**Security-review fixes verified E2E:** an unauthenticated `POST /api/bills/offline` now
returns **401**; after a full restart the stale-session drain 401s, the queue **holds OFF-n**
(nothing lost) and the POS shows a **"Sign in to sync N offline bills"** prompt, then after
**re-login** the authenticated drain committed the queued bill (e.g. **OFF-4 →
ST1C2/26-000015**) with `cashier_id` taken from the session, not the body. For
overrides, a **fabricated** approvalId and a **replayed** (already-used) approvalId are each
rejected **403** with the whole commit rolled back and **no doc-series gap**, a **cashier**
PIN yields no token (403), while a genuine **Suresh/8265** approval committed and audited
from the server-side identity. **Gate E2E (approvalId path):** F3 price change ₹145.00 →
₹120.00 (beyond the ±10 % band) as Ramesh raised the inline supervisor panel → **Suresh/8265
approved** → committed **ST1C2/26-000012** at ₹120.00, and the **Audit** row records
**PRICE_OVERRIDE** with `approvedBy: Suresh` / `approvedByUserId: u2` **read server-side from
the redeemed token** (by Ramesh), plus the matching PIN_VERIFY. **Honest caveats:** the
reconnect drain needs a live session, so after a full server *restart* the queue waits for
the counter to **re-log in** before it drains (a transient blip that leaves the process alive
drains seamlessly) — and a new online bill likewise needs a fresh login after a restart (the
401 keeps the cart and does **not** queue; only true network failures queue); an override
approval token is valid for **5 minutes**, so in the rare race where a supervisor approves
online and the server dies before commit, that one queued bill's override would expire before
drain (in practice offline bills carry no overrides — the gate itself needs the server up);
the service worker precaches only the navigation root on install, so the full shell caches on
the first *controlled* load (one online reload) and a new deploy is seen only after the SW's
`CACHE` version bumps; provisional receipts can't physically print while the server (the
print relay) is itself down.
**Batch & expiry (S5) — done 2026-07-10.** Perishables now carry lots from purchase to
counter to dashboard, with FEFO picking and an expired-sale guard. New pure
`@sims/domain/batch.ts` (`sortFefo` earliest-expiry-first/undated-last, `isExpired`,
`pickFefoBatch` = earliest **non-expired** lot else earliest) is the single FEFO rule both
surfaces and the tests share (ISO dates compare lexicographically — no Date parsing).
**Schema (R7 preserved):** a new `batch` table (uuidv7 id, tenant/store/item, batch_no,
**nullable** expiry_date, UNIQUE(tenant,store,item,batch_no)); `stock_movement` and
`purchase_line` gained a nullable `batch_id` (CREATE TABLE additions + a PRAGMA-guarded
`migrate()` for the running dev DB — the `idx_move_batch` index lives in `migrate()` only,
after the ALTER, so it never races the SCHEMA pass on an upgraded DB). **Per-batch on-hand
stays derived** — `SUM(movements WHERE batch_id=lot)`, never stored. **Seed/migrate**
marks Amul Butter (107) and Maggi (105) `batch_tracked` and, if no batch exists, splits
each item's opening 120 into lots (one **expired-with-stock**, one **≤7-day**, one **~6-month**
for Amul; ≤7-day + ~6-month for Maggi) via per-lot OPENING movements **offset by one
negative un-batched OPENING adjustment** so total on-hand is unchanged (idempotent; runs
from `seedIfEmpty` for fresh DBs and from `server.ts` for the already-populated dev DB).
**Purchase capture:** for a batch-tracked item the Enter chain extends qty→cost→**batch
no→expiry** (date input; blank Enter skips expiry for non-perishables) →scan; the server
`upsertBatch`es the lot (unique key or new, filling a later expiry) and carries `batch_id`
onto the purchase line and the stock-IN movement. **POS FEFO pick (R18 — never modal):**
`GET /api/batches` (per-batch on-hand>0, item-keyed) is cached at login and refreshed after
each commit; adding a batch-tracked item raises an **inline** picker, FEFO-sorted with the
first sellable lot preselected (↑/↓ move, Enter accepts), **expired lots flagged red and
blocked with E-1310** unless approved — a supervisor+ sells on their own authority, a
cashier raises the existing `supervisorGate`, and the approval rides as a new `kind:'batch'`
override audited **BATCH_OVERRIDE** server-side (single-use token, same SEC path as price/
discount). The committed line carries `batchId`; `commitBill` writes the movement's
`batch_id` and **rejects (rolls back) any batch that doesn't belong to the item+store
(E-1311)**. **Expiry dashboard is LIVE** (replaces the registry wireframe via the CUSTOM
map): `GET /api/expiry` feeds ≤7-day and ≤30-day buckets with per-batch on-hand + value at
sale price, an expired-with-stock section, and a dead-stock note; the Stock page gained a
batch-tracked second table + a "batch" tag. **Verified 2026-07-10: 178 tests green** (6 new
FEFO domain cases + 8 server batch cases: seed totals-preserved/idempotent, purchase upsert
+ reuse, batch-attributed decrement, E-1311 belongs-check, BATCH_OVERRIDE audit), typecheck
clean on root + all three apps, all three apps build, store-server rebuilt and restarted
(migration added the batch table + `batch_id` columns + demo lots to the running dev DB;
totals held — Amul 120, Maggi 119). **Full browser E2E (Puppeteer, real Chromium):** back
office (thomas/sims) → Purchase Entry → Amul Butter, Enter chain captured **batch B-TEST /
expiry 2026-07-17**, F12 **posted**; Expiry Dashboard rendered live — **≤7-day ₹6,350 / 3
batches** (B-105A ₹1,400, B-107A ₹3,300, B-TEST ₹1,650) and **Expired-with-stock ₹2,200 /
1 batch** (B-107X). POS (Ramesh/4728, Skip count) → billed Amul Butter → the **inline FEFO
picker** listed B-107X (EXPIRED, red) · **B-107A (FEFO-preselected, exp 2026-07-15)** ·
B-TEST · B-107B → Enter committed **ST1C2/26-000016** and **B-107A on-hand fell 12→11**
(per-batch, not item-wide); a second bill navigated (↑) to the expired **B-107X** → the
inline gate showed **"batch B-107X EXPIRED 2026-07-07 — approve sale? (E-1310)"** with
Suresh/Thomas as approvers → cancelled, so B-107X stayed at 8 (never sold). **Honest
caveats:** the server validates a batch belongs to the item+store but does **not** itself
re-block expired sales — the E-1310 gate is POS-side with a BATCH_OVERRIDE audit trail
(server-side expiry enforcement is a follow-up); the batch cache is fetched in the billing
screen at mount (effectively login) and after each commit, but is **not** written to the S4
offline IndexedDB cache, so a batch-tracked sale made while the server is down bills
un-batched with a warning; the demo dead-stock note flags by expiry only (velocity-based
slow-mover ranking is a follow-up); the chrome-devtools MCP was unusable this session (its
browser handle wedged into a "browser already running" state that survived killing every
Chrome + the profile lock and needs an MCP-server restart), so the browser E2E was driven
via Puppeteer against the same running store-server instead. No git commits.
**Load verification (S7) — done 2026-07-10.** The 15-EDITIONS scale target is now
verified by load test, not assumed (R25). A scripted run (`apps/store-server/load/loadtest.mjs`,
plain node 22 + better-sqlite3, no new deps) drove **2,500 real bill commits distributed
across 10 counters** at a concurrency of 10 (one in-flight request per counter) against a
**second, isolated store-server instance on :5281 with a fresh data dir** — the dev DB and
the running :5181 server were never touched (new `SIMS_DATA_DIR` env in `db.ts` points the
isolated instance at its own data dir; the seed makes C1/C2, so C3C10 were added as real
counter rows with their own per-counter GST series). Each bill was a randomized 15-line
cart from the seeded catalog (the 2 batch-tracked items skipped); the client payable is
`roundToRupee(Σ qty·price)` — exact for tax-inclusive, no-discount bills — and the server
still re-verified every document to the paisa with the shared engine (R5). A burst phase
then pushed 100 bills at concurrency 50 to find the knee.
**Environment (single dev machine — not a distributed benchmark):**
| Field | Value |
|---|---|
| Node | v22.20.0 |
| CPU | 12× 13th Gen Intel Core i5-1335U |
| RAM | 15.69 GB |
| OS | Windows 11 (10.0.26200) |
| Target | one store-server process, isolated data dir, port 5281 |
**Results:**
| Phase | Bills | Concurrency | Success | Wall | Throughput | p50 | p95 | p99 | max |
|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|
| Steady | 2,500 | 10 (1/counter) | 2,500 (100%) | 8.64 s | **289.4 bills/s** | 28.8 ms | 54.6 ms | 76.0 ms | 397.6 ms |
| Burst | 100 | 50 | 100 (100%) | 1.16 s | 85.9 bills/s | 445.9 ms | 737.4 ms | 746.5 ms | 748.7 ms |
**Integrity (queried directly against the load DB after the run):**
| # | Check | Result |
|---|---|---|
| a | bill rows == successful commits | **PASS** (2,600 == 2,600) |
| b | every counter's SALE series gap-free & duplicate-free | **PASS** (c1c10 each `1..260`) |
| c | stock derived == opening billed, per item (R7) | **PASS** (9 items reconciled; 7,800 SALE movements == 7,800 billed lines) |
| d | every bill's payments sum == payable | **PASS** (2,600 bills, 0 mismatch) |
| e | audit `BILL_COMMIT` count == bill count (R17) | **PASS** (2,600 == 2,600) |
| f | outbox rows == bill count (R15) | **PASS** (2,600 == 2,600) |
**Honest interpretation.** Steady-state throughput on this dev laptop is **~289 committed
bills/sec** with a sub-30 ms median. Reading the S7 target at its heaviest (10 counters ×
2,500 = 25,000 bills/day), the entire day's work is **~86 s of server-busy time** spread
across a full trading day — 23 orders of magnitude of headroom; the lighter reading (2,500
bills/day for the store) completes in **8.6 s**. `better-sqlite3` serializes writes in-process
(one synchronous commit at a time), so throughput is bounded by single-core commit cost
(~3.5 ms/bill) and concurrency buys latency, not throughput: the burst at concurrency 50
inflated p50 latency ~15× (29 ms → 446 ms) **without** raising throughput (it measured lower),
so the knee is well below 50 — the realistic 10-counter operating point already sits near the
single-write-core ceiling with a wide latency margin. Correctness held perfectly under load:
zero failed commits, and all six integrity invariants (no series gaps/dups across 10
independent counters, exact stock reconciliation, payments == payable, one audit + one outbox
row per bill) passed on 2,600 bills. Incidentally confirmed the 2-digit counter code C10 stays
within the GST 16-char doc-no rule at 6-digit sequences (`ST1C10/26-000260`). These are
single-machine dev-hardware numbers; a store-server on real store hardware and the future
Oracle 12c adapter (D12) will have different constants, but the serialization model and the
integrity guarantees are the same. No git commits; typecheck clean on the one TS file touched
(`db.ts`), `:5281` instance killed after the run.
## Purchase Entry — LIVE (the second crown jewel)
`/purchases/entry`: supplier type-ahead → invoice no/date → scan-grammar line entry
@ -163,10 +611,14 @@ Login (1) + Dashboard (1, custom) + 44 module pages:
| Reports | Sales Reports · Margins · Stock Aging · Day-End Digest | 4 |
| Admin | **Users & Roles** (custom matrix) · Stores & Counters · **Settings** (custom hierarchy) · Print Templates · Integrations · Subscription & Plan · Audit Log · Sync & Devices · Backups · Data Import | 10 |
**6 pages are now LIVE on real data**: Items (list + search + New Item — billable at the
**11 pages are now LIVE on real data**: Items (list + search + New Item — billable at the
counter immediately), Customers, Bills (rows open the printable A4 GST invoice — S2),
Stock (derived from movements), Audit Log, and Label Printing (barcode/MRP sheets — S2).
Login is real (server-verified). 35 pages render from the data-driven registry; 4 are
**Returns & Credit Notes** (live credit-note list, against-bill + reason + refund — F9 flow),
Stock (derived from movements, + per-batch breakdown — S5), Audit Log, Label Printing
(barcode/MRP sheets — S2), the **Expiry Dashboard** (live near-expiry/expired buckets — S5), and
the three **GST returns** pages — **GSTR-1**, **GSTR-3B** and **HSN Summary** (folded live from the
counters' immutable documents, with a reconciliation banner and a portal-shaped JSON export).
Login is real (server-verified). 34 pages render from the data-driven registry; 4 are
custom wireframes (Dashboard, Purchase Inbox, Settings hierarchy, Users & Roles).
Edition-locked pages render as upsells; everything themed and role/edition-aware.
@ -175,7 +627,8 @@ Edition-locked pages render as upsells; everything themed and role/edition-aware
Serves both web apps + `POST /api/print` + `GET /api/health`, and now the data API:
`/api/auth/pin`, `/api/auth/login` (scrypt + lockout), `/api/bootstrap`, `/api/items`
(GET/POST), `/api/parties` (GET/POST), `/api/bills` (GET/POST with engine
re-verification), `/api/stock`, `/api/audit`. SQLite (WAL) at `apps/store-server/data/`
re-verification), `/api/returns`, `/api/gst/{gstr1,gstr3b,hsn,gstr1.json}` (READ-ONLY
returns reporting, REPORT_VIEW), `/api/stock`, `/api/audit`. SQLite (WAL) at `apps/store-server/data/`
behind portable repositories (`src/repos.ts`) — **the Oracle 12c adapter reimplements
those signatures when the Classic DDL arrives; nothing above them changes.**
Port 5181 (808085 are Windows-reserved).

@ -1,4 +1,5 @@
/** Back office ↔ store-server client; session token survives reloads. */
import type { Gstr1, Gstr3b, HsnRow, Reconciliation, TaxAmounts } from '@sims/billing-engine'
let token = sessionStorage.getItem('bo.token') ?? ''
@ -36,6 +37,12 @@ export async function login(username: string, password: string): Promise<string>
return s.displayName
}
/** End the session server-side (M4) then clear it locally. Best-effort on the network call. */
export async function logout(): Promise<void> {
try { await call('/auth/logout', { method: 'POST' }) } catch { /* server down: clear locally anyway */ }
clearSession()
}
export const getItems = (q?: string): Promise<Record<string, unknown>[]> =>
call(`/items${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`)
export const createItem = (body: Record<string, unknown>): Promise<Record<string, unknown>> =>
@ -50,6 +57,16 @@ export const getBills = (date?: string, limit?: number, offset?: number): Promis
return call(`/bills${qs.toString() !== '' ? `?${qs}` : ''}`)
}
export const getStock = (): Promise<Record<string, unknown>[]> => call('/stock')
/** Credit notes (SALE_RETURN docs) — tenant-scoped, paginated; feeds the Returns page. */
export const getReturns = (limit?: number, offset?: number): Promise<Record<string, unknown>[]> => {
const qs = new URLSearchParams()
if (limit !== undefined) qs.set('limit', String(limit))
if (offset !== undefined) qs.set('offset', String(offset))
return call(`/returns${qs.toString() !== '' ? `?${qs}` : ''}`)
}
/** S5: per-batch on-hand + value inputs joined to the item feeds the expiry
* dashboard and the Stock page's batch breakdown. */
export const getExpiry = (): Promise<Record<string, unknown>[]> => call('/expiry')
export const getAudit = (limit?: number, offset?: number): Promise<Record<string, unknown>[]> => {
const qs = new URLSearchParams()
if (limit !== undefined) qs.set('limit', String(limit))
@ -71,3 +88,15 @@ export interface Seller { id: string; name: string; storeCode: string; stateCode
/** Seller header for GST invoices/labels — store name, GSTIN, state (from bootstrap). */
export const getSeller = (): Promise<Seller> =>
call<{ store: Seller }>('/bootstrap?counter=C1').then((b) => b.store)
// GST returns — READ-ONLY reporting over a period's immutable documents. The server folds them
// through the shared engine and returns each section plus a reconciliation proof (see repos-gst).
export interface Gstr1Response extends Gstr1 { period: string; storeStateCode: string; reconciliation: Reconciliation }
export interface Gstr3bResponse extends Gstr3b { period: string; storeStateCode: string; reconciliation: Reconciliation }
export interface HsnResponse { period: string; storeStateCode: string; rows: HsnRow[]; totals: TaxAmounts; reconciliation: Reconciliation }
export const getGstr1 = (period: string): Promise<Gstr1Response> => call(`/gst/gstr1?period=${encodeURIComponent(period)}`)
export const getGstr3b = (period: string): Promise<Gstr3bResponse> => call(`/gst/gstr3b?period=${encodeURIComponent(period)}`)
export const getGstHsn = (period: string): Promise<HsnResponse> => call(`/gst/hsn?period=${encodeURIComponent(period)}`)
/** The pragmatic GSTN-shaped JSON export (documented in-payload as NOT a certified filing). */
export const getGstr1Json = (period: string): Promise<Record<string, unknown>> => call(`/gst/gstr1.json?period=${encodeURIComponent(period)}`)

@ -6,7 +6,7 @@ import '@fontsource-variable/jetbrains-mono'
import '../../../packages/ui/src/tokens.css'
import './app.css'
import { initTheme } from '@sims/ui'
import { clearSession } from './api'
import { logout } from './api'
import { Layout } from './Layout'
import { Login } from './Login'
import { Dashboard } from './pages/Dashboard'
@ -14,7 +14,8 @@ import { GenericPage } from './pages/GenericPage'
import { PurchaseInbox } from './pages/PurchaseInbox'
import { SettingsPage } from './pages/SettingsPage'
import { UsersRoles } from './pages/UsersRoles'
import { AuditPage, BillsPage, CustomersPage, ItemsPage, StockPage } from './pages/live'
import { AuditPage, BillsPage, CustomersPage, ExpiryPage, ItemsPage, ReturnsPage, StockPage } from './pages/live'
import { Gstr1Page, Gstr3bPage, HsnPage } from './pages/gst'
import { LabelsPage } from './pages/labels'
import { PurchaseEntryPage, PurchasesListPage } from './pages/purchase-entry'
import { PAGES } from './pages/registry'
@ -28,7 +29,12 @@ const CUSTOM: Record<string, () => React.JSX.Element> = {
'/catalog/labels': LabelsPage,
'/sales/customers': CustomersPage,
'/sales/bills': BillsPage,
'/sales/returns': ReturnsPage,
'/inventory/stock': StockPage,
'/inventory/expiry': ExpiryPage,
'/gst/gstr1': Gstr1Page,
'/gst/gstr3b': Gstr3bPage,
'/gst/hsn': HsnPage,
'/admin/audit': AuditPage,
'/purchases/entry': PurchaseEntryPage,
'/purchases/list': PurchasesListPage,
@ -40,7 +46,7 @@ function App() {
return (
<HashRouter>
<Routes>
<Route element={<Layout userName={userName} onLogout={() => { clearSession(); setUserName(undefined) }} />}>
<Route element={<Layout userName={userName} onLogout={() => { void logout(); setUserName(undefined) }} />}>
<Route path="/" element={<Dashboard />} />
{Object.entries(CUSTOM).map(([path, Page]) => (
<Route key={path} path={path} element={<Page />} />

@ -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>
)
}

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { formatINR, type BillLine, type BillTotals } from '@sims/domain'
import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing'
import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, Toolbar } from '@sims/ui'
import { createItem, getAudit, getBills, getItems, getParties, getSeller, getStock, type Seller } from '../api'
import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
import { createItem, getAudit, getBills, getExpiry, getItems, getParties, getReturns, getSeller, getStock, type Seller } from '../api'
/** The first live pages — real data from the store-server (the slice). */
@ -243,28 +243,112 @@ export function BillsPage() {
)
}
/**
* Sales Returns & Credit Notes LIVE. Every SALE_RETURN document, tenant-scoped and
* paginated (R13). A credit note references its original bill (Against Bill) and reverses tax
* at the original bill-date rates; the refund is the immutable document's payable. Replaces the
* registry wireframe via the CUSTOM map.
*/
export function ReturnsPage() {
const { rows, error, hasMore, loading, loadMore } = usePaged<Record<string, unknown>>(
(limit, offset) => getReturns(limit, offset), [],
)
const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0)
const reasonOf = (b: Record<string, unknown>): string => {
try { return String((JSON.parse(String(b['payload'])) as { reason?: string }).reason ?? '') } catch { return '' }
}
return (
<div className="wf-page">
<PageHeader
title="Returns & Credit Notes" badge="LIVE"
desc="Each credit note reverses an original bill — taxes reverse at the original bill-date rates. Immutable documents from the counters (POS F9)."
/>
<Toolbar>
<Badge>{rows.length}{hasMore ? '+' : ''} credit notes</Badge>
<Badge tone="accent">{formatINR(total)} refunded</Badge>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{rows.length === 0 && loading ? <EmptyState>Loading</EmptyState>
: rows.length === 0 && error === undefined ? <EmptyState>No credit notes yet process a return at the POS (F9).</EmptyState> : (
<DataTable
columns={[
{ key: 'no', label: 'Credit Note' }, { key: 'against', label: 'Against Bill' },
{ key: 'date', label: 'Date' }, { key: 'cashier', label: 'Cashier' },
{ key: 'customer', label: 'Customer' }, { key: 'reason', label: 'Reason' },
{ key: 'amount', label: 'Refund', numeric: true },
]}
rows={rows.map((b) => ({
no: String(b['doc_no']),
against: b['original_doc_no'] !== null && b['original_doc_no'] !== undefined ? String(b['original_doc_no']) : '—',
date: new Date(String(b['created_at_wall'])).toLocaleString(),
cashier: String(b['cashier_name'] ?? ''),
customer: b['customer_name'] !== null && b['customer_name'] !== undefined ? String(b['customer_name']) : 'walk-in',
reason: reasonOf(b),
amount: inr(b['payable_paise']),
}))}
/>
)}
{hasMore && (
<div style={{ marginTop: 10 }}>
<Button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</Button>
</div>
)}
</div>
)
}
export function StockPage() {
const { data, error } = useData(() => getStock())
// S5: the per-batch breakdown for batch-tracked items (a second table, kept simple).
const { data: batches } = useData(() => getExpiry())
const today = new Date().toISOString().slice(0, 10)
return (
<div className="wf-page">
<PageHeader title="Stock" badge="LIVE" desc="On-hand derived from movements (invariant I4) — opening stock minus every sale." />
<PageHeader title="Stock" badge="LIVE" desc="On-hand derived from movements (invariant I4) — opening stock minus every sale. Batch-tracked items break down by lot below." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Item' },
{ key: 'onhand', label: 'On hand', numeric: true }, { key: 'state', label: '' },
{ key: 'onhand', label: 'On hand', numeric: true }, { key: 'tracked', label: 'Tracking' },
{ key: 'state', label: '' },
]}
rows={data.map((s) => {
const onHand = Number(s['on_hand'])
return {
code: String(s['code']), name: String(s['name']),
onhand: `${Math.round(onHand * 1000) / 1000} ${String(s['unit_code'])}`,
tracked: Number(s['batch_tracked']) === 1 ? <Badge tone="accent">batch</Badge> : '—',
state: onHand <= 0 ? <Badge tone="err">out</Badge> : onHand < 15 ? <Badge tone="warn">low</Badge> : <Badge tone="ok">ok</Badge>,
}
})}
/>
)}
{batches !== undefined && batches.length > 0 && (
<>
<h3 style={{ margin: '18px 0 8px' }}>Batch-tracked stock</h3>
<DataTable
columns={[
{ key: 'item', label: 'Item' }, { key: 'batch', label: 'Batch' },
{ key: 'expiry', label: 'Expiry' }, { key: 'onhand', label: 'On hand', numeric: true },
{ key: 'value', label: 'Value @ sale', numeric: true },
]}
rows={batches.map((b) => {
const onHand = Number(b['on_hand'])
const expiry = b['expiry_date'] !== null ? String(b['expiry_date']) : undefined
const expired = expiry !== undefined && expiry < today
return {
item: String(b['name']),
batch: String(b['batch_no']),
expiry: expiry === undefined ? <Badge>no expiry</Badge>
: expired ? <Badge tone="err">{expiry}</Badge> : expiry,
onhand: `${Math.round(onHand * 1000) / 1000} ${String(b['unit_code'])}`,
value: inr(onHand * Number(b['sale_price_paise'])),
}
})}
/>
</>
)}
</div>
)
}
@ -302,3 +386,89 @@ export function AuditPage() {
</div>
)
}
/**
* S5 Expiry Dashboard live. Every in-stock batch, bucketed by the working date:
* expired-with-stock, expiring 7 days, 30 days. Per-batch on-hand is derived from
* movements (R7); value is on-hand × the item's current sale price. Replaces the
* registry wireframe via the CUSTOM map.
*/
interface ExpiryRow { id: string; code: string; name: string; batchNo: string; expiry?: string; onHand: number; unit: string; value: number }
function bucketTable(title: string, tone: 'err' | 'warn' | undefined, rows: ExpiryRow[]) {
if (rows.length === 0) return null
return (
<>
<h3 style={{ margin: '18px 0 8px', color: tone !== undefined ? `var(--${tone})` : undefined }}>{title}</h3>
<DataTable
columns={[
{ key: 'item', label: 'Item' }, { key: 'batch', label: 'Batch' },
{ key: 'expiry', label: 'Expiry' }, { key: 'qty', label: 'On hand', numeric: true },
{ key: 'value', label: 'Value @ sale', numeric: true },
]}
rows={rows.map((r) => ({
item: r.name, batch: r.batchNo,
expiry: r.expiry === undefined ? '—'
: tone === 'err' ? <Badge tone="err">{r.expiry}</Badge>
: tone === 'warn' ? <Badge tone="warn">{r.expiry}</Badge>
: r.expiry,
qty: `${Math.round(r.onHand * 1000) / 1000} ${r.unit}`,
value: inr(r.value),
}))}
/>
</>
)
}
export function ExpiryPage() {
const { data, error } = useData(() => getExpiry())
const today = new Date().toISOString().slice(0, 10)
const plusDays = (n: number): string => {
const d = new Date()
d.setDate(d.getDate() + n)
return d.toISOString().slice(0, 10)
}
const in7 = plusDays(7)
const in30 = plusDays(30)
const rows: ExpiryRow[] = (data ?? []).map((r) => ({
id: String(r['id']), code: String(r['code']), name: String(r['name']),
batchNo: String(r['batch_no']),
expiry: r['expiry_date'] !== null && r['expiry_date'] !== undefined ? String(r['expiry_date']) : undefined,
onHand: Number(r['on_hand']), unit: String(r['unit_code']),
value: Number(r['on_hand']) * Number(r['sale_price_paise']),
}))
const dated = rows.filter((r) => r.expiry !== undefined)
const expired = dated.filter((r) => r.expiry! < today)
const le7 = dated.filter((r) => r.expiry! >= today && r.expiry! <= in7)
const le30 = dated.filter((r) => r.expiry! > in7 && r.expiry! <= in30)
const far = rows.filter((r) => r.expiry === undefined || r.expiry > in30)
const sum = (list: ExpiryRow[]): number => list.reduce((a, r) => a + r.value, 0)
return (
<div className="wf-page">
<PageHeader title="Expiry Dashboard" badge="LIVE" desc="Near-expiry and expired stock by batch — act before it becomes wastage. Value is on-hand × current sale price." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : rows.length === 0 ? (
<EmptyState>No batch-tracked stock yet receive a batch in Purchase Entry.</EmptyState>
) : (
<>
<Stats>
<StatCard label="Expiring ≤ 7 days" value={formatINR(sum(le7))} hint={`${le7.length} batch${le7.length === 1 ? '' : 'es'}`} />
<StatCard label="Expiring ≤ 30 days" value={formatINR(sum(le30))} hint={`${le30.length} batch${le30.length === 1 ? '' : 'es'}`} />
<StatCard label="Expired — with stock" value={formatINR(sum(expired))} hint={`${expired.length} batch${expired.length === 1 ? '' : 'es'}`} />
</Stats>
{bucketTable('Expired — clear or write off', 'err', expired)}
{bucketTable('Expiring within 7 days', 'warn', le7)}
{bucketTable('Expiring within 30 days', undefined, le30)}
<div style={{ marginTop: 18 }}>
<Notice tone="warn">
Dead-stock: {far.length} batch{far.length === 1 ? '' : 'es'} carry {formatINR(sum(far))} of stock dated beyond 30 days.
Slow-mover ranking by sales velocity is a follow-up this view flags by expiry today.
</Notice>
</div>
</>
)}
</div>
)
}

@ -27,6 +27,7 @@ interface ItemLite {
id: string; code: string; name: string; taxClassCode: string
salePricePaise: number; mrpPaise?: number
unit: { code: string; decimals: number }; barcodes: string[]
batchTracked: boolean
}
interface PLine {
@ -35,6 +36,9 @@ interface PLine {
lastCostPaise?: number
currentSalePaise: number; currentMrpPaise?: number
newSalePricePaise?: number; newMrpPaise?: number
/** S5: batch-tracked items extend the Enter chain qty→cost→batch→expiry→scan. */
batchTracked: boolean
batchNo?: string; expiryDate?: string
}
const inr = (p: number) => formatINR(p, { symbol: false })
@ -51,14 +55,15 @@ export function PurchaseEntryPage() {
const [invoiceNo, setInvoiceNo] = useState('')
const [invoiceDate, setInvoiceDate] = useState(today)
const [lines, setLines] = useState<PLine[]>([])
const anyBatch = lines.some((l) => l.batchTracked)
const [entry, setEntry] = useState('')
const [printedTotal, setPrintedTotal] = useState('')
const [notice, setNotice] = useState<{ tone: 'ok' | 'warn' | 'err'; text: string } | undefined>()
const [busy, setBusy] = useState(false)
/** Filled on post so the receiving clerk can print labels for what just came in. */
const [labelQueue, setLabelQueue] = useState<LabelInput[] | undefined>()
/** Which cell owns focus: the scan box, or qty/cost of a line. */
const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' } | undefined>()
/** Which cell owns focus: the scan box, or qty/cost/batch/expiry of a line. */
const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' | 'batch' | 'expiry' } | undefined>()
const entryRef = useRef<HTMLInputElement>(null)
const cellRef = useRef<HTMLInputElement>(null)
@ -106,8 +111,14 @@ export function PurchaseEntryPage() {
}, [supplier?.id])
useEffect(() => {
if (focusCell !== undefined) cellRef.current?.select()
else entryRef.current?.focus()
if (focusCell !== undefined) {
const el = cellRef.current
el?.focus()
// select() only text-likes; a date cell (expiry) would throw, so focus only.
if (el !== null && (focusCell.cell === 'qty' || focusCell.cell === 'cost')) el.select()
} else {
entryRef.current?.focus()
}
}, [focusCell, lines.length])
const say = (tone: 'ok' | 'warn' | 'err', text: string) => setNotice({ tone, text })
@ -134,6 +145,7 @@ export function PurchaseEntryPage() {
: {}),
currentSalePaise: item.salePricePaise,
...(item.mrpPaise !== undefined ? { currentMrpPaise: item.mrpPaise } : {}),
batchTracked: item.batchTracked === true,
}]
})
setNotice(undefined)
@ -200,6 +212,9 @@ export function PurchaseEntryPage() {
taxRateBp: l.taxRateBp,
...(l.newSalePricePaise !== undefined ? { newSalePricePaise: l.newSalePricePaise } : {}),
...(l.newMrpPaise !== undefined ? { newMrpPaise: l.newMrpPaise } : {}),
// S5: batch/expiry ride with a batch-tracked line; the server upserts the lot.
...(l.batchTracked && l.batchNo !== undefined && l.batchNo !== '' ? { batchNo: l.batchNo } : {}),
...(l.batchTracked && l.expiryDate !== undefined && l.expiryDate !== '' ? { expiryDate: l.expiryDate } : {}),
})),
clientTotalPaise: totals.total,
})
@ -332,6 +347,7 @@ export function PurchaseEntryPage() {
<thead>
<tr>
<th>#</th><th>Item</th><th className="num">Qty</th><th className="num">Cost </th>
{anyBatch && <><th>Batch</th><th>Expiry</th></>}
<th className="num">Last</th><th className="num">GST</th><th className="num">Line total</th><th>Price</th>
</tr>
</thead>
@ -340,8 +356,42 @@ export function PurchaseEntryPage() {
const lineTaxable = Math.round(l.qty * l.unitCostPaise)
const lineTax = Math.round((lineTaxable * l.taxRateBp) / 10_000)
const costChanged = l.lastCostPaise !== undefined && l.unitCostPaise !== l.lastCostPaise && l.unitCostPaise > 0
const cell = (kind: 'qty' | 'cost') =>
focusCell?.line === i && focusCell.cell === kind ? (
const editing = (kind: 'qty' | 'cost' | 'batch' | 'expiry') =>
focusCell?.line === i && focusCell.cell === kind
// Enter chain: qty → cost → (batch-tracked ? batch → expiry :) scan box.
const cell = (kind: 'qty' | 'cost' | 'batch' | 'expiry') => {
if (editing(kind) && kind === 'expiry') {
return (
<input
ref={cellRef} type="date" className="wf" style={{ width: 150 }}
defaultValue={l.expiryDate ?? ''}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== 'Tab') return
e.preventDefault()
const v = (e.target as HTMLInputElement).value
setLine(i, { expiryDate: v !== '' ? v : undefined })
setFocusCell(undefined) // blank Enter skips expiry for non-perishables → scan box
}}
/>
)
}
if (editing(kind) && kind === 'batch') {
return (
<input
ref={cellRef} className="wf" style={{ width: 120 }} placeholder="Batch no"
defaultValue={l.batchNo ?? ''}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== 'Tab') return
e.preventDefault()
const v = (e.target as HTMLInputElement).value.trim()
setLine(i, { batchNo: v !== '' ? v : undefined })
setFocusCell({ line: i, cell: 'expiry' })
}}
/>
)
}
if (editing(kind) && (kind === 'qty' || kind === 'cost')) {
return (
<input
ref={cellRef}
className="wf num" style={{ width: 90 }}
@ -356,21 +406,35 @@ export function PurchaseEntryPage() {
setFocusCell({ line: i, cell: 'cost' })
} else {
setLine(i, { unitCostPaise: rs(v) })
setFocusCell(undefined) // Enter on cost returns to the scan box
// batch-tracked lines continue to batch → expiry; others return to the scan box
setFocusCell(l.batchTracked ? { line: i, cell: 'batch' } : undefined)
}
}}
/>
) : (
)
}
// display / click-to-focus
if (kind === 'batch' || kind === 'expiry') {
if (!l.batchTracked) return <span style={{ color: 'var(--text-dim)' }}></span>
const shown = kind === 'batch' ? (l.batchNo ?? 'set…') : (l.expiryDate ?? '—')
return (
<span style={{ cursor: 'pointer', color: (kind === 'batch' && l.batchNo === undefined) ? 'var(--warn)' : undefined }}
onClick={() => setFocusCell({ line: i, cell: kind })}>{shown}</span>
)
}
return (
<span style={{ cursor: 'pointer' }} onClick={() => setFocusCell({ line: i, cell: kind })}>
{kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)}
</span>
)
}
return (
<tr key={i}>
<td>{i + 1}</td>
<td>{l.name}</td>
<td className="num">{cell('qty')}</td>
<td className="num">{cell('cost')}</td>
{anyBatch && <><td>{cell('batch')}</td><td>{cell('expiry')}</td></>}
<td className="num">{l.lastCostPaise !== undefined ? inr(l.lastCostPaise) : '—'}</td>
<td className="num">{l.taxRateBp / 100}%</td>
<td className="num">{inr(lineTaxable + lineTax)}</td>

@ -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())
}),
)
})

@ -1,26 +1,74 @@
import { useEffect, useState } from 'react'
import { Button, Notice } from '@sims/ui'
import { uuidv7, type Item } from '@sims/domain'
import { fetchBootstrap, fetchItemsCache, type Bootstrap, type PosUser } from './api'
import { fetchBootstrap, fetchItemsCache, hasSession, logout, type Bootstrap, type PosUser } from './api'
import { clearCatalogCache, loadCatalogCache, saveCatalogCache } from './offline'
import { Login } from './Login'
import { ShiftOpen } from './ShiftOpen'
import { BillingScreen } from './BillingScreen'
/** Shift context kept small enough to resume from sessionStorage after a refresh. */
interface Shift { id: string; floatPaise: number }
/**
* Resume context (S4): the authenticated user + open shift, persisted in
* sessionStorage so a mid-shift browser refresh with the server DOWN lands back on
* the billing screen (paired with the cached catalog in IndexedDB and the session
* token, also in sessionStorage). Dies with the tab a fresh login still needs
* the server (a locally-cached PIN hash login is deliberately OUT OF SCOPE).
*/
const RESUME_KEY = 'pos.resume'
const readResume = (): { user: PosUser; shift: Shift } | undefined => {
try {
const raw = sessionStorage.getItem(RESUME_KEY)
return raw !== null ? (JSON.parse(raw) as { user: PosUser; shift: Shift }) : undefined
} catch { return undefined }
}
const writeResume = (user: PosUser, shift: Shift): void => {
try { sessionStorage.setItem(RESUME_KEY, JSON.stringify({ user, shift })) } catch { /* ignore */ }
}
const clearResume = (): void => {
try { sessionStorage.removeItem(RESUME_KEY) } catch { /* ignore */ }
}
/** POS flow: bootstrap from store-server → login (PIN) → shift open → billing. */
export function App() {
const [boot, setBoot] = useState<Bootstrap | undefined>()
const [bootError, setBootError] = useState<string | undefined>()
const [user, setUser] = useState<PosUser | undefined>()
const [items, setItems] = useState<Item[] | undefined>()
const [shift, setShift] = useState<{ id: string; floatPaise: number } | undefined>()
const [shift, setShift] = useState<Shift | undefined>()
// True when this session booted from the IndexedDB cache (server was unreachable).
const [cachedCatalog, setCachedCatalog] = useState(false)
const load = () => {
setBootError(undefined)
const counter = new URLSearchParams(location.search).get('counter') ?? 'C2'
fetchBootstrap(counter).then(setBoot).catch((e: Error) => setBootError(e.message))
fetchBootstrap(counter)
.then(setBoot)
.catch(async () => {
// Server unreachable. If we still hold an authenticated session and a cached
// catalog + resume context, reopen the billing screen offline (S4). Otherwise
// surface the error — a fresh login needs the server.
const [cache, resume] = [await loadCatalogCache().catch(() => undefined), readResume()]
if (cache !== undefined && resume !== undefined && hasSession()) {
setBoot({ store: cache.store, users: cache.users, taxClasses: cache.taxClasses })
setItems(cache.items)
setUser(resume.user)
setShift(resume.shift)
setCachedCatalog(true)
} else {
setBootError('Store-server unreachable (E-3301)')
}
})
}
useEffect(load, [])
// Keep the resume context current while a shift is open; clear it on lock.
useEffect(() => {
if (user !== undefined && shift !== undefined) writeResume(user, shift)
}, [user, shift])
if (bootError !== undefined) {
return (
<div className="login-wrap">
@ -40,8 +88,23 @@ export function App() {
store={boot.store}
users={boot.users}
onLogin={async (u) => {
setUser(u)
setItems(await fetchItemsCache())
// M1: the pre-auth bootstrap omits each user's role and the seller GSTIN. Now that we
// hold a session, re-fetch it AUTHENTICATED so the supervisor gate (user.role), the
// approver list (users[].role) and the receipt's seller GSTIN have real data. Falls
// back to the pre-auth boot if the refetch fails (offline right after login).
const counter = new URLSearchParams(location.search).get('counter') ?? 'C2'
const authedBoot = await fetchBootstrap(counter).catch(() => boot)
setBoot(authedBoot)
const enrichedUser = authedBoot.users.find((x) => x.id === u.id) ?? u
setUser(enrichedUser)
const its = await fetchItemsCache()
setItems(its)
// Persist the catalog snapshot so a mid-shift refresh with the server down
// can still reach billing (S4 deliverable 3).
void saveCatalogCache({
store: authedBoot.store, users: authedBoot.users, taxClasses: authedBoot.taxClasses,
items: its, savedAt: new Date().toISOString(),
}).catch(() => undefined)
}}
/>
)
@ -67,7 +130,16 @@ export function App() {
items={items}
shiftId={shift.id}
floatPaise={shift.floatPaise}
cachedCatalog={cachedCatalog}
onLock={() => {
// M4: end the session server-side so the bearer token stops working immediately
// (best-effort — the local state is cleared regardless of whether the server answers).
void logout()
clearResume()
// M11: wipe the cached catalog + staff roster from IndexedDB so no customer/staff
// data lingers on this shared PC after lock. Un-drained offline bills are kept
// (a lock must never lose a sale) and the catalog is re-fetched on the next login.
void clearCatalogCache().catch(() => undefined)
setShift(undefined)
setUser(undefined)
setItems(undefined)

@ -1,15 +1,16 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, Field, Modal, Notice, ThemeSwitcher } from '@sims/ui'
import { deriveSupply, formatINR, validateGstin, type Item, type PaymentMode, type RoleCode } from '@sims/domain'
import { deriveSupply, formatINR, isExpired, pickFefoBatch, sortFefo, validateGstin, type BatchStock, type Item, type PaymentMode, type RoleCode } from '@sims/domain'
// Subpath import: the @sims/auth barrel re-exports pin.ts (node:crypto), which the
// browser bundle can't include. permissions.ts is pure (type-only imports), so the
// role→gate matrix stays the single source of truth without dragging in scrypt.
import { gateFor } from '@sims/auth/permissions'
import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
import { computeBill, computeReturn, type LineInput, type TaxClassRow } from '@sims/billing-engine'
import { parseEntry, parseScaleBarcode, WedgeDetector, type ScaleBarcodeProfile } from '@sims/scanning'
import { buildIndex, querySearch } from '@sims/search-core'
import { renderReceipt, receiptLines, type ReceiptDoc } from '@sims/printing'
import { createCustomer, createDraftItem, fetchBills, fetchStock, findCustomers, postBill, verifySupervisorPin, type BillOverride, type BootstrapStore, type PosCustomer, type PosUser } from './api'
import { createCustomer, createDraftItem, fetchBatches, fetchBills, fetchReturnable, fetchStock, findCustomers, lookupBillByDocNo, lookupBillsByPhone, NetworkError, pingHealth, postBill, postBillOffline, postReturn, verifySupervisorPin, type BillLookupResult, type BillOverride, type BootstrapStore, type OverrideContext, type PosBatchRow, type PosCustomer, type PosUser, type ReturnableBill } from './api'
import { buildQueuedBill, countQueuedBills, enqueueBill, listQueuedBills, nextOffSeq, removeQueuedBill, toOfflineBillBody } from './offline'
import { beepErr, beepOk, beepWarn } from './audio'
import { loadPrinterCfg, sendRasterReceipt, sendToPrinter } from './print'
import { SettingsModal } from './Settings'
@ -24,8 +25,13 @@ const POS_LIMITS = {
DISC_WARN_PCT: 10, // a line discount beyond this % of line value raises the supervisor gate
} as const
/** A supervisor's approval, resolved by the inline gate panel. */
interface Approval { approvedByUserId: string; approvedByName: string }
/**
* A supervisor's approval, resolved by the inline gate panel. `approvalId` is the
* single-use token the server minted on the verified PIN; it rides with the override
* so the server records the approver from its own row (SEC fix) the client no longer
* asserts an approver user id.
*/
interface Approval { approvalId: string; approvedByName: string }
const SCALE_PROFILE: ScaleBarcodeProfile = {
prefixes: ['21'],
@ -38,7 +44,26 @@ const QUICK_KEYS = ['42', '43', '44', '108'] // per-counter DB config later
interface CommittedBill { docNo: string; payablePaise: number; mode: PaymentMode; at: string }
type NoticeMsg = { tone: 'ok' | 'warn' | 'err'; text: string }
type Overlay = 'none' | 'pay' | 'settings' | 'history' | 'dayend' | 'customer'
type Overlay = 'none' | 'pay' | 'settings' | 'history' | 'dayend' | 'customer' | 'returns'
/** Return reasons offered at the counter (09-UX returns/exchange). 'Other' is the free catch-all. */
const RETURN_REASONS = ['Damaged', 'Wrong item', 'Expired', 'Customer changed mind', 'Other'] as const
/** Group /api/batches rows into a per-item map (S5) — the FEFO picker's cache. */
function groupBatches(rows: PosBatchRow[]): Map<string, BatchStock[]> {
const m = new Map<string, BatchStock[]>()
for (const r of rows) {
const b: BatchStock = {
id: r.id, itemId: r.item_id, batchNo: r.batch_no,
...(r.expiry_date !== null ? { expiryDate: r.expiry_date } : {}),
onHand: Number(r.on_hand),
}
const arr = m.get(b.itemId)
if (arr !== undefined) arr.push(b)
else m.set(b.itemId, [b])
}
return m
}
export function BillingScreen(props: {
user: PosUser
@ -48,6 +73,8 @@ export function BillingScreen(props: {
items: Item[]
shiftId: string
floatPaise: number
/** True when the app booted from the IndexedDB cache (server was down) — shows the offline banner. */
cachedCatalog?: boolean
onLock: () => void
}) {
const { store, taxClasses } = props
@ -61,7 +88,11 @@ export function BillingScreen(props: {
// Supervisor-approved overrides accumulate here and ride with postBill; the server
// audits each in the commit transaction (spec P0-2). Cleared on commit / cart-clear.
const [overrides, setOverrides] = useState<BillOverride[]>([])
const [supPanel, setSupPanel] = useState<{ desc: string; resolve: (a: Approval | undefined) => void } | undefined>()
const [supPanel, setSupPanel] = useState<{ desc: string; override: OverrideContext; resolve: (a: Approval | undefined) => void } | undefined>()
// S5: per-item batch cache for the FEFO picker, and the active pick (item + qty
// waiting for a batch choice). Refreshed at mount and after each online commit.
const [batchMap, setBatchMap] = useState<Map<string, BatchStock[]>>(new Map())
const [batchPick, setBatchPick] = useState<{ item: Item; qty: number; batches: BatchStock[] } | undefined>()
const [stockMap, setStockMap] = useState<Map<string, number>>(new Map())
const [lastBillLines, setLastBillLines] = useState<LineInput[]>([])
const [changeFlash, setChangeFlash] = useState<number | undefined>()
@ -76,6 +107,8 @@ export function BillingScreen(props: {
const [overlay, setOverlay] = useState<Overlay>('none')
const [busy, setBusy] = useState(false)
const [serverBills, setServerBills] = useState<Record<string, unknown>[] | undefined>()
// S4: count of bills committed offline and still waiting to drain (the amber chip).
const [offlineCount, setOfflineCount] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const wedge = useRef(new WedgeDetector())
@ -88,6 +121,9 @@ export function BillingScreen(props: {
// The supervisor panel holds focus (its PIN field) and owns every key but Esc.
const supPanelRef = useRef<typeof supPanel>(undefined)
supPanelRef.current = supPanel
// The batch picker owns arrows/Enter while open; the global handler bails for it.
const batchPickRef = useRef<typeof batchPick>(undefined)
batchPickRef.current = batchPick
// B2B at the counter (S3): an attached customer with a GSTIN moves the place of
// supply to their state — the server derives this same decision independently
@ -120,12 +156,98 @@ export function BillingScreen(props: {
.catch(() => undefined)
}, [])
// S5: load the FEFO batch cache at login (mount) and again after each commit so
// per-batch on-hand stays live. Keyed by item id. Silent on failure (offline).
const refreshBatches = (): void => {
fetchBatches()
.then((rows) => setBatchMap(groupBatches(rows)))
.catch(() => undefined)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { refreshBatches() }, [])
// S4 reconnect drain: every 15s (and on the browser 'online' event) ping health;
// when the store-server is back, POST queued bills oldest-first to the idempotent
// /api/bills/offline and map each OFF-n to its real GST doc no. Also runs once on
// mount, to drain a queue a prior session/refresh may have left behind.
useEffect(() => {
let cancelled = false
let draining = false
const drain = async () => {
if (cancelled || draining) return
draining = true
try {
if (!(await pingHealth())) return
const queued = await listQueuedBills() // oldest-first
if (queued.length === 0) return
const mapped: string[] = []
let sessionExpired = false
for (const q of queued) {
if (cancelled) break
try {
const res = await postBillOffline(toOfflineBillBody(q))
await removeQueuedBill(q.id) // idempotent: a re-drain returns the same doc no
mapped.push(`${q.provisionalToken}${res.docNo}`)
} catch (err) {
// The drain runs authenticated (SEC-1). A 401 means the session lapsed —
// typically the server restarted — so the queue is intact but can't sync
// until the counter signs in again; flag it and stop. Any other error:
// stop and retry next tick.
if (err instanceof Error && /E-6103|not signed in/i.test(err.message)) sessionExpired = true
break
}
}
const remaining = await countQueuedBills()
if (cancelled) return
setOfflineCount(remaining)
if (mapped.length > 0) say('ok', `Synced ${mapped.length} offline bill(s): ${mapped.join(', ')}`)
// Prompt re-login WITHOUT a beep (setNotice, not say) so the 15s retry loop
// isn't noisy; it clears itself once a login refreshes the session and the
// next drain succeeds.
if (sessionExpired && remaining > 0) {
setNotice({ tone: 'warn', text: `Sign in to sync ${remaining} offline bill${remaining === 1 ? '' : 's'} — the session expired after a reconnect` })
}
} finally {
draining = false
}
}
countQueuedBills().then((n) => { if (!cancelled) setOfflineCount(n) }).catch(() => undefined)
const iv = setInterval(() => { void drain() }, 15000)
const onOnline = (): void => { void drain() }
window.addEventListener('online', onOnline)
void drain()
return () => { cancelled = true; clearInterval(iv); window.removeEventListener('online', onOnline) }
}, [])
const addItem = (item: Item, qty?: number) => {
const addQty = qty ?? pendingQty ?? 1
// Whole quantities only for unit-less items; keep the multiplier for a rescan.
if (item.unit.decimals === 0 && !Number.isInteger(addQty)) {
return say('err', `Whole quantities only for ${item.unit.code} — rescan (E-1104)`)
}
// S5: a batch-tracked item routes through the inline FEFO picker before the cart.
if (item.batchTracked) {
const avail = (batchMap.get(item.id) ?? []).filter((b) => b.onHand > 0)
if (avail.length > 0) {
setPendingQty(undefined)
setSugg(undefined)
setBatchPick({ item, qty: addQty, batches: avail })
return
}
// No lot on hand (batch-tracked item never received with a batch): never block
// the counter (R18) — bill it un-batched and warn.
say('warn', `No batch on hand for ${item.name} — billing without a batch`)
}
finalizeAdd(item, addQty, undefined, qty)
}
/**
* Append the (optionally batch-picked) line to the cart the tail of addItem,
* extracted so the batch picker can finalise after a FEFO/expired decision.
* `qtyParam` mirrors addItem's original qty arg: a bare unit scan (undefined) still
* merges into an existing un-batched line; a batch line always stays distinct.
*/
const finalizeAdd = (item: Item, addQty: number, batchId: string | undefined, qtyParam?: number) => {
setPendingQty(undefined)
beepOk()
const onHand = stockMap.get(item.code)
@ -133,8 +255,8 @@ export function BillingScreen(props: {
say('warn', `${item.name} shows out of stock — the shelf is the arbiter, billing anyway`)
}
setLines((prev) => {
if (item.unit.decimals === 0 && qty === undefined) {
const i = prev.findIndex((l) => l.itemId === item.id)
if (item.unit.decimals === 0 && qtyParam === undefined && batchId === undefined) {
const i = prev.findIndex((l) => l.itemId === item.id && l.batchId === undefined)
if (i >= 0) {
const copy = [...prev]
copy[i] = { ...copy[i]!, qty: copy[i]!.qty + addQty }
@ -148,6 +270,7 @@ export function BillingScreen(props: {
unitCode: item.unit.code, unitPricePaise: item.salePricePaise,
priceIncludesTax: item.priceIncludesTax, taxClassCode: item.taxClassCode,
...(item.mrpPaise !== undefined ? { mrpPaise: item.mrpPaise } : {}),
...(batchId !== undefined ? { batchId } : {}),
}]
})
setNotice(undefined)
@ -238,22 +361,66 @@ export function BillingScreen(props: {
* modal), resolves with the approver on success or undefined on cancel / 3 fails.
* F4 discounts (P1-3) and future Del/void reuse this exact promise.
*/
const supervisorGate = (desc: string): Promise<Approval | undefined> =>
new Promise((resolve) => setSupPanel({ desc, resolve }))
const supervisorGate = (desc: string, override: OverrideContext): Promise<Approval | undefined> =>
new Promise((resolve) => setSupPanel({ desc, override, resolve }))
const applyPrice = (i: number, paise: number, approval?: Approval, before?: number) => {
setLines((prev) => prev.map((x, j) => (j === i ? { ...x, unitPricePaise: paise } : x)))
if (approval !== undefined && before !== undefined) {
const itemId = lines[i]?.itemId
if (itemId !== undefined) {
setOverrides((o) => [...o, { itemId, kind: 'price', before, after: paise, approvedByUserId: approval.approvedByUserId }])
/** Append an override that rides with the bill; the server binds/audits it (spec P0-2, H7). */
const pushOverride = (o: BillOverride) => setOverrides((prev) => [...prev, o])
/**
* The inline FEFO picker resolved a batch (spec S5). A non-expired lot adds the line
* straight away. An EXPIRED lot is blocked with E-1310 unless approved: a supervisor+
* at the counter sells it on their own authority; a cashier raises the supervisor gate
* and the approval rides along as a `batch` override (audited BATCH_OVERRIDE server-side).
*/
const acceptBatch = async (batch: BatchStock) => {
const pick = batchPick
setBatchPick(undefined)
if (pick === undefined) return
const { item, qty } = pick
if (isExpired(batch, today)) {
if (userGate('PRICE_OVERRIDE') === 'allow') {
// H7: even a self-authorised expired-batch sale is audited server-side.
pushOverride({ itemId: item.id, kind: 'batch', before: 0, after: 0 })
finalizeAdd(item, qty, batch.id)
say('warn', `EXPIRED batch ${batch.batchNo} (${batch.expiryDate ?? ''}) sold on your authority (E-1310)`)
inputRef.current?.focus()
return
}
const approval = await supervisorGate(
`${item.name}: batch ${batch.batchNo} EXPIRED ${batch.expiryDate ?? ''} — approve sale? (E-1310)`,
{ itemId: item.id, kind: 'batch', before: 0, after: 0 },
)
if (approval === undefined) {
say('warn', `Expired batch ${batch.batchNo} not sold — no supervisor approval`)
inputRef.current?.focus()
return
}
pushOverride({ itemId: item.id, kind: 'batch', before: 0, after: 0, approvalId: approval.approvalId })
finalizeAdd(item, qty, batch.id)
say('ok', `Expired batch ${batch.batchNo} approved by ${approval.approvedByName}`)
inputRef.current?.focus()
return
}
finalizeAdd(item, qty, batch.id)
inputRef.current?.focus()
}
const cancelBatchPick = () => {
setBatchPick(undefined)
say('warn', 'Batch selection cancelled — item not added')
inputRef.current?.focus()
}
const applyPrice = (i: number, paise: number) => {
setLines((prev) => prev.map((x, j) => (j === i ? { ...x, unitPricePaise: paise } : x)))
inputRef.current?.focus()
}
// F3 price override (spec P0-2). price>MRP is a hard block; a deviation beyond the
// ±band — or ANY change by a cashier — raises the supervisor gate before it applies.
// The deviation's `before` is the item MASTER price (`ref`), which is exactly what the
// server anchors to and binds the token against (H4) — so the two always agree.
const commitPriceEdit = async (i: number, v: string) => {
setEditCell(undefined)
const line = lines[i]
@ -268,12 +435,19 @@ export function BillingScreen(props: {
if (paise === line.unitPricePaise) { inputRef.current?.focus(); return } // no-op
const ref = cat.find((c) => c.id === line.itemId)?.salePricePaise ?? line.unitPricePaise
const beyondBand = Math.abs(paise - ref) > ref * POS_LIMITS.PRICE_BAND_PCT / 100
if (userGate('PRICE_OVERRIDE') === 'allow') { applyPrice(i, paise); return } // supervisor+ self-authorises
if (userGate('PRICE_OVERRIDE') === 'allow') {
applyPrice(i, paise) // supervisor+ self-authorises
// H7: a self-authorised deviation from the master is still audited server-side.
if (paise !== ref) pushOverride({ itemId: line.itemId, kind: 'price', before: ref, after: paise })
return
}
const approval = await supervisorGate(
`${line.name}: rate ${formatINR(line.unitPricePaise)}${formatINR(paise)}${beyondBand ? ` (beyond ±${POS_LIMITS.PRICE_BAND_PCT}% band)` : ''}`,
{ itemId: line.itemId, kind: 'price', before: ref, after: paise },
)
if (approval === undefined) { say('warn', 'Price override cancelled — no supervisor approval'); inputRef.current?.focus(); return }
applyPrice(i, paise, approval, line.unitPricePaise)
applyPrice(i, paise)
pushOverride({ itemId: line.itemId, kind: 'price', before: ref, after: paise, approvalId: approval.approvalId })
say('ok', `Rate override approved by ${approval.approvedByName}`)
}
@ -297,37 +471,55 @@ export function BillingScreen(props: {
const lineValue = line.qty * line.unitPricePaise
const discPaise = isPct ? Math.round(lineValue * Number(m[1]) / 100) : Math.round(Number(m[1]) * 100)
const overCap = discPaise > lineValue * POS_LIMITS.DISC_WARN_PCT / 100
const apply = (approval?: Approval) => {
const applyDiscount = () => {
setLines((prev) => prev.map((x, j) => (j === i ? { ...x, discount } : x)))
if (approval !== undefined) {
setOverrides((o) => [...o, { itemId: line.itemId, kind: 'discount', before: 0, after: discPaise, approvedByUserId: approval.approvedByUserId }])
}
inputRef.current?.focus()
}
if (!overCap || userGate('DISCOUNT_OVER_CAP') === 'allow') { apply(); return }
const approval = await supervisorGate(`${line.name}: discount ${formatINR(discPaise)} (> ${POS_LIMITS.DISC_WARN_PCT}% of line)`)
if (!overCap) { applyDiscount(); return } // within the band: no approval, no override
if (userGate('DISCOUNT_OVER_CAP') === 'allow') {
applyDiscount()
// H7: an over-cap discount applied on the user's own authority is still audited.
pushOverride({ itemId: line.itemId, kind: 'discount', before: 0, after: discPaise })
return
}
const approval = await supervisorGate(
`${line.name}: discount ${formatINR(discPaise)} (> ${POS_LIMITS.DISC_WARN_PCT}% of line)`,
{ itemId: line.itemId, kind: 'discount', before: 0, after: discPaise },
)
if (approval === undefined) { say('warn', 'Discount cancelled — no supervisor approval'); inputRef.current?.focus(); return }
apply(approval)
applyDiscount()
pushOverride({ itemId: line.itemId, kind: 'discount', before: 0, after: discPaise, approvalId: approval.approvalId })
say('ok', `Discount approved by ${approval.approvedByName}`)
}
const commit = (mode: PaymentMode, tenderedPaise?: number) => {
if (busy) return
if (computed === undefined) return say('warn', 'Nothing to bill')
setBusy(true)
const totals = computed.totals
const committedLines = lines
postBill({
storeId: store.id, counterId: store.counterId, shiftId: props.shiftId,
...(customer !== undefined ? { customerId: customer.id } : {}),
businessDate: today, lines,
payments: [{ mode, amountPaise: totals.payablePaise }],
clientPayablePaise: totals.payablePaise,
...(overrides.length > 0 ? { overrides } : {}), // audited server-side in the commit txn (spec P0-2)
})
.then((res) => {
// The bill is committed server-side; printing is a consequence (09-UX §5.4).
setDayLog((d) => [...d, { docNo: res.docNo, payablePaise: totals.payablePaise, mode, at: new Date().toLocaleTimeString() }])
type Computed = NonNullable<typeof computed>
/**
* Print a receipt. `docNo` is the GST doc no online, or "PROVISIONAL OFF-n
* (offline)" for a queued bill. Reads `customer`/`supply` from the render's
* closure correct even after the cart-clear setState calls, which don't
* mutate these locals.
*/
const printBill = (docNo: string, snapshot: Computed, mode: PaymentMode): Promise<void> => {
const cfg = loadPrinterCfg()
const kick = mode === 'CASH'
const receiptDoc: ReceiptDoc = {
storeName: store.name, gstin: store.gstin, docNo, businessDate: today,
counterCode: store.counterCode, cashierName: props.user.name,
...(customer !== undefined ? { buyerName: customer.name } : {}),
...(supply.buyerGstin !== undefined ? { buyerGstin: supply.buyerGstin } : {}),
lines: snapshot.lines, totals: snapshot.totals, paymentLabel: mode,
}
// Indic receipts rasterize (09-UX §5.3); ASCII stays the fast text path.
// Same content model (receiptLines) so item names match on either path.
return cfg.script === 'raster'
? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick)
: sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick }))
}
/** Cart-clear + optimistic local stock + change flash, shared by both commit paths. */
const clearAfterCommit = (docNo: string, snapshot: Computed, mode: PaymentMode, committedLines: LineInput[], tenderedPaise?: number) => {
setDayLog((d) => [...d, { docNo, payablePaise: snapshot.totals.payablePaise, mode, at: new Date().toLocaleTimeString() }])
setLastBillLines(committedLines) // Ctrl+B repeat (spec P1-1)
// decrement local stock view optimistically (spec P1-5)
setStockMap((m) => {
@ -338,8 +530,8 @@ export function BillingScreen(props: {
}
return next
})
if (tenderedPaise !== undefined && tenderedPaise > totals.payablePaise) {
setChangeFlash(tenderedPaise - totals.payablePaise)
if (tenderedPaise !== undefined && tenderedPaise > snapshot.totals.payablePaise) {
setChangeFlash(tenderedPaise - snapshot.totals.payablePaise)
setTimeout(() => setChangeFlash(undefined), 4000)
}
setLines([])
@ -347,28 +539,73 @@ export function BillingScreen(props: {
setCustomer(undefined)
setOverrides([]) // override records belong to the committed cart only
setOverlay('none')
const cfg = loadPrinterCfg()
const kick = mode === 'CASH'
const receiptDoc: ReceiptDoc = {
storeName: store.name, gstin: store.gstin, docNo: res.docNo, businessDate: today,
counterCode: store.counterCode, cashierName: props.user.name,
...(customer !== undefined ? { buyerName: customer.name } : {}),
...(supply.buyerGstin !== undefined ? { buyerGstin: supply.buyerGstin } : {}),
lines: computed.lines, totals, paymentLabel: mode,
}
// Indic receipts rasterize (09-UX §5.3); ASCII stays the fast text path.
// Same content model (receiptLines) so item names match on either path.
const send = cfg.script === 'raster'
? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick)
: sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick }))
return send
.then(() => say('ok', `Bill ${res.docNo} · ${formatINR(totals.payablePaise)} — printed`))
/**
* Store-server unreachable (S4): instead of losing the sale, persist it to the
* IndexedDB queue with a client UUIDv7 + provisional token (OFF-n), clear the
* cart, and keep billing. The reconnect drain assigns the real GST doc no later.
* Enqueue is awaited BEFORE the cart clears so a queue write failure keeps the
* cart on screen (R18: never lost).
*/
const queueOffline = async (mode: PaymentMode, snapshot: Computed, committedLines: LineInput[], tenderedPaise?: number) => {
try {
const seq = nextOffSeq()
const q = buildQueuedBill({
seq,
storeId: store.id, counterId: store.counterId, cashierId: props.user.id, shiftId: props.shiftId,
// M11: persist ONLY the customer id — never the buyer's name or GSTIN. The server
// re-resolves buyer identity from the id at drain (same as an online bill), so no
// customer PII is written to this shared PC. The provisional receipt printed below
// still names the buyer, from live in-memory state — not from the queued record.
...(customer !== undefined ? { customerId: customer.id } : {}),
businessDate: today,
lines: committedLines,
payments: [{ mode, amountPaise: snapshot.totals.payablePaise }],
totals: snapshot.totals,
...(overrides.length > 0 ? { overrides } : {}),
})
await enqueueBill(q)
clearAfterCommit(q.provisionalToken, snapshot, mode, committedLines, tenderedPaise)
setOfflineCount((n) => n + 1)
// Provisional receipt: "PROVISIONAL OFF-n (offline)" instead of a GST doc no.
void printBill(`PROVISIONAL ${q.provisionalToken} (offline)`, snapshot, mode).catch(() => undefined)
say('warn', `Store-server offline — saved ${q.provisionalToken}, cart cleared; will sync when it returns`)
} catch (err) {
// Even the local queue failed — keep the cart rather than lose the sale.
say('err', `Could not queue the bill offline: ${err instanceof Error ? err.message : String(err)} — cart kept`)
}
}
const commit = (mode: PaymentMode, tenderedPaise?: number) => {
if (busy) return
if (computed === undefined) return say('warn', 'Nothing to bill')
setBusy(true)
const snapshot: Computed = computed
const committedLines = lines
postBill({
storeId: store.id, counterId: store.counterId, shiftId: props.shiftId,
...(customer !== undefined ? { customerId: customer.id } : {}),
businessDate: today, lines,
payments: [{ mode, amountPaise: snapshot.totals.payablePaise }],
clientPayablePaise: snapshot.totals.payablePaise,
...(overrides.length > 0 ? { overrides } : {}), // audited server-side in the commit txn (spec P0-2)
})
.then((res) => {
// The bill is committed server-side; printing is a consequence (09-UX §5.4).
clearAfterCommit(res.docNo, snapshot, mode, committedLines, tenderedPaise)
refreshBatches() // per-batch on-hand changed server-side (S5)
return printBill(res.docNo, snapshot, mode)
.then(() => say('ok', `Bill ${res.docNo} · ${formatINR(snapshot.totals.payablePaise)} — printed`))
.catch((err: unknown) =>
say('warn', `Bill ${res.docNo} saved — print: ${err instanceof Error ? err.message : String(err)} (E-2101)`))
})
.catch((err: unknown) => {
// Commit failed: the cart stays; nothing is lost.
// A NETWORK failure (store-server dead) queues offline; the cart is not lost.
// Any other error (engine mismatch, payments, 4xx) keeps the cart on screen.
if (err instanceof NetworkError) return queueOffline(mode, snapshot, committedLines, tenderedPaise)
say('err', `${err instanceof Error ? err.message : String(err)} — bill NOT saved, cart kept`)
return undefined
})
.finally(() => {
setBusy(false)
@ -411,6 +648,10 @@ export function BillingScreen(props: {
inputRef.current?.focus()
return
}
if (batchPickRef.current !== undefined) { // cancel the FEFO pick; item not added
cancelBatchPick()
return
}
setOverlay('none')
setNotice(undefined)
setInput('')
@ -424,7 +665,10 @@ export function BillingScreen(props: {
// The supervisor panel owns every key but Esc — its PIN field has focus and
// F12/commit must never fire mid-approval.
if (supPanelRef.current !== undefined) return
if (overlayRef.current === 'settings' || overlayRef.current === 'customer') return
// The batch picker owns arrows/Enter (handled on its own focused element); the
// global handler must not fire F12/commit or F-keys while a lot is being picked.
if (batchPickRef.current !== undefined) return
if (overlayRef.current === 'settings' || overlayRef.current === 'customer' || overlayRef.current === 'returns') return
// Suggestion list owns arrows+Enter; inline editors own everything.
if (suggRef.current && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter')) return
if (inlineEditRef.current && e.key !== 'F12') return
@ -479,6 +723,7 @@ export function BillingScreen(props: {
case 'F6': e.preventDefault(); setOverlay('customer'); break
case 'F7': e.preventDefault(); hold(); break
case 'F8': e.preventDefault(); resume(); break
case 'F9': e.preventDefault(); setOverlay('returns'); break
case 'F10': e.preventDefault(); openHistory(); break
case 'F11': e.preventDefault(); setOverlay('pay'); break
case 'Delete':
@ -510,6 +755,15 @@ export function BillingScreen(props: {
<Button onClick={props.onLock}>Lock</Button>
</header>
{props.cachedCatalog === true && (
<div style={{
background: 'var(--warn)', color: '#1a1206', fontWeight: 600,
padding: '6px 16px', fontSize: 13, textAlign: 'center',
}}>
OFFLINE using cached catalog. Bills are queued locally and sync when the store-server returns.
</div>
)}
<main className="pos-main">
<section className="pos-left">
<input
@ -543,7 +797,7 @@ export function BillingScreen(props: {
else if (e.key.length === 1) wedge.current.feed(e.key, e.timeStamp)
}}
onBlur={() => {
if (overlayRef.current === 'none' && !inlineEditRef.current && supPanelRef.current === undefined) {
if (overlayRef.current === 'none' && !inlineEditRef.current && supPanelRef.current === undefined && batchPickRef.current === undefined) {
setTimeout(() => inputRef.current?.focus(), 0)
}
}}
@ -599,10 +853,21 @@ export function BillingScreen(props: {
{supPanel !== undefined ? (
<SupervisorPanel
desc={supPanel.desc}
override={supPanel.override}
approvers={props.users.filter((u) => u.role !== 'cashier')}
onApprove={(a) => { supPanel.resolve(a); setSupPanel(undefined) }}
onCancel={() => { supPanel.resolve(undefined); setSupPanel(undefined) }}
/>
) : batchPick !== undefined ? (
<BatchPicker
key={batchPick.item.id}
item={batchPick.item}
qty={batchPick.qty}
batches={batchPick.batches}
today={today}
onPick={(b) => { void acceptBatch(b) }}
onCancel={cancelBatchPick}
/>
) : notice !== undefined ? (
<Notice tone={notice.tone}>{notice.text}</Notice>
) : null}
@ -732,6 +997,12 @@ export function BillingScreen(props: {
<span><span className={`dot ${cfg.enabled ? 'ok' : 'warn'}`} />
{cfg.enabled ? `Printer ${cfg.host}` : 'Printer off'}
{window.pos !== undefined ? ' · kiosk shell' : ' · web'}</span>
{offlineCount > 0 && (
<span style={{
background: 'var(--warn)', color: '#1a1206', fontWeight: 600,
padding: '1px 8px', borderRadius: 4,
}}>OFFLINE · {offlineCount} queued</span>
)}
<span>Bills today: {dayLog.length}</span>
<span>Last: {dayLog[dayLog.length - 1]?.docNo ?? '—'}</span>
<span>Held: {held.length}</span>
@ -763,6 +1034,24 @@ export function BillingScreen(props: {
/>
)}
{overlay === 'returns' && (
<ReturnsModal
store={store}
user={props.user}
shiftId={props.shiftId}
today={today}
onClose={() => { setOverlay('none'); inputRef.current?.focus() }}
onCommitted={(docNo, refundPaise, reason) => {
setOverlay('none')
// Stock went UP server-side (R7) — refresh the local views so the counter sees it.
fetchStock().then((rows) => setStockMap(new Map(rows.map((r) => [r.code, Number(r.on_hand)])))).catch(() => undefined)
refreshBatches()
say('ok', `Credit note ${docNo} · refund ${formatINR(refundPaise)} (${reason})`)
inputRef.current?.focus()
}}
/>
)}
{overlay === 'settings' && <SettingsModal onClose={() => setOverlay('none')} />}
{overlay === 'history' && (
@ -827,6 +1116,73 @@ function EditCell(props: { initial: string; onCommit: (value: string) => void })
)
}
/**
* Inline FEFO batch picker (spec S5) never a modal (R18), replaces the notice area.
* Lots are FEFO-sorted (earliest expiry first); the first sellable lot is preselected;
* / move, Enter accepts, Esc cancels (Esc is handled by the screen's global handler).
* Expired lots are flagged red selecting one raises the E-1310 gate upstream. The root
* div takes focus so the scan box never sees these keys mid-pick.
*/
function BatchPicker(props: {
item: Item
qty: number
batches: BatchStock[]
today: string
onPick: (b: BatchStock) => void
onCancel: () => void
}) {
const sorted = useMemo(() => sortFefo(props.batches), [props.batches])
const [hi, setHi] = useState(() => {
const pick = pickFefoBatch(props.batches, props.today)
const idx = pick !== undefined ? sorted.findIndex((b) => b.id === pick.id) : 0
return idx < 0 ? 0 : idx
})
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => { rootRef.current?.focus() }, [])
return (
<div
ref={rootRef}
tabIndex={0}
className="wf-notice"
style={{ display: 'flex', flexDirection: 'column', gap: 6, outline: 'none' }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setHi((h) => (h + 1) % sorted.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHi((h) => (h + sorted.length - 1) % sorted.length) }
else if (e.key === 'Enter') { e.preventDefault(); const b = sorted[hi]; if (b !== undefined) props.onPick(b) }
}}
>
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
<b>Pick batch {props.item.name} × {props.qty}</b>
<span style={{ color: 'var(--text-dim)', fontSize: 12 }}>FEFO · / move · Enter accept · Esc cancel</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{sorted.map((b, i) => {
const expired = isExpired(b, props.today)
return (
<div
key={b.id}
onClick={() => props.onPick(b)}
style={{
display: 'flex', gap: 12, alignItems: 'center', padding: '4px 8px', borderRadius: 4,
cursor: 'pointer', fontWeight: i === hi ? 600 : 400,
border: i === hi ? '1px solid var(--accent)' : '1px solid transparent',
color: expired ? 'var(--err)' : undefined,
}}
>
<span style={{ fontFamily: 'var(--mono)', minWidth: 96 }}>{b.batchNo}</span>
<span style={{ minWidth: 130 }}>{b.expiryDate !== undefined ? `exp ${b.expiryDate}` : 'no expiry'}</span>
<span className="num" style={{ minWidth: 90 }}>{Math.round(b.onHand)} on hand</span>
{expired ? <Badge tone="err">EXPIRED</Badge> : i === hi ? <Badge tone="ok">FEFO</Badge> : null}
</div>
)
})}
</div>
<div><Button onClick={props.onCancel}>Cancel (Esc)</Button></div>
</div>
)
}
/**
* Inline supervisor gate (spec P0-2) never a modal, replaces the notice area.
* Pick an approver (bootstrap users with cashiers excluded), enter their 46 digit
@ -835,6 +1191,7 @@ function EditCell(props: { initial: string; onCommit: (value: string) => void })
*/
function SupervisorPanel(props: {
desc: string
override: OverrideContext
approvers: PosUser[]
onApprove: (a: Approval) => void
onCancel: () => void
@ -851,8 +1208,8 @@ function SupervisorPanel(props: {
if (who === undefined || pin.length < 4 || busy) return
setBusy(true)
setError(undefined)
verifySupervisorPin(who.id, pin)
.then((r) => props.onApprove({ approvedByUserId: r.userId, approvedByName: r.displayName }))
verifySupervisorPin(who.id, pin, props.override)
.then((r) => props.onApprove({ approvalId: r.approvalId, approvedByName: r.displayName }))
.catch((e: Error) => {
const n = fails + 1
setFails(n)
@ -1015,3 +1372,205 @@ function CustomerModal(props: { onClose: () => void; onAttach: (c: PosCustomer)
</Modal>
)
}
/**
* F9 returns / credit note (01-SCOPE SalesReturns, 09-UX returns/exchange). Look up the
* ORIGINAL bill by doc no or customer phone, choose returnable quantities per line, pick a
* reason + refund tender, and commit. The refund is computed by the SHARED computeReturn from
* the original bill's own amounts (so it matches the server to the paisa, R5), but the server
* is authoritative it re-anchors to its stored bill, blocks over-return, and re-verifies the
* refund (409 on mismatch). Keyboard-first, errors inline, Esc closes (R18/R19).
*/
function ReturnsModal(props: {
store: BootstrapStore
user: PosUser
shiftId: string
today: string
onClose: () => void
onCommitted: (docNo: string, refundPaise: number, reason: string) => void
}) {
const { store, user, today } = props
const [mode, setMode] = useState<'docno' | 'phone'>('docno')
const [query, setQuery] = useState('')
const [results, setResults] = useState<BillLookupResult[] | undefined>()
const [returnable, setReturnable] = useState<ReturnableBill | undefined>()
const [qtys, setQtys] = useState<Record<number, string>>({})
const [reason, setReason] = useState<string>(RETURN_REASONS[0])
const [refundMode, setRefundMode] = useState<PaymentMode>('CASH')
const [error, setError] = useState<string | undefined>()
const [busy, setBusy] = useState(false)
const lookup = () => {
const q = query.trim()
if (q === '') return
setError(undefined)
setResults(undefined)
const p = mode === 'docno' ? lookupBillByDocNo(q) : lookupBillsByPhone(q)
p.then((r) => {
setResults(r)
if (r.length === 0) setError(mode === 'docno' ? `No bill "${q}" found` : `No bills for phone ${q}`)
}).catch((e: Error) => setError(e.message))
}
const chooseBill = (billId: string) => {
setError(undefined)
fetchReturnable(billId)
.then((rb) => {
setReturnable(rb)
setQtys({})
if (rb.lines.every((l) => l.returnableQty <= 0)) setError('Every line on this bill has already been fully returned.')
})
.catch((e: Error) => setError(e.message))
}
// The requested lines: every line with a positive typed quantity.
const requested = returnable === undefined ? [] : returnable.lines.flatMap((l) => {
const q = Number(qtys[l.lineIndex] ?? '')
return Number.isFinite(q) && q > 0 ? [{ lineIndex: l.lineIndex, qty: q }] : []
})
const overReturn = returnable !== undefined && returnable.lines.some((l) => {
const q = Number(qtys[l.lineIndex] ?? '')
return Number.isFinite(q) && q > l.returnableQty
})
// Live refund preview via the SHARED engine (server stays authoritative).
let preview: ReturnType<typeof computeReturn> | undefined
try {
preview = returnable !== undefined && requested.length > 0 && !overReturn
? computeReturn(returnable.originalLines, requested, { roundToRupee: true })
: undefined
} catch { preview = undefined }
const printCreditNote = (docNo: string, computed: ReturnType<typeof computeReturn>): Promise<void> => {
const cfg = loadPrinterCfg()
const kick = refundMode === 'CASH'
const receiptDoc: ReceiptDoc = {
storeName: store.name, gstin: store.gstin, docNo, businessDate: today,
counterCode: store.counterCode, cashierName: user.name,
...(returnable?.customerName !== undefined ? { buyerName: returnable.customerName } : {}),
lines: computed.lines, totals: computed.totals, paymentLabel: refundMode,
variant: 'credit-note', againstDocNo: returnable?.docNo ?? '', reason,
}
return cfg.script === 'raster'
? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick)
: sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick }))
}
const commit = () => {
if (returnable === undefined || busy) return
if (requested.length === 0) { setError('Enter a return quantity on at least one line.'); return }
if (overReturn) { setError('A return quantity exceeds what is left to return.'); return }
if (preview === undefined) { setError('Could not compute the refund — check the quantities.'); return }
const snapshot = preview
setBusy(true)
setError(undefined)
postReturn({
storeId: store.id, counterId: store.counterId, shiftId: props.shiftId, businessDate: today,
originalBillId: returnable.id, lines: requested, reason, refundMode,
clientRefundPaise: snapshot.totals.payablePaise,
})
.then((res) => {
// The credit note is committed server-side; printing is a consequence (never blocks it).
void printCreditNote(res.docNo, snapshot).catch(() => undefined)
props.onCommitted(res.docNo, res.refundPaise, reason)
})
.catch((e: Error) => { setError(e.message); setBusy(false) })
}
return (
<Modal title="Return / Credit note (F9)" onClose={props.onClose}>
{returnable === undefined ? (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<select
className="wf" style={{ maxWidth: 150 }} value={mode}
onChange={(e) => { setMode(e.target.value as 'docno' | 'phone'); setResults(undefined); setError(undefined) }}
>
<option value="docno">By bill no</option>
<option value="phone">By phone</option>
</select>
<input
autoFocus className="wf" style={{ flex: 1 }}
placeholder={mode === 'docno' ? 'Original bill no e.g. ST1C22026-000001' : 'Customer phone'}
value={query}
onChange={(e) => setQuery(mode === 'phone' ? e.target.value.replace(/\D/g, '') : e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookup() } }}
/>
<Button tone="primary" onClick={lookup}>Find</Button>
</div>
{results !== undefined && results.length > 0 && (
<table className="wf">
<thead><tr><th>Bill</th><th>Date</th><th>Cashier</th><th>Customer</th><th className="num">Amount</th></tr></thead>
<tbody>
{results.map((b) => (
<tr key={b.id} style={{ cursor: 'pointer' }} onClick={() => chooseBill(b.id)}>
<td>{b.doc_no}</td>
<td>{b.business_date}</td>
<td>{b.cashier_name ?? ''}</td>
<td>{b.customer_name !== null && b.customer_name !== undefined ? b.customer_name : 'walk-in'}</td>
<td className="num">{formatINR(b.payable_paise)}</td>
</tr>
))}
</tbody>
</table>
)}
</>
) : (
<>
<div style={{ marginBottom: 8 }}>
Returning against <b>{returnable.docNo}</b> · {returnable.businessDate}
{returnable.customerName !== undefined ? <> · {returnable.customerName}</> : null}
<span style={{ marginLeft: 10 }}>
<Button onClick={() => { setReturnable(undefined); setResults(undefined); setError(undefined) }}>Change bill</Button>
</span>
</div>
<table className="wf">
<thead>
<tr>
<th>Item</th><th className="num">Sold</th><th className="num">Returned</th>
<th className="num">Left</th><th className="num">Return qty</th>
</tr>
</thead>
<tbody>
{returnable.lines.map((l) => (
<tr key={l.lineIndex}>
<td>{l.name}</td>
<td className="num">{l.soldQty} {l.unitCode}</td>
<td className="num">{l.returnedQty}</td>
<td className="num">{l.returnableQty}</td>
<td className="num">
<input
className="wf num" style={{ width: 80 }}
disabled={l.returnableQty <= 0}
placeholder="0"
value={qtys[l.lineIndex] ?? ''}
onChange={(e) => setQtys((m) => ({ ...m, [l.lineIndex]: e.target.value.replace(/[^\d.]/g, '') }))}
/>
</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', marginTop: 10, flexWrap: 'wrap' }}>
<Field label="Reason">
<select className="wf" value={reason} onChange={(e) => setReason(e.target.value)}>
{RETURN_REASONS.map((r) => <option key={r}>{r}</option>)}
</select>
</Field>
<Field label="Refund via">
<select className="wf" value={refundMode} onChange={(e) => setRefundMode(e.target.value as PaymentMode)}>
{(['CASH', 'UPI', 'CARD', 'KHATA'] as PaymentMode[]).map((m) => <option key={m}>{m}</option>)}
</select>
</Field>
</div>
{overReturn && <Notice tone="err">A return quantity exceeds what is left to return.</Notice>}
<div className="payable" style={{ margin: '12px 0' }}>
<span>REFUND</span>
<span className="amount num">{formatINR(preview?.totals.payablePaise ?? 0)}</span>
</div>
<Button tone="primary" onClick={commit}>{busy ? 'Processing…' : 'Commit return & print credit note'}</Button>
</>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Modal>
)
}

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

@ -8,3 +8,12 @@ import { App } from './App'
initTheme()
createRoot(document.getElementById('root')!).render(<App />)
// S4 app-shell offline: register the cache-first service worker (scope /pos/) so the
// POS loads from cache when the store-server is down. Best-effort — a failure here
// never blocks billing (the IndexedDB queue + cached catalog do the real work).
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('./sw.js').catch(() => undefined)
})
}

@ -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,44 @@
# SiMS store-server environment — copy to a real env/secret store for production.
# None of these are required for a dev run (npm start sets SIMS_SEED_DEMO for you), but
# production MUST set the security-critical ones marked below.
# H3 — full-DB encryption at rest (REQUIRED in production).
# When set, the whole SQLite store (sims.db + its WAL/SHM sidecars) is SQLCipher-encrypted on disk,
# so a copied or snooped shop PC yields no customer PII (phones/GSTINs — DPDP), costs & margins,
# sales history, party ledgers or PIN hashes. Held ONLY here / in the OS secret store — NEVER in the
# repo or the DB. On Windows, deliver it via a machine-scoped DPAPI secret (or an OS keychain), not a
# plaintext file next to the DB. Use a long random value, e.g.:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# WARNING: this keys the database. An EXISTING plaintext sims.db must be migrated ONCE offline
# (sqlcipher_export — see openDb() in src/db.ts) before the first encrypted boot; a fresh/empty DB
# is created encrypted automatically. Leave unset only in dev (the file is then plaintext).
SIMS_DB_KEY=
# M3 — server-held PIN/password pepper (REQUIRED in production).
# HMAC-SHA256'd into every PIN/password before scrypt, so a stolen DB cannot be brute-forced
# against the tiny 4-digit PIN keyspace. Keep it out of the DB and out of source control; store
# it in the OS secret store / process env. Use a long random value, e.g.:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# WARNING: enabling or changing this invalidates every existing hash — PINs/passwords must be
# re-enrolled (a dev re-seed handles it automatically). Leave unset only in dev.
SIMS_PIN_PEPPER=
# M10 — demo seed gate. '1' seeds the MegaMart demo (known-credential owner thomas/9174/sims)
# into an empty DB. Leave UNSET in production so a real empty DB is never auto-seeded. The dev
# start script (npm start → dev-start.mjs) sets this to '1' for you.
SIMS_SEED_DEMO=
# M13 — TLS (production should serve HTTPS). Paths to a cert + key; when both are set the server
# serves HTTPS, otherwise plain HTTP for dev. Make a throwaway dev cert with:
# openssl req -x509 -newkey rsa:2048 -nodes -keyout dev-key.pem -out dev-cert.pem -days 365 -subj "/CN=localhost"
SIMS_TLS_CERT=
SIMS_TLS_KEY=
# H1 — extra allowlisted LAN printer ports (comma-separated) beyond the defaults {9100,9101,9102}.
SIMS_PRINTER_PORTS=
# Isolated data dir for a second instance (load tests, throwaway runs). Unset ⇒ ../data.
SIMS_DATA_DIR=
# HTTP port (defaults to 5181; 80808085 are Windows-reserved on shop PCs).
PORT=

@ -6,6 +6,9 @@ await build({
bundle: true,
platform: 'node',
format: 'cjs',
external: ['better-sqlite3'],
// Native modules can't be bundled. db.ts imports better-sqlite3-multiple-ciphers (the
// SQLCipher-compatible drop-in — H3 encryption at rest); better-sqlite3 stays externalled
// too in case any transitive path still references it.
external: ['better-sqlite3-multiple-ciphers', 'better-sqlite3'],
})
console.log('store-server built')

@ -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,306 @@
// S7 load verification (docs/17 §S7, R25) — scripted load against ONE store-server.
//
// Fires 2,500 bill commits distributed across 10 counters at a concurrency of 10 (one
// in-flight request per counter, realistic for a single store-server), then a burst
// phase (100 bills at concurrency 50) to find the knee. Measures wall-clock, throughput,
// and p50/p95/p99/max latency, then runs six integrity checks directly against the load
// DB with better-sqlite3 and prints a pass/fail table.
//
// Plain node (node 22, global fetch). The only imports are better-sqlite3 and esbuild's
// absence — no engine bundle is needed because every billable seed item is priced
// tax-inclusive with no discount, so the payable is exactly roundToRupee(Σ qty·price)
// (billing-engine compute.ts: priceIncludesTax ⇒ lineTotal = net = round(price·qty);
// payable = round(Σ lineTotal / 100)·100 — independent of the tax rate). The server
// re-verifies every bill to the paisa with the real shared engine (R5) and rejects any
// mismatch, so a wrong client payable would surface immediately as failed commits.
import Database from 'better-sqlite3'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
const BASE = process.env.BASE ?? 'http://localhost:5281'
const DATA_DIR = process.env.SIMS_DATA_DIR
if (!DATA_DIR) { console.error('Set SIMS_DATA_DIR to the load target data dir'); process.exit(1) }
const DB_PATH = path.join(DATA_DIR, 'sims.db')
const TENANT = 't1'
const STORE = 's1'
const BUSINESS_DATE = '2026-07-10'
const N_COUNTERS = 10
const TOTAL_BILLS = 2500
const BURST_BILLS = 100
const BURST_CONC = 50
// The 4 seeded users (sessions are per-token, so reusing them across counters is fine).
const USERS = [
{ id: 'u1', pin: '4728' }, { id: 'u2', pin: '8265' },
{ id: 'u3', pin: '9174' }, { id: 'u4', pin: '3591' },
]
const COUNTERS = Array.from({ length: N_COUNTERS }, (_, i) => ({
id: `c${i + 1}`, code: `C${i + 1}`,
}))
const rng = (n) => Math.floor(Math.random() * n)
const pct = (arr, p) => {
if (arr.length === 0) return 0
const s = [...arr].sort((a, b) => a - b)
const idx = Math.min(s.length - 1, Math.ceil((p / 100) * s.length) - 1)
return s[Math.max(0, idx)]
}
const round2 = (n) => Math.round(n * 100) / 100
// ---- setup: ensure 10 real counters (seed makes only C1/C2) ----
function ensureCounters() {
const db = new Database(DB_PATH)
db.pragma('busy_timeout = 5000')
const ins = db.prepare(
`INSERT OR IGNORE INTO counter (id, tenant_id, store_id, code, name) VALUES (?, ?, ?, ?, ?)`,
)
const tx = db.transaction(() => {
for (const c of COUNTERS) ins.run(c.id, TENANT, STORE, c.code, `Counter ${c.code.slice(1)}`)
})
tx()
const n = db.prepare(`SELECT COUNT(*) n FROM counter WHERE tenant_id=? AND store_id=?`).get(TENANT, STORE).n
db.close()
return n
}
async function login(user) {
const r = await fetch(`${BASE}/api/auth/pin`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id, pin: user.pin }),
})
if (!r.ok) throw new Error(`login ${user.id} failed: ${r.status} ${await r.text()}`)
return (await r.json()).token
}
async function loadCatalog(token) {
const r = await fetch(`${BASE}/api/items?all=1`, { headers: { Authorization: `Bearer ${token}` } })
if (!r.ok) throw new Error(`items failed: ${r.status}`)
const items = await r.json()
// Skip batch-tracked items (they need a valid batchId); everything else is
// tax-inclusive, so the payable is trivially roundToRupee(Σ qty·price).
return items
.filter((it) => !it.batchTracked && it.priceIncludesTax)
.map((it) => ({
itemId: it.id, name: it.name, hsn: it.hsn, unitCode: it.unit.code,
unitPricePaise: it.salePricePaise, priceIncludesTax: true, taxClassCode: it.taxClassCode,
}))
}
// Build a random 15 line bill from distinct catalog items, integer qty 15.
function buildBill(catalog, counterId) {
const nLines = 1 + rng(5)
const pool = [...catalog]
const lines = []
for (let i = 0; i < nLines && pool.length > 0; i++) {
const idx = rng(pool.length)
const it = pool.splice(idx, 1)[0]
const qty = 1 + rng(5)
lines.push({ ...it, qty })
}
const total = lines.reduce((a, l) => a + l.unitPricePaise * l.qty, 0)
const payable = Math.round(total / 100) * 100 // roundToRupee (ctx.roundToRupee=true)
return {
body: {
storeId: STORE, counterId, shiftId: 'load-sh1', businessDate: BUSINESS_DATE,
lines, payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
},
lines,
}
}
async function postBill(token, bill, tally) {
const t0 = performance.now()
let status = 0, errText = ''
try {
const r = await fetch(`${BASE}/api/bills`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(bill.body),
})
status = r.status
if (!r.ok) errText = await r.text()
else {
await r.json()
for (const l of bill.lines) tally.items[l.itemId] = (tally.items[l.itemId] ?? 0) + l.qty
tally.lines += bill.lines.length
}
} catch (e) {
errText = String(e)
}
const dt = performance.now() - t0
return { ok: status >= 200 && status < 300, status, errText, dt }
}
// ---- steady phase: one worker per counter, 250 sequential bills each ----
async function steadyPhase(tokenByCounter, catalog, tally) {
const perCounter = Math.floor(TOTAL_BILLS / N_COUNTERS)
const latencies = []
let success = 0
const errors = {}
const t0 = performance.now()
await Promise.all(COUNTERS.map(async (counter) => {
const token = tokenByCounter[counter.id]
for (let i = 0; i < perCounter; i++) {
const bill = buildBill(catalog, counter.id)
const res = await postBill(token, bill, tally)
latencies.push(res.dt)
if (res.ok) success++
else errors[res.status] = (errors[res.status] ?? 0) + 1, (tally.firstErr ??= res.errText)
}
}))
const wallMs = performance.now() - t0
return { attempted: perCounter * N_COUNTERS, success, errors, latencies, wallMs }
}
// ---- burst phase: 100 bills, concurrency 50 (find the knee) ----
async function burstPhase(tokens, catalog, tally) {
const latencies = []
let success = 0
const errors = {}
let next = 0
const t0 = performance.now()
async function worker() {
while (true) {
const i = next++
if (i >= BURST_BILLS) return
const counter = COUNTERS[i % N_COUNTERS]
const token = tokens[i % tokens.length]
const bill = buildBill(catalog, counter.id)
const res = await postBill(token, bill, tally)
latencies.push(res.dt)
if (res.ok) success++
else errors[res.status] = (errors[res.status] ?? 0) + 1
}
}
await Promise.all(Array.from({ length: BURST_CONC }, worker))
const wallMs = performance.now() - t0
return { attempted: BURST_BILLS, success, errors, latencies, wallMs }
}
// ---- integrity checks against the load DB ----
function integrity(tally, expectedBills, expectedLines) {
const db = new Database(DB_PATH, { readonly: true })
db.pragma('busy_timeout = 5000')
const checks = []
const add = (name, pass, detail) => checks.push({ name, pass, detail })
// (a) bill count == successful commits
const billCount = db.prepare(`SELECT COUNT(*) n FROM bill`).get().n
add('(a) bill rows == successful commits', billCount === expectedBills,
`bills=${billCount}, successes=${expectedBills}`)
// (b) every counter's SALE series is gap-free and duplicate-free (1..n)
const bills = db.prepare(`SELECT counter_id, doc_no FROM bill WHERE doc_type='SALE'`).all()
const byCounter = {}
for (const b of bills) {
const seq = parseInt(b.doc_no.slice(b.doc_no.lastIndexOf('-') + 1), 10)
;(byCounter[b.counter_id] ??= []).push(seq)
}
let seriesOk = true
const seriesDetail = []
for (const [cid, seqs] of Object.entries(byCounter)) {
seqs.sort((a, b) => a - b)
const dup = new Set(seqs).size !== seqs.length
const gap = seqs.some((s, i) => s !== i + 1)
if (dup || gap) seriesOk = false
seriesDetail.push(`${cid}:1..${seqs.length}${dup ? ' DUP' : ''}${gap ? ' GAP' : ''}`)
}
add('(b) per-counter series gap-free & dup-free', seriesOk, seriesDetail.join(' '))
// (c) SUM(movements per item) == opening billed qty; and one SALE movement per line
let stockOk = true
const stockDetail = []
for (const [itemId, billedQty] of Object.entries(tally.items)) {
const opening = db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=? AND reason='OPENING'`).get(itemId).s
const final = db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=?`).get(itemId).s
const saleQty = -db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=? AND reason='SALE'`).get(itemId).s
const ok = final === opening - billedQty && saleQty === billedQty
if (!ok) { stockOk = false; stockDetail.push(`${itemId} open=${opening} billed=${billedQty} final=${final} saleMv=${saleQty}`) }
}
const saleMoves = db.prepare(`SELECT COUNT(*) n FROM stock_movement WHERE reason='SALE'`).get().n
if (saleMoves !== expectedLines) { stockOk = false; stockDetail.push(`SALE movements ${saleMoves} != lines ${expectedLines}`) }
add('(c) stock derived == opening billed (per item)', stockOk,
stockOk ? `${Object.keys(tally.items).length} items reconciled; ${saleMoves} SALE movements == ${expectedLines} lines` : stockDetail.join(' | '))
// (d) every bill's payments sum == payable
const payRows = db.prepare(`SELECT payable_paise, payload FROM bill`).all()
let payMismatch = 0
for (const row of payRows) {
const payments = JSON.parse(row.payload).payments ?? []
const sum = payments.reduce((a, p) => a + p.amountPaise, 0)
if (sum !== row.payable_paise) payMismatch++
}
add('(d) payments sum == payable (every bill)', payMismatch === 0,
`${payRows.length} bills checked, ${payMismatch} mismatch`)
// (e) audit BILL_COMMIT count == bill count
const auditCommits = db.prepare(`SELECT COUNT(*) n FROM audit_log WHERE action='BILL_COMMIT'`).get().n
add('(e) audit BILL_COMMIT == bill count', auditCommits === billCount,
`BILL_COMMIT=${auditCommits}, bills=${billCount}`)
// (f) outbox rows == bill count
const outbox = db.prepare(`SELECT COUNT(*) n FROM outbox`).get().n
add('(f) outbox rows == bill count', outbox === billCount,
`outbox=${outbox}, bills=${billCount}`)
db.close()
return checks
}
async function main() {
console.log('=== S7 LOAD VERIFICATION ===')
const cpu = os.cpus()
console.log(`env: node ${process.version} | ${cpu.length}× ${cpu[0].model.trim()} | ${round2(os.totalmem() / 1024 ** 3)} GB RAM | ${os.platform()} ${os.release()}`)
console.log(`target: ${BASE} db: ${DB_PATH}`)
const counterCount = ensureCounters()
console.log(`counters ready: ${counterCount}`)
const tokens = []
for (const u of USERS) tokens.push(await login(u))
console.log(`logged in ${tokens.length} users`)
const tokenByCounter = {}
COUNTERS.forEach((c, i) => { tokenByCounter[c.id] = tokens[i % tokens.length] })
const catalog = await loadCatalog(tokens[0])
console.log(`billable catalog: ${catalog.length} items (batch-tracked skipped)`)
const tally = { items: {}, lines: 0, firstErr: undefined }
console.log(`\n--- STEADY: ${TOTAL_BILLS} bills across ${N_COUNTERS} counters, concurrency ${N_COUNTERS} ---`)
const steady = await steadyPhase(tokenByCounter, catalog, tally)
const sThr = steady.success / (steady.wallMs / 1000)
console.log(`attempted=${steady.attempted} success=${steady.success} errors=${JSON.stringify(steady.errors)}`)
console.log(`wall=${round2(steady.wallMs / 1000)}s throughput=${round2(sThr)} bills/s`)
console.log(`latency ms: p50=${round2(pct(steady.latencies, 50))} p95=${round2(pct(steady.latencies, 95))} p99=${round2(pct(steady.latencies, 99))} max=${round2(Math.max(...steady.latencies))}`)
if (steady.firstErr) console.log(`first error: ${steady.firstErr}`)
if (tally.firstErr) console.log(`sample error body: ${tally.firstErr}`)
console.log(`\n--- BURST: ${BURST_BILLS} bills, concurrency ${BURST_CONC} ---`)
const burst = await burstPhase(tokens, catalog, tally)
const bThr = burst.success / (burst.wallMs / 1000)
console.log(`attempted=${burst.attempted} success=${burst.success} errors=${JSON.stringify(burst.errors)}`)
console.log(`wall=${round2(burst.wallMs / 1000)}s throughput=${round2(bThr)} bills/s`)
console.log(`latency ms: p50=${round2(pct(burst.latencies, 50))} p95=${round2(pct(burst.latencies, 95))} p99=${round2(pct(burst.latencies, 99))} max=${round2(Math.max(...burst.latencies))}`)
const totalSuccess = steady.success + burst.success
console.log(`\n--- INTEGRITY (load DB) ---`)
const checks = integrity(tally, totalSuccess, tally.lines)
for (const c of checks) console.log(`${c.pass ? 'PASS' : 'FAIL'} ${c.name} [${c.detail}]`)
const allPass = checks.every((c) => c.pass)
console.log(`\nINTEGRITY: ${allPass ? 'ALL PASS' : 'FAILURES PRESENT'}`)
// Machine-readable summary for transcription into STATUS.md.
const summary = {
env: { node: process.version, cpus: cpu.length, cpuModel: cpu[0].model.trim(), ramGB: round2(os.totalmem() / 1024 ** 3), platform: `${os.platform()} ${os.release()}` },
steady: { attempted: steady.attempted, success: steady.success, errors: steady.errors, wallS: round2(steady.wallMs / 1000), throughput: round2(sThr), p50: round2(pct(steady.latencies, 50)), p95: round2(pct(steady.latencies, 95)), p99: round2(pct(steady.latencies, 99)), max: round2(Math.max(...steady.latencies)) },
burst: { attempted: burst.attempted, success: burst.success, errors: burst.errors, wallS: round2(burst.wallMs / 1000), throughput: round2(bThr), p50: round2(pct(burst.latencies, 50)), p95: round2(pct(burst.latencies, 95)), p99: round2(pct(burst.latencies, 99)), max: round2(Math.max(...burst.latencies)) },
totalSuccess, totalLines: tally.lines,
checks, allPass,
}
console.log(`\n===SUMMARY_JSON===\n${JSON.stringify(summary)}\n===END_SUMMARY_JSON===`)
process.exit(allPass && totalSuccess === TOTAL_BILLS + BURST_BILLS ? 0 : 1)
}
main().catch((e) => { console.error(e); process.exit(2) })

@ -5,15 +5,17 @@
"type": "module",
"scripts": {
"build": "node build-server.mjs",
"start": "npm run build && node dist/server.cjs",
"start": "npm run build && node dev-start.mjs",
"start:prod": "npm run build && node dist/server.cjs",
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"@sims/auth": "*",
"@sims/billing-engine": "*",
"@sims/domain": "*",
"better-sqlite3": "^11.7.0",
"express": "^4.21.0"
"better-sqlite3-multiple-ciphers": "^11.10.0",
"express": "^4.21.0",
"helmet": "^8.3.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",

@ -1,21 +1,35 @@
import { Router, type Request, type Response, type NextFunction } from 'express'
import { randomUUID } from 'node:crypto'
import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, type LockoutState } from '@sims/auth'
import net from 'node:net'
import dns from 'node:dns'
import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, can, type LockoutState, type ActionCode } from '@sims/auth'
import type { RoleCode } from '@sims/domain'
import type { LineInput } from '@sims/billing-engine'
import type { DB } from './db'
import {
commitBill, createCustomer, createItem, listAudit, listBills, listItems,
listParties, listTaxClasses, stockView, writeAudit,
batchesForStore, commitBill, createCustomer, createItem, createOverrideApproval, expiryView,
listAudit, listBills, listItems, listParties, listTaxClasses, resolveDefaultTaxClass,
resolveDraftMaxPricePaise, stockView, verifyAuditChain, writeAudit,
} from './repos'
import { commitPurchase, costHistory, lastCosts, listPurchases, type CommitPurchaseInput } from './repos-purchase'
import { commitReturn, listReturns, lookupBillByDocNo, lookupBillsByPhone, returnableLines } from './repos-returns'
import { gstr1, gstr3b, hsnSummary, gstr1PortalJson } from './repos-gst'
import {
ValidationError, assertString, optString, assertInt, assertNonNegInt, assertPositiveInt,
assertPaise, assertQty, assertBusinessDate, assertPeriod, assertArray, clampLimit, clampOffset, PAYMENT_MODES,
} from './validate'
import {
createRateLimiter, authRatePerIpFromEnv, authRateGlobalFromEnv, type RateLimitPolicy,
} from './rate-limit'
import { isSessionExpired, sessionTtlFromEnv, type SessionTtl } from './session-policy'
import { checkPrintEnvelope, checkPrintAddress, parsePrinterPorts } from './print-guard'
interface Session {
token: string; userId: string; tenantId: string; displayName: string; roles: string[]
// M4: sessions now have a lifetime — created-at (absolute cap) + last-seen (idle cap).
createdMs: number; lastSeenMs: number
}
const sessions = new Map<string, Session>()
const lockouts = new Map<string, LockoutState>()
interface UserRow {
id: string; tenant_id: string; username: string; display_name: string
roles: string; active: number
@ -23,26 +37,173 @@ interface UserRow {
pw_salt: string | null; pw_hash: string | null
}
function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
/** Injection points so tests can force tiny TTLs / rate caps / a stub DNS resolver. */
export interface ApiRouterOpts {
sessionTtl?: SessionTtl
authRatePerIp?: RateLimitPolicy
authRateGlobal?: RateLimitPolicy
printerPorts?: ReadonlySet<number>
resolveHost?: (host: string) => Promise<string[]>
}
/** The bearer token carried on a request, if any. */
const bearer = (req: Request): string => (req.headers.authorization ?? '').replace('Bearer ', '')
const sess = (req: Request): Session => (req as Request & { session: Session }).session
const asUser = (s: Session): { roles: RoleCode[]; active: boolean } => ({ roles: s.roles as RoleCode[], active: true })
/**
* Per-route server-side authorization (C1/C4), deny-by-default. Each mutating or sensitive
* route names the ActionCode(s) that grant it; a session whose roles grant NONE is 403'd
* (E-6105) before the handler runs. The same roleaction matrix the React app uses
* (permissions.ts) is now enforced on the server the client is no longer trusted to gate.
*/
const requireAnyPerm = (...actions: ActionCode[]) =>
(req: Request, res: Response, next: NextFunction): void => {
const user = asUser(sess(req))
if (actions.some((a) => can(user, a))) { next(); return }
res.status(403).json({ error: 'You are not permitted to do that (E-6105)', code: 'E-6105' })
}
const OVERRIDE_KINDS = new Set(['price', 'qty', 'discount', 'batch'])
/** Validate the shared bill body shape (M7) — deep money truth is re-derived in commitBill. */
function validateBillBody(raw: unknown): {
storeId: string; counterId: string; shiftId: string; customerId?: string
businessDate: string; lines: LineInput[]
payments: { mode: string; amountPaise: number; reference?: string }[]
clientPayablePaise: number
overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string }[]
} {
const b = (raw ?? {}) as Record<string, unknown>
const storeId = assertString(b['storeId'], 'storeId')
const counterId = assertString(b['counterId'], 'counterId')
const shiftId = assertString(b['shiftId'], 'shiftId')
const customerId = optString(b['customerId'], 'customerId')
const businessDate = assertBusinessDate(b['businessDate'])
const rawLines = assertArray<Record<string, unknown>>(b['lines'], 'lines', 500)
const lines = rawLines.map((l): LineInput => {
assertString(l['itemId'], 'line itemId')
assertQty(l['qty'], 'line qty')
assertPaise(l['unitPricePaise'], 'line price')
return l as unknown as LineInput
})
const rawPayments = assertArray<Record<string, unknown>>(b['payments'], 'payments', 50)
const payments = rawPayments.map((p) => {
const amountPaise = assertInt(p['amountPaise'], 'payment amount')
if (amountPaise <= 0) throw new ValidationError('Every payment must be a positive amount', 'E-1107')
const mode = assertString(p['mode'], 'payment mode')
if (!PAYMENT_MODES.has(mode)) throw new ValidationError('Unknown payment mode', 'E-1108')
return { mode, amountPaise, ...(optString(p['reference'], 'reference') !== undefined ? { reference: optString(p['reference'], 'reference')! } : {}) }
})
const clientPayablePaise = assertPaise(b['clientPayablePaise'], 'clientPayablePaise')
const overrides = b['overrides'] === undefined ? undefined
: (assertArray<Record<string, unknown>>(b['overrides'], 'overrides', 500)).map((o) => {
const kind = assertString(o['kind'], 'override kind')
if (!OVERRIDE_KINDS.has(kind)) throw new ValidationError('Unknown override kind', 'E-1001')
return {
itemId: assertString(o['itemId'], 'override itemId'),
kind: kind as 'price' | 'qty' | 'discount' | 'batch',
before: assertInt(o['before'], 'override before'),
after: assertInt(o['after'], 'override after'),
...(optString(o['approvalId'], 'approvalId') !== undefined ? { approvalId: optString(o['approvalId'], 'approvalId')! } : {}),
}
})
return {
storeId, counterId, shiftId, ...(customerId !== undefined ? { customerId } : {}),
businessDate, lines, payments, clientPayablePaise, ...(overrides !== undefined ? { overrides } : {}),
}
}
/** Validate the return / credit-note body (M7). Deep money truth is re-derived in commitReturn. */
function validateReturnBody(raw: unknown): {
storeId: string; counterId: string; shiftId: string; businessDate: string
originalBillId: string; lines: { lineIndex: number; qty: number }[]
reason: string; refundMode: string; clientRefundPaise?: number
} {
const b = (raw ?? {}) as Record<string, unknown>
const storeId = assertString(b['storeId'], 'storeId')
const counterId = assertString(b['counterId'], 'counterId')
const shiftId = assertString(b['shiftId'], 'shiftId')
const businessDate = assertBusinessDate(b['businessDate'])
const originalBillId = assertString(b['originalBillId'], 'originalBillId')
const rawLines = assertArray<Record<string, unknown>>(b['lines'], 'lines', 500)
const lines = rawLines.map((l) => ({
lineIndex: assertNonNegInt(l['lineIndex'], 'line index'),
qty: assertQty(l['qty'], 'return qty'),
}))
const reason = assertString(b['reason'], 'reason')
const refundMode = assertString(b['refundMode'], 'refundMode')
if (!PAYMENT_MODES.has(refundMode)) throw new ValidationError('Unknown refund mode', 'E-1326')
const clientRefundPaise = b['clientRefundPaise'] === undefined ? undefined : assertPaise(b['clientRefundPaise'], 'clientRefundPaise')
return {
storeId, counterId, shiftId, businessDate, originalBillId, lines, reason, refundMode,
...(clientRefundPaise !== undefined ? { clientRefundPaise } : {}),
}
}
export function apiRouter(db: DB, opts: ApiRouterOpts = {}): Router {
const r = Router()
// Edge state is per-router-instance (not module-global) so each mounted server — the prod
// process or a test harness — is fully isolated: its own sessions, lockouts and rate-limit
// windows, with no cross-instance bleed.
const sessions = new Map<string, Session>()
const lockouts = new Map<string, LockoutState>()
const ttl = opts.sessionTtl ?? sessionTtlFromEnv()
const authPerIp = createRateLimiter(opts.authRatePerIp ?? authRatePerIpFromEnv())
const authGlobal = createRateLimiter(opts.authRateGlobal ?? authRateGlobalFromEnv())
const printerPorts = opts.printerPorts ?? parsePrinterPorts(process.env['SIMS_PRINTER_PORTS'])
const resolveHost = opts.resolveHost ?? (async (h: string): Promise<string[]> =>
(await dns.promises.lookup(h, { all: true })).map((a) => a.address))
const sessionDto = (s: Session): Record<string, unknown> =>
({ token: s.token, userId: s.userId, tenantId: s.tenantId, displayName: s.displayName, roles: s.roles })
/**
* Authenticated-session gate with lifetime enforcement (M4): a token must map to a live
* session inside BOTH its idle and absolute TTL, else it is evicted and the caller gets 401
* (E-6110) and re-logs in. Each authenticated hit slides the idle window (refreshes last-seen).
*/
const requireAuth = (req: Request, res: Response, next: NextFunction): void => {
const token = bearer(req)
const session = sessions.get(token)
if (session === undefined) {
res.status(401).json({ error: 'Not signed in (E-6103)' })
res.status(401).json({ error: 'Not signed in (E-6103)', code: 'E-6103' })
return
}
const now = Date.now()
if (isSessionExpired(session, now, ttl)) {
sessions.delete(token)
res.status(401).json({ error: 'Session expired — sign in again (E-6110)', code: 'E-6110' })
return
}
session.lastSeenMs = now
;(req as Request & { session: Session }).session = session
next()
}
const sess = (req: Request): Session => (req as Request & { session: Session }).session
export function apiRouter(db: DB): Router {
const r = Router()
/**
* Global + per-IP throttle for /auth/* (M2/M4, rt-F7). Layered ON TOP of the per-user scrypt
* lockout: the lockout stops repeated failures against ONE user; this blunts a parallel grind
* across the whole roster from one source, whichever user each attempt names.
*/
const authRateLimit = (req: Request, res: Response, next: NextFunction): void => {
const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown'
const now = Date.now()
if (!authPerIp.hit(ip, now) || !authGlobal.hit('*', now)) {
res.status(429).json({ error: 'Too many attempts — try again shortly (E-6109)', code: 'E-6109' })
return
}
next()
}
const startSession = (u: UserRow): Session => {
const now = Date.now()
const s: Session = {
token: randomUUID(), userId: u.id, tenantId: u.tenant_id,
displayName: u.display_name, roles: JSON.parse(u.roles) as string[],
createdMs: now, lastSeenMs: now,
}
sessions.set(s.token, s)
return s
@ -63,30 +224,59 @@ export function apiRouter(db: DB): Router {
}
// POS: name-tile + PIN
r.post('/auth/pin', (req, res) => {
r.post('/auth/pin', authRateLimit, (req, res) => {
const { userId, pin } = req.body as { userId?: string; pin?: string }
const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined
const out = attempt(u, pin ?? '', u?.pin_salt ?? null, u?.pin_hash ?? null)
'token' in out ? res.json(out) : res.status(out.status).json({ error: out.error })
'token' in out ? res.json(sessionDto(out)) : res.status(out.status).json({ error: out.error })
})
// Back office: username + password
r.post('/auth/login', (req, res) => {
r.post('/auth/login', authRateLimit, (req, res) => {
const { username, password } = req.body as { username?: string; password?: string }
const u = db.prepare(`SELECT * FROM app_user WHERE username=?`).get((username ?? '').toLowerCase()) as UserRow | undefined
const out = attempt(u, password ?? '', u?.pw_salt ?? null, u?.pw_hash ?? null)
'token' in out ? res.json(out) : res.status(out.status).json({ error: out.error })
'token' in out ? res.json(sessionDto(out)) : res.status(out.status).json({ error: out.error })
})
// In-flow supervisor gate (spec P0-2): verify a PIN WITHOUT creating a session —
// the cashier's login must never be disturbed and `sessions` must not grow. Shares
// the same lockout + scrypt verification as attempt(); audits PIN_VERIFY on success
// and a LOGIN_FAILED-style PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor /
// manager / owner roles may approve an override (09-UX §5.5) — a valid-PIN cashier
// is rejected loudly, never granted.
// and a PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor / manager / owner roles
// may approve an override (09-UX §5.5) — a valid-PIN cashier is rejected loudly.
//
// H4 fix: the minted token is BOUND to the exact override being approved
// ({itemId, kind, before, after}) — the commit re-verifies this against the line's
// server-computed deviation so an approval can never be re-attached to another line.
const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
r.post('/auth/verify-pin', (req, res) => {
const { userId, pin } = req.body as { userId?: string; pin?: string }
// M2: verify-pin is only ever called mid-billing by an already-logged-in cashier, so it now
// REQUIRES a valid caller session (requireAuth) and is rate-limited — closing its use as an
// UNAUTHENTICATED PIN oracle / token-mint vector. The approver's PIN still rides in the body;
// the caller session just proves a real counter is asking.
r.post('/auth/verify-pin', authRateLimit, requireAuth, (req, res) => {
const body = (req.body ?? {}) as { userId?: string; pin?: string; override?: Record<string, unknown> }
// Bound override context is required — a PIN verification with nothing to authorise is
// rejected rather than minting an unbound (redirectable) token.
const ovRaw = body.override
if (ovRaw === undefined || ovRaw === null || typeof ovRaw !== 'object') {
res.status(400).json({ error: 'Missing the override this PIN is approving (E-1001)', code: 'E-1001' })
return
}
let ov: { itemId: string; kind: string; before: number; after: number }
try {
const kind = assertString(ovRaw['kind'], 'override kind')
if (!OVERRIDE_KINDS.has(kind)) throw new ValidationError('Unknown override kind', 'E-1001')
ov = {
itemId: assertString(ovRaw['itemId'], 'override itemId'),
kind,
before: assertInt(ovRaw['before'], 'override before'),
after: assertInt(ovRaw['after'], 'override after'),
}
} catch (err) {
const code = err instanceof ValidationError ? err.code : 'E-1001'
res.status(400).json({ error: 'Override context is invalid (E-1001)', code }); return
}
const { userId, pin } = body
const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined
const key = u?.id ?? 'unknown'
const now = Date.now()
@ -106,15 +296,26 @@ export function apiRouter(db: DB): Router {
lockouts.set(key, recordSuccess())
const roles = JSON.parse(u.roles) as string[]
if (!roles.some((role) => APPROVER_ROLES.has(role))) {
res.status(403).json({ error: 'This user cannot approve overrides — pick a supervisor, manager or owner (E-6105)' })
res.status(403).json({ error: 'This user cannot approve overrides — pick a supervisor, manager or owner (E-6105)', code: 'E-6105' })
return
}
const approvalId = createOverrideApproval(db, u.tenant_id, u.id, {
itemId: ov.itemId, kind: ov.kind as 'price' | 'qty' | 'discount' | 'batch',
beforePaise: ov.before, afterPaise: ov.after,
})
writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY', 'app_user', u.id)
res.json({ ok: true, userId: u.id, displayName: u.display_name })
res.json({ ok: true, userId: u.id, displayName: u.display_name, approvalId })
})
// POS bootstrap: store/counter context, login tiles, tax classes
// POS bootstrap: store/counter context, login tiles, tax classes. This is UNAUTHENTICATED so
// the POS can paint its login screen — which means it must NOT leak targeting data (M1). The
// pre-auth payload therefore DROPS each user's role and the tenant GSTIN. Both are restored
// when the SAME endpoint is called WITH a valid session (the POS re-fetches right after login;
// back office is always authed), so the supervisor gate, the approver list and the receipt's
// seller GSTIN keep working — only the anonymous, pre-login caller is starved of the roster's
// roles and the store's GSTIN.
r.get('/bootstrap', (req, res) => {
const authed = sessions.has(bearer(req))
const counterCode = String(req.query['counter'] ?? 'C2')
const store = db.prepare(`SELECT * FROM store LIMIT 1`).get() as Record<string, unknown>
const counter = db.prepare(`SELECT * FROM counter WHERE store_id=? AND code=?`).get(store['id'], counterCode) as Record<string, unknown> | undefined
@ -123,103 +324,286 @@ export function apiRouter(db: DB): Router {
res.json({
store: {
id: store['id'], name: store['name'], storeCode: store['code'],
stateCode: store['state_code'], gstin: tenant['gstin'],
counterId: counter?.['id'] ?? 'c2', counterCode,
stateCode: store['state_code'], counterId: counter?.['id'] ?? 'c2', counterCode,
...(authed ? { gstin: tenant['gstin'] } : {}),
},
users: users.map((u) => ({ id: u['id'], name: u['display_name'], role: (JSON.parse(u['roles'] as string) as string[])[0] })),
users: users.map((u) => ({
id: u['id'], name: u['display_name'],
...(authed ? { role: (JSON.parse(u['roles'] as string) as string[])[0] } : {}),
})),
taxClasses: listTaxClasses(db),
})
})
r.use(requireAuth)
// M4: end a session on demand — POS "Lock" and back-office "Logout" call this so the token
// stops working immediately (a leaked/rotated token is cut off without waiting for a restart).
r.post('/auth/logout', (req, res) => {
sessions.delete(bearer(req))
res.json({ ok: true })
})
// H1: ESC/POS print relay, now constrained to a LAN printer on an allowlisted port with a
// capped payload (print-guard.ts). Authenticated (it sits below requireAuth). The host is
// resolved and EVERY resolved IP must be private/LAN; we then connect to the resolved IP —
// not the original name — so a hostname can't DNS-rebind between the check and the connect.
r.post('/print', async (req, res, next) => {
try {
const { host, port, bytes } = req.body as { host?: unknown; port?: unknown; bytes?: unknown }
if (typeof host !== 'string' || host === '' || typeof port !== 'number' || !Array.isArray(bytes)) {
res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }', code: 'E-2200' })
return
}
const envelope = checkPrintEnvelope(bytes.length, port, printerPorts)
if (!envelope.ok) { res.status(envelope.status).json({ ok: false, error: envelope.error, code: envelope.code }); return }
let addrs: string[]
try { addrs = await resolveHost(host) } catch { addrs = [] }
if (addrs.length === 0) {
res.status(400).json({ ok: false, error: 'Printer host is not a private/LAN address', code: 'E-2203' })
return
}
for (const ip of addrs) {
const chk = checkPrintAddress(ip)
if (!chk.ok) { res.status(chk.status).json({ ok: false, error: chk.error, code: chk.code }); return }
}
const target = addrs[0]!
// 'close' always follows 'error'/'timeout' on net sockets — respond exactly once.
let done = false
const finish = (status: number, body: object): void => { if (done) return; done = true; res.status(status).json(body) }
const socket = net.createConnection({ host: target, port, timeout: 3000 })
socket.on('connect', () => socket.end(Buffer.from(bytes as number[])))
socket.on('close', () => finish(200, { ok: true }))
socket.on('timeout', () => { socket.destroy(); finish(504, { ok: false, error: `Printer ${host}:${port} timed out` }) })
socket.on('error', (e: Error) => finish(502, { ok: false, error: e.message }))
} catch (e) { next(e) }
})
// Catalog reads are needed to bill, so they stay open to any authenticated session —
// but pagination is clamped so a hostile limit can never dump the whole table (M6).
r.get('/items', (req, res) => {
res.json(listItems(db, sess(req).tenantId, {
...(req.query['q'] !== undefined ? { query: String(req.query['q']) } : {}),
all: req.query['all'] === '1',
...(req.query['limit'] !== undefined ? { limit: Number(req.query['limit']) } : {}),
...(req.query['offset'] !== undefined ? { offset: Number(req.query['offset']) } : {}),
limit: clampLimit(req.query['limit'], 5000, 5000),
offset: clampOffset(req.query['offset']),
}))
})
// A cashier (BILL_CREATE) may quick-add a DRAFT item mid-bill; any non-draft item — or a
// caller without BILL_CREATE — requires MASTER_EDIT (C1: no editing the master as cashier).
r.post('/items', (req, res) => {
const item = createItem(db, sess(req).tenantId, req.body as Parameters<typeof createItem>[2])
writeAudit(db, sess(req).tenantId, sess(req).userId, 'ITEM_CREATE', 'item', item.id, undefined, item)
const s = sess(req)
const b = (req.body ?? {}) as Record<string, unknown>
const status = optString(b['status'], 'status') ?? 'active'
const user = asUser(s)
const isMasterEditor = can(user, 'MASTER_EDIT')
if (!(isMasterEditor || (status === 'draft' && can(user, 'BILL_CREATE')))) {
res.status(403).json({ error: 'You are not permitted to add or edit items (E-6105)', code: 'E-6105' })
return
}
const salePricePaise = assertPositiveInt(b['salePricePaise'], 'salePricePaise')
// C3 fix: a cashier-created draft's tax class is FORCED to the store default — the
// client's taxClassCode is ignored so a taxable good can't be self-added as ZERO to
// evade GST. A MASTER_EDIT role (manager/owner) still sets the class explicitly.
const taxClassCode = isMasterEditor
? assertString(b['taxClassCode'], 'taxClassCode')
: resolveDefaultTaxClass(db, s.tenantId)
// C2 bound: a cashier-created draft's price IS its sell price, so cap it — an
// expensive unknown must go through a supervisor / back office, not be self-priced
// at the counter. (E-1108 here is the draft-cap denial, distinct endpoint from the
// bill-path payment-mode use of the same code.)
if (!isMasterEditor && salePricePaise > resolveDraftMaxPricePaise(db, s.tenantId)) {
res.status(400).json({
error: 'This price is too high to quick-add at the counter — call a supervisor to add it in back office (E-1108)',
code: 'E-1108',
})
return
}
const input = {
code: assertString(b['code'], 'code'),
name: assertString(b['name'], 'name'),
hsn: optString(b['hsn'], 'hsn') ?? '',
taxClassCode,
unitCode: optString(b['unitCode'], 'unitCode') ?? 'PCS',
unitDecimals: assertNonNegInt(b['unitDecimals'] ?? 0, 'unitDecimals'),
salePricePaise,
...(b['mrpPaise'] !== undefined ? { mrpPaise: assertNonNegInt(b['mrpPaise'], 'mrpPaise') } : {}),
...(optString(b['barcode'], 'barcode') !== undefined ? { barcode: optString(b['barcode'], 'barcode')! } : {}),
status,
}
const item = createItem(db, s.tenantId, input)
writeAudit(db, s.tenantId, s.userId, 'ITEM_CREATE', 'item', item.id, undefined, item)
res.json(item)
})
// Party lookup/create is part of billing (attach a customer at the counter) — open to any
// authenticated session; the GSTIN is validated server-side in createCustomer.
r.get('/parties', (req, res) => {
res.json(listParties(db, sess(req).tenantId, req.query['kind'] as string | undefined, req.query['phone'] as string | undefined))
})
r.post('/parties', (req, res) => {
const { name, phone, gstin, stateCode } = req.body as { name: string; phone: string; gstin?: string; stateCode?: string }
try {
const b = (req.body ?? {}) as Record<string, unknown>
const name = assertString(b['name'], 'name')
const phone = assertString(b['phone'], 'phone')
const gstin = optString(b['gstin'], 'gstin')
const stateCode = optString(b['stateCode'], 'stateCode')
const p = createCustomer(db, sess(req).tenantId, name, phone, {
...(gstin !== undefined ? { gstin } : {}),
...(stateCode !== undefined ? { stateCode } : {}),
})
writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p)
res.json(p)
} catch (err) {
const status = (err as { status?: number }).status ?? 500
res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/bills', (req, res) => {
const body = req.body as {
storeId: string; counterId: string; shiftId: string; customerId?: string
businessDate: string; lines: LineInput[]
payments: { mode: string; amountPaise: number; reference?: string }[]
clientPayablePaise: number
overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[]
}
try {
const out = commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId })
res.json(out)
} catch (err) {
const status = (err as { status?: number }).status ?? 500
res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
}
r.post('/bills', requireAnyPerm('BILL_CREATE'), (req, res) => {
const body = validateBillBody(req.body)
res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }))
})
// S4 reconnect drain (authenticated): bills a browser POS queued while the store-server
// was unreachable are drained here under the cashier's live session — tenant and cashier
// come from the session, NEVER the body. The client bill id is the idempotency key.
r.post('/bills/offline', requireAnyPerm('BILL_CREATE'), (req, res) => {
const b = (req.body ?? {}) as Record<string, unknown>
const clientId = assertString(b['id'], 'id')
const body = validateBillBody(req.body)
res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId, clientId }))
})
r.get('/bills', (req, res) => {
// A cashier may read bills, but only their OWN — a caller without REPORT_VIEW is pinned to
// the session user id server-side, so a BILL_CREATE-only cashier can never enumerate other
// counters'/cashiers' bills (residual cross-counter read fix). A REPORT_VIEW/manager role
// keeps the full store-wide view. Pagination is clamped (M6).
r.get('/bills', requireAnyPerm('BILL_CREATE', 'REPORT_VIEW'), (req, res) => {
const s = sess(req)
const ownBillsOnly = !can(asUser(s), 'REPORT_VIEW')
res.json(listBills(
db, sess(req).tenantId,
db, s.tenantId,
req.query['date'] as string | undefined, req.query['counterId'] as string | undefined,
req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined,
req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined,
clampLimit(req.query['limit'], 100, 1000),
clampOffset(req.query['offset']),
ownBillsOnly ? s.userId : undefined,
))
})
// Returns / credit notes. A cashier has RETURN_CREATE (permissions.ts), so returns ride the
// same money/commit path the red-team hardened: commitReturn anchors everything to OUR stored
// original bill (never the client), blocks over-return, computes the refund server-side, and
// writes an immutable SALE_RETURN doc + credit-note series + stock-back movements + refund leg
// + hash-chained audit row in ONE transaction.
r.post('/returns', requireAnyPerm('RETURN_CREATE'), (req, res) => {
const body = validateReturnBody(req.body)
res.json(commitReturn(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }))
})
// Credit-note list — tenant-scoped, paginated (M6). A caller without REPORT_VIEW is pinned to
// their own credit notes, the same scoping the sales list uses.
r.get('/returns', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
const s = sess(req)
const ownOnly = !can(asUser(s), 'REPORT_VIEW')
res.json(listReturns(
db, s.tenantId, clampLimit(req.query['limit'], 100, 1000), clampOffset(req.query['offset']),
ownOnly ? s.userId : undefined,
))
})
// The return lookup: find the original SALE by exact doc no, or recent sales for a phone. A
// targeted, tenant-scoped lookup for issuing a return (not the bills-list enumeration) so a
// cashier can return a bill a colleague rang up.
r.get('/bills/lookup', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
const s = sess(req)
const docNo = optString(req.query['docNo'], 'docNo')
const phone = optString(req.query['phone'], 'phone')
if (docNo !== undefined) {
const bill = lookupBillByDocNo(db, s.tenantId, docNo)
res.json(bill === undefined ? [] : [bill])
return
}
if (phone !== undefined) { res.json(lookupBillsByPhone(db, s.tenantId, phone)); return }
res.status(400).json({ error: 'Provide a docNo or phone to look up (E-1327)', code: 'E-1327' })
})
// The original bill's lines with sold / already-returned / returnable quantities + the raw
// lines for the POS refund preview.
r.get('/bills/:id/returnable', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
res.json(returnableLines(db, sess(req).tenantId, assertString(req.params['id'], 'id')))
})
// GST returns — READ-ONLY compliance reporting, gated REPORT_VIEW, tenant-scoped. Each folds the
// period's immutable SALE + SALE_RETURN documents through the shared engine (never recomputes tax)
// and returns the sections + a reconciliation proof. The .json route is a pragmatic GSTN-shaped
// export for a CA / GSP — documented in the payload as NOT a certified filing.
r.get('/gst/gstr1', requireAnyPerm('REPORT_VIEW'), (req, res) => {
res.json(gstr1(db, sess(req).tenantId, assertPeriod(req.query['period'])))
})
r.get('/gst/gstr3b', requireAnyPerm('REPORT_VIEW'), (req, res) => {
res.json(gstr3b(db, sess(req).tenantId, assertPeriod(req.query['period'])))
})
r.get('/gst/hsn', requireAnyPerm('REPORT_VIEW'), (req, res) => {
res.json(hsnSummary(db, sess(req).tenantId, assertPeriod(req.query['period'])))
})
r.get('/gst/gstr1.json', requireAnyPerm('REPORT_VIEW'), (req, res) => {
res.json(gstr1PortalJson(db, sess(req).tenantId, assertPeriod(req.query['period'])))
})
r.get('/stock', (req, res) => {
const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
res.json(stockView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
})
r.get('/audit', (req, res) => {
// S5: batches with derived on-hand > 0 (POS FEFO picker cache, keyed by item).
r.get('/batches', (req, res) => {
const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
res.json(batchesForStore(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
})
// S5 expiry dashboard feed — a reporting view (stock value/expiry), not a counter path.
r.get('/expiry', requireAnyPerm('REPORT_VIEW'), (req, res) => {
const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
res.json(expiryView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
})
r.get('/audit', requireAnyPerm('AUDIT_VIEW'), (req, res) => {
res.json(listAudit(
db, sess(req).tenantId,
req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined,
req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined,
clampLimit(req.query['limit'], 100, 1000),
clampOffset(req.query['offset']),
))
})
// H2 — recompute the tenant's tamper-evident audit hash chain. {ok:true} means untampered;
// {ok:false, brokenAtId} names the first row whose content or prev-link no longer verifies.
r.get('/audit/verify', requireAnyPerm('AUDIT_VIEW'), (req, res) => {
res.json(verifyAuditChain(db, sess(req).tenantId))
})
r.get('/purchases', (req, res) => {
r.get('/purchases', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
res.json(listPurchases(db, sess(req).tenantId))
})
r.get('/purchases/last-costs', (req, res) => {
r.get('/purchases/last-costs', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
res.json(lastCosts(db, sess(req).tenantId, req.query['supplierId'] as string | undefined))
})
r.get('/purchases/cost-history', (req, res) => {
r.get('/purchases/cost-history', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
res.json(costHistory(db, sess(req).tenantId, String(req.query['itemId'] ?? ''), req.query['supplierId'] as string | undefined))
})
r.post('/purchases', (req, res) => {
const body = req.body as Omit<CommitPurchaseInput, 'tenantId' | 'userId'>
try {
res.json(commitPurchase(db, { ...body, tenantId: sess(req).tenantId, userId: sess(req).userId }))
} catch (err) {
const status = (err as { status?: number }).status ?? 500
res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
}
r.post('/purchases', requireAnyPerm('PURCHASE_ENTRY'), (req, res) => {
const b = (req.body ?? {}) as Record<string, unknown>
const supplierId = assertString(b['supplierId'], 'supplierId')
const invoiceNo = assertString(b['invoiceNo'], 'invoiceNo')
const invoiceDate = assertBusinessDate(b['invoiceDate'], 'invoiceDate')
const businessDate = assertBusinessDate(b['businessDate'])
const rawLines = assertArray<Record<string, unknown>>(b['lines'], 'lines', 500)
const lines = rawLines.map((l) => {
assertString(l['itemId'], 'line itemId')
assertString(l['name'], 'line name')
assertQty(l['qty'], 'line qty')
assertNonNegInt(l['unitCostPaise'], 'line cost')
assertNonNegInt(l['taxRateBp'], 'line tax rate')
if (l['newSalePricePaise'] !== undefined) assertPositiveInt(l['newSalePricePaise'], 'new sale price')
if (l['newMrpPaise'] !== undefined) assertPositiveInt(l['newMrpPaise'], 'new MRP')
return l as unknown as CommitPurchaseInput['lines'][number]
})
const clientTotalPaise = assertPaise(b['clientTotalPaise'], 'clientTotalPaise')
res.json(commitPurchase(db, {
tenantId: sess(req).tenantId, userId: sess(req).userId,
storeId: assertString(b['storeId'], 'storeId'),
supplierId, invoiceNo, invoiceDate, businessDate, lines, clientTotalPaise,
}))
})
return r

@ -1,4 +1,9 @@
import Database from 'better-sqlite3'
// H3 (red-team): better-sqlite3-multiple-ciphers is an API-compatible drop-in for
// better-sqlite3 that adds SQLCipher, so the whole DB file can be encrypted at rest with a
// single `pragma('key=...')`. It ships its own DefinitelyTyped-derived declarations (identical
// shape to @types/better-sqlite3), so `Database.Database` and `db.pragma()` type exactly as
// before — no shim needed. See openDb() for the keying / dev-fallback logic.
import Database from 'better-sqlite3-multiple-ciphers'
import fs from 'node:fs'
import path from 'node:path'
@ -66,6 +71,7 @@ CREATE TABLE IF NOT EXISTS bill (
cess_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL,
payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL,
buyer_gstin TEXT, place_of_supply TEXT,
client_id TEXT,
payload TEXT NOT NULL, created_at_wall TEXT NOT NULL,
lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '',
device_seq INTEGER NOT NULL DEFAULT 0,
@ -74,7 +80,16 @@ CREATE TABLE IF NOT EXISTS bill (
CREATE TABLE IF NOT EXISTS stock_movement (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
item_id TEXT NOT NULL, qty_delta REAL NOT NULL, reason TEXT NOT NULL,
ref_doc_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL
ref_doc_id TEXT, batch_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL
);
-- S5 batch/expiry: a batch is an item's lot with an optional expiry (nullable
-- non-perishables are batch-tracked without a date). Stock stays derived (R7):
-- a batch's on-hand = SUM(stock_movement.qty_delta WHERE batch_id = the batch).
CREATE TABLE IF NOT EXISTS batch (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT,
created_at TEXT NOT NULL,
UNIQUE (tenant_id, store_id, item_id, batch_no)
);
CREATE TABLE IF NOT EXISTS setting (
scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, key TEXT NOT NULL,
@ -86,13 +101,34 @@ CREATE TABLE IF NOT EXISTS message_catalog (
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, at_wall TEXT NOT NULL,
user_id TEXT NOT NULL, action TEXT NOT NULL, entity TEXT NOT NULL,
entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT
entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT,
-- H2 (red-team): per-tenant tamper-evident hash chain. entry_hash =
-- sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity,
-- entity_id, before_json, after_json)); prev_hash = the previous entry's
-- entry_hash for the tenant (genesis = ''). Any out-of-band UPDATE/DELETE/INSERT
-- to this table breaks the chain, which GET /api/audit/verify recomputes. This is
-- tamper-EVIDENCE, not prevention the file is still writable (that is H3's job).
prev_hash TEXT, entry_hash TEXT
);
CREATE TABLE IF NOT EXISTS outbox (
op_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, device_id TEXT NOT NULL,
device_seq INTEGER NOT NULL, doc_type TEXT NOT NULL, doc_id TEXT NOT NULL,
envelope TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL
);
-- Single-use supervisor-approval tokens (SEC fix): /auth/verify-pin mints one on a
-- valid supervisor PIN; the bill commit redeems it (used=0 -> 1, age < 5 min, approver
-- role re-checked) so the client can never forge who approved an override.
-- H4 fix: the token is BOUND at mint to the exact override it authorises
-- item_id, kind, before_paise (server-computed master value) and after_paise. The
-- commit re-verifies these against the specific line's server-computed deviation, so
-- a "Milk ₹50→45" approval can never be re-attached to "Whiskey ₹2000→200".
CREATE TABLE IF NOT EXISTS override_approval (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
item_id TEXT, kind TEXT, before_paise INTEGER, after_paise INTEGER,
cashier_user_id TEXT
);
CREATE TABLE IF NOT EXISTS purchase (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
supplier_id TEXT NOT NULL, invoice_no TEXT NOT NULL, invoice_date TEXT NOT NULL,
@ -105,13 +141,17 @@ CREATE TABLE IF NOT EXISTS purchase_line (
purchase_id TEXT NOT NULL, line_no INTEGER NOT NULL, item_id TEXT NOT NULL,
name TEXT NOT NULL, qty REAL NOT NULL, unit_cost_paise INTEGER NOT NULL,
tax_rate_bp INTEGER NOT NULL, tax_paise INTEGER NOT NULL, line_total_paise INTEGER NOT NULL,
new_sale_price_paise INTEGER, new_mrp_paise INTEGER,
new_sale_price_paise INTEGER, new_mrp_paise INTEGER, batch_id TEXT,
PRIMARY KEY (purchase_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_pline_item ON purchase_line (item_id);
CREATE INDEX IF NOT EXISTS idx_item_name ON item (tenant_id, name);
CREATE INDEX IF NOT EXISTS idx_bill_date ON bill (tenant_id, business_date);
CREATE INDEX IF NOT EXISTS idx_move_item ON stock_movement (tenant_id, store_id, item_id);
-- idx_move_batch lives in migrate() only: on an upgraded dev DB the batch_id column is
-- added by ALTER there, so indexing it in SCHEMA (which runs first) would fail. The
-- batch table is fully defined above, so its index is safe to keep here.
CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (tenant_id, at_wall);
`
@ -127,12 +167,116 @@ function migrate(db: DB): void {
// S3 B2B: buyer GSTIN + place-of-supply snapshot on the immutable bill.
if (!has('buyer_gstin')) db.exec(`ALTER TABLE bill ADD COLUMN buyer_gstin TEXT`)
if (!has('place_of_supply')) db.exec(`ALTER TABLE bill ADD COLUMN place_of_supply TEXT`)
// S4 offline queue: the client bill id is the idempotency key for the reconnect
// drain. It is NULL for normal (online) bills and non-null only for a bill that
// was queued offline; a partial unique index enforces one committed bill per
// (tenant, client id) so a retried drain can never duplicate a bill or its stock.
// (A partial index, not a table UNIQUE, because SQLite ALTER TABLE cannot add a
// constraint and NULLs must stay non-conflicting for the online path.)
if (!has('client_id')) db.exec(`ALTER TABLE bill ADD COLUMN client_id TEXT`)
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bill_client ON bill (tenant_id, client_id) WHERE client_id IS NOT NULL`)
// SEC fix — single-use supervisor-approval tokens. A whole new table, so the
// idempotent guard is the table itself (CREATE TABLE IF NOT EXISTS, which SCHEMA
// also runs on every open); re-stated here so the migration is explicit for dev
// DBs seeded before the table existed.
db.exec(`CREATE TABLE IF NOT EXISTS override_approval (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
)`)
// H4 fix — bind the approval token to the override it authorises. Additive columns
// for dev DBs seeded before the binding existed; fresh DBs get them from SCHEMA above.
const oaCols = db.prepare(`PRAGMA table_info(override_approval)`).all() as { name: string }[]
const oaHas = (c: string): boolean => oaCols.some((x) => x.name === c)
if (!oaHas('item_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN item_id TEXT`)
if (!oaHas('kind')) db.exec(`ALTER TABLE override_approval ADD COLUMN kind TEXT`)
if (!oaHas('before_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN before_paise INTEGER`)
if (!oaHas('after_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN after_paise INTEGER`)
if (!oaHas('cashier_user_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN cashier_user_id TEXT`)
// S5 batch/expiry — the batch table (SCHEMA also creates it; re-stated so the
// migration is explicit for dev DBs seeded before it existed), plus the nullable
// batch_id columns on stock_movement and purchase_line. Per-batch on-hand stays
// derived from movements (R7); batch_id is NULL for un-batched (non-tracked) stock.
db.exec(`CREATE TABLE IF NOT EXISTS batch (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT,
created_at TEXT NOT NULL,
UNIQUE (tenant_id, store_id, item_id, batch_no)
)`)
const moveCols = db.prepare(`PRAGMA table_info(stock_movement)`).all() as { name: string }[]
if (!moveCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE stock_movement ADD COLUMN batch_id TEXT`)
const plineCols = db.prepare(`PRAGMA table_info(purchase_line)`).all() as { name: string }[]
if (!plineCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE purchase_line ADD COLUMN batch_id TEXT`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_move_batch ON stock_movement (batch_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id)`)
// H2 — tamper-evident audit hash chain. Additive columns for dev DBs seeded before the
// chain existed; fresh DBs get them from SCHEMA above. Pre-existing rows stay NULL (they are
// not retro-hashed) — on a real upgrade the chain is consistent from the first row written
// after this migration; the dev DB is reset for this slice so every row is chained from genesis.
const auditCols = db.prepare(`PRAGMA table_info(audit_log)`).all() as { name: string }[]
const auditHas = (c: string): boolean => auditCols.some((x) => x.name === c)
if (!auditHas('prev_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT`)
if (!auditHas('entry_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN entry_hash TEXT`)
}
export function openDb(): DB {
const dir = path.resolve(__dirname, '../data')
/**
* H3 (red-team): full-DB encryption at rest via SQLCipher. When SIMS_DB_KEY is set we key the
* database BEFORE any read, so the file on disk main db AND its WAL/SHM sidecars is ciphertext.
* A copied or snooped sims.db then yields no customer phone numbers/GSTINs (DPDP), no item/purchase
* costs & margins, no sales history, no party ledgers and no PIN salts/hashes: every page is
* encrypted under a key held ONLY in the server's environment a machine-scoped secret (Windows
* DPAPI / OS keychain), NEVER in the repo or the DB itself.
*
* When SIMS_DB_KEY is UNSET we open unencrypted exactly as before (dev fallback, so :5181 keeps
* working) and warn ONCE that production MUST set it. Mirrors the SIMS_PIN_PEPPER pattern.
*
* The key must be applied immediately after opening, before journal_mode / schema touch the file
* (SQLCipher derives the page key from it on first access). Single quotes in the secret are escaped
* by doubling so an arbitrary passphrase is a safe SQL string literal.
*
* One-time migration of an EXISTING plaintext sims.db to encrypted (not needed for the empty dev DB,
* which is simply recreated by deleting data/): open the plaintext file, then
* ATTACH DATABASE 'sims-enc.db' AS enc KEY '<SIMS_DB_KEY>';
* SELECT sqlcipher_export('enc'); DETACH DATABASE enc;
* and swap sims-enc.db in. Do this offline, once, before first encrypted boot.
*/
let dbKeyWarned = false
function applyKeyIfSet(db: DB): void {
const key = process.env['SIMS_DB_KEY']
if (key === undefined || key === '') {
if (!dbKeyWarned) {
dbKeyWarned = true
// eslint-disable-next-line no-console
console.warn(
'[store-server] SIMS_DB_KEY is not set — the store DB is written UNENCRYPTED on disk. A copied '
+ 'sims.db then yields customer PII (phones/GSTINs), costs & margins, the full sales history, party '
+ 'ledgers and PIN hashes. Production MUST set a machine-scoped SIMS_DB_KEY (SQLCipher at rest).',
)
}
return
}
db.pragma(`cipher='sqlcipher'`)
db.pragma(`key='${key.replace(/'/g, "''")}'`)
}
/**
* Open the store DB. Defaults to the on-disk WAL file; pass an explicit path
* (e.g. `:memory:`) for hermetic tests. Same schema + migrations either way.
*/
export function openDb(dbPath?: string): DB {
let file: string
if (dbPath !== undefined) {
file = dbPath
} else {
// SIMS_DATA_DIR lets a second instance run against an isolated data dir (e.g. the
// S7 load target) without touching the dev DB; unset ⇒ the standard ../data path.
const envDir = process.env['SIMS_DATA_DIR']
const dir = envDir !== undefined && envDir !== '' ? path.resolve(envDir) : path.resolve(__dirname, '../data')
fs.mkdirSync(dir, { recursive: true })
const db = new Database(path.join(dir, 'sims.db'))
file = path.join(dir, 'sims.db')
}
const db = new Database(file)
applyKeyIfSet(db) // H3 — key BEFORE any read (journal_mode/schema touch the file)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(SCHEMA)

@ -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,
}
}

@ -1,6 +1,7 @@
import { uuidv7 } from '@sims/domain'
import { uuidv7, type RoleCode } from '@sims/domain'
import { can } from '@sims/auth'
import type { DB } from './db'
import { writeAudit } from './repos'
import { upsertBatch, writeAudit } from './repos'
/**
* Purchases stock IN, cost memory, and price updates in one transaction.
@ -16,6 +17,10 @@ export interface PurchaseLineInput {
/** Optional price revisions decided at entry time (cost-changed prompt). */
newSalePricePaise?: number
newMrpPaise?: number
/** S5: batch/expiry captured for a batch-tracked item. batchNo upserts the batch
* (found by unique key or created); expiryDate is optional (undated lots allowed). */
batchNo?: string
expiryDate?: string
}
export interface CommitPurchaseInput {
@ -62,6 +67,13 @@ export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string
throw Object.assign(new Error(`Invoice ${input.invoiceNo} from this supplier is already entered (duplicate)`), { status: 409 })
}
// C4 fix: receiving stock (PURCHASE_ENTRY, gated at the route) is separate from moving
// the item master. Only a MASTER_EDIT holder may push newSalePricePaise / newMrpPaise;
// for anyone else the stock still comes in but the price revision is ignored and noted.
const buyer = db.prepare(`SELECT roles FROM app_user WHERE id=? AND tenant_id=?`).get(input.userId, input.tenantId) as { roles: string } | undefined
const canEditMaster = buyer !== undefined && can({ roles: JSON.parse(buyer.roles) as RoleCode[], active: true }, 'MASTER_EDIT')
let priceUpdatesSkipped = 0
const id = uuidv7()
const now = new Date().toISOString()
@ -75,18 +87,31 @@ export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string
input.businessDate, taxable, tax, total, input.userId, now, JSON.stringify(input.lines),
)
const insLine = db.prepare(
`INSERT INTO purchase_line (purchase_id, line_no, item_id, name, qty, unit_cost_paise, tax_rate_bp, tax_paise, line_total_paise, new_sale_price_paise, new_mrp_paise)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO purchase_line (purchase_id, line_no, item_id, name, qty, unit_cost_paise, tax_rate_bp, tax_paise, line_total_paise, new_sale_price_paise, new_mrp_paise, batch_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
const move = db.prepare(
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall)
VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?)`,
`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 (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?, ?)`,
)
input.lines.forEach((l, i) => {
const m = math[i]!
insLine.run(id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total, l.newSalePricePaise ?? null, l.newMrpPaise ?? null)
move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, input.businessDate, now)
if (l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined) {
// S5: a batch-tracked line names its batch — upsert it (unique key or new) and
// carry the batch_id onto both the purchase line and the stock-IN movement so
// per-batch on-hand derives from movements (R7). Un-batched lines pass NULL.
const batchId = l.batchNo !== undefined && l.batchNo.trim() !== ''
? upsertBatch(db, input.tenantId, input.storeId, l.itemId, l.batchNo.trim(), l.expiryDate)
: null
const requestedPriceChange = l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined
const applyPriceChange = requestedPriceChange && canEditMaster
insLine.run(
id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total,
applyPriceChange ? (l.newSalePricePaise ?? null) : null,
applyPriceChange ? (l.newMrpPaise ?? null) : null,
batchId,
)
move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, batchId, input.businessDate, now)
if (applyPriceChange) {
const before = db.prepare(`SELECT sale_price_paise, mrp_paise FROM item WHERE id=?`).get(l.itemId)
db.prepare(
`UPDATE item SET sale_price_paise = COALESCE(?, sale_price_paise), mrp_paise = COALESCE(?, mrp_paise) WHERE id=? AND tenant_id=?`,
@ -94,10 +119,13 @@ export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string
writeAudit(db, input.tenantId, input.userId, 'ITEM_PRICE_UPDATE', 'item', l.itemId, before, {
salePricePaise: l.newSalePricePaise, mrpPaise: l.newMrpPaise, source: `purchase ${input.invoiceNo}`,
})
} else if (requestedPriceChange) {
priceUpdatesSkipped++
}
})
writeAudit(db, input.tenantId, input.userId, 'PURCHASE_COMMIT', 'purchase', id, undefined, {
invoiceNo: input.invoiceNo, supplier: input.supplierId, total,
...(priceUpdatesSkipped > 0 ? { priceUpdatesSkipped, reason: 'caller lacks MASTER_EDIT' } : {}),
})
})()

@ -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>[]
}

@ -1,9 +1,16 @@
import { createHash } from 'node:crypto'
import {
deriveSupply, formatDocNo, fyOf, fyShort, seriesPrefix, uuidv7, validateGstin,
type Item, type Party,
deriveSupply, formatDocNo, fyOf, seriesPrefix, uuidv7, validateGstin,
type Item, type Party, type RoleCode,
} from '@sims/domain'
import { can, type ActionCode } from '@sims/auth'
import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
import type { DB } from './db'
import { PAYMENT_MODES, assertBusinessDate } from './validate'
/** 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 })
/**
* Repositories the portable data layer (D12 guardrail). Plain functions over
@ -150,19 +157,142 @@ export interface CommitBillInput {
* Supervisor-approved counter overrides (spec P0-2 / P1-3): each price / qty /
* discount deviation that raised the supervisor PIN gate rides along with the
* bill so its audit row is written in the SAME transaction as the commit (R17).
* `approvalId` is the single-use token minted by /auth/verify-pin the server
* redeems it and reads the approver identity from ITS OWN row, never trusting a
* client-supplied user id (SEC fix).
*/
overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string }[]
/**
* S4 offline queue: the client-generated bill id (uuidv7). Present only when a
* bill made while the store-server was unreachable is drained on reconnect; it
* is the idempotency key a re-drained payload returns the already-assigned doc
* no instead of committing a second bill (02-ARCHITECTURE §3 outbox pattern).
*/
overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[]
clientId?: string
}
const OVERRIDE_ACTION = {
price: 'PRICE_OVERRIDE', qty: 'QTY_OVERRIDE', discount: 'DISCOUNT_OVERRIDE',
// S5: a supervisor approved selling an EXPIRED batch (E-1310 gate). before/after
// are 0 — the audited fact is "who approved the expired-batch sale for this item".
batch: 'BATCH_OVERRIDE',
} as const
export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: string; payablePaise: number } {
const store = db.prepare(`SELECT * FROM store WHERE id=?`).get(input.storeId) as { code: string; state_code: string }
const counter = db.prepare(`SELECT * FROM counter WHERE id=?`).get(input.counterId) as { code: string }
/** Roles allowed to approve a counter override (09-UX §5.5). */
const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
/** A supervisor-approval token is valid for five minutes after it is minted. */
const APPROVAL_TTL_MS = 5 * 60 * 1000
/** The bound context a supervisor is approving — re-verified at redeem (H4). */
export interface OverrideContext {
itemId: string
kind: 'price' | 'qty' | 'discount' | 'batch'
beforePaise: number
afterPaise: number
/** The cashier the approval was granted to (advisory; not enforced at redeem). */
cashierUserId?: string
}
/**
* Mint a single-use supervisor-approval token (SEC fix), BOUND to the exact override it
* authorises (H4 fix). Called by /auth/verify-pin once a supervisor PIN is proven; the
* bill commit later redeems it and re-verifies item/kind/before/after against the line's
* server-computed deviation, so a token minted for one line can never move to another.
*/
export function createOverrideApproval(
db: DB, tenantId: string, approverUserId: string, ctx: OverrideContext,
): string {
const id = uuidv7()
db.prepare(
`INSERT INTO override_approval (id, tenant_id, approver_user_id, created_at, used, item_id, kind, before_paise, after_paise, cashier_user_id)
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`,
).run(id, tenantId, approverUserId, new Date().toISOString(), ctx.itemId, ctx.kind, ctx.beforePaise, ctx.afterPaise, ctx.cashierUserId ?? null)
return id
}
/**
* The discount cap (basis points) beyond which a line discount is a deviation needing a
* supervisor override. A dated tenant setting (R9/R10): tenant row wins over the system
* default; 1000 bp (10%) if neither is present.
*/
function resolveDiscountCapBp(db: DB, tenantId: string): number {
const row = db.prepare(
`SELECT value FROM setting WHERE key='discount.capBp'
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 : 1000
}
/**
* The tax class the server FORCES onto a cashier-created draft item (C3 fix). A cashier
* quick-adding an unknown good must not be able to choose ZERO (or any class) to evade GST;
* the server ignores the client's taxClassCode and assigns the store's standard rate. A
* dated tenant setting (R9/R10): tenant row wins over the system default; 'GST18' if neither
* is present. A MASTER_EDIT role (manager/owner) keeps its chosen class back office
* completes the draft's real HSN/tax class later.
*/
export function resolveDefaultTaxClass(db: DB, tenantId: string): string {
const row = db.prepare(
`SELECT value FROM setting WHERE key='catalog.defaultTaxClass'
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
return row !== undefined && row.value !== '' ? row.value : 'GST18'
}
/**
* The price ceiling (paise) a cashier-created draft may carry (C2 bound). A quick-add's
* price IS its sell price (inherent to the flow), so a genuinely expensive unknown must go
* through a supervisor / back office rather than be self-priced arbitrarily at the counter.
* A dated tenant setting (R9/R10): tenant row wins over the system default; 5,000
* (500000 paise) if neither is present.
*/
export function resolveDraftMaxPricePaise(db: DB, tenantId: string): number {
const row = db.prepare(
`SELECT value FROM setting WHERE key='catalog.draftMaxPricePaise'
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 : 500_000
}
export function commitBill(
db: DB, input: CommitBillInput,
): { id: string; docNo: string; payablePaise: number; duplicate?: boolean } {
// Idempotent drain (S4): if this client bill id already committed, return the
// doc no it was assigned — never allocate a new series number or a second stock
// movement. The partial unique index on (tenant_id, client_id) is the backstop.
if (input.clientId !== undefined) {
const existing = db.prepare(
`SELECT id, doc_no, payable_paise FROM bill WHERE tenant_id=? AND client_id=?`,
).get(input.tenantId, input.clientId) as { id: string; doc_no: string; payable_paise: number } | undefined
if (existing !== undefined) {
return { id: existing.id, docNo: existing.doc_no, payablePaise: existing.payable_paise, duplicate: true }
}
}
// The business date is a real, sanely-bounded ISO day (H6) even when a caller reaches
// commitBill directly — a garbage date must never mint a doc series or postdate a bill.
assertBusinessDate(input.businessDate)
const store = db.prepare(`SELECT * FROM store WHERE id=? AND tenant_id=?`).get(input.storeId, input.tenantId) as { code: string; state_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 rates = listTaxClasses(db)
// The committing user's authority (C2/H7): owner/manager/supervisor may self-authorise a
// deviation from the master; everyone else needs a bound supervisor token per deviation.
const committer = db.prepare(
`SELECT display_name, roles FROM app_user WHERE id=? AND tenant_id=?`,
).get(input.cashierId, input.tenantId) as { display_name: string; roles: string } | undefined
if (committer === undefined) throw httpErr(403, 'Unknown cashier (E-6103)', 'E-6103')
const committerRoles = JSON.parse(committer.roles) as RoleCode[]
const committerCan = (action: ActionCode): boolean => can({ roles: committerRoles, active: true }, action)
const capBp = resolveDiscountCapBp(db, input.tenantId)
// Place of supply is decided from OUR copy of the customer row, never the
// client's claim: a B2B buyer's GSTIN state drives IGST vs CGST/SGST (R5).
const customer = input.customerId !== undefined
@ -175,32 +305,138 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo:
store.state_code,
)
// Same engine everywhere: the server recomputes and must agree to the paisa.
const computed = computeBill(input.lines, {
// ---- Item-master anchoring (C2, C3, H5, M8) ----
// Every line is rebuilt from OUR item row: tax class, HSN, MRP, unit and the inclusive-
// price flag come from the master; the client's values for them are ignored. The unit
// price is accepted only when it equals the master sale price — any other value is a
// deviation that must be authorised below (a bound token, or the committer's own
// authority). A price above MRP is a hard block even with a token (E-1301).
interface Deviation { kind: 'price' | 'discount'; itemId: string; before: number; after: number; name: string }
const deviations: Deviation[] = []
// Draft-item sales (C2/C3 review trail): a cashier-authored quick-add item is sold on the
// same session that created it, so its only master-side trace is ITEM_CREATE. Every line
// whose master row is still status='draft' is flagged here and audited DRAFT_SALE in the
// commit transaction below, so a self-authored-item sale is never invisible in a review.
interface DraftSale { itemId: string; name: string; pricePaise: number }
const draftSales: DraftSale[] = []
const anchored: LineInput[] = input.lines.map((line) => {
const item = db.prepare(`SELECT * FROM item WHERE id=? AND tenant_id=?`).get(line.itemId, input.tenantId) as ItemRow | undefined
if (item === undefined) {
throw httpErr(400, 'A billed item is not in this store catalogue (E-1105)', 'E-1105')
}
if (!Number.isFinite(line.qty) || line.qty <= 0 || line.qty > 1e12) {
throw httpErr(400, `Quantity is out of range for "${item.name}" (E-1104)`, 'E-1104')
}
if (item.unit_decimals === 0 && !Number.isInteger(line.qty)) {
throw httpErr(400, `"${item.name}" is sold whole — a fractional quantity is not allowed (E-1104)`, 'E-1104')
}
const effPrice = line.unitPricePaise
if (!Number.isInteger(effPrice) || effPrice < 0) {
throw httpErr(400, `Unit price is invalid for "${item.name}" (E-1103)`, 'E-1103')
}
if (item.status === 'draft') {
draftSales.push({ itemId: line.itemId, name: item.name, pricePaise: effPrice })
}
const masterPrice = item.sale_price_paise
if (effPrice !== masterPrice) {
if (item.mrp_paise !== null && effPrice > item.mrp_paise) {
throw httpErr(400, `Price for "${item.name}" is above its MRP — not allowed (E-1301)`, 'E-1301')
}
deviations.push({ kind: 'price', itemId: line.itemId, before: masterPrice, after: effPrice, name: item.name })
}
if (line.discount !== undefined) {
const gross = Math.round(effPrice * line.qty)
const discPaise = line.discount.kind === 'percentBp'
? Math.round((gross * line.discount.value) / 10_000)
: line.discount.paise
if (discPaise > Math.floor((gross * capBp) / 10_000)) {
deviations.push({ kind: 'discount', itemId: line.itemId, before: 0, after: discPaise, name: item.name })
}
}
return {
itemId: line.itemId,
name: item.name,
hsn: item.hsn,
qty: line.qty,
unitCode: item.unit_code,
unitPricePaise: effPrice,
priceIncludesTax: item.price_includes_tax === 1,
taxClassCode: item.tax_class_code,
...(item.mrp_paise !== null ? { mrpPaise: item.mrp_paise } : {}),
...(line.discount !== undefined ? { discount: line.discount } : {}),
...(line.batchId !== undefined ? { batchId: line.batchId } : {}),
}
})
// Same engine everywhere, now on the ANCHORED lines: the server recomputes from its own
// prices/tax and must still agree with the client to the paisa (R5) — a client that
// under-computed GST (C3) or forged a total is rejected here.
const computed = computeBill(anchored, {
businessDate: input.businessDate,
supplyStateCode: store.state_code,
placeOfSupplyStateCode: supply.placeOfSupplyStateCode,
roundToRupee: true,
}, rates)
if (computed.totals.payablePaise !== input.clientPayablePaise) {
throw Object.assign(new Error('Engine mismatch: client and server computed different totals'), { status: 409 })
throw httpErr(409, 'Bill total does not match the item master — refresh and retry (E-1302)', 'E-1302')
}
// Payments (M8): every leg a positive amount with an allowlisted mode, summing to payable.
for (const p of input.payments) {
if (!Number.isInteger(p.amountPaise) || p.amountPaise <= 0) {
throw httpErr(400, 'Every payment must be a positive amount (E-1107)', 'E-1107')
}
if (!PAYMENT_MODES.has(p.mode)) throw httpErr(400, 'Unknown payment mode (E-1108)', 'E-1108')
}
const paid = input.payments.reduce((a, p) => a + p.amountPaise, 0)
if (paid !== computed.totals.payablePaise) {
throw Object.assign(new Error('Payments do not sum to payable'), { status: 400 })
throw httpErr(400, 'Payments do not sum to the payable amount (E-1109)', 'E-1109')
}
const id = uuidv7()
const now = new Date().toISOString()
const fy = fyOf(input.businessDate)
// Redeem a bound supervisor token for a specific server-computed deviation (H4): the
// token must exist, be unused, be < 5 min old, belong to an approver role, AND match the
// exact item/kind/before/after — then it is marked used in THIS transaction and the
// approver identity is read from the server's own row, never the client's.
const redeemToken = (
approvalId: string, kind: Deviation['kind'] | 'batch', itemId: string, before: number, after: number,
): { name: string; userId: string } => {
const appr = db.prepare(
`SELECT approver_user_id, created_at, used, item_id, kind, before_paise, after_paise FROM override_approval WHERE id=? AND tenant_id=?`,
).get(approvalId, input.tenantId) as {
approver_user_id: string; created_at: string; used: number
item_id: string | null; kind: string | null; before_paise: number | null; after_paise: number | null
} | undefined
if (appr === undefined) throw httpErr(403, 'Override approval is not recognised (E-6107)', 'E-6107')
if (appr.used !== 0) throw httpErr(403, 'Override approval was already used (E-6108)', 'E-6108')
if (Date.now() - Date.parse(appr.created_at) > APPROVAL_TTL_MS) {
throw httpErr(403, 'Override approval has expired — re-approve at the counter (E-6109)', 'E-6109')
}
if (appr.item_id !== itemId || appr.kind !== kind || appr.before_paise !== before || appr.after_paise !== after) {
throw httpErr(403, 'Override approval does not match this line — re-approve at the counter (E-6108)', 'E-6108')
}
const approver = db.prepare(
`SELECT display_name, roles FROM app_user WHERE id=? AND tenant_id=?`,
).get(appr.approver_user_id, input.tenantId) as { display_name: string; roles: string } | undefined
if (approver === undefined || !(JSON.parse(approver.roles) as string[]).some((role) => APPROVER_ROLES.has(role))) {
throw httpErr(403, 'Override approver is not authorised (E-6110)', 'E-6110')
}
const redeemed = db.prepare(
`UPDATE override_approval SET used=1 WHERE id=? AND tenant_id=? AND used=0`,
).run(approvalId, input.tenantId)
if (redeemed.changes !== 1) throw httpErr(403, 'Override approval was already used (E-6108)', 'E-6108')
return { name: approver.display_name, userId: appr.approver_user_id }
}
const txn = db.transaction(() => {
// per-counter, per-FY series allocated inside the commit transaction
let series = db.prepare(
`SELECT * FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`,
).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined
if (series === undefined) {
series = { prefix: seriesPrefix(store.code, counter.code, fyShort(fy)), next_seq: 1 }
series = { prefix: seriesPrefix(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', ?, ?, 1)`,
).run(input.tenantId, input.storeId, input.counterId, fy, series.prefix)
@ -215,21 +451,34 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo:
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, payload, created_at_wall) VALUES (?, ?, ?, ?, 'SALE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
buyer_gstin, place_of_supply, client_id, payload, created_at_wall) VALUES (?, ?, ?, ?, 'SALE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate,
input.cashierId, input.shiftId, input.customerId ?? null,
t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise,
t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise,
supply.buyerGstin ?? null, supply.placeOfSupplyStateCode,
supply.buyerGstin ?? null, supply.placeOfSupplyStateCode, input.clientId ?? null,
payload, now,
)
const move = db.prepare(
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall)
VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?)`,
`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', ?, ?, ?, ?)`,
)
for (const line of computed.lines) {
move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, input.businessDate, now)
// S5: a batch-picked line carries a batchId; the sale movement is attributed to
// it so per-batch on-hand derives correctly (R7). Trust nothing — the batch must
// belong to THIS item and store, or the whole commit rolls back (E-1311).
let batchId: string | null = null
if (line.batchId !== undefined) {
const belongs = db.prepare(
`SELECT 1 FROM batch WHERE id=? AND tenant_id=? AND store_id=? AND item_id=?`,
).get(line.batchId, input.tenantId, input.storeId, line.itemId)
if (belongs === undefined) {
throw Object.assign(new Error(`Batch does not belong to "${line.name}" at this store (E-1311)`), { status: 400 })
}
batchId = line.batchId
}
move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, batchId, input.businessDate, now)
}
// dormant outbox row (D14): written in the same transaction, drained by nothing yet
db.prepare(
@ -237,18 +486,59 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo:
VALUES (?, ?, 'store-server', 0, 'SALE', ?, ?, ?, 0)`,
).run(uuidv7(), input.tenantId, id, payload, now)
writeAudit(db, input.tenantId, input.cashierId, 'BILL_COMMIT', 'bill', id, undefined, { docNo, payable: t.payablePaise })
// One audit row per approved override, in-transaction (R17). The approver's id
// is resolved to a display name so the audit trail is human-readable.
// Flag every draft-item line for back-office review (C2/C3): one DRAFT_SALE row per
// draft sold, in THIS transaction, carrying the item + the price it sold at — so a
// cashier who quick-adds and sells a self-priced item leaves an explicit trail.
for (const d of draftSales) {
writeAudit(db, input.tenantId, input.cashierId, 'DRAFT_SALE', 'bill', id,
undefined, { itemId: d.itemId, name: d.name, pricePaise: d.pricePaise, docNo })
}
// Authorise + audit every SERVER-detected price/discount deviation (C2, H4, H7). Each
// deviation is approved by a bound token that matches it exactly, OR — for owner /
// manager / supervisor — the committer's own authority; either way an override audit
// row is written in-transaction (R17). An unauthorised deviation throws and the whole
// commit rolls back, so a mispriced line can never reach the ledger unaudited.
const usedOverride = new Set<number>()
for (const dev of deviations) {
const action: ActionCode = dev.kind === 'price' ? 'PRICE_OVERRIDE' : 'DISCOUNT_OVER_CAP'
const auditAction = dev.kind === 'price' ? OVERRIDE_ACTION.price : OVERRIDE_ACTION.discount
const idx = (input.overrides ?? []).findIndex((o, i) =>
!usedOverride.has(i) && o.approvalId !== undefined && o.kind === dev.kind && o.itemId === dev.itemId && o.after === dev.after)
if (idx >= 0) {
usedOverride.add(idx)
const approvalId = input.overrides![idx]!.approvalId!
const appr = redeemToken(approvalId, dev.kind, dev.itemId, dev.before, dev.after)
writeAudit(db, input.tenantId, input.cashierId, auditAction, 'bill', id,
{ before: dev.before, itemId: dev.itemId },
{ after: dev.after, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId })
} else if (committerCan(action)) {
writeAudit(db, input.tenantId, input.cashierId, auditAction, 'bill', id,
{ before: dev.before, itemId: dev.itemId },
{ after: dev.after, approvedBy: committer.display_name, approvedByUserId: input.cashierId, selfAuthorized: true })
} else {
const code = dev.kind === 'price' ? 'E-1302' : 'E-1303'
throw httpErr(403, `"${dev.name}": ${dev.kind} change needs supervisor approval (${code})`, code)
}
}
// Client-declared batch overrides (expired-batch sale, S5): no server-side price
// deviation to detect, but the approval is bound + audited the same way (H4/H7).
for (const o of input.overrides ?? []) {
const approvedBy = o.approvedByUserId !== undefined
? (db.prepare(`SELECT display_name FROM app_user WHERE id=?`).get(o.approvedByUserId) as { display_name: string } | undefined)?.display_name
?? o.approvedByUserId
: undefined
writeAudit(
db, input.tenantId, input.cashierId, OVERRIDE_ACTION[o.kind], 'bill', id,
{ itemId: o.itemId, before: o.before },
{ after: o.after, approvedBy, approvedByUserId: o.approvedByUserId },
)
if (o.kind !== 'batch') continue
if (o.approvalId !== undefined) {
const appr = redeemToken(o.approvalId, 'batch', o.itemId, 0, 0)
writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id,
{ before: 0, itemId: o.itemId },
{ after: 0, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId: o.approvalId })
} else if (committerCan('PRICE_OVERRIDE')) {
writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id,
{ before: 0, itemId: o.itemId },
{ after: 0, approvedBy: committer.display_name, approvedByUserId: input.cashierId, selfAuthorized: true })
} else {
throw httpErr(403, 'Expired-batch sale needs supervisor approval (E-1310)', 'E-1310')
}
}
return docNo
})
@ -259,13 +549,21 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo:
export function listBills(
db: DB, tenantId: string, date?: string, counterId?: string, limit = 100, offset = 0,
cashierId?: string,
): Record<string, unknown>[] {
// SALE only — credit notes (SALE_RETURN) are their own document and live on the Returns list,
// never mixed into the sales list or the POS day history.
let sql = `SELECT b.*, 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=?`
WHERE b.tenant_id=? AND b.doc_type='SALE'`
const args: unknown[] = [tenantId]
if (date !== undefined) { sql += ` AND b.business_date=?`; args.push(date) }
if (counterId !== undefined) { sql += ` AND b.counter_id=?`; args.push(counterId) }
// Cross-counter read scope (residual LOW/MED): when the caller lacks REPORT_VIEW the route
// pins this to the session's own user id, so a cashier sees only their OWN bills — never
// every counter's/cashier's. A REPORT_VIEW/manager role passes undefined and keeps the
// full store-wide view.
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>[]
@ -274,26 +572,144 @@ export function listBills(
// ---------- stock ----------
export function stockView(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] {
return db.prepare(
`SELECT i.code, i.name, i.unit_code, i.sale_price_paise,
`SELECT i.code, i.name, i.unit_code, i.sale_price_paise, i.batch_tracked,
COALESCE(SUM(m.qty_delta), 0) AS on_hand
FROM item i LEFT JOIN stock_movement m ON m.item_id=i.id AND m.store_id=?
WHERE i.tenant_id=? GROUP BY i.id ORDER BY i.name`,
).all(storeId, tenantId) as Record<string, unknown>[]
}
// ---------- audit ----------
// ---------- batches (S5) ----------
/**
* Find a batch by its unique key (tenant, store, item, batch_no); create it if new.
* Returns the batch id either way. Called at purchase commit. When an expiry is
* supplied and the existing lot had none (or a different date), the date is filled
* in a later invoice can date a batch first captured undated. Client-generated
* ids per R14. Callers run this inside their own transaction.
*/
export function upsertBatch(
db: DB, tenantId: string, storeId: string, itemId: string, batchNo: string, expiryDate?: string,
): string {
const existing = db.prepare(
`SELECT id, expiry_date FROM batch WHERE tenant_id=? AND store_id=? AND item_id=? AND batch_no=?`,
).get(tenantId, storeId, itemId, batchNo) as { id: string; expiry_date: string | null } | undefined
if (existing !== undefined) {
if (expiryDate !== undefined && expiryDate !== '' && expiryDate !== existing.expiry_date) {
db.prepare(`UPDATE batch SET expiry_date=? WHERE id=?`).run(expiryDate, existing.id)
}
return existing.id
}
const id = uuidv7()
db.prepare(
`INSERT INTO batch (id, tenant_id, store_id, item_id, batch_no, expiry_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(id, tenantId, storeId, itemId, batchNo, expiryDate !== undefined && expiryDate !== '' ? expiryDate : null, new Date().toISOString())
return id
}
/**
* Every batch at a store with derived on-hand > 0 (R7) the POS caches this keyed
* by item to drive the FEFO picker. Stock is SUM(movements) per batch, never stored.
*/
export function batchesForStore(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] {
return db.prepare(
`SELECT b.id, b.item_id, b.batch_no, b.expiry_date,
COALESCE(SUM(m.qty_delta), 0) AS on_hand
FROM batch b LEFT JOIN stock_movement m ON m.batch_id=b.id AND m.store_id=b.store_id
WHERE b.tenant_id=? AND b.store_id=?
GROUP BY b.id HAVING on_hand > 0
ORDER BY b.item_id, b.expiry_date`,
).all(tenantId, storeId) as Record<string, unknown>[]
}
/**
* The expiry dashboard feed: each in-stock batch with its item, derived on-hand and
* value at the item's current sale price. Buckets (7d, 30d, expired) are computed
* by the page against the business date so the numbers stay live.
*/
export function expiryView(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] {
return db.prepare(
`SELECT b.id, b.item_id, b.batch_no, b.expiry_date,
i.code, i.name, i.unit_code, i.sale_price_paise,
COALESCE(SUM(m.qty_delta), 0) AS on_hand
FROM batch b
JOIN item i ON i.id=b.item_id
LEFT JOIN stock_movement m ON m.batch_id=b.id AND m.store_id=b.store_id
WHERE b.tenant_id=? AND b.store_id=?
GROUP BY b.id HAVING on_hand > 0
ORDER BY b.expiry_date IS NULL, b.expiry_date`,
).all(tenantId, storeId) as Record<string, unknown>[]
}
// ---------- audit (H2: tamper-evident hash chain) ----------
/**
* The canonical bytes an audit entry hashes over (H2). A JSON array is an injective,
* delimiter-safe encoding: `null` stays distinct from an empty string, and every field is
* escaped, so no two different entries can serialise to the same bytes. `beforeJson`/`afterJson`
* are the EXACT stored column values (a JSON string or SQL NULLJS null) so verify recomputes
* byte-identically to what writeAudit hashed. The chain is per-tenant: prevHash is the previous
* entry's entry_hash for the tenant, genesis '' for the first.
*/
function auditEntryHash(
prevHash: string, tenantId: string, atWall: string, userId: string, action: string,
entity: string, entityId: string, beforeJson: string | null, afterJson: string | null,
): string {
const canonical = JSON.stringify([prevHash, tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson])
return createHash('sha256').update(canonical, 'utf8').digest('hex')
}
/**
* Append one audit entry, linked into the tenant's hash chain (H2). The previous entry is the
* tenant's last-inserted row (SQLite rowid order = insertion order under the single-writer
* model); verifyAuditChain recomputes in the same order. Called inside the existing commit
* transactions the prev-row read sees earlier in-transaction inserts on the same connection,
* so several audit rows written in one commit chain correctly among themselves.
*/
export function writeAudit(
db: DB, tenantId: string, userId: string, action: string,
entity: string, entityId: string, before?: unknown, after?: unknown,
): void {
const prev = db.prepare(
`SELECT entry_hash FROM audit_log WHERE tenant_id=? ORDER BY rowid DESC LIMIT 1`,
).get(tenantId) as { entry_hash: string | null } | undefined
const prevHash = prev?.entry_hash ?? ''
const atWall = new Date().toISOString()
const beforeJson = before === undefined ? null : JSON.stringify(before)
const afterJson = after === undefined ? null : JSON.stringify(after)
const entryHash = auditEntryHash(prevHash, tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson)
db.prepare(
`INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
uuidv7(), tenantId, new Date().toISOString(), userId, action, entity, entityId,
before === undefined ? null : JSON.stringify(before),
after === undefined ? null : JSON.stringify(after),
`INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(uuidv7(), tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson, prevHash, entryHash)
}
/**
* Recompute the tenant's audit hash chain and report the first entry that fails (H2). Returns
* `{ ok: true }` when every row's stored prev_hash matches the running chain AND its entry_hash
* matches a recompute over its own content; otherwise `{ ok: false, brokenAtId }` names the first
* broken row. This makes any out-of-band tamper VISIBLE: an UPDATE changes a row's content so its
* entry_hash no longer recomputes; a DELETE breaks the next row's prev_hash link; a naive INSERT
* lands with a hash the chain can't reproduce. It is tamper-EVIDENCE, not prevention an attacker
* with file write access could re-chain forward (the chain is unsigned); sealing the file is H3.
*/
export function verifyAuditChain(db: DB, tenantId: string): { ok: boolean; brokenAtId?: string } {
const rows = db.prepare(
`SELECT id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash
FROM audit_log WHERE tenant_id=? ORDER BY rowid ASC`,
).all(tenantId) as {
id: string; at_wall: string; user_id: string; action: string; entity: string
entity_id: string; before_json: string | null; after_json: string | null
prev_hash: string | null; entry_hash: string | null
}[]
let prevHash = ''
for (const r of rows) {
if ((r.prev_hash ?? '') !== prevHash) return { ok: false, brokenAtId: r.id }
const expected = auditEntryHash(
prevHash, tenantId, r.at_wall, r.user_id, r.action, r.entity, r.entity_id, r.before_json, r.after_json,
)
if (expected !== r.entry_hash) return { ok: false, brokenAtId: r.id }
prevHash = r.entry_hash
}
return { ok: true }
}
export function listAudit(db: DB, tenantId: string, limit = 100, offset = 0): Record<string, unknown>[] {

@ -80,6 +80,82 @@ export function seedIfEmpty(db: DB): void {
setting.run('system', '', 'gst.roundToRupee', 'true')
setting.run('system', '', 'discount.capBp', '1000')
setting.run('tenant', 't1', 'discount.capBp', '1500')
// Cashier quick-add draft guards (C2/C3): the tax class the server forces onto a
// cashier-created draft (never the client's — no ZERO-tax GST evasion), and the price
// ceiling above which a draft must go through a supervisor / back office. Tenant rows may
// override; the resolvers fall back to these same defaults if a row is missing.
setting.run('system', '', 'catalog.defaultTaxClass', 'GST18')
setting.run('system', '', 'catalog.draftMaxPricePaise', '500000')
// GST returns: the B2C-Large threshold (a B2C inter-state invoice ABOVE this is B2CL, else
// B2CS). Dated config (R10) — the classic ₹2,50,000; a tenant row overrides per period.
setting.run('system', '', 'gst.b2clThresholdPaise', '25000000')
db.prepare(`INSERT INTO message_catalog (code, channel, lang, template) VALUES ('BILL_ESHARE','whatsapp','en','Hi {name}, your bill of {amount} from {store}. Thank you!')`).run()
ensureBatchTrackingSeed(db)
}
/**
* S5 demo batches. Idempotent and safe on ANY db it runs both on a fresh seed and
* against the already-populated dev db (where seedIfEmpty is a no-op, so batches must
* be backfilled here). Marks the two demo perishables batch-tracked, then, if no batch
* exists yet, splits each item's opening stock into dated lots. Total on-hand is
* preserved (R7): the per-lot OPENING movements are offset by one negative un-batched
* OPENING adjustment per item, so re-attributing stock into lots never inflates the
* derived quantity. One lot per item is deliberately expired-with-stock to exercise the
* E-1310 gate and the dashboard's expired section.
*/
export function ensureBatchTrackingSeed(db: DB): void {
db.prepare(`UPDATE item SET batch_tracked=1 WHERE tenant_id='t1' AND code IN ('105','107')`).run()
const existing = (db.prepare(`SELECT COUNT(*) AS n FROM batch WHERE tenant_id='t1'`).get() as { n: number }).n
if (existing > 0) return
const now = new Date()
const iso = (d: Date): string => d.toISOString().slice(0, 10)
const plusDays = (days: number): string => {
const d = new Date(now)
d.setDate(d.getDate() + days)
return iso(d)
}
// code → lots to create: batch_no, expiry offset in days (negative = already
// expired), opening qty. Quantities are re-attributed from the item's opening 120.
const plan: Record<string, { no: string; days: number; qty: number }[]> = {
// Amul Butter 500g (₹275): an expired lot WITH stock (proves the block + the
// expired-with-stock section), a ≤7-day lot, and a ~6-month lot.
'107': [
{ no: 'B-107X', days: -3, qty: 8 },
{ no: 'B-107A', days: 5, qty: 12 },
{ no: 'B-107B', days: 180, qty: 30 },
],
// Maggi 12-pack (₹140): a ≤7-day lot and a ~6-month lot.
'105': [
{ no: 'B-105A', days: 4, qty: 10 },
{ no: 'B-105B', days: 175, qty: 25 },
],
}
const nowIso = now.toISOString()
const today = iso(now)
const insBatch = db.prepare(
`INSERT INTO batch (id, tenant_id, store_id, item_id, batch_no, expiry_date, created_at) VALUES (?, 't1', 's1', ?, ?, ?, ?)`,
)
const insMove = db.prepare(
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, batch_id, business_date, created_at_wall)
VALUES (?, 't1', 's1', ?, ?, 'OPENING', ?, ?, ?)`,
)
db.transaction(() => {
for (const [code, lots] of Object.entries(plan)) {
const item = db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string } | undefined
if (item === undefined) continue
let batched = 0
for (const lot of lots) {
const batchId = uuidv7()
insBatch.run(batchId, item.id, lot.no, plusDays(lot.days), nowIso)
insMove.run(uuidv7(), item.id, lot.qty, batchId, today, nowIso)
batched += lot.qty
}
// Offsetting un-batched OPENING adjustment: total on-hand stays exactly what it
// was (R7) — we split existing stock into lots, we never conjure new stock.
insMove.run(uuidv7(), item.id, -batched, null, today, nowIso)
}
})()
}

@ -1,8 +1,10 @@
import express from 'express'
import net from 'node:net'
import path from 'node:path'
import fs from 'node:fs'
import https from 'node:https'
import helmet from 'helmet'
import { openDb } from './db'
import { seedIfEmpty } from './seed'
import { ensureBatchTrackingSeed, seedIfEmpty } from './seed'
import { apiRouter } from './api'
/**
@ -10,11 +12,59 @@ import { apiRouter } from './api'
* one Node process on the store's server machine
* / back office web app
* /pos POS web app (counters browse here; zero installs)
* /api/print ESC/POS bytes to a network printer (browsers can't open sockets)
* /api/print ESC/POS bytes to a LAN network printer (authenticated + allowlisted, H1)
* /api/health monitoring handshake
* The Oracle 12c repository layer (D12) and the outbox sync agent (D14) land here next.
*/
const app = express()
app.disable('x-powered-by') // no framework banner (M14)
// M13 — TLS is available but NOT forced in dev. Production MUST supply a cert + key via
// SIMS_TLS_CERT / SIMS_TLS_KEY (paths); when both are set we serve HTTPS, otherwise plain
// HTTP on :5181 so the existing dev/browser/verification flow is unchanged. Once prod is
// HTTPS the bearer token no longer rides the LAN in cleartext (closes M13) and the
// cache-first service worker can no longer be MITM-poisoned over HTTP (closes M15).
// Make a throwaway dev cert (self-signed, 1 year) with:
// openssl req -x509 -newkey rsa:2048 -nodes -keyout dev-key.pem -out dev-cert.pem -days 365 -subj "/CN=localhost"
// then start with: SIMS_TLS_CERT=dev-cert.pem SIMS_TLS_KEY=dev-key.pem npm start
const TLS_CERT = process.env['SIMS_TLS_CERT']
const TLS_KEY = process.env['SIMS_TLS_KEY']
const tlsEnabled = TLS_CERT !== undefined && TLS_CERT !== '' && TLS_KEY !== undefined && TLS_KEY !== ''
// M14 — security headers via helmet. The CSP is tuned for the built SPAs: they load their own
// module script + stylesheet from 'self', use inline style attributes (React `style={{}}`),
// open invoice/label tabs as blob: documents (which inherit this policy) whose ONLY inline
// script is a `window.print()` button — permitted by a single hash under 'unsafe-hashes'
// rather than opening script-src up to 'unsafe-inline' — and embed fonts as data:. The POS
// service worker + assets are all 'self'. `upgrade-insecure-requests` is emitted ONLY under
// TLS, so the dev HTTP instance is never told to upgrade to a port that serves no HTTPS.
app.use(helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
baseUri: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'", "'unsafe-hashes'", "'sha256-MguIPR6qNR8D3B+eAlK+bIRTZe8t3wkOY4B/56Me9FU='"],
connectSrc: ["'self'"],
workerSrc: ["'self'"],
frameSrc: ["'self'", 'blob:'],
...(tlsEnabled ? { upgradeInsecureRequests: [] } : {}),
},
},
// helmet only offers X-Frame-Options: SAMEORIGIN; the review asks for DENY, so set it below.
frameguard: false,
// HSTS only means anything over TLS; emitting it on the dev HTTP instance would be a lie.
hsts: tlsEnabled ? { maxAge: 15_552_000, includeSubDomains: true } : false,
crossOriginEmbedderPolicy: false,
}))
app.use((_req, res, next) => { res.setHeader('X-Frame-Options', 'DENY'); next() })
app.use(express.json({ limit: '1mb' }))
const POS_DIST = path.resolve(__dirname, '../../pos/dist')
@ -28,36 +78,52 @@ app.get('/api/health', (_req, res) => {
})
const db = openDb()
// M10 — the demo seed creates a known-credential owner (thomas / PIN 9174 / pw 'sims') and the
// rest of the MegaMart demo. That must NEVER auto-materialise on a real, empty production DB, so
// seeding runs ONLY when explicitly enabled with SIMS_SEED_DEMO=1. The dev start script sets it
// (see package.json `start` → dev-start.mjs), so :5181 still seeds; a production deployment runs
// the built server directly with the flag unset, leaving an empty DB empty for the onboarding
// wizard. ensureBatchTrackingSeed (which backfills demo batches, hardcoded to tenant 't1') is
// gated with it — on a non-demo DB there is no 't1' data for it to touch anyway.
if (process.env['SIMS_SEED_DEMO'] === '1') {
seedIfEmpty(db)
// S5: backfill batch-tracking + demo batches on the already-populated dev DB, where
// seedIfEmpty short-circuits. Idempotent, so it is a no-op once the batches exist.
ensureBatchTrackingSeed(db)
} else {
console.warn('[store-server] SIMS_SEED_DEMO is not set — skipping demo seed (an empty DB stays empty; set SIMS_SEED_DEMO=1 for the dev demo data).')
}
// The data API — including the now-authenticated + allowlisted /api/print relay (H1),
// /api/auth/logout (M4) and the rate-limited /api/auth/* routes — lives in apiRouter.
app.use('/api', apiRouter(db))
app.post('/api/print', (req, res) => {
const { host, port, bytes } = req.body as { host?: string; port?: number; bytes?: number[] }
if (typeof host !== 'string' || typeof port !== 'number' || !Array.isArray(bytes)) {
res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }' })
return
}
// 'close' always follows 'error'/'timeout' on net sockets — respond exactly once.
let done = false
const finish = (status: number, body: object) => {
if (done) return
done = true
res.status(status).json(body)
}
const socket = net.createConnection({ host, port, timeout: 3000 })
socket.on('connect', () => socket.end(Buffer.from(bytes)))
socket.on('close', () => finish(200, { ok: true }))
socket.on('timeout', () => {
socket.destroy()
finish(504, { ok: false, error: `Printer ${host}:${port} timed out` })
/**
* Generic error handler (M7): validators and repositories throw errors tagged with a safe
* `status` + `code`; anything untagged is treated as an internal fault and answered with a
* generic message. A stack trace or install path is NEVER sent to the client unexpected
* errors are logged server-side only. Must be the LAST middleware and take 4 args.
*/
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 (status >= 500) console.error('[store-server] unhandled error:', err)
if (res.headersSent) return
res.status(status).json({
error: status >= 500 ? 'Something went wrong (E-5000)' : (e.message ?? 'Bad request'),
code: e.code ?? (status >= 500 ? 'E-5000' : 'E-1000'),
})
socket.on('error', (err) => finish(502, { ok: false, error: err.message }))
})
// 80808085 sit in Windows' Hyper-V excluded port ranges on shop PCs too — avoid them.
const PORT = Number(process.env['PORT'] ?? 5181)
app.listen(PORT, () => {
console.log(`SiMS store-server listening on http://localhost:${PORT}`)
console.log(` Back office: http://localhost:${PORT}/`)
console.log(` POS: http://localhost:${PORT}/pos/`)
})
const banner = (proto: string) => () => {
console.log(`SiMS store-server listening on ${proto}://localhost:${PORT}`)
console.log(` Back office: ${proto}://localhost:${PORT}/`)
console.log(` POS: ${proto}://localhost:${PORT}/pos/`)
}
if (tlsEnabled) {
const creds = { cert: fs.readFileSync(TLS_CERT!), key: fs.readFileSync(TLS_KEY!) }
https.createServer(creds, app).listen(PORT, banner('https'))
} else {
app.listen(PORT, banner('http'))
}

@ -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,92 @@
import { describe, expect, it } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { commitBill, verifyAuditChain, writeAudit } from '../src/repos'
/**
* H2 (red-team): the audit log is a per-tenant hash chain. Each row's entry_hash covers the
* previous entry's hash plus its own content, so any out-of-band UPDATE/DELETE/INSERT to the
* table breaks the chain, which verifyAuditChain / GET /api/audit/verify recomputes. This is
* tamper-EVIDENCE, not prevention (the SQLite file is still writable sealing it is H3). These
* tests build a chain, prove it verifies, then tamper directly via better-sqlite3 and re-verify.
*/
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
interface AuditRow { id: string; prev_hash: string | null; entry_hash: string | null }
const auditRows = (db: DB): AuditRow[] =>
db.prepare("SELECT id, prev_hash, entry_hash FROM audit_log WHERE tenant_id='t1' ORDER BY rowid").all() as AuditRow[]
describe('H2 — tamper-evident audit hash chain', () => {
it('a genuine sequence verifies, and each row links to the previous (genesis = "")', () => {
const db = fresh()
// a real commit writes a BILL_COMMIT row inside its transaction...
commitBill(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
businessDate: '2026-07-10',
lines: [{ itemId: itemId(db, '108'), name: 'Carry Bag', hsn: '3923', qty: 1, unitCode: 'PCS', unitPricePaise: 500, priceIncludesTax: true, taxClassCode: 'GST18' }],
payments: [{ mode: 'CASH', amountPaise: 500 }], clientPayablePaise: 500,
})
// ...and a couple of plain rows chain on after it
writeAudit(db, 't1', 'u1', 'TEST_A', 'x', 'x1')
writeAudit(db, 't1', 'u3', 'TEST_B', 'x', 'x2', { a: 1 }, { b: 2 })
const rows = auditRows(db)
expect(rows.length).toBeGreaterThanOrEqual(3)
expect(rows[0]!.prev_hash).toBe('') // genesis
for (let i = 1; i < rows.length; i++) expect(rows[i]!.prev_hash).toBe(rows[i - 1]!.entry_hash)
expect(verifyAuditChain(db, 't1')).toEqual({ ok: true })
})
it('detects an out-of-band UPDATE, pointing at the changed row', () => {
const db = fresh()
writeAudit(db, 't1', 'u1', 'A', 'e', '1')
writeAudit(db, 't1', 'u1', 'B', 'e', '2')
writeAudit(db, 't1', 'u1', 'C', 'e', '3')
expect(verifyAuditChain(db, 't1').ok).toBe(true)
const target = auditRows(db)[1]!.id
db.prepare('UPDATE audit_log SET after_json=? WHERE id=?').run(JSON.stringify({ forged: true }), target)
const res = verifyAuditChain(db, 't1')
expect(res.ok).toBe(false)
expect(res.brokenAtId).toBe(target)
})
it('detects an out-of-band DELETE, pointing at the row after the gap', () => {
const db = fresh()
writeAudit(db, 't1', 'u1', 'A', 'e', '1')
writeAudit(db, 't1', 'u1', 'B', 'e', '2')
writeAudit(db, 't1', 'u1', 'C', 'e', '3')
const rows = auditRows(db)
db.prepare('DELETE FROM audit_log WHERE id=?').run(rows[1]!.id) // remove the middle row
const res = verifyAuditChain(db, 't1')
expect(res.ok).toBe(false)
expect(res.brokenAtId).toBe(rows[2]!.id) // its prev_hash no longer matches the running chain
})
it('detects a forged INSERT appended out-of-band', () => {
const db = fresh()
writeAudit(db, 't1', 'u1', 'A', 'e', '1')
db.prepare(
`INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash)
VALUES ('forged-id', 't1', ?, 'attacker', 'FORGED', 'e', '9', NULL, ?, 'deadbeef', 'cafebabe')`,
).run(new Date().toISOString(), JSON.stringify({ evil: true }))
const res = verifyAuditChain(db, 't1')
expect(res.ok).toBe(false)
expect(res.brokenAtId).toBe('forged-id')
})
it('scopes per tenant — one tenants tamper does not fail another tenants chain', () => {
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())
writeAudit(db, 't1', 'u1', 'A', 'e', '1')
writeAudit(db, 't2', 'z9', 'A', 'e', '1')
writeAudit(db, 't2', 'z9', 'B', 'e', '2')
// tamper only t1
db.prepare('UPDATE audit_log SET action=? WHERE tenant_id=?').run('HACKED', 't1')
expect(verifyAuditChain(db, 't1').ok).toBe(false)
expect(verifyAuditChain(db, 't2')).toEqual({ ok: true })
})
})

@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import { openDb, type DB } from '../src/db'
import { ensureBatchTrackingSeed, seedIfEmpty } from '../src/seed'
import { batchesForStore, commitBill, createOverrideApproval, expiryView, stockView } from '../src/repos'
import { commitPurchase } from '../src/repos-purchase'
/**
* S5 batch/expiry server layer against a hermetic in-memory DB. seedIfEmpty already
* calls ensureBatchTrackingSeed, so the demo perishables (105 Maggi, 107 Amul Butter)
* arrive batch-tracked with dated lots and an on-hand-preserving offset.
*/
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 onHand = (db: DB, code: string): number =>
Number((stockView(db, 't1', 's1').find((r) => r['code'] === code) as { on_hand: number }).on_hand)
describe('S5 seed — batch tracking + preserved totals', () => {
it('marks Amul Butter (107) and Maggi (105) batch-tracked', () => {
const db = fresh()
for (const code of ['105', '107']) {
const it = db.prepare(`SELECT batch_tracked FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { batch_tracked: number }
expect(it.batch_tracked).toBe(1)
}
})
it('splits opening stock into lots WITHOUT changing total on-hand', () => {
const db = fresh()
// seed opening was 120 each; the lot movements are offset by a negative un-batched OPENING.
expect(onHand(db, '107')).toBe(120)
expect(onHand(db, '105')).toBe(120)
// the lots themselves carry the re-attributed quantities
const batches = batchesForStore(db, 't1', 's1')
const sum = batches.filter((b) => b['item_id'] === itemId(db, '107')).reduce((a, b) => a + Number(b['on_hand']), 0)
expect(sum).toBe(8 + 12 + 30)
})
it('is idempotent — a second call adds no batches', () => {
const db = fresh()
const before = batchesForStore(db, 't1', 's1').length
ensureBatchTrackingSeed(db)
expect(batchesForStore(db, 't1', 's1').length).toBe(before)
})
it('exposes an expired-with-stock lot and near-expiry lots to the dashboard feed', () => {
const db = fresh()
const today = new Date().toISOString().slice(0, 10)
const rows = expiryView(db, 't1', 's1')
const expired = rows.filter((r) => r['expiry_date'] !== null && String(r['expiry_date']) < today)
expect(expired.length).toBeGreaterThan(0)
// value at sale price is derivable: Amul 107 expired lot = 8 × ₹275
const amulExpired = expired.find((r) => r['code'] === '107')!
expect(Number(amulExpired['on_hand']) * Number(amulExpired['sale_price_paise'])).toBe(8 * 27_500)
})
})
describe('S5 purchase capture — batch upsert + stock IN', () => {
it('creates the batch, attributes the IN movement to it, and re-purchase reuses the same lot', () => {
const db = fresh()
const amul = itemId(db, '107')
const supplier = (db.prepare(`SELECT id FROM party WHERE tenant_id='t1' AND code='SU002'`).get() as { id: string }).id
const before = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul).length
commitPurchase(db, {
tenantId: 't1', storeId: 's1', userId: 'u3', supplierId: supplier,
invoiceNo: 'AA-900', invoiceDate: '2026-07-10', businessDate: '2026-07-10',
lines: [{ itemId: amul, name: 'Amul Butter 500g', qty: 20, unitCostPaise: 24_000, taxRateBp: 1_200, batchNo: 'B-FRESH', expiryDate: '2026-12-01' }],
clientTotalPaise: 20 * 24_000 + Math.round(20 * 24_000 * 1_200 / 10_000),
})
const after = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul)
expect(after.length).toBe(before + 1)
const fresh1 = after.find((b) => b['batch_no'] === 'B-FRESH')!
expect(Number(fresh1['on_hand'])).toBe(20)
// a second invoice for the same batch_no upserts (no duplicate lot), on-hand adds up
commitPurchase(db, {
tenantId: 't1', storeId: 's1', userId: 'u3', supplierId: supplier,
invoiceNo: 'AA-901', invoiceDate: '2026-07-10', businessDate: '2026-07-10',
lines: [{ itemId: amul, name: 'Amul Butter 500g', qty: 5, unitCostPaise: 24_000, taxRateBp: 1_200, batchNo: 'B-FRESH', expiryDate: '2026-12-01' }],
clientTotalPaise: 5 * 24_000 + Math.round(5 * 24_000 * 1_200 / 10_000),
})
const reused = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul && b['batch_no'] === 'B-FRESH')
expect(reused.length).toBe(1)
expect(Number(reused[0]!['on_hand'])).toBe(25)
})
})
describe('S5 sale — batch-attributed movement + belongs validation', () => {
const line = (id: string, batchId?: string) => ({
itemId: id, name: 'Amul Butter 500g', hsn: '0405', qty: 2, unitCode: 'PCS',
unitPricePaise: 27_500, priceIncludesTax: true, taxClassCode: 'GST12',
...(batchId !== undefined ? { batchId } : {}),
})
it('decrements the picked batch, not the item at large', () => {
const db = fresh()
const amul = itemId(db, '107')
const far = batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-107B')!
const farId = String(far['id'])
const before = Number(far['on_hand'])
commitBill(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
businessDate: '2026-07-10', lines: [line(amul, farId)],
payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000,
})
const after = batchesForStore(db, 't1', 's1').find((b) => b['id'] === farId)!
expect(Number(after['on_hand'])).toBe(before - 2)
})
it('rejects a sale whose batch belongs to a different item (E-1311) — nothing committed', () => {
const db = fresh()
const amul = itemId(db, '107')
const maggiBatch = String(batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-105A')!['id'])
const billCount = () => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
expect(() => commitBill(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
businessDate: '2026-07-10', lines: [line(amul, maggiBatch)],
payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000,
})).toThrow(/E-1311/)
expect(billCount()).toBe(0)
})
it('audits a BATCH_OVERRIDE when an expired-batch sale is approved', () => {
const db = fresh()
const amul = itemId(db, '107')
const expired = String(batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-107X')!['id'])
const approvalId = createOverrideApproval(db, 't1', 'u2', { itemId: amul, kind: 'batch', beforePaise: 0, afterPaise: 0 }) // Suresh
commitBill(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
businessDate: '2026-07-10', lines: [line(amul, expired)],
payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000,
overrides: [{ itemId: amul, kind: 'batch', before: 0, after: 0, approvalId }],
})
const audit = db.prepare(`SELECT after_json FROM audit_log WHERE action='BATCH_OVERRIDE'`).get() as { after_json: string } | undefined
expect(audit).toBeDefined()
expect((JSON.parse(audit!.after_json) as { approvedBy: string }).approvedBy).toBe('Suresh')
})
})

@ -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,221 @@
import { afterEach, 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 { commitBill, createCustomer } from '../src/repos'
import { commitReturn } from '../src/repos-returns'
import { gstr1, gstr3b, hsnSummary, gstr1PortalJson } from '../src/repos-gst'
import { apiRouter } from '../src/api'
/**
* GST returns READ-ONLY compliance reporting over the period's immutable documents. These lock:
* the B2B/B2CS split by buyer GSTIN, B2CS aggregation by rate, a credit note reducing its rate
* bucket, HSN totals, the GSTR-3B 3.1(a) outward figures hand-checked against the bills, the
* reconciliation identity (sections == source == HSN == 3B), the REPORT_VIEW gate, and valid JSON.
*
* Hermetic in-memory seed: item 103 Surf Excel (GST18, 145 incl), 107 Amul Butter (GST12, 275),
* 104 Parle-G (GST5, 90). Store state 32. u1 Ramesh/cashier (4728), u3 Thomas/owner (9174).
*/
const PERIOD = '2026-07'
const DATE = '2026-07-10'
const CTIN = '27ABCDE1234F1Z0' // registered buyer in state 27 → inter-state B2B (IGST)
function itemId(db: DB, code: string): string {
return (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
}
interface Built { custA: string; bill1: string; bill2: string; bill3: string }
/** Build the known document set for a single month and return the ids. */
function buildMonth(db: DB): Built {
const custA = createCustomer(db, 't1', 'Anand Traders', '9876543210', { gstin: CTIN }).id
const line = (code: string, qty: number, price: number, cls: string, hsn: string, name: string) => ({
itemId: itemId(db, code), name, hsn, qty, unitCode: 'PCS', unitPricePaise: price, priceIncludesTax: true, taxClassCode: cls,
})
const commit = (lines: ReturnType<typeof line>[], payable: number, customerId?: string) =>
commitBill(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: DATE,
lines, payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
...(customerId !== undefined ? { customerId } : {}),
})
// Bill 1 — B2B: 2 × Surf Excel to the GSTIN buyer (inter-state → IGST).
const bill1 = commit([line('103', 2, 14_500, 'GST18', '3402', 'Surf Excel 1kg')], 29_000, custA).id
// Bill 2 — B2C cash: 1 × Surf Excel + 1 × Amul Butter (intra → CGST/SGST).
const bill2 = commit([line('103', 1, 14_500, 'GST18', '3402', 'Surf Excel 1kg'), line('107', 1, 27_500, 'GST12', '0405', 'Amul Butter 500g')], 42_000).id
// Bill 3 — B2C cash: 3 × Parle-G.
const bill3 = commit([line('104', 3, 9_000, 'GST5', '1905', 'Parle-G 800g')], 27_000).id
// Return — the full Amul line from bill 2 (index 1) → nets into B2CS at GST12.
commitReturn(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: DATE,
originalBillId: bill2, lines: [{ lineIndex: 1, qty: 1 }], reason: 'Damaged', refundMode: 'CASH',
})
return { custA, bill1, bill2, bill3 }
}
function fresh(): DB {
const db = openDb(':memory:')
seedIfEmpty(db)
return db
}
describe('gstr1 — sections over the known month', () => {
it('B2B shows the GSTIN invoice; B2CS aggregates the cash bills by rate; the return nets out', () => {
const db = fresh()
buildMonth(db)
const r = gstr1(db, 't1', PERIOD)
// B2B: one party, one invoice, GST18, taxable 24576 + IGST 4424 (₹290 incl of 2×₹145).
expect(r.b2b).toHaveLength(1)
expect(r.b2b[0]!.ctin).toBe(CTIN)
expect(r.b2b[0]!.invoiceCount).toBe(1)
expect(r.b2b[0]!.totals.taxablePaise).toBe(24_576)
expect(r.b2b[0]!.totals.igstPaise).toBe(4_424)
// B2CS: Surf (GST18) and Parle (GST5) survive; the Amul (GST12) bucket nets to zero and is dropped.
const b18 = r.b2cs.find((b) => b.rateBp === 1800)!
expect(b18.taxablePaise).toBe(12_288)
expect(b18.cgstPaise).toBe(1_106)
expect(r.b2cs.find((b) => b.rateBp === 500)!.taxablePaise).toBe(25_714)
expect(r.b2cs.some((b) => b.rateBp === 1200)).toBe(false)
// No B2CL / CDNR / CDNUR in this set (nothing large, no registered return).
expect(r.b2cl).toHaveLength(0)
expect(r.cdnr).toHaveLength(0)
expect(r.cdnur).toHaveLength(0)
// HSN: Surf 3402 aggregates B2B + B2C (qty 3); Amul 0405 nets to zero and drops.
const surf = r.hsn.find((h) => h.hsn === '3402')!
expect(surf.taxablePaise).toBe(36_864)
expect(surf.qty).toBe(3)
expect(r.hsn.some((h) => h.hsn === '0405')).toBe(false)
expect(r.totals.hsn.taxablePaise).toBe(62_578)
// Documents issued: 3 invoices + 1 credit note.
expect(r.docs.find((d) => d.natureOfDocument === 'Invoices for outward supply')!.totalNumber).toBe(3)
expect(r.docs.find((d) => d.natureOfDocument === 'Credit notes')!.totalNumber).toBe(1)
})
})
describe('gstr3b — 3.1(a) hand-checked against the bills', () => {
it('outward = Σ bill tax credit-note tax, at captured rates', () => {
const db = fresh()
buildMonth(db)
const r = gstr3b(db, 't1', PERIOD)
// Sales tax: IGST 4424 + CGST 3222 + SGST 3222 = 10868; credit note: CGST 1473 + SGST 1473 = 2946.
expect(r.salesTax.igstPaise + r.salesTax.cgstPaise + r.salesTax.sgstPaise).toBe(10_868)
expect(r.returnsTax.cgstPaise + r.returnsTax.sgstPaise).toBe(2_946)
expect(r.outward.taxablePaise).toBe(62_578)
expect(r.outward.igstPaise).toBe(4_424)
expect(r.outward.cgstPaise).toBe(1_749)
expect(r.outward.sgstPaise).toBe(1_749)
expect(r.outward.igstPaise + r.outward.cgstPaise + r.outward.sgstPaise).toBe(7_922) // 10868 2946
expect(r.itc.igstPaise).toBe(0) // ITC stub
})
})
describe('reconciliation — the compliance gate ties out', () => {
it('GSTR-1 sections == source == HSN == GSTR-3B (taxable and tax)', () => {
const db = fresh()
buildMonth(db)
const r = gstr1(db, 't1', PERIOD)
expect(r.reconciliation.ok).toBe(true)
expect(r.reconciliation.mismatches).toEqual([])
expect(r.reconciliation.sourceTaxablePaise).toBe(62_578)
expect(r.reconciliation.gstr1NetTaxablePaise).toBe(62_578)
expect(r.reconciliation.hsnTaxablePaise).toBe(62_578)
expect(r.reconciliation.gstr3bTaxablePaise).toBe(62_578)
expect(r.reconciliation.sourceTaxPaise).toBe(7_922)
expect(r.reconciliation.gstr3bTaxPaise).toBe(7_922)
})
it('an empty period reconciles to zero, not an error', () => {
const db = fresh()
const r = gstr1(db, 't1', '2026-01')
expect(r.reconciliation.ok).toBe(true)
expect(r.reconciliation.sourceTaxablePaise).toBe(0)
expect(r.b2b).toHaveLength(0)
expect(r.docs).toHaveLength(0)
})
})
describe('gstr1PortalJson — pragmatic GSTN-shaped export', () => {
it('produces valid, serialisable JSON with the seller GSTIN and section keys', () => {
const db = fresh()
buildMonth(db)
const j = gstr1PortalJson(db, 't1', PERIOD)
// Round-trips through JSON.stringify/parse (no NaN, no cycles) and carries the shape.
const round = JSON.parse(JSON.stringify(j)) as Record<string, unknown>
expect(round['gstin']).toBe('32ABCDE1234F1Z5')
expect(round['fp']).toBe('072026')
expect(Array.isArray(round['b2b'])).toBe(true)
expect((round['b2b'] as unknown[]).length).toBe(1)
expect(round['_note']).toMatch(/NOT a certified/i)
const hsn = round['hsn'] as { data: unknown[] }
expect(Array.isArray(hsn.data)).toBe(true)
})
})
// ---- HTTP surface: the REPORT_VIEW gate + period validation ----
interface Harness { db: DB; url: string; server: Server }
async function start(): Promise<Harness> {
const db = openDb(':memory:')
seedIfEmpty(db)
buildMonth(db)
const app = express()
app.use(express.json({ limit: '1mb' }))
app.use('/api', apiRouter(db))
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 get = (url: string, path: string, token?: string) =>
fetch(`${url}${path}`, { headers: { ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) } })
describe('GST HTTP routes — REPORT_VIEW gate + validation', () => {
let h: Harness
afterEach(() => { h.server.close() })
it('owner (REPORT_VIEW) reads the returns; a cashier is 403; unauth is 401; a bad period is 400', async () => {
h = await start()
// unauthenticated
expect((await get(h.url, `/api/gst/gstr1?period=${PERIOD}`)).status).toBe(401)
// a cashier lacks REPORT_VIEW → 403 E-6105
const cashier = await login(h.url, 'u1', '4728')
const denied = await get(h.url, `/api/gst/gstr1?period=${PERIOD}`, cashier)
expect(denied.status).toBe(403)
expect(((await denied.json()) as { code?: string }).code).toBe('E-6105')
// the owner reads it → 200 with the reconciled numbers
const owner = await login(h.url, 'u3', '9174')
const ok = await get(h.url, `/api/gst/gstr1?period=${PERIOD}`, owner)
expect(ok.status).toBe(200)
const body = (await ok.json()) as { reconciliation: { ok: boolean }; totals: { net: { taxablePaise: number } } }
expect(body.reconciliation.ok).toBe(true)
expect(body.totals.net.taxablePaise).toBe(62_578)
// 3B + hsn + json all 200 for the owner
expect((await get(h.url, `/api/gst/gstr3b?period=${PERIOD}`, owner)).status).toBe(200)
expect((await get(h.url, `/api/gst/hsn?period=${PERIOD}`, owner)).status).toBe(200)
const j = await get(h.url, `/api/gst/gstr1.json?period=${PERIOD}`, owner)
expect(j.status).toBe(200)
expect(((await j.json()) as { fp?: string }).fp).toBe('072026')
// a malformed period → 400 E-1330
const bad = await get(h.url, `/api/gst/gstr1?period=2026-13`, owner)
expect(bad.status).toBe(400)
expect(((await bad.json()) as { code?: string }).code).toBe('E-1330')
})
})

@ -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,203 @@
import { afterEach, describe, expect, it } from 'vitest'
import express from 'express'
import type { AddressInfo } from 'node:net'
import type { Server } from 'node:http'
import { hashPin } from '@sims/auth'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { commitBill } from '../src/repos'
import { commitReturn, listReturns, returnableLines } from '../src/repos-returns'
import { apiRouter } from '../src/api'
/**
* Returns / credit notes ride the money/commit trust boundary the red-team hardened (docs/18):
* everything anchors to OUR stored original bill, never the client. These tests lock the security
* properties: refund computed from the original (not client), tax reversed at the original rate,
* over-return blocked, cross-tenant/nonexistent original rejected, the doc no within the GST
* 16-char rule, and the POST /returns route gated by RETURN_CREATE.
*
* Hermetic in-memory DB; seed item 107 = Amul Butter (master 27500, GST12, whole unit); seed users
* u1 Ramesh/cashier (4728), u3 Thomas/owner (9174).
*/
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 onHand = (db: DB, code: string): number =>
Number((db.prepare(`SELECT COALESCE(SUM(qty_delta),0) n FROM stock_movement WHERE item_id=?`).get(itemId(db, code)) as { n: number }).n)
const returnCount = (db: DB): number =>
(db.prepare(`SELECT COUNT(*) n FROM bill WHERE doc_type='SALE_RETURN'`).get() as { n: number }).n
/** Sell 3 × Amul Butter at the master price (₹825.00 inclusive), return the committed bill id. */
function sell3(db: DB): { billId: string; payable: number } {
const line = {
itemId: itemId(db, '107'), name: 'Amul Butter 500g', hsn: '0405', qty: 3, unitCode: 'PCS',
unitPricePaise: 27_500, priceIncludesTax: true, taxClassCode: 'GST12',
}
const out = commitBill(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
lines: [line], payments: [{ mode: 'CASH', amountPaise: 82_500 }], clientPayablePaise: 82_500,
})
return { billId: out.id, payable: 82_500 }
}
const ret = (
billId: string, lines: { lineIndex: number; qty: number }[], over: Record<string, unknown> = {},
): Parameters<typeof commitReturn>[1] => ({
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
originalBillId: billId, lines, reason: 'Damaged', refundMode: 'CASH', ...over,
})
const caught = (fn: () => unknown): (Error & { status?: number; code?: string }) | undefined => {
try { fn(); return undefined } catch (e) { return e as Error & { status?: number; code?: string } }
}
describe('commitReturn — anchored to the original bill', () => {
it('commits a 2-of-3 return: credit-note doc no, stock back up by 2, refund 2/3 of the taxed line', () => {
const db = fresh()
const { billId } = sell3(db)
const before = onHand(db, '107')
const out = commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }]))
expect(out.docNo).toMatch(/^CNST1C22026-\d{4}$/) // CN-marked, 4-digit-FY (M9)
expect(out.docNo.length).toBeLessThanOrEqual(16) // GST 16-char rule
expect(out.refundPaise).toBe(55_000) // 2/3 of ₹825.00
expect(onHand(db, '107')).toBe(before + 2) // stock ADDED BACK (R7)
expect(returnCount(db)).toBe(1)
})
it('reverses GST at the ORIGINAL rate — the credit note carries CGST+SGST, not ₹0', () => {
const db = fresh()
const { billId } = sell3(db)
commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }]))
const b = db.prepare(`SELECT cgst_paise, sgst_paise, igst_paise, taxable_paise, payable_paise FROM bill WHERE doc_type='SALE_RETURN'`).get() as {
cgst_paise: number; sgst_paise: number; igst_paise: number; taxable_paise: number; payable_paise: number
}
expect(b.cgst_paise + b.sgst_paise).toBeGreaterThan(0)
expect(b.igst_paise).toBe(0)
expect(b.taxable_paise).toBeLessThan(b.payable_paise) // some of the refund is reversed tax
})
it('computes the refund itself — a client-inflated refund total is rejected 409 (E-1325)', () => {
const db = fresh()
const { billId } = sell3(db)
const err = caught(() => commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }], { clientRefundPaise: 100 })))
expect(err?.status).toBe(409)
expect(err?.code).toBe('E-1325')
expect(returnCount(db)).toBe(0) // nothing committed
})
it('blocks over-return: after returning 2 of 3, a second 2-unit return is rejected (E-1324); the last 1 is allowed', () => {
const db = fresh()
const { billId } = sell3(db)
commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }]))
const err = caught(() => commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }])))
expect(err?.status).toBe(400)
expect(err?.code).toBe('E-1324')
// exactly 1 remains returnable
const rl = returnableLines(db, 't1', billId)
expect(rl.lines[0]!.returnedQty).toBe(2)
expect(rl.lines[0]!.returnableQty).toBe(1)
const ok = commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 1 }]))
expect(ok.docNo).toMatch(/^CNST1C22026-\d{4}$/)
expect(returnableLines(db, 't1', billId).lines[0]!.returnableQty).toBe(0)
})
it('rejects a fractional return of a whole-unit line (E-1323)', () => {
const db = fresh()
const { billId } = sell3(db)
const err = caught(() => commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 1.5 }])))
expect(err?.status).toBe(400)
expect(err?.code).toBe('E-1323')
})
it('rejects a nonexistent / cross-tenant original bill (400 E-1320) and a non-sale reference', () => {
const db = fresh()
const { billId } = sell3(db)
expect(caught(() => commitReturn(db, ret('DOES-NOT-EXIST', [{ lineIndex: 0, qty: 1 }])))?.code).toBe('E-1320')
// A credit note is not a returnable SALE — returning against one is rejected.
const cn = commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 1 }]))
expect(caught(() => commitReturn(db, ret(cn.id, [{ lineIndex: 0, qty: 1 }])))?.code).toBe('E-1320')
})
it('records the credit note in listReturns with its against-bill and reason', () => {
const db = fresh()
const { billId } = sell3(db)
const out = commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 1 }], { reason: 'Wrong item' }))
const rows = listReturns(db, 't1')
expect(rows.length).toBe(1)
expect(rows[0]!['doc_no']).toBe(out.docNo)
const origDocNo = (db.prepare(`SELECT doc_no FROM bill WHERE id=?`).get(billId) as { doc_no: string }).doc_no
expect(rows[0]!['original_doc_no']).toBe(origDocNo)
expect(String(rows[0]!['payload'])).toContain('Wrong item')
})
})
// ---- HTTP surface: the RETURN_CREATE gate on POST /returns ----
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({ limit: '1mb' }))
app.use('/api', apiRouter(db))
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 postReturn = (url: string, token: string | undefined, body: unknown) =>
fetch(`${url}/api/returns`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
})
describe('POST /returns — RETURN_CREATE gate', () => {
let h: Harness
afterEach(() => { h.server.close() })
it('a cashier (RETURN_CREATE) commits; an accountant (no RETURN_CREATE) is 403; unauth is 401', async () => {
h = await start()
const { billId } = sell3(h.db)
const body = {
storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-10',
originalBillId: billId, lines: [{ lineIndex: 0, qty: 1 }], reason: 'Damaged', refundMode: 'CASH',
}
// unauthenticated → 401
expect((await postReturn(h.url, undefined, body)).status).toBe(401)
// an accountant lacks RETURN_CREATE → 403 E-6105
const p = hashPin('1111')
h.db.prepare(
`INSERT INTO app_user (id, tenant_id, username, display_name, roles, store_ids, language, pin_salt, pin_hash)
VALUES ('u9','t1','acct','Accountant','["accountant"]','"all"','en',?,?)`,
).run(p.salt, p.hash)
const acctToken = await login(h.url, 'u9', '1111')
const denied = await postReturn(h.url, acctToken, body)
expect(denied.status).toBe(403)
expect(((await denied.json()) as { code?: string }).code).toBe('E-6105')
// the cashier has RETURN_CREATE → 200 + a credit-note doc no
const cashier = await login(h.url, 'u1', '4728')
const ok = await postReturn(h.url, cashier, body)
expect(ok.status).toBe(200)
const res = (await ok.json()) as { docNo: string; refundPaise: number }
expect(res.docNo).toMatch(/^CNST1C22026-\d{4}$/)
})
})

@ -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)
})
})

@ -0,0 +1,259 @@
# 18 — Red-Team Security Review (2026-07-10)
Adversarial review of the SiMS Next build by five parallel reviewers, each reading the
code and firing proof-of-concept requests at the live dev server (`:5181`). Every finding
is labelled **CONFIRMED** (proven with a PoC) or **THEORETICAL**. Nothing was fixed —
this is assessment; remediation is the founder's call (see plan at the end).
## The one-sentence verdict
**The store-server authenticates requests but does not *authorize* them and does not
*validate or anchor* the business data in them** — so any logged-in cashier (the lowest
role) can, with `curl`, rewrite prices, evade GST, edit the item master, and forge the
audit trail. The React app enforces all these rules; the server trusts the client to have
done so. Nothing is shipped to a real store yet, so this is the right moment to find it.
**Why "the server re-verifies every bill to the paisa" (R5) gave false confidence:** it
proves the client's total matches the client's *own* line prices — internal consistency,
not authenticity against the item master. The supervisor gate we verified in the browser
is real in the UI and unenforced on the server.
## Remediation status — P0 CLOSED (2026-07-10)
The P0 workstreams below are **REMEDIATED**: the trust boundary now lives inside the
store-server (item-master anchoring, per-route authorization, input validation, bound
approval tokens). The dev DB was reset and every PoC re-fired against the fresh server;
each that used to succeed now fails closed. A real-browser E2E confirms the honest counter
flow still works. **P1 and P2 remain open** (see the plan at the end).
| PoC | Before (assessment) | After (2026-07-10) | Evidence |
|---|---|---|---|
| **C1** cashier → `POST /items` (edit master) | 200, item written | **403 E-6105** | authz middleware; a cashier may still `POST /items` a *draft* (200) — now with a **server-forced tax class + price cap + `DRAFT_SALE` audit** (see the 2026-07-13 follow-up below) |
| **C1** cashier → `GET /audit` | 200, full log | **403 E-6105** | `AUDIT_VIEW` gate |
| **C1** cashier → `GET /purchases/last-costs`, `/purchases` | 200, supplier costs | **403 E-6105** | `PURCHASE_ENTRY`/`REPORT_VIEW` gate |
| **C2** cashier bills a ₹275 item at ₹1 | committed, no audit | **403 E-1302** (deviation needs approval) | server anchors master price; nothing committed |
| **C3** 12% item sent as `taxClassCode:ZERO` | committed, ₹0 GST | **committed at GST12** (CGST₹14.73+SGST₹14.73 on the ₹275 bill) | tax class read from master, request ignored |
| **C4** cashier `POST /purchases` w/ `newSalePricePaise` | master price rewritten | **403 E-6105** (route denied); a `PURCHASE_ENTRY`-only role receives stock but the price change is ignored (needs `MASTER_EDIT`) | authz + `MASTER_EDIT` split |
| **H4** bound token for item A reattached to item B | audit falsely records the approver | **403 E-6108** (bound mismatch); same token used correctly on item A → 200 | token binds item/kind/before/after |
| **H5** `itemId:"DOES-NOT-EXIST"` | phantom bill + stock | **400 E-1105** | item verified vs (id, tenant) |
| **H6** `businessDate` `2050-07-10` / `not-a-date` | postdated bill / garbage series | **400 E-1106** | ISO + windowed validation |
| **H7** owner/supervisor self-authorised override | no audit row at all | **audited** from the session identity (`selfAuthorized:true`) | server auto-detects + audits every deviation |
| **M6** `/items?limit=-1` | whole table | **clamped** (≤ 1 row) | limit clamped `[1,MAX]`, offset ≥ 0 |
| **M7** wrong types | 500 + stack + install path | **400 `{error,code}`** generic, no stack | hand-rolled validators + 4-arg error handler |
| **M8** negative/mismatched payment legs, fractional qty | slipped through | **400** (leg > 0, allowlisted mode, sums to payable; unit-decimals enforced) | payment + qty guards |
| **M12** giant line count | unbounded | **≤ 500 lines** (400 over cap) | `assertArray` cap |
| **M14** `X-Powered-By: Express` banner | present | **removed** | `app.disable('x-powered-by')` |
Real-browser E2E (fresh DB, Chrome): Ramesh `4728` → normal Surf Excel bill `ST1C2/26-000004`
commits at ₹145; Ramesh F3 rate ₹145→₹140 → Suresh `8265`**bound-token** commit
`…000005`, audited `approvedBy=Suresh`; Thomas `9174` owner self-price-change → commit
`…000006`, audited `approvedBy=Thomas, selfAuthorized:true`. Tests 207 green; typecheck +
all three app builds clean.
## HIGH follow-up — cashier draft quick-add path CLOSED (2026-07-13)
The independent verifier found that the C1 remediation left one **HIGH** hole inside the
quick-add convenience it deliberately kept open (an unknown-barcode item must not stop the
queue): a cashier (`BILL_CREATE`, no `MASTER_EDIT`) could `POST /items` with `status:'draft'`,
choosing **an arbitrary `salePricePaise` AND an arbitrary `taxClassCode`**, then bill it.
Because `commitBill` anchors each line to the just-authored master, this **reproduced C2
(arbitrary price) and C3 (GST evasion — a taxable good self-added as `ZERO`, sold at ₹0 GST)
with no deviation/override audit row** — the only trace was `ITEM_CREATE`. Proven live: draft
"Redteam Whiskey" `ZERO` tax @ ₹2000 → billed with ₹0 GST.
**Remediated (2026-07-13)** — the quick-add convenience is kept, the fraud is removed:
| Vector | Fix | Evidence (re-fired live as cashier u1/4728, fresh DB `:5181`) |
|---|---|---|
| **C3 via draft** — self-add taxable good as `ZERO` | Server **forces** the tax class on a non-`MASTER_EDIT` caller's draft to the store default (`catalog.defaultTaxClass`, resolved system→tenant, default `GST18`); the client's `taxClassCode` is ignored. A `MASTER_EDIT` role (manager/owner) still sets it. | draft `ZERO`→server assigned **`GST18`**; billed the draft → **CGST+SGST = 30508 paise** (was ₹0) |
| **C2 via draft** — self-price an expensive unknown | A non-`MASTER_EDIT` draft's `salePricePaise` is **capped** (`catalog.draftMaxPricePaise`, default ₹5,000 = 500000); over-cap is denied with a clear message. A `MASTER_EDIT` role may exceed it. | draft @ ₹9,999 → **400 E-1108** "call a supervisor"; owner @ ₹9,999 → 200 |
| **Invisible draft sale** — self-authored item sold with no trail | Every bill line whose master row is still `status:'draft'` writes a **`DRAFT_SALE`** audit row (entity `bill`, `{itemId, name, pricePaise, docNo}`) **in the commit transaction**. Back office already shows draft status on Items. | selling the two drafts wrote **2 `DRAFT_SALE`** rows; an active-item sale writes none |
| **Cross-counter bill read** (residual LOW/MED) | `GET /bills` for a caller **without `REPORT_VIEW`** is pinned server-side to the session user id — a cashier sees only their **own** bills; a `REPORT_VIEW`/manager keeps the full view. | Ramesh → only `u1` rows; Divya → only `u4` rows; owner → all cashiers |
Regression re-check (same run): **C2** existing-item ₹275→₹1 no-approval → **403 E-1302**;
**C3** existing-item `ZERO`@master → committed **taxed at GST12** (CGST+SGST 2946 paise);
**C1** cashier `POST /items status:'active'`**403 E-6105**, cashier `GET /audit`**403**.
Legit quick-add still works end-to-end (unknown barcode → draft @ ₹30 → 2× bill `ST1C2/26-000004`
₹60). **220 tests green** (9 new: draft tax forced, over-cap E-1108, `DRAFT_SALE` audit,
cashier `/bills` scoped, owner exceptions); typecheck + all three app builds clean. Back office
must complete each draft's real HSN/tax class later (the forced default is a safe placeholder,
not a substitute for classification).
## P1 edge & transport hardening — CLOSED (2026-07-13)
The P1 "lock down the edges + TLS" workstream is **REMEDIATED**: **H1, M1, M2, M4, M13, M14**
are closed and **M15** is noted (closed once prod is HTTPS). Server changes stayed in
`server.ts` + `api.ts` plus three pure, unit-tested modules (`print-guard.ts`, `rate-limit.ts`,
`session-policy.ts`); client edits were limited to logout wiring + the POS post-login bootstrap
re-fetch. No new runtime dep except **helmet** (justified). All PoCs were re-fired live against
`:5181` (the HTTP dev instance, unchanged); the session-TTL and rate-limit PoCs ran against a
short-lived isolated instance (`:5182`, own data dir) so the dev DB and `:5181` stayed clean.
(M5 — tenant-scoping `commitBill`'s store/counter lookup — is owned by the money-logic
workstream and is NOT part of this pass.)
| # | Before | After (2026-07-13) | Evidence (live) |
|---|---|---|---|
| **H1** SSRF via `POST /api/print` | authed cashier opened a socket to ANY host:port + bytes (internal port-scan / egress) | destination constrained: **LAN-only IP** (127/8,10/8,172.16/12,192.168/16; link-local/public rejected), **port allowlist** {9100,9101,9102} (+`SIMS_PRINTER_PORTS`), **≤64 KB** payload; the host is resolved and every resolved IP checked, then we connect to the resolved IP (no DNS-rebind). Now an authenticated route inside `apiRouter` | authed → `8.8.8.8:80` **400 E-2202**, `8.8.8.8:9100` **400 E-2203** (no socket opened), `127.0.0.1:9100` **502 ECONNREFUSED** (guard passed → socket attempted); unauth → **401** |
| **M1** `/bootstrap` leaks roster roles + GSTIN pre-auth | the unauthenticated payload carried each user's **role** + the tenant **GSTIN** (targeting data) | the pre-auth payload **drops role + gstin** (keeps id+name for PIN login); the SAME endpoint restores both **when called WITH a session** — the POS re-fetches after login, back office is always authed | unauth `/api/bootstrap``store` has **no gstin**, users are `{id,name}` only (grep for `gstin`/`role`: none); authed → gstin `32ABCDE1234F1Z5` + roles present |
| **M2** verify-pin unauth PIN oracle / token mint | callable with NO session — a raw PIN-guessing + token-minting endpoint | now behind **requireAuth** (a logged-in caller) **and rate-limited**; the approver PIN still rides in the body | unauth `POST /auth/verify-pin`**401 E-6103**; authed cashier caller + Suresh/8265 → **200 + bound approvalId**; wrong approver PIN → still 401 |
| **M4** no session expiry / logout / revocation | a token was valid until the process restarted | sessions carry **created + last-seen**; `requireAuth` evicts past a **12 h absolute / 30 m idle** TTL (→ 401 E-6110); **`POST /api/auth/logout`** deletes the session (POS "Lock" + back-office "Logout" call it); **global + per-IP `/auth/*` rate limit** (20/min/IP, 200/min global, env-tunable) layered on the per-user lockout | logout: `/items` 200 → logout 200 → `/items` **401**; idle (1 s-TTL instance): after 1.4 s → **401 E-6110**; rate limit (cap 3): attempts 1-3 = 401, **4-5 = 429** |
| **M13** plaintext HTTP | bearer token rides the LAN in cleartext | **HTTPS available**: `SIMS_TLS_CERT`+`SIMS_TLS_KEY` set ⇒ `https.createServer`; unset ⇒ HTTP dev (unchanged). Dev-cert one-liner + "prod MUST supply certs" documented in `server.ts`. HSTS + `upgrade-insecure-requests` emitted **only** under TLS (so dev HTTP is never told to upgrade to a port that serves no HTTPS) | dev `:5181` stays HTTP (verification/browser flow intact); the TLS path is code-reviewed, not forced on dev |
| **M14** no security headers / `X-Powered-By` | no CSP, no nosniff/frame/referrer headers | **helmet** on with a CSP tuned for the SPAs (`'self'` scripts + `'unsafe-inline'` styles for React inline styles + `blob:`/`data:` for the invoice/label tabs + `data:` fonts; the blob tabs' only inline script — `window.print()` — is allowed by a single **`'unsafe-hashes'` sha256**, NOT by opening script-src to `'unsafe-inline'`); **`X-Frame-Options: DENY`** (helmet only ships SAMEORIGIN) + `frame-ancestors 'none'`; nosniff; `Referrer-Policy: no-referrer`; `x-powered-by` already gone | `curl -i /api/health` shows the full header set, no `X-Powered-By`, no HSTS on HTTP; **back office + POS both load in a real browser with ZERO CSP console violations**, and the `window.print()` inline handler is not blocked |
| **M15** SW cache-first over HTTP → MITM poisoning | THEORETICAL, needs LAN MITM | **noted** — closes automatically once prod serves HTTPS (M13); no code change needed for the dev slice | n/a (documentation) |
**Tests:** two new files — `edge-guards.test.ts` (pure: LAN/port/byte print validator,
sliding-window rate limiter, session-TTL check) and `edge.test.ts` (HTTP against a mounted
apiRouter: bootstrap minimize, verify-pin auth, TTL eviction, logout, rate-limit 429, print
allowlist). **245 tests green** (was 220), typecheck clean on root + all three apps, all three
apps build, store-server rebuilt + restarted on `:5181` (`/api/health` ok). Browser check: back
office login + POS login screens render with **no CSP violations**; the POS login tiles now show
**no role subtitle** (the pre-auth roster no longer carries roles — the expected M1 effect).
**Residue:** the live PoCs added one benign, unused `override_approval` row to the `:5181` dev DB
(a verify-pin mint); no bills/items/stock were written. No git commits.
## P2 data-integrity & crypto — CLOSED (2026-07-13)
The P2 "harden-before-cloud" data-integrity slice is **REMEDIATED**: **H2, M3, M5, M9, M10** are
closed (M11 client-PII and M14 helmet closed earlier). Only **H3** (encryption at rest) remains,
deliberately owned by a separate agent next. Changes were confined to `apps/store-server/src/{db.ts,
repos.ts, api.ts, server.ts}`, `packages/auth/src/pin.ts`, `packages/domain/src/doc-series.ts`, plus
`dev-start.mjs` + `.env.example`; the transport/helmet/TLS and `requireAuth`/rate-limit/session code
from the P1 pass was left intact (only `writeAudit` and the commit/series paths were touched).
| # | What changed | Evidence (live on the reset `:5181`, `SIMS_SEED_DEMO=1`) |
|---|---|---|
| **H2** audit hash chain | `audit_log` gained `prev_hash`+`entry_hash` (CREATE TABLE + PRAGMA-guarded migrate). `writeAudit` links each row into the tenant's chain **inside the existing commit transactions** (`entry_hash = sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json))`, genesis `''`, insertion order = SQLite rowid under the single-writer model). `verifyAuditChain(db, tenant)` recomputes and returns `{ok, brokenAtId?}`; `GET /api/audit/verify` (AUDIT_VIEW) exposes it. **Tamper-EVIDENCE, not prevention** — the file is still writable (H3). | Owner (thomas/9174) `GET /api/audit/verify` after a real bill → **`{ok:true}`**; then a direct better-sqlite3 `UPDATE audit_log SET after_json=…` on one row → verify → **`{ok:false, brokenAtId:<that row>}`** (brokenAtId matched the tampered row exactly). |
| **M3** PIN pepper | `pin.ts` HMAC-SHA256s the secret with `SIMS_PIN_PEPPER` before scrypt in both `hashPin`/`verifyPin`; unset ⇒ raw-secret fallback (byte-identical to pre-M3) + one-time warning. `.env.example` documents it; enabling requires PIN re-enrolment. | Unit tests: round-trip with a pepper; a peppered hash **fails** to verify once the pepper is gone (a DB-only theft is uncrackable) and again with a **different** pepper (rotation invalidates); unset path still verifies. Dev logins on `:5181` unchanged (pepper unset). |
| **M5** tenant-scope commit | store AND counter selected `WHERE id=? AND tenant_id=?`; unknown-for-tenant → **400 E-1110** before any money logic (kills the undefined-deref 500). Both `/bills` and `/bills/offline` covered. | `POST /api/bills` with `storeId:"no-such-store"`**`400 {"code":"E-1110"}`** (not 500). Unit: a store/counter belonging to another tenant is rejected; honest same-tenant commit still bills. |
| **M9** 4-digit FY | `seriesPrefix` embeds the 4-digit FY start year; the counter↔FY `/` is dropped so the doc no fits the GST 16-char rule at 6-digit sequences (`ST1C22026-000123`). Old `ST1C2/26-…` collided a century apart. | Fresh bill on `:5181` → doc no **`ST1C22026-000001`** (length **16**, matches `^ST1C22026-\d{6}$`). Unit: `seriesPrefix('ST1','C2','2026-27')`≠`…'2126-27'`, both ≤16 at seq 999999. |
| **M10** demo-seed gate | `seedIfEmpty`/`ensureBatchTrackingSeed` run only when `SIMS_SEED_DEMO=1`; dev `npm start``dev-start.mjs` sets it. | Isolated instance, flag **unset** → healthy but **0 tenants**, thomas login **401**. `:5181` with the flag set → seeds MegaMart + 4 users as before. |
**Tests:** **260 green** (was 245): new `audit-chain.test.ts` (5: chain continuity + UPDATE/DELETE/INSERT
detection + per-tenant scope), `tenant-scope.test.ts` (4: nonexistent/cross-tenant store/counter reject,
honest commit), a `/audit/verify` HTTP case in `edge.test.ts`, 4 PIN-pepper cases in `auth.test.ts`, and
a 4-digit-FY collision + 16-char case in `domain.test.ts`. Typecheck clean on root + all three apps; all
three apps build; the dev DB was reset and the store-server restarted on `:5181` with `SIMS_SEED_DEMO=1`
(`/api/health` ok, seeded, `audit/verify` ok on the clean chain). **H3 (encryption at rest) is the only
open P2 item.** No git commits.
## H3 — encryption at rest — CLOSED (2026-07-13)
The last open finding is **REMEDIATED**: the store DB is now **full-DB encrypted at rest with
SQLCipher**, which closes H3, the **F5 at-rest concern**, and the **M11 residual** (the offline
queue's lines/amounts sitting in plaintext until drained — the server tier they drain into is now
encrypted). `better-sqlite3` was swapped for the API-compatible drop-in
**`better-sqlite3-multiple-ciphers`** (same API + `pragma('key=…')`; it ships its own
DefinitelyTyped-derived declarations, so `DB` and every call type unchanged — **no shim, no loosened
strictness**). The change was confined to `apps/store-server/src/db.ts`, `package.json`,
`build-server.mjs` and `.env.example`; none of the earlier hardening (pepper, hash chain,
tenant-scope, seed gate, TLS/helmet, print allowlist) was touched.
**Path taken: full-DB encryption.** The native package installed from a **prebuilt binary** (no
node-gyp build) on this Windows / Node 22 box, so the app-level field-encryption fallback was **not**
needed.
| Vector | Fix | Evidence (live on the reset, keyed `:5181`) |
|---|---|---|
| **Whole file readable if copied** — a stolen/snooped `sims.db` (+ WAL) yielded phones/GSTINs (DPDP), costs & margins, full sales history, party ledgers, PIN salts/hashes | In `openDb()`, when **`SIMS_DB_KEY`** is set the DB is keyed (`cipher='sqlcipher'`, `key=…`) **before any read**, so every page — main db **and** the WAL/SHM sidecars — is ciphertext. The key lives ONLY in the server env (a machine-scoped DPAPI/keychain secret), never in the repo or the DB; single quotes in it are escaped so an arbitrary passphrase is a safe SQL literal. | `head -c 16 data/sims.db``28 85 83 94 c9 b5 4f 93 …` (random), **not** `53 51 4c 69 …` ("SQLite format 3\0"). Grep of `sims.db` / `-wal` / `-shm` for `MegaMart`, `Lakshmi`, the phone `9840012345`, and the tenant GSTIN `32ABCDE1234F1Z5`**none found** in cleartext. |
| **A stolen copy must be unreadable without the key** | Wrong/absent key fails closed | Opening the live `data/sims.db` **without** the key → `file is not a database`; with a **wrong** key → `file is not a database`; with the **correct** key → opens (tenant count 1, parties `["Lakshmi","Joseph",…]` decrypt). |
| **The app must still fully work through the cipher** | The keyed DB is transparent to the app | Cashier Ramesh (u1/4728) PIN login **200**; `GET /items?all=1` **200** (11 items decrypt); `POST /bills` **200 → `ST1C22026-000001`** (payable ₹5.00); owner Thomas (u3/9174) `GET /audit/verify`**`{ok:true}`** on a chain written through the encrypted DB. `/api/health` ok. |
| **Dev fallback unchanged** | Key UNSET ⇒ open unencrypted exactly as before + a one-time warning that prod MUST set it (mirrors the `SIMS_PIN_PEPPER` pattern) | Unit test: key unset ⇒ file **is** plaintext (`SQLite format 3\0`) and the phone **is** present in cleartext — a positive control proving the byte-scan genuinely detects plaintext. The one-time boot warning fires only when unset; with the dev key set on `:5181` it does not. |
**Tests:** **265 green** (was 260; +5 in new `encryption.test.ts`: a keyed file has no SQLite magic +
no cleartext PII across db/WAL, the keyed data round-trips, opening without / with the wrong key throws,
and the plaintext-fallback positive control). Typecheck clean on root + store-server; all three apps
build (esbuild externals the ciphers native module; vite unaffected). The dev DB was reset (`data/`
deleted — it held red-team junk + a deliberately-tampered audit row) and the store-server restarted on
`:5181` **with a dev `SIMS_DB_KEY`**, so encryption is live and proven end-to-end while the `:5181` flow
still works.
**A production deploy still MUST:** (1) supply `SIMS_DB_KEY` as a **machine-scoped secret** (Windows
DPAPI or an OS keychain) — never a plaintext file beside the DB, never in the repo; (2) **migrate any
existing plaintext `sims.db` once, offline** (`ATTACH … KEY …; SELECT sqlcipher_export('enc'); DETACH`
— documented in `openDb()` and `.env.example`) before the first encrypted boot; a fresh/empty DB is
created encrypted automatically; (3) treat key **rotation** as a re-key (`PRAGMA rekey`) or an
export/reimport, and back up the key **with** the DB (an unrecoverable key = an unrecoverable DB); (4)
still tighten the data-dir ACLs as defence-in-depth. No git commits.
## Clean bill of health (verified defended — do not "fix")
| Area | Verdict |
|---|---|
| SQL injection | None — every query is a bound better-sqlite3 prepared statement; `DROP TABLE` payloads inert |
| XSS / HTML injection | None — every invoice/label/receipt sink escapes; React auto-escaping intact; zero `dangerouslySetInnerHTML` |
| Dependency supply chain | `npm audit` 0 vulnerabilities across 542 deps; lockfile committed |
| CSRF / CORS | Not vulnerable — bearer-token-in-header design (not cookies) defends both |
| doc-series under load | Crash-safe & gap-free by transaction design (genuine strength, S7-proven) |
| Approval token entropy/TTL/single-use | Correct — 5-min TTL + atomic single-use enforced server-side |
| Engine numeric guards | Rejects negative/zero/NaN qty, negative price, >100% discount, payment mismatch, >1 MB body |
## Findings — ranked
### CRITICAL — exploitable now by any cashier session
| # | Finding | Evidence | Fix |
|---|---------|----------|-----|
| C1 ✅ **REMEDIATED** | **No server-side authorization.** RBAC (`permissions.ts` `can()`/`gateFor()`) is called only in client code; `api.ts` never imports it. Every route past `requireAuth` checks only that a token exists. | Cashier token created items, read full `/audit`, dumped supplier costs, listed all counters' bills — all 200. `api.ts:138` is the lone gate. | Per-route authorization middleware mapping route→`ActionCode`, `can(session, action)` server-side, deny by default. |
| C2 ✅ **REMEDIATED** | **Bill line prices trusted from client; the override/discount gate is client-only and `curl`-bypassable with no audit.** `commitBill` recomputes from `input.lines` and never reads `item.sale_price_paise`/`mrp`. | Cashier sold a ₹270 item at ₹1 — committed, no override, no PIN, no audit row (`repos.ts:223`, `compute.ts:41-61`). | Load each line's master price; reject any deviation unless a bound override token is redeemed; hard-block price > MRP. |
| C3 ✅ **REMEDIATED** | **Client-chosen tax class → GST evasion.** `taxClassCode` comes from the request; `resolveTaxClass` trusts it. | A 12% item sent as `ZERO` committed with ₹0 GST (`compute.ts:81`). Scales to the whole ledger. | Resolve tax class from the item master, never the request. |
| C4 ✅ **REMEDIATED** | **Purchase posting rewrites master price/MRP with no role check.** | Cashier posted a purchase with `newSalePricePaise:99999`; the item master changed (`api.ts` purchase route → `repos-purchase.ts:99-106`). | Require `PURCHASE_ENTRY`; separate "receive stock" from "change master price". |
### HIGH
| # | Finding | Evidence | Fix |
|---|---------|----------|-----|
| H1 ✅ **REMEDIATED** (2026-07-13) | **Authenticated SSRF via `POST /api/print`** — arbitrary host:port + bytes; clean internal port-scan oracle. (Correctly auth-gated: unauth → 401.) | Cashier token opened sockets to chosen host:port; closed→502, open→504 (`server.ts:37-58`). | **DONE**: LAN-only IP + port allowlist ({9100,9101,9102}, `SIMS_PRINTER_PORTS`) + ≤64 KB cap; host resolved and each resolved IP LAN-checked, connect to the resolved IP (no rebind). `print-guard.ts` + the `/print` route in `api.ts`. See the P1 section. |
| H2 ✅ **REMEDIATED** (2026-07-13) | **Audit log & "immutable" bills are tamper-evident by convention only** — no hash chain, no signing; plain SQLite file. | Any file-level `UPDATE`/`DELETE` rewrites bills, deletes override evidence, forges approvals (`repos.ts:431-443`, schema). Undercuts the GST/MCA audit-trail claim. | **DONE**: per-tenant hash chain on `audit_log` (`prev_hash`/`entry_hash`; `entry_hash = sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json))`), written in-transaction by `writeAudit`; `verifyAuditChain` + `GET /api/audit/verify` (AUDIT_VIEW) recompute it. Any out-of-band UPDATE/DELETE/INSERT breaks the chain visibly. Tamper-EVIDENCE, not prevention — sealing the writable file is H3. See the P2 section. |
| H3 ✅ **REMEDIATED** (2026-07-13) | **No encryption at rest** — PIN hashes, phone numbers (DPDP), GSTINs, costs, full sales history in a plain `sims.db` (+ WAL). | A copied file yields the whole business + every staff PIN hash (`db.ts` `new Database(file)`). | **DONE**: full-DB **SQLCipher** via the drop-in `better-sqlite3-multiple-ciphers`; `openDb()` keys the DB from **`SIMS_DB_KEY`** (env-only, machine-scoped) before any read, so `sims.db` + WAL/SHM are ciphertext; key unset ⇒ plaintext dev fallback + one-time warning. Proven live: header not `SQLite format 3`, no cleartext PII in db/WAL, open-without-key fails, app (login+bill+audit/verify) works with the key. Prod must supply the key via DPAPI/keychain + migrate an existing plaintext DB once (`sqlcipher_export`). See the H3 section. |
| H4 ✅ **REMEDIATED** | **Approval token not bound to the override it authorizes** — redirectable. Token stores only approver+time+used, not item/kind/amount/cashier. | A real "Milk ₹50→45" approval was reattached to "Whiskey ₹2000→200"; audit falsely records the supervisor (`repos.ts:184-190, 303-337`). Audited before/after are client-supplied too. | Bind item/kind/before/after (+cashier) into the token at mint; re-verify at redeem. |
| H5 ✅ **REMEDIATED** | **Item never validated → phantom / cross-tenant lines.** No `SELECT item`, no FK on `stock_movement.item_id`. | `itemId:"DOES-NOT-EXIST"` committed a bill + phantom stock movement (`repos.ts:192-343`). | Verify `itemId` belongs to tenant+store; add FK. |
| H6 ✅ **REMEDIATED** | **Unvalidated `businessDate` → antedated/postdated GST invoices + garbage series.** | `2050-07-10` postdated a bill; `"not-a-date"` produced doc_no `ST1C2/N--000001` (`repos.ts:239`). | Validate ISO date, reject NaN FY, bound to the open business day. |
| H7 ✅ **REMEDIATED** | **Self-authorized overrides (owner/supervisor at POS) are never audited** end-to-end. | `gateFor==='allow'` applies the edit with no override entry, so nothing reaches the server or the log (`BillingScreen.tsx:431`). The least-supervised roles leave no trail. | Emit an audited (server-recomputed) override for every price/discount change, including self-authorized. |
### MEDIUM
| # | Finding | Fix |
|---|---------|-----|
| M1 ✅ **REMEDIATED** (2026-07-13) | Unauthenticated `/api/bootstrap` leaks the full user roster (ids, names, roles, who has a PIN) + store GSTIN → targeted PIN attack. | **DONE**: pre-auth payload drops **role + gstin** (keeps id+name for PIN login); both restored on the SAME endpoint when called WITH a session (POS re-fetches post-login). See the P1 section. |
| M2 ✅ **REMEDIATED** (2026-07-13) | `/auth/verify-pin` is an **unauthenticated PIN oracle** that also mints approval tokens and writes DB rows on each call. | **DONE**: now behind `requireAuth` (a logged-in caller) + the `/auth/*` rate limiter; approver PIN still in the body. |
| M3 ✅ **REMEDIATED** (2026-07-13, pepper) | **Weak PIN throttle** — in-memory, per-user, no global/IP limit, resets on restart; 4-digit space; scrypt cost immaterial vs the tiny keyspace; no server-side pepper. | **DONE (pepper)**: `pin.ts` HMAC-SHA256s the secret with a server-held pepper (`SIMS_PIN_PEPPER`) BEFORE scrypt, in both `hashPin`/`verifyPin`, so a stolen DB can't be brute-forced against the 10k PIN keyspace without the env-only pepper. Unset ⇒ raw-secret fallback (dev unchanged) + one-time warning. `.env.example` added; enabling requires re-enrolling PINs (dev re-seed does it). The IP/global `/auth/*` limiter already landed in M2/M4; longer approver PINs deferred (policy). See the P2 section. |
| M4 ✅ **REMEDIATED** (2026-07-13) | **No session expiry, logout, or revocation** — a leaked token is valid until the process restarts; a fired employee can't be cut off. | **DONE**: 12 h absolute + 30 m idle TTL evicted in `requireAuth` (→ 401 E-6110); `POST /api/auth/logout` deletes the session (POS Lock + BO Logout wired); global + per-IP `/auth/*` rate limit (`rate-limit.ts`, `session-policy.ts`). |
| M5 ✅ **REMEDIATED** (2026-07-13) | **`commitBill` store/counter lookup not tenant-scoped** → cross-tenant reference (latent, single-tenant dev DB) + a bad `storeId` 500s the handler. | **DONE**: `commitBill` selects store AND counter `WHERE id=? AND tenant_id=?` and throws **400 E-1110** ("Unknown store or counter for this tenant") BEFORE any money logic — killing the old undefined-deref 500 and any cross-tenant reference. Both `/bills` and `/bills/offline` funnel through it. (E-1110, not the task-suggested E-1109, because E-1109 is already the payments-≠-payable code.) See the P2 section. |
| M6 ✅ | **Pagination bypass**`limit=-1` / huge / NaN returns whole tables → unbounded stall/OOM on `/items`,`/bills`,`/audit`. | Clamp limit to `[1,MAX]`, offset ≥ 0, reject NaN. |
| M7 ✅ | **No request schema validation**`as` casts; wrong types 500 with **full stack traces + absolute install paths**. | zod/valibot per route + a generic error handler that never leaks the stack; `NODE_ENV=production`. |
| M8 ✅ | Numeric edges slip through: fractional qty on integer units, qty > 2^53 (precision loss / ledger corruption), negative payment legs when the sum still matches. | Enforce unit-decimals; clamp qty/total < 2^53; require every payment leg > 0 with an allowlisted mode. |
| M9 ✅ **REMEDIATED** (2026-07-13) | **doc-series `fyShort` 2-digit truncation** → FYs 100 years apart collide, forcing `UNIQUE` violations that can block billing on a prefix. | **DONE**: `seriesPrefix` now embeds the full **4-digit FY start year** ("2026" vs "2126" no longer collide). To keep the GST 16-char rule at 6-digit sequences the counter↔FY `/` is dropped (the sequence `-` stays): `ST1C22026-000123` (16 chars). doc_no format changes → dev reseed handles it; prod starts the new format at cutover. Caveat: store+counter codes must total ≤ 5 chars (the same tightness the old format had at C10). See the P2 section. |
| M10 ✅ **REMEDIATED** (2026-07-13) | **Hardcoded demo owner credentials** (`thomas`/PIN 9174/pw `sims`) auto-seed on an empty DB. | **DONE**: `seedIfEmpty`/`ensureBatchTrackingSeed` run ONLY when `SIMS_SEED_DEMO=1`; a prod empty DB never auto-creates the known-credential owner (stays empty for onboarding). Dev keeps seeding via `npm start``dev-start.mjs` (sets the flag). Proven live: flag unset ⇒ 0 tenants, thomas login 401. See the P2 section. |
| M11 ✅ **REMEDIATED** (client PII) | **Unencrypted customer PII in the offline IndexedDB** (names, GSTINs, line items) on shared counter PCs. | POS client fixed: the queued bill now persists **only `customerId`** — no `customerName`/`buyerGstin` — and the server re-resolves the buyer's name/GSTIN/place-of-supply from that id at drain, exactly as for an online bill (`repos.ts:296-298`). Catalog + staff-roster cache is wiped on Lock/logout (`clearCatalogCache`), while un-drained queued bills are preserved. Verified in a real browser IndexedDB (queued record carries `customerId` only; injected PII dropped; cache cleared on lock; queue survived). Files: `apps/pos/src/offline.ts`, `App.tsx` `onLock`, `BillingScreen.tsx` `queueOffline`; test `apps/pos/test/offline.test.ts`. **Residual (now closed):** the queued lines/amounts sat in a plain IndexedDB until drained; the server tier they drain into is now SQLCipher-encrypted at rest (H3, closed 2026-07-13), so the standing at-rest exposure is removed end-to-end. |
| M12 ✅ (line cap) | Single-threaded synchronous server: one 5,200-line bill blocked `/api/health` 155 ms; no per-bill line cap. | Cap `lines.length`; clamp result sizes (M6). |
| M13 ✅ **REMEDIATED** (2026-07-13) | **Plaintext HTTP** — bearer token rides the LAN cleartext; a sniffer unlocks H1 + all authed reads. *The single highest-leverage fix.* | **DONE (available, not forced on dev)**: `SIMS_TLS_CERT`+`SIMS_TLS_KEY` ⇒ HTTPS (`https.createServer`); unset ⇒ HTTP dev. Dev-cert one-liner + "prod MUST supply certs" in `server.ts`. Prod deploy must set the certs. |
| M14 ✅ **REMEDIATED** (2026-07-13) | No `helmet` / security headers; `X-Powered-By: Express` banner. | **DONE**: `helmet` on (SPA-compatible CSP; nosniff; `Referrer-Policy: no-referrer`) + explicit `X-Frame-Options: DENY` + `frame-ancestors 'none'`; `x-powered-by` already disabled (P0). Both apps load with zero CSP violations. |
| M15 ✅ **NOTED** (2026-07-13) | Service-worker cache-first over HTTP → MITM-injected bundle cached persistently (THEORETICAL; needs LAN MITM). | Closed once prod is HTTPS (M13). No dev-slice code change. |
### LOW
- Bearer tokens + purchase-entry draft in web storage (readable by same-origin JS; not exploitable today since XSS is refuted). Move session to an `httpOnly` cookie eventually.
## Root cause & remediation shape
Almost every Critical/High traces to **one architectural fact**: the store-server was
built as a trusted-client data layer (correct while the only client was our own verified
React app in the browser) but it exposes a REST API that trusts the client for **identity,
business data, and input shape**. The fix is one coherent theme, not 30 scattered patches:
**Move the trust boundary into the server.**
| Priority | Workstream | Closes |
|---|---|---|
| P0 ✅ **DONE** | **Anchor every bill line to the item master** (price, tax class, MRP, existence, unit) inside `commitBill`; a deviation requires a redeemed, *bound* override token | C2, C3, H4, H5, H7, M8 |
| P0 ✅ **DONE** | **Server-side authorization**: per-route `ActionCode` middleware, deny-by-default | C1, C4 |
| P0 ✅ **DONE** | **Input validation layer** (hand-rolled guards, no new deps) + generic error handler; clamp limit/offset; validate businessDate; cap lines | H6, M6, M7, M8, M12 |
| P1 ✅ **DONE** | Lock down the edges: `/api/print` allowlist, `/bootstrap` minimize, `/auth/verify-pin` behind auth + rate limit, session TTL/logout, tenant-scope store/counter (M5 closed with the P2 pass) | H1, M1, M2, M4, M5 |
| P1 ✅ **DONE** | **TLS** on the store LAN (available via env; prod must supply certs) | M13, M15, amplifies all |
| P2 ✅ **DONE** (except H3) | Harden-before-cloud (D14): PIN pepper, audit hash chain, demo-seed gate, 4-digit-FY series, tenant-scope commit — plus offline-cache PII (M11) + helmet (M14) already closed | H2, M3, M5, M9, M10, M11, M14 |
| P2 ✅ **DONE** (2026-07-13) | **Encryption at rest** — full-DB SQLCipher via `better-sqlite3-multiple-ciphers`, keyed from a machine-scoped `SIMS_DB_KEY`; prod must supply the key (DPAPI/keychain) + tighten data-dir ACLs | H3 |
## Reviewer note on test artifacts
The PoCs left benign junk in the **dev** DB (test bills `000017``000023`, two garbage-FY
bills, a corrupted Carry Bag stock balance, a self-clearing u4 lockout). Reset the dev DB
(delete `apps/store-server/data/`, restart) before any demo. No source was modified.

28
package-lock.json generated

@ -2264,8 +2264,9 @@
"@sims/auth": "*",
"@sims/billing-engine": "*",
"@sims/domain": "*",
"better-sqlite3": "^11.7.0",
"express": "^4.21.0"
"better-sqlite3-multiple-ciphers": "^11.10.0",
"express": "^4.21.0",
"helmet": "^8.3.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
@ -4663,6 +4664,17 @@
"prebuild-install": "^7.1.1"
}
},
"node_modules/better-sqlite3-multiple-ciphers": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3-multiple-ciphers/-/better-sqlite3-multiple-ciphers-11.10.0.tgz",
"integrity": "sha512-/dKO3lKuJFbmuzh80uN2cmMsz8iyTskGB2l/fd9X6rt1P3EPIOvRUIxD7Qim8gLygUPB/u+db8byZGumOOdp3g==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@ -5788,6 +5800,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",

@ -1,4 +1,4 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
/**
* POS login is name-tile + PIN (09-UX §1: PIN-fast shift open); back office is
@ -9,6 +9,33 @@ export interface PinHash {
hash: string // hex
}
/**
* M3 (red-team): a server-held pepper, HMAC-SHA256'd into the secret BEFORE scrypt. A stolen DB
* gives an attacker the per-credential salts and hashes, but a 4-digit PIN's keyspace is only
* 10,000 trivially brute-forced with the salt, no matter how costly scrypt is. The pepper lives
* only in the server's environment (`SIMS_PIN_PEPPER`), NEVER in the DB, so a DB-only theft leaves
* the attacker unable to compute a single candidate hash: the PIN is peppered under a key they do
* not have. When the env is unset we hash the raw secret exactly as before (dev logins keep
* working) and warn ONCE that production MUST set it. Enabling/rotating the pepper invalidates
* every existing hash PINs must be re-enrolled (a dev re-seed does this since seed calls hashPin).
*/
let pepperWarned = false
function peppered(secret: string): string | Buffer {
const pepper = process.env['SIMS_PIN_PEPPER']
if (pepper === undefined || pepper === '') {
if (!pepperWarned) {
pepperWarned = true
// eslint-disable-next-line no-console
console.warn(
'[auth] SIMS_PIN_PEPPER is not set — PIN/password hashes are un-peppered. A stolen DB could '
+ 'be brute-forced against the tiny PIN keyspace. Production MUST set a server-held pepper.',
)
}
return secret // byte-identical to the pre-pepper scrypt input
}
return createHmac('sha256', pepper).update(secret, 'utf8').digest()
}
export function assertUsablePin(pin: string): void {
if (!/^\d{4,6}$/.test(pin)) throw new Error('PIN must be 46 digits')
if (/^(\d)\1+$/.test(pin)) throw new Error('PIN must not be a single repeated digit')
@ -19,11 +46,11 @@ export function assertUsablePin(pin: string): void {
export function hashPin(pin: string): PinHash {
const salt = randomBytes(16)
const hash = scryptSync(pin, salt, 32)
const hash = scryptSync(peppered(pin), salt, 32)
return { salt: salt.toString('hex'), hash: hash.toString('hex') }
}
export function verifyPin(pin: string, stored: PinHash): boolean {
const candidate = scryptSync(pin, Buffer.from(stored.salt, 'hex'), 32)
const candidate = scryptSync(peppered(pin), Buffer.from(stored.salt, 'hex'), 32)
return timingSafeEqual(candidate, Buffer.from(stored.hash, 'hex'))
}

@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertUsablePin, hashPin, verifyPin,
FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, DEFAULT_LOCKOUT,
@ -23,6 +23,43 @@ describe('PIN policy & hashing', () => {
})
})
describe('PIN pepper (M3) — HMAC before scrypt', () => {
const KEY = 'SIMS_PIN_PEPPER'
afterEach(() => { delete process.env[KEY] })
it('round-trips with a server-held pepper set', () => {
process.env[KEY] = 'a-long-random-server-pepper'
const stored = hashPin('4728')
expect(verifyPin('4728', stored)).toBe(true)
expect(verifyPin('4729', stored)).toBe(false)
})
it('a peppered hash cannot be verified once the pepper is gone (a DB-only theft is uncrackable)', () => {
process.env[KEY] = 'secret-pepper'
const stored = hashPin('4728')
delete process.env[KEY]
expect(verifyPin('4728', stored)).toBe(false) // no pepper in hand → even the right PIN fails
process.env[KEY] = 'secret-pepper'
expect(verifyPin('4728', stored)).toBe(true) // restored pepper verifies again
})
it('a different pepper does not verify (rotation invalidates old hashes)', () => {
process.env[KEY] = 'pepper-A'
const stored = hashPin('4728')
process.env[KEY] = 'pepper-B'
expect(verifyPin('4728', stored)).toBe(false)
})
it('unset pepper is byte-identical to a hash of the raw PIN (dev fallback unchanged)', () => {
// With no pepper, the scrypt input is the raw PIN, so a hash made now must verify — proving
// the un-peppered path is exactly the pre-M3 behaviour dev logins depend on.
delete process.env[KEY]
const stored = hashPin('4728')
expect(verifyPin('4728', stored)).toBe(true)
expect(verifyPin('9999', stored)).toBe(false)
})
})
describe('lockout reducer', () => {
it('locks after max failures and unlocks after the window', () => {
let s = FRESH_LOCKOUT

@ -0,0 +1,478 @@
import type { Paise } from '@sims/domain'
/**
* The GST-returns engine pure functions that fold a period's IMMUTABLE outward documents
* (sales + their SALE_RETURN credit notes) into the sections of GSTR-1 and the 3.1(a) block of
* GSTR-3B. It is the single, shared place GST-return math lives (R5/GST-3): the server reads it,
* the back office renders it, and the reconciliation identity below is provable in tests.
*
* COMPLIANCE ANCHORS (docs/10 GST lens):
* - Never recompute tax. Every amount is read straight off the stored bill/credit-note line
* (taxable, CGST/SGST/IGST, cess, rate) EXACTLY AS CAPTURED. A return therefore reduces the
* liability at the ORIGINAL bill-date rate even after a slab change (GST-1/GST-2/GST-10).
* - Integer paise throughout (R4); the only arithmetic is addition of already-rounded amounts,
* so no new rounding is introduced and books == JSON == 3B to the paise (GST-3).
* - Reconciliation is a first-class output: Σ(section taxable, returns negative) ==
* Σ(source taxable) == HSN total == GSTR-3B outward taxable. See `reconcile`.
*
* SCOPE (this increment): B2B, B2CL, B2CS, CDNR, CDNUR, HSN summary, docs-issued, and 3.1(a).
* Nil-rated / exempt / non-GST split tables (GST-4), ITC / 2A-2B (GالسTR-3B tables 4/inward),
* amendments (B2BA/CDNRA) and the filing-freeze / sync-completeness gate (GST-11) are DEFERRED
* see STATUS. Zero-rated (0%) outward supply appears here as a rate-0 bucket in the taxable
* sections, not yet split into the dedicated exempt table.
*/
/** One outward-supply line as it sits, immutable, on a stored bill / credit-note payload. */
export interface GstDocLine {
hsn: string
qty: number
/** Unit quantity code for the HSN summary (PCS, KG…). */
unitCode: string
taxablePaise: Paise
cgstPaise: Paise
sgstPaise: Paise
igstPaise: Paise
cessPaise: Paise
/** GST rate in basis points as captured on the line (1800 == 18%); cess is separate. */
taxRateBp: number
}
/** A source document for the period: a SALE invoice or its SALE_RETURN credit note. */
export interface GstDocument {
docType: 'SALE' | 'SALE_RETURN'
docNo: string
/** ISO business date the document was raised (drives period membership upstream). */
businessDate: string
/** The buyer's GSTIN when registered (B2B); absent ⇒ B2C. */
buyerGstin?: string
/** 2-digit place-of-supply state code. Equals the store state for a walk-in B2C sale. */
placeOfSupply: string
/** The store's own GST state code. Falls back to `opts.storeStateCode` when absent. */
supplyStateCode?: string
customerName?: string
/** The document's total value (invoice value / credit-note value) — drives the B2CL threshold. */
invoiceValuePaise: Paise
/** For a credit note: the original bill's doc no (CDNR/CDNUR note detail). */
originalDocNo?: string
lines: GstDocLine[]
}
export interface GstReturnOptions {
/** The store's GST state code, used to decide intra- vs inter-state where a doc omits its own. */
storeStateCode: string
/**
* B2C-Large threshold in paise (a B2C inter-state invoice ABOVE this is B2CL, else B2CS).
* Dated/config value (R10) the caller passes the value in force for the period; defaults to
* the classic 2,50,000 so a missing config never silently mis-buckets.
*/
b2clThresholdPaise?: Paise
}
/** The classic B2C-Large threshold: ₹2,50,000. A dated config value in production (R10). */
export const B2CL_THRESHOLD_PAISE: Paise = 25_000_000
/** The tax components common to every section row. */
export interface TaxAmounts {
taxablePaise: Paise
igstPaise: Paise
cgstPaise: Paise
sgstPaise: Paise
cessPaise: Paise
}
const zeroAmounts = (): TaxAmounts => ({ taxablePaise: 0, igstPaise: 0, cgstPaise: 0, sgstPaise: 0, cessPaise: 0 })
/** Fold one line into an accumulator (sign +1 for sales, 1 to reverse). Mutates `a`. */
function addLine(a: TaxAmounts, l: GstDocLine, sign: 1 | -1): void {
a.taxablePaise += sign * l.taxablePaise
a.igstPaise += sign * l.igstPaise
a.cgstPaise += sign * l.cgstPaise
a.sgstPaise += sign * l.sgstPaise
a.cessPaise += sign * l.cessPaise
}
/** a ± b, returned as a fresh object. */
function combine(a: TaxAmounts, b: TaxAmounts, sign: 1 | -1): TaxAmounts {
return {
taxablePaise: a.taxablePaise + sign * b.taxablePaise,
igstPaise: a.igstPaise + sign * b.igstPaise,
cgstPaise: a.cgstPaise + sign * b.cgstPaise,
sgstPaise: a.sgstPaise + sign * b.sgstPaise,
cessPaise: a.cessPaise + sign * b.cessPaise,
}
}
/** Total tax on an amounts row (IGST + CGST + SGST + cess). */
export function taxOf(a: TaxAmounts): Paise {
return a.igstPaise + a.cgstPaise + a.sgstPaise + a.cessPaise
}
const anyNonZero = (a: TaxAmounts): boolean =>
a.taxablePaise !== 0 || a.igstPaise !== 0 || a.cgstPaise !== 0 || a.sgstPaise !== 0 || a.cessPaise !== 0
// ---------- section output types ----------
export interface RateAmounts extends TaxAmounts { rateBp: number }
/** GSTR-1 Table 4A — registered buyers, grouped by GSTIN with a rate-wise breakdown. */
export interface B2BGroup {
ctin: string
invoiceCount: number
rates: RateAmounts[]
totals: TaxAmounts
}
/** GSTR-1 Table 5 — B2C-Large inter-state invoices above the threshold, one row per invoice. */
export interface B2CLInvoice {
docNo: string
businessDate: string
pos: string
invoiceValuePaise: Paise
rates: RateAmounts[]
totals: TaxAmounts
}
/** GSTR-1 Table 7 B2C-Small, aggregated by place of supply + rate + supply type. Net of small
* B2C credit notes (a small return reduces its (pos, rate) bucket, as the portal expects). */
export interface B2CSBucket extends TaxAmounts {
pos: string
rateBp: number
supplyType: 'INTER' | 'INTRA'
}
/** GSTR-1 Table 9B — a credit note (registered ⇒ CDNR, unregistered inter-large ⇒ CDNUR). */
export interface CreditNote {
ctin?: string
noteDocNo: string
noteDate: string
originalDocNo?: string
pos: string
supplyType: 'INTER' | 'INTRA'
noteValuePaise: Paise
rates: RateAmounts[]
totals: TaxAmounts
}
/** GSTR-1 Table 12 — HSN-wise summary, net of returns (a return reduces qty and amounts). */
export interface HsnRow extends TaxAmounts {
hsn: string
rateBp: number
uqc: string
qty: number
}
/** GSTR-1 Table 13 — documents issued (invoice/credit-note number ranges and counts). */
export interface DocIssued {
natureOfDocument: string
fromDocNo: string
toDocNo: string
totalNumber: number
cancelled: number
net: number
}
export interface Gstr1SectionTotals {
b2b: TaxAmounts
b2cl: TaxAmounts
b2cs: TaxAmounts
cdnr: TaxAmounts
cdnur: TaxAmounts
hsn: TaxAmounts
/** Net outward = b2b + b2cl + b2cs cdnr cdnur. Equals source == HSN == 3B (see reconcile). */
net: TaxAmounts
}
export interface Gstr1 {
b2b: B2BGroup[]
b2cl: B2CLInvoice[]
b2cs: B2CSBucket[]
cdnr: CreditNote[]
cdnur: CreditNote[]
hsn: HsnRow[]
docs: DocIssued[]
totals: Gstr1SectionTotals
counts: {
b2bParties: number
b2bInvoices: number
b2clInvoices: number
b2csBuckets: number
cdnrNotes: number
cdnurNotes: number
saleDocs: number
returnDocs: number
}
}
const rateArray = (m: Map<number, TaxAmounts>): RateAmounts[] =>
[...m.entries()].sort((a, b) => a[0] - b[0]).map(([rateBp, a]) => ({ rateBp, ...a }))
/** True iff the document is inter-state (place of supply differs from the store's state). */
function isInterState(d: GstDocument, opts: GstReturnOptions): boolean {
return (d.supplyStateCode ?? opts.storeStateCode) !== d.placeOfSupply
}
/** Build a per-note (credit-note) section entry with a rate-wise breakdown. */
function makeNote(d: GstDocument, supplyType: 'INTER' | 'INTRA'): CreditNote {
const rates = new Map<number, TaxAmounts>()
const totals = zeroAmounts()
for (const l of d.lines) {
let a = rates.get(l.taxRateBp)
if (a === undefined) { a = zeroAmounts(); rates.set(l.taxRateBp, a) }
addLine(a, l, 1)
addLine(totals, l, 1)
}
return {
...(d.buyerGstin !== undefined && d.buyerGstin !== '' ? { ctin: d.buyerGstin } : {}),
noteDocNo: d.docNo,
noteDate: d.businessDate,
...(d.originalDocNo !== undefined ? { originalDocNo: d.originalDocNo } : {}),
pos: d.placeOfSupply,
supplyType,
noteValuePaise: d.invoiceValuePaise,
rates: rateArray(rates),
totals,
}
}
/**
* Fold the period's documents into GSTR-1 sections. Every SALE line lands in exactly one of
* {B2B, B2CL, B2CS}; every SALE_RETURN line lands in exactly one of {CDNR, CDNUR, B2CS-negative}.
* That partition is what makes the reconciliation identity hold by construction.
*/
export function computeGstr1(docs: GstDocument[], opts: GstReturnOptions): Gstr1 {
const threshold = opts.b2clThresholdPaise ?? B2CL_THRESHOLD_PAISE
const b2bMap = new Map<string, { invoiceCount: number; rates: Map<number, TaxAmounts>; totals: TaxAmounts }>()
const b2cl: B2CLInvoice[] = []
const b2csMap = new Map<string, B2CSBucket>()
const cdnr: CreditNote[] = []
const cdnur: CreditNote[] = []
const hsnMap = new Map<string, HsnRow>()
const saleDocNos: string[] = []
const returnDocNos: string[] = []
const accHsn = (d: GstDocument, sign: 1 | -1): void => {
for (const l of d.lines) {
const key = `${l.hsn}|${l.taxRateBp}|${l.unitCode}`
let row = hsnMap.get(key)
if (row === undefined) {
row = { hsn: l.hsn, rateBp: l.taxRateBp, uqc: l.unitCode, qty: 0, ...zeroAmounts() }
hsnMap.set(key, row)
}
row.qty += sign * l.qty
addLine(row, l, sign)
}
}
const b2csAdd = (d: GstDocument, supplyType: 'INTER' | 'INTRA', sign: 1 | -1): void => {
for (const l of d.lines) {
const key = `${d.placeOfSupply}|${l.taxRateBp}|${supplyType}`
let b = b2csMap.get(key)
if (b === undefined) {
b = { pos: d.placeOfSupply, rateBp: l.taxRateBp, supplyType, ...zeroAmounts() }
b2csMap.set(key, b)
}
addLine(b, l, sign)
}
}
for (const d of docs) {
const inter = isInterState(d, opts)
const supplyType: 'INTER' | 'INTRA' = inter ? 'INTER' : 'INTRA'
const registered = d.buyerGstin !== undefined && d.buyerGstin !== ''
if (d.docType === 'SALE') {
saleDocNos.push(d.docNo)
accHsn(d, 1)
if (registered) {
let g = b2bMap.get(d.buyerGstin!)
if (g === undefined) { g = { invoiceCount: 0, rates: new Map(), totals: zeroAmounts() }; b2bMap.set(d.buyerGstin!, g) }
g.invoiceCount += 1
for (const l of d.lines) {
let a = g.rates.get(l.taxRateBp)
if (a === undefined) { a = zeroAmounts(); g.rates.set(l.taxRateBp, a) }
addLine(a, l, 1)
addLine(g.totals, l, 1)
}
} else if (inter && d.invoiceValuePaise > threshold) {
const rates = new Map<number, TaxAmounts>()
const totals = zeroAmounts()
for (const l of d.lines) {
let a = rates.get(l.taxRateBp)
if (a === undefined) { a = zeroAmounts(); rates.set(l.taxRateBp, a) }
addLine(a, l, 1)
addLine(totals, l, 1)
}
b2cl.push({
docNo: d.docNo, businessDate: d.businessDate, pos: d.placeOfSupply,
invoiceValuePaise: d.invoiceValuePaise, rates: rateArray(rates), totals,
})
} else {
b2csAdd(d, supplyType, 1)
}
} else {
returnDocNos.push(d.docNo)
accHsn(d, -1)
if (registered) {
cdnr.push(makeNote(d, supplyType))
} else if (inter && d.invoiceValuePaise > threshold) {
cdnur.push(makeNote(d, supplyType))
} else {
// A small B2C return nets into its (pos, rate) B2CS bucket — the portal has no separate
// section for it; it reduces reported B2C turnover at the original rate.
b2csAdd(d, supplyType, -1)
}
}
}
const b2b: B2BGroup[] = [...b2bMap.entries()]
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.map(([ctin, g]) => ({ ctin, invoiceCount: g.invoiceCount, rates: rateArray(g.rates), totals: g.totals }))
const b2cs = [...b2csMap.values()]
.filter(anyNonZero)
.sort((a, b) => (a.pos === b.pos ? a.rateBp - b.rateBp : a.pos < b.pos ? -1 : 1))
const hsn = [...hsnMap.values()]
.filter((r) => r.qty !== 0 || anyNonZero(r))
.sort((a, b) => (a.hsn === b.hsn ? a.rateBp - b.rateBp : a.hsn < b.hsn ? -1 : 1))
const sumTotals = (list: TaxAmounts[]): TaxAmounts => list.reduce((acc, a) => combine(acc, a, 1), zeroAmounts())
const b2bTot = sumTotals(b2b.map((g) => g.totals))
const b2clTot = sumTotals(b2cl.map((i) => i.totals))
const b2csTot = sumTotals(b2cs)
const cdnrTot = sumTotals(cdnr.map((n) => n.totals))
const cdnurTot = sumTotals(cdnur.map((n) => n.totals))
const hsnTot = sumTotals(hsn)
const net = combine(combine(combine(combine(b2bTot, b2clTot, 1), b2csTot, 1), cdnrTot, -1), cdnurTot, -1)
const docGroup = (nature: string, nos: string[]): DocIssued => {
const sorted = [...nos].sort()
return {
natureOfDocument: nature,
fromDocNo: sorted[0]!,
toDocNo: sorted[sorted.length - 1]!,
totalNumber: nos.length,
cancelled: 0,
net: nos.length,
}
}
const docsIssued: DocIssued[] = []
if (saleDocNos.length > 0) docsIssued.push(docGroup('Invoices for outward supply', saleDocNos))
if (returnDocNos.length > 0) docsIssued.push(docGroup('Credit notes', returnDocNos))
return {
b2b, b2cl, b2cs, cdnr, cdnur, hsn, docs: docsIssued,
totals: { b2b: b2bTot, b2cl: b2clTot, b2cs: b2csTot, cdnr: cdnrTot, cdnur: cdnurTot, hsn: hsnTot, net },
counts: {
b2bParties: b2b.length,
b2bInvoices: b2b.reduce((a, g) => a + g.invoiceCount, 0),
b2clInvoices: b2cl.length,
b2csBuckets: b2cs.length,
cdnrNotes: cdnr.length,
cdnurNotes: cdnur.length,
saleDocs: saleDocNos.length,
returnDocs: returnDocNos.length,
},
}
}
// ---------- GSTR-3B ----------
export interface Gstr3b {
/** Table 3.1(a) — outward taxable supplies (other than zero/nil/exempt), NET of credit notes. */
outward: TaxAmounts
/** Gross outward supply (sales only) — a reconciliation aid. */
salesTax: TaxAmounts
/** Credit notes (positive magnitude) subtracted from sales to get `outward`. */
returnsTax: TaxAmounts
/**
* Table 4 (eligible ITC) and the inward/RCM blocks are a STUB for this increment the store
* has purchase data but 3B ITC reconciliation against GSTR-2B is a follow-up (see STATUS).
*/
itc: { igstPaise: Paise; cgstPaise: Paise; sgstPaise: Paise; cessPaise: Paise; note: string }
}
/**
* GSTR-3B 3.1(a): total taxable value and tax on outward supplies, net of credit notes. A return
* REDUCES the liability at its original captured rate (its stored line amounts are subtracted).
*/
export function computeGstr3b(docs: GstDocument[]): Gstr3b {
const salesTax = zeroAmounts()
const returnsTax = zeroAmounts()
for (const d of docs) {
const target = d.docType === 'SALE' ? salesTax : returnsTax
for (const l of d.lines) addLine(target, l, 1)
}
return {
outward: combine(salesTax, returnsTax, -1),
salesTax,
returnsTax,
itc: {
igstPaise: 0, cgstPaise: 0, sgstPaise: 0, cessPaise: 0,
note: 'ITC / inward-supply reconciliation (GSTR-2A/2B) is a follow-up — purchase data exists but 3B table 4 is out of scope this increment.',
},
}
}
// ---------- reconciliation ----------
export interface Reconciliation {
/** Σ over source docs of taxable (SALE +, SALE_RETURN ). */
sourceTaxablePaise: Paise
sourceTaxPaise: Paise
gstr1NetTaxablePaise: Paise
gstr1NetTaxPaise: Paise
hsnTaxablePaise: Paise
hsnTaxPaise: Paise
gstr3bTaxablePaise: Paise
gstr3bTaxPaise: Paise
ok: boolean
mismatches: string[]
}
/**
* Prove the numbers tie out: the sum of GSTR-1 section taxables (returns negative) equals the sum
* of the source documents' taxables, equals the HSN-summary total, equals GSTR-3B's outward
* taxable and likewise for total tax. Any inequality is named. This is the R23 correctness gate
* for a compliance report; a caller that gets `ok:false` must STOP and investigate, never file.
*/
export function reconcile(docs: GstDocument[], g1: Gstr1, g3: Gstr3b): Reconciliation {
let srcTaxable = 0
let srcTax = 0
for (const d of docs) {
const sign: 1 | -1 = d.docType === 'SALE' ? 1 : -1
for (const l of d.lines) {
srcTaxable += sign * l.taxablePaise
srcTax += sign * (l.igstPaise + l.cgstPaise + l.sgstPaise + l.cessPaise)
}
}
const g1Taxable = g1.totals.net.taxablePaise
const g1Tax = taxOf(g1.totals.net)
const hsnTaxable = g1.totals.hsn.taxablePaise
const hsnTax = taxOf(g1.totals.hsn)
const g3Taxable = g3.outward.taxablePaise
const g3Tax = taxOf(g3.outward)
const mismatches: string[] = []
const eq = (label: string, got: number, want: number): void => {
if (got !== want) mismatches.push(`${label}: ${got} != source ${want}`)
}
eq('GSTR-1 net taxable', g1Taxable, srcTaxable)
eq('HSN taxable', hsnTaxable, srcTaxable)
eq('GSTR-3B outward taxable', g3Taxable, srcTaxable)
eq('GSTR-1 net tax', g1Tax, srcTax)
eq('HSN tax', hsnTax, srcTax)
eq('GSTR-3B outward tax', g3Tax, srcTax)
return {
sourceTaxablePaise: srcTaxable,
sourceTaxPaise: srcTax,
gstr1NetTaxablePaise: g1Taxable,
gstr1NetTaxPaise: g1Tax,
hsnTaxablePaise: hsnTaxable,
hsnTaxPaise: hsnTax,
gstr3bTaxablePaise: g3Taxable,
gstr3bTaxPaise: g3Tax,
ok: mismatches.length === 0,
mismatches,
}
}

@ -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,191 @@
import { describe, expect, it } from 'vitest'
import {
B2CL_THRESHOLD_PAISE, computeGstr1, computeGstr3b, reconcile, taxOf,
type GstDocLine, type GstDocument,
} from '@sims/billing-engine'
/**
* GST-returns engine hand-computed golden set. All line amounts are stated as integer paise
* so the expected values are transparent, and every amount is a CAPTURED amount (the engine never
* recomputes tax). The centrepiece is the reconciliation test: Σ section taxables (returns
* negative) == Σ source == HSN total == GالسTR-3B outward, and likewise for total tax.
*
* Store state '32'. A registered inter-state buyer sits in state 27.
*/
const STORE = '32'
const CTIN = '27ABCDE1234F1Z0'
const OPTS = { storeStateCode: STORE }
/** A tax-inclusive line's captured amounts. Intra ⇒ pass cgst/sgst; inter ⇒ pass igst. */
function line(
hsn: string, rateBp: number, taxable: number,
t: { cgst?: number; sgst?: number; igst?: number; cess?: number }, qty = 1, unitCode = 'PCS',
): GstDocLine {
return {
hsn, taxRateBp: rateBp, qty, unitCode, taxablePaise: taxable,
cgstPaise: t.cgst ?? 0, sgstPaise: t.sgst ?? 0, igstPaise: t.igst ?? 0, cessPaise: t.cess ?? 0,
}
}
// GST18 net ₹1,180 → taxable 100000, tax 18000. GST12 net ₹1,120 → taxable 100000, tax 12000.
// GST5 net ₹1,050 → taxable 100000, tax 5000. ZERO net ₹1,000 → taxable 100000, no tax.
function docSet(): GstDocument[] {
return [
// 1 — B2B inter-state (buyer in 27), GST18: taxable 100000, IGST 18000.
{ docType: 'SALE', docNo: 'ST1C22026-000001', businessDate: '2026-07-02', buyerGstin: CTIN, placeOfSupply: '27', invoiceValuePaise: 118000, lines: [line('3402', 1800, 100000, { igst: 18000 })] },
// 2 — B2B inter-state, GST12: taxable 100000, IGST 12000 (same buyer → invoiceCount 2).
{ docType: 'SALE', docNo: 'ST1C22026-000002', businessDate: '2026-07-03', buyerGstin: CTIN, placeOfSupply: '27', invoiceValuePaise: 112000, lines: [line('0405', 1200, 100000, { igst: 12000 })] },
// 3 — B2C intra small, GST18: taxable 100000, CGST 9000, SGST 9000.
{ docType: 'SALE', docNo: 'ST1C22026-000003', businessDate: '2026-07-04', placeOfSupply: '32', invoiceValuePaise: 118000, lines: [line('3402', 1800, 100000, { cgst: 9000, sgst: 9000 })] },
// 4 — B2C intra small, GST18 + GST5.
{ docType: 'SALE', docNo: 'ST1C22026-000004', businessDate: '2026-07-05', placeOfSupply: '32', invoiceValuePaise: 223000, lines: [line('3402', 1800, 100000, { cgst: 9000, sgst: 9000 }), line('1905', 500, 100000, { cgst: 2500, sgst: 2500 })] },
// 5 — B2C intra, ZERO-rated: taxable 100000, no tax.
{ docType: 'SALE', docNo: 'ST1C22026-000005', businessDate: '2026-07-06', placeOfSupply: '32', invoiceValuePaise: 100000, lines: [line('1101', 0, 100000, {})] },
// 6 — B2C inter-state LARGE (> ₹2,50,000): taxable 30,000,000, IGST 5,400,000, value 35,400,000.
{ docType: 'SALE', docNo: 'ST1C22026-000006', businessDate: '2026-07-07', placeOfSupply: '27', invoiceValuePaise: 35_400_000, lines: [line('3402', 1800, 30_000_000, { igst: 5_400_000 }, 3)] },
// 7 — B2C intra small RETURN against #4's GST18 line → nets into B2CS (negative).
{ docType: 'SALE_RETURN', docNo: 'CNST1C22026-0001', businessDate: '2026-07-08', placeOfSupply: '32', invoiceValuePaise: 118000, originalDocNo: 'ST1C22026-000004', lines: [line('3402', 1800, 100000, { cgst: 9000, sgst: 9000 })] },
// 8 — B2B RETURN against #1 (buyer 27) → CDNR.
{ docType: 'SALE_RETURN', docNo: 'CNST1C22026-0002', businessDate: '2026-07-09', buyerGstin: CTIN, placeOfSupply: '27', invoiceValuePaise: 118000, originalDocNo: 'ST1C22026-000001', lines: [line('3402', 1800, 100000, { igst: 18000 })] },
]
}
describe('computeGstr1 — section bucketing', () => {
const g1 = computeGstr1(docSet(), OPTS)
it('B2B groups by GSTIN with a rate-wise split and an invoice count', () => {
expect(g1.b2b).toHaveLength(1)
const g = g1.b2b[0]!
expect(g.ctin).toBe(CTIN)
expect(g.invoiceCount).toBe(2)
expect(g.rates.map((r) => r.rateBp)).toEqual([1200, 1800])
expect(g.totals.taxablePaise).toBe(200000)
expect(g.totals.igstPaise).toBe(30000)
expect(g.totals.cgstPaise).toBe(0)
})
it('B2CL captures only the inter-state invoice above ₹2,50,000, per invoice', () => {
expect(g1.b2cl).toHaveLength(1)
const inv = g1.b2cl[0]!
expect(inv.docNo).toBe('ST1C22026-000006')
expect(inv.pos).toBe('27')
expect(inv.invoiceValuePaise).toBe(35_400_000)
expect(inv.totals.taxablePaise).toBe(30_000_000)
expect(inv.totals.igstPaise).toBe(5_400_000)
})
it('B2CS aggregates small B2C by (pos, rate) and is NET of a small return', () => {
// GST18 intra: sold 100000 (#3) + 100000 (#4) 100000 returned (#7) = 100000 net.
const gst18 = g1.b2cs.find((b) => b.rateBp === 1800 && b.supplyType === 'INTRA')!
expect(gst18.pos).toBe('32')
expect(gst18.taxablePaise).toBe(100000)
expect(gst18.cgstPaise).toBe(9000)
expect(gst18.sgstPaise).toBe(9000)
// GST5 and ZERO buckets are untouched by returns.
expect(g1.b2cs.find((b) => b.rateBp === 500)!.taxablePaise).toBe(100000)
expect(g1.b2cs.find((b) => b.rateBp === 0)!.taxablePaise).toBe(100000)
})
it('CDNR reverses a registered credit note at the original rate', () => {
expect(g1.cdnr).toHaveLength(1)
const n = g1.cdnr[0]!
expect(n.ctin).toBe(CTIN)
expect(n.originalDocNo).toBe('ST1C22026-000001')
expect(n.totals.taxablePaise).toBe(100000)
expect(n.totals.igstPaise).toBe(18000)
expect(g1.cdnur).toHaveLength(0)
})
it('HSN summary nets sales and returns by (hsn, rate)', () => {
const h = g1.hsn.find((r) => r.hsn === '3402' && r.rateBp === 1800)!
// taxable: 100000(#1)+100000(#3)+100000(#4)+30,000,000(#6) 100000(#7) 100000(#8).
expect(h.taxablePaise).toBe(30_100_000)
expect(h.igstPaise).toBe(5_400_000)
expect(h.cgstPaise).toBe(9000)
expect(h.qty).toBe(4)
expect(g1.totals.hsn.taxablePaise).toBe(30_400_000)
})
it('documents-issued lists invoice and credit-note ranges with counts', () => {
const inv = g1.docs.find((d) => d.natureOfDocument === 'Invoices for outward supply')!
expect(inv.totalNumber).toBe(6)
expect(inv.fromDocNo).toBe('ST1C22026-000001')
expect(inv.toDocNo).toBe('ST1C22026-000006')
const cdn = g1.docs.find((d) => d.natureOfDocument === 'Credit notes')!
expect(cdn.totalNumber).toBe(2)
})
})
describe('computeGstr3b — 3.1(a) outward, net of credit notes', () => {
const g3 = computeGstr3b(docSet())
it('outward taxable and tax are sales minus returns at captured rates', () => {
expect(g3.salesTax.taxablePaise).toBe(30_600_000)
expect(g3.returnsTax.taxablePaise).toBe(200000)
expect(g3.outward.taxablePaise).toBe(30_400_000)
expect(g3.outward.igstPaise).toBe(5_412_000) // 84000 sales 18000 return
expect(g3.outward.cgstPaise).toBe(11500)
expect(g3.outward.sgstPaise).toBe(11500)
expect(taxOf(g3.outward)).toBe(5_435_000)
})
it('ITC block is an explicit stub (follow-up)', () => {
expect(g3.itc.igstPaise).toBe(0)
expect(g3.itc.note).toMatch(/follow-up/i)
})
})
describe('reconcile — the compliance gate', () => {
it('GSTR-1 sections == source == HSN == GSTR-3B, taxable and tax', () => {
const docs = docSet()
const g1 = computeGstr1(docs, OPTS)
const g3 = computeGstr3b(docs)
const r = reconcile(docs, g1, g3)
expect(r.ok).toBe(true)
expect(r.mismatches).toEqual([])
expect(r.sourceTaxablePaise).toBe(30_400_000)
expect(r.gstr1NetTaxablePaise).toBe(30_400_000)
expect(r.hsnTaxablePaise).toBe(30_400_000)
expect(r.gstr3bTaxablePaise).toBe(30_400_000)
expect(r.sourceTaxPaise).toBe(5_435_000)
expect(r.gstr1NetTaxPaise).toBe(5_435_000)
expect(r.hsnTaxPaise).toBe(5_435_000)
expect(r.gstr3bTaxPaise).toBe(5_435_000)
})
it('flags a mismatch loudly if a section total is wrong (guard against silent drift)', () => {
const docs = docSet()
const g1 = computeGstr1(docs, OPTS)
const g3 = computeGstr3b(docs)
// Corrupt a section total the way a bug would; reconcile must catch it.
g1.totals.net.taxablePaise += 1
const r = reconcile(docs, g1, g3)
expect(r.ok).toBe(false)
expect(r.mismatches.some((m) => m.includes('GSTR-1 net taxable'))).toBe(true)
})
})
describe('B2CL / CDNUR threshold behaviour', () => {
it('a B2C inter-state invoice BELOW the threshold is B2CS, not B2CL', () => {
const docs: GstDocument[] = [{
docType: 'SALE', docNo: 'ST1C22026-000010', businessDate: '2026-07-02',
placeOfSupply: '27', invoiceValuePaise: B2CL_THRESHOLD_PAISE, // exactly at threshold ⇒ NOT above
lines: [line('3402', 1800, 100000, { igst: 18000 })],
}]
const g1 = computeGstr1(docs, OPTS)
expect(g1.b2cl).toHaveLength(0)
const bucket = g1.b2cs.find((b) => b.supplyType === 'INTER')!
expect(bucket.pos).toBe('27')
expect(bucket.igstPaise).toBe(18000)
})
it('a large inter-state B2C return becomes CDNUR', () => {
const docs: GstDocument[] = [{
docType: 'SALE_RETURN', docNo: 'CNST1C22026-0009', businessDate: '2026-07-08',
placeOfSupply: '27', invoiceValuePaise: 35_400_000, originalDocNo: 'ST1C22026-000006',
lines: [line('3402', 1800, 30_000_000, { igst: 5_400_000 }, 3)],
}]
const g1 = computeGstr1(docs, OPTS)
expect(g1.cdnr).toHaveLength(0)
expect(g1.cdnur).toHaveLength(1)
expect(g1.cdnur[0]!.totals.igstPaise).toBe(5_400_000)
})
})

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { computeBill, computeReturn, type LineInput, type TaxClassRow } from '@sims/billing-engine'
/**
* The credit-note engine reverses a sale line PROPORTIONALLY and at the ORIGINAL captured rate.
* These lock: a full-line return reproduces the original amounts exactly; a partial return stays
* internally consistent (taxable + taxes == lineTotal) and reverses real GST; and the reversal
* scales the STORED rate/amounts, never a live rate table (so a later master change can't move it).
*/
const RATES: TaxClassRow[] = [
{ classCode: 'GST12', ratePctBp: 1200, cessPctBp: 0, effectiveFrom: '2017-07-01' },
]
const CTX = { businessDate: '2026-07-10', supplyStateCode: '32', placeOfSupplyStateCode: '32', roundToRupee: true }
// A sale of 3 × Amul Butter @ ₹275 tax-inclusive, GST12 (₹825.00 total).
function saleLines(): ReturnType<typeof computeBill>['lines'] {
const l: LineInput = {
itemId: '107', name: 'Amul Butter 500g', hsn: '0405', qty: 3, unitCode: 'PCS',
unitPricePaise: 27_500, priceIncludesTax: true, taxClassCode: 'GST12',
}
return computeBill([l], CTX, RATES).lines
}
describe('computeReturn — proportional reversal at the original rate', () => {
it('a full-line return reproduces the original line amounts exactly', () => {
const orig = saleLines()
const o = orig[0]!
const r = computeReturn(orig, [{ lineIndex: 0, qty: 3 }], { roundToRupee: true })
const x = r.lines[0]!
expect(x.taxablePaise).toBe(o.taxablePaise)
expect(x.cgstPaise).toBe(o.cgstPaise)
expect(x.sgstPaise).toBe(o.sgstPaise)
expect(x.cessPaise).toBe(o.cessPaise)
expect(x.lineTotalPaise).toBe(o.lineTotalPaise)
expect(r.totals.payablePaise).toBe(Math.round(o.lineTotalPaise / 100) * 100)
expect(x.originalLineIndex).toBe(0)
})
it('a partial 2-of-3 return credits 2/3, stays consistent, and reverses real GST', () => {
const orig = saleLines()
const r = computeReturn(orig, [{ lineIndex: 0, qty: 2 }], { roundToRupee: true })
const x = r.lines[0]!
// 2/3 of the ₹825 inclusive line = ₹550.00 → 55000 paise.
expect(x.lineTotalPaise).toBe(55_000)
expect(r.totals.payablePaise).toBe(55_000)
// Internal consistency (the same invariant computeBill produces).
expect(x.taxablePaise + x.cgstPaise + x.sgstPaise + x.igstPaise + x.cessPaise).toBe(x.lineTotalPaise)
// GST is genuinely reversed, split as CGST+SGST (intra-state), not zeroed.
expect(x.cgstPaise + x.sgstPaise).toBeGreaterThan(0)
expect(x.igstPaise).toBe(0)
})
it('reverses at the ORIGINAL captured rate — it scales stored amounts, never a rate table', () => {
const orig = saleLines()
const r = computeReturn(orig, [{ lineIndex: 0, qty: 1 }], { roundToRupee: false })
// The captured rate rides on the credit-note line; no rate list is even passed in.
expect(r.lines[0]!.taxRateBp).toBe(1200)
})
it('rejects a return qty above what was sold (defence in depth; server enforces the ledger)', () => {
const orig = saleLines()
expect(() => computeReturn(orig, [{ lineIndex: 0, qty: 4 }], { roundToRupee: true })).toThrow()
expect(() => computeReturn(orig, [{ lineIndex: 9, qty: 1 }], { roundToRupee: true })).toThrow()
})
})

@ -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]
}

@ -27,11 +27,46 @@ export function formatDocNo(prefix: string, seq: number, width = 6): string {
return docNo
}
/** e.g. seriesPrefix("ST1", "C2", "26") → "ST1C2/26" */
export function seriesPrefix(storeCode: string, counterCode: string, fyShortMark: string): string {
return `${storeCode}${counterCode}/${fyShortMark}`
/**
* The per-counter series prefix. It embeds the FULL 4-digit FY START YEAR (M9 collision fix):
* a 2-digit marker made FY 2026-27 and 2126-27 share the prefix "ST1C2/26", so a century apart
* their doc numbers collided on the bill's UNIQUE(tenant, doc_no) and billing on that prefix was
* blocked. The 4-digit start year ("2026" vs "2126") cannot collide within a millennium.
*
* GST caps a doc number at 16 chars from [A-Za-z0-9/-]. At a 6-digit sequence,
* store(3)+counter(2)+"/"+FY(4)+"-"+seq(6) = 17 would BREAK that rule so the counterFY "/"
* separator is dropped (the sequence "-" from formatDocNo stays for readability):
* seriesPrefix("ST1", "C2", "2026-27") "ST1C22026" doc no "ST1C22026-000123" (16 chars).
* This keeps the 16-char rule for store+counter codes totalling 5 chars (the demo ST1+C2). A
* store with a 3-char counter code (C10+) plus a 4-digit FY would need a shorter store code a
* documented onboarding constraint, the same tightness the 2-digit format already had at C10.
*
* `fy` is the full FY label ("2026-27"); only its 4-digit start year is used.
*/
export function seriesPrefix(storeCode: string, counterCode: string, fy: string): string {
return `${storeCode}${counterCode}${fy.slice(0, 4)}`
}
/**
* Per-counter credit-note (SALE_RETURN) series prefix. A credit note lives in the same
* document table as sales under the SAME UNIQUE(tenant, doc_no), so its number must never
* collide with a sale's the leading "CN" marker guarantees that (a sale never starts "CN").
* It reuses M9's 4-digit-FY scheme (store + counter + 4-digit FY start year) so credit notes
* of FY 2026-27 and 2126-27 can't collide either.
*
* The "CN" marker costs two characters, so the sequence is 4 digits (`CREDIT_NOTE_SEQ_WIDTH`)
* rather than the sale's 6, keeping the GST 16-char [A-Za-z0-9/-] rule at the demo's ST1+C2
* sizing: `creditNotePrefix("ST1","C2","2026-27")` "CNST1C22026" "CNST1C22026-0001" (16).
* That caps a counter at 9,999 credit notes per FY far beyond any real counter and, as for
* sales, needs store+counter codes to total 5 chars (a documented onboarding constraint).
*/
export function creditNotePrefix(storeCode: string, counterCode: string, fy: string): string {
return `CN${storeCode}${counterCode}${fy.slice(0, 4)}`
}
/** Credit-note sequence width — 4 digits so the "CN"-marked doc no stays within 16 chars. */
export const CREDIT_NOTE_SEQ_WIDTH = 4
export function nextDocNo(series: DocSeries): { docNo: string; series: DocSeries } {
const docNo = formatDocNo(series.prefix, series.nextSeq)
return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } }

@ -6,4 +6,5 @@ export * from './doc-series'
export * from './gstin'
export * from './masters'
export * from './documents'
export * from './batch'
export * from './outbox'

@ -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()
})
})

@ -38,20 +38,31 @@ describe('uuidv7', () => {
describe('document series — offline per-counter numbering', () => {
const series: DocSeries = {
tenantId: 't', storeId: 's', counterId: 'c', docType: 'SALE',
fy: '2026-27', prefix: seriesPrefix('ST1', 'C2', '26'), nextSeq: 123,
fy: '2026-27', prefix: seriesPrefix('ST1', 'C2', '2026-27'), nextSeq: 123,
}
it('formats within the GST 16-char rule', () => {
it('formats within the GST 16-char rule (4-digit FY, M9)', () => {
const { docNo, series: bumped } = nextDocNo(series)
expect(docNo).toBe('ST1C2/26-000123')
expect(docNo).toBe('ST1C22026-000123')
expect(docNo.length).toBeLessThanOrEqual(16)
expect(bumped.nextSeq).toBe(124)
})
it('M9: the 4-digit FY start year makes centuries-apart FYs distinct (no prefix collision)', () => {
const a = seriesPrefix('ST1', 'C2', '2026-27')
const b = seriesPrefix('ST1', 'C2', '2126-27') // 100 years later — collided under the old 2-digit "26"
expect(a).toBe('ST1C22026')
expect(b).toBe('ST1C22126')
expect(a).not.toBe(b)
// both still fit the 16-char [A-Za-z0-9/-] rule at the top of the 6-digit sequence range
expect(formatDocNo(a, 999_999)).toBe('ST1C22026-999999')
expect(formatDocNo(a, 999_999).length).toBe(16)
expect(() => formatDocNo(b, 999_999)).not.toThrow()
})
it('rejects numbers that break the GST rule', () => {
expect(() => assertGstDocNo('THIS/PREFIX/IS/TOO/LONG-000001')).toThrow(/16-char/)
expect(() => formatDocNo('BAD_CHAR', 1)).toThrow(/16-char/) // underscore not allowed
})
it('FY rollover restarts the sequence', () => {
const rolled = rolloverForFy(series, '2027-28', seriesPrefix('ST1', 'C2', '27'))
const rolled = rolloverForFy(series, '2027-28', seriesPrefix('ST1', 'C2', '2027-28'))
expect(rolled.nextSeq).toBe(1)
expect(rolloverForFy(series, '2026-27', series.prefix)).toBe(series)
})

@ -17,6 +17,13 @@ export interface ReceiptDoc {
totals: BillTotals
paymentLabel: string
footer?: string
/** 'credit-note' relabels the receipt as a CREDIT NOTE (refund) and prints the original bill
* it is issued against + the return reason. Defaults to a normal sale receipt. */
variant?: 'sale' | 'credit-note'
/** The original SALE doc no a credit note is raised against (credit-note variant only). */
againstDocNo?: string
/** The return reason (credit-note variant only). */
reason?: string
}
export interface ReceiptOptions {
@ -35,15 +42,19 @@ const inr = (p: number) => formatINR(p, { symbol: false })
*/
export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] {
const w = opts.width
const cn = doc.variant === 'credit-note'
const out: string[] = []
out.push(doc.storeName)
if (doc.storeAddress !== undefined) out.push(doc.storeAddress)
if (doc.gstin !== undefined) out.push(`GSTIN: ${doc.gstin}`)
if (cn) out.push('*** CREDIT NOTE ***')
out.push(hr(w))
out.push(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w))
out.push(twoCol(`${cn ? 'Credit Note' : 'Bill'}: ${doc.docNo}`, doc.businessDate, w))
if (cn && doc.againstDocNo !== undefined) out.push(`Against Bill: ${doc.againstDocNo}`)
out.push(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w))
if (doc.buyerName !== undefined) out.push(`Buyer: ${doc.buyerName}`)
if (doc.buyerGstin !== undefined) out.push(`Buyer GSTIN: ${doc.buyerGstin}`)
if (cn && doc.reason !== undefined) out.push(`Reason: ${doc.reason}`)
out.push(hr(w))
for (const l of doc.lines) {
out.push(l.name)
@ -60,16 +71,17 @@ export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] {
if (doc.totals.cessPaise > 0) out.push(twoCol('Cess', inr(doc.totals.cessPaise), w))
if (doc.totals.discountPaise > 0) out.push(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w))
if (doc.totals.roundOffPaise !== 0) out.push(twoCol('Round off', inr(doc.totals.roundOffPaise), w))
out.push(twoCol('PAYABLE', inr(doc.totals.payablePaise), w))
out.push(twoCol('Paid by', doc.paymentLabel, w))
if (doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`)
out.push(doc.footer ?? 'Thank you! Visit again')
out.push(twoCol(cn ? 'REFUND' : 'PAYABLE', inr(doc.totals.payablePaise), w))
out.push(twoCol(cn ? 'Refund via' : 'Paid by', doc.paymentLabel, w))
if (!cn && doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`)
out.push(doc.footer ?? (cn ? 'Refund processed. Keep this credit note.' : 'Thank you! Visit again'))
return out
}
/** Thermal receipt bytes. Pure — golden-testable without a printer. */
export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array {
const w = opts.width
const cn = doc.variant === 'credit-note'
const p = new EscPos().init()
if (opts.kickDrawer) p.drawerKick()
@ -77,12 +89,15 @@ export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array
p.align('center').bold(true).size(2, 2).line(doc.storeName).size(1, 1).bold(false)
if (doc.storeAddress !== undefined) p.line(doc.storeAddress)
if (doc.gstin !== undefined) p.line(`GSTIN: ${doc.gstin}`)
if (cn) p.bold(true).line('*** CREDIT NOTE ***').bold(false)
p.line(hr(w))
p.align('left')
p.line(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w))
p.line(twoCol(`${cn ? 'Credit Note' : 'Bill'}: ${doc.docNo}`, doc.businessDate, w))
if (cn && doc.againstDocNo !== undefined) p.line(`Against Bill: ${doc.againstDocNo}`)
p.line(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w))
if (doc.buyerName !== undefined) p.line(`Buyer: ${doc.buyerName}`)
if (doc.buyerGstin !== undefined) p.line(`Buyer GSTIN: ${doc.buyerGstin}`)
if (cn && doc.reason !== undefined) p.line(`Reason: ${doc.reason}`)
p.line(hr(w))
for (const l of doc.lines) {
@ -101,12 +116,12 @@ export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array
if (doc.totals.cessPaise > 0) p.line(twoCol('Cess', inr(doc.totals.cessPaise), w))
if (doc.totals.discountPaise > 0) p.line(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w))
if (doc.totals.roundOffPaise !== 0) p.line(twoCol('Round off', inr(doc.totals.roundOffPaise), w))
p.bold(true).size(1, 2).line(twoCol('PAYABLE', inr(doc.totals.payablePaise), w)).size(1, 1).bold(false)
p.line(twoCol('Paid by', doc.paymentLabel, w))
if (doc.totals.savingsVsMrpPaise > 0) {
p.bold(true).size(1, 2).line(twoCol(cn ? 'REFUND' : 'PAYABLE', inr(doc.totals.payablePaise), w)).size(1, 1).bold(false)
p.line(twoCol(cn ? 'Refund via' : 'Paid by', doc.paymentLabel, w))
if (!cn && doc.totals.savingsVsMrpPaise > 0) {
p.align('center').line(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`)
}
p.align('center').line(doc.footer ?? 'Thank you! Visit again')
p.align('center').line(doc.footer ?? (cn ? 'Refund processed. Keep this credit note.' : 'Thank you! Visit again'))
p.feed(4).cut()
return p.build()
}

Loading…
Cancel
Save