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

62 KiB

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_KEYhttps.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 startdev-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 storeId400 E-1110 (not 500); an isolated instance with SIMS_SEED_DEMO unset0 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 /billsST1C22026-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).listBillsnow filters todoc_type='SALE'` so credit notes never pollute the sales list or POS history
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 derivedSUM(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 upsertBatches 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