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 Back Office Bills, stock derived correctly, audit trail written. Verified by driving
both UIs in a real browser: bill `ST1C2/26-000001`, ₹600.00. 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) ## The foundation (packages/ — real, tested code, not wireframe)
| Package | What it does | Tests | | 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. (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 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). 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), 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 **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 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` 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). 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) ## Purchase Entry — LIVE (the second crown jewel)
`/purchases/entry`: supplier type-ahead → invoice no/date → scan-grammar line entry `/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 | | 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 | | 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), 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). **Returns & Credit Notes** (live credit-note list, against-bill + reason + refund — F9 flow),
Login is real (server-verified). 35 pages render from the data-driven registry; 4 are 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). custom wireframes (Dashboard, Purchase Inbox, Settings hierarchy, Users & Roles).
Edition-locked pages render as upsells; everything themed and role/edition-aware. 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: 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` `/api/auth/pin`, `/api/auth/login` (scrypt + lockout), `/api/bootstrap`, `/api/items`
(GET/POST), `/api/parties` (GET/POST), `/api/bills` (GET/POST with engine (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 behind portable repositories (`src/repos.ts`) — **the Oracle 12c adapter reimplements
those signatures when the Classic DDL arrives; nothing above them changes.** those signatures when the Classic DDL arrives; nothing above them changes.**
Port 5181 (808085 are Windows-reserved). Port 5181 (808085 are Windows-reserved).

@ -1,4 +1,5 @@
/** Back office ↔ store-server client; session token survives reloads. */ /** 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') ?? '' let token = sessionStorage.getItem('bo.token') ?? ''
@ -36,6 +37,12 @@ export async function login(username: string, password: string): Promise<string>
return s.displayName 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>[]> => export const getItems = (q?: string): Promise<Record<string, unknown>[]> =>
call(`/items${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`) call(`/items${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`)
export const createItem = (body: Record<string, unknown>): Promise<Record<string, unknown>> => 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}` : ''}`) return call(`/bills${qs.toString() !== '' ? `?${qs}` : ''}`)
} }
export const getStock = (): Promise<Record<string, unknown>[]> => call('/stock') 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>[]> => { export const getAudit = (limit?: number, offset?: number): Promise<Record<string, unknown>[]> => {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (limit !== undefined) qs.set('limit', String(limit)) 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). */ /** Seller header for GST invoices/labels — store name, GSTIN, state (from bootstrap). */
export const getSeller = (): Promise<Seller> => export const getSeller = (): Promise<Seller> =>
call<{ store: Seller }>('/bootstrap?counter=C1').then((b) => b.store) 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 '../../../packages/ui/src/tokens.css'
import './app.css' import './app.css'
import { initTheme } from '@sims/ui' import { initTheme } from '@sims/ui'
import { clearSession } from './api' import { logout } from './api'
import { Layout } from './Layout' import { Layout } from './Layout'
import { Login } from './Login' import { Login } from './Login'
import { Dashboard } from './pages/Dashboard' import { Dashboard } from './pages/Dashboard'
@ -14,7 +14,8 @@ import { GenericPage } from './pages/GenericPage'
import { PurchaseInbox } from './pages/PurchaseInbox' import { PurchaseInbox } from './pages/PurchaseInbox'
import { SettingsPage } from './pages/SettingsPage' import { SettingsPage } from './pages/SettingsPage'
import { UsersRoles } from './pages/UsersRoles' 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 { LabelsPage } from './pages/labels'
import { PurchaseEntryPage, PurchasesListPage } from './pages/purchase-entry' import { PurchaseEntryPage, PurchasesListPage } from './pages/purchase-entry'
import { PAGES } from './pages/registry' import { PAGES } from './pages/registry'
@ -28,7 +29,12 @@ const CUSTOM: Record<string, () => React.JSX.Element> = {
'/catalog/labels': LabelsPage, '/catalog/labels': LabelsPage,
'/sales/customers': CustomersPage, '/sales/customers': CustomersPage,
'/sales/bills': BillsPage, '/sales/bills': BillsPage,
'/sales/returns': ReturnsPage,
'/inventory/stock': StockPage, '/inventory/stock': StockPage,
'/inventory/expiry': ExpiryPage,
'/gst/gstr1': Gstr1Page,
'/gst/gstr3b': Gstr3bPage,
'/gst/hsn': HsnPage,
'/admin/audit': AuditPage, '/admin/audit': AuditPage,
'/purchases/entry': PurchaseEntryPage, '/purchases/entry': PurchaseEntryPage,
'/purchases/list': PurchasesListPage, '/purchases/list': PurchasesListPage,
@ -40,7 +46,7 @@ function App() {
return ( return (
<HashRouter> <HashRouter>
<Routes> <Routes>
<Route element={<Layout userName={userName} onLogout={() => { clearSession(); setUserName(undefined) }} />}> <Route element={<Layout userName={userName} onLogout={() => { void logout(); setUserName(undefined) }} />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
{Object.entries(CUSTOM).map(([path, Page]) => ( {Object.entries(CUSTOM).map(([path, Page]) => (
<Route key={path} path={path} element={<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 { useEffect, useState } from 'react'
import { formatINR, type BillLine, type BillTotals } from '@sims/domain' import { formatINR, type BillLine, type BillTotals } from '@sims/domain'
import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing' import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing'
import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, Toolbar } from '@sims/ui' import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
import { createItem, getAudit, getBills, getItems, getParties, getSeller, getStock, type Seller } from '../api' 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). */ /** 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() { export function StockPage() {
const { data, error } = useData(() => getStock()) 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 ( return (
<div className="wf-page"> <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>} {error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : ( {data === undefined ? <EmptyState>Loading</EmptyState> : (
<DataTable <DataTable
columns={[ columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Item' }, { 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) => { rows={data.map((s) => {
const onHand = Number(s['on_hand']) const onHand = Number(s['on_hand'])
return { return {
code: String(s['code']), name: String(s['name']), code: String(s['code']), name: String(s['name']),
onhand: `${Math.round(onHand * 1000) / 1000} ${String(s['unit_code'])}`, 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>, 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> </div>
) )
} }
@ -302,3 +386,89 @@ export function AuditPage() {
</div> </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 id: string; code: string; name: string; taxClassCode: string
salePricePaise: number; mrpPaise?: number salePricePaise: number; mrpPaise?: number
unit: { code: string; decimals: number }; barcodes: string[] unit: { code: string; decimals: number }; barcodes: string[]
batchTracked: boolean
} }
interface PLine { interface PLine {
@ -35,6 +36,9 @@ interface PLine {
lastCostPaise?: number lastCostPaise?: number
currentSalePaise: number; currentMrpPaise?: number currentSalePaise: number; currentMrpPaise?: number
newSalePricePaise?: number; newMrpPaise?: 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 }) const inr = (p: number) => formatINR(p, { symbol: false })
@ -51,14 +55,15 @@ export function PurchaseEntryPage() {
const [invoiceNo, setInvoiceNo] = useState('') const [invoiceNo, setInvoiceNo] = useState('')
const [invoiceDate, setInvoiceDate] = useState(today) const [invoiceDate, setInvoiceDate] = useState(today)
const [lines, setLines] = useState<PLine[]>([]) const [lines, setLines] = useState<PLine[]>([])
const anyBatch = lines.some((l) => l.batchTracked)
const [entry, setEntry] = useState('') const [entry, setEntry] = useState('')
const [printedTotal, setPrintedTotal] = useState('') const [printedTotal, setPrintedTotal] = useState('')
const [notice, setNotice] = useState<{ tone: 'ok' | 'warn' | 'err'; text: string } | undefined>() const [notice, setNotice] = useState<{ tone: 'ok' | 'warn' | 'err'; text: string } | undefined>()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
/** Filled on post so the receiving clerk can print labels for what just came in. */ /** Filled on post so the receiving clerk can print labels for what just came in. */
const [labelQueue, setLabelQueue] = useState<LabelInput[] | undefined>() const [labelQueue, setLabelQueue] = useState<LabelInput[] | undefined>()
/** Which cell owns focus: the scan box, or qty/cost of a line. */ /** Which cell owns focus: the scan box, or qty/cost/batch/expiry of a line. */
const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' } | undefined>() const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' | 'batch' | 'expiry' } | undefined>()
const entryRef = useRef<HTMLInputElement>(null) const entryRef = useRef<HTMLInputElement>(null)
const cellRef = useRef<HTMLInputElement>(null) const cellRef = useRef<HTMLInputElement>(null)
@ -106,8 +111,14 @@ export function PurchaseEntryPage() {
}, [supplier?.id]) }, [supplier?.id])
useEffect(() => { useEffect(() => {
if (focusCell !== undefined) cellRef.current?.select() if (focusCell !== undefined) {
else entryRef.current?.focus() 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]) }, [focusCell, lines.length])
const say = (tone: 'ok' | 'warn' | 'err', text: string) => setNotice({ tone, text }) const say = (tone: 'ok' | 'warn' | 'err', text: string) => setNotice({ tone, text })
@ -134,6 +145,7 @@ export function PurchaseEntryPage() {
: {}), : {}),
currentSalePaise: item.salePricePaise, currentSalePaise: item.salePricePaise,
...(item.mrpPaise !== undefined ? { currentMrpPaise: item.mrpPaise } : {}), ...(item.mrpPaise !== undefined ? { currentMrpPaise: item.mrpPaise } : {}),
batchTracked: item.batchTracked === true,
}] }]
}) })
setNotice(undefined) setNotice(undefined)
@ -200,6 +212,9 @@ export function PurchaseEntryPage() {
taxRateBp: l.taxRateBp, taxRateBp: l.taxRateBp,
...(l.newSalePricePaise !== undefined ? { newSalePricePaise: l.newSalePricePaise } : {}), ...(l.newSalePricePaise !== undefined ? { newSalePricePaise: l.newSalePricePaise } : {}),
...(l.newMrpPaise !== undefined ? { newMrpPaise: l.newMrpPaise } : {}), ...(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, clientTotalPaise: totals.total,
}) })
@ -332,6 +347,7 @@ export function PurchaseEntryPage() {
<thead> <thead>
<tr> <tr>
<th>#</th><th>Item</th><th className="num">Qty</th><th className="num">Cost </th> <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> <th className="num">Last</th><th className="num">GST</th><th className="num">Line total</th><th>Price</th>
</tr> </tr>
</thead> </thead>
@ -340,8 +356,42 @@ export function PurchaseEntryPage() {
const lineTaxable = Math.round(l.qty * l.unitCostPaise) const lineTaxable = Math.round(l.qty * l.unitCostPaise)
const lineTax = Math.round((lineTaxable * l.taxRateBp) / 10_000) const lineTax = Math.round((lineTaxable * l.taxRateBp) / 10_000)
const costChanged = l.lastCostPaise !== undefined && l.unitCostPaise !== l.lastCostPaise && l.unitCostPaise > 0 const costChanged = l.lastCostPaise !== undefined && l.unitCostPaise !== l.lastCostPaise && l.unitCostPaise > 0
const cell = (kind: 'qty' | 'cost') => const editing = (kind: 'qty' | 'cost' | 'batch' | 'expiry') =>
focusCell?.line === i && focusCell.cell === kind ? ( 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 <input
ref={cellRef} ref={cellRef}
className="wf num" style={{ width: 90 }} className="wf num" style={{ width: 90 }}
@ -356,21 +406,35 @@ export function PurchaseEntryPage() {
setFocusCell({ line: i, cell: 'cost' }) setFocusCell({ line: i, cell: 'cost' })
} else { } else {
setLine(i, { unitCostPaise: rs(v) }) 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 })}> <span style={{ cursor: 'pointer' }} onClick={() => setFocusCell({ line: i, cell: kind })}>
{kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)} {kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)}
</span> </span>
) )
}
return ( return (
<tr key={i}> <tr key={i}>
<td>{i + 1}</td> <td>{i + 1}</td>
<td>{l.name}</td> <td>{l.name}</td>
<td className="num">{cell('qty')}</td> <td className="num">{cell('qty')}</td>
<td className="num">{cell('cost')}</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.lastCostPaise !== undefined ? inr(l.lastCostPaise) : '—'}</td>
<td className="num">{l.taxRateBp / 100}%</td> <td className="num">{l.taxRateBp / 100}%</td>
<td className="num">{inr(lineTaxable + lineTax)}</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 { useEffect, useState } from 'react'
import { Button, Notice } from '@sims/ui' import { Button, Notice } from '@sims/ui'
import { uuidv7, type Item } from '@sims/domain' 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 { Login } from './Login'
import { ShiftOpen } from './ShiftOpen' import { ShiftOpen } from './ShiftOpen'
import { BillingScreen } from './BillingScreen' 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. */ /** POS flow: bootstrap from store-server → login (PIN) → shift open → billing. */
export function App() { export function App() {
const [boot, setBoot] = useState<Bootstrap | undefined>() const [boot, setBoot] = useState<Bootstrap | undefined>()
const [bootError, setBootError] = useState<string | undefined>() const [bootError, setBootError] = useState<string | undefined>()
const [user, setUser] = useState<PosUser | undefined>() const [user, setUser] = useState<PosUser | undefined>()
const [items, setItems] = useState<Item[] | 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 = () => { const load = () => {
setBootError(undefined) setBootError(undefined)
const counter = new URLSearchParams(location.search).get('counter') ?? 'C2' 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, []) 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) { if (bootError !== undefined) {
return ( return (
<div className="login-wrap"> <div className="login-wrap">
@ -40,8 +88,23 @@ export function App() {
store={boot.store} store={boot.store}
users={boot.users} users={boot.users}
onLogin={async (u) => { onLogin={async (u) => {
setUser(u) // M1: the pre-auth bootstrap omits each user's role and the seller GSTIN. Now that we
setItems(await fetchItemsCache()) // 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} items={items}
shiftId={shift.id} shiftId={shift.id}
floatPaise={shift.floatPaise} floatPaise={shift.floatPaise}
cachedCatalog={cachedCatalog}
onLock={() => { 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) setShift(undefined)
setUser(undefined) setUser(undefined)
setItems(undefined) setItems(undefined)

@ -1,15 +1,16 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, Field, Modal, Notice, ThemeSwitcher } from '@sims/ui' 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 // 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 // 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. // role→gate matrix stays the single source of truth without dragging in scrypt.
import { gateFor } from '@sims/auth/permissions' 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 { parseEntry, parseScaleBarcode, WedgeDetector, type ScaleBarcodeProfile } from '@sims/scanning'
import { buildIndex, querySearch } from '@sims/search-core' import { buildIndex, querySearch } from '@sims/search-core'
import { renderReceipt, receiptLines, type ReceiptDoc } from '@sims/printing' 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 { beepErr, beepOk, beepWarn } from './audio'
import { loadPrinterCfg, sendRasterReceipt, sendToPrinter } from './print' import { loadPrinterCfg, sendRasterReceipt, sendToPrinter } from './print'
import { SettingsModal } from './Settings' 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 DISC_WARN_PCT: 10, // a line discount beyond this % of line value raises the supervisor gate
} as const } 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 = { const SCALE_PROFILE: ScaleBarcodeProfile = {
prefixes: ['21'], 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 } interface CommittedBill { docNo: string; payablePaise: number; mode: PaymentMode; at: string }
type NoticeMsg = { tone: 'ok' | 'warn' | 'err'; text: 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: { export function BillingScreen(props: {
user: PosUser user: PosUser
@ -48,6 +73,8 @@ export function BillingScreen(props: {
items: Item[] items: Item[]
shiftId: string shiftId: string
floatPaise: number floatPaise: number
/** True when the app booted from the IndexedDB cache (server was down) — shows the offline banner. */
cachedCatalog?: boolean
onLock: () => void onLock: () => void
}) { }) {
const { store, taxClasses } = props const { store, taxClasses } = props
@ -61,7 +88,11 @@ export function BillingScreen(props: {
// Supervisor-approved overrides accumulate here and ride with postBill; the server // 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. // audits each in the commit transaction (spec P0-2). Cleared on commit / cart-clear.
const [overrides, setOverrides] = useState<BillOverride[]>([]) 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 [stockMap, setStockMap] = useState<Map<string, number>>(new Map())
const [lastBillLines, setLastBillLines] = useState<LineInput[]>([]) const [lastBillLines, setLastBillLines] = useState<LineInput[]>([])
const [changeFlash, setChangeFlash] = useState<number | undefined>() const [changeFlash, setChangeFlash] = useState<number | undefined>()
@ -76,6 +107,8 @@ export function BillingScreen(props: {
const [overlay, setOverlay] = useState<Overlay>('none') const [overlay, setOverlay] = useState<Overlay>('none')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [serverBills, setServerBills] = useState<Record<string, unknown>[] | undefined>() 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 inputRef = useRef<HTMLInputElement>(null)
const wedge = useRef(new WedgeDetector()) 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. // The supervisor panel holds focus (its PIN field) and owns every key but Esc.
const supPanelRef = useRef<typeof supPanel>(undefined) const supPanelRef = useRef<typeof supPanel>(undefined)
supPanelRef.current = supPanel 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 // 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 // supply to their state — the server derives this same decision independently
@ -120,12 +156,98 @@ export function BillingScreen(props: {
.catch(() => undefined) .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 addItem = (item: Item, qty?: number) => {
const addQty = qty ?? pendingQty ?? 1 const addQty = qty ?? pendingQty ?? 1
// Whole quantities only for unit-less items; keep the multiplier for a rescan. // Whole quantities only for unit-less items; keep the multiplier for a rescan.
if (item.unit.decimals === 0 && !Number.isInteger(addQty)) { if (item.unit.decimals === 0 && !Number.isInteger(addQty)) {
return say('err', `Whole quantities only for ${item.unit.code} — rescan (E-1104)`) 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) setPendingQty(undefined)
beepOk() beepOk()
const onHand = stockMap.get(item.code) 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`) say('warn', `${item.name} shows out of stock — the shelf is the arbiter, billing anyway`)
} }
setLines((prev) => { setLines((prev) => {
if (item.unit.decimals === 0 && qty === undefined) { if (item.unit.decimals === 0 && qtyParam === undefined && batchId === undefined) {
const i = prev.findIndex((l) => l.itemId === item.id) const i = prev.findIndex((l) => l.itemId === item.id && l.batchId === undefined)
if (i >= 0) { if (i >= 0) {
const copy = [...prev] const copy = [...prev]
copy[i] = { ...copy[i]!, qty: copy[i]!.qty + addQty } copy[i] = { ...copy[i]!, qty: copy[i]!.qty + addQty }
@ -148,6 +270,7 @@ export function BillingScreen(props: {
unitCode: item.unit.code, unitPricePaise: item.salePricePaise, unitCode: item.unit.code, unitPricePaise: item.salePricePaise,
priceIncludesTax: item.priceIncludesTax, taxClassCode: item.taxClassCode, priceIncludesTax: item.priceIncludesTax, taxClassCode: item.taxClassCode,
...(item.mrpPaise !== undefined ? { mrpPaise: item.mrpPaise } : {}), ...(item.mrpPaise !== undefined ? { mrpPaise: item.mrpPaise } : {}),
...(batchId !== undefined ? { batchId } : {}),
}] }]
}) })
setNotice(undefined) setNotice(undefined)
@ -238,22 +361,66 @@ export function BillingScreen(props: {
* modal), resolves with the approver on success or undefined on cancel / 3 fails. * 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. * F4 discounts (P1-3) and future Del/void reuse this exact promise.
*/ */
const supervisorGate = (desc: string): Promise<Approval | undefined> => const supervisorGate = (desc: string, override: OverrideContext): Promise<Approval | undefined> =>
new Promise((resolve) => setSupPanel({ desc, resolve })) new Promise((resolve) => setSupPanel({ desc, override, resolve }))
const applyPrice = (i: number, paise: number, approval?: Approval, before?: number) => { /** Append an override that rides with the bill; the server binds/audits it (spec P0-2, H7). */
setLines((prev) => prev.map((x, j) => (j === i ? { ...x, unitPricePaise: paise } : x))) const pushOverride = (o: BillOverride) => setOverrides((prev) => [...prev, o])
if (approval !== undefined && before !== undefined) {
const itemId = lines[i]?.itemId /**
if (itemId !== undefined) { * The inline FEFO picker resolved a batch (spec S5). A non-expired lot adds the line
setOverrides((o) => [...o, { itemId, kind: 'price', before, after: paise, approvedByUserId: approval.approvedByUserId }]) * 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() inputRef.current?.focus()
} }
// F3 price override (spec P0-2). price>MRP is a hard block; a deviation beyond the // 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. // ±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) => { const commitPriceEdit = async (i: number, v: string) => {
setEditCell(undefined) setEditCell(undefined)
const line = lines[i] const line = lines[i]
@ -268,12 +435,19 @@ export function BillingScreen(props: {
if (paise === line.unitPricePaise) { inputRef.current?.focus(); return } // no-op if (paise === line.unitPricePaise) { inputRef.current?.focus(); return } // no-op
const ref = cat.find((c) => c.id === line.itemId)?.salePricePaise ?? line.unitPricePaise 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 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( const approval = await supervisorGate(
`${line.name}: rate ${formatINR(line.unitPricePaise)}${formatINR(paise)}${beyondBand ? ` (beyond ±${POS_LIMITS.PRICE_BAND_PCT}% band)` : ''}`, `${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 } 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}`) say('ok', `Rate override approved by ${approval.approvedByName}`)
} }
@ -297,37 +471,55 @@ export function BillingScreen(props: {
const lineValue = line.qty * line.unitPricePaise const lineValue = line.qty * line.unitPricePaise
const discPaise = isPct ? Math.round(lineValue * Number(m[1]) / 100) : Math.round(Number(m[1]) * 100) 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 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))) 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() inputRef.current?.focus()
} }
if (!overCap || userGate('DISCOUNT_OVER_CAP') === 'allow') { apply(); return } if (!overCap) { applyDiscount(); return } // within the band: no approval, no override
const approval = await supervisorGate(`${line.name}: discount ${formatINR(discPaise)} (> ${POS_LIMITS.DISC_WARN_PCT}% of line)`) 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 } 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}`) say('ok', `Discount approved by ${approval.approvedByName}`)
} }
const commit = (mode: PaymentMode, tenderedPaise?: number) => { type Computed = NonNullable<typeof computed>
if (busy) return
if (computed === undefined) return say('warn', 'Nothing to bill') /**
setBusy(true) * Print a receipt. `docNo` is the GST doc no online, or "PROVISIONAL OFF-n
const totals = computed.totals * (offline)" for a queued bill. Reads `customer`/`supply` from the render's
const committedLines = lines * closure correct even after the cart-clear setState calls, which don't
postBill({ * mutate these locals.
storeId: store.id, counterId: store.counterId, shiftId: props.shiftId, */
...(customer !== undefined ? { customerId: customer.id } : {}), const printBill = (docNo: string, snapshot: Computed, mode: PaymentMode): Promise<void> => {
businessDate: today, lines, const cfg = loadPrinterCfg()
payments: [{ mode, amountPaise: totals.payablePaise }], const kick = mode === 'CASH'
clientPayablePaise: totals.payablePaise, const receiptDoc: ReceiptDoc = {
...(overrides.length > 0 ? { overrides } : {}), // audited server-side in the commit txn (spec P0-2) storeName: store.name, gstin: store.gstin, docNo, businessDate: today,
}) counterCode: store.counterCode, cashierName: props.user.name,
.then((res) => { ...(customer !== undefined ? { buyerName: customer.name } : {}),
// The bill is committed server-side; printing is a consequence (09-UX §5.4). ...(supply.buyerGstin !== undefined ? { buyerGstin: supply.buyerGstin } : {}),
setDayLog((d) => [...d, { docNo: res.docNo, payablePaise: totals.payablePaise, mode, at: new Date().toLocaleTimeString() }]) 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) setLastBillLines(committedLines) // Ctrl+B repeat (spec P1-1)
// decrement local stock view optimistically (spec P1-5) // decrement local stock view optimistically (spec P1-5)
setStockMap((m) => { setStockMap((m) => {
@ -338,8 +530,8 @@ export function BillingScreen(props: {
} }
return next return next
}) })
if (tenderedPaise !== undefined && tenderedPaise > totals.payablePaise) { if (tenderedPaise !== undefined && tenderedPaise > snapshot.totals.payablePaise) {
setChangeFlash(tenderedPaise - totals.payablePaise) setChangeFlash(tenderedPaise - snapshot.totals.payablePaise)
setTimeout(() => setChangeFlash(undefined), 4000) setTimeout(() => setChangeFlash(undefined), 4000)
} }
setLines([]) setLines([])
@ -347,28 +539,73 @@ export function BillingScreen(props: {
setCustomer(undefined) setCustomer(undefined)
setOverrides([]) // override records belong to the committed cart only setOverrides([]) // override records belong to the committed cart only
setOverlay('none') 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' * Store-server unreachable (S4): instead of losing the sale, persist it to the
? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick) * IndexedDB queue with a client UUIDv7 + provisional token (OFF-n), clear the
: sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick })) * cart, and keep billing. The reconnect drain assigns the real GST doc no later.
return send * Enqueue is awaited BEFORE the cart clears so a queue write failure keeps the
.then(() => say('ok', `Bill ${res.docNo} · ${formatINR(totals.payablePaise)} — printed`)) * 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) => .catch((err: unknown) =>
say('warn', `Bill ${res.docNo} saved — print: ${err instanceof Error ? err.message : String(err)} (E-2101)`)) say('warn', `Bill ${res.docNo} saved — print: ${err instanceof Error ? err.message : String(err)} (E-2101)`))
}) })
.catch((err: unknown) => { .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`) say('err', `${err instanceof Error ? err.message : String(err)} — bill NOT saved, cart kept`)
return undefined
}) })
.finally(() => { .finally(() => {
setBusy(false) setBusy(false)
@ -411,6 +648,10 @@ export function BillingScreen(props: {
inputRef.current?.focus() inputRef.current?.focus()
return return
} }
if (batchPickRef.current !== undefined) { // cancel the FEFO pick; item not added
cancelBatchPick()
return
}
setOverlay('none') setOverlay('none')
setNotice(undefined) setNotice(undefined)
setInput('') setInput('')
@ -424,7 +665,10 @@ export function BillingScreen(props: {
// The supervisor panel owns every key but Esc — its PIN field has focus and // The supervisor panel owns every key but Esc — its PIN field has focus and
// F12/commit must never fire mid-approval. // F12/commit must never fire mid-approval.
if (supPanelRef.current !== undefined) return 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. // Suggestion list owns arrows+Enter; inline editors own everything.
if (suggRef.current && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter')) return if (suggRef.current && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter')) return
if (inlineEditRef.current && e.key !== 'F12') return if (inlineEditRef.current && e.key !== 'F12') return
@ -479,6 +723,7 @@ export function BillingScreen(props: {
case 'F6': e.preventDefault(); setOverlay('customer'); break case 'F6': e.preventDefault(); setOverlay('customer'); break
case 'F7': e.preventDefault(); hold(); break case 'F7': e.preventDefault(); hold(); break
case 'F8': e.preventDefault(); resume(); break case 'F8': e.preventDefault(); resume(); break
case 'F9': e.preventDefault(); setOverlay('returns'); break
case 'F10': e.preventDefault(); openHistory(); break case 'F10': e.preventDefault(); openHistory(); break
case 'F11': e.preventDefault(); setOverlay('pay'); break case 'F11': e.preventDefault(); setOverlay('pay'); break
case 'Delete': case 'Delete':
@ -510,6 +755,15 @@ export function BillingScreen(props: {
<Button onClick={props.onLock}>Lock</Button> <Button onClick={props.onLock}>Lock</Button>
</header> </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"> <main className="pos-main">
<section className="pos-left"> <section className="pos-left">
<input <input
@ -543,7 +797,7 @@ export function BillingScreen(props: {
else if (e.key.length === 1) wedge.current.feed(e.key, e.timeStamp) else if (e.key.length === 1) wedge.current.feed(e.key, e.timeStamp)
}} }}
onBlur={() => { 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) setTimeout(() => inputRef.current?.focus(), 0)
} }
}} }}
@ -599,10 +853,21 @@ export function BillingScreen(props: {
{supPanel !== undefined ? ( {supPanel !== undefined ? (
<SupervisorPanel <SupervisorPanel
desc={supPanel.desc} desc={supPanel.desc}
override={supPanel.override}
approvers={props.users.filter((u) => u.role !== 'cashier')} approvers={props.users.filter((u) => u.role !== 'cashier')}
onApprove={(a) => { supPanel.resolve(a); setSupPanel(undefined) }} onApprove={(a) => { supPanel.resolve(a); setSupPanel(undefined) }}
onCancel={() => { supPanel.resolve(undefined); 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 !== undefined ? (
<Notice tone={notice.tone}>{notice.text}</Notice> <Notice tone={notice.tone}>{notice.text}</Notice>
) : null} ) : null}
@ -732,6 +997,12 @@ export function BillingScreen(props: {
<span><span className={`dot ${cfg.enabled ? 'ok' : 'warn'}`} /> <span><span className={`dot ${cfg.enabled ? 'ok' : 'warn'}`} />
{cfg.enabled ? `Printer ${cfg.host}` : 'Printer off'} {cfg.enabled ? `Printer ${cfg.host}` : 'Printer off'}
{window.pos !== undefined ? ' · kiosk shell' : ' · web'}</span> {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>Bills today: {dayLog.length}</span>
<span>Last: {dayLog[dayLog.length - 1]?.docNo ?? '—'}</span> <span>Last: {dayLog[dayLog.length - 1]?.docNo ?? '—'}</span>
<span>Held: {held.length}</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 === 'settings' && <SettingsModal onClose={() => setOverlay('none')} />}
{overlay === 'history' && ( {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. * 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 * 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: { function SupervisorPanel(props: {
desc: string desc: string
override: OverrideContext
approvers: PosUser[] approvers: PosUser[]
onApprove: (a: Approval) => void onApprove: (a: Approval) => void
onCancel: () => void onCancel: () => void
@ -851,8 +1208,8 @@ function SupervisorPanel(props: {
if (who === undefined || pin.length < 4 || busy) return if (who === undefined || pin.length < 4 || busy) return
setBusy(true) setBusy(true)
setError(undefined) setError(undefined)
verifySupervisorPin(who.id, pin) verifySupervisorPin(who.id, pin, props.override)
.then((r) => props.onApprove({ approvedByUserId: r.userId, approvedByName: r.displayName })) .then((r) => props.onApprove({ approvalId: r.approvalId, approvedByName: r.displayName }))
.catch((e: Error) => { .catch((e: Error) => {
const n = fails + 1 const n = fails + 1
setFails(n) setFails(n)
@ -1015,3 +1372,205 @@ function CustomerModal(props: { onClose: () => void; onAttach: (c: PosCustomer)
</Modal> </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' import type { LineInput, TaxClassRow } from '@sims/billing-engine'
/** POS ↔ store-server client. Same origin in production (served under /pos/). */ /** 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 PosUser { id: string; name: string; role: string }
export interface Bootstrap { store: BootstrapStore; users: PosUser[]; taxClasses: TaxClassRow[] } 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> { async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, { const res = await fetch(`/api${path}`, {
@ -20,12 +50,16 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> {
...(token !== '' ? { authorization: `Bearer ${token}` } : {}), ...(token !== '' ? { authorization: `Bearer ${token}` } : {}),
}, },
}).catch(() => undefined) }).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 } const body = (await res.json().catch(() => ({}))) as T & { error?: string }
if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`) if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`)
return body 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> => export const fetchBootstrap = (counter: string): Promise<Bootstrap> =>
call(`/bootstrap?counter=${encodeURIComponent(counter)}`) call(`/bootstrap?counter=${encodeURIComponent(counter)}`)
@ -34,16 +68,40 @@ export async function pinLogin(userId: string, pin: string): Promise<void> {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, pin }), 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 * 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. * 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. * 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 }> => 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 }) }) 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 // The counter caches the whole sellable catalog (no silent cap — R13); ?all=1
// returns every non-inactive item unpaginated. // 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 } 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 { 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: { export const postBill = (body: {
@ -66,6 +129,34 @@ export const postBill = (body: {
overrides?: BillOverride[] overrides?: BillOverride[]
}): Promise<CommitResult> => call('/bills', { method: 'POST', body: JSON.stringify(body) }) }): 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>[]> => export const fetchBills = (date: string, counterId: string): Promise<Record<string, unknown>[]> =>
call(`/bills?date=${date}&counterId=${encodeURIComponent(counterId)}`) call(`/bills?date=${date}&counterId=${encodeURIComponent(counterId)}`)
@ -86,3 +177,53 @@ export const createCustomer = (name: string, phone: string, gstin?: string): Pro
method: 'POST', method: 'POST',
body: JSON.stringify({ name, phone, ...(gstin !== undefined && gstin !== '' ? { gstin } : {}) }), 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() initTheme()
createRoot(document.getElementById('root')!).render(<App />) 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, bundle: true,
platform: 'node', platform: 'node',
format: 'cjs', 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') 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", "type": "module",
"scripts": { "scripts": {
"build": "node build-server.mjs", "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" "typecheck": "tsc -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@sims/auth": "*", "@sims/auth": "*",
"@sims/billing-engine": "*", "@sims/billing-engine": "*",
"@sims/domain": "*", "@sims/domain": "*",
"better-sqlite3": "^11.7.0", "better-sqlite3-multiple-ciphers": "^11.10.0",
"express": "^4.21.0" "express": "^4.21.0",
"helmet": "^8.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",

@ -1,21 +1,35 @@
import { Router, type Request, type Response, type NextFunction } from 'express' import { Router, type Request, type Response, type NextFunction } from 'express'
import { randomUUID } from 'node:crypto' 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 { LineInput } from '@sims/billing-engine'
import type { DB } from './db' import type { DB } from './db'
import { import {
commitBill, createCustomer, createItem, listAudit, listBills, listItems, batchesForStore, commitBill, createCustomer, createItem, createOverrideApproval, expiryView,
listParties, listTaxClasses, stockView, writeAudit, listAudit, listBills, listItems, listParties, listTaxClasses, resolveDefaultTaxClass,
resolveDraftMaxPricePaise, stockView, verifyAuditChain, writeAudit,
} from './repos' } from './repos'
import { commitPurchase, costHistory, lastCosts, listPurchases, type CommitPurchaseInput } from './repos-purchase' 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 { interface Session {
token: string; userId: string; tenantId: string; displayName: string; roles: string[] 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 { interface UserRow {
id: string; tenant_id: string; username: string; display_name: string id: string; tenant_id: string; username: string; display_name: string
roles: string; active: number roles: string; active: number
@ -23,26 +37,173 @@ interface UserRow {
pw_salt: string | null; pw_hash: string | null pw_salt: string | null; pw_hash: string | null
} }
function requireAuth(req: Request, res: Response, next: NextFunction): void { /** Injection points so tests can force tiny TTLs / rate caps / a stub DNS resolver. */
const token = (req.headers.authorization ?? '').replace('Bearer ', '') 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) const session = sessions.get(token)
if (session === undefined) { 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 return
} }
session.lastSeenMs = now
;(req as Request & { session: Session }).session = session ;(req as Request & { session: Session }).session = session
next() next()
} }
const sess = (req: Request): Session => (req as Request & { session: Session }).session /**
* Global + per-IP throttle for /auth/* (M2/M4, rt-F7). Layered ON TOP of the per-user scrypt
export function apiRouter(db: DB): Router { * lockout: the lockout stops repeated failures against ONE user; this blunts a parallel grind
const r = Router() * 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 startSession = (u: UserRow): Session => {
const now = Date.now()
const s: Session = { const s: Session = {
token: randomUUID(), userId: u.id, tenantId: u.tenant_id, token: randomUUID(), userId: u.id, tenantId: u.tenant_id,
displayName: u.display_name, roles: JSON.parse(u.roles) as string[], displayName: u.display_name, roles: JSON.parse(u.roles) as string[],
createdMs: now, lastSeenMs: now,
} }
sessions.set(s.token, s) sessions.set(s.token, s)
return s return s
@ -63,30 +224,59 @@ export function apiRouter(db: DB): Router {
} }
// POS: name-tile + PIN // 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 { 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 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) 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 // 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 { 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 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) 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 — // 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 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 // 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 / // and a PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor / manager / owner roles
// manager / owner roles may approve an override (09-UX §5.5) — a valid-PIN cashier // may approve an override (09-UX §5.5) — a valid-PIN cashier is rejected loudly.
// is rejected loudly, never granted. //
// 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']) const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
r.post('/auth/verify-pin', (req, res) => { // M2: verify-pin is only ever called mid-billing by an already-logged-in cashier, so it now
const { userId, pin } = req.body as { userId?: string; pin?: string } // 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 u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined
const key = u?.id ?? 'unknown' const key = u?.id ?? 'unknown'
const now = Date.now() const now = Date.now()
@ -106,15 +296,26 @@ export function apiRouter(db: DB): Router {
lockouts.set(key, recordSuccess()) lockouts.set(key, recordSuccess())
const roles = JSON.parse(u.roles) as string[] const roles = JSON.parse(u.roles) as string[]
if (!roles.some((role) => APPROVER_ROLES.has(role))) { 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 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) 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) => { r.get('/bootstrap', (req, res) => {
const authed = sessions.has(bearer(req))
const counterCode = String(req.query['counter'] ?? 'C2') const counterCode = String(req.query['counter'] ?? 'C2')
const store = db.prepare(`SELECT * FROM store LIMIT 1`).get() as Record<string, unknown> 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 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({ res.json({
store: { store: {
id: store['id'], name: store['name'], storeCode: store['code'], id: store['id'], name: store['name'], storeCode: store['code'],
stateCode: store['state_code'], gstin: tenant['gstin'], stateCode: store['state_code'], counterId: counter?.['id'] ?? 'c2', counterCode,
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), taxClasses: listTaxClasses(db),
}) })
}) })
r.use(requireAuth) 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) => { r.get('/items', (req, res) => {
res.json(listItems(db, sess(req).tenantId, { res.json(listItems(db, sess(req).tenantId, {
...(req.query['q'] !== undefined ? { query: String(req.query['q']) } : {}), ...(req.query['q'] !== undefined ? { query: String(req.query['q']) } : {}),
all: req.query['all'] === '1', all: req.query['all'] === '1',
...(req.query['limit'] !== undefined ? { limit: Number(req.query['limit']) } : {}), limit: clampLimit(req.query['limit'], 5000, 5000),
...(req.query['offset'] !== undefined ? { offset: Number(req.query['offset']) } : {}), 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) => { r.post('/items', (req, res) => {
const item = createItem(db, sess(req).tenantId, req.body as Parameters<typeof createItem>[2]) const s = sess(req)
writeAudit(db, sess(req).tenantId, sess(req).userId, 'ITEM_CREATE', 'item', item.id, undefined, item) 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) 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) => { r.get('/parties', (req, res) => {
res.json(listParties(db, sess(req).tenantId, req.query['kind'] as string | undefined, req.query['phone'] as string | undefined)) res.json(listParties(db, sess(req).tenantId, req.query['kind'] as string | undefined, req.query['phone'] as string | undefined))
}) })
r.post('/parties', (req, res) => { r.post('/parties', (req, res) => {
const { name, phone, gstin, stateCode } = req.body as { name: string; phone: string; gstin?: string; stateCode?: string } const b = (req.body ?? {}) as Record<string, unknown>
try { 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, { const p = createCustomer(db, sess(req).tenantId, name, phone, {
...(gstin !== undefined ? { gstin } : {}), ...(gstin !== undefined ? { gstin } : {}),
...(stateCode !== undefined ? { stateCode } : {}), ...(stateCode !== undefined ? { stateCode } : {}),
}) })
writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p) writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p)
res.json(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) => { r.post('/bills', requireAnyPerm('BILL_CREATE'), (req, res) => {
const body = req.body as { const body = validateBillBody(req.body)
storeId: string; counterId: string; shiftId: string; customerId?: string res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }))
businessDate: string; lines: LineInput[] })
payments: { mode: string; amountPaise: number; reference?: string }[]
clientPayablePaise: number // S4 reconnect drain (authenticated): bills a browser POS queued while the store-server
overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[] // 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.
try { r.post('/bills/offline', requireAnyPerm('BILL_CREATE'), (req, res) => {
const out = commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }) const b = (req.body ?? {}) as Record<string, unknown>
res.json(out) const clientId = assertString(b['id'], 'id')
} catch (err) { const body = validateBillBody(req.body)
const status = (err as { status?: number }).status ?? 500 res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId, clientId }))
res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
}
}) })
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( res.json(listBills(
db, sess(req).tenantId, db, s.tenantId,
req.query['date'] as string | undefined, req.query['counterId'] as string | undefined, req.query['date'] as string | undefined, req.query['counterId'] as string | undefined,
req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined, clampLimit(req.query['limit'], 100, 1000),
req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined, 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) => { r.get('/stock', (req, res) => {
const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string } 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)) 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( res.json(listAudit(
db, sess(req).tenantId, db, sess(req).tenantId,
req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined, clampLimit(req.query['limit'], 100, 1000),
req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined, 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)) 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)) 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)) res.json(costHistory(db, sess(req).tenantId, String(req.query['itemId'] ?? ''), req.query['supplierId'] as string | undefined))
}) })
r.post('/purchases', (req, res) => { r.post('/purchases', requireAnyPerm('PURCHASE_ENTRY'), (req, res) => {
const body = req.body as Omit<CommitPurchaseInput, 'tenantId' | 'userId'> const b = (req.body ?? {}) as Record<string, unknown>
try { const supplierId = assertString(b['supplierId'], 'supplierId')
res.json(commitPurchase(db, { ...body, tenantId: sess(req).tenantId, userId: sess(req).userId })) const invoiceNo = assertString(b['invoiceNo'], 'invoiceNo')
} catch (err) { const invoiceDate = assertBusinessDate(b['invoiceDate'], 'invoiceDate')
const status = (err as { status?: number }).status ?? 500 const businessDate = assertBusinessDate(b['businessDate'])
res.status(status).json({ error: err instanceof Error ? err.message : String(err) }) 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 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 fs from 'node:fs'
import path from 'node:path' 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, cess_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL,
payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL, payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL,
buyer_gstin TEXT, place_of_supply TEXT, buyer_gstin TEXT, place_of_supply TEXT,
client_id TEXT,
payload TEXT NOT NULL, created_at_wall TEXT NOT NULL, payload TEXT NOT NULL, created_at_wall TEXT NOT NULL,
lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '', lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '',
device_seq INTEGER NOT NULL DEFAULT 0, device_seq INTEGER NOT NULL DEFAULT 0,
@ -74,7 +80,16 @@ CREATE TABLE IF NOT EXISTS bill (
CREATE TABLE IF NOT EXISTS stock_movement ( CREATE TABLE IF NOT EXISTS stock_movement (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, 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, 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 ( CREATE TABLE IF NOT EXISTS setting (
scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, key TEXT NOT NULL, 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 ( CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, at_wall TEXT NOT NULL, 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, 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 ( CREATE TABLE IF NOT EXISTS outbox (
op_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, device_id TEXT NOT NULL, 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, 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 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 ( CREATE TABLE IF NOT EXISTS purchase (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, 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, 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, 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, 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, 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) 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_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_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_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); 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); 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. // 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('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`) 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 }) 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('journal_mode = WAL')
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
db.exec(SCHEMA) 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 type { DB } from './db'
import { writeAudit } from './repos' import { upsertBatch, writeAudit } from './repos'
/** /**
* Purchases stock IN, cost memory, and price updates in one transaction. * 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). */ /** Optional price revisions decided at entry time (cost-changed prompt). */
newSalePricePaise?: number newSalePricePaise?: number
newMrpPaise?: 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 { 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 }) 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 id = uuidv7()
const now = new Date().toISOString() 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), input.businessDate, taxable, tax, total, input.userId, now, JSON.stringify(input.lines),
) )
const insLine = db.prepare( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) )
const move = db.prepare( 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) `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', ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?, ?)`,
) )
input.lines.forEach((l, i) => { input.lines.forEach((l, i) => {
const m = math[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) // S5: a batch-tracked line names its batch — upsert it (unique key or new) and
move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, input.businessDate, now) // carry the batch_id onto both the purchase line and the stock-IN movement so
if (l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined) { // 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) const before = db.prepare(`SELECT sale_price_paise, mrp_paise FROM item WHERE id=?`).get(l.itemId)
db.prepare( db.prepare(
`UPDATE item SET sale_price_paise = COALESCE(?, sale_price_paise), mrp_paise = COALESCE(?, mrp_paise) WHERE id=? AND tenant_id=?`, `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, { writeAudit(db, input.tenantId, input.userId, 'ITEM_PRICE_UPDATE', 'item', l.itemId, before, {
salePricePaise: l.newSalePricePaise, mrpPaise: l.newMrpPaise, source: `purchase ${input.invoiceNo}`, 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, { writeAudit(db, input.tenantId, input.userId, 'PURCHASE_COMMIT', 'purchase', id, undefined, {
invoiceNo: input.invoiceNo, supplier: input.supplierId, total, 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 { import {
deriveSupply, formatDocNo, fyOf, fyShort, seriesPrefix, uuidv7, validateGstin, deriveSupply, formatDocNo, fyOf, seriesPrefix, uuidv7, validateGstin,
type Item, type Party, type Item, type Party, type RoleCode,
} from '@sims/domain' } from '@sims/domain'
import { can, type ActionCode } from '@sims/auth'
import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine' import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
import type { DB } from './db' 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 * 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 / * Supervisor-approved counter overrides (spec P0-2 / P1-3): each price / qty /
* discount deviation that raised the supervisor PIN gate rides along with the * 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). * 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 = { const OVERRIDE_ACTION = {
price: 'PRICE_OVERRIDE', qty: 'QTY_OVERRIDE', discount: 'DISCOUNT_OVERRIDE', 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 } as const
export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: string; payablePaise: number } { /** Roles allowed to approve a counter override (09-UX §5.5). */
const store = db.prepare(`SELECT * FROM store WHERE id=?`).get(input.storeId) as { code: string; state_code: string } const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
const counter = db.prepare(`SELECT * FROM counter WHERE id=?`).get(input.counterId) as { code: string } /** 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) 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 // 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). // client's claim: a B2B buyer's GSTIN state drives IGST vs CGST/SGST (R5).
const customer = input.customerId !== undefined const customer = input.customerId !== undefined
@ -175,32 +305,138 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo:
store.state_code, store.state_code,
) )
// Same engine everywhere: the server recomputes and must agree to the paisa. // ---- Item-master anchoring (C2, C3, H5, M8) ----
const computed = computeBill(input.lines, { // 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, businessDate: input.businessDate,
supplyStateCode: store.state_code, supplyStateCode: store.state_code,
placeOfSupplyStateCode: supply.placeOfSupplyStateCode, placeOfSupplyStateCode: supply.placeOfSupplyStateCode,
roundToRupee: true, roundToRupee: true,
}, rates) }, rates)
if (computed.totals.payablePaise !== input.clientPayablePaise) { 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) const paid = input.payments.reduce((a, p) => a + p.amountPaise, 0)
if (paid !== computed.totals.payablePaise) { 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 id = uuidv7()
const now = new Date().toISOString() const now = new Date().toISOString()
const fy = fyOf(input.businessDate) 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(() => { const txn = db.transaction(() => {
// per-counter, per-FY series allocated inside the commit transaction // per-counter, per-FY series allocated inside the commit transaction
let series = db.prepare( let series = db.prepare(
`SELECT * FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`, `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 ).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined
if (series === 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( db.prepare(
`INSERT INTO doc_series (tenant_id, store_id, counter_id, doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 'SALE', ?, ?, 1)`, `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) ).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( 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, `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, 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( ).run(
id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate, id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate,
input.cashierId, input.shiftId, input.customerId ?? null, input.cashierId, input.shiftId, input.customerId ?? null,
t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise, t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise,
t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise, 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, payload, now,
) )
const move = db.prepare( 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) `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', ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?, ?)`,
) )
for (const line of computed.lines) { 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 // dormant outbox row (D14): written in the same transaction, drained by nothing yet
db.prepare( db.prepare(
@ -237,18 +486,59 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo:
VALUES (?, ?, 'store-server', 0, 'SALE', ?, ?, ?, 0)`, VALUES (?, ?, 'store-server', 0, 'SALE', ?, ?, ?, 0)`,
).run(uuidv7(), input.tenantId, id, payload, now) ).run(uuidv7(), input.tenantId, id, payload, now)
writeAudit(db, input.tenantId, input.cashierId, 'BILL_COMMIT', 'bill', id, undefined, { docNo, payable: t.payablePaise }) 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 ?? []) { for (const o of input.overrides ?? []) {
const approvedBy = o.approvedByUserId !== undefined if (o.kind !== 'batch') continue
? (db.prepare(`SELECT display_name FROM app_user WHERE id=?`).get(o.approvedByUserId) as { display_name: string } | undefined)?.display_name if (o.approvalId !== undefined) {
?? o.approvedByUserId const appr = redeemToken(o.approvalId, 'batch', o.itemId, 0, 0)
: undefined writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id,
writeAudit( { before: 0, itemId: o.itemId },
db, input.tenantId, input.cashierId, OVERRIDE_ACTION[o.kind], 'bill', id, { after: 0, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId: o.approvalId })
{ itemId: o.itemId, before: o.before }, } else if (committerCan('PRICE_OVERRIDE')) {
{ after: o.after, approvedBy, approvedByUserId: o.approvedByUserId }, 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 return docNo
}) })
@ -259,13 +549,21 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo:
export function listBills( export function listBills(
db: DB, tenantId: string, date?: string, counterId?: string, limit = 100, offset = 0, db: DB, tenantId: string, date?: string, counterId?: string, limit = 100, offset = 0,
cashierId?: string,
): Record<string, unknown>[] { ): 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 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 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] const args: unknown[] = [tenantId]
if (date !== undefined) { sql += ` AND b.business_date=?`; args.push(date) } if (date !== undefined) { sql += ` AND b.business_date=?`; args.push(date) }
if (counterId !== undefined) { sql += ` AND b.counter_id=?`; args.push(counterId) } 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 ?` sql += ` ORDER BY b.created_at_wall DESC LIMIT ? OFFSET ?`
args.push(limit, offset) args.push(limit, offset)
return db.prepare(sql).all(...args) as Record<string, unknown>[] return db.prepare(sql).all(...args) as Record<string, unknown>[]
@ -274,26 +572,144 @@ export function listBills(
// ---------- stock ---------- // ---------- stock ----------
export function stockView(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] { export function stockView(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] {
return db.prepare( 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 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=? 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`, WHERE i.tenant_id=? GROUP BY i.id ORDER BY i.name`,
).all(storeId, tenantId) as Record<string, unknown>[] ).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( export function writeAudit(
db: DB, tenantId: string, userId: string, action: string, db: DB, tenantId: string, userId: string, action: string,
entity: string, entityId: string, before?: unknown, after?: unknown, entity: string, entityId: string, before?: unknown, after?: unknown,
): void { ): 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( db.prepare(
`INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json) `INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(uuidv7(), tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson, prevHash, entryHash)
uuidv7(), tenantId, new Date().toISOString(), userId, action, entity, entityId, }
before === undefined ? null : JSON.stringify(before),
after === undefined ? null : JSON.stringify(after), /**
* 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>[] { 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', '', 'gst.roundToRupee', 'true')
setting.run('system', '', 'discount.capBp', '1000') setting.run('system', '', 'discount.capBp', '1000')
setting.run('tenant', 't1', 'discount.capBp', '1500') 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() 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 express from 'express'
import net from 'node:net'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs'
import https from 'node:https'
import helmet from 'helmet'
import { openDb } from './db' import { openDb } from './db'
import { seedIfEmpty } from './seed' import { ensureBatchTrackingSeed, seedIfEmpty } from './seed'
import { apiRouter } from './api' import { apiRouter } from './api'
/** /**
@ -10,11 +12,59 @@ import { apiRouter } from './api'
* one Node process on the store's server machine * one Node process on the store's server machine
* / back office web app * / back office web app
* /pos POS web app (counters browse here; zero installs) * /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 * /api/health monitoring handshake
* The Oracle 12c repository layer (D12) and the outbox sync agent (D14) land here next. * The Oracle 12c repository layer (D12) and the outbox sync agent (D14) land here next.
*/ */
const app = express() 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' })) app.use(express.json({ limit: '1mb' }))
const POS_DIST = path.resolve(__dirname, '../../pos/dist') const POS_DIST = path.resolve(__dirname, '../../pos/dist')
@ -28,36 +78,52 @@ app.get('/api/health', (_req, res) => {
}) })
const db = openDb() 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) 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.use('/api', apiRouter(db))
app.post('/api/print', (req, res) => { /**
const { host, port, bytes } = req.body as { host?: string; port?: number; bytes?: number[] } * Generic error handler (M7): validators and repositories throw errors tagged with a safe
if (typeof host !== 'string' || typeof port !== 'number' || !Array.isArray(bytes)) { * `status` + `code`; anything untagged is treated as an internal fault and answered with a
res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }' }) * generic message. A stack trace or install path is NEVER sent to the client unexpected
return * errors are logged server-side only. Must be the LAST middleware and take 4 args.
} */
// 'close' always follows 'error'/'timeout' on net sockets — respond exactly once. app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
let done = false const e = (err ?? {}) as { status?: number; code?: string; message?: string }
const finish = (status: number, body: object) => { const status = typeof e.status === 'number' ? e.status : 500
if (done) return if (status >= 500) console.error('[store-server] unhandled error:', err)
done = true if (res.headersSent) return
res.status(status).json(body) res.status(status).json({
} error: status >= 500 ? 'Something went wrong (E-5000)' : (e.message ?? 'Bad request'),
const socket = net.createConnection({ host, port, timeout: 3000 }) code: e.code ?? (status >= 500 ? 'E-5000' : 'E-1000'),
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` })
}) })
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. // 80808085 sit in Windows' Hyper-V excluded port ranges on shop PCs too — avoid them.
const PORT = Number(process.env['PORT'] ?? 5181) const PORT = Number(process.env['PORT'] ?? 5181)
app.listen(PORT, () => { const banner = (proto: string) => () => {
console.log(`SiMS store-server listening on http://localhost:${PORT}`) console.log(`SiMS store-server listening on ${proto}://localhost:${PORT}`)
console.log(` Back office: http://localhost:${PORT}/`) console.log(` Back office: ${proto}://localhost:${PORT}/`)
console.log(` POS: http://localhost:${PORT}/pos/`) 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/auth": "*",
"@sims/billing-engine": "*", "@sims/billing-engine": "*",
"@sims/domain": "*", "@sims/domain": "*",
"better-sqlite3": "^11.7.0", "better-sqlite3-multiple-ciphers": "^11.10.0",
"express": "^4.21.0" "express": "^4.21.0",
"helmet": "^8.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",
@ -4663,6 +4664,17 @@
"prebuild-install": "^7.1.1" "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": { "node_modules/bindings": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@ -5788,6 +5800,18 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "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 * 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 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 { export function assertUsablePin(pin: string): void {
if (!/^\d{4,6}$/.test(pin)) throw new Error('PIN must be 46 digits') 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') 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 { export function hashPin(pin: string): PinHash {
const salt = randomBytes(16) 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') } return { salt: salt.toString('hex'), hash: hash.toString('hex') }
} }
export function verifyPin(pin: string, stored: PinHash): boolean { 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')) 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 { import {
assertUsablePin, hashPin, verifyPin, assertUsablePin, hashPin, verifyPin,
FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, DEFAULT_LOCKOUT, 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', () => { describe('lockout reducer', () => {
it('locks after max failures and unlocks after the window', () => { it('locks after max failures and unlocks after the window', () => {
let s = FRESH_LOCKOUT 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 './tax'
export * from './compute' 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 return docNo
} }
/** e.g. seriesPrefix("ST1", "C2", "26") → "ST1C2/26" */ /**
export function seriesPrefix(storeCode: string, counterCode: string, fyShortMark: string): string { * The per-counter series prefix. It embeds the FULL 4-digit FY START YEAR (M9 collision fix):
return `${storeCode}${counterCode}/${fyShortMark}` * 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 } { export function nextDocNo(series: DocSeries): { docNo: string; series: DocSeries } {
const docNo = formatDocNo(series.prefix, series.nextSeq) const docNo = formatDocNo(series.prefix, series.nextSeq)
return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } } return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } }

@ -6,4 +6,5 @@ export * from './doc-series'
export * from './gstin' export * from './gstin'
export * from './masters' export * from './masters'
export * from './documents' export * from './documents'
export * from './batch'
export * from './outbox' 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', () => { describe('document series — offline per-counter numbering', () => {
const series: DocSeries = { const series: DocSeries = {
tenantId: 't', storeId: 's', counterId: 'c', docType: 'SALE', 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) const { docNo, series: bumped } = nextDocNo(series)
expect(docNo).toBe('ST1C2/26-000123') expect(docNo).toBe('ST1C22026-000123')
expect(docNo.length).toBeLessThanOrEqual(16) expect(docNo.length).toBeLessThanOrEqual(16)
expect(bumped.nextSeq).toBe(124) 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', () => { it('rejects numbers that break the GST rule', () => {
expect(() => assertGstDocNo('THIS/PREFIX/IS/TOO/LONG-000001')).toThrow(/16-char/) expect(() => assertGstDocNo('THIS/PREFIX/IS/TOO/LONG-000001')).toThrow(/16-char/)
expect(() => formatDocNo('BAD_CHAR', 1)).toThrow(/16-char/) // underscore not allowed expect(() => formatDocNo('BAD_CHAR', 1)).toThrow(/16-char/) // underscore not allowed
}) })
it('FY rollover restarts the sequence', () => { 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(rolled.nextSeq).toBe(1)
expect(rolloverForFy(series, '2026-27', series.prefix)).toBe(series) expect(rolloverForFy(series, '2026-27', series.prefix)).toBe(series)
}) })

@ -17,6 +17,13 @@ export interface ReceiptDoc {
totals: BillTotals totals: BillTotals
paymentLabel: string paymentLabel: string
footer?: 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 { export interface ReceiptOptions {
@ -35,15 +42,19 @@ const inr = (p: number) => formatINR(p, { symbol: false })
*/ */
export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] { export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] {
const w = opts.width const w = opts.width
const cn = doc.variant === 'credit-note'
const out: string[] = [] const out: string[] = []
out.push(doc.storeName) out.push(doc.storeName)
if (doc.storeAddress !== undefined) out.push(doc.storeAddress) if (doc.storeAddress !== undefined) out.push(doc.storeAddress)
if (doc.gstin !== undefined) out.push(`GSTIN: ${doc.gstin}`) if (doc.gstin !== undefined) out.push(`GSTIN: ${doc.gstin}`)
if (cn) out.push('*** CREDIT NOTE ***')
out.push(hr(w)) 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)) out.push(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w))
if (doc.buyerName !== undefined) out.push(`Buyer: ${doc.buyerName}`) if (doc.buyerName !== undefined) out.push(`Buyer: ${doc.buyerName}`)
if (doc.buyerGstin !== undefined) out.push(`Buyer GSTIN: ${doc.buyerGstin}`) if (doc.buyerGstin !== undefined) out.push(`Buyer GSTIN: ${doc.buyerGstin}`)
if (cn && doc.reason !== undefined) out.push(`Reason: ${doc.reason}`)
out.push(hr(w)) out.push(hr(w))
for (const l of doc.lines) { for (const l of doc.lines) {
out.push(l.name) 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.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.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)) 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(cn ? 'REFUND' : 'PAYABLE', inr(doc.totals.payablePaise), w))
out.push(twoCol('Paid by', doc.paymentLabel, w)) out.push(twoCol(cn ? 'Refund via' : 'Paid by', doc.paymentLabel, w))
if (doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`) if (!cn && doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`)
out.push(doc.footer ?? 'Thank you! Visit again') out.push(doc.footer ?? (cn ? 'Refund processed. Keep this credit note.' : 'Thank you! Visit again'))
return out return out
} }
/** Thermal receipt bytes. Pure — golden-testable without a printer. */ /** Thermal receipt bytes. Pure — golden-testable without a printer. */
export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array { export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array {
const w = opts.width const w = opts.width
const cn = doc.variant === 'credit-note'
const p = new EscPos().init() const p = new EscPos().init()
if (opts.kickDrawer) p.drawerKick() 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) 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.storeAddress !== undefined) p.line(doc.storeAddress)
if (doc.gstin !== undefined) p.line(`GSTIN: ${doc.gstin}`) if (doc.gstin !== undefined) p.line(`GSTIN: ${doc.gstin}`)
if (cn) p.bold(true).line('*** CREDIT NOTE ***').bold(false)
p.line(hr(w)) p.line(hr(w))
p.align('left') 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)) p.line(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w))
if (doc.buyerName !== undefined) p.line(`Buyer: ${doc.buyerName}`) if (doc.buyerName !== undefined) p.line(`Buyer: ${doc.buyerName}`)
if (doc.buyerGstin !== undefined) p.line(`Buyer GSTIN: ${doc.buyerGstin}`) if (doc.buyerGstin !== undefined) p.line(`Buyer GSTIN: ${doc.buyerGstin}`)
if (cn && doc.reason !== undefined) p.line(`Reason: ${doc.reason}`)
p.line(hr(w)) p.line(hr(w))
for (const l of doc.lines) { 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.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.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)) 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.bold(true).size(1, 2).line(twoCol(cn ? 'REFUND' : 'PAYABLE', inr(doc.totals.payablePaise), w)).size(1, 1).bold(false)
p.line(twoCol('Paid by', doc.paymentLabel, w)) p.line(twoCol(cn ? 'Refund via' : 'Paid by', doc.paymentLabel, w))
if (doc.totals.savingsVsMrpPaise > 0) { if (!cn && doc.totals.savingsVsMrpPaise > 0) {
p.align('center').line(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`) 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() p.feed(4).cut()
return p.build() return p.build()
} }

Loading…
Cancel
Save