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_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).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), 4–6 digit PIN, 3 wrong PINs closes + cancels the edit;
verifySupervisorPin verifies without touching the module token (the cashier stays logged in).
F3 price: >MRP stays hard-blocked; a cashier's change (beyond ±band or any change) raises the
gate, owner/supervisor/manager self-authorise via gateFor (@sims/auth). F4 discount > 10% of
line value raises the same gate. Approved edits accumulate in an overrides state array (paise
- approvedByUserId), ride with
postBill, and clear on commit/cart-clear. Verified 2026-07-10: 117 tests green, typecheck clean on root + all three apps, all three apps build, store-server rebuilt + restarted. Browser E2E: Ramesh (cashier) billed Surf Excel → F3 145.00→120.00 (beyond 10% band) → inline panel appeared (picker showed only Suresh + Thomas) → a wrong PIN showed "attempt 1 of 3" → Suresh PIN 8265 approved → F4 20% discount (₹24 > 10%) raised the gate again → approved → F12 committed ST1C2/26-000006 (₹96.00, engine recomputed both edits) → back office (thomas/sims) Audit Log shows PRICE_OVERRIDEbefore 14500 → after 12000, approvedBy Sureshand DISCOUNT_OVERRIDEbefore 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 rendersbefore → afterfor override rows. Caveats: override records clear on hold/cart-clear (a resumed held cart re-approves);POS_LIMITSawaits the DB settings surface (R9 follow-up); the web bundle imports the pure@sims/auth/permissionssubpath 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, soean13Svgencodes stored 13-digit codes as-is (a strictnormalizeEan13validator 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 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_idcolumns + demo lots to the running dev DB; totals held — Amul 120, Maggi 119). Full browser E2E (Puppeteer, real Chromium): back office (thomas/sims) → Purchase Entry → Amul Butter, Enter chain captured batch B-TEST / expiry 2026-07-17, F12 posted; Expiry Dashboard rendered live — ≤7-day ₹6,350 / 3 batches (B-105A ₹1,400, B-107A ₹3,300, B-TEST ₹1,650) and Expired-with-stock ₹2,200 / 1 batch (B-107X). POS (Ramesh/4728, Skip count) → billed Amul Butter → the inline FEFO picker listed B-107X (EXPIRED, red) · B-107A (FEFO-preselected, exp 2026-07-15) · B-TEST · B-107B → Enter committed ST1C2/26-000016 and B-107A on-hand fell 12→11 (per-batch, not item-wide); a second bill navigated (↑) to the expired B-107X → the inline gate showed "batch B-107X EXPIRED 2026-07-07 — approve sale? (E-1310)" with Suresh/Thomas as approvers → cancelled, so B-107X stayed at 8 (never sold). Honest caveats: the server validates a batch belongs to the item+store but does not itself re-block expired sales — the E-1310 gate is POS-side with a BATCH_OVERRIDE audit trail (server-side expiry enforcement is a follow-up); the batch cache is fetched in the billing screen at mount (effectively login) and after each commit, but is not written to the S4 offline IndexedDB cache, so a batch-tracked sale made while the server is down bills un-batched with a warning; the demo dead-stock note flags by expiry only (velocity-based slow-mover ranking is a follow-up); the chrome-devtools MCP was unusable this session (its browser handle wedged into a "browser already running" state that survived killing every Chrome + the profile lock and needs an MCP-server restart), so the browser E2E was driven via Puppeteer against the same running store-server instead. No git commits.
Load verification (S7) — done 2026-07-10. The 15-EDITIONS scale target is now
verified by load test, not assumed (R25). A scripted run (apps/store-server/load/loadtest.mjs,
plain node 22 + better-sqlite3, no new deps) drove 2,500 real bill commits distributed
across 10 counters at a concurrency of 10 (one in-flight request per counter) against a
second, isolated store-server instance on :5281 with a fresh data dir — the dev DB and
the running :5181 server were never touched (new SIMS_DATA_DIR env in db.ts points the
isolated instance at its own data dir; the seed makes C1/C2, so C3–C10 were added as real
counter rows with their own per-counter GST series). Each bill was a randomized 1–5-line
cart from the seeded catalog (the 2 batch-tracked items skipped); the client payable is
roundToRupee(Σ qty·price) — exact for tax-inclusive, no-discount bills — and the server
still re-verified every document to the paisa with the shared engine (R5). A burst phase
then pushed 100 bills at concurrency 50 to find the knee.
Environment (single dev machine — not a distributed benchmark):
| Field | Value |
|---|---|
| Node | v22.20.0 |
| CPU | 12× 13th Gen Intel Core i5-1335U |
| RAM | 15.69 GB |
| OS | Windows 11 (10.0.26200) |
| Target | one store-server process, isolated data dir, port 5281 |
Results:
| Phase | Bills | Concurrency | Success | Wall | Throughput | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|---|---|
| Steady | 2,500 | 10 (1/counter) | 2,500 (100%) | 8.64 s | 289.4 bills/s | 28.8 ms | 54.6 ms | 76.0 ms | 397.6 ms |
| Burst | 100 | 50 | 100 (100%) | 1.16 s | 85.9 bills/s | 445.9 ms | 737.4 ms | 746.5 ms | 748.7 ms |
Integrity (queried directly against the load DB after the run):
| # | Check | Result |
|---|---|---|
| a | bill rows == successful commits | PASS (2,600 == 2,600) |
| b | every counter's SALE series gap-free & duplicate-free | PASS (c1–c10 each 1..260) |
| c | stock derived == opening − billed, per item (R7) | PASS (9 items reconciled; 7,800 SALE movements == 7,800 billed lines) |
| d | every bill's payments sum == payable | PASS (2,600 bills, 0 mismatch) |
| e | audit BILL_COMMIT count == bill count (R17) |
PASS (2,600 == 2,600) |
| f | outbox rows == bill count (R15) | PASS (2,600 == 2,600) |
Honest interpretation. Steady-state throughput on this dev laptop is ~289 committed
bills/sec with a sub-30 ms median. Reading the S7 target at its heaviest (10 counters ×
2,500 = 25,000 bills/day), the entire day's work is ~86 s of server-busy time spread
across a full trading day — 2–3 orders of magnitude of headroom; the lighter reading (2,500
bills/day for the store) completes in 8.6 s. better-sqlite3 serializes writes in-process
(one synchronous commit at a time), so throughput is bounded by single-core commit cost
(~3.5 ms/bill) and concurrency buys latency, not throughput: the burst at concurrency 50
inflated p50 latency ~15× (29 ms → 446 ms) without raising throughput (it measured lower),
so the knee is well below 50 — the realistic 10-counter operating point already sits near the
single-write-core ceiling with a wide latency margin. Correctness held perfectly under load:
zero failed commits, and all six integrity invariants (no series gaps/dups across 10
independent counters, exact stock reconciliation, payments == payable, one audit + one outbox
row per bill) passed on 2,600 bills. Incidentally confirmed the 2-digit counter code C10 stays
within the GST 16-char doc-no rule at 6-digit sequences (ST1C10/26-000260). These are
single-machine dev-hardware numbers; a store-server on real store hardware and the future
Oracle 12c adapter (D12) will have different constants, but the serialization model and the
integrity guarantees are the same. No git commits; typecheck clean on the one TS file touched
(db.ts), :5281 instance killed after the run.
Purchase Entry — LIVE (the second crown jewel)
/purchases/entry: supplier type-ahead → invoice no/date → scan-grammar line entry
(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 (8080–85 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