You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/STATUS.md

657 lines
62 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Build Status — 2026-07-10 (post-slice)
**54 screens across 2 surfaces** (46 back office + 8 POS), on 8 tested logic packages
(89 tests green), and a store-server **with a real data layer** (SQLite behind portable
repositories; Oracle 12c adapter slots in when the Classic DDL arrives — D12).
Product form: **web app** served by the store-server (D2) — back office at `/`,
POS at `/pos/`, printing via `/api/print`.
**The vertical slice is LIVE and verified end-to-end**: PIN login (server-side scrypt +
lockout) → item cache from DB → bill commit in one transaction (GST series allocation,
bill + lines, stock movements, dormant outbox row, audit entry) with **server-side
engine re-verification** (client and server must agree to the paisa) → bill visible in
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 |
|---|---|---|
| `@sims/domain` | Tenancy (tenant→store→counter→user), integer-paise money + ₹ Indian grouping, UUIDv7 ids, Day-Begin business day + FY, per-counter GST doc series (16-char rule), GSTIN checksum validation, immutable document types, dormant outbox (D14) | ✅ |
| `@sims/billing-engine` | Dated GST rates, inclusive/exclusive prices, CGST/SGST vs IGST, cess, pro-rata bill discounts, rupee round-off — golden-tested to the paisa | ✅ |
| `@sims/auth` | PIN policy + scrypt hashing, lockout reducer, role→action matrix with supervisor-PIN gates, POS session bound to business date | ✅ |
| `@sims/scanning` | EAN-13 validation, weighing-scale (2x) barcodes, scan-box grammar (`3*` qty, code, search), wedge scanner vs human-typing detector | ✅ |
| `@sims/search-core` | Catalog search (pure TS, no deps): Indic phonetic folding (chawal≈chaval, paneer≈panir, jeera≈zeera), in-memory index, tiered ranking (whole-prefix → token-prefix → phonetic → substring) | ✅ |
| `@sims/printing` | ESC/POS byte builder, receipt renderer (42/32 col), drawer kick; **A4 GST invoice HTML + amount-in-words, EAN-13 label sheets, GS v 0 Indic raster (S2)** | ✅ |
| `@sims/config` | Settings hierarchy resolver (system→tenant→store→counter→user, effective-dated), message catalog with language fallback | ✅ |
| `@sims/ui` | Design system: light/dark themes, 6 accents, Inter + JetBrains Mono bundled, wireframe component kit, ThemeSwitcher | — |
## POS — 7 screens (`/pos/`) — FUNCTIONAL
| # | Screen | State |
|---|--------|-------|
| 1 | Login — user tiles + PIN pad | Functional (seed users) |
| 2 | Shift Open — denomination count → opening float | Functional |
| 3 | **Billing screen** — scan box, lines grid, totals, quick keys, status bar | **Functional end-to-end**: real engine, barcode/multiplier/scale-barcode entry, hold/resume, per-counter GST series, one-key cash close (F12/Numpad+) |
| 4 | Payment overlay (F11) — Cash/UPI/Card/Khata | Functional (modes recorded; QR/split later) |
| 5 | Bill history (F10) | Functional (day log) |
| 6 | Day-End summary — totals by payment mode | Functional (variance calc later) |
| 7 | Settings (Ctrl+,) — printer IP/port/width, test print, drawer kick | Functional (prints via store-server) |
Also live: customer attach (F6); server-backed history (F10); commit failures keep the
cart; **live search suggestions** (visible-before-add, ↑/↓+Enter); **F2 qty / F3 price
inline cell edits** (price>MRP blocked); **unknown-code quick-add** (draft item created
server-side, billable immediately). Verified: bill ST1C2/26-000002.
**P1 pass done (verified)**: tendered-cash change flash (type `100` F12 → CHANGE ₹20),
Ctrl+B repeat-last-bill (current prices, never resurrected), instant `*` multiplier with
qty chip, F4 line discount (`4` = ₹4, `5%` = percent; Disc column appears on demand),
audio feedback (ok/warn/err beeps), ok-notices auto-clear, stock chips in suggestions
(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).
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.
**Supervisor PIN gate + override audit (P0-2 / P1-3) — done 2026-07-10.** In-flow price/
discount overrides now raise a supervisor PIN instead of applying silently (R20, 09-UX §5.5).
New `POST /api/auth/verify-pin` reuses the existing `attempt()` scrypt + lockout but creates
**no session** (never pollutes `sessions`): success audits `PIN_VERIFY`, a wrong PIN audits
`PIN_VERIFY_FAILED` and increments the same per-user lockout (5 fails → locked 60 s, E-6104),
and only supervisor/manager/owner roles may approve — a valid-PIN cashier is rejected (E-6105).
`POST /api/bills` gained an `overrides[]` body; `commitBill` writes one audit row per entry
(`PRICE_OVERRIDE`/`QTY_OVERRIDE`/`DISCOUNT_OVERRIDE`, entity `bill`, before/after JSON with the
approver resolved to a display name) **inside the existing commit transaction** (R17). POS: a
`POS_LIMITS` const (`PRICE_BAND_PCT=10`, `DISC_WARN_PCT=10` — module consts today, DB `setting`
rows later per R9) tops `BillingScreen`; a reusable `supervisorGate(desc)` promise renders an
**inline** panel (never a modal) replacing the notice area — supervisor picker (bootstrap
`users`, cashiers excluded), 46 digit PIN, 3 wrong PINs closes + cancels the edit;
`verifySupervisorPin` verifies without touching the module token (the cashier stays logged in).
F3 price: >MRP stays hard-blocked; a cashier's change (beyond ±band or any change) raises the
gate, owner/supervisor/manager self-authorise via `gateFor` (@sims/auth). F4 discount > 10% of
line value raises the same gate. Approved edits accumulate in an `overrides` state array (paise
+ approvedByUserId), ride with `postBill`, and clear on commit/cart-clear. Verified 2026-07-10:
**117 tests green**, typecheck clean on root + all three apps, all three apps build, store-server
rebuilt + restarted. Browser E2E: Ramesh (cashier) billed Surf Excel → F3 145.00→120.00 (beyond
10% band) → inline panel appeared (picker showed only Suresh + Thomas) → a wrong PIN showed
"attempt 1 of 3" → Suresh PIN 8265 approved → F4 20% discount (₹24 > 10%) raised the gate again
→ approved → F12 committed **ST1C2/26-000006** (₹96.00, engine recomputed both edits) → back
office (thomas/sims) Audit Log shows **PRICE_OVERRIDE** `before 14500 → after 12000, approvedBy
Suresh` and **DISCOUNT_OVERRIDE** `before 0 → after 2400, approvedBy Suresh`. Also confirmed via
API: a cashier picked as approver is rejected (E-6105) and 5 wrong PINs lock the user (E-6104).
Back-office polish: the Audit detail column now renders `before → after` for override rows.
**Caveats:** override records clear on hold/cart-clear (a resumed held cart re-approves);
`POS_LIMITS` awaits the DB settings surface (R9 follow-up); the web bundle imports the pure
`@sims/auth/permissions` subpath because the barrel re-exports scrypt (node:crypto) — added a
matching vite alias + tsconfig path. No git commits.
**Catalog scale (S1 + S6) — done 2026-07-10.** New `@sims/search-core` (pure TS, no
deps) replaces BillingScreen's linear name filter: `phoneticKey` folds the Indic
spelling variants the spec names (aa→a, ee→i, oo→u, w→v, ph→f, bh→b, chh→ch, sh→s,
th→t, dh→d, z→j, drop doubled letters), and `buildIndex`/`querySearch` rank matches in
tiers (whole-prefix → token-prefix → phonetic → substring, shorter name breaks ties).
The POS memoizes the index over its live item cache (grows with quick-adds); the ≤8-row
dropdown and arrow/Enter behaviour are unchanged. S6/R13: the silent `LIMIT 2000` in
`listItems` is gone — `GET /api/items?all=1` returns the whole non-inactive catalog for
the POS cache (which now fetches with `?all=1`), every other path paginates
(limit/offset, default 5000); `listBills`/`listAudit` and their routes paginate too
(default 100), and the back-office Bills and Audit pages grew a "Load more" button.
Verified 2026-07-10: 66/66 tests green (11 new folding/tier cases), `npm run typecheck`
clean on root + all three apps, all three apps build, and the store-server was rebuilt
and restarted — `/api/health` ok, `/api/items?all=1` returns the full 12-item catalog
with no cap, limit/offset confirmed on items/bills/audit, and a phonetic query
`ashirwad` resolves to `Aashirvaad Atta 5kg`. Integer paise, existing tests, and the
zero-runtime-dep rule are untouched.
**Print coverage (S2) — done 2026-07-10.** Three new pure modules in `@sims/printing`:
`invoice-html.ts` (`renderInvoiceHtml` → a self-contained, printable **A4 GST tax
invoice**: seller name/GSTIN/address header, buyer block, HSN-wise line table, a
CGST/SGST **or** IGST + cess breakup grouped by HSN+rate, round-off, payable, and
**amount-in-words** via a new `amountInWordsINR(paise)` domain util — integer paise,
Indian lakh/crore numbering, singular "Paisa" — plus a bilingual English/Hindi footer);
`labels.ts` (`ean13Svg` — standards-correct EAN-13 with L/G/R element patterns and
first-digit parity, checksum via `@sims/scanning`'s `ean13CheckDigit`; `renderLabelSheetHtml`
— a printable barcode/MRP label grid); and `raster.ts` (pure `packRasterRow` /
`bitonalFromRGBA` / `rasterToEscPos` for **GS v 0** 1-bit bit-image receipts). Back
office: **Bills rows are clickable** → the A4 invoice opens in a print-ready tab (Blob
URL, own "Print A4" button); **Catalog → Label Printing is now LIVE** (prints labels for
current items, columns + label size configurable); Purchase Entry offers "Print labels"
for the received lines after a post. POS: a **"Receipt script: ASCII / Raster (Indic)"**
setting (persisted in the printer cfg) routes the commit-time receipt through a
canvas→GS v 0 raster path (`apps/pos/src/raster.ts`) so Devanagari/Malayalam item names
print, sharing one `receiptLines` content model with the ASCII path. Verified 2026-07-10:
**97 tests green** (20 new S2 cases: amountInWordsINR 0/paisa/1/lakh/crore, invoice fields
+ amount-in-words + IGST switch, EAN-13 structure + checksum, label sheet, raster
bit-packing), typecheck clean on root + all three apps, all three apps build, store-server
rebuilt and restarted. Drove the back office in a real browser (thomas/sims): Bills → click
`ST1C2/26-000001` → A4 invoice rendered with **"Rupees Six Hundred Only"**, CGST/SGST
breakup and place-of-supply Kerala; Catalog → Label Printing → sheet rendered **7 real
EAN-13 SVG symbols**; and the raster canvas pipeline was exercised in-browser (384-px 2"
head, Indic glyphs drew 1,575 ink pixels via the OS font, GS v 0 header bytes correct).
**Honest caveats:** raster receipts are unit- and canvas-verified but the physical thermal
output needs a real 80 mm printer; canvas Indic glyphs fall back to the shop PC's OS fonts
(Windows ships Nirmala UI) — bundled canvas webfonts are a follow-up; the demo seed
barcodes carry placeholder check digits, so `ean13Svg` encodes stored 13-digit codes
as-is (a strict `normalizeEan13` validator is exported for real catalog input).
**B2B at the counter (S3) — done 2026-07-10.** A registered buyer's GSTIN now flows from
attach → engine → document. New pure `deriveSupply(customer, storeState)` in `@sims/domain`
is the single place-of-supply rule both surfaces run: no GSTIN → B2C, place of supply = the
store (CGST/SGST); a GSTIN → B2B, place of supply = the buyer's state (its first two digits,
or an explicit stateCode), inter-state ⇒ IGST. **Parties gained B2B identity**: `createCustomer`
takes an optional GSTIN, **validated server-side** with `validateGstin` (bad checksum/shape
rejected with E-1401/E-1402), the state code derived from the GSTIN when absent; the back
office **Customers page has a GSTIN column** and the POS **F6 attach modal has an optional
"GSTIN (B2B)" field with live checksum/state feedback**. **Billing is now place-of-supply
aware**: the POS `computeBill` ctx uses the attached customer's state (falls back to the
store), and `commitBill` **does not trust the client** — it re-reads the customer row, derives
place of supply itself, recomputes, and the paisa-agreement check (R5) stays. The **immutable
bill snapshots the buyer** (`buyer_gstin`, `place_of_supply` — added to the CREATE TABLE and
back-filled on the running dev DB by a PRAGMA-guarded `ALTER TABLE` migrate step in `db.ts`);
`listBills` returns them. **Documents follow**: the thermal receipt prints a buyer GSTIN line
(IGST rows on the existing inter-state path), the A4 invoice buyer block fills GSTIN +
place-of-supply from the bill (closing the S2 caveat), and the POS totals panel shows an
**"IGST · inter-state"** badge plus a B2B chip on the customer strip. Verified 2026-07-10:
**110 tests pass** (4 new: `deriveSupply` no-GSTIN/intra/inter, B2B receipt buyer line),
typecheck clean on root + all three apps, all three apps build, store-server rebuilt and
restarted (`/api/health` ok, migration added both columns to the 3-bill dev DB). Drove both
UIs in a real browser: POS login (Ramesh/4728, Skip count) → F6 → created **Anand Traders**
(phone 9876543210) with Maharashtra GSTIN **`27ABCDE1234F1Z0`** (body `27ABCDE1234F1Z` +
`gstinCheckDigit` = `0`) → the modal showed "place of supply state 27 — GSTIN valid", the
strip showed the B2B chip, the totals showed **IGST · inter-state** → billed Surf Excel
(GST ₹22.12 as IGST) → committed **ST1C2/26-000004**. Server row confirms `buyer_gstin`,
`place_of_supply='27'`, `igst_paise=2212`, `cgst=sgst=0`, payable ₹145.00 (client/server
agreed to the paisa). Back office → Bills → the row opened its A4 **Tax Invoice** with
**Billed To Anand Traders / GSTIN 27ABCDE1234F1Z0 / State 27 — Maharashtra**, Place of Supply
**"27 — Maharashtra"**, and an **IGST** tax row (no CGST/SGST); Customers shows the GSTIN column.
**Honest caveats:** the buyer GSTIN receipt line is unit-tested only (no physical printer here);
the seed store's state 32 resolves as "Kerala" in the invoice state map though it is named
T.Nagar — a seed-data quirk unrelated to S3; the e-Invoice/IRN (GSP) hook remains P2 (S3 stops
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 qtycost→**batch
noexpiry** (date input; blank Enter skips expiry for non-perishables) scan; the server
`upsertBatch`es the lot (unique key or new, filling a later expiry) and carries `batch_id`
onto the purchase line and the stock-IN movement. **POS FEFO pick (R18 — never modal):**
`GET /api/batches` (per-batch on-hand>0, item-keyed) is cached at login and refreshed after
each commit; adding a batch-tracked item raises an **inline** picker, FEFO-sorted with the
first sellable lot preselected (↑/↓ move, Enter accepts), **expired lots flagged red and
blocked with E-1310** unless approved — a supervisor+ sells on their own authority, a
cashier raises the existing `supervisorGate`, and the approval rides as a new `kind:'batch'`
override audited **BATCH_OVERRIDE** server-side (single-use token, same SEC path as price/
discount). The committed line carries `batchId`; `commitBill` writes the movement's
`batch_id` and **rejects (rolls back) any batch that doesn't belong to the item+store
(E-1311)**. **Expiry dashboard is LIVE** (replaces the registry wireframe via the CUSTOM
map): `GET /api/expiry` feeds ≤7-day and ≤30-day buckets with per-batch on-hand + value at
sale price, an expired-with-stock section, and a dead-stock note; the Stock page gained a
batch-tracked second table + a "batch" tag. **Verified 2026-07-10: 178 tests green** (6 new
FEFO domain cases + 8 server batch cases: seed totals-preserved/idempotent, purchase upsert
+ reuse, batch-attributed decrement, E-1311 belongs-check, BATCH_OVERRIDE audit), typecheck
clean on root + all three apps, all three apps build, store-server rebuilt and restarted
(migration added the batch table + `batch_id` columns + demo lots to the running dev DB;
totals held — Amul 120, Maggi 119). **Full browser E2E (Puppeteer, real Chromium):** back
office (thomas/sims) → Purchase Entry → Amul Butter, Enter chain captured **batch B-TEST /
expiry 2026-07-17**, F12 **posted**; Expiry Dashboard rendered live — **≤7-day ₹6,350 / 3
batches** (B-105A ₹1,400, B-107A ₹3,300, B-TEST ₹1,650) and **Expired-with-stock ₹2,200 /
1 batch** (B-107X). POS (Ramesh/4728, Skip count) → billed Amul Butter → the **inline FEFO
picker** listed B-107X (EXPIRED, red) · **B-107A (FEFO-preselected, exp 2026-07-15)** ·
B-TEST · B-107B → Enter committed **ST1C2/26-000016** and **B-107A on-hand fell 12→11**
(per-batch, not item-wide); a second bill navigated (↑) to the expired **B-107X** → the
inline gate showed **"batch B-107X EXPIRED 2026-07-07 — approve sale? (E-1310)"** with
Suresh/Thomas as approvers → cancelled, so B-107X stayed at 8 (never sold). **Honest
caveats:** the server validates a batch belongs to the item+store but does **not** itself
re-block expired sales — the E-1310 gate is POS-side with a BATCH_OVERRIDE audit trail
(server-side expiry enforcement is a follow-up); the batch cache is fetched in the billing
screen at mount (effectively login) and after each commit, but is **not** written to the S4
offline IndexedDB cache, so a batch-tracked sale made while the server is down bills
un-batched with a warning; the demo dead-stock note flags by expiry only (velocity-based
slow-mover ranking is a follow-up); the chrome-devtools MCP was unusable this session (its
browser handle wedged into a "browser already running" state that survived killing every
Chrome + the profile lock and needs an MCP-server restart), so the browser E2E was driven
via Puppeteer against the same running store-server instead. No git commits.
**Load verification (S7) — done 2026-07-10.** The 15-EDITIONS scale target is now
verified by load test, not assumed (R25). A scripted run (`apps/store-server/load/loadtest.mjs`,
plain node 22 + better-sqlite3, no new deps) drove **2,500 real bill commits distributed
across 10 counters** at a concurrency of 10 (one in-flight request per counter) against a
**second, isolated store-server instance on :5281 with a fresh data dir** — the dev DB and
the running :5181 server were never touched (new `SIMS_DATA_DIR` env in `db.ts` points the
isolated instance at its own data dir; the seed makes C1/C2, so C3C10 were added as real
counter rows with their own per-counter GST series). Each bill was a randomized 15-line
cart from the seeded catalog (the 2 batch-tracked items skipped); the client payable is
`roundToRupee(Σ qty·price)` — exact for tax-inclusive, no-discount bills — and the server
still re-verified every document to the paisa with the shared engine (R5). A burst phase
then pushed 100 bills at concurrency 50 to find the knee.
**Environment (single dev machine — not a distributed benchmark):**
| Field | Value |
|---|---|
| Node | v22.20.0 |
| CPU | 12× 13th Gen Intel Core i5-1335U |
| RAM | 15.69 GB |
| OS | Windows 11 (10.0.26200) |
| Target | one store-server process, isolated data dir, port 5281 |
**Results:**
| Phase | Bills | Concurrency | Success | Wall | Throughput | p50 | p95 | p99 | max |
|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|
| Steady | 2,500 | 10 (1/counter) | 2,500 (100%) | 8.64 s | **289.4 bills/s** | 28.8 ms | 54.6 ms | 76.0 ms | 397.6 ms |
| Burst | 100 | 50 | 100 (100%) | 1.16 s | 85.9 bills/s | 445.9 ms | 737.4 ms | 746.5 ms | 748.7 ms |
**Integrity (queried directly against the load DB after the run):**
| # | Check | Result |
|---|---|---|
| a | bill rows == successful commits | **PASS** (2,600 == 2,600) |
| b | every counter's SALE series gap-free & duplicate-free | **PASS** (c1c10 each `1..260`) |
| c | stock derived == opening billed, per item (R7) | **PASS** (9 items reconciled; 7,800 SALE movements == 7,800 billed lines) |
| d | every bill's payments sum == payable | **PASS** (2,600 bills, 0 mismatch) |
| e | audit `BILL_COMMIT` count == bill count (R17) | **PASS** (2,600 == 2,600) |
| f | outbox rows == bill count (R15) | **PASS** (2,600 == 2,600) |
**Honest interpretation.** Steady-state throughput on this dev laptop is **~289 committed
bills/sec** with a sub-30 ms median. Reading the S7 target at its heaviest (10 counters ×
2,500 = 25,000 bills/day), the entire day's work is **~86 s of server-busy time** spread
across a full trading day — 23 orders of magnitude of headroom; the lighter reading (2,500
bills/day for the store) completes in **8.6 s**. `better-sqlite3` serializes writes in-process
(one synchronous commit at a time), so throughput is bounded by single-core commit cost
(~3.5 ms/bill) and concurrency buys latency, not throughput: the burst at concurrency 50
inflated p50 latency ~15× (29 ms → 446 ms) **without** raising throughput (it measured lower),
so the knee is well below 50 — the realistic 10-counter operating point already sits near the
single-write-core ceiling with a wide latency margin. Correctness held perfectly under load:
zero failed commits, and all six integrity invariants (no series gaps/dups across 10
independent counters, exact stock reconciliation, payments == payable, one audit + one outbox
row per bill) passed on 2,600 bills. Incidentally confirmed the 2-digit counter code C10 stays
within the GST 16-char doc-no rule at 6-digit sequences (`ST1C10/26-000260`). These are
single-machine dev-hardware numbers; a store-server on real store hardware and the future
Oracle 12c adapter (D12) will have different constants, but the serialization model and the
integrity guarantees are the same. No git commits; typecheck clean on the one TS file touched
(`db.ts`), `:5281` instance killed after the run.
## Purchase Entry — LIVE (the second crown jewel)
`/purchases/entry`: supplier type-ahead → invoice no/date → scan-grammar line entry
(barcode / code / name / `48*surf`) → qty→cost cell flow (Enter-advances) → last-cost
prefill + cost-changed price-revision prompt → printed-total cross-check → F12 post.
Server: transactional commit (stock IN, price/MRP updates + audit, duplicate-invoice
rejection, totals re-verification). Purchase List page live too. Verified end-to-end:
HD/2651 ₹6,988.83 posted, duplicate rejected, stock 118→166. Full P1/P2 backlog:
docs/13-SPEC-PURCHASE-ENTRY.md.
## Back office — 46 screens (`/`) — WIREFRAME (sample data)
Login (1) + Dashboard (1, custom) + 44 module pages:
| Module | Pages | Count |
|--------|-------|------:|
| Catalog | Items · Categories · Price Lists · Schemes & Offers · Label Printing | 5 |
| Sales | Bills · Returns & Credit Notes · Customers · Khata & Collections · Loyalty | 5 |
| Purchases | Purchase List · Purchase Entry · **Purchase Inbox** (custom split-review) · Suppliers · GSTR-2B Reconciliation | 5 |
| Inventory | Stock · Adjustments · Transfers · Stock Take · Expiry Dashboard | 5 |
| Accounting | Vouchers · Ledgers · Day Book · P&L/Balance Sheet · Tally Export | 5 |
| GST | GSTR-1 · GSTR-3B · e-Invoice Queue · e-Way Bills · HSN Summary | 5 |
| 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 |
**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),
**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.
## Store-server (`apps/store-server`) — RUNNING SERVICE with DATA LAYER
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/returns`, `/api/gst/{gstr1,gstr3b,hsn,gstr1.json}` (READ-ONLY
returns reporting, REPORT_VIEW), `/api/stock`, `/api/audit`. SQLite (WAL) at `apps/store-server/data/`
behind portable repositories (`src/repos.ts`) — **the Oracle 12c adapter reimplements
those signatures when the Classic DDL arrives; nothing above them changes.**
Port 5181 (808085 are Windows-reserved).
## Not built yet (per roadmap)
- Store-server data layer: Oracle repository, real auth, item/bill APIs → needs the
**Classic Oracle DDL** (→ docs/12-DATA-MODEL.md, to be written once the DDL arrives)
- Owner mobile app (Phase 3) · Customer display · HQ console (doc 11): ops slice now spec'd to **start early** as `apps/hq` (docs/14-SPEC-HQ-CONSOLE.md, D15); fleet half still waits for the cloud tier (D14)
- Purchase Inbox backend (AI parsing) · e-Invoice/e-Way GSP · WhatsApp/UPI integrations
- Sync agent + cloud tier (D14: deliberately later) · Classic migration tool
- Onboarding wizard · i18n content (mechanism exists, Hindi/Malayalam strings pending)
## Decisions: resolved vs open
Resolved: D2 web-first · D5 DB-driven plans/prices · D8 Android later · D12 Oracle 12c
for now · D14 local-first-then-cloud · D15 HQ ops console early, in-monorepo.
**Open: D1 product name · D4 GSP vendor · D5 ₹ amounts · D11 builder headcount · D13 helpdesk.**
## How to run
```
cd apps/store-server && npm start # http://localhost:5181 (/ and /pos/)
npm test && npm run typecheck # from repo root
```