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

Section summary

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

B2B — invoices to registered buyers (Table 4A)

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

B2C Small — aggregated by place of supply + rate (Table 7)

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

B2C Large — inter-state invoices above ₹2,50,000 (Table 5)

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

{props.title}

+ + + ) +} + +function HsnTable(props: { rows: HsnRow[]; totals: TaxAmounts }) { + const rows: Record[] = 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 ( + <> +

HSN summary (Table 12)

+ + + ) +} + +function DocsTable(props: { docs: Gstr1Response['docs'] }) { + if (props.docs.length === 0) return null + return ( + <> +

Documents issued (Table 13)

+ ({ + 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() + const download = (): void => { + setJsonErr(undefined) + getGstr1Json(period).then((obj) => downloadJson(obj, `GSTR1-${period}.json`)).catch((e: Error) => setJsonErr(e.message)) + } + return ( +
+ Download JSON} + /> + + + Period {period} + {data !== undefined && {data.counts.saleDocs} invoices · {data.counts.returnDocs} credit notes} + + {error !== undefined && {error}} + {jsonErr !== undefined && {jsonErr}} + {data === undefined && error === undefined ? Loading… : data !== undefined && ( + <> + + + + + + + + {data.b2b.length > 0 && } + {data.b2cs.length > 0 && } + {data.b2cl.length > 0 && } + {data.cdnr.length > 0 && } + {data.cdnur.length > 0 && } + + + + + )} +
+ ) +} + +export function Gstr3bPage() { + const [period, setPeriod] = useState(thisMonth()) + const { data, error } = useData(() => getGstr3b(period), [period]) + return ( +
+ + + + Period {period} + + {error !== undefined && {error}} + {data === undefined && error === undefined ? Loading… : data !== undefined && ( + <> + + + + + + +

3.1 Details of outward supplies

+ +

How 3.1(a) is derived

+ +
+ {data.itc.note} +
+ + + )} +
+ ) +} + +export function HsnPage() { + const [period, setPeriod] = useState(thisMonth()) + const { data, error } = useData(() => getGstHsn(period), [period]) + return ( +
+ + + + Period {period} + {data !== undefined && {data.rows.length} HSN rows} + + {error !== undefined && {error}} + {data === undefined && error === undefined ? Loading… : data !== undefined && ( + <> + + + + + + {data.rows.length === 0 ? No outward supply in {period}. + : } + + + )} +
+ ) +} diff --git a/apps/backoffice/src/pages/live.tsx b/apps/backoffice/src/pages/live.tsx index 96c6195..63a4a41 100644 --- a/apps/backoffice/src/pages/live.tsx +++ b/apps/backoffice/src/pages/live.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react' import { formatINR, type BillLine, type BillTotals } from '@sims/domain' import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing' -import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, Toolbar } from '@sims/ui' -import { createItem, getAudit, getBills, getItems, getParties, getSeller, getStock, type Seller } from '../api' +import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui' +import { createItem, getAudit, getBills, getExpiry, getItems, getParties, getReturns, getSeller, getStock, type Seller } from '../api' /** The first live pages — real data from the store-server (the slice). */ @@ -243,28 +243,112 @@ export function BillsPage() { ) } +/** + * Sales → Returns & Credit Notes — LIVE. Every SALE_RETURN document, tenant-scoped and + * paginated (R13). A credit note references its original bill (Against Bill) and reverses tax + * at the original bill-date rates; the refund is the immutable document's payable. Replaces the + * registry wireframe via the CUSTOM map. + */ +export function ReturnsPage() { + const { rows, error, hasMore, loading, loadMore } = usePaged>( + (limit, offset) => getReturns(limit, offset), [], + ) + const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0) + const reasonOf = (b: Record): string => { + try { return String((JSON.parse(String(b['payload'])) as { reason?: string }).reason ?? '') } catch { return '' } + } + return ( +
+ + + {rows.length}{hasMore ? '+' : ''} credit notes + {formatINR(total)} refunded + + {error !== undefined && {error}} + {rows.length === 0 && loading ? Loading… + : rows.length === 0 && error === undefined ? No credit notes yet — process a return at the POS (F9). : ( + ({ + 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 && ( +
+ +
+ )} +
+ ) +} + export function StockPage() { const { data, error } = useData(() => getStock()) + // S5: the per-batch breakdown for batch-tracked items (a second table, kept simple). + const { data: batches } = useData(() => getExpiry()) + const today = new Date().toISOString().slice(0, 10) return (
- + {error !== undefined && {error}} {data === undefined ? Loading… : ( { const onHand = Number(s['on_hand']) return { code: String(s['code']), name: String(s['name']), onhand: `${Math.round(onHand * 1000) / 1000} ${String(s['unit_code'])}`, + tracked: Number(s['batch_tracked']) === 1 ? batch : '—', state: onHand <= 0 ? out : onHand < 15 ? low : ok, } })} /> )} + {batches !== undefined && batches.length > 0 && ( + <> +

Batch-tracked stock

+ { + 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 ? no expiry + : expired ? {expiry} : expiry, + onhand: `${Math.round(onHand * 1000) / 1000} ${String(b['unit_code'])}`, + value: inr(onHand * Number(b['sale_price_paise'])), + } + })} + /> + + )}
) } @@ -302,3 +386,89 @@ export function AuditPage() { ) } + +/** + * 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 ( + <> +

{title}

+ ({ + item: r.name, batch: r.batchNo, + expiry: r.expiry === undefined ? '—' + : tone === 'err' ? {r.expiry} + : tone === 'warn' ? {r.expiry} + : 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 ( +
+ + {error !== undefined && {error}} + {data === undefined ? Loading… : rows.length === 0 ? ( + No batch-tracked stock yet — receive a batch in Purchase Entry. + ) : ( + <> + + + + + + {bucketTable('Expired — clear or write off', 'err', expired)} + {bucketTable('Expiring within 7 days', 'warn', le7)} + {bucketTable('Expiring within 30 days', undefined, le30)} +
+ + 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. + +
+ + )} +
+ ) +} diff --git a/apps/backoffice/src/pages/purchase-entry.tsx b/apps/backoffice/src/pages/purchase-entry.tsx index d632eae..879f915 100644 --- a/apps/backoffice/src/pages/purchase-entry.tsx +++ b/apps/backoffice/src/pages/purchase-entry.tsx @@ -27,6 +27,7 @@ interface ItemLite { id: string; code: string; name: string; taxClassCode: string salePricePaise: number; mrpPaise?: number unit: { code: string; decimals: number }; barcodes: string[] + batchTracked: boolean } interface PLine { @@ -35,6 +36,9 @@ interface PLine { lastCostPaise?: number currentSalePaise: number; currentMrpPaise?: number newSalePricePaise?: number; newMrpPaise?: number + /** S5: batch-tracked items extend the Enter chain qty→cost→batch→expiry→scan. */ + batchTracked: boolean + batchNo?: string; expiryDate?: string } const inr = (p: number) => formatINR(p, { symbol: false }) @@ -51,14 +55,15 @@ export function PurchaseEntryPage() { const [invoiceNo, setInvoiceNo] = useState('') const [invoiceDate, setInvoiceDate] = useState(today) const [lines, setLines] = useState([]) + const anyBatch = lines.some((l) => l.batchTracked) const [entry, setEntry] = useState('') const [printedTotal, setPrintedTotal] = useState('') const [notice, setNotice] = useState<{ tone: 'ok' | 'warn' | 'err'; text: string } | undefined>() const [busy, setBusy] = useState(false) /** Filled on post so the receiving clerk can print labels for what just came in. */ const [labelQueue, setLabelQueue] = useState() - /** Which cell owns focus: the scan box, or qty/cost of a line. */ - const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' } | undefined>() + /** Which cell owns focus: the scan box, or qty/cost/batch/expiry of a line. */ + const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' | 'batch' | 'expiry' } | undefined>() const entryRef = useRef(null) const cellRef = useRef(null) @@ -106,8 +111,14 @@ export function PurchaseEntryPage() { }, [supplier?.id]) useEffect(() => { - if (focusCell !== undefined) cellRef.current?.select() - else entryRef.current?.focus() + if (focusCell !== undefined) { + const el = cellRef.current + el?.focus() + // select() only text-likes; a date cell (expiry) would throw, so focus only. + if (el !== null && (focusCell.cell === 'qty' || focusCell.cell === 'cost')) el.select() + } else { + entryRef.current?.focus() + } }, [focusCell, lines.length]) const say = (tone: 'ok' | 'warn' | 'err', text: string) => setNotice({ tone, text }) @@ -134,6 +145,7 @@ export function PurchaseEntryPage() { : {}), currentSalePaise: item.salePricePaise, ...(item.mrpPaise !== undefined ? { currentMrpPaise: item.mrpPaise } : {}), + batchTracked: item.batchTracked === true, }] }) setNotice(undefined) @@ -200,6 +212,9 @@ export function PurchaseEntryPage() { taxRateBp: l.taxRateBp, ...(l.newSalePricePaise !== undefined ? { newSalePricePaise: l.newSalePricePaise } : {}), ...(l.newMrpPaise !== undefined ? { newMrpPaise: l.newMrpPaise } : {}), + // S5: batch/expiry ride with a batch-tracked line; the server upserts the lot. + ...(l.batchTracked && l.batchNo !== undefined && l.batchNo !== '' ? { batchNo: l.batchNo } : {}), + ...(l.batchTracked && l.expiryDate !== undefined && l.expiryDate !== '' ? { expiryDate: l.expiryDate } : {}), })), clientTotalPaise: totals.total, }) @@ -332,6 +347,7 @@ export function PurchaseEntryPage() { #ItemQtyCost ₹ + {anyBatch && <>BatchExpiry} LastGSTLine totalPrice @@ -340,37 +356,85 @@ export function PurchaseEntryPage() { const lineTaxable = Math.round(l.qty * l.unitCostPaise) const lineTax = Math.round((lineTaxable * l.taxRateBp) / 10_000) const costChanged = l.lastCostPaise !== undefined && l.unitCostPaise !== l.lastCostPaise && l.unitCostPaise > 0 - const cell = (kind: 'qty' | 'cost') => - focusCell?.line === i && focusCell.cell === kind ? ( - { - if (e.key !== 'Enter' && e.key !== 'Tab') return - e.preventDefault() - const v = (e.target as HTMLInputElement).value - if (kind === 'qty') { - const q = Number(v) - if (q > 0) setLine(i, { qty: q }) - setFocusCell({ line: i, cell: 'cost' }) - } else { - setLine(i, { unitCostPaise: rs(v) }) - setFocusCell(undefined) // Enter on cost returns to the scan box - } - }} - /> - ) : ( + const editing = (kind: 'qty' | 'cost' | 'batch' | 'expiry') => + focusCell?.line === i && focusCell.cell === kind + // Enter chain: qty → cost → (batch-tracked ? batch → expiry :) scan box. + const cell = (kind: 'qty' | 'cost' | 'batch' | 'expiry') => { + if (editing(kind) && kind === 'expiry') { + return ( + { + 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 ( + { + 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 ( + { + if (e.key !== 'Enter' && e.key !== 'Tab') return + e.preventDefault() + const v = (e.target as HTMLInputElement).value + if (kind === 'qty') { + const q = Number(v) + if (q > 0) setLine(i, { qty: q }) + setFocusCell({ line: i, cell: 'cost' }) + } else { + setLine(i, { unitCostPaise: rs(v) }) + // 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 + const shown = kind === 'batch' ? (l.batchNo ?? 'set…') : (l.expiryDate ?? '—') + return ( + setFocusCell({ line: i, cell: kind })}>{shown} + ) + } + return ( setFocusCell({ line: i, cell: kind })}> {kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)} ) + } return ( {i + 1} {l.name} {cell('qty')} {cell('cost')} + {anyBatch && <>{cell('batch')}{cell('expiry')}} {l.lastCostPaise !== undefined ? inr(l.lastCostPaise) : '—'} {l.taxRateBp / 100}% {inr(lineTaxable + lineTax)} diff --git a/apps/pos/public/sw.js b/apps/pos/public/sw.js new file mode 100644 index 0000000..bed009c --- /dev/null +++ b/apps/pos/public/sw.js @@ -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()) + }), + ) +}) diff --git a/apps/pos/src/App.tsx b/apps/pos/src/App.tsx index 3a229c8..f25b93a 100644 --- a/apps/pos/src/App.tsx +++ b/apps/pos/src/App.tsx @@ -1,26 +1,74 @@ import { useEffect, useState } from 'react' import { Button, Notice } from '@sims/ui' import { uuidv7, type Item } from '@sims/domain' -import { fetchBootstrap, fetchItemsCache, type Bootstrap, type PosUser } from './api' +import { fetchBootstrap, fetchItemsCache, hasSession, logout, type Bootstrap, type PosUser } from './api' +import { clearCatalogCache, loadCatalogCache, saveCatalogCache } from './offline' import { Login } from './Login' import { ShiftOpen } from './ShiftOpen' import { BillingScreen } from './BillingScreen' +/** Shift context kept small enough to resume from sessionStorage after a refresh. */ +interface Shift { id: string; floatPaise: number } + +/** + * Resume context (S4): the authenticated user + open shift, persisted in + * sessionStorage so a mid-shift browser refresh with the server DOWN lands back on + * the billing screen (paired with the cached catalog in IndexedDB and the session + * token, also in sessionStorage). Dies with the tab — a fresh login still needs + * the server (a locally-cached PIN hash login is deliberately OUT OF SCOPE). + */ +const RESUME_KEY = 'pos.resume' +const readResume = (): { user: PosUser; shift: Shift } | undefined => { + try { + const raw = sessionStorage.getItem(RESUME_KEY) + return raw !== null ? (JSON.parse(raw) as { user: PosUser; shift: Shift }) : undefined + } catch { return undefined } +} +const writeResume = (user: PosUser, shift: Shift): void => { + try { sessionStorage.setItem(RESUME_KEY, JSON.stringify({ user, shift })) } catch { /* ignore */ } +} +const clearResume = (): void => { + try { sessionStorage.removeItem(RESUME_KEY) } catch { /* ignore */ } +} + /** POS flow: bootstrap from store-server → login (PIN) → shift open → billing. */ export function App() { const [boot, setBoot] = useState() const [bootError, setBootError] = useState() const [user, setUser] = useState() const [items, setItems] = useState() - const [shift, setShift] = useState<{ id: string; floatPaise: number } | undefined>() + const [shift, setShift] = useState() + // True when this session booted from the IndexedDB cache (server was unreachable). + const [cachedCatalog, setCachedCatalog] = useState(false) const load = () => { setBootError(undefined) const counter = new URLSearchParams(location.search).get('counter') ?? 'C2' - fetchBootstrap(counter).then(setBoot).catch((e: Error) => setBootError(e.message)) + fetchBootstrap(counter) + .then(setBoot) + .catch(async () => { + // Server unreachable. If we still hold an authenticated session and a cached + // catalog + resume context, reopen the billing screen offline (S4). Otherwise + // surface the error — a fresh login needs the server. + const [cache, resume] = [await loadCatalogCache().catch(() => undefined), readResume()] + if (cache !== undefined && resume !== undefined && hasSession()) { + setBoot({ store: cache.store, users: cache.users, taxClasses: cache.taxClasses }) + setItems(cache.items) + setUser(resume.user) + setShift(resume.shift) + setCachedCatalog(true) + } else { + setBootError('Store-server unreachable (E-3301)') + } + }) } useEffect(load, []) + // Keep the resume context current while a shift is open; clear it on lock. + useEffect(() => { + if (user !== undefined && shift !== undefined) writeResume(user, shift) + }, [user, shift]) + if (bootError !== undefined) { return (
@@ -40,8 +88,23 @@ export function App() { store={boot.store} users={boot.users} onLogin={async (u) => { - setUser(u) - setItems(await fetchItemsCache()) + // M1: the pre-auth bootstrap omits each user's role and the seller GSTIN. Now that we + // hold a session, re-fetch it AUTHENTICATED so the supervisor gate (user.role), the + // approver list (users[].role) and the receipt's seller GSTIN have real data. Falls + // back to the pre-auth boot if the refetch fails (offline right after login). + const counter = new URLSearchParams(location.search).get('counter') ?? 'C2' + const authedBoot = await fetchBootstrap(counter).catch(() => boot) + setBoot(authedBoot) + const enrichedUser = authedBoot.users.find((x) => x.id === u.id) ?? u + setUser(enrichedUser) + const its = await fetchItemsCache() + setItems(its) + // Persist the catalog snapshot so a mid-shift refresh with the server down + // can still reach billing (S4 deliverable 3). + void saveCatalogCache({ + store: authedBoot.store, users: authedBoot.users, taxClasses: authedBoot.taxClasses, + items: its, savedAt: new Date().toISOString(), + }).catch(() => undefined) }} /> ) @@ -67,7 +130,16 @@ export function App() { items={items} shiftId={shift.id} floatPaise={shift.floatPaise} + cachedCatalog={cachedCatalog} onLock={() => { + // M4: end the session server-side so the bearer token stops working immediately + // (best-effort — the local state is cleared regardless of whether the server answers). + void logout() + clearResume() + // M11: wipe the cached catalog + staff roster from IndexedDB so no customer/staff + // data lingers on this shared PC after lock. Un-drained offline bills are kept + // (a lock must never lose a sale) and the catalog is re-fetched on the next login. + void clearCatalogCache().catch(() => undefined) setShift(undefined) setUser(undefined) setItems(undefined) diff --git a/apps/pos/src/BillingScreen.tsx b/apps/pos/src/BillingScreen.tsx index b59594a..b799897 100644 --- a/apps/pos/src/BillingScreen.tsx +++ b/apps/pos/src/BillingScreen.tsx @@ -1,15 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Badge, Button, Field, Modal, Notice, ThemeSwitcher } from '@sims/ui' -import { deriveSupply, formatINR, validateGstin, type Item, type PaymentMode, type RoleCode } from '@sims/domain' +import { deriveSupply, formatINR, isExpired, pickFefoBatch, sortFefo, validateGstin, type BatchStock, type Item, type PaymentMode, type RoleCode } from '@sims/domain' // Subpath import: the @sims/auth barrel re-exports pin.ts (node:crypto), which the // browser bundle can't include. permissions.ts is pure (type-only imports), so the // role→gate matrix stays the single source of truth without dragging in scrypt. import { gateFor } from '@sims/auth/permissions' -import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine' +import { computeBill, computeReturn, type LineInput, type TaxClassRow } from '@sims/billing-engine' import { parseEntry, parseScaleBarcode, WedgeDetector, type ScaleBarcodeProfile } from '@sims/scanning' import { buildIndex, querySearch } from '@sims/search-core' import { renderReceipt, receiptLines, type ReceiptDoc } from '@sims/printing' -import { createCustomer, createDraftItem, fetchBills, fetchStock, findCustomers, postBill, verifySupervisorPin, type BillOverride, type BootstrapStore, type PosCustomer, type PosUser } from './api' +import { createCustomer, createDraftItem, fetchBatches, fetchBills, fetchReturnable, fetchStock, findCustomers, lookupBillByDocNo, lookupBillsByPhone, NetworkError, pingHealth, postBill, postBillOffline, postReturn, verifySupervisorPin, type BillLookupResult, type BillOverride, type BootstrapStore, type OverrideContext, type PosBatchRow, type PosCustomer, type PosUser, type ReturnableBill } from './api' +import { buildQueuedBill, countQueuedBills, enqueueBill, listQueuedBills, nextOffSeq, removeQueuedBill, toOfflineBillBody } from './offline' import { beepErr, beepOk, beepWarn } from './audio' import { loadPrinterCfg, sendRasterReceipt, sendToPrinter } from './print' import { SettingsModal } from './Settings' @@ -24,8 +25,13 @@ const POS_LIMITS = { DISC_WARN_PCT: 10, // a line discount beyond this % of line value raises the supervisor gate } as const -/** A supervisor's approval, resolved by the inline gate panel. */ -interface Approval { approvedByUserId: string; approvedByName: string } +/** + * A supervisor's approval, resolved by the inline gate panel. `approvalId` is the + * single-use token the server minted on the verified PIN; it rides with the override + * so the server records the approver from its own row (SEC fix) — the client no longer + * asserts an approver user id. + */ +interface Approval { approvalId: string; approvedByName: string } const SCALE_PROFILE: ScaleBarcodeProfile = { prefixes: ['21'], @@ -38,7 +44,26 @@ const QUICK_KEYS = ['42', '43', '44', '108'] // per-counter DB config later interface CommittedBill { docNo: string; payablePaise: number; mode: PaymentMode; at: string } type NoticeMsg = { tone: 'ok' | 'warn' | 'err'; text: string } -type Overlay = 'none' | 'pay' | 'settings' | 'history' | 'dayend' | 'customer' +type Overlay = 'none' | 'pay' | 'settings' | 'history' | 'dayend' | 'customer' | 'returns' + +/** Return reasons offered at the counter (09-UX returns/exchange). 'Other' is the free catch-all. */ +const RETURN_REASONS = ['Damaged', 'Wrong item', 'Expired', 'Customer changed mind', 'Other'] as const + +/** Group /api/batches rows into a per-item map (S5) — the FEFO picker's cache. */ +function groupBatches(rows: PosBatchRow[]): Map { + const m = new Map() + for (const r of rows) { + const b: BatchStock = { + id: r.id, itemId: r.item_id, batchNo: r.batch_no, + ...(r.expiry_date !== null ? { expiryDate: r.expiry_date } : {}), + onHand: Number(r.on_hand), + } + const arr = m.get(b.itemId) + if (arr !== undefined) arr.push(b) + else m.set(b.itemId, [b]) + } + return m +} export function BillingScreen(props: { user: PosUser @@ -48,6 +73,8 @@ export function BillingScreen(props: { items: Item[] shiftId: string floatPaise: number + /** True when the app booted from the IndexedDB cache (server was down) — shows the offline banner. */ + cachedCatalog?: boolean onLock: () => void }) { const { store, taxClasses } = props @@ -61,7 +88,11 @@ export function BillingScreen(props: { // Supervisor-approved overrides accumulate here and ride with postBill; the server // audits each in the commit transaction (spec P0-2). Cleared on commit / cart-clear. const [overrides, setOverrides] = useState([]) - 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>(new Map()) + const [batchPick, setBatchPick] = useState<{ item: Item; qty: number; batches: BatchStock[] } | undefined>() const [stockMap, setStockMap] = useState>(new Map()) const [lastBillLines, setLastBillLines] = useState([]) const [changeFlash, setChangeFlash] = useState() @@ -76,6 +107,8 @@ export function BillingScreen(props: { const [overlay, setOverlay] = useState('none') const [busy, setBusy] = useState(false) const [serverBills, setServerBills] = useState[] | undefined>() + // S4: count of bills committed offline and still waiting to drain (the amber chip). + const [offlineCount, setOfflineCount] = useState(0) const inputRef = useRef(null) const wedge = useRef(new WedgeDetector()) @@ -88,6 +121,9 @@ export function BillingScreen(props: { // The supervisor panel holds focus (its PIN field) and owns every key but Esc. const supPanelRef = useRef(undefined) supPanelRef.current = supPanel + // The batch picker owns arrows/Enter while open; the global handler bails for it. + const batchPickRef = useRef(undefined) + batchPickRef.current = batchPick // B2B at the counter (S3): an attached customer with a GSTIN moves the place of // supply to their state — the server derives this same decision independently @@ -120,12 +156,98 @@ export function BillingScreen(props: { .catch(() => undefined) }, []) + // S5: load the FEFO batch cache at login (mount) and again after each commit so + // per-batch on-hand stays live. Keyed by item id. Silent on failure (offline). + const refreshBatches = (): void => { + fetchBatches() + .then((rows) => setBatchMap(groupBatches(rows))) + .catch(() => undefined) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { refreshBatches() }, []) + + // S4 reconnect drain: every 15s (and on the browser 'online' event) ping health; + // when the store-server is back, POST queued bills oldest-first to the idempotent + // /api/bills/offline and map each OFF-n to its real GST doc no. Also runs once on + // mount, to drain a queue a prior session/refresh may have left behind. + useEffect(() => { + let cancelled = false + let draining = false + const drain = async () => { + if (cancelled || draining) return + draining = true + try { + if (!(await pingHealth())) return + const queued = await listQueuedBills() // oldest-first + if (queued.length === 0) return + const mapped: string[] = [] + let sessionExpired = false + for (const q of queued) { + if (cancelled) break + try { + const res = await postBillOffline(toOfflineBillBody(q)) + await removeQueuedBill(q.id) // idempotent: a re-drain returns the same doc no + mapped.push(`${q.provisionalToken} → ${res.docNo}`) + } catch (err) { + // The drain runs authenticated (SEC-1). A 401 means the session lapsed — + // typically the server restarted — so the queue is intact but can't sync + // until the counter signs in again; flag it and stop. Any other error: + // stop and retry next tick. + if (err instanceof Error && /E-6103|not signed in/i.test(err.message)) sessionExpired = true + break + } + } + const remaining = await countQueuedBills() + if (cancelled) return + setOfflineCount(remaining) + if (mapped.length > 0) say('ok', `Synced ${mapped.length} offline bill(s): ${mapped.join(', ')}`) + // Prompt re-login WITHOUT a beep (setNotice, not say) so the 15s retry loop + // isn't noisy; it clears itself once a login refreshes the session and the + // next drain succeeds. + if (sessionExpired && remaining > 0) { + setNotice({ tone: 'warn', text: `Sign in to sync ${remaining} offline bill${remaining === 1 ? '' : 's'} — the session expired after a reconnect` }) + } + } finally { + draining = false + } + } + countQueuedBills().then((n) => { if (!cancelled) setOfflineCount(n) }).catch(() => undefined) + const iv = setInterval(() => { void drain() }, 15000) + const onOnline = (): void => { void drain() } + window.addEventListener('online', onOnline) + void drain() + return () => { cancelled = true; clearInterval(iv); window.removeEventListener('online', onOnline) } + }, []) + const addItem = (item: Item, qty?: number) => { const addQty = qty ?? pendingQty ?? 1 // Whole quantities only for unit-less items; keep the multiplier for a rescan. if (item.unit.decimals === 0 && !Number.isInteger(addQty)) { return say('err', `Whole quantities only for ${item.unit.code} — rescan (E-1104)`) } + // S5: a batch-tracked item routes through the inline FEFO picker before the cart. + if (item.batchTracked) { + const avail = (batchMap.get(item.id) ?? []).filter((b) => b.onHand > 0) + if (avail.length > 0) { + setPendingQty(undefined) + setSugg(undefined) + setBatchPick({ item, qty: addQty, batches: avail }) + return + } + // No lot on hand (batch-tracked item never received with a batch): never block + // the counter (R18) — bill it un-batched and warn. + say('warn', `No batch on hand for ${item.name} — billing without a batch`) + } + finalizeAdd(item, addQty, undefined, qty) + } + + /** + * Append the (optionally batch-picked) line to the cart — the tail of addItem, + * extracted so the batch picker can finalise after a FEFO/expired decision. + * `qtyParam` mirrors addItem's original qty arg: a bare unit scan (undefined) still + * merges into an existing un-batched line; a batch line always stays distinct. + */ + const finalizeAdd = (item: Item, addQty: number, batchId: string | undefined, qtyParam?: number) => { setPendingQty(undefined) beepOk() const onHand = stockMap.get(item.code) @@ -133,8 +255,8 @@ export function BillingScreen(props: { say('warn', `${item.name} shows out of stock — the shelf is the arbiter, billing anyway`) } setLines((prev) => { - if (item.unit.decimals === 0 && qty === undefined) { - const i = prev.findIndex((l) => l.itemId === item.id) + if (item.unit.decimals === 0 && qtyParam === undefined && batchId === undefined) { + const i = prev.findIndex((l) => l.itemId === item.id && l.batchId === undefined) if (i >= 0) { const copy = [...prev] copy[i] = { ...copy[i]!, qty: copy[i]!.qty + addQty } @@ -148,6 +270,7 @@ export function BillingScreen(props: { unitCode: item.unit.code, unitPricePaise: item.salePricePaise, priceIncludesTax: item.priceIncludesTax, taxClassCode: item.taxClassCode, ...(item.mrpPaise !== undefined ? { mrpPaise: item.mrpPaise } : {}), + ...(batchId !== undefined ? { batchId } : {}), }] }) setNotice(undefined) @@ -238,22 +361,66 @@ export function BillingScreen(props: { * modal), resolves with the approver on success or undefined on cancel / 3 fails. * F4 discounts (P1-3) and future Del/void reuse this exact promise. */ - const supervisorGate = (desc: string): Promise => - new Promise((resolve) => setSupPanel({ desc, resolve })) + const supervisorGate = (desc: string, override: OverrideContext): Promise => + new Promise((resolve) => setSupPanel({ desc, override, resolve })) - const applyPrice = (i: number, paise: number, approval?: Approval, before?: number) => { - setLines((prev) => prev.map((x, j) => (j === i ? { ...x, unitPricePaise: paise } : x))) - if (approval !== undefined && before !== undefined) { - const itemId = lines[i]?.itemId - if (itemId !== undefined) { - setOverrides((o) => [...o, { itemId, kind: 'price', before, after: paise, approvedByUserId: approval.approvedByUserId }]) + /** Append an override that rides with the bill; the server binds/audits it (spec P0-2, H7). */ + const pushOverride = (o: BillOverride) => setOverrides((prev) => [...prev, o]) + + /** + * The inline FEFO picker resolved a batch (spec S5). A non-expired lot adds the line + * straight away. An EXPIRED lot is blocked with E-1310 unless approved: a supervisor+ + * at the counter sells it on their own authority; a cashier raises the supervisor gate + * and the approval rides along as a `batch` override (audited BATCH_OVERRIDE server-side). + */ + const acceptBatch = async (batch: BatchStock) => { + const pick = batchPick + setBatchPick(undefined) + if (pick === undefined) return + const { item, qty } = pick + if (isExpired(batch, today)) { + if (userGate('PRICE_OVERRIDE') === 'allow') { + // H7: even a self-authorised expired-batch sale is audited server-side. + pushOverride({ itemId: item.id, kind: 'batch', before: 0, after: 0 }) + finalizeAdd(item, qty, batch.id) + say('warn', `EXPIRED batch ${batch.batchNo} (${batch.expiryDate ?? ''}) sold on your authority (E-1310)`) + inputRef.current?.focus() + return + } + const approval = await supervisorGate( + `${item.name}: batch ${batch.batchNo} EXPIRED ${batch.expiryDate ?? ''} — approve sale? (E-1310)`, + { itemId: item.id, kind: 'batch', before: 0, after: 0 }, + ) + if (approval === undefined) { + say('warn', `Expired batch ${batch.batchNo} not sold — no supervisor approval`) + inputRef.current?.focus() + return } + pushOverride({ itemId: item.id, kind: 'batch', before: 0, after: 0, approvalId: approval.approvalId }) + finalizeAdd(item, qty, batch.id) + say('ok', `Expired batch ${batch.batchNo} approved by ${approval.approvedByName}`) + inputRef.current?.focus() + return } + finalizeAdd(item, qty, batch.id) + inputRef.current?.focus() + } + + const cancelBatchPick = () => { + setBatchPick(undefined) + say('warn', 'Batch selection cancelled — item not added') + inputRef.current?.focus() + } + + const applyPrice = (i: number, paise: number) => { + setLines((prev) => prev.map((x, j) => (j === i ? { ...x, unitPricePaise: paise } : x))) inputRef.current?.focus() } // F3 price override (spec P0-2). price>MRP is a hard block; a deviation beyond the // ±band — or ANY change by a cashier — raises the supervisor gate before it applies. + // The deviation's `before` is the item MASTER price (`ref`), which is exactly what the + // server anchors to and binds the token against (H4) — so the two always agree. const commitPriceEdit = async (i: number, v: string) => { setEditCell(undefined) const line = lines[i] @@ -268,12 +435,19 @@ export function BillingScreen(props: { if (paise === line.unitPricePaise) { inputRef.current?.focus(); return } // no-op const ref = cat.find((c) => c.id === line.itemId)?.salePricePaise ?? line.unitPricePaise const beyondBand = Math.abs(paise - ref) > ref * POS_LIMITS.PRICE_BAND_PCT / 100 - if (userGate('PRICE_OVERRIDE') === 'allow') { applyPrice(i, paise); return } // supervisor+ self-authorises + if (userGate('PRICE_OVERRIDE') === 'allow') { + applyPrice(i, paise) // supervisor+ self-authorises + // H7: a self-authorised deviation from the master is still audited server-side. + if (paise !== ref) pushOverride({ itemId: line.itemId, kind: 'price', before: ref, after: paise }) + return + } const approval = await supervisorGate( `${line.name}: rate ${formatINR(line.unitPricePaise)} → ${formatINR(paise)}${beyondBand ? ` (beyond ±${POS_LIMITS.PRICE_BAND_PCT}% band)` : ''}`, + { itemId: line.itemId, kind: 'price', before: ref, after: paise }, ) if (approval === undefined) { say('warn', 'Price override cancelled — no supervisor approval'); inputRef.current?.focus(); return } - applyPrice(i, paise, approval, line.unitPricePaise) + applyPrice(i, paise) + pushOverride({ itemId: line.itemId, kind: 'price', before: ref, after: paise, approvalId: approval.approvalId }) say('ok', `Rate override approved by ${approval.approvedByName}`) } @@ -297,78 +471,141 @@ export function BillingScreen(props: { const lineValue = line.qty * line.unitPricePaise const discPaise = isPct ? Math.round(lineValue * Number(m[1]) / 100) : Math.round(Number(m[1]) * 100) const overCap = discPaise > lineValue * POS_LIMITS.DISC_WARN_PCT / 100 - const apply = (approval?: Approval) => { + const applyDiscount = () => { setLines((prev) => prev.map((x, j) => (j === i ? { ...x, discount } : x))) - if (approval !== undefined) { - setOverrides((o) => [...o, { itemId: line.itemId, kind: 'discount', before: 0, after: discPaise, approvedByUserId: approval.approvedByUserId }]) - } inputRef.current?.focus() } - if (!overCap || userGate('DISCOUNT_OVER_CAP') === 'allow') { apply(); return } - const approval = await supervisorGate(`${line.name}: discount ${formatINR(discPaise)} (> ${POS_LIMITS.DISC_WARN_PCT}% of line)`) + if (!overCap) { applyDiscount(); return } // within the band: no approval, no override + if (userGate('DISCOUNT_OVER_CAP') === 'allow') { + applyDiscount() + // H7: an over-cap discount applied on the user's own authority is still audited. + pushOverride({ itemId: line.itemId, kind: 'discount', before: 0, after: discPaise }) + return + } + const approval = await supervisorGate( + `${line.name}: discount ${formatINR(discPaise)} (> ${POS_LIMITS.DISC_WARN_PCT}% of line)`, + { itemId: line.itemId, kind: 'discount', before: 0, after: discPaise }, + ) if (approval === undefined) { say('warn', 'Discount cancelled — no supervisor approval'); inputRef.current?.focus(); return } - apply(approval) + applyDiscount() + pushOverride({ itemId: line.itemId, kind: 'discount', before: 0, after: discPaise, approvalId: approval.approvalId }) say('ok', `Discount approved by ${approval.approvedByName}`) } + type Computed = NonNullable + + /** + * Print a receipt. `docNo` is the GST doc no online, or "PROVISIONAL OFF-n + * (offline)" for a queued bill. Reads `customer`/`supply` from the render's + * closure — correct even after the cart-clear setState calls, which don't + * mutate these locals. + */ + const printBill = (docNo: string, snapshot: Computed, mode: PaymentMode): Promise => { + const cfg = loadPrinterCfg() + const kick = mode === 'CASH' + const receiptDoc: ReceiptDoc = { + storeName: store.name, gstin: store.gstin, docNo, businessDate: today, + counterCode: store.counterCode, cashierName: props.user.name, + ...(customer !== undefined ? { buyerName: customer.name } : {}), + ...(supply.buyerGstin !== undefined ? { buyerGstin: supply.buyerGstin } : {}), + lines: snapshot.lines, totals: snapshot.totals, paymentLabel: mode, + } + // Indic receipts rasterize (09-UX §5.3); ASCII stays the fast text path. + // Same content model (receiptLines) so item names match on either path. + return cfg.script === 'raster' + ? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick) + : sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick })) + } + + /** Cart-clear + optimistic local stock + change flash, shared by both commit paths. */ + const clearAfterCommit = (docNo: string, snapshot: Computed, mode: PaymentMode, committedLines: LineInput[], tenderedPaise?: number) => { + setDayLog((d) => [...d, { docNo, payablePaise: snapshot.totals.payablePaise, mode, at: new Date().toLocaleTimeString() }]) + setLastBillLines(committedLines) // Ctrl+B repeat (spec P1-1) + // decrement local stock view optimistically (spec P1-5) + setStockMap((m) => { + const next = new Map(m) + for (const l of committedLines) { + const item = cat.find((i) => i.id === l.itemId) + if (item !== undefined && next.has(item.code)) next.set(item.code, next.get(item.code)! - l.qty) + } + return next + }) + if (tenderedPaise !== undefined && tenderedPaise > snapshot.totals.payablePaise) { + setChangeFlash(tenderedPaise - snapshot.totals.payablePaise) + setTimeout(() => setChangeFlash(undefined), 4000) + } + setLines([]) + setPendingQty(undefined) + setCustomer(undefined) + setOverrides([]) // override records belong to the committed cart only + setOverlay('none') + } + + /** + * Store-server unreachable (S4): instead of losing the sale, persist it to the + * IndexedDB queue with a client UUIDv7 + provisional token (OFF-n), clear the + * cart, and keep billing. The reconnect drain assigns the real GST doc no later. + * Enqueue is awaited BEFORE the cart clears so a queue write failure keeps the + * cart on screen (R18: never lost). + */ + const queueOffline = async (mode: PaymentMode, snapshot: Computed, committedLines: LineInput[], tenderedPaise?: number) => { + try { + const seq = nextOffSeq() + const q = buildQueuedBill({ + seq, + storeId: store.id, counterId: store.counterId, cashierId: props.user.id, shiftId: props.shiftId, + // M11: persist ONLY the customer id — never the buyer's name or GSTIN. The server + // re-resolves buyer identity from the id at drain (same as an online bill), so no + // customer PII is written to this shared PC. The provisional receipt printed below + // still names the buyer, from live in-memory state — not from the queued record. + ...(customer !== undefined ? { customerId: customer.id } : {}), + businessDate: today, + lines: committedLines, + payments: [{ mode, amountPaise: snapshot.totals.payablePaise }], + totals: snapshot.totals, + ...(overrides.length > 0 ? { overrides } : {}), + }) + await enqueueBill(q) + clearAfterCommit(q.provisionalToken, snapshot, mode, committedLines, tenderedPaise) + setOfflineCount((n) => n + 1) + // Provisional receipt: "PROVISIONAL OFF-n (offline)" instead of a GST doc no. + void printBill(`PROVISIONAL ${q.provisionalToken} (offline)`, snapshot, mode).catch(() => undefined) + say('warn', `Store-server offline — saved ${q.provisionalToken}, cart cleared; will sync when it returns`) + } catch (err) { + // Even the local queue failed — keep the cart rather than lose the sale. + say('err', `Could not queue the bill offline: ${err instanceof Error ? err.message : String(err)} — cart kept`) + } + } + const commit = (mode: PaymentMode, tenderedPaise?: number) => { if (busy) return if (computed === undefined) return say('warn', 'Nothing to bill') setBusy(true) - const totals = computed.totals + 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: totals.payablePaise }], - clientPayablePaise: totals.payablePaise, + 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). - setDayLog((d) => [...d, { docNo: res.docNo, payablePaise: totals.payablePaise, mode, at: new Date().toLocaleTimeString() }]) - setLastBillLines(committedLines) // Ctrl+B repeat (spec P1-1) - // decrement local stock view optimistically (spec P1-5) - setStockMap((m) => { - const next = new Map(m) - for (const l of committedLines) { - const item = cat.find((i) => i.id === l.itemId) - if (item !== undefined && next.has(item.code)) next.set(item.code, next.get(item.code)! - l.qty) - } - return next - }) - if (tenderedPaise !== undefined && tenderedPaise > totals.payablePaise) { - setChangeFlash(tenderedPaise - totals.payablePaise) - setTimeout(() => setChangeFlash(undefined), 4000) - } - setLines([]) - setPendingQty(undefined) - setCustomer(undefined) - setOverrides([]) // override records belong to the committed cart only - setOverlay('none') - const cfg = loadPrinterCfg() - const kick = mode === 'CASH' - const receiptDoc: ReceiptDoc = { - storeName: store.name, gstin: store.gstin, docNo: res.docNo, businessDate: today, - counterCode: store.counterCode, cashierName: props.user.name, - ...(customer !== undefined ? { buyerName: customer.name } : {}), - ...(supply.buyerGstin !== undefined ? { buyerGstin: supply.buyerGstin } : {}), - lines: computed.lines, totals, paymentLabel: mode, - } - // Indic receipts rasterize (09-UX §5.3); ASCII stays the fast text path. - // Same content model (receiptLines) so item names match on either path. - const send = cfg.script === 'raster' - ? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick) - : sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick })) - return send - .then(() => say('ok', `Bill ${res.docNo} · ${formatINR(totals.payablePaise)} — printed`)) + clearAfterCommit(res.docNo, snapshot, mode, committedLines, tenderedPaise) + refreshBatches() // per-batch on-hand changed server-side (S5) + return printBill(res.docNo, snapshot, mode) + .then(() => say('ok', `Bill ${res.docNo} · ${formatINR(snapshot.totals.payablePaise)} — printed`)) .catch((err: unknown) => say('warn', `Bill ${res.docNo} saved — print: ${err instanceof Error ? err.message : String(err)} (E-2101)`)) }) .catch((err: unknown) => { - // Commit failed: the cart stays; nothing is lost. + // A NETWORK failure (store-server dead) queues offline; the cart is not lost. + // Any other error (engine mismatch, payments, 4xx) keeps the cart on screen. + if (err instanceof NetworkError) return queueOffline(mode, snapshot, committedLines, tenderedPaise) say('err', `${err instanceof Error ? err.message : String(err)} — bill NOT saved, cart kept`) + return undefined }) .finally(() => { setBusy(false) @@ -411,6 +648,10 @@ export function BillingScreen(props: { inputRef.current?.focus() return } + if (batchPickRef.current !== undefined) { // cancel the FEFO pick; item not added + cancelBatchPick() + return + } setOverlay('none') setNotice(undefined) setInput('') @@ -424,7 +665,10 @@ export function BillingScreen(props: { // The supervisor panel owns every key but Esc — its PIN field has focus and // F12/commit must never fire mid-approval. if (supPanelRef.current !== undefined) return - if (overlayRef.current === 'settings' || overlayRef.current === 'customer') return + // The batch picker owns arrows/Enter (handled on its own focused element); the + // global handler must not fire F12/commit or F-keys while a lot is being picked. + if (batchPickRef.current !== undefined) return + if (overlayRef.current === 'settings' || overlayRef.current === 'customer' || overlayRef.current === 'returns') return // Suggestion list owns arrows+Enter; inline editors own everything. if (suggRef.current && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter')) return if (inlineEditRef.current && e.key !== 'F12') return @@ -479,6 +723,7 @@ export function BillingScreen(props: { case 'F6': e.preventDefault(); setOverlay('customer'); break case 'F7': e.preventDefault(); hold(); break case 'F8': e.preventDefault(); resume(); break + case 'F9': e.preventDefault(); setOverlay('returns'); break case 'F10': e.preventDefault(); openHistory(); break case 'F11': e.preventDefault(); setOverlay('pay'); break case 'Delete': @@ -510,6 +755,15 @@ export function BillingScreen(props: { + {props.cachedCatalog === true && ( +
+ OFFLINE — using cached catalog. Bills are queued locally and sync when the store-server returns. +
+ )} +
{ - if (overlayRef.current === 'none' && !inlineEditRef.current && supPanelRef.current === undefined) { + if (overlayRef.current === 'none' && !inlineEditRef.current && supPanelRef.current === undefined && batchPickRef.current === undefined) { setTimeout(() => inputRef.current?.focus(), 0) } }} @@ -599,10 +853,21 @@ export function BillingScreen(props: { {supPanel !== undefined ? ( u.role !== 'cashier')} onApprove={(a) => { supPanel.resolve(a); setSupPanel(undefined) }} onCancel={() => { supPanel.resolve(undefined); setSupPanel(undefined) }} /> + ) : batchPick !== undefined ? ( + { void acceptBatch(b) }} + onCancel={cancelBatchPick} + /> ) : notice !== undefined ? ( {notice.text} ) : null} @@ -732,6 +997,12 @@ export function BillingScreen(props: { {cfg.enabled ? `Printer ${cfg.host}` : 'Printer off'} {window.pos !== undefined ? ' · kiosk shell' : ' · web'} + {offlineCount > 0 && ( + OFFLINE · {offlineCount} queued + )} Bills today: {dayLog.length} Last: {dayLog[dayLog.length - 1]?.docNo ?? '—'} Held: {held.length} @@ -763,6 +1034,24 @@ export function BillingScreen(props: { /> )} + {overlay === 'returns' && ( + { 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' && setOverlay('none')} />} {overlay === 'history' && ( @@ -827,6 +1116,73 @@ function EditCell(props: { initial: string; onCommit: (value: string) => void }) ) } +/** + * Inline FEFO batch picker (spec S5) — never a modal (R18), replaces the notice area. + * Lots are FEFO-sorted (earliest expiry first); the first sellable lot is preselected; + * ↑/↓ move, Enter accepts, Esc cancels (Esc is handled by the screen's global handler). + * Expired lots are flagged red — selecting one raises the E-1310 gate upstream. The root + * div takes focus so the scan box never sees these keys mid-pick. + */ +function BatchPicker(props: { + item: Item + qty: number + batches: BatchStock[] + today: string + onPick: (b: BatchStock) => void + onCancel: () => void +}) { + const sorted = useMemo(() => sortFefo(props.batches), [props.batches]) + const [hi, setHi] = useState(() => { + const pick = pickFefoBatch(props.batches, props.today) + const idx = pick !== undefined ? sorted.findIndex((b) => b.id === pick.id) : 0 + return idx < 0 ? 0 : idx + }) + const rootRef = useRef(null) + useEffect(() => { rootRef.current?.focus() }, []) + + return ( +
{ + 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) } + }} + > +
+ Pick batch — {props.item.name} × {props.qty} + FEFO · ↑/↓ move · Enter accept · Esc cancel +
+
+ {sorted.map((b, i) => { + const expired = isExpired(b, props.today) + return ( +
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, + }} + > + {b.batchNo} + {b.expiryDate !== undefined ? `exp ${b.expiryDate}` : 'no expiry'} + {Math.round(b.onHand)} on hand + {expired ? EXPIRED : i === hi ? FEFO : null} +
+ ) + })} +
+
+
+ ) +} + /** * Inline supervisor gate (spec P0-2) — never a modal, replaces the notice area. * Pick an approver (bootstrap users with cashiers excluded), enter their 4–6 digit @@ -835,6 +1191,7 @@ function EditCell(props: { initial: string; onCommit: (value: string) => void }) */ function SupervisorPanel(props: { desc: string + override: OverrideContext approvers: PosUser[] onApprove: (a: Approval) => void onCancel: () => void @@ -851,8 +1208,8 @@ function SupervisorPanel(props: { if (who === undefined || pin.length < 4 || busy) return setBusy(true) setError(undefined) - verifySupervisorPin(who.id, pin) - .then((r) => props.onApprove({ approvedByUserId: r.userId, approvedByName: r.displayName })) + verifySupervisorPin(who.id, pin, props.override) + .then((r) => props.onApprove({ approvalId: r.approvalId, approvedByName: r.displayName })) .catch((e: Error) => { const n = fails + 1 setFails(n) @@ -1015,3 +1372,205 @@ function CustomerModal(props: { onClose: () => void; onAttach: (c: PosCustomer) ) } + +/** + * F9 returns / credit note (01-SCOPE Sales→Returns, 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() + const [returnable, setReturnable] = useState() + const [qtys, setQtys] = useState>({}) + const [reason, setReason] = useState(RETURN_REASONS[0]) + const [refundMode, setRefundMode] = useState('CASH') + const [error, setError] = useState() + 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 | 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): Promise => { + 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 ( + + {returnable === undefined ? ( + <> +
+ + setQuery(mode === 'phone' ? e.target.value.replace(/\D/g, '') : e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookup() } }} + /> + +
+ {results !== undefined && results.length > 0 && ( + + + + {results.map((b) => ( + chooseBill(b.id)}> + + + + + + + ))} + +
BillDateCashierCustomerAmount
{b.doc_no}{b.business_date}{b.cashier_name ?? ''}{b.customer_name !== null && b.customer_name !== undefined ? b.customer_name : 'walk-in'}{formatINR(b.payable_paise)}
+ )} + + ) : ( + <> +
+ Returning against {returnable.docNo} · {returnable.businessDate} + {returnable.customerName !== undefined ? <> · {returnable.customerName} : null} + + + +
+ + + + + + + + + {returnable.lines.map((l) => ( + + + + + + + + ))} + +
ItemSoldReturnedLeftReturn qty
{l.name}{l.soldQty} {l.unitCode}{l.returnedQty}{l.returnableQty} + setQtys((m) => ({ ...m, [l.lineIndex]: e.target.value.replace(/[^\d.]/g, '') }))} + /> +
+
+ + + + + + +
+ {overReturn && A return quantity exceeds what is left to return.} +
+ REFUND + {formatINR(preview?.totals.payablePaise ?? 0)} +
+ + + )} + {error !== undefined && {error}} +
+ ) +} diff --git a/apps/pos/src/api.ts b/apps/pos/src/api.ts index 788e520..e7e5be2 100644 --- a/apps/pos/src/api.ts +++ b/apps/pos/src/api.ts @@ -1,4 +1,4 @@ -import type { Item } from '@sims/domain' +import type { BillLine, Item } from '@sims/domain' import type { LineInput, TaxClassRow } from '@sims/billing-engine' /** POS ↔ store-server client. Same origin in production (served under /pos/). */ @@ -10,7 +10,37 @@ export interface BootstrapStore { export interface PosUser { id: string; name: string; role: string } export interface Bootstrap { store: BootstrapStore; users: PosUser[]; taxClasses: TaxClassRow[] } -let token = '' +/** + * Thrown ONLY when the store-server is physically unreachable (fetch rejected) — + * as opposed to a server that answered with an error (4xx/5xx). The counter's + * offline path (S4) keys off this: a NetworkError means "queue the bill and keep + * billing"; any other error means the server rejected the bill and the cart is + * kept on screen instead (R18: the cart is never lost, either way). + */ +export class NetworkError extends Error { + constructor(message = 'Store-server unreachable (E-3301)') { + super(message) + this.name = 'NetworkError' + } +} + +// The POS session token survives a browser refresh in sessionStorage (S4): a +// mid-shift reload while the server is down can resume the already-authenticated +// session. sessionStorage (not localStorage) so it dies with the tab, never +// outlives the shift. Guarded for non-browser contexts (unit tests). +const TOKEN_KEY = 'pos.token' +const readStoredToken = (): string => { + try { return sessionStorage.getItem(TOKEN_KEY) ?? '' } catch { return '' } +} +let token = readStoredToken() + +function setToken(t: string): void { + token = t + try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* non-browser: in-memory only */ } +} + +/** True when an authenticated session token is present (used by offline resume). */ +export const hasSession = (): boolean => token !== '' async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { @@ -20,12 +50,16 @@ async function call(path: string, init?: RequestInit): Promise { ...(token !== '' ? { authorization: `Bearer ${token}` } : {}), }, }).catch(() => undefined) - if (res === undefined) throw new Error('Store-server unreachable (E-3301)') + if (res === undefined) throw new NetworkError() const body = (await res.json().catch(() => ({}))) as T & { error?: string } if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`) return body } +/** Unauthenticated liveness probe — the drain loop pings this before draining. */ +export const pingHealth = (): Promise => + fetch('/api/health').then((r) => r.ok).catch(() => false) + export const fetchBootstrap = (counter: string): Promise => call(`/bootstrap?counter=${encodeURIComponent(counter)}`) @@ -34,16 +68,40 @@ export async function pinLogin(userId: string, pin: string): Promise { method: 'POST', body: JSON.stringify({ userId, pin }), }) - token = s.token + setToken(s.token) +} + +/** + * End the session server-side (M4) then drop the local token. Best-effort: if the store-server + * is unreachable we still forget the token locally so the counter locks. Called from "Lock". + */ +export async function logout(): Promise { + try { await call('/auth/logout', { method: 'POST' }) } catch { /* server down: drop the token anyway */ } + setToken('') +} + +/** + * The exact deviation a supervisor is approving. It is sent to /auth/verify-pin so the + * minted token is BOUND to this item+kind+before+after (H4 fix) — the server re-checks it + * against the line's own server-computed deviation at commit, so an approval for one line + * can never be redirected to another. + */ +export interface OverrideContext { + itemId: string + kind: 'price' | 'qty' | 'discount' | 'batch' + /** Server-anchored "before" value (the item master price for a price override; 0 otherwise). */ + before: number + after: number } /** * In-flow supervisor gate (spec P0-2): verify a supervisor's PIN without touching * the module-level `token` — the cashier stays logged in and no session is created. * Distinct from `pinLogin` on purpose; reusing that would hijack the cashier's session. + * The bound override context rides along so the server mints a token pinned to it. */ -export const verifySupervisorPin = (userId: string, pin: string): Promise<{ ok: true; userId: string; displayName: string }> => - call('/auth/verify-pin', { method: 'POST', body: JSON.stringify({ userId, pin }) }) +export const verifySupervisorPin = (userId: string, pin: string, override: OverrideContext): Promise<{ ok: true; userId: string; displayName: string; approvalId: string }> => + call('/auth/verify-pin', { method: 'POST', body: JSON.stringify({ userId, pin, override }) }) // The counter caches the whole sellable catalog (no silent cap — R13); ?all=1 // returns every non-inactive item unpaginated. @@ -53,9 +111,14 @@ export const fetchStock = (): Promise<{ code: string; on_hand: number }[]> => ca export interface CommitResult { id: string; docNo: string; payablePaise: number } -/** Supervisor-approved override, audited server-side in the commit transaction (spec P0-2). */ +/** + * Supervisor-approved override, audited server-side in the commit transaction (spec + * P0-2). `approvalId` is the single-use token returned by verifySupervisorPin — the + * server redeems it and records the approver from its own row, so the client never + * asserts who approved (SEC fix). + */ export interface BillOverride { - itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string + itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string } export const postBill = (body: { @@ -66,6 +129,34 @@ export const postBill = (body: { overrides?: BillOverride[] }): Promise => 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 => 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 => + call('/bills/offline', { method: 'POST', body: JSON.stringify(body) }) + export const fetchBills = (date: string, counterId: string): Promise[]> => call(`/bills?date=${date}&counterId=${encodeURIComponent(counterId)}`) @@ -86,3 +177,53 @@ export const createCustomer = (name: string, phone: string, gstin?: string): Pro method: 'POST', body: JSON.stringify({ name, phone, ...(gstin !== undefined && gstin !== '' ? { gstin } : {}) }), }) + +// ---------- returns / credit notes ---------- + +/** A found original SALE bill for the return lookup (doc-no or phone search). */ +export interface BillLookupResult { + id: string; doc_no: string; business_date: string; payable_paise: number + cashier_name?: string; customer_name?: string | null +} + +/** One original line annotated with sold / already-returned / still-returnable quantities. */ +export interface ReturnableLine { + lineIndex: number + itemId: string; name: string; hsn: string; unitCode: string + unitPricePaise: number; taxRateBp: number; lineTotalPaise: number + soldQty: number; returnedQty: number; returnableQty: number + batchId?: string +} + +/** The original bill + its returnable lines + the raw lines for the shared refund preview. */ +export interface ReturnableBill { + id: string; docNo: string; businessDate: string + customerId?: string; customerName?: string + lines: ReturnableLine[] + originalLines: BillLine[] +} + +export interface ReturnResult { id: string; docNo: string; refundPaise: number } + +/** Look up an original SALE bill by its exact doc no (returns 0 or 1 result). */ +export const lookupBillByDocNo = (docNo: string): Promise => + call(`/bills/lookup?docNo=${encodeURIComponent(docNo)}`) + +/** Look up recent original SALE bills for a customer phone. */ +export const lookupBillsByPhone = (phone: string): Promise => + call(`/bills/lookup?phone=${encodeURIComponent(phone)}`) + +/** Fetch the original bill's returnable lines (sold / returned / returnable + raw lines). */ +export const fetchReturnable = (billId: string): Promise => + 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 => call('/returns', { method: 'POST', body: JSON.stringify(body) }) diff --git a/apps/pos/src/main.tsx b/apps/pos/src/main.tsx index 8668edb..45eb1ca 100644 --- a/apps/pos/src/main.tsx +++ b/apps/pos/src/main.tsx @@ -8,3 +8,12 @@ import { App } from './App' initTheme() createRoot(document.getElementById('root')!).render() + +// 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) + }) +} diff --git a/apps/pos/src/offline.ts b/apps/pos/src/offline.ts new file mode 100644 index 0000000..1836642 --- /dev/null +++ b/apps/pos/src/offline.ts @@ -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 { + 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(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest): Promise { + return openIdb().then((db) => new Promise((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 { + await run(BILLS, 'readwrite', (s) => s.put(bill)) +} + +/** All queued bills, oldest-first (the drain order). */ +export async function listQueuedBills(): Promise { + const all = await run(BILLS, 'readonly', (s) => s.getAll() as IDBRequest) + return all.sort((a, b) => a.seq - b.seq) +} + +export async function removeQueuedBill(id: string): Promise { + await run(BILLS, 'readwrite', (s) => s.delete(id) as unknown as IDBRequest) +} + +export async function countQueuedBills(): Promise { + return run(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): Promise { + await run(CACHE, 'readwrite', (s) => s.put({ key: 'catalog', ...data })) +} + +export async function loadCatalogCache(): Promise { + return run(CACHE, 'readonly', (s) => s.get('catalog') as IDBRequest) +} + +/** + * 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 { + await run(CACHE, 'readwrite', (s) => s.clear() as unknown as IDBRequest) +} diff --git a/apps/pos/test/offline.test.ts b/apps/pos/test/offline.test.ts new file mode 100644 index 0000000..a6e8d87 --- /dev/null +++ b/apps/pos/test/offline.test.ts @@ -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) + }) +}) diff --git a/apps/store-server/.env.example b/apps/store-server/.env.example new file mode 100644 index 0000000..11ca796 --- /dev/null +++ b/apps/store-server/.env.example @@ -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; 8080–8085 are Windows-reserved on shop PCs). +PORT= diff --git a/apps/store-server/build-server.mjs b/apps/store-server/build-server.mjs index d523980..259ab28 100644 --- a/apps/store-server/build-server.mjs +++ b/apps/store-server/build-server.mjs @@ -6,6 +6,9 @@ await build({ bundle: true, platform: 'node', format: 'cjs', - external: ['better-sqlite3'], + // Native modules can't be bundled. db.ts imports better-sqlite3-multiple-ciphers (the + // SQLCipher-compatible drop-in — H3 encryption at rest); better-sqlite3 stays externalled + // too in case any transitive path still references it. + external: ['better-sqlite3-multiple-ciphers', 'better-sqlite3'], }) console.log('store-server built') diff --git a/apps/store-server/dev-start.mjs b/apps/store-server/dev-start.mjs new file mode 100644 index 0000000..fae7485 --- /dev/null +++ b/apps/store-server/dev-start.mjs @@ -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') diff --git a/apps/store-server/load/loadtest.mjs b/apps/store-server/load/loadtest.mjs new file mode 100644 index 0000000..b6b380c --- /dev/null +++ b/apps/store-server/load/loadtest.mjs @@ -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 1–5 line bill from distinct catalog items, integer qty 1–5. +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) }) diff --git a/apps/store-server/package.json b/apps/store-server/package.json index 250abcb..66df9fa 100644 --- a/apps/store-server/package.json +++ b/apps/store-server/package.json @@ -5,15 +5,17 @@ "type": "module", "scripts": { "build": "node build-server.mjs", - "start": "npm run build && node dist/server.cjs", + "start": "npm run build && node dev-start.mjs", + "start:prod": "npm run build && node dist/server.cjs", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { "@sims/auth": "*", "@sims/billing-engine": "*", "@sims/domain": "*", - "better-sqlite3": "^11.7.0", - "express": "^4.21.0" + "better-sqlite3-multiple-ciphers": "^11.10.0", + "express": "^4.21.0", + "helmet": "^8.3.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", diff --git a/apps/store-server/src/api.ts b/apps/store-server/src/api.ts index a08a77c..515de81 100644 --- a/apps/store-server/src/api.ts +++ b/apps/store-server/src/api.ts @@ -1,21 +1,35 @@ import { Router, type Request, type Response, type NextFunction } from 'express' import { randomUUID } from 'node:crypto' -import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, type LockoutState } from '@sims/auth' +import net from 'node:net' +import dns from 'node:dns' +import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, can, type LockoutState, type ActionCode } from '@sims/auth' +import type { RoleCode } from '@sims/domain' import type { LineInput } from '@sims/billing-engine' import type { DB } from './db' import { - commitBill, createCustomer, createItem, listAudit, listBills, listItems, - listParties, listTaxClasses, stockView, writeAudit, + batchesForStore, commitBill, createCustomer, createItem, createOverrideApproval, expiryView, + listAudit, listBills, listItems, listParties, listTaxClasses, resolveDefaultTaxClass, + resolveDraftMaxPricePaise, stockView, verifyAuditChain, writeAudit, } from './repos' import { commitPurchase, costHistory, lastCosts, listPurchases, type CommitPurchaseInput } from './repos-purchase' +import { commitReturn, listReturns, lookupBillByDocNo, lookupBillsByPhone, returnableLines } from './repos-returns' +import { gstr1, gstr3b, hsnSummary, gstr1PortalJson } from './repos-gst' +import { + ValidationError, assertString, optString, assertInt, assertNonNegInt, assertPositiveInt, + assertPaise, assertQty, assertBusinessDate, assertPeriod, assertArray, clampLimit, clampOffset, PAYMENT_MODES, +} from './validate' +import { + createRateLimiter, authRatePerIpFromEnv, authRateGlobalFromEnv, type RateLimitPolicy, +} from './rate-limit' +import { isSessionExpired, sessionTtlFromEnv, type SessionTtl } from './session-policy' +import { checkPrintEnvelope, checkPrintAddress, parsePrinterPorts } from './print-guard' interface Session { token: string; userId: string; tenantId: string; displayName: string; roles: string[] + // M4: sessions now have a lifetime — created-at (absolute cap) + last-seen (idle cap). + createdMs: number; lastSeenMs: number } -const sessions = new Map() -const lockouts = new Map() - interface UserRow { id: string; tenant_id: string; username: string; display_name: string roles: string; active: number @@ -23,26 +37,173 @@ interface UserRow { pw_salt: string | null; pw_hash: string | null } -function requireAuth(req: Request, res: Response, next: NextFunction): void { - const token = (req.headers.authorization ?? '').replace('Bearer ', '') - const session = sessions.get(token) - if (session === undefined) { - res.status(401).json({ error: 'Not signed in (E-6103)' }) - return - } - ;(req as Request & { session: Session }).session = session - next() +/** Injection points so tests can force tiny TTLs / rate caps / a stub DNS resolver. */ +export interface ApiRouterOpts { + sessionTtl?: SessionTtl + authRatePerIp?: RateLimitPolicy + authRateGlobal?: RateLimitPolicy + printerPorts?: ReadonlySet + resolveHost?: (host: string) => Promise } +/** 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 role→action 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 + 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>(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>(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>(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 + 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>(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): Router { +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() + const lockouts = new Map() + 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 => + (await dns.promises.lookup(h, { all: true })).map((a) => a.address)) + + const sessionDto = (s: Session): Record => + ({ token: s.token, userId: s.userId, tenantId: s.tenantId, displayName: s.displayName, roles: s.roles }) + + /** + * Authenticated-session gate with lifetime enforcement (M4): a token must map to a live + * session inside BOTH its idle and absolute TTL, else it is evicted and the caller gets 401 + * (E-6110) and re-logs in. Each authenticated hit slides the idle window (refreshes last-seen). + */ + const requireAuth = (req: Request, res: Response, next: NextFunction): void => { + const token = bearer(req) + const session = sessions.get(token) + if (session === undefined) { + res.status(401).json({ error: 'Not signed in (E-6103)', code: 'E-6103' }) + return + } + const now = Date.now() + if (isSessionExpired(session, now, ttl)) { + sessions.delete(token) + res.status(401).json({ error: 'Session expired — sign in again (E-6110)', code: 'E-6110' }) + return + } + session.lastSeenMs = now + ;(req as Request & { session: Session }).session = session + next() + } + + /** + * Global + per-IP throttle for /auth/* (M2/M4, rt-F7). Layered ON TOP of the per-user scrypt + * lockout: the lockout stops repeated failures against ONE user; this blunts a parallel grind + * across the whole roster from one source, whichever user each attempt names. + */ + const authRateLimit = (req: Request, res: Response, next: NextFunction): void => { + const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown' + const now = Date.now() + if (!authPerIp.hit(ip, now) || !authGlobal.hit('*', now)) { + res.status(429).json({ error: 'Too many attempts — try again shortly (E-6109)', code: 'E-6109' }) + return + } + next() + } + const startSession = (u: UserRow): Session => { + const now = Date.now() const s: Session = { token: randomUUID(), userId: u.id, tenantId: u.tenant_id, displayName: u.display_name, roles: JSON.parse(u.roles) as string[], + createdMs: now, lastSeenMs: now, } sessions.set(s.token, s) return s @@ -63,30 +224,59 @@ export function apiRouter(db: DB): Router { } // POS: name-tile + PIN - r.post('/auth/pin', (req, res) => { + r.post('/auth/pin', authRateLimit, (req, res) => { const { userId, pin } = req.body as { userId?: string; pin?: string } const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined const out = attempt(u, pin ?? '', u?.pin_salt ?? null, u?.pin_hash ?? null) - 'token' in out ? res.json(out) : res.status(out.status).json({ error: out.error }) + 'token' in out ? res.json(sessionDto(out)) : res.status(out.status).json({ error: out.error }) }) // Back office: username + password - r.post('/auth/login', (req, res) => { + r.post('/auth/login', authRateLimit, (req, res) => { const { username, password } = req.body as { username?: string; password?: string } const u = db.prepare(`SELECT * FROM app_user WHERE username=?`).get((username ?? '').toLowerCase()) as UserRow | undefined const out = attempt(u, password ?? '', u?.pw_salt ?? null, u?.pw_hash ?? null) - 'token' in out ? res.json(out) : res.status(out.status).json({ error: out.error }) + 'token' in out ? res.json(sessionDto(out)) : res.status(out.status).json({ error: out.error }) }) // In-flow supervisor gate (spec P0-2): verify a PIN WITHOUT creating a session — // the cashier's login must never be disturbed and `sessions` must not grow. Shares // the same lockout + scrypt verification as attempt(); audits PIN_VERIFY on success - // and a LOGIN_FAILED-style PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor / - // manager / owner roles may approve an override (09-UX §5.5) — a valid-PIN cashier - // is rejected loudly, never granted. + // and a PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor / manager / owner roles + // may approve an override (09-UX §5.5) — a valid-PIN cashier is rejected loudly. + // + // H4 fix: the minted token is BOUND to the exact override being approved + // ({itemId, kind, before, after}) — the commit re-verifies this against the line's + // server-computed deviation so an approval can never be re-attached to another line. const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner']) - r.post('/auth/verify-pin', (req, res) => { - const { userId, pin } = req.body as { userId?: string; pin?: string } + // M2: verify-pin is only ever called mid-billing by an already-logged-in cashier, so it now + // REQUIRES a valid caller session (requireAuth) and is rate-limited — closing its use as an + // UNAUTHENTICATED PIN oracle / token-mint vector. The approver's PIN still rides in the body; + // the caller session just proves a real counter is asking. + r.post('/auth/verify-pin', authRateLimit, requireAuth, (req, res) => { + const body = (req.body ?? {}) as { userId?: string; pin?: string; override?: Record } + // Bound override context is required — a PIN verification with nothing to authorise is + // rejected rather than minting an unbound (redirectable) token. + const ovRaw = body.override + if (ovRaw === undefined || ovRaw === null || typeof ovRaw !== 'object') { + res.status(400).json({ error: 'Missing the override this PIN is approving (E-1001)', code: 'E-1001' }) + return + } + let ov: { itemId: string; kind: string; before: number; after: number } + try { + const kind = assertString(ovRaw['kind'], 'override kind') + if (!OVERRIDE_KINDS.has(kind)) throw new ValidationError('Unknown override kind', 'E-1001') + ov = { + itemId: assertString(ovRaw['itemId'], 'override itemId'), + kind, + before: assertInt(ovRaw['before'], 'override before'), + after: assertInt(ovRaw['after'], 'override after'), + } + } catch (err) { + const code = err instanceof ValidationError ? err.code : 'E-1001' + res.status(400).json({ error: 'Override context is invalid (E-1001)', code }); return + } + const { userId, pin } = body const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined const key = u?.id ?? 'unknown' const now = Date.now() @@ -106,15 +296,26 @@ export function apiRouter(db: DB): Router { lockouts.set(key, recordSuccess()) const roles = JSON.parse(u.roles) as string[] if (!roles.some((role) => APPROVER_ROLES.has(role))) { - res.status(403).json({ error: 'This user cannot approve overrides — pick a supervisor, manager or owner (E-6105)' }) + res.status(403).json({ error: 'This user cannot approve overrides — pick a supervisor, manager or owner (E-6105)', code: 'E-6105' }) return } + const approvalId = createOverrideApproval(db, u.tenant_id, u.id, { + itemId: ov.itemId, kind: ov.kind as 'price' | 'qty' | 'discount' | 'batch', + beforePaise: ov.before, afterPaise: ov.after, + }) writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY', 'app_user', u.id) - res.json({ ok: true, userId: u.id, displayName: u.display_name }) + res.json({ ok: true, userId: u.id, displayName: u.display_name, approvalId }) }) - // POS bootstrap: store/counter context, login tiles, tax classes + // POS bootstrap: store/counter context, login tiles, tax classes. This is UNAUTHENTICATED so + // the POS can paint its login screen — which means it must NOT leak targeting data (M1). The + // pre-auth payload therefore DROPS each user's role and the tenant GSTIN. Both are restored + // when the SAME endpoint is called WITH a valid session (the POS re-fetches right after login; + // back office is always authed), so the supervisor gate, the approver list and the receipt's + // seller GSTIN keep working — only the anonymous, pre-login caller is starved of the roster's + // roles and the store's GSTIN. r.get('/bootstrap', (req, res) => { + const authed = sessions.has(bearer(req)) const counterCode = String(req.query['counter'] ?? 'C2') const store = db.prepare(`SELECT * FROM store LIMIT 1`).get() as Record const counter = db.prepare(`SELECT * FROM counter WHERE store_id=? AND code=?`).get(store['id'], counterCode) as Record | undefined @@ -123,103 +324,286 @@ export function apiRouter(db: DB): Router { res.json({ store: { id: store['id'], name: store['name'], storeCode: store['code'], - stateCode: store['state_code'], gstin: tenant['gstin'], - counterId: counter?.['id'] ?? 'c2', counterCode, + stateCode: store['state_code'], counterId: counter?.['id'] ?? 'c2', counterCode, + ...(authed ? { gstin: tenant['gstin'] } : {}), }, - users: users.map((u) => ({ id: u['id'], name: u['display_name'], role: (JSON.parse(u['roles'] as string) as string[])[0] })), + users: users.map((u) => ({ + id: u['id'], name: u['display_name'], + ...(authed ? { role: (JSON.parse(u['roles'] as string) as string[])[0] } : {}), + })), taxClasses: listTaxClasses(db), }) }) r.use(requireAuth) + // M4: end a session on demand — POS "Lock" and back-office "Logout" call this so the token + // stops working immediately (a leaked/rotated token is cut off without waiting for a restart). + r.post('/auth/logout', (req, res) => { + sessions.delete(bearer(req)) + res.json({ ok: true }) + }) + + // H1: ESC/POS print relay, now constrained to a LAN printer on an allowlisted port with a + // capped payload (print-guard.ts). Authenticated (it sits below requireAuth). The host is + // resolved and EVERY resolved IP must be private/LAN; we then connect to the resolved IP — + // not the original name — so a hostname can't DNS-rebind between the check and the connect. + r.post('/print', async (req, res, next) => { + try { + const { host, port, bytes } = req.body as { host?: unknown; port?: unknown; bytes?: unknown } + if (typeof host !== 'string' || host === '' || typeof port !== 'number' || !Array.isArray(bytes)) { + res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }', code: 'E-2200' }) + return + } + const envelope = checkPrintEnvelope(bytes.length, port, printerPorts) + if (!envelope.ok) { res.status(envelope.status).json({ ok: false, error: envelope.error, code: envelope.code }); return } + let addrs: string[] + try { addrs = await resolveHost(host) } catch { addrs = [] } + if (addrs.length === 0) { + res.status(400).json({ ok: false, error: 'Printer host is not a private/LAN address', code: 'E-2203' }) + return + } + for (const ip of addrs) { + const chk = checkPrintAddress(ip) + if (!chk.ok) { res.status(chk.status).json({ ok: false, error: chk.error, code: chk.code }); return } + } + const target = addrs[0]! + // 'close' always follows 'error'/'timeout' on net sockets — respond exactly once. + let done = false + const finish = (status: number, body: object): void => { if (done) return; done = true; res.status(status).json(body) } + const socket = net.createConnection({ host: target, port, timeout: 3000 }) + socket.on('connect', () => socket.end(Buffer.from(bytes as number[]))) + socket.on('close', () => finish(200, { ok: true })) + socket.on('timeout', () => { socket.destroy(); finish(504, { ok: false, error: `Printer ${host}:${port} timed out` }) }) + socket.on('error', (e: Error) => finish(502, { ok: false, error: e.message })) + } catch (e) { next(e) } + }) + + // Catalog reads are needed to bill, so they stay open to any authenticated session — + // but pagination is clamped so a hostile limit can never dump the whole table (M6). r.get('/items', (req, res) => { res.json(listItems(db, sess(req).tenantId, { ...(req.query['q'] !== undefined ? { query: String(req.query['q']) } : {}), all: req.query['all'] === '1', - ...(req.query['limit'] !== undefined ? { limit: Number(req.query['limit']) } : {}), - ...(req.query['offset'] !== undefined ? { offset: Number(req.query['offset']) } : {}), + limit: clampLimit(req.query['limit'], 5000, 5000), + offset: clampOffset(req.query['offset']), })) }) + // A cashier (BILL_CREATE) may quick-add a DRAFT item mid-bill; any non-draft item — or a + // caller without BILL_CREATE — requires MASTER_EDIT (C1: no editing the master as cashier). r.post('/items', (req, res) => { - const item = createItem(db, sess(req).tenantId, req.body as Parameters[2]) - writeAudit(db, sess(req).tenantId, sess(req).userId, 'ITEM_CREATE', 'item', item.id, undefined, item) + const s = sess(req) + const b = (req.body ?? {}) as Record + const status = optString(b['status'], 'status') ?? 'active' + const user = asUser(s) + const isMasterEditor = can(user, 'MASTER_EDIT') + if (!(isMasterEditor || (status === 'draft' && can(user, 'BILL_CREATE')))) { + res.status(403).json({ error: 'You are not permitted to add or edit items (E-6105)', code: 'E-6105' }) + return + } + const salePricePaise = assertPositiveInt(b['salePricePaise'], 'salePricePaise') + // C3 fix: a cashier-created draft's tax class is FORCED to the store default — the + // client's taxClassCode is ignored so a taxable good can't be self-added as ZERO to + // evade GST. A MASTER_EDIT role (manager/owner) still sets the class explicitly. + const taxClassCode = isMasterEditor + ? assertString(b['taxClassCode'], 'taxClassCode') + : resolveDefaultTaxClass(db, s.tenantId) + // C2 bound: a cashier-created draft's price IS its sell price, so cap it — an + // expensive unknown must go through a supervisor / back office, not be self-priced + // at the counter. (E-1108 here is the draft-cap denial, distinct endpoint from the + // bill-path payment-mode use of the same code.) + if (!isMasterEditor && salePricePaise > resolveDraftMaxPricePaise(db, s.tenantId)) { + res.status(400).json({ + error: 'This price is too high to quick-add at the counter — call a supervisor to add it in back office (E-1108)', + code: 'E-1108', + }) + return + } + const input = { + code: assertString(b['code'], 'code'), + name: assertString(b['name'], 'name'), + hsn: optString(b['hsn'], 'hsn') ?? '', + taxClassCode, + unitCode: optString(b['unitCode'], 'unitCode') ?? 'PCS', + unitDecimals: assertNonNegInt(b['unitDecimals'] ?? 0, 'unitDecimals'), + salePricePaise, + ...(b['mrpPaise'] !== undefined ? { mrpPaise: assertNonNegInt(b['mrpPaise'], 'mrpPaise') } : {}), + ...(optString(b['barcode'], 'barcode') !== undefined ? { barcode: optString(b['barcode'], 'barcode')! } : {}), + status, + } + const item = createItem(db, s.tenantId, input) + writeAudit(db, s.tenantId, s.userId, 'ITEM_CREATE', 'item', item.id, undefined, item) res.json(item) }) + // Party lookup/create is part of billing (attach a customer at the counter) — open to any + // authenticated session; the GSTIN is validated server-side in createCustomer. r.get('/parties', (req, res) => { res.json(listParties(db, sess(req).tenantId, req.query['kind'] as string | undefined, req.query['phone'] as string | undefined)) }) r.post('/parties', (req, res) => { - const { name, phone, gstin, stateCode } = req.body as { name: string; phone: string; gstin?: string; stateCode?: string } - try { - const p = createCustomer(db, sess(req).tenantId, name, phone, { - ...(gstin !== undefined ? { gstin } : {}), - ...(stateCode !== undefined ? { stateCode } : {}), - }) - writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p) - res.json(p) - } catch (err) { - const status = (err as { status?: number }).status ?? 500 - res.status(status).json({ error: err instanceof Error ? err.message : String(err) }) - } + const b = (req.body ?? {}) as Record + const name = assertString(b['name'], 'name') + const phone = assertString(b['phone'], 'phone') + const gstin = optString(b['gstin'], 'gstin') + const stateCode = optString(b['stateCode'], 'stateCode') + const p = createCustomer(db, sess(req).tenantId, name, phone, { + ...(gstin !== undefined ? { gstin } : {}), + ...(stateCode !== undefined ? { stateCode } : {}), + }) + writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p) + res.json(p) }) - r.post('/bills', (req, res) => { - const body = req.body as { - storeId: string; counterId: string; shiftId: string; customerId?: string - businessDate: string; lines: LineInput[] - payments: { mode: string; amountPaise: number; reference?: string }[] - clientPayablePaise: number - overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[] - } - try { - const out = commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }) - res.json(out) - } catch (err) { - const status = (err as { status?: number }).status ?? 500 - res.status(status).json({ error: err instanceof Error ? err.message : String(err) }) - } + r.post('/bills', requireAnyPerm('BILL_CREATE'), (req, res) => { + const body = validateBillBody(req.body) + res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId })) + }) + + // S4 reconnect drain (authenticated): bills a browser POS queued while the store-server + // was unreachable are drained here under the cashier's live session — tenant and cashier + // come from the session, NEVER the body. The client bill id is the idempotency key. + r.post('/bills/offline', requireAnyPerm('BILL_CREATE'), (req, res) => { + const b = (req.body ?? {}) as Record + const clientId = assertString(b['id'], 'id') + const body = validateBillBody(req.body) + res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId, clientId })) }) - r.get('/bills', (req, res) => { + + // A cashier may read bills, but only their OWN — a caller without REPORT_VIEW is pinned to + // the session user id server-side, so a BILL_CREATE-only cashier can never enumerate other + // counters'/cashiers' bills (residual cross-counter read fix). A REPORT_VIEW/manager role + // keeps the full store-wide view. Pagination is clamped (M6). + r.get('/bills', requireAnyPerm('BILL_CREATE', 'REPORT_VIEW'), (req, res) => { + const s = sess(req) + const ownBillsOnly = !can(asUser(s), 'REPORT_VIEW') res.json(listBills( - db, sess(req).tenantId, + db, s.tenantId, req.query['date'] as string | undefined, req.query['counterId'] as string | undefined, - req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined, - req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined, + clampLimit(req.query['limit'], 100, 1000), + clampOffset(req.query['offset']), + ownBillsOnly ? s.userId : undefined, )) }) + // Returns / credit notes. A cashier has RETURN_CREATE (permissions.ts), so returns ride the + // same money/commit path the red-team hardened: commitReturn anchors everything to OUR stored + // original bill (never the client), blocks over-return, computes the refund server-side, and + // writes an immutable SALE_RETURN doc + credit-note series + stock-back movements + refund leg + // + hash-chained audit row in ONE transaction. + r.post('/returns', requireAnyPerm('RETURN_CREATE'), (req, res) => { + const body = validateReturnBody(req.body) + res.json(commitReturn(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId })) + }) + // Credit-note list — tenant-scoped, paginated (M6). A caller without REPORT_VIEW is pinned to + // their own credit notes, the same scoping the sales list uses. + r.get('/returns', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => { + const s = sess(req) + const ownOnly = !can(asUser(s), 'REPORT_VIEW') + res.json(listReturns( + db, s.tenantId, clampLimit(req.query['limit'], 100, 1000), clampOffset(req.query['offset']), + ownOnly ? s.userId : undefined, + )) + }) + // The return lookup: find the original SALE by exact doc no, or recent sales for a phone. A + // targeted, tenant-scoped lookup for issuing a return (not the bills-list enumeration) so a + // cashier can return a bill a colleague rang up. + r.get('/bills/lookup', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => { + const s = sess(req) + const docNo = optString(req.query['docNo'], 'docNo') + const phone = optString(req.query['phone'], 'phone') + if (docNo !== undefined) { + const bill = lookupBillByDocNo(db, s.tenantId, docNo) + res.json(bill === undefined ? [] : [bill]) + return + } + if (phone !== undefined) { res.json(lookupBillsByPhone(db, s.tenantId, phone)); return } + res.status(400).json({ error: 'Provide a docNo or phone to look up (E-1327)', code: 'E-1327' }) + }) + // The original bill's lines with sold / already-returned / returnable quantities + the raw + // lines for the POS refund preview. + r.get('/bills/:id/returnable', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => { + res.json(returnableLines(db, sess(req).tenantId, assertString(req.params['id'], 'id'))) + }) + + // GST returns — READ-ONLY compliance reporting, gated REPORT_VIEW, tenant-scoped. Each folds the + // period's immutable SALE + SALE_RETURN documents through the shared engine (never recomputes tax) + // and returns the sections + a reconciliation proof. The .json route is a pragmatic GSTN-shaped + // export for a CA / GSP — documented in the payload as NOT a certified filing. + r.get('/gst/gstr1', requireAnyPerm('REPORT_VIEW'), (req, res) => { + res.json(gstr1(db, sess(req).tenantId, assertPeriod(req.query['period']))) + }) + r.get('/gst/gstr3b', requireAnyPerm('REPORT_VIEW'), (req, res) => { + res.json(gstr3b(db, sess(req).tenantId, assertPeriod(req.query['period']))) + }) + r.get('/gst/hsn', requireAnyPerm('REPORT_VIEW'), (req, res) => { + res.json(hsnSummary(db, sess(req).tenantId, assertPeriod(req.query['period']))) + }) + r.get('/gst/gstr1.json', requireAnyPerm('REPORT_VIEW'), (req, res) => { + res.json(gstr1PortalJson(db, sess(req).tenantId, assertPeriod(req.query['period']))) + }) + r.get('/stock', (req, res) => { const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string } res.json(stockView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id)) }) - r.get('/audit', (req, res) => { + // S5: batches with derived on-hand > 0 (POS FEFO picker cache, keyed by item). + r.get('/batches', (req, res) => { + const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string } + res.json(batchesForStore(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id)) + }) + // S5 expiry dashboard feed — a reporting view (stock value/expiry), not a counter path. + r.get('/expiry', requireAnyPerm('REPORT_VIEW'), (req, res) => { + const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string } + res.json(expiryView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id)) + }) + + r.get('/audit', requireAnyPerm('AUDIT_VIEW'), (req, res) => { res.json(listAudit( db, sess(req).tenantId, - req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined, - req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined, + clampLimit(req.query['limit'], 100, 1000), + clampOffset(req.query['offset']), )) }) + // H2 — recompute the tenant's tamper-evident audit hash chain. {ok:true} means untampered; + // {ok:false, brokenAtId} names the first row whose content or prev-link no longer verifies. + r.get('/audit/verify', requireAnyPerm('AUDIT_VIEW'), (req, res) => { + res.json(verifyAuditChain(db, sess(req).tenantId)) + }) - r.get('/purchases', (req, res) => { + r.get('/purchases', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => { res.json(listPurchases(db, sess(req).tenantId)) }) - r.get('/purchases/last-costs', (req, res) => { + r.get('/purchases/last-costs', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => { res.json(lastCosts(db, sess(req).tenantId, req.query['supplierId'] as string | undefined)) }) - r.get('/purchases/cost-history', (req, res) => { + r.get('/purchases/cost-history', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => { res.json(costHistory(db, sess(req).tenantId, String(req.query['itemId'] ?? ''), req.query['supplierId'] as string | undefined)) }) - r.post('/purchases', (req, res) => { - const body = req.body as Omit - try { - res.json(commitPurchase(db, { ...body, tenantId: sess(req).tenantId, userId: sess(req).userId })) - } catch (err) { - const status = (err as { status?: number }).status ?? 500 - res.status(status).json({ error: err instanceof Error ? err.message : String(err) }) - } + r.post('/purchases', requireAnyPerm('PURCHASE_ENTRY'), (req, res) => { + const b = (req.body ?? {}) as Record + const supplierId = assertString(b['supplierId'], 'supplierId') + const invoiceNo = assertString(b['invoiceNo'], 'invoiceNo') + const invoiceDate = assertBusinessDate(b['invoiceDate'], 'invoiceDate') + const businessDate = assertBusinessDate(b['businessDate']) + const rawLines = assertArray>(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 diff --git a/apps/store-server/src/db.ts b/apps/store-server/src/db.ts index b18634d..11c55c3 100644 --- a/apps/store-server/src/db.ts +++ b/apps/store-server/src/db.ts @@ -1,4 +1,9 @@ -import Database from 'better-sqlite3' +// H3 (red-team): better-sqlite3-multiple-ciphers is an API-compatible drop-in for +// better-sqlite3 that adds SQLCipher, so the whole DB file can be encrypted at rest with a +// single `pragma('key=...')`. It ships its own DefinitelyTyped-derived declarations (identical +// shape to @types/better-sqlite3), so `Database.Database` and `db.pragma()` type exactly as +// before — no shim needed. See openDb() for the keying / dev-fallback logic. +import Database from 'better-sqlite3-multiple-ciphers' import fs from 'node:fs' import path from 'node:path' @@ -66,6 +71,7 @@ CREATE TABLE IF NOT EXISTS bill ( cess_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL, payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL, buyer_gstin TEXT, place_of_supply TEXT, + client_id TEXT, payload TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '', device_seq INTEGER NOT NULL DEFAULT 0, @@ -74,7 +80,16 @@ CREATE TABLE IF NOT EXISTS bill ( CREATE TABLE IF NOT EXISTS stock_movement ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, item_id TEXT NOT NULL, qty_delta REAL NOT NULL, reason TEXT NOT NULL, - ref_doc_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL + ref_doc_id TEXT, batch_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL +); +-- S5 batch/expiry: a batch is an item's lot with an optional expiry (nullable — +-- non-perishables are batch-tracked without a date). Stock stays derived (R7): +-- a batch's on-hand = SUM(stock_movement.qty_delta WHERE batch_id = the batch). +CREATE TABLE IF NOT EXISTS batch ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, + item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT, + created_at TEXT NOT NULL, + UNIQUE (tenant_id, store_id, item_id, batch_no) ); CREATE TABLE IF NOT EXISTS setting ( scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, key TEXT NOT NULL, @@ -86,13 +101,34 @@ CREATE TABLE IF NOT EXISTS message_catalog ( CREATE TABLE IF NOT EXISTS audit_log ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, at_wall TEXT NOT NULL, user_id TEXT NOT NULL, action TEXT NOT NULL, entity TEXT NOT NULL, - entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT + entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT, + -- H2 (red-team): per-tenant tamper-evident hash chain. entry_hash = + -- sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity, + -- entity_id, before_json, after_json)); prev_hash = the previous entry's + -- entry_hash for the tenant (genesis = ''). Any out-of-band UPDATE/DELETE/INSERT + -- to this table breaks the chain, which GET /api/audit/verify recomputes. This is + -- tamper-EVIDENCE, not prevention — the file is still writable (that is H3's job). + prev_hash TEXT, entry_hash TEXT ); CREATE TABLE IF NOT EXISTS outbox ( op_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, device_id TEXT NOT NULL, device_seq INTEGER NOT NULL, doc_type TEXT NOT NULL, doc_id TEXT NOT NULL, envelope TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL ); +-- Single-use supervisor-approval tokens (SEC fix): /auth/verify-pin mints one on a +-- valid supervisor PIN; the bill commit redeems it (used=0 -> 1, age < 5 min, approver +-- role re-checked) so the client can never forge who approved an override. +-- H4 fix: the token is BOUND at mint to the exact override it authorises — +-- item_id, kind, before_paise (server-computed master value) and after_paise. The +-- commit re-verifies these against the specific line's server-computed deviation, so +-- a "Milk ₹50→45" approval can never be re-attached to "Whiskey ₹2000→200". +CREATE TABLE IF NOT EXISTS override_approval ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, + approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + item_id TEXT, kind TEXT, before_paise INTEGER, after_paise INTEGER, + cashier_user_id TEXT +); CREATE TABLE IF NOT EXISTS purchase ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, supplier_id TEXT NOT NULL, invoice_no TEXT NOT NULL, invoice_date TEXT NOT NULL, @@ -105,13 +141,17 @@ CREATE TABLE IF NOT EXISTS purchase_line ( purchase_id TEXT NOT NULL, line_no INTEGER NOT NULL, item_id TEXT NOT NULL, name TEXT NOT NULL, qty REAL NOT NULL, unit_cost_paise INTEGER NOT NULL, tax_rate_bp INTEGER NOT NULL, tax_paise INTEGER NOT NULL, line_total_paise INTEGER NOT NULL, - new_sale_price_paise INTEGER, new_mrp_paise INTEGER, + new_sale_price_paise INTEGER, new_mrp_paise INTEGER, batch_id TEXT, PRIMARY KEY (purchase_id, line_no) ); CREATE INDEX IF NOT EXISTS idx_pline_item ON purchase_line (item_id); CREATE INDEX IF NOT EXISTS idx_item_name ON item (tenant_id, name); CREATE INDEX IF NOT EXISTS idx_bill_date ON bill (tenant_id, business_date); CREATE INDEX IF NOT EXISTS idx_move_item ON stock_movement (tenant_id, store_id, item_id); +-- idx_move_batch lives in migrate() only: on an upgraded dev DB the batch_id column is +-- added by ALTER there, so indexing it in SCHEMA (which runs first) would fail. The +-- batch table is fully defined above, so its index is safe to keep here. +CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id); CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (tenant_id, at_wall); ` @@ -127,12 +167,116 @@ function migrate(db: DB): void { // S3 B2B: buyer GSTIN + place-of-supply snapshot on the immutable bill. if (!has('buyer_gstin')) db.exec(`ALTER TABLE bill ADD COLUMN buyer_gstin TEXT`) if (!has('place_of_supply')) db.exec(`ALTER TABLE bill ADD COLUMN place_of_supply TEXT`) + // S4 offline queue: the client bill id is the idempotency key for the reconnect + // drain. It is NULL for normal (online) bills and non-null only for a bill that + // was queued offline; a partial unique index enforces one committed bill per + // (tenant, client id) so a retried drain can never duplicate a bill or its stock. + // (A partial index, not a table UNIQUE, because SQLite ALTER TABLE cannot add a + // constraint and NULLs must stay non-conflicting for the online path.) + if (!has('client_id')) db.exec(`ALTER TABLE bill ADD COLUMN client_id TEXT`) + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bill_client ON bill (tenant_id, client_id) WHERE client_id IS NOT NULL`) + // SEC fix — single-use supervisor-approval tokens. A whole new table, so the + // idempotent guard is the table itself (CREATE TABLE IF NOT EXISTS, which SCHEMA + // also runs on every open); re-stated here so the migration is explicit for dev + // DBs seeded before the table existed. + db.exec(`CREATE TABLE IF NOT EXISTS override_approval ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, + approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0 + )`) + // H4 fix — bind the approval token to the override it authorises. Additive columns + // for dev DBs seeded before the binding existed; fresh DBs get them from SCHEMA above. + const oaCols = db.prepare(`PRAGMA table_info(override_approval)`).all() as { name: string }[] + const oaHas = (c: string): boolean => oaCols.some((x) => x.name === c) + if (!oaHas('item_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN item_id TEXT`) + if (!oaHas('kind')) db.exec(`ALTER TABLE override_approval ADD COLUMN kind TEXT`) + if (!oaHas('before_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN before_paise INTEGER`) + if (!oaHas('after_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN after_paise INTEGER`) + if (!oaHas('cashier_user_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN cashier_user_id TEXT`) + // S5 batch/expiry — the batch table (SCHEMA also creates it; re-stated so the + // migration is explicit for dev DBs seeded before it existed), plus the nullable + // batch_id columns on stock_movement and purchase_line. Per-batch on-hand stays + // derived from movements (R7); batch_id is NULL for un-batched (non-tracked) stock. + db.exec(`CREATE TABLE IF NOT EXISTS batch ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, + item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT, + created_at TEXT NOT NULL, + UNIQUE (tenant_id, store_id, item_id, batch_no) + )`) + const moveCols = db.prepare(`PRAGMA table_info(stock_movement)`).all() as { name: string }[] + if (!moveCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE stock_movement ADD COLUMN batch_id TEXT`) + const plineCols = db.prepare(`PRAGMA table_info(purchase_line)`).all() as { name: string }[] + if (!plineCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE purchase_line ADD COLUMN batch_id TEXT`) + db.exec(`CREATE INDEX IF NOT EXISTS idx_move_batch ON stock_movement (batch_id)`) + db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id)`) + // H2 — tamper-evident audit hash chain. Additive columns for dev DBs seeded before the + // chain existed; fresh DBs get them from SCHEMA above. Pre-existing rows stay NULL (they are + // not retro-hashed) — on a real upgrade the chain is consistent from the first row written + // after this migration; the dev DB is reset for this slice so every row is chained from genesis. + const auditCols = db.prepare(`PRAGMA table_info(audit_log)`).all() as { name: string }[] + const auditHas = (c: string): boolean => auditCols.some((x) => x.name === c) + if (!auditHas('prev_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT`) + if (!auditHas('entry_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN entry_hash TEXT`) } -export function openDb(): DB { - const dir = path.resolve(__dirname, '../data') - fs.mkdirSync(dir, { recursive: true }) - const db = new Database(path.join(dir, 'sims.db')) +/** + * 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 ''; + * 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 }) + file = path.join(dir, 'sims.db') + } + const db = new Database(file) + applyKeyIfSet(db) // H3 — key BEFORE any read (journal_mode/schema touch the file) db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') db.exec(SCHEMA) diff --git a/apps/store-server/src/print-guard.ts b/apps/store-server/src/print-guard.ts new file mode 100644 index 0000000..8b20bbc --- /dev/null +++ b/apps/store-server/src/print-guard.ts @@ -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 { + 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): 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') +} diff --git a/apps/store-server/src/rate-limit.ts b/apps/store-server/src/rate-limit.ts new file mode 100644 index 0000000..b873bc5 --- /dev/null +++ b/apps/store-server/src/rate-limit.ts @@ -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() + 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 } +} diff --git a/apps/store-server/src/repos-gst.ts b/apps/store-server/src/repos-gst.ts new file mode 100644 index 0000000..268f76b --- /dev/null +++ b/apps/store-server/src/repos-gst.ts @@ -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 { + 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): 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 => { + const m = new Map() + 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[]>() + 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[]>() + 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[]>() + for (const n of g1.cdnr) { + const rateMap = new Map() + 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() + 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, + } +} diff --git a/apps/store-server/src/repos-purchase.ts b/apps/store-server/src/repos-purchase.ts index aaff0b2..96f9236 100644 --- a/apps/store-server/src/repos-purchase.ts +++ b/apps/store-server/src/repos-purchase.ts @@ -1,6 +1,7 @@ -import { uuidv7 } from '@sims/domain' +import { uuidv7, type RoleCode } from '@sims/domain' +import { can } from '@sims/auth' import type { DB } from './db' -import { writeAudit } from './repos' +import { upsertBatch, writeAudit } from './repos' /** * Purchases — stock IN, cost memory, and price updates in one transaction. @@ -16,6 +17,10 @@ export interface PurchaseLineInput { /** Optional price revisions decided at entry time (cost-changed prompt). */ newSalePricePaise?: number newMrpPaise?: number + /** S5: batch/expiry captured for a batch-tracked item. batchNo upserts the batch + * (found by unique key or created); expiryDate is optional (undated lots allowed). */ + batchNo?: string + expiryDate?: string } export interface CommitPurchaseInput { @@ -62,6 +67,13 @@ export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string throw Object.assign(new Error(`Invoice ${input.invoiceNo} from this supplier is already entered (duplicate)`), { status: 409 }) } + // C4 fix: receiving stock (PURCHASE_ENTRY, gated at the route) is separate from moving + // the item master. Only a MASTER_EDIT holder may push newSalePricePaise / newMrpPaise; + // for anyone else the stock still comes in but the price revision is ignored and noted. + const buyer = db.prepare(`SELECT roles FROM app_user WHERE id=? AND tenant_id=?`).get(input.userId, input.tenantId) as { roles: string } | undefined + const canEditMaster = buyer !== undefined && can({ roles: JSON.parse(buyer.roles) as RoleCode[], active: true }, 'MASTER_EDIT') + let priceUpdatesSkipped = 0 + const id = uuidv7() const now = new Date().toISOString() @@ -75,18 +87,31 @@ export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string input.businessDate, taxable, tax, total, input.userId, now, JSON.stringify(input.lines), ) const insLine = db.prepare( - `INSERT INTO purchase_line (purchase_id, line_no, item_id, name, qty, unit_cost_paise, tax_rate_bp, tax_paise, line_total_paise, new_sale_price_paise, new_mrp_paise) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO purchase_line (purchase_id, line_no, item_id, name, qty, unit_cost_paise, tax_rate_bp, tax_paise, line_total_paise, new_sale_price_paise, new_mrp_paise, batch_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) const move = db.prepare( - `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall) - VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?)`, + `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall) + VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?, ?)`, ) input.lines.forEach((l, i) => { const m = math[i]! - insLine.run(id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total, l.newSalePricePaise ?? null, l.newMrpPaise ?? null) - move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, input.businessDate, now) - if (l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined) { + // S5: a batch-tracked line names its batch — upsert it (unique key or new) and + // carry the batch_id onto both the purchase line and the stock-IN movement so + // per-batch on-hand derives from movements (R7). Un-batched lines pass NULL. + const batchId = l.batchNo !== undefined && l.batchNo.trim() !== '' + ? upsertBatch(db, input.tenantId, input.storeId, l.itemId, l.batchNo.trim(), l.expiryDate) + : null + const requestedPriceChange = l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined + const applyPriceChange = requestedPriceChange && canEditMaster + insLine.run( + id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total, + applyPriceChange ? (l.newSalePricePaise ?? null) : null, + applyPriceChange ? (l.newMrpPaise ?? null) : null, + batchId, + ) + move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, batchId, input.businessDate, now) + if (applyPriceChange) { const before = db.prepare(`SELECT sale_price_paise, mrp_paise FROM item WHERE id=?`).get(l.itemId) db.prepare( `UPDATE item SET sale_price_paise = COALESCE(?, sale_price_paise), mrp_paise = COALESCE(?, mrp_paise) WHERE id=? AND tenant_id=?`, @@ -94,10 +119,13 @@ export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string writeAudit(db, input.tenantId, input.userId, 'ITEM_PRICE_UPDATE', 'item', l.itemId, before, { salePricePaise: l.newSalePricePaise, mrpPaise: l.newMrpPaise, source: `purchase ${input.invoiceNo}`, }) + } else if (requestedPriceChange) { + priceUpdatesSkipped++ } }) writeAudit(db, input.tenantId, input.userId, 'PURCHASE_COMMIT', 'purchase', id, undefined, { invoiceNo: input.invoiceNo, supplier: input.supplierId, total, + ...(priceUpdatesSkipped > 0 ? { priceUpdatesSkipped, reason: 'caller lacks MASTER_EDIT' } : {}), }) })() diff --git a/apps/store-server/src/repos-returns.ts b/apps/store-server/src/repos-returns.ts new file mode 100644 index 0000000..d33c06b --- /dev/null +++ b/apps/store-server/src/repos-returns.ts @@ -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 { + 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() + 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() + 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[] { + 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[] +} + +/** + * 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 | 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 | 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[] { + 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[] +} diff --git a/apps/store-server/src/repos.ts b/apps/store-server/src/repos.ts index a98b49a..340f210 100644 --- a/apps/store-server/src/repos.ts +++ b/apps/store-server/src/repos.ts @@ -1,9 +1,16 @@ +import { createHash } from 'node:crypto' import { - deriveSupply, formatDocNo, fyOf, fyShort, seriesPrefix, uuidv7, validateGstin, - type Item, type Party, + deriveSupply, formatDocNo, fyOf, seriesPrefix, uuidv7, validateGstin, + type Item, type Party, type RoleCode, } from '@sims/domain' +import { can, type ActionCode } from '@sims/auth' import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine' import type { DB } from './db' +import { PAYMENT_MODES, assertBusinessDate } from './validate' + +/** Tag an error with an HTTP status + a safe client code (the error handler never leaks a stack). */ +const httpErr = (status: number, message: string, code: string): Error => + Object.assign(new Error(message), { status, code }) /** * Repositories — the portable data layer (D12 guardrail). Plain functions over @@ -150,19 +157,142 @@ export interface CommitBillInput { * Supervisor-approved counter overrides (spec P0-2 / P1-3): each price / qty / * discount deviation that raised the supervisor PIN gate rides along with the * bill so its audit row is written in the SAME transaction as the commit (R17). + * `approvalId` is the single-use token minted by /auth/verify-pin — the server + * redeems it and reads the approver identity from ITS OWN row, never trusting a + * client-supplied user id (SEC fix). + */ + overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string }[] + /** + * S4 offline queue: the client-generated bill id (uuidv7). Present only when a + * bill made while the store-server was unreachable is drained on reconnect; it + * is the idempotency key — a re-drained payload returns the already-assigned doc + * no instead of committing a second bill (02-ARCHITECTURE §3 outbox pattern). */ - overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[] + clientId?: string } const OVERRIDE_ACTION = { price: 'PRICE_OVERRIDE', qty: 'QTY_OVERRIDE', discount: 'DISCOUNT_OVERRIDE', + // S5: a supervisor approved selling an EXPIRED batch (E-1310 gate). before/after + // are 0 — the audited fact is "who approved the expired-batch sale for this item". + batch: 'BATCH_OVERRIDE', } as const -export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: string; payablePaise: number } { - const store = db.prepare(`SELECT * FROM store WHERE id=?`).get(input.storeId) as { code: string; state_code: string } - const counter = db.prepare(`SELECT * FROM counter WHERE id=?`).get(input.counterId) as { code: string } +/** Roles allowed to approve a counter override (09-UX §5.5). */ +const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner']) +/** A supervisor-approval token is valid for five minutes after it is minted. */ +const APPROVAL_TTL_MS = 5 * 60 * 1000 + +/** The bound context a supervisor is approving — re-verified at redeem (H4). */ +export interface OverrideContext { + itemId: string + kind: 'price' | 'qty' | 'discount' | 'batch' + beforePaise: number + afterPaise: number + /** The cashier the approval was granted to (advisory; not enforced at redeem). */ + cashierUserId?: string +} + +/** + * Mint a single-use supervisor-approval token (SEC fix), BOUND to the exact override it + * authorises (H4 fix). Called by /auth/verify-pin once a supervisor PIN is proven; the + * bill commit later redeems it and re-verifies item/kind/before/after against the line's + * server-computed deviation, so a token minted for one line can never move to another. + */ +export function createOverrideApproval( + db: DB, tenantId: string, approverUserId: string, ctx: OverrideContext, +): string { + const id = uuidv7() + db.prepare( + `INSERT INTO override_approval (id, tenant_id, approver_user_id, created_at, used, item_id, kind, before_paise, after_paise, cashier_user_id) + VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`, + ).run(id, tenantId, approverUserId, new Date().toISOString(), ctx.itemId, ctx.kind, ctx.beforePaise, ctx.afterPaise, ctx.cashierUserId ?? null) + return id +} + +/** + * The discount cap (basis points) beyond which a line discount is a deviation needing a + * supervisor override. A dated tenant setting (R9/R10): tenant row wins over the system + * default; 1000 bp (10%) if neither is present. + */ +function resolveDiscountCapBp(db: DB, tenantId: string): number { + const row = db.prepare( + `SELECT value FROM setting WHERE key='discount.capBp' + AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?)) + ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`, + ).get(tenantId) as { value: string } | undefined + const n = row !== undefined ? Number(row.value) : NaN + return Number.isFinite(n) && n >= 0 ? n : 1000 +} + +/** + * The tax class the server FORCES onto a cashier-created draft item (C3 fix). A cashier + * quick-adding an unknown good must not be able to choose ZERO (or any class) to evade GST; + * the server ignores the client's taxClassCode and assigns the store's standard rate. A + * dated tenant setting (R9/R10): tenant row wins over the system default; 'GST18' if neither + * is present. A MASTER_EDIT role (manager/owner) keeps its chosen class — back office + * completes the draft's real HSN/tax class later. + */ +export function resolveDefaultTaxClass(db: DB, tenantId: string): string { + const row = db.prepare( + `SELECT value FROM setting WHERE key='catalog.defaultTaxClass' + AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?)) + ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`, + ).get(tenantId) as { value: string } | undefined + return row !== undefined && row.value !== '' ? row.value : 'GST18' +} + +/** + * The price ceiling (paise) a cashier-created draft may carry (C2 bound). A quick-add's + * price IS its sell price (inherent to the flow), so a genuinely expensive unknown must go + * through a supervisor / back office rather than be self-priced arbitrarily at the counter. + * A dated tenant setting (R9/R10): tenant row wins over the system default; ₹5,000 + * (500000 paise) if neither is present. + */ +export function resolveDraftMaxPricePaise(db: DB, tenantId: string): number { + const row = db.prepare( + `SELECT value FROM setting WHERE key='catalog.draftMaxPricePaise' + AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?)) + ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`, + ).get(tenantId) as { value: string } | undefined + const n = row !== undefined ? Number(row.value) : NaN + return Number.isFinite(n) && n > 0 ? n : 500_000 +} + +export function commitBill( + db: DB, input: CommitBillInput, +): { id: string; docNo: string; payablePaise: number; duplicate?: boolean } { + // Idempotent drain (S4): if this client bill id already committed, return the + // doc no it was assigned — never allocate a new series number or a second stock + // movement. The partial unique index on (tenant_id, client_id) is the backstop. + if (input.clientId !== undefined) { + const existing = db.prepare( + `SELECT id, doc_no, payable_paise FROM bill WHERE tenant_id=? AND client_id=?`, + ).get(input.tenantId, input.clientId) as { id: string; doc_no: string; payable_paise: number } | undefined + if (existing !== undefined) { + return { id: existing.id, docNo: existing.doc_no, payablePaise: existing.payable_paise, duplicate: true } + } + } + // The business date is a real, sanely-bounded ISO day (H6) even when a caller reaches + // commitBill directly — a garbage date must never mint a doc series or postdate a bill. + assertBusinessDate(input.businessDate) + const store = db.prepare(`SELECT * FROM store WHERE id=? AND tenant_id=?`).get(input.storeId, input.tenantId) as { code: string; state_code: string } | undefined + const counter = db.prepare(`SELECT * FROM counter WHERE id=? AND tenant_id=?`).get(input.counterId, input.tenantId) as { code: string } | undefined + if (store === undefined || counter === undefined) { + throw httpErr(400, 'Unknown store or counter for this tenant (E-1110)', 'E-1110') + } const rates = listTaxClasses(db) + // The committing user's authority (C2/H7): owner/manager/supervisor may self-authorise a + // deviation from the master; everyone else needs a bound supervisor token per deviation. + const committer = db.prepare( + `SELECT display_name, roles FROM app_user WHERE id=? AND tenant_id=?`, + ).get(input.cashierId, input.tenantId) as { display_name: string; roles: string } | undefined + if (committer === undefined) throw httpErr(403, 'Unknown cashier (E-6103)', 'E-6103') + const committerRoles = JSON.parse(committer.roles) as RoleCode[] + const committerCan = (action: ActionCode): boolean => can({ roles: committerRoles, active: true }, action) + const capBp = resolveDiscountCapBp(db, input.tenantId) + // Place of supply is decided from OUR copy of the customer row, never the // client's claim: a B2B buyer's GSTIN state drives IGST vs CGST/SGST (R5). const customer = input.customerId !== undefined @@ -175,32 +305,138 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: store.state_code, ) - // Same engine everywhere: the server recomputes and must agree to the paisa. - const computed = computeBill(input.lines, { + // ---- Item-master anchoring (C2, C3, H5, M8) ---- + // Every line is rebuilt from OUR item row: tax class, HSN, MRP, unit and the inclusive- + // price flag come from the master; the client's values for them are ignored. The unit + // price is accepted only when it equals the master sale price — any other value is a + // deviation that must be authorised below (a bound token, or the committer's own + // authority). A price above MRP is a hard block even with a token (E-1301). + interface Deviation { kind: 'price' | 'discount'; itemId: string; before: number; after: number; name: string } + const deviations: Deviation[] = [] + // Draft-item sales (C2/C3 review trail): a cashier-authored quick-add item is sold on the + // same session that created it, so its only master-side trace is ITEM_CREATE. Every line + // whose master row is still status='draft' is flagged here and audited DRAFT_SALE in the + // commit transaction below, so a self-authored-item sale is never invisible in a review. + interface DraftSale { itemId: string; name: string; pricePaise: number } + const draftSales: DraftSale[] = [] + const anchored: LineInput[] = input.lines.map((line) => { + const item = db.prepare(`SELECT * FROM item WHERE id=? AND tenant_id=?`).get(line.itemId, input.tenantId) as ItemRow | undefined + if (item === undefined) { + throw httpErr(400, 'A billed item is not in this store catalogue (E-1105)', 'E-1105') + } + if (!Number.isFinite(line.qty) || line.qty <= 0 || line.qty > 1e12) { + throw httpErr(400, `Quantity is out of range for "${item.name}" (E-1104)`, 'E-1104') + } + if (item.unit_decimals === 0 && !Number.isInteger(line.qty)) { + throw httpErr(400, `"${item.name}" is sold whole — a fractional quantity is not allowed (E-1104)`, 'E-1104') + } + const effPrice = line.unitPricePaise + if (!Number.isInteger(effPrice) || effPrice < 0) { + throw httpErr(400, `Unit price is invalid for "${item.name}" (E-1103)`, 'E-1103') + } + if (item.status === 'draft') { + draftSales.push({ itemId: line.itemId, name: item.name, pricePaise: effPrice }) + } + const masterPrice = item.sale_price_paise + if (effPrice !== masterPrice) { + if (item.mrp_paise !== null && effPrice > item.mrp_paise) { + throw httpErr(400, `Price for "${item.name}" is above its MRP — not allowed (E-1301)`, 'E-1301') + } + deviations.push({ kind: 'price', itemId: line.itemId, before: masterPrice, after: effPrice, name: item.name }) + } + if (line.discount !== undefined) { + const gross = Math.round(effPrice * line.qty) + const discPaise = line.discount.kind === 'percentBp' + ? Math.round((gross * line.discount.value) / 10_000) + : line.discount.paise + if (discPaise > Math.floor((gross * capBp) / 10_000)) { + deviations.push({ kind: 'discount', itemId: line.itemId, before: 0, after: discPaise, name: item.name }) + } + } + return { + itemId: line.itemId, + name: item.name, + hsn: item.hsn, + qty: line.qty, + unitCode: item.unit_code, + unitPricePaise: effPrice, + priceIncludesTax: item.price_includes_tax === 1, + taxClassCode: item.tax_class_code, + ...(item.mrp_paise !== null ? { mrpPaise: item.mrp_paise } : {}), + ...(line.discount !== undefined ? { discount: line.discount } : {}), + ...(line.batchId !== undefined ? { batchId: line.batchId } : {}), + } + }) + + // Same engine everywhere, now on the ANCHORED lines: the server recomputes from its own + // prices/tax and must still agree with the client to the paisa (R5) — a client that + // under-computed GST (C3) or forged a total is rejected here. + const computed = computeBill(anchored, { businessDate: input.businessDate, supplyStateCode: store.state_code, placeOfSupplyStateCode: supply.placeOfSupplyStateCode, roundToRupee: true, }, rates) if (computed.totals.payablePaise !== input.clientPayablePaise) { - throw Object.assign(new Error('Engine mismatch: client and server computed different totals'), { status: 409 }) + throw httpErr(409, 'Bill total does not match the item master — refresh and retry (E-1302)', 'E-1302') + } + // Payments (M8): every leg a positive amount with an allowlisted mode, summing to payable. + for (const p of input.payments) { + if (!Number.isInteger(p.amountPaise) || p.amountPaise <= 0) { + throw httpErr(400, 'Every payment must be a positive amount (E-1107)', 'E-1107') + } + if (!PAYMENT_MODES.has(p.mode)) throw httpErr(400, 'Unknown payment mode (E-1108)', 'E-1108') } const paid = input.payments.reduce((a, p) => a + p.amountPaise, 0) if (paid !== computed.totals.payablePaise) { - throw Object.assign(new Error('Payments do not sum to payable'), { status: 400 }) + throw httpErr(400, 'Payments do not sum to the payable amount (E-1109)', 'E-1109') } const id = uuidv7() const now = new Date().toISOString() const fy = fyOf(input.businessDate) + // Redeem a bound supervisor token for a specific server-computed deviation (H4): the + // token must exist, be unused, be < 5 min old, belong to an approver role, AND match the + // exact item/kind/before/after — then it is marked used in THIS transaction and the + // approver identity is read from the server's own row, never the client's. + const redeemToken = ( + approvalId: string, kind: Deviation['kind'] | 'batch', itemId: string, before: number, after: number, + ): { name: string; userId: string } => { + const appr = db.prepare( + `SELECT approver_user_id, created_at, used, item_id, kind, before_paise, after_paise FROM override_approval WHERE id=? AND tenant_id=?`, + ).get(approvalId, input.tenantId) as { + approver_user_id: string; created_at: string; used: number + item_id: string | null; kind: string | null; before_paise: number | null; after_paise: number | null + } | undefined + if (appr === undefined) throw httpErr(403, 'Override approval is not recognised (E-6107)', 'E-6107') + if (appr.used !== 0) throw httpErr(403, 'Override approval was already used (E-6108)', 'E-6108') + if (Date.now() - Date.parse(appr.created_at) > APPROVAL_TTL_MS) { + throw httpErr(403, 'Override approval has expired — re-approve at the counter (E-6109)', 'E-6109') + } + if (appr.item_id !== itemId || appr.kind !== kind || appr.before_paise !== before || appr.after_paise !== after) { + throw httpErr(403, 'Override approval does not match this line — re-approve at the counter (E-6108)', 'E-6108') + } + const approver = db.prepare( + `SELECT display_name, roles FROM app_user WHERE id=? AND tenant_id=?`, + ).get(appr.approver_user_id, input.tenantId) as { display_name: string; roles: string } | undefined + if (approver === undefined || !(JSON.parse(approver.roles) as string[]).some((role) => APPROVER_ROLES.has(role))) { + throw httpErr(403, 'Override approver is not authorised (E-6110)', 'E-6110') + } + const redeemed = db.prepare( + `UPDATE override_approval SET used=1 WHERE id=? AND tenant_id=? AND used=0`, + ).run(approvalId, input.tenantId) + if (redeemed.changes !== 1) throw httpErr(403, 'Override approval was already used (E-6108)', 'E-6108') + return { name: approver.display_name, userId: appr.approver_user_id } + } + const txn = db.transaction(() => { // per-counter, per-FY series allocated inside the commit transaction let series = db.prepare( `SELECT * FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`, ).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined if (series === undefined) { - series = { prefix: seriesPrefix(store.code, counter.code, fyShort(fy)), next_seq: 1 } + series = { prefix: seriesPrefix(store.code, counter.code, fy), next_seq: 1 } db.prepare( `INSERT INTO doc_series (tenant_id, store_id, counter_id, doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 'SALE', ?, ?, 1)`, ).run(input.tenantId, input.storeId, input.counterId, fy, series.prefix) @@ -215,21 +451,34 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: db.prepare( `INSERT INTO bill (id, tenant_id, store_id, counter_id, doc_type, doc_no, business_date, cashier_id, shift_id, customer_id, ref_doc_id, gross_paise, discount_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, cess_paise, round_off_paise, payable_paise, savings_paise, - buyer_gstin, place_of_supply, payload, created_at_wall) VALUES (?, ?, ?, ?, 'SALE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + buyer_gstin, place_of_supply, client_id, payload, created_at_wall) VALUES (?, ?, ?, ?, 'SALE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate, input.cashierId, input.shiftId, input.customerId ?? null, t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise, - supply.buyerGstin ?? null, supply.placeOfSupplyStateCode, + supply.buyerGstin ?? null, supply.placeOfSupplyStateCode, input.clientId ?? null, payload, now, ) const move = db.prepare( - `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall) - VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?)`, + `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall) + VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?, ?)`, ) for (const line of computed.lines) { - move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, input.businessDate, now) + // S5: a batch-picked line carries a batchId; the sale movement is attributed to + // it so per-batch on-hand derives correctly (R7). Trust nothing — the batch must + // belong to THIS item and store, or the whole commit rolls back (E-1311). + let batchId: string | null = null + if (line.batchId !== undefined) { + const belongs = db.prepare( + `SELECT 1 FROM batch WHERE id=? AND tenant_id=? AND store_id=? AND item_id=?`, + ).get(line.batchId, input.tenantId, input.storeId, line.itemId) + if (belongs === undefined) { + throw Object.assign(new Error(`Batch does not belong to "${line.name}" at this store (E-1311)`), { status: 400 }) + } + batchId = line.batchId + } + move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, batchId, input.businessDate, now) } // dormant outbox row (D14): written in the same transaction, drained by nothing yet db.prepare( @@ -237,18 +486,59 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: VALUES (?, ?, 'store-server', 0, 'SALE', ?, ?, ?, 0)`, ).run(uuidv7(), input.tenantId, id, payload, now) writeAudit(db, input.tenantId, input.cashierId, 'BILL_COMMIT', 'bill', id, undefined, { docNo, payable: t.payablePaise }) - // One audit row per approved override, in-transaction (R17). The approver's id - // is resolved to a display name so the audit trail is human-readable. + + // Flag every draft-item line for back-office review (C2/C3): one DRAFT_SALE row per + // draft sold, in THIS transaction, carrying the item + the price it sold at — so a + // cashier who quick-adds and sells a self-priced item leaves an explicit trail. + for (const d of draftSales) { + writeAudit(db, input.tenantId, input.cashierId, 'DRAFT_SALE', 'bill', id, + undefined, { itemId: d.itemId, name: d.name, pricePaise: d.pricePaise, docNo }) + } + + // Authorise + audit every SERVER-detected price/discount deviation (C2, H4, H7). Each + // deviation is approved by a bound token that matches it exactly, OR — for owner / + // manager / supervisor — the committer's own authority; either way an override audit + // row is written in-transaction (R17). An unauthorised deviation throws and the whole + // commit rolls back, so a mispriced line can never reach the ledger unaudited. + const usedOverride = new Set() + for (const dev of deviations) { + const action: ActionCode = dev.kind === 'price' ? 'PRICE_OVERRIDE' : 'DISCOUNT_OVER_CAP' + const auditAction = dev.kind === 'price' ? OVERRIDE_ACTION.price : OVERRIDE_ACTION.discount + const idx = (input.overrides ?? []).findIndex((o, i) => + !usedOverride.has(i) && o.approvalId !== undefined && o.kind === dev.kind && o.itemId === dev.itemId && o.after === dev.after) + if (idx >= 0) { + usedOverride.add(idx) + const approvalId = input.overrides![idx]!.approvalId! + const appr = redeemToken(approvalId, dev.kind, dev.itemId, dev.before, dev.after) + writeAudit(db, input.tenantId, input.cashierId, auditAction, 'bill', id, + { before: dev.before, itemId: dev.itemId }, + { after: dev.after, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId }) + } else if (committerCan(action)) { + writeAudit(db, input.tenantId, input.cashierId, auditAction, 'bill', id, + { before: dev.before, itemId: dev.itemId }, + { after: dev.after, approvedBy: committer.display_name, approvedByUserId: input.cashierId, selfAuthorized: true }) + } else { + const code = dev.kind === 'price' ? 'E-1302' : 'E-1303' + throw httpErr(403, `"${dev.name}": ${dev.kind} change needs supervisor approval (${code})`, code) + } + } + + // Client-declared batch overrides (expired-batch sale, S5): no server-side price + // deviation to detect, but the approval is bound + audited the same way (H4/H7). for (const o of input.overrides ?? []) { - const approvedBy = o.approvedByUserId !== undefined - ? (db.prepare(`SELECT display_name FROM app_user WHERE id=?`).get(o.approvedByUserId) as { display_name: string } | undefined)?.display_name - ?? o.approvedByUserId - : undefined - writeAudit( - db, input.tenantId, input.cashierId, OVERRIDE_ACTION[o.kind], 'bill', id, - { itemId: o.itemId, before: o.before }, - { after: o.after, approvedBy, approvedByUserId: o.approvedByUserId }, - ) + if (o.kind !== 'batch') continue + if (o.approvalId !== undefined) { + const appr = redeemToken(o.approvalId, 'batch', o.itemId, 0, 0) + writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id, + { before: 0, itemId: o.itemId }, + { after: 0, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId: o.approvalId }) + } else if (committerCan('PRICE_OVERRIDE')) { + writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id, + { before: 0, itemId: o.itemId }, + { after: 0, approvedBy: committer.display_name, approvedByUserId: input.cashierId, selfAuthorized: true }) + } else { + throw httpErr(403, 'Expired-batch sale needs supervisor approval (E-1310)', 'E-1310') + } } return docNo }) @@ -259,13 +549,21 @@ export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: export function listBills( db: DB, tenantId: string, date?: string, counterId?: string, limit = 100, offset = 0, + cashierId?: string, ): Record[] { + // SALE only — credit notes (SALE_RETURN) are their own document and live on the Returns list, + // never mixed into the sales list or the POS day history. let sql = `SELECT b.*, u.display_name AS cashier_name, p.name AS customer_name FROM bill b LEFT JOIN app_user u ON u.id=b.cashier_id LEFT JOIN party p ON p.id=b.customer_id - WHERE b.tenant_id=?` + WHERE b.tenant_id=? AND b.doc_type='SALE'` const args: unknown[] = [tenantId] if (date !== undefined) { sql += ` AND b.business_date=?`; args.push(date) } if (counterId !== undefined) { sql += ` AND b.counter_id=?`; args.push(counterId) } + // Cross-counter read scope (residual LOW/MED): when the caller lacks REPORT_VIEW the route + // pins this to the session's own user id, so a cashier sees only their OWN bills — never + // every counter's/cashier's. A REPORT_VIEW/manager role passes undefined and keeps the + // full store-wide view. + if (cashierId !== undefined) { sql += ` AND b.cashier_id=?`; args.push(cashierId) } sql += ` ORDER BY b.created_at_wall DESC LIMIT ? OFFSET ?` args.push(limit, offset) return db.prepare(sql).all(...args) as Record[] @@ -274,26 +572,144 @@ export function listBills( // ---------- stock ---------- export function stockView(db: DB, tenantId: string, storeId: string): Record[] { return db.prepare( - `SELECT i.code, i.name, i.unit_code, i.sale_price_paise, + `SELECT i.code, i.name, i.unit_code, i.sale_price_paise, i.batch_tracked, COALESCE(SUM(m.qty_delta), 0) AS on_hand FROM item i LEFT JOIN stock_movement m ON m.item_id=i.id AND m.store_id=? WHERE i.tenant_id=? GROUP BY i.id ORDER BY i.name`, ).all(storeId, tenantId) as Record[] } -// ---------- 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[] { + 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[] +} + +/** + * 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[] { + 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[] +} + +// ---------- 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 NULL→JS null) so verify recomputes + * byte-identically to what writeAudit hashed. The chain is per-tenant: prevHash is the previous + * entry's entry_hash for the tenant, genesis '' for the first. + */ +function auditEntryHash( + prevHash: string, tenantId: string, atWall: string, userId: string, action: string, + entity: string, entityId: string, beforeJson: string | null, afterJson: string | null, +): string { + const canonical = JSON.stringify([prevHash, tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson]) + return createHash('sha256').update(canonical, 'utf8').digest('hex') +} + +/** + * Append one audit entry, linked into the tenant's hash chain (H2). The previous entry is the + * tenant's last-inserted row (SQLite rowid order = insertion order under the single-writer + * model); verifyAuditChain recomputes in the same order. Called inside the existing commit + * transactions — the prev-row read sees earlier in-transaction inserts on the same connection, + * so several audit rows written in one commit chain correctly among themselves. + */ export function writeAudit( db: DB, tenantId: string, userId: string, action: string, entity: string, entityId: string, before?: unknown, after?: unknown, ): void { + const prev = db.prepare( + `SELECT entry_hash FROM audit_log WHERE tenant_id=? ORDER BY rowid DESC LIMIT 1`, + ).get(tenantId) as { entry_hash: string | null } | undefined + const prevHash = prev?.entry_hash ?? '' + const atWall = new Date().toISOString() + const beforeJson = before === undefined ? null : JSON.stringify(before) + const afterJson = after === undefined ? null : JSON.stringify(after) + const entryHash = auditEntryHash(prevHash, tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson) db.prepare( - `INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - uuidv7(), tenantId, new Date().toISOString(), userId, action, entity, entityId, - before === undefined ? null : JSON.stringify(before), - after === undefined ? null : JSON.stringify(after), - ) + `INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(uuidv7(), tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson, prevHash, entryHash) +} + +/** + * Recompute the tenant's audit hash chain and report the first entry that fails (H2). Returns + * `{ ok: true }` when every row's stored prev_hash matches the running chain AND its entry_hash + * matches a recompute over its own content; otherwise `{ ok: false, brokenAtId }` names the first + * broken row. This makes any out-of-band tamper VISIBLE: an UPDATE changes a row's content so its + * entry_hash no longer recomputes; a DELETE breaks the next row's prev_hash link; a naive INSERT + * lands with a hash the chain can't reproduce. It is tamper-EVIDENCE, not prevention — an attacker + * with file write access could re-chain forward (the chain is unsigned); sealing the file is H3. + */ +export function verifyAuditChain(db: DB, tenantId: string): { ok: boolean; brokenAtId?: string } { + const rows = db.prepare( + `SELECT id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash + FROM audit_log WHERE tenant_id=? ORDER BY rowid ASC`, + ).all(tenantId) as { + id: string; at_wall: string; user_id: string; action: string; entity: string + entity_id: string; before_json: string | null; after_json: string | null + prev_hash: string | null; entry_hash: string | null + }[] + let prevHash = '' + for (const r of rows) { + if ((r.prev_hash ?? '') !== prevHash) return { ok: false, brokenAtId: r.id } + const expected = auditEntryHash( + prevHash, tenantId, r.at_wall, r.user_id, r.action, r.entity, r.entity_id, r.before_json, r.after_json, + ) + if (expected !== r.entry_hash) return { ok: false, brokenAtId: r.id } + prevHash = r.entry_hash + } + return { ok: true } } export function listAudit(db: DB, tenantId: string, limit = 100, offset = 0): Record[] { diff --git a/apps/store-server/src/seed.ts b/apps/store-server/src/seed.ts index 5ce391f..9b4cbda 100644 --- a/apps/store-server/src/seed.ts +++ b/apps/store-server/src/seed.ts @@ -80,6 +80,82 @@ export function seedIfEmpty(db: DB): void { setting.run('system', '', 'gst.roundToRupee', 'true') setting.run('system', '', 'discount.capBp', '1000') setting.run('tenant', 't1', 'discount.capBp', '1500') + // Cashier quick-add draft guards (C2/C3): the tax class the server forces onto a + // cashier-created draft (never the client's — no ZERO-tax GST evasion), and the price + // ceiling above which a draft must go through a supervisor / back office. Tenant rows may + // override; the resolvers fall back to these same defaults if a row is missing. + setting.run('system', '', 'catalog.defaultTaxClass', 'GST18') + setting.run('system', '', 'catalog.draftMaxPricePaise', '500000') + // GST returns: the B2C-Large threshold (a B2C inter-state invoice ABOVE this is B2CL, else + // B2CS). Dated config (R10) — the classic ₹2,50,000; a tenant row overrides per period. + setting.run('system', '', 'gst.b2clThresholdPaise', '25000000') db.prepare(`INSERT INTO message_catalog (code, channel, lang, template) VALUES ('BILL_ESHARE','whatsapp','en','Hi {name}, your bill of {amount} from {store}. Thank you!')`).run() + + ensureBatchTrackingSeed(db) +} + +/** + * S5 demo batches. Idempotent and safe on ANY db — it runs both on a fresh seed and + * against the already-populated dev db (where seedIfEmpty is a no-op, so batches must + * be backfilled here). Marks the two demo perishables batch-tracked, then, if no batch + * exists yet, splits each item's opening stock into dated lots. Total on-hand is + * preserved (R7): the per-lot OPENING movements are offset by one negative un-batched + * OPENING adjustment per item, so re-attributing stock into lots never inflates the + * derived quantity. One lot per item is deliberately expired-with-stock to exercise the + * E-1310 gate and the dashboard's expired section. + */ +export function ensureBatchTrackingSeed(db: DB): void { + db.prepare(`UPDATE item SET batch_tracked=1 WHERE tenant_id='t1' AND code IN ('105','107')`).run() + const existing = (db.prepare(`SELECT COUNT(*) AS n FROM batch WHERE tenant_id='t1'`).get() as { n: number }).n + if (existing > 0) return + + const now = new Date() + const iso = (d: Date): string => d.toISOString().slice(0, 10) + const plusDays = (days: number): string => { + const d = new Date(now) + d.setDate(d.getDate() + days) + return iso(d) + } + // code → lots to create: batch_no, expiry offset in days (negative = already + // expired), opening qty. Quantities are re-attributed from the item's opening 120. + const plan: Record = { + // 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) + } + })() } diff --git a/apps/store-server/src/server.ts b/apps/store-server/src/server.ts index 9be15c4..5279276 100644 --- a/apps/store-server/src/server.ts +++ b/apps/store-server/src/server.ts @@ -1,8 +1,10 @@ import express from 'express' -import net from 'node:net' import path from 'node:path' +import fs from 'node:fs' +import https from 'node:https' +import helmet from 'helmet' import { openDb } from './db' -import { seedIfEmpty } from './seed' +import { ensureBatchTrackingSeed, seedIfEmpty } from './seed' import { apiRouter } from './api' /** @@ -10,11 +12,59 @@ import { apiRouter } from './api' * one Node process on the store's server machine — * / → back office web app * /pos → POS web app (counters browse here; zero installs) - * /api/print → ESC/POS bytes to a network printer (browsers can't open sockets) + * /api/print → ESC/POS bytes to a LAN network printer (authenticated + allowlisted, H1) * /api/health → monitoring handshake * The Oracle 12c repository layer (D12) and the outbox sync agent (D14) land here next. */ const app = express() +app.disable('x-powered-by') // no framework banner (M14) + +// M13 — TLS is available but NOT forced in dev. Production MUST supply a cert + key via +// SIMS_TLS_CERT / SIMS_TLS_KEY (paths); when both are set we serve HTTPS, otherwise plain +// HTTP on :5181 so the existing dev/browser/verification flow is unchanged. Once prod is +// HTTPS the bearer token no longer rides the LAN in cleartext (closes M13) and the +// cache-first service worker can no longer be MITM-poisoned over HTTP (closes M15). +// Make a throwaway dev cert (self-signed, 1 year) with: +// openssl req -x509 -newkey rsa:2048 -nodes -keyout dev-key.pem -out dev-cert.pem -days 365 -subj "/CN=localhost" +// then start with: SIMS_TLS_CERT=dev-cert.pem SIMS_TLS_KEY=dev-key.pem npm start +const TLS_CERT = process.env['SIMS_TLS_CERT'] +const TLS_KEY = process.env['SIMS_TLS_KEY'] +const tlsEnabled = TLS_CERT !== undefined && TLS_CERT !== '' && TLS_KEY !== undefined && TLS_KEY !== '' + +// M14 — security headers via helmet. The CSP is tuned for the built SPAs: they load their own +// module script + stylesheet from 'self', use inline style attributes (React `style={{}}`), +// open invoice/label tabs as blob: documents (which inherit this policy) whose ONLY inline +// script is a `window.print()` button — permitted by a single hash under 'unsafe-hashes' +// rather than opening script-src up to 'unsafe-inline' — and embed fonts as data:. The POS +// service worker + assets are all 'self'. `upgrade-insecure-requests` is emitted ONLY under +// TLS, so the dev HTTP instance is never told to upgrade to a port that serves no HTTPS. +app.use(helmet({ + contentSecurityPolicy: { + useDefaults: false, + directives: { + defaultSrc: ["'self'"], + baseUri: ["'self'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + formAction: ["'self'"], + imgSrc: ["'self'", 'data:', 'blob:'], + fontSrc: ["'self'", 'data:'], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'", "'unsafe-hashes'", "'sha256-MguIPR6qNR8D3B+eAlK+bIRTZe8t3wkOY4B/56Me9FU='"], + connectSrc: ["'self'"], + workerSrc: ["'self'"], + frameSrc: ["'self'", 'blob:'], + ...(tlsEnabled ? { upgradeInsecureRequests: [] } : {}), + }, + }, + // helmet only offers X-Frame-Options: SAMEORIGIN; the review asks for DENY, so set it below. + frameguard: false, + // HSTS only means anything over TLS; emitting it on the dev HTTP instance would be a lie. + hsts: tlsEnabled ? { maxAge: 15_552_000, includeSubDomains: true } : false, + crossOriginEmbedderPolicy: false, +})) +app.use((_req, res, next) => { res.setHeader('X-Frame-Options', 'DENY'); next() }) + app.use(express.json({ limit: '1mb' })) const POS_DIST = path.resolve(__dirname, '../../pos/dist') @@ -28,36 +78,52 @@ app.get('/api/health', (_req, res) => { }) const db = openDb() -seedIfEmpty(db) +// M10 — the demo seed creates a known-credential owner (thomas / PIN 9174 / pw 'sims') and the +// rest of the MegaMart demo. That must NEVER auto-materialise on a real, empty production DB, so +// seeding runs ONLY when explicitly enabled with SIMS_SEED_DEMO=1. The dev start script sets it +// (see package.json `start` → dev-start.mjs), so :5181 still seeds; a production deployment runs +// the built server directly with the flag unset, leaving an empty DB empty for the onboarding +// wizard. ensureBatchTrackingSeed (which backfills demo batches, hardcoded to tenant 't1') is +// gated with it — on a non-demo DB there is no 't1' data for it to touch anyway. +if (process.env['SIMS_SEED_DEMO'] === '1') { + seedIfEmpty(db) + // S5: backfill batch-tracking + demo batches on the already-populated dev DB, where + // seedIfEmpty short-circuits. Idempotent, so it is a no-op once the batches exist. + ensureBatchTrackingSeed(db) +} else { + console.warn('[store-server] SIMS_SEED_DEMO is not set — skipping demo seed (an empty DB stays empty; set SIMS_SEED_DEMO=1 for the dev demo data).') +} +// The data API — including the now-authenticated + allowlisted /api/print relay (H1), +// /api/auth/logout (M4) and the rate-limited /api/auth/* routes — lives in apiRouter. app.use('/api', apiRouter(db)) -app.post('/api/print', (req, res) => { - const { host, port, bytes } = req.body as { host?: string; port?: number; bytes?: number[] } - if (typeof host !== 'string' || typeof port !== 'number' || !Array.isArray(bytes)) { - res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }' }) - return - } - // 'close' always follows 'error'/'timeout' on net sockets — respond exactly once. - let done = false - const finish = (status: number, body: object) => { - if (done) return - done = true - res.status(status).json(body) - } - const socket = net.createConnection({ host, port, timeout: 3000 }) - socket.on('connect', () => socket.end(Buffer.from(bytes))) - socket.on('close', () => finish(200, { ok: true })) - socket.on('timeout', () => { - socket.destroy() - finish(504, { ok: false, error: `Printer ${host}:${port} timed out` }) +/** + * Generic error handler (M7): validators and repositories throw errors tagged with a safe + * `status` + `code`; anything untagged is treated as an internal fault and answered with a + * generic message. A stack trace or install path is NEVER sent to the client — unexpected + * errors are logged server-side only. Must be the LAST middleware and take 4 args. + */ +app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const e = (err ?? {}) as { status?: number; code?: string; message?: string } + const status = typeof e.status === 'number' ? e.status : 500 + if (status >= 500) console.error('[store-server] unhandled error:', err) + if (res.headersSent) return + res.status(status).json({ + error: status >= 500 ? 'Something went wrong (E-5000)' : (e.message ?? 'Bad request'), + code: e.code ?? (status >= 500 ? 'E-5000' : 'E-1000'), }) - socket.on('error', (err) => finish(502, { ok: false, error: err.message })) }) // 8080–8085 sit in Windows' Hyper-V excluded port ranges on shop PCs too — avoid them. const PORT = Number(process.env['PORT'] ?? 5181) -app.listen(PORT, () => { - console.log(`SiMS store-server listening on http://localhost:${PORT}`) - console.log(` Back office: http://localhost:${PORT}/`) - console.log(` POS: http://localhost:${PORT}/pos/`) -}) +const banner = (proto: string) => () => { + console.log(`SiMS store-server listening on ${proto}://localhost:${PORT}`) + console.log(` Back office: ${proto}://localhost:${PORT}/`) + console.log(` POS: ${proto}://localhost:${PORT}/pos/`) +} +if (tlsEnabled) { + const creds = { cert: fs.readFileSync(TLS_CERT!), key: fs.readFileSync(TLS_KEY!) } + https.createServer(creds, app).listen(PORT, banner('https')) +} else { + app.listen(PORT, banner('http')) +} diff --git a/apps/store-server/src/session-policy.ts b/apps/store-server/src/session-policy.ts new file mode 100644 index 0000000..31551bc --- /dev/null +++ b/apps/store-server/src/session-policy.ts @@ -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, + } +} diff --git a/apps/store-server/src/validate.ts b/apps/store-server/src/validate.ts new file mode 100644 index 0000000..07c17dc --- /dev/null +++ b/apps/store-server/src/validate.ts @@ -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(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']) diff --git a/apps/store-server/test/anchoring.test.ts b/apps/store-server/test/anchoring.test.ts new file mode 100644 index 0000000..a14d42d --- /dev/null +++ b/apps/store-server/test/anchoring.test.ts @@ -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, payable: number): Parameters[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) + }) +}) diff --git a/apps/store-server/test/audit-chain.test.ts b/apps/store-server/test/audit-chain.test.ts new file mode 100644 index 0000000..9c83e6d --- /dev/null +++ b/apps/store-server/test/audit-chain.test.ts @@ -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 tenant’s tamper does not fail another tenant’s 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 }) + }) +}) diff --git a/apps/store-server/test/batch.test.ts b/apps/store-server/test/batch.test.ts new file mode 100644 index 0000000..69a5858 --- /dev/null +++ b/apps/store-server/test/batch.test.ts @@ -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') + }) +}) diff --git a/apps/store-server/test/draft-guard.test.ts b/apps/store-server/test/draft-guard.test.ts new file mode 100644 index 0000000..8be4bd5 --- /dev/null +++ b/apps/store-server/test/draft-guard.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/apps/store-server/test/edge-guards.test.ts b/apps/store-server/test/edge-guards.test.ts new file mode 100644 index 0000000..219aa09 --- /dev/null +++ b/apps/store-server/test/edge-guards.test.ts @@ -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) + }) +}) diff --git a/apps/store-server/test/edge.test.ts b/apps/store-server/test/edge.test.ts new file mode 100644 index 0000000..35a017a --- /dev/null +++ b/apps/store-server/test/edge.test.ts @@ -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 { + 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 { + 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; users: Record[] } + 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; users: Record[] } + 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) + }) +}) diff --git a/apps/store-server/test/encryption.test.ts b/apps/store-server/test/encryption.test.ts new file mode 100644 index 0000000..c802c68 --- /dev/null +++ b/apps/store-server/test/encryption.test.ts @@ -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(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 + }) +}) diff --git a/apps/store-server/test/gst.test.ts b/apps/store-server/test/gst.test.ts new file mode 100644 index 0000000..701fd81 --- /dev/null +++ b/apps/store-server/test/gst.test.ts @@ -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[], 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 + 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 { + 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 { + 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') + }) +}) diff --git a/apps/store-server/test/override-approval.test.ts b/apps/store-server/test/override-approval.test.ts new file mode 100644 index 0000000..790bdfc --- /dev/null +++ b/apps/store-server/test/override-approval.test.ts @@ -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[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) + }) +}) diff --git a/apps/store-server/test/returns.test.ts b/apps/store-server/test/returns.test.ts new file mode 100644 index 0000000..cb5f19d --- /dev/null +++ b/apps/store-server/test/returns.test.ts @@ -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 = {}, +): Parameters[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 { + 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 { + 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}$/) + }) +}) diff --git a/apps/store-server/test/tenant-scope.test.ts b/apps/store-server/test/tenant-scope.test.ts new file mode 100644 index 0000000..3191ed7 --- /dev/null +++ b/apps/store-server/test/tenant-scope.test.ts @@ -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[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) + }) +}) diff --git a/docs/18-RED-TEAM-REVIEW.md b/docs/18-RED-TEAM-REVIEW.md new file mode 100644 index 0000000..2403a82 --- /dev/null +++ b/docs/18-RED-TEAM-REVIEW.md @@ -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:}`** (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. diff --git a/package-lock.json b/package-lock.json index 8e9d94a..dc9a921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2264,8 +2264,9 @@ "@sims/auth": "*", "@sims/billing-engine": "*", "@sims/domain": "*", - "better-sqlite3": "^11.7.0", - "express": "^4.21.0" + "better-sqlite3-multiple-ciphers": "^11.10.0", + "express": "^4.21.0", + "helmet": "^8.3.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", @@ -4663,6 +4664,17 @@ "prebuild-install": "^7.1.1" } }, + "node_modules/better-sqlite3-multiple-ciphers": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3-multiple-ciphers/-/better-sqlite3-multiple-ciphers-11.10.0.tgz", + "integrity": "sha512-/dKO3lKuJFbmuzh80uN2cmMsz8iyTskGB2l/fd9X6rt1P3EPIOvRUIxD7Qim8gLygUPB/u+db8byZGumOOdp3g==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -5788,6 +5800,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/packages/auth/src/pin.ts b/packages/auth/src/pin.ts index 9de52c7..2638a4d 100644 --- a/packages/auth/src/pin.ts +++ b/packages/auth/src/pin.ts @@ -1,4 +1,4 @@ -import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' /** * POS login is name-tile + PIN (09-UX §1: PIN-fast shift open); back office is @@ -9,6 +9,33 @@ export interface PinHash { hash: string // hex } +/** + * M3 (red-team): a server-held pepper, HMAC-SHA256'd into the secret BEFORE scrypt. A stolen DB + * gives an attacker the per-credential salts and hashes, but a 4-digit PIN's keyspace is only + * 10,000 — trivially brute-forced with the salt, no matter how costly scrypt is. The pepper lives + * only in the server's environment (`SIMS_PIN_PEPPER`), NEVER in the DB, so a DB-only theft leaves + * the attacker unable to compute a single candidate hash: the PIN is peppered under a key they do + * not have. When the env is unset we hash the raw secret exactly as before (dev logins keep + * working) and warn ONCE that production MUST set it. Enabling/rotating the pepper invalidates + * every existing hash → PINs must be re-enrolled (a dev re-seed does this since seed calls hashPin). + */ +let pepperWarned = false +function peppered(secret: string): string | Buffer { + const pepper = process.env['SIMS_PIN_PEPPER'] + if (pepper === undefined || pepper === '') { + if (!pepperWarned) { + pepperWarned = true + // eslint-disable-next-line no-console + console.warn( + '[auth] SIMS_PIN_PEPPER is not set — PIN/password hashes are un-peppered. A stolen DB could ' + + 'be brute-forced against the tiny PIN keyspace. Production MUST set a server-held pepper.', + ) + } + return secret // byte-identical to the pre-pepper scrypt input + } + return createHmac('sha256', pepper).update(secret, 'utf8').digest() +} + export function assertUsablePin(pin: string): void { if (!/^\d{4,6}$/.test(pin)) throw new Error('PIN must be 4–6 digits') if (/^(\d)\1+$/.test(pin)) throw new Error('PIN must not be a single repeated digit') @@ -19,11 +46,11 @@ export function assertUsablePin(pin: string): void { export function hashPin(pin: string): PinHash { const salt = randomBytes(16) - const hash = scryptSync(pin, salt, 32) + const hash = scryptSync(peppered(pin), salt, 32) return { salt: salt.toString('hex'), hash: hash.toString('hex') } } export function verifyPin(pin: string, stored: PinHash): boolean { - const candidate = scryptSync(pin, Buffer.from(stored.salt, 'hex'), 32) + const candidate = scryptSync(peppered(pin), Buffer.from(stored.salt, 'hex'), 32) return timingSafeEqual(candidate, Buffer.from(stored.hash, 'hex')) } diff --git a/packages/auth/test/auth.test.ts b/packages/auth/test/auth.test.ts index ae870d9..4feb856 100644 --- a/packages/auth/test/auth.test.ts +++ b/packages/auth/test/auth.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { assertUsablePin, hashPin, verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, DEFAULT_LOCKOUT, @@ -23,6 +23,43 @@ describe('PIN policy & hashing', () => { }) }) +describe('PIN pepper (M3) — HMAC before scrypt', () => { + const KEY = 'SIMS_PIN_PEPPER' + afterEach(() => { delete process.env[KEY] }) + + it('round-trips with a server-held pepper set', () => { + process.env[KEY] = 'a-long-random-server-pepper' + const stored = hashPin('4728') + expect(verifyPin('4728', stored)).toBe(true) + expect(verifyPin('4729', stored)).toBe(false) + }) + + it('a peppered hash cannot be verified once the pepper is gone (a DB-only theft is uncrackable)', () => { + process.env[KEY] = 'secret-pepper' + const stored = hashPin('4728') + delete process.env[KEY] + expect(verifyPin('4728', stored)).toBe(false) // no pepper in hand → even the right PIN fails + process.env[KEY] = 'secret-pepper' + expect(verifyPin('4728', stored)).toBe(true) // restored pepper verifies again + }) + + it('a different pepper does not verify (rotation invalidates old hashes)', () => { + process.env[KEY] = 'pepper-A' + const stored = hashPin('4728') + process.env[KEY] = 'pepper-B' + expect(verifyPin('4728', stored)).toBe(false) + }) + + it('unset pepper is byte-identical to a hash of the raw PIN (dev fallback unchanged)', () => { + // With no pepper, the scrypt input is the raw PIN, so a hash made now must verify — proving + // the un-peppered path is exactly the pre-M3 behaviour dev logins depend on. + delete process.env[KEY] + const stored = hashPin('4728') + expect(verifyPin('4728', stored)).toBe(true) + expect(verifyPin('9999', stored)).toBe(false) + }) +}) + describe('lockout reducer', () => { it('locks after max failures and unlocks after the window', () => { let s = FRESH_LOCKOUT diff --git a/packages/billing-engine/src/gst-returns.ts b/packages/billing-engine/src/gst-returns.ts new file mode 100644 index 0000000..22a0da1 --- /dev/null +++ b/packages/billing-engine/src/gst-returns.ts @@ -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): 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() + 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; totals: TaxAmounts }>() + const b2cl: B2CLInvoice[] = [] + const b2csMap = new Map() + const cdnr: CreditNote[] = [] + const cdnur: CreditNote[] = [] + const hsnMap = new Map() + 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() + 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, + } +} diff --git a/packages/billing-engine/src/index.ts b/packages/billing-engine/src/index.ts index 0a980ac..cccc97a 100644 --- a/packages/billing-engine/src/index.ts +++ b/packages/billing-engine/src/index.ts @@ -1,2 +1,4 @@ export * from './tax' export * from './compute' +export * from './returns' +export * from './gst-returns' diff --git a/packages/billing-engine/src/returns.ts b/packages/billing-engine/src/returns.ts new file mode 100644 index 0000000..8b8cd36 --- /dev/null +++ b/packages/billing-engine/src/returns.ts @@ -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 } +} diff --git a/packages/billing-engine/test/gst-returns.test.ts b/packages/billing-engine/test/gst-returns.test.ts new file mode 100644 index 0000000..0bd9018 --- /dev/null +++ b/packages/billing-engine/test/gst-returns.test.ts @@ -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) + }) +}) diff --git a/packages/billing-engine/test/returns.test.ts b/packages/billing-engine/test/returns.test.ts new file mode 100644 index 0000000..be328f9 --- /dev/null +++ b/packages/billing-engine/test/returns.test.ts @@ -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['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() + }) +}) diff --git a/packages/domain/src/batch.ts b/packages/domain/src/batch.ts new file mode 100644 index 0000000..19b5a36 --- /dev/null +++ b/packages/domain/src/batch.ts @@ -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, 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] +} diff --git a/packages/domain/src/doc-series.ts b/packages/domain/src/doc-series.ts index 06c35f5..ef247fe 100644 --- a/packages/domain/src/doc-series.ts +++ b/packages/domain/src/doc-series.ts @@ -27,11 +27,46 @@ export function formatDocNo(prefix: string, seq: number, width = 6): string { return docNo } -/** e.g. seriesPrefix("ST1", "C2", "26") → "ST1C2/26" */ -export function seriesPrefix(storeCode: string, counterCode: string, fyShortMark: string): string { - return `${storeCode}${counterCode}/${fyShortMark}` +/** + * The per-counter series prefix. It embeds the FULL 4-digit FY START YEAR (M9 collision fix): + * a 2-digit marker made FY 2026-27 and 2126-27 share the prefix "ST1C2/26", so a century apart + * their doc numbers collided on the bill's UNIQUE(tenant, doc_no) and billing on that prefix was + * blocked. The 4-digit start year ("2026" vs "2126") cannot collide within a millennium. + * + * GST caps a doc number at 16 chars from [A-Za-z0-9/-]. At a 6-digit sequence, + * store(3)+counter(2)+"/"+FY(4)+"-"+seq(6) = 17 would BREAK that rule — so the counter↔FY "/" + * separator is dropped (the sequence "-" from formatDocNo stays for readability): + * seriesPrefix("ST1", "C2", "2026-27") → "ST1C22026" ⇒ doc no "ST1C22026-000123" (16 chars). + * This keeps the 16-char rule for store+counter codes totalling ≤ 5 chars (the demo ST1+C2). A + * store with a 3-char counter code (C10+) plus a 4-digit FY would need a shorter store code — a + * documented onboarding constraint, the same tightness the 2-digit format already had at C10. + * + * `fy` is the full FY label ("2026-27"); only its 4-digit start year is used. + */ +export function seriesPrefix(storeCode: string, counterCode: string, fy: string): string { + return `${storeCode}${counterCode}${fy.slice(0, 4)}` } +/** + * Per-counter credit-note (SALE_RETURN) series prefix. A credit note lives in the same + * document table as sales under the SAME UNIQUE(tenant, doc_no), so its number must never + * collide with a sale's — the leading "CN" marker guarantees that (a sale never starts "CN"). + * It reuses M9's 4-digit-FY scheme (store + counter + 4-digit FY start year) so credit notes + * of FY 2026-27 and 2126-27 can't collide either. + * + * The "CN" marker costs two characters, so the sequence is 4 digits (`CREDIT_NOTE_SEQ_WIDTH`) + * rather than the sale's 6, keeping the GST 16-char [A-Za-z0-9/-] rule at the demo's ST1+C2 + * sizing: `creditNotePrefix("ST1","C2","2026-27")` → "CNST1C22026" ⇒ "CNST1C22026-0001" (16). + * That caps a counter at 9,999 credit notes per FY — far beyond any real counter — and, as for + * sales, needs store+counter codes to total ≤ 5 chars (a documented onboarding constraint). + */ +export function creditNotePrefix(storeCode: string, counterCode: string, fy: string): string { + return `CN${storeCode}${counterCode}${fy.slice(0, 4)}` +} + +/** Credit-note sequence width — 4 digits so the "CN"-marked doc no stays within 16 chars. */ +export const CREDIT_NOTE_SEQ_WIDTH = 4 + export function nextDocNo(series: DocSeries): { docNo: string; series: DocSeries } { const docNo = formatDocNo(series.prefix, series.nextSeq) return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } } diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 748cb46..ad739a3 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -6,4 +6,5 @@ export * from './doc-series' export * from './gstin' export * from './masters' export * from './documents' +export * from './batch' export * from './outbox' diff --git a/packages/domain/test/batch.test.ts b/packages/domain/test/batch.test.ts new file mode 100644 index 0000000..f2327c7 --- /dev/null +++ b/packages/domain/test/batch.test.ts @@ -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() + }) +}) diff --git a/packages/domain/test/domain.test.ts b/packages/domain/test/domain.test.ts index e155524..d790fdf 100644 --- a/packages/domain/test/domain.test.ts +++ b/packages/domain/test/domain.test.ts @@ -38,20 +38,31 @@ describe('uuidv7', () => { describe('document series — offline per-counter numbering', () => { const series: DocSeries = { tenantId: 't', storeId: 's', counterId: 'c', docType: 'SALE', - fy: '2026-27', prefix: seriesPrefix('ST1', 'C2', '26'), nextSeq: 123, + fy: '2026-27', prefix: seriesPrefix('ST1', 'C2', '2026-27'), nextSeq: 123, } - it('formats within the GST 16-char rule', () => { + it('formats within the GST 16-char rule (4-digit FY, M9)', () => { const { docNo, series: bumped } = nextDocNo(series) - expect(docNo).toBe('ST1C2/26-000123') + expect(docNo).toBe('ST1C22026-000123') expect(docNo.length).toBeLessThanOrEqual(16) expect(bumped.nextSeq).toBe(124) }) + it('M9: the 4-digit FY start year makes centuries-apart FYs distinct (no prefix collision)', () => { + const a = seriesPrefix('ST1', 'C2', '2026-27') + const b = seriesPrefix('ST1', 'C2', '2126-27') // 100 years later — collided under the old 2-digit "26" + expect(a).toBe('ST1C22026') + expect(b).toBe('ST1C22126') + expect(a).not.toBe(b) + // both still fit the 16-char [A-Za-z0-9/-] rule at the top of the 6-digit sequence range + expect(formatDocNo(a, 999_999)).toBe('ST1C22026-999999') + expect(formatDocNo(a, 999_999).length).toBe(16) + expect(() => formatDocNo(b, 999_999)).not.toThrow() + }) it('rejects numbers that break the GST rule', () => { expect(() => assertGstDocNo('THIS/PREFIX/IS/TOO/LONG-000001')).toThrow(/16-char/) expect(() => formatDocNo('BAD_CHAR', 1)).toThrow(/16-char/) // underscore not allowed }) it('FY rollover restarts the sequence', () => { - const rolled = rolloverForFy(series, '2027-28', seriesPrefix('ST1', 'C2', '27')) + const rolled = rolloverForFy(series, '2027-28', seriesPrefix('ST1', 'C2', '2027-28')) expect(rolled.nextSeq).toBe(1) expect(rolloverForFy(series, '2026-27', series.prefix)).toBe(series) }) diff --git a/packages/printing/src/receipt.ts b/packages/printing/src/receipt.ts index b9a8f4b..ad5475f 100644 --- a/packages/printing/src/receipt.ts +++ b/packages/printing/src/receipt.ts @@ -17,6 +17,13 @@ export interface ReceiptDoc { totals: BillTotals paymentLabel: string footer?: string + /** 'credit-note' relabels the receipt as a CREDIT NOTE (refund) and prints the original bill + * it is issued against + the return reason. Defaults to a normal sale receipt. */ + variant?: 'sale' | 'credit-note' + /** The original SALE doc no a credit note is raised against (credit-note variant only). */ + againstDocNo?: string + /** The return reason (credit-note variant only). */ + reason?: string } export interface ReceiptOptions { @@ -35,15 +42,19 @@ const inr = (p: number) => formatINR(p, { symbol: false }) */ export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] { const w = opts.width + const cn = doc.variant === 'credit-note' const out: string[] = [] out.push(doc.storeName) if (doc.storeAddress !== undefined) out.push(doc.storeAddress) if (doc.gstin !== undefined) out.push(`GSTIN: ${doc.gstin}`) + if (cn) out.push('*** CREDIT NOTE ***') out.push(hr(w)) - out.push(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w)) + out.push(twoCol(`${cn ? 'Credit Note' : 'Bill'}: ${doc.docNo}`, doc.businessDate, w)) + if (cn && doc.againstDocNo !== undefined) out.push(`Against Bill: ${doc.againstDocNo}`) out.push(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w)) if (doc.buyerName !== undefined) out.push(`Buyer: ${doc.buyerName}`) if (doc.buyerGstin !== undefined) out.push(`Buyer GSTIN: ${doc.buyerGstin}`) + if (cn && doc.reason !== undefined) out.push(`Reason: ${doc.reason}`) out.push(hr(w)) for (const l of doc.lines) { out.push(l.name) @@ -60,16 +71,17 @@ export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] { if (doc.totals.cessPaise > 0) out.push(twoCol('Cess', inr(doc.totals.cessPaise), w)) if (doc.totals.discountPaise > 0) out.push(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w)) if (doc.totals.roundOffPaise !== 0) out.push(twoCol('Round off', inr(doc.totals.roundOffPaise), w)) - out.push(twoCol('PAYABLE', inr(doc.totals.payablePaise), w)) - out.push(twoCol('Paid by', doc.paymentLabel, w)) - if (doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`) - out.push(doc.footer ?? 'Thank you! Visit again') + out.push(twoCol(cn ? 'REFUND' : 'PAYABLE', inr(doc.totals.payablePaise), w)) + out.push(twoCol(cn ? 'Refund via' : 'Paid by', doc.paymentLabel, w)) + if (!cn && doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`) + out.push(doc.footer ?? (cn ? 'Refund processed. Keep this credit note.' : 'Thank you! Visit again')) return out } /** Thermal receipt bytes. Pure — golden-testable without a printer. */ export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array { const w = opts.width + const cn = doc.variant === 'credit-note' const p = new EscPos().init() if (opts.kickDrawer) p.drawerKick() @@ -77,12 +89,15 @@ export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array p.align('center').bold(true).size(2, 2).line(doc.storeName).size(1, 1).bold(false) if (doc.storeAddress !== undefined) p.line(doc.storeAddress) if (doc.gstin !== undefined) p.line(`GSTIN: ${doc.gstin}`) + if (cn) p.bold(true).line('*** CREDIT NOTE ***').bold(false) p.line(hr(w)) p.align('left') - p.line(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w)) + p.line(twoCol(`${cn ? 'Credit Note' : 'Bill'}: ${doc.docNo}`, doc.businessDate, w)) + if (cn && doc.againstDocNo !== undefined) p.line(`Against Bill: ${doc.againstDocNo}`) p.line(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w)) if (doc.buyerName !== undefined) p.line(`Buyer: ${doc.buyerName}`) if (doc.buyerGstin !== undefined) p.line(`Buyer GSTIN: ${doc.buyerGstin}`) + if (cn && doc.reason !== undefined) p.line(`Reason: ${doc.reason}`) p.line(hr(w)) for (const l of doc.lines) { @@ -101,12 +116,12 @@ export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array if (doc.totals.cessPaise > 0) p.line(twoCol('Cess', inr(doc.totals.cessPaise), w)) if (doc.totals.discountPaise > 0) p.line(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w)) if (doc.totals.roundOffPaise !== 0) p.line(twoCol('Round off', inr(doc.totals.roundOffPaise), w)) - p.bold(true).size(1, 2).line(twoCol('PAYABLE', inr(doc.totals.payablePaise), w)).size(1, 1).bold(false) - p.line(twoCol('Paid by', doc.paymentLabel, w)) - if (doc.totals.savingsVsMrpPaise > 0) { + p.bold(true).size(1, 2).line(twoCol(cn ? 'REFUND' : 'PAYABLE', inr(doc.totals.payablePaise), w)).size(1, 1).bold(false) + p.line(twoCol(cn ? 'Refund via' : 'Paid by', doc.paymentLabel, w)) + if (!cn && doc.totals.savingsVsMrpPaise > 0) { p.align('center').line(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`) } - p.align('center').line(doc.footer ?? 'Thank you! Visit again') + p.align('center').line(doc.footer ?? (cn ? 'Refund processed. Keep this credit note.' : 'Thank you! Visit again')) p.feed(4).cut() return p.build() }