diff --git a/BUILDING.md b/BUILDING.md
deleted file mode 100644
index e4e63cc..0000000
--- a/BUILDING.md
+++ /dev/null
@@ -1,101 +0,0 @@
-# Building SiMS Next
-
-## Layout
-
-```
-Store Software/
-├─ docs/ planning docs 00–11 (read 00-VISION first)
-├─ packages/ pure, tested business logic — no UI, no DB, no hardware
-│ ├─ domain/ tenancy (tenant→store→counter→user), money (integer paise,
-│ │ ₹ Indian grouping), uuidv7 ids, business day (Day Begin
-│ │ idiom from the OG apps), GST doc series, GSTIN validation,
-│ │ immutable document types, dormant outbox rows (D14)
-│ ├─ billing-engine/ deterministic GST math: dated rate resolution, inclusive/
-│ │ exclusive prices, CGST/SGST vs IGST, cess, pro-rata bill
-│ │ discounts, rupee round-off — locked by golden tests
-│ ├─ auth/ PIN policy + scrypt hashing, lockout reducer, role→action
-│ │ permission matrix with supervisor-PIN gates, POS session
-│ │ bound to the Day-Begin business date
-│ ├─ scanning/ EAN-13 validation, label-scale (2x-prefix) weight/price
-│ │ barcodes, the scan-box grammar (qty*, code, search),
-│ │ wedge scanner vs human-typing detector
-│ └─ config/ settings hierarchy resolver (system→tenant→store→counter
-│ →user, effective-dated) + message catalog with language
-│ fallback — the everything-in-DB principle as code
-└─ apps/ thin shells over the packages (in build order)
- ├─ pos/ Electron + React counter (Phase-0 hardware spike next)
- ├─ store-server/ Node service beside the existing Oracle 12c (D12)
- └─ backoffice/ React SPA, served locally by store-server in v1
-```
-
-## Commands
-
-```
-npm install # once; workspaces link @sims/* packages
-npm test # vitest across all packages
-npm run typecheck # tsc strict over every src + test file
-
-# THE WEB APP (the product, per D2 revision) — one process serves everything:
-cd apps/pos && npm run build
-cd apps/backoffice && npm run build
-cd apps/store-server && npm start
-# Back office: http://localhost:5181/ (thomas / sims)
-# POS: http://localhost:5181/pos/ (counters browse here; zero installs)
-# Printing: POST /api/print → ESC/POS over TCP 9100 (browsers can't open sockets)
-
-# Optional Electron kiosk wrapper for the POS (same web bundle, direct print bridge):
-cd apps/pos && npm start
-```
-
-## Design system (packages/ui)
-
-- **Tokens** (`tokens.css`): light + dark themes, six switchable accents (indigo, blue,
- emerald, violet, rose, amber), semantic status colors, shadows, radii — applied via
- ``. References: Stripe (light clarity), Linear (dark).
-- **Fonts bundled, never CDN** (shops are offline): Inter Variable for UI + tabular
- money digits, JetBrains Mono Variable for codes/receipts — via fontsource, built into
- each app's dist.
-- **`ThemeSwitcher`** sits in both app headers; prefs persist in localStorage now and
- become user-scope settings rows later (config-driven principle).
-- Restyle rule: pages consume tokens/classes only — changing `tokens.css` restyles the
- entire product; no page edits.
-
-## Wireframe status (v0.1)
-
-- **POS**: login (PIN tiles) → shift open (denomination count) → billing screen —
- functional: real billing engine, scanner wedge detection, scale barcodes, hold/resume,
- per-counter GST series, payment overlay, day-end summary, ESC/POS printing over
- network (Ctrl+, to configure; test print + drawer kick). Runs in a browser too
- (`npm run dev`) with printing stubbed.
-- **Back office**: full menu tree (09-UX §2), ~40 pages as data-driven wireframes with
- realistic sample data; custom builds of the Purchase Inbox (split review), Settings
- hierarchy, Users & Roles matrix, Dashboard. Edition-locked pages render as upsells.
-- Revamp path: pages are `PageConfig` rows rendered by one `GenericPage` — restyling the
- kit restyles the app; promoting a page to "real" means swapping its config for a
- component fed by the store-server API.
-
-## Rules the structure enforces
-
-1. **Logic lives in packages, apps are shells.** The same billing engine must produce
- byte-identical bills on POS, store server, and cloud — that's why it is pure TS with
- golden tests, and why the frontend can stay stable (founder principle) while behavior
- evolves as data.
-2. **Tenancy from row one.** Classic has no tenant concept; here `tenantId` is on every
- record even though a v1 install holds one tenant. Retrofitting this later is the
- single most expensive migration there is — so it is never retrofitted.
-3. **Documents are immutable, ids are client-generated (uuidv7), outbox rows are written
- from day one** even though nothing drains them yet (D14): cloud enablement must be a
- switch-on, not a migration.
-4. **Nothing user-facing is hard-coded.** Rates, settings, messages, plans resolve from
- rows (see `@sims/config`); the packages define the resolution semantics and the DB
- supplies the rows once the Oracle schema mapping (docs/12-DATA-MODEL.md) exists.
-
-## Next steps (in order)
-
-1. **Phase-0 hardware spike** in `apps/pos`: Electron shell, ESC/POS print, drawer kick,
- wedge scanner wired to `@sims/scanning`, one weighing scale.
-2. **Golden bills from Classic**: 100 real bills re-computed through the engine to the
- paisa — extend `packages/billing-engine/test`.
-3. **Classic schema mapping** (needs the Classic Oracle DDL export) → docs/12-DATA-MODEL.md,
- then the repository layer in store-server.
-4. Billing screen prototype against the item cache, timed with real cashiers.
diff --git a/STATUS.md b/STATUS.md
deleted file mode 100644
index ed1eba2..0000000
--- a/STATUS.md
+++ /dev/null
@@ -1,656 +0,0 @@
-# Build Status — 2026-07-10 (post-slice)
-
-**54 screens across 2 surfaces** (46 back office + 8 POS), on 8 tested logic packages
-(89 tests green), and a store-server **with a real data layer** (SQLite behind portable
-repositories; Oracle 12c adapter slots in when the Classic DDL arrives — D12).
-Product form: **web app** served by the store-server (D2) — back office at `/`,
-POS at `/pos/`, printing via `/api/print`.
-
-**The vertical slice is LIVE and verified end-to-end**: PIN login (server-side scrypt +
-lockout) → item cache from DB → bill commit in one transaction (GST series allocation,
-bill + lines, stock movements, dormant outbox row, audit entry) with **server-side
-engine re-verification** (client and server must agree to the paisa) → bill visible in
-Back Office Bills, stock derived correctly, audit trail written. Verified by driving
-both UIs in a real browser: bill `ST1C2/26-000001`, ₹600.00.
-
-## P0 security remediation — DONE 2026-07-10 (red-team close-out)
-
-The store-server now enforces the trust boundary the red-team review (docs/18) found
-missing: it no longer trusts the client for **identity, price, tax, item existence, or
-input shape**. One coherent change, three server-side moves + client wiring:
-
-| Move | What changed | Closes |
-|---|---|---|
-| **Item-master anchoring** | `commitBill` rebuilds every line from OUR `item` row — tax class, HSN, MRP, unit, inclusive-price flag are read from the master and the request's copies ignored; the unit price is accepted only if it equals the master sale price, else it is a *deviation* needing approval; price > MRP is a hard block (E-1301); fractional qty on a whole unit is rejected (E-1104); non-existent/foreign item rejected (E-1105) | C2, C3, H5, M8 |
-| **Per-route authorization** | `requireAnyPerm(...ActionCode)` middleware in `api.ts` maps each mutating/sensitive route to `permissions.ts` grants, `can(session, action)`, deny-by-default 403 (E-6105). Cashier keeps billing + draft quick-add + own reads; loses `/audit`, `/purchases*`, cost endpoints, master edits, purchase posting | C1, C4 |
-| **Input validation + generic errors** | hand-rolled `validate.ts` guards (no new deps) on every route: ISO+windowed `businessDate` (H6), clamped `limit`/`offset` (M6), capped `lines` ≤ 500 (M12), payment legs > 0 with an allowlisted mode summing to payable (M8). Express 4-arg error handler returns `{error, code}` with no stack/path (M7); `x-powered-by` disabled (M14) | H6, M6, M7, M8, M12 |
-| **Bound approval tokens** | `override_approval` rows carry `item_id/kind/before/after`; `/auth/verify-pin` binds the override context at mint; `commitBill` re-verifies the token against the line's server-computed deviation (H4). Self-authorised (owner/manager/supervisor) deviations are audited from the session identity, so **every** deviation leaves a trail (H7) | H4, H7 |
-
-**Proven on a fresh DB by re-running the red-team PoCs (all now blocked) and a real-browser
-E2E** (Ramesh normal bill → commits; Ramesh F3 rate override → Suresh PIN → bound-token
-commit + audit; Thomas owner self-price-change → commit + audit). 207 tests green,
-typecheck + all three builds clean. Details + evidence table in docs/18-RED-TEAM-REVIEW.md.
-P1 (print allowlist, TLS, session TTL, bootstrap/verify-pin hardening) and P2
-(encryption-at-rest, hash chain, demo-seed gate) remain **open** by design.
-
-### HIGH follow-up — cashier draft quick-add path CLOSED 2026-07-13
-
-The independent verifier found the C1 remediation left one HIGH hole inside the quick-add
-convenience it kept open: a cashier could `POST /items status:'draft'` with an **arbitrary
-price AND tax class**, then bill it — reproducing **C2 (arbitrary price)** and **C3 (GST
-evasion: a taxable good self-added as `ZERO`, sold at ₹0 GST)** with only an `ITEM_CREATE`
-trace. Closed while keeping the convenience (an unknown barcode must not stop the queue):
-
-| Move | What changed | Closes |
-|---|---|---|
-| **Force the draft tax class** | On a non-`MASTER_EDIT` caller's `POST /items` draft, the server **ignores** the client `taxClassCode` and assigns the store default (`catalog.defaultTaxClass` setting, system→tenant, default `GST18`). A manager/owner still sets it; back office completes the real HSN/class later | C3-via-draft |
-| **Cap the draft price** | A non-`MASTER_EDIT` draft over `catalog.draftMaxPricePaise` (default ₹5,000) is denied **E-1108** ("call a supervisor"); a `MASTER_EDIT` role may exceed it | C2-via-draft |
-| **Audit every draft sale** | `commitBill` writes a **`DRAFT_SALE`** audit row (entity `bill`, `{itemId,name,pricePaise,docNo}`) in the commit transaction for each line whose master row is still `status:'draft'` — a self-authored-item sale is no longer invisible in a review | review gap |
-| **Scope cross-counter reads** | `GET /bills` for a caller without `REPORT_VIEW` is pinned server-side to the session user id — a cashier sees only their **own** bills; a manager keeps the full view | residual LOW/MED |
-
-Two new settings seed at `system` scope (`catalog.defaultTaxClass=GST18`,
-`catalog.draftMaxPricePaise=500000`), tenant-overridable per R9. **Proven live on a fresh DB**
-(re-fired as cashier u1/4728): "Redteam Whiskey" `ZERO`@₹2000 → server forced **GST18**, billed
-**CGST+SGST 30508 paise** (was ₹0); draft @ ₹9,999 → **400 E-1108**; two draft sales wrote **2
-`DRAFT_SALE`** rows; Ramesh/Divya each saw only their own `/bills` while the owner saw all; and
-the earlier C1/C2/C3 PoCs on existing items still fail closed. Legit quick-add still bills
-end-to-end. **220 tests green** (9 new), typecheck + all three builds clean, dev DB reset and
-store-server restarted on `:5181`. No git commits.
-
-### M11 (MEDIUM) — offline customer PII on shared counter PCs CLOSED (POS client) 2026-07-13
-
-Red-team M11 (docs/18): the POS offline IndexedDB (`sims-pos-offline`) held, unencrypted on
-shared counter PCs, (a) queued offline bills carrying `customerName` + `buyerGstin`, and (b) a
-catalog cache including the staff roster — readable by the next cashier or any same-origin script.
-**Closed on the POS client (store-server untouched — a separate agent owns that tier):**
-
-| Move | What changed | File |
-|---|---|---|
-| **No PII in the queued bill** | `QueuedBill` / `buildQueuedBill` no longer model or store `customerName`/`buyerGstin`; the queued record keeps **only `customerId`**. The drain (`POST /api/bills/offline`) already carried id-only, and the server re-resolves the buyer's name/GSTIN/place-of-supply from `customerId` + tenant at commit (`repos.ts:296-298`), identical to an online bill — so correctness is unchanged and no buyer PII is written to the device | `apps/pos/src/offline.ts`, `BillingScreen.tsx` `queueOffline` |
-| **Wipe cache/roster on Lock & logout** | new `clearCatalogCache()` clears the whole `cache` store (store, staff roster, item master) from `App.onLock`; **un-drained queued bills are deliberately preserved** (a lock must never lose a sale) and the catalog is re-fetched on next login | `apps/pos/src/offline.ts`, `App.tsx` `onLock` |
-
-**Verified** (server was mid-restart / down on :5181, so no full E2E — did the offline path instead):
-typecheck clean + POS builds clean; **6 offline unit tests green** (asserting the PII-free queued shape);
-and a **real-browser IndexedDB run** (Vite dev + dynamic import of the actual `offline.ts`) confirmed the
-persisted queued record has `customerId` only — injected `customerName`/`buyerGstin` were dropped, nothing
-PII in the serialized record — the catalog+roster cache was present then **cleared after lock**, while the
-un-drained queued bill **survived** (count 1→1). **Residual:** the queued lines/amounts still sit in a plain
-IndexedDB until they drain — true at-rest **encryption** of that remaining queue rolls into H3 (server-tier
-SQLCipher), a separate finding. This fix removes the standing PII, not the queue itself. No git commits.
-
-## P1 edge & transport hardening — DONE 2026-07-13 (red-team close-out)
-
-The P1 "lock down the edges + TLS" workstream (docs/18) is **REMEDIATED**: **H1, M1, M2, M4,
-M13, M14** closed, **M15** noted. Server changes stayed in `server.ts` + `api.ts` plus three
-pure, unit-tested modules; the only new runtime dep is **helmet** (justified). Client edits were
-limited to logout wiring + the POS post-login bootstrap re-fetch. (M5 — tenant-scoping
-`commitBill`'s store/counter — belongs to the money-logic workstream and is untouched here.)
-
-| Finding | What changed | File(s) |
-|---|---|---|
-| **H1** print SSRF | `/api/print` now an authenticated `apiRouter` route constrained to a **LAN IP** (127/8, 10/8, 172.16/12, 192.168/16; public/link-local rejected) on a **port allowlist** ({9100,9101,9102}, `SIMS_PRINTER_PORTS`) with a **≤64 KB** cap; the host is resolved and every resolved IP LAN-checked, then we connect to the resolved IP (no DNS-rebind) | `apps/store-server/src/print-guard.ts`, `api.ts` |
-| **M1** `/bootstrap` minimize | the UNAUTHENTICATED payload drops each user's **role** and the tenant **GSTIN** (keeps id+name for PIN login); both restored when the SAME endpoint is called WITH a session — the POS re-fetches after login, back office is always authed | `api.ts` `/bootstrap`, `apps/pos/src/App.tsx` `onLogin` |
-| **M2** verify-pin behind auth | `/auth/verify-pin` now requires a logged-in **caller** (`requireAuth`) and is rate-limited; the approver PIN still rides in the body — it is no longer an unauthenticated PIN oracle / token mint | `api.ts` |
-| **M4** session TTL + logout + rate limit | sessions carry created+last-seen and are evicted past a **12 h absolute / 30 m idle** TTL (401 E-6110); **`POST /api/auth/logout`** deletes the session (POS "Lock" + back-office "Logout" now call it); a **global + per-IP `/auth/*`** limiter (20/min/IP, 200/min global, env-tunable) layers on the per-user lockout | `api.ts`, `rate-limit.ts`, `session-policy.ts`; POS `App.tsx`/`api.ts`, back office `main.tsx`/`api.ts` |
-| **M13** TLS | HTTPS available: `SIMS_TLS_CERT`+`SIMS_TLS_KEY` ⇒ `https.createServer`; unset ⇒ HTTP dev (`:5181` unchanged). Dev-cert one-liner + "prod MUST supply certs" in `server.ts`; HSTS + `upgrade-insecure-requests` only under TLS | `server.ts` |
-| **M14** security headers | **helmet** with a SPA-compatible CSP (`'self'` scripts + `'unsafe-inline'` styles + `blob:`/`data:` for the invoice/label tabs + `data:` fonts; the tabs' only inline script — `window.print()` — allowed by a single `'unsafe-hashes'` sha256) + explicit **`X-Frame-Options: DENY`** + `frame-ancestors 'none'` + nosniff + `Referrer-Policy: no-referrer` | `server.ts` |
-| **M15** SW poisoning | noted — closes once prod is HTTPS (M13) | (doc) |
-
-**Verified**: **245 tests green** (was 220; new `edge-guards.test.ts` pure cases +
-`edge.test.ts` HTTP cases), typecheck clean on root + all three apps, all three apps build,
-store-server rebuilt + restarted on `:5181` (HTTP, `/api/health` ok, helmet headers present, no
-`X-Powered-By`). Live PoCs re-fired: unauth `/bootstrap` carries no role/gstin (authed does);
-`/api/print` → public `8.8.8.8:9100` **400 E-2203** (no socket), `8.8.8.8:80` **400 E-2202**,
-LAN `127.0.0.1:9100` **502 ECONNREFUSED** (guard passed), unauth **401**; unauth verify-pin
-**401**, authed cashier caller **200 + approvalId**; logout then reuse **401**; a 1 s-idle
-instance **401 E-6110** after the timeout; a cap-3 instance **429** on the 4th auth attempt.
-Real browser: back office + POS login screens render with **zero CSP console violations** (the
-POS tiles now show no role subtitle — the expected M1 effect). No git commits.
-
-## P2 data-integrity & crypto — DONE 2026-07-13 (red-team close-out)
-
-The P2 "harden-before-cloud" data-integrity slice (docs/18) is **REMEDIATED**: **H2, M3, M5, M9,
-M10** closed; only **H3** (encryption at rest) stays open, owned by a separate agent next. Confined
-to `apps/store-server/src/{db.ts,repos.ts,api.ts,server.ts}`, `packages/auth/src/pin.ts`,
-`packages/domain/src/doc-series.ts`, + `dev-start.mjs` and `.env.example`. No new runtime deps.
-
-| Finding | What changed | File(s) |
-|---|---|---|
-| **H2** tamper-evident audit log | `audit_log` gained `prev_hash`+`entry_hash`; `writeAudit` links each row into the tenant's SHA-256 hash chain **inside the existing commit transactions** (genesis `''`, rowid insertion order); `verifyAuditChain` + **`GET /api/audit/verify`** (AUDIT_VIEW) recompute it and name the first broken row. Tamper-EVIDENCE, not prevention (the file is still writable — that is H3) | `db.ts`, `repos.ts`, `api.ts` |
-| **M3** PIN pepper | `hashPin`/`verifyPin` HMAC-SHA256 the secret with a server-held pepper (`SIMS_PIN_PEPPER`) **before scrypt**, so a stolen DB can't be brute-forced against the 10k PIN keyspace without the env-only pepper; unset ⇒ raw-secret fallback (dev unchanged) + one-time warning; `.env.example` added | `packages/auth/src/pin.ts`, `.env.example` |
-| **M5** tenant-scope commit | `commitBill` selects store AND counter `WHERE id=? AND tenant_id=?` → **400 E-1110** before any money logic (kills the undefined-deref 500 + any cross-tenant reference); `/bills` + `/bills/offline` both covered. (E-1110, not the task's E-1109 — that code is already payments-≠-payable) | `repos.ts` (already present from the H5/anchoring pass; verified + tested) |
-| **M9** 4-digit FY series | `seriesPrefix` embeds the 4-digit FY start year (`ST1C22026-000123`); FY 2026-27 vs 2126-27 no longer collide. The counter↔FY `/` is dropped to fit the GST 16-char rule at 6-digit sequences (store+counter must total ≤ 5 chars) | `packages/domain/src/doc-series.ts`, `repos.ts` |
-| **M10** demo-seed gate | `seedIfEmpty`/`ensureBatchTrackingSeed` run only under `SIMS_SEED_DEMO=1`; a prod empty DB never auto-creates the known-credential owner (thomas/9174/sims); dev `npm start` → `dev-start.mjs` sets the flag | `server.ts`, `dev-start.mjs`, `package.json` |
-
-**Verified**: **260 tests green** (was 245; +5 audit-chain, +4 tenant-scope, +1 `/audit/verify` HTTP,
-+4 PIN-pepper, +1 4-digit-FY collision), typecheck clean on root + all three apps, all three apps build.
-Dev DB reset and store-server restarted on `:5181` with `SIMS_SEED_DEMO=1` (`/api/health` ok, seeded).
-Live PoCs: a fresh bill's doc no **`ST1C22026-000001`** (16 chars); owner `GET /api/audit/verify` **`ok:true`**,
-then a direct `UPDATE` of one `audit_log` row → **`ok:false`** with `brokenAtId` = that row; a bill with a
-foreign `storeId` → **400 E-1110** (not 500); an isolated instance with `SIMS_SEED_DEMO` **unset** → **0
-tenants**, thomas login **401**. **H3 (encryption at rest) is the only open P2 item.** No git commits.
-
-### H3 (HIGH) — encryption at rest CLOSED 2026-07-13 (red-team close-out)
-
-The **last** open red-team finding is **REMEDIATED**: the store DB is **full-DB encrypted at rest
-with SQLCipher**, closing H3, the F5 at-rest concern, and the M11 residual (the offline queue drains
-into a now-encrypted server tier). `better-sqlite3` was swapped for the API-compatible drop-in
-**`better-sqlite3-multiple-ciphers`** (same API + `pragma('key=…')`; it ships its own
-DefinitelyTyped-derived types, so the `DB` type and every call are unchanged — **no shim, no
-loosened strictness**). Confined to `apps/store-server/src/db.ts`, `package.json`,
-`build-server.mjs`, `.env.example`; no other hardening touched.
-
-| Move | What changed | File(s) |
-|---|---|---|
-| **Key the DB from the environment** | `openDb()` reads **`SIMS_DB_KEY`**; when set it keys the DB (`cipher='sqlcipher'`, `key=…`) **before any read**, so `sims.db` + its WAL/SHM sidecars are ciphertext. The key is held ONLY in the server env (machine-scoped DPAPI/keychain), never in the repo or DB; quotes in it are escaped | `apps/store-server/src/db.ts` |
-| **Dev fallback preserved** | Key UNSET ⇒ open unencrypted exactly as before + a **one-time** warning that prod MUST set it (mirrors the `SIMS_PIN_PEPPER` pattern), so `:5181` keeps working with no key | `apps/store-server/src/db.ts`, `.env.example` |
-| **Native module externalled** | esbuild externals `better-sqlite3-multiple-ciphers` (as it did `better-sqlite3`) so the server bundle stays buildable | `apps/store-server/build-server.mjs` |
-
-**Path taken: full-DB encryption** — the native package installed from a **prebuilt binary** (no
-node-gyp) on this Windows / Node 22 box, so the app-level field-encryption fallback was not needed.
-**Proven live** on the reset, keyed `:5181`: (a) `head -c 16 data/sims.db` → random `28 85 83 94…`,
-**not** `SQLite format 3\0`, and grep of db/WAL/SHM for `MegaMart`/`Lakshmi`/the phone/the GSTIN found
-**no cleartext**; (b) opening `data/sims.db` **without** the key (and with a **wrong** key) → `file is
-not a database`, while the **correct** key decrypts it; (c) the app works end-to-end through the
-cipher — cashier login **200**, `GET /items?all=1` **200** (11 items), `POST /bills` → **`ST1C22026-000001`**,
-owner `GET /audit/verify` → **`{ok:true}`**. **265 tests green** (was 260; +5 new `encryption.test.ts`),
-typecheck clean on root + store-server, all three apps build. **Production still must**: supply
-`SIMS_DB_KEY` via a machine-scoped secret (DPAPI/keychain, never a file beside the DB); migrate any
-existing plaintext `sims.db` once offline (`sqlcipher_export`) before the first encrypted boot; treat
-rotation as a re-key; back up the key with the DB; and tighten data-dir ACLs. No git commits. **All
-red-team findings are now closed.**
-
-## Returns / credit notes — LIVE 2026-07-13 (first increment: return against an original bill)
-
-The **RETURNS / credit-note flow** ships end-to-end (POS F9 → server → back office), built
-security-first so it rides — never weakens — the money/commit boundary the red-team hardened
-(docs/18). A return is a new **immutable `SALE_RETURN` document** referencing the original bill
-(R6): everything anchors to OUR stored original bill, the refund is server-computed, over-return
-is blocked, and the whole commit (credit-note series, stock-back, refund leg, outbox, hash-chained
-audit) lands in ONE transaction (R15/R17). Scope this increment: **return against an original
-sale**; exchanges and split-tender refunds are deferred (below).
-
-| Move | What changed | File(s) |
-|---|---|---|
-| **Shared return engine (R5)** | new pure `computeReturn` — the money half of a credit note, shared BYTE-FOR-BYTE by the POS (refund preview) and the server (authoritative commit), exactly like `computeBill`. It REVERSES the original line PROPORTIONALLY by `returnQty/soldQty` and at the **ORIGINAL captured rate** (it scales the stored taxable/CGST/SGST/IGST/cess, never today's tax master — so a return reverses tax at the rate on the bill even if the master changed since, R5/R10). Integer paise; `taxable + taxes == lineTotal` by construction; a full-line return reproduces the original exactly | `packages/billing-engine/src/returns.ts` |
-| **Credit-note series (M9 reuse)** | `creditNotePrefix` — a **`CN`-marked** per-counter, 4-digit-FY series so a credit note can never collide with a sale in `bill`'s `UNIQUE(tenant, doc_no)`; the CN marker costs 2 chars, so the sequence is 4 digits to hold the GST 16-char rule: `CNST1C22026-0001` (16) | `packages/domain/src/doc-series.ts` |
-| **Server `commitReturn` (the trust boundary)** | loads the original bill by **id AND tenant_id AND doc_type='SALE'** (cross-tenant / nonexistent / non-sale → **400 E-1320**); reads prices/tax/HSN/splits **from the stored original payload, never the client**; enforces **returnable qty** = returned-so-far (Σ prior `SALE_RETURN` refs) + this qty ≤ sold (over-return → **400 E-1324**); rejects qty ≤ 0 / fractional-on-whole-unit (**E-1323**); computes the refund server-side and **409 E-1325** if a client-declared total disagrees (mirrors `commitBill`). In one txn: the `SALE_RETURN` bill row (refDocId = original, buyer/place-of-supply snapshotted FROM the original), the CN series no, **stock ADDED BACK** (positive `qty_delta`, reason `SALE_RETURN`, carrying the original line's `batch_id`), the refund payment leg, the outbox row, and a **`RETURN_COMMIT`** audit row hash-chained in-txn (H2) | `apps/store-server/src/repos-returns.ts` |
-| **API** | `POST /api/returns` (gated **`RETURN_CREATE`** per-route authz — cashiers have it), `GET /api/returns` (credit notes, tenant-scoped, paginated, cashier pinned to own per the M-fix scoping), `GET /api/bills/:id/returnable` (sold/returned/returnable per line + raw lines for the preview), `GET /api/bills/lookup?docNo|phone` (targeted SALE lookup for the return screen). `listBills` now filters to `doc_type='SALE'` so credit notes never pollute the sales list or POS history | `apps/store-server/src/api.ts`, `repos.ts` |
-| **POS — F9 returns** | F9 opens a keyboard-first returns overlay: look up the original by **bill no or customer phone**, a line grid with sold/returned/**returnable-qty inputs**, a **reason** dropdown (Damaged / Wrong item / Expired / Customer changed mind / Other), a **live server-matching refund preview** (client `computeReturn`), commit → prints a **CREDIT NOTE** receipt. Esc cancels, focus discipline kept, over-return + zero-qty blocked inline | `apps/pos/src/BillingScreen.tsx`, `api.ts` |
-| **Back office — `/sales/returns` LIVE** | the registry wireframe is replaced (CUSTOM map) by a live, tenant-scoped, paginated credit-note list (CN no, against-bill, date, cashier, customer, reason, refund) — pattern-copied from `BillsPage` | `apps/backoffice/src/pages/live.tsx`, `main.tsx`, `api.ts` |
-| **Printing** | `receiptLines`/`renderReceipt` gained a `variant:'credit-note'` — a **CREDIT NOTE** banner, the original bill ref, REFUND/Refund-via labels and the reason, on the one shared content model | `packages/printing/src/receipt.ts` |
-
-**Verified (R23 — not just unit tests): 333 tests green** across all suites, 0 failing (+12 for this
-flow — 4 in a new engine `returns.test.ts`, 8 in a new server `returns.test.ts` covering over-return,
-refund-from-original-not-client 409, tax reversed at the original rate, cross-tenant/non-sale
-reject, the `RETURN_CREATE` gate, doc_no ≤16), typecheck clean on root + all 3 apps, all 3 apps
-build, store-server rebuilt + restarted on `:5181` (plaintext
-dev DB, `SIMS_SEED_DEMO=1`). **Real-HTTP E2E on `:5181`** (17/17 checks): cashier `u1/4728` billed
-3× Surf Excel → `ST1C22026-000001` (stock 120→117) → returned 2 → **`CNST1C22026-0001`** (16 chars),
-**refund ₹290.00 = 2/3 of the taxed line**, **stock 117→119 (+2)**, **GST reversed CGST+SGST 4424 paise
-at the original GST18 rate** (not ₹0), back-office `/returns` lists it against the original; a second
-2-unit return (only 1 left) → **400 E-1324**; abuse cases all fail closed — client-inflated refund →
-**409 E-1325**, out-of-range line → **400 E-1321**, qty > sold → **400 E-1324**, nonexistent/cross-tenant
-original → **400 E-1320**; `GET /audit/verify` stays **`{ok:true}`** with the `RETURN_COMMIT` row chained
-in. **Real browser (Chrome, both surfaces):** back office `/sales/returns` renders the credit note LIVE;
-POS **F9** → looked up `ST1C22026-000002` → line grid (Sold 3 / Returned 0 / Left 3) → qty 2, reason
-"Wrong item" → **live REFUND ₹290.00** → committed **`CNST1C22026-0002`** (gap-free series), Surf Excel
-on-hand settled at 118 (sold 6, returned 4). No git commits. **Deferred (follow-ups):** exchanges
-(return + re-sell in one document); partial refund split to a **different** tender than the original;
-an A4 credit-note document (the thermal CREDIT NOTE receipt ships now); offline-queued returns (the
-return path needs the server up — the sale's S4 queue is unchanged); a physical thermal print of the
-credit note (unit- + browser-verified, no 80 mm printer here).
-
-## GST RETURNS — LIVE 2026-07-13 (GSTR-1 + GSTR-3B + HSN from real transaction data)
-
-The **GST returns** phase ships: GSTR-1, GSTR-3B (3.1a) and the HSN summary are generated
-**live from the counters' immutable SALE + SALE_RETURN documents** and the wireframe GST pages are
-now real. It is **READ-ONLY reporting** — no writes, no new money-commit surface — but
-**compliance-critical**, so it never recomputes tax: every amount is read straight off the stored
-document line **as captured**, so a return reduces liability at the **original bill-date rate** even
-after a slab change (GST-1/GST-2/GST-10). Integer paise throughout (R4); the only math is addition
-of already-rounded amounts, so books == report == JSON to the paise (GST-3). Reconciliation is a
-first-class, tested output.
-
-| Move | What changed | File(s) |
-|---|---|---|
-| **Shared pure GST-return engine (R5)** | new `computeGstr1` / `computeGstr3b` / `reconcile` over a list of documents. **GSTR-1 sections**: B2B (Table 4A, grouped by buyer GSTIN + rate), B2CL (Table 5, inter-state B2C invoices above the **₹2,50,000** dated threshold, per invoice), B2CS (Table 7, aggregated by place-of-supply + rate, **net of small B2C credit notes**), CDNR (Table 9B, registered credit notes) + CDNUR (unregistered inter-state large), HSN summary (Table 12, net of returns) and documents-issued (Table 13). **GSTR-3B 3.1(a)**: outward taxable value + IGST/CGST/SGST/cess, **net of credit notes** (ITC/inward is an explicit stub). Each line lands in exactly one section, so the reconciliation identity holds by construction | `packages/billing-engine/src/gst-returns.ts` |
-| **Reconciliation as a compliance gate** | `reconcile()` proves **Σ GSTR-1 section taxables (returns negative) == Σ source == HSN total == GSTR-3B outward**, and likewise for total tax; any inequality is named and `ok:false`. Surfaced on every page and in the API response — a non-reconciling period says "do not file" loudly rather than papering over | same |
-| **Server repo (read-only)** | `gstr1` / `gstr3b` / `hsnSummary` / `gstr1PortalJson` read the period's SALE+SALE_RETURN by `business_date` (each bill carries its own store state so intra/inter is per-document), fold through the engine, and attach the reconciliation. B2CL threshold is a **dated tenant setting** `gst.b2clThresholdPaise` (R9/R10, seeded ₹2,50,000). The `.json` builder emits a **pragmatic GSTN-shaped** export (b2b/b2cl/b2cs/cdnr/cdnur/hsn/doc_issue, rupee amounts) documented in-payload as **NOT a certified filing** | `apps/store-server/src/repos-gst.ts`, `validate.ts` (`assertPeriod`), `seed.ts` |
-| **API** | `GET /api/gst/gstr1?period=YYYY-MM`, `/gstr3b`, `/hsn`, `/gstr1.json` — all gated **REPORT_VIEW**, tenant-scoped, period-validated (E-1330) | `apps/store-server/src/api.ts` |
-| **Back office LIVE pages** | `/gst/gstr1`, `/gst/gstr3b`, `/gst/hsn` replace the registry wireframes (CUSTOM map): month picker, a green/red **reconciliation banner**, stat cards, every section as a table (tables-rule R1), a **Download JSON** button, and a clear **"not a certified filing — export for your CA / GSP"** note on each | `apps/backoffice/src/pages/gst.tsx`, `main.tsx`, `api.ts` |
-
-**Verified (R23): 351 tests green** (was 333; +12 engine `gst-returns.test.ts` — hand-computed B2B/B2CL/B2CS/CDNR/HSN, threshold behaviour, and the reconciliation identity incl. a deliberate-drift catch; +6 server `gst.test.ts` — sections over a known month, 3B hand-checked, reconciliation, empty period, valid JSON, and the REPORT_VIEW gate). Typecheck clean on root + all 3 apps; all 3 apps build. Dev DB reset + store-server rebuilt and restarted on `:5181` (plaintext, `SIMS_SEED_DEMO=1`, no db key).
-
-**Live proof on `:5181`** — a known single-month set created via the API (period 2026-07): a **B2B** invoice `ST1C22026-000001` (2× Surf Excel to Anand Traders `27ABCDE1234F1Z0`, inter-state IGST), two **B2C** cash bills (`-000002` Surf+Amul, `-000003` 3× Parle), and a **credit note** `CNST1C22026-0001` (full Amul line of bill 2). The reconciliation ties out exactly:
-
-| Figure (paise) | Source docs | GSTR-1 net | HSN summary | GSTR-3B outward |
-|---|--:|--:|--:|--:|
-| **Taxable** | 62,578 | 62,578 | 62,578 | 62,578 |
-| **Total tax** | 7,922 | 7,922 | 7,922 | 7,922 |
-
-3B hand-check: **Σ bill tax ₹108.68 − Σ credit-note tax ₹29.46 = ₹79.22** == 3B total tax. GSTR-1 B2B shows the GSTIN invoice (taxable ₹245.76, IGST ₹44.24); B2CS aggregates the cash bills by rate (5% ₹257.14, 18% ₹122.88) with the returned Amul (GST12) bucket **netting to zero and dropping**; HSN totals ₹625.78; the `.json` export is valid (gstin `32ABCDE1234F1Z5`, fp `072026`). **Real browser (login thomas/sims):** `/gst/gstr1` and `/gst/gstr3b` render the LIVE numbers with the green **Reconciled** banner, all section tables, and the not-certified note (screenshots captured; MCP browser was wedged so driven via Puppeteer, per the S5 precedent). No git commits.
-
-**Deferred (follow-ups, noted for compliance honesty):** ITC / inward-supply reconciliation (GSTR-2A/2B, 3B Table 4 — purchase data exists); GSP e-filing (the JSON is an approximation, not a signed return — doc 17 §S2/D4); dedicated nil-rated / exempt / non-GST split tables (GST-4; 0%-rated supply currently sits as rate-0 buckets in the taxable sections); amendments (B2BA/CDNRA…); the cross-period **filing-freeze / sync-completeness** gate + soft-lock (GST-11); the 30-Nov credit-note guillotine alerting (GST-10); HSN Table-12 dropdown validation (GST-13); composition-scheme (Bill of Supply) returns (GST-6). Multi-store tenants are handled per-document (each bill's own store state drives intra/inter).
-
-## The foundation (packages/ — real, tested code, not wireframe)
-
-| Package | What it does | Tests |
-|---|---|---|
-| `@sims/domain` | Tenancy (tenant→store→counter→user), integer-paise money + ₹ Indian grouping, UUIDv7 ids, Day-Begin business day + FY, per-counter GST doc series (16-char rule), GSTIN checksum validation, immutable document types, dormant outbox (D14) | ✅ |
-| `@sims/billing-engine` | Dated GST rates, inclusive/exclusive prices, CGST/SGST vs IGST, cess, pro-rata bill discounts, rupee round-off — golden-tested to the paisa | ✅ |
-| `@sims/auth` | PIN policy + scrypt hashing, lockout reducer, role→action matrix with supervisor-PIN gates, POS session bound to business date | ✅ |
-| `@sims/scanning` | EAN-13 validation, weighing-scale (2x) barcodes, scan-box grammar (`3*` qty, code, search), wedge scanner vs human-typing detector | ✅ |
-| `@sims/search-core` | Catalog search (pure TS, no deps): Indic phonetic folding (chawal≈chaval, paneer≈panir, jeera≈zeera), in-memory index, tiered ranking (whole-prefix → token-prefix → phonetic → substring) | ✅ |
-| `@sims/printing` | ESC/POS byte builder, receipt renderer (42/32 col), drawer kick; **A4 GST invoice HTML + amount-in-words, EAN-13 label sheets, GS v 0 Indic raster (S2)** | ✅ |
-| `@sims/config` | Settings hierarchy resolver (system→tenant→store→counter→user, effective-dated), message catalog with language fallback | ✅ |
-| `@sims/ui` | Design system: light/dark themes, 6 accents, Inter + JetBrains Mono bundled, wireframe component kit, ThemeSwitcher | — |
-
-## POS — 7 screens (`/pos/`) — FUNCTIONAL
-
-| # | Screen | State |
-|---|--------|-------|
-| 1 | Login — user tiles + PIN pad | Functional (seed users) |
-| 2 | Shift Open — denomination count → opening float | Functional |
-| 3 | **Billing screen** — scan box, lines grid, totals, quick keys, status bar | **Functional end-to-end**: real engine, barcode/multiplier/scale-barcode entry, hold/resume, per-counter GST series, one-key cash close (F12/Numpad+) |
-| 4 | Payment overlay (F11) — Cash/UPI/Card/Khata | Functional (modes recorded; QR/split later) |
-| 5 | Bill history (F10) | Functional (day log) |
-| 6 | Day-End summary — totals by payment mode | Functional (variance calc later) |
-| 7 | Settings (Ctrl+,) — printer IP/port/width, test print, drawer kick | Functional (prints via store-server) |
-
-Also live: customer attach (F6); server-backed history (F10); commit failures keep the
-cart; **live search suggestions** (visible-before-add, ↑/↓+Enter); **F2 qty / F3 price
-inline cell edits** (price>MRP blocked); **unknown-code quick-add** (draft item created
-server-side, billable immediately). Verified: bill ST1C2/26-000002.
-**P1 pass done (verified)**: tendered-cash change flash (type `100` F12 → CHANGE ₹20),
-Ctrl+B repeat-last-bill (current prices, never resurrected), instant `*` multiplier with
-qty chip, F4 line discount (`4` = ₹4, `5%` = percent; Disc column appears on demand),
-audio feedback (ok/warn/err beeps), ok-notices auto-clear, stock chips in suggestions
-(live counts, out-of-stock warn-not-block). Bill ST1C2/26-000003 proves the chain.
-Purchase Entry P1: crash-proof localStorage draft with resume banner; glance strip on
-cell focus (stock · sale · MRP · live margin · last-3 costs via /purchases/cost-history).
-Returns/credit notes now LIVE (F9 — see the Returns section above). Still pending:
-purchase schema P1s (free-qty, disc%, interstate split — one migration pass together),
-history-R repeat, customer display.
-
-**Supervisor PIN gate + override audit (P0-2 / P1-3) — done 2026-07-10.** In-flow price/
-discount overrides now raise a supervisor PIN instead of applying silently (R20, 09-UX §5.5).
-New `POST /api/auth/verify-pin` reuses the existing `attempt()` scrypt + lockout but creates
-**no session** (never pollutes `sessions`): success audits `PIN_VERIFY`, a wrong PIN audits
-`PIN_VERIFY_FAILED` and increments the same per-user lockout (5 fails → locked 60 s, E-6104),
-and only supervisor/manager/owner roles may approve — a valid-PIN cashier is rejected (E-6105).
-`POST /api/bills` gained an `overrides[]` body; `commitBill` writes one audit row per entry
-(`PRICE_OVERRIDE`/`QTY_OVERRIDE`/`DISCOUNT_OVERRIDE`, entity `bill`, before/after JSON with the
-approver resolved to a display name) **inside the existing commit transaction** (R17). POS: a
-`POS_LIMITS` const (`PRICE_BAND_PCT=10`, `DISC_WARN_PCT=10` — module consts today, DB `setting`
-rows later per R9) tops `BillingScreen`; a reusable `supervisorGate(desc)` promise renders an
-**inline** panel (never a modal) replacing the notice area — supervisor picker (bootstrap
-`users`, cashiers excluded), 4–6 digit PIN, 3 wrong PINs closes + cancels the edit;
-`verifySupervisorPin` verifies without touching the module token (the cashier stays logged in).
-F3 price: >MRP stays hard-blocked; a cashier's change (beyond ±band or any change) raises the
-gate, owner/supervisor/manager self-authorise via `gateFor` (@sims/auth). F4 discount > 10% of
-line value raises the same gate. Approved edits accumulate in an `overrides` state array (paise
-+ approvedByUserId), ride with `postBill`, and clear on commit/cart-clear. Verified 2026-07-10:
-**117 tests green**, typecheck clean on root + all three apps, all three apps build, store-server
-rebuilt + restarted. Browser E2E: Ramesh (cashier) billed Surf Excel → F3 145.00→120.00 (beyond
-10% band) → inline panel appeared (picker showed only Suresh + Thomas) → a wrong PIN showed
-"attempt 1 of 3" → Suresh PIN 8265 approved → F4 20% discount (₹24 > 10%) raised the gate again
-→ approved → F12 committed **ST1C2/26-000006** (₹96.00, engine recomputed both edits) → back
-office (thomas/sims) Audit Log shows **PRICE_OVERRIDE** `before 14500 → after 12000, approvedBy
-Suresh` and **DISCOUNT_OVERRIDE** `before 0 → after 2400, approvedBy Suresh`. Also confirmed via
-API: a cashier picked as approver is rejected (E-6105) and 5 wrong PINs lock the user (E-6104).
-Back-office polish: the Audit detail column now renders `before → after` for override rows.
-**Caveats:** override records clear on hold/cart-clear (a resumed held cart re-approves);
-`POS_LIMITS` awaits the DB settings surface (R9 follow-up); the web bundle imports the pure
-`@sims/auth/permissions` subpath because the barrel re-exports scrypt (node:crypto) — added a
-matching vite alias + tsconfig path. No git commits.
-
-**Catalog scale (S1 + S6) — done 2026-07-10.** New `@sims/search-core` (pure TS, no
-deps) replaces BillingScreen's linear name filter: `phoneticKey` folds the Indic
-spelling variants the spec names (aa→a, ee→i, oo→u, w→v, ph→f, bh→b, chh→ch, sh→s,
-th→t, dh→d, z→j, drop doubled letters), and `buildIndex`/`querySearch` rank matches in
-tiers (whole-prefix → token-prefix → phonetic → substring, shorter name breaks ties).
-The POS memoizes the index over its live item cache (grows with quick-adds); the ≤8-row
-dropdown and arrow/Enter behaviour are unchanged. S6/R13: the silent `LIMIT 2000` in
-`listItems` is gone — `GET /api/items?all=1` returns the whole non-inactive catalog for
-the POS cache (which now fetches with `?all=1`), every other path paginates
-(limit/offset, default 5000); `listBills`/`listAudit` and their routes paginate too
-(default 100), and the back-office Bills and Audit pages grew a "Load more" button.
-Verified 2026-07-10: 66/66 tests green (11 new folding/tier cases), `npm run typecheck`
-clean on root + all three apps, all three apps build, and the store-server was rebuilt
-and restarted — `/api/health` ok, `/api/items?all=1` returns the full 12-item catalog
-with no cap, limit/offset confirmed on items/bills/audit, and a phonetic query
-`ashirwad` resolves to `Aashirvaad Atta 5kg`. Integer paise, existing tests, and the
-zero-runtime-dep rule are untouched.
-
-**Print coverage (S2) — done 2026-07-10.** Three new pure modules in `@sims/printing`:
-`invoice-html.ts` (`renderInvoiceHtml` → a self-contained, printable **A4 GST tax
-invoice**: seller name/GSTIN/address header, buyer block, HSN-wise line table, a
-CGST/SGST **or** IGST + cess breakup grouped by HSN+rate, round-off, payable, and
-**amount-in-words** via a new `amountInWordsINR(paise)` domain util — integer paise,
-Indian lakh/crore numbering, singular "Paisa" — plus a bilingual English/Hindi footer);
-`labels.ts` (`ean13Svg` — standards-correct EAN-13 with L/G/R element patterns and
-first-digit parity, checksum via `@sims/scanning`'s `ean13CheckDigit`; `renderLabelSheetHtml`
-— a printable barcode/MRP label grid); and `raster.ts` (pure `packRasterRow` /
-`bitonalFromRGBA` / `rasterToEscPos` for **GS v 0** 1-bit bit-image receipts). Back
-office: **Bills rows are clickable** → the A4 invoice opens in a print-ready tab (Blob
-URL, own "Print A4" button); **Catalog → Label Printing is now LIVE** (prints labels for
-current items, columns + label size configurable); Purchase Entry offers "Print labels"
-for the received lines after a post. POS: a **"Receipt script: ASCII / Raster (Indic)"**
-setting (persisted in the printer cfg) routes the commit-time receipt through a
-canvas→GS v 0 raster path (`apps/pos/src/raster.ts`) so Devanagari/Malayalam item names
-print, sharing one `receiptLines` content model with the ASCII path. Verified 2026-07-10:
-**97 tests green** (20 new S2 cases: amountInWordsINR 0/paisa/1/lakh/crore, invoice fields
-+ amount-in-words + IGST switch, EAN-13 structure + checksum, label sheet, raster
-bit-packing), typecheck clean on root + all three apps, all three apps build, store-server
-rebuilt and restarted. Drove the back office in a real browser (thomas/sims): Bills → click
-`ST1C2/26-000001` → A4 invoice rendered with **"Rupees Six Hundred Only"**, CGST/SGST
-breakup and place-of-supply Kerala; Catalog → Label Printing → sheet rendered **7 real
-EAN-13 SVG symbols**; and the raster canvas pipeline was exercised in-browser (384-px 2"
-head, Indic glyphs drew 1,575 ink pixels via the OS font, GS v 0 header bytes correct).
-**Honest caveats:** raster receipts are unit- and canvas-verified but the physical thermal
-output needs a real 80 mm printer; canvas Indic glyphs fall back to the shop PC's OS fonts
-(Windows ships Nirmala UI) — bundled canvas webfonts are a follow-up; the demo seed
-barcodes carry placeholder check digits, so `ean13Svg` encodes stored 13-digit codes
-as-is (a strict `normalizeEan13` validator is exported for real catalog input).
-
-**B2B at the counter (S3) — done 2026-07-10.** A registered buyer's GSTIN now flows from
-attach → engine → document. New pure `deriveSupply(customer, storeState)` in `@sims/domain`
-is the single place-of-supply rule both surfaces run: no GSTIN → B2C, place of supply = the
-store (CGST/SGST); a GSTIN → B2B, place of supply = the buyer's state (its first two digits,
-or an explicit stateCode), inter-state ⇒ IGST. **Parties gained B2B identity**: `createCustomer`
-takes an optional GSTIN, **validated server-side** with `validateGstin` (bad checksum/shape
-rejected with E-1401/E-1402), the state code derived from the GSTIN when absent; the back
-office **Customers page has a GSTIN column** and the POS **F6 attach modal has an optional
-"GSTIN (B2B)" field with live checksum/state feedback**. **Billing is now place-of-supply
-aware**: the POS `computeBill` ctx uses the attached customer's state (falls back to the
-store), and `commitBill` **does not trust the client** — it re-reads the customer row, derives
-place of supply itself, recomputes, and the paisa-agreement check (R5) stays. The **immutable
-bill snapshots the buyer** (`buyer_gstin`, `place_of_supply` — added to the CREATE TABLE and
-back-filled on the running dev DB by a PRAGMA-guarded `ALTER TABLE` migrate step in `db.ts`);
-`listBills` returns them. **Documents follow**: the thermal receipt prints a buyer GSTIN line
-(IGST rows on the existing inter-state path), the A4 invoice buyer block fills GSTIN +
-place-of-supply from the bill (closing the S2 caveat), and the POS totals panel shows an
-**"IGST · inter-state"** badge plus a B2B chip on the customer strip. Verified 2026-07-10:
-**110 tests pass** (4 new: `deriveSupply` no-GSTIN/intra/inter, B2B receipt buyer line),
-typecheck clean on root + all three apps, all three apps build, store-server rebuilt and
-restarted (`/api/health` ok, migration added both columns to the 3-bill dev DB). Drove both
-UIs in a real browser: POS login (Ramesh/4728, Skip count) → F6 → created **Anand Traders**
-(phone 9876543210) with Maharashtra GSTIN **`27ABCDE1234F1Z0`** (body `27ABCDE1234F1Z` +
-`gstinCheckDigit` = `0`) → the modal showed "place of supply state 27 — GSTIN valid", the
-strip showed the B2B chip, the totals showed **IGST · inter-state** → billed Surf Excel
-(GST ₹22.12 as IGST) → committed **ST1C2/26-000004**. Server row confirms `buyer_gstin`,
-`place_of_supply='27'`, `igst_paise=2212`, `cgst=sgst=0`, payable ₹145.00 (client/server
-agreed to the paisa). Back office → Bills → the row opened its A4 **Tax Invoice** with
-**Billed To Anand Traders / GSTIN 27ABCDE1234F1Z0 / State 27 — Maharashtra**, Place of Supply
-**"27 — Maharashtra"**, and an **IGST** tax row (no CGST/SGST); Customers shows the GSTIN column.
-**Honest caveats:** the buyer GSTIN receipt line is unit-tested only (no physical printer here);
-the seed store's state 32 resolves as "Kerala" in the invoice state map though it is named
-T.Nagar — a seed-data quirk unrelated to S3; the e-Invoice/IRN (GSP) hook remains P2 (S3 stops
-at the document, per doc 17 §S3). Unrelated pre-existing failure: `apps/hq/test/gmail.test.ts`
-imports a not-yet-added `../src/gmail` (HQ-console in-flight work, untouched by S3).
-
-**Counter resilience / offline queue (S4) — done 2026-07-10.** A browser POS now
-survives the store-server dying mid-shift: billing continues, nothing is lost, the queue
-drains on reconnect (02-ARCHITECTURE §3, R18 cart-never-lost). New pure
-`apps/pos/src/offline.ts` (**raw IndexedDB, zero new deps**): when `postBill` fails with a
-**network** error — the server unreachable, distinguished from a server that *answered*
-with a 4xx/5xx (which still just keeps the cart on screen) via a new `NetworkError`
-type — the sale is persisted to an IndexedDB `bills` store with a **client UUIDv7** id and
-a provisional token **OFF-n**, the cart clears, and billing continues; the receipt prints
-**"PROVISIONAL OFF-n (offline)"** instead of a GST doc no, and an amber
-**"OFFLINE · n queued"** chip shows in the status bar. A background **reconnect drain**
-(every 15 s ping `/api/health`, also on the window `online` event) POSTs queued bills
-**oldest-first** to a new **idempotent `POST /api/bills/offline`**: the client bill id is the
-idempotency key, stored on a new `bill.client_id` column (PRAGMA-guarded `ALTER` + a
-partial `CREATE UNIQUE INDEX … (tenant_id, client_id) WHERE client_id IS NOT NULL`), and
-`commitBill` short-circuits on a known client id, so a **re-drained payload returns the
-already-assigned doc no — never a second bill or stock movement**. The **real GST series
-number is allocated at drain time** and engine re-verification (R5) still runs; the POS
-maps each **OFF-n → the real doc no** in a notice and deletes it from IDB. **The drain
-endpoint is authenticated** (security review, SEC-1): it sits below `requireAuth` and takes
-tenant + cashier from the cashier's **live session**, never from the body — an earlier
-session-independent variant that trusted a body `cashierId` was an unauthenticated
-bill-commit hole and was removed. If the reconnected server has restarted (its in-memory
-sessions gone) the drain simply **401s and the queue waits until the counter re-logs in** —
-nothing is lost; a transient blip that leaves the process alive drains seamlessly. **Override
-approvals are unforgeable** (security review, SEC-2): `/auth/verify-pin` now mints a
-**single-use token** (new `override_approval` table) that the POS sends with each override
-(`approvalId`, replacing the old client-supplied `approvedByUserId`); `commitBill` redeems
-it **inside the commit transaction** (must be unused, < 5 min old, approver still holds a
-supervisor/manager/owner role), marks it used, writes the audit row from the **server-side**
-approver identity, and **rejects the whole commit** (rolls back) on any mismatch — the client
-can no longer claim an approver it never got. **Item-cache persistence:**
-the catalog (items + tax classes + store/users) is saved to IndexedDB at login and the
-session token to `sessionStorage`, so a **mid-shift browser refresh with the server down
-resumes straight to billing** behind an **"OFFLINE — using cached catalog"** banner (a
-fresh login still needs the server — a locally-cached PIN-hash login is deliberately out
-of scope). **App shell:** a minimal cache-first service worker (`apps/pos/public/sw.js`,
-registered in `main.tsx`, scope `/pos/`) serves the built assets from cache when the
-server is down; `/api/*` is **never** cached, so the queue + drain logic stay in control.
-Verified 2026-07-10: **164 tests green** (6 offline-serialization cases —
-`nextProvisionalToken` / `buildQueuedBill` / `toOfflineBillBody` — plus 4 new server-side
-`override_approval` security cases: fabricated / replayed / non-approver-role tokens and a
-missing-approval override are each rejected, a genuine token audits the server-side
-approver), typecheck clean on root + all three apps, all three apps build, store-server
-rebuilt and restarted (migration added `bill.client_id` + the partial unique index + the
-`override_approval` table to the running dev DB). **Full browser E2E:** Ramesh/4728 → billed Surf Excel online (**ST1C2/26-000007**)
-→ **killed the store-server** → billed Parle-G then Maggi (each showed **"saved OFF-n"**, the
-amber **OFFLINE · n queued** chip, cart cleared; both persisted to IndexedDB with valid
-uuidv7 ids) → **restarted the server** → the drain mapped **OFF-1 → ST1C2/26-000008** and
-**OFF-2 → ST1C2/26-000009**; back-office **Bills** shows both with real doc nos and Parle-G
-carries exactly one `SALE -1` movement (**no duplication**). **Idempotency proven** by
-re-POSTing a drained payload manually — the server returned the same doc no with
-`duplicate:true`, bill count unchanged. **Offline resume proven** by killing the server and
-reloading `/pos/` — the app shell loaded from the SW cache and resumed to the billing
-screen with the cached catalog + banner (quick keys populated from IndexedDB, no server).
-**Security-review fixes verified E2E:** an unauthenticated `POST /api/bills/offline` now
-returns **401**; after a full restart the stale-session drain 401s, the queue **holds OFF-n**
-(nothing lost) and the POS shows a **"Sign in to sync N offline bills"** prompt, then after
-**re-login** the authenticated drain committed the queued bill (e.g. **OFF-4 →
-ST1C2/26-000015**) with `cashier_id` taken from the session, not the body. For
-overrides, a **fabricated** approvalId and a **replayed** (already-used) approvalId are each
-rejected **403** with the whole commit rolled back and **no doc-series gap**, a **cashier**
-PIN yields no token (403), while a genuine **Suresh/8265** approval committed and audited
-from the server-side identity. **Gate E2E (approvalId path):** F3 price change ₹145.00 →
-₹120.00 (beyond the ±10 % band) as Ramesh raised the inline supervisor panel → **Suresh/8265
-approved** → committed **ST1C2/26-000012** at ₹120.00, and the **Audit** row records
-**PRICE_OVERRIDE** with `approvedBy: Suresh` / `approvedByUserId: u2` **read server-side from
-the redeemed token** (by Ramesh), plus the matching PIN_VERIFY. **Honest caveats:** the
-reconnect drain needs a live session, so after a full server *restart* the queue waits for
-the counter to **re-log in** before it drains (a transient blip that leaves the process alive
-drains seamlessly) — and a new online bill likewise needs a fresh login after a restart (the
-401 keeps the cart and does **not** queue; only true network failures queue); an override
-approval token is valid for **5 minutes**, so in the rare race where a supervisor approves
-online and the server dies before commit, that one queued bill's override would expire before
-drain (in practice offline bills carry no overrides — the gate itself needs the server up);
-the service worker precaches only the navigation root on install, so the full shell caches on
-the first *controlled* load (one online reload) and a new deploy is seen only after the SW's
-`CACHE` version bumps; provisional receipts can't physically print while the server (the
-print relay) is itself down.
-
-**Batch & expiry (S5) — done 2026-07-10.** Perishables now carry lots from purchase to
-counter to dashboard, with FEFO picking and an expired-sale guard. New pure
-`@sims/domain/batch.ts` (`sortFefo` earliest-expiry-first/undated-last, `isExpired`,
-`pickFefoBatch` = earliest **non-expired** lot else earliest) is the single FEFO rule both
-surfaces and the tests share (ISO dates compare lexicographically — no Date parsing).
-**Schema (R7 preserved):** a new `batch` table (uuidv7 id, tenant/store/item, batch_no,
-**nullable** expiry_date, UNIQUE(tenant,store,item,batch_no)); `stock_movement` and
-`purchase_line` gained a nullable `batch_id` (CREATE TABLE additions + a PRAGMA-guarded
-`migrate()` for the running dev DB — the `idx_move_batch` index lives in `migrate()` only,
-after the ALTER, so it never races the SCHEMA pass on an upgraded DB). **Per-batch on-hand
-stays derived** — `SUM(movements WHERE batch_id=lot)`, never stored. **Seed/migrate**
-marks Amul Butter (107) and Maggi (105) `batch_tracked` and, if no batch exists, splits
-each item's opening 120 into lots (one **expired-with-stock**, one **≤7-day**, one **~6-month**
-for Amul; ≤7-day + ~6-month for Maggi) via per-lot OPENING movements **offset by one
-negative un-batched OPENING adjustment** so total on-hand is unchanged (idempotent; runs
-from `seedIfEmpty` for fresh DBs and from `server.ts` for the already-populated dev DB).
-**Purchase capture:** for a batch-tracked item the Enter chain extends qty→cost→**batch
-no→expiry** (date input; blank Enter skips expiry for non-perishables) →scan; the server
-`upsertBatch`es the lot (unique key or new, filling a later expiry) and carries `batch_id`
-onto the purchase line and the stock-IN movement. **POS FEFO pick (R18 — never modal):**
-`GET /api/batches` (per-batch on-hand>0, item-keyed) is cached at login and refreshed after
-each commit; adding a batch-tracked item raises an **inline** picker, FEFO-sorted with the
-first sellable lot preselected (↑/↓ move, Enter accepts), **expired lots flagged red and
-blocked with E-1310** unless approved — a supervisor+ sells on their own authority, a
-cashier raises the existing `supervisorGate`, and the approval rides as a new `kind:'batch'`
-override audited **BATCH_OVERRIDE** server-side (single-use token, same SEC path as price/
-discount). The committed line carries `batchId`; `commitBill` writes the movement's
-`batch_id` and **rejects (rolls back) any batch that doesn't belong to the item+store
-(E-1311)**. **Expiry dashboard is LIVE** (replaces the registry wireframe via the CUSTOM
-map): `GET /api/expiry` feeds ≤7-day and ≤30-day buckets with per-batch on-hand + value at
-sale price, an expired-with-stock section, and a dead-stock note; the Stock page gained a
-batch-tracked second table + a "batch" tag. **Verified 2026-07-10: 178 tests green** (6 new
-FEFO domain cases + 8 server batch cases: seed totals-preserved/idempotent, purchase upsert
-+ reuse, batch-attributed decrement, E-1311 belongs-check, BATCH_OVERRIDE audit), typecheck
-clean on root + all three apps, all three apps build, store-server rebuilt and restarted
-(migration added the batch table + `batch_id` columns + demo lots to the running dev DB;
-totals held — Amul 120, Maggi 119). **Full browser E2E (Puppeteer, real Chromium):** back
-office (thomas/sims) → Purchase Entry → Amul Butter, Enter chain captured **batch B-TEST /
-expiry 2026-07-17**, F12 **posted**; Expiry Dashboard rendered live — **≤7-day ₹6,350 / 3
-batches** (B-105A ₹1,400, B-107A ₹3,300, B-TEST ₹1,650) and **Expired-with-stock ₹2,200 /
-1 batch** (B-107X). POS (Ramesh/4728, Skip count) → billed Amul Butter → the **inline FEFO
-picker** listed B-107X (EXPIRED, red) · **B-107A (FEFO-preselected, exp 2026-07-15)** ·
-B-TEST · B-107B → Enter committed **ST1C2/26-000016** and **B-107A on-hand fell 12→11**
-(per-batch, not item-wide); a second bill navigated (↑) to the expired **B-107X** → the
-inline gate showed **"batch B-107X EXPIRED 2026-07-07 — approve sale? (E-1310)"** with
-Suresh/Thomas as approvers → cancelled, so B-107X stayed at 8 (never sold). **Honest
-caveats:** the server validates a batch belongs to the item+store but does **not** itself
-re-block expired sales — the E-1310 gate is POS-side with a BATCH_OVERRIDE audit trail
-(server-side expiry enforcement is a follow-up); the batch cache is fetched in the billing
-screen at mount (effectively login) and after each commit, but is **not** written to the S4
-offline IndexedDB cache, so a batch-tracked sale made while the server is down bills
-un-batched with a warning; the demo dead-stock note flags by expiry only (velocity-based
-slow-mover ranking is a follow-up); the chrome-devtools MCP was unusable this session (its
-browser handle wedged into a "browser already running" state that survived killing every
-Chrome + the profile lock and needs an MCP-server restart), so the browser E2E was driven
-via Puppeteer against the same running store-server instead. No git commits.
-
-**Load verification (S7) — done 2026-07-10.** The 15-EDITIONS scale target is now
-verified by load test, not assumed (R25). A scripted run (`apps/store-server/load/loadtest.mjs`,
-plain node 22 + better-sqlite3, no new deps) drove **2,500 real bill commits distributed
-across 10 counters** at a concurrency of 10 (one in-flight request per counter) against a
-**second, isolated store-server instance on :5281 with a fresh data dir** — the dev DB and
-the running :5181 server were never touched (new `SIMS_DATA_DIR` env in `db.ts` points the
-isolated instance at its own data dir; the seed makes C1/C2, so C3–C10 were added as real
-counter rows with their own per-counter GST series). Each bill was a randomized 1–5-line
-cart from the seeded catalog (the 2 batch-tracked items skipped); the client payable is
-`roundToRupee(Σ qty·price)` — exact for tax-inclusive, no-discount bills — and the server
-still re-verified every document to the paisa with the shared engine (R5). A burst phase
-then pushed 100 bills at concurrency 50 to find the knee.
-
-**Environment (single dev machine — not a distributed benchmark):**
-
-| Field | Value |
-|---|---|
-| Node | v22.20.0 |
-| CPU | 12× 13th Gen Intel Core i5-1335U |
-| RAM | 15.69 GB |
-| OS | Windows 11 (10.0.26200) |
-| Target | one store-server process, isolated data dir, port 5281 |
-
-**Results:**
-
-| Phase | Bills | Concurrency | Success | Wall | Throughput | p50 | p95 | p99 | max |
-|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|
-| Steady | 2,500 | 10 (1/counter) | 2,500 (100%) | 8.64 s | **289.4 bills/s** | 28.8 ms | 54.6 ms | 76.0 ms | 397.6 ms |
-| Burst | 100 | 50 | 100 (100%) | 1.16 s | 85.9 bills/s | 445.9 ms | 737.4 ms | 746.5 ms | 748.7 ms |
-
-**Integrity (queried directly against the load DB after the run):**
-
-| # | Check | Result |
-|---|---|---|
-| a | bill rows == successful commits | **PASS** (2,600 == 2,600) |
-| b | every counter's SALE series gap-free & duplicate-free | **PASS** (c1–c10 each `1..260`) |
-| c | stock derived == opening − billed, per item (R7) | **PASS** (9 items reconciled; 7,800 SALE movements == 7,800 billed lines) |
-| d | every bill's payments sum == payable | **PASS** (2,600 bills, 0 mismatch) |
-| e | audit `BILL_COMMIT` count == bill count (R17) | **PASS** (2,600 == 2,600) |
-| f | outbox rows == bill count (R15) | **PASS** (2,600 == 2,600) |
-
-**Honest interpretation.** Steady-state throughput on this dev laptop is **~289 committed
-bills/sec** with a sub-30 ms median. Reading the S7 target at its heaviest (10 counters ×
-2,500 = 25,000 bills/day), the entire day's work is **~86 s of server-busy time** spread
-across a full trading day — 2–3 orders of magnitude of headroom; the lighter reading (2,500
-bills/day for the store) completes in **8.6 s**. `better-sqlite3` serializes writes in-process
-(one synchronous commit at a time), so throughput is bounded by single-core commit cost
-(~3.5 ms/bill) and concurrency buys latency, not throughput: the burst at concurrency 50
-inflated p50 latency ~15× (29 ms → 446 ms) **without** raising throughput (it measured lower),
-so the knee is well below 50 — the realistic 10-counter operating point already sits near the
-single-write-core ceiling with a wide latency margin. Correctness held perfectly under load:
-zero failed commits, and all six integrity invariants (no series gaps/dups across 10
-independent counters, exact stock reconciliation, payments == payable, one audit + one outbox
-row per bill) passed on 2,600 bills. Incidentally confirmed the 2-digit counter code C10 stays
-within the GST 16-char doc-no rule at 6-digit sequences (`ST1C10/26-000260`). These are
-single-machine dev-hardware numbers; a store-server on real store hardware and the future
-Oracle 12c adapter (D12) will have different constants, but the serialization model and the
-integrity guarantees are the same. No git commits; typecheck clean on the one TS file touched
-(`db.ts`), `:5281` instance killed after the run.
-
-## Purchase Entry — LIVE (the second crown jewel)
-
-`/purchases/entry`: supplier type-ahead → invoice no/date → scan-grammar line entry
-(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
-```
diff --git a/apps/backoffice/README.md b/apps/backoffice/README.md
deleted file mode 100644
index d0ae6d9..0000000
--- a/apps/backoffice/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# apps/backoffice — Back Office (React SPA)
-
-Masters, purchases, inventory, reports, GST, Admin (users/roles, settings hierarchy,
-stores/counters). Served by store-server locally in v1; the same build runs from the
-cloud tier later. Menu tree and page anatomy: docs/09-UX-FLOWS-AND-MENUS.md §2.
diff --git a/apps/backoffice/index.html b/apps/backoffice/index.html
deleted file mode 100644
index 15c0faa..0000000
--- a/apps/backoffice/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- SiMS Next — Back Office
-
-
-
-
-
-
diff --git a/apps/backoffice/package.json b/apps/backoffice/package.json
deleted file mode 100644
index b41e1f5..0000000
--- a/apps/backoffice/package.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "name": "@sims/backoffice",
- "version": "0.1.0",
- "private": true,
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "typecheck": "tsc -p tsconfig.json"
- },
- "dependencies": {
- "@sims/billing-engine": "*",
- "@sims/domain": "*",
- "@sims/printing": "*",
- "@sims/scanning": "*",
- "@sims/ui": "*",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "react-router-dom": "^7.0.0"
- },
- "devDependencies": {
- "@types/react": "^19.0.0",
- "@types/react-dom": "^19.0.0",
- "@vitejs/plugin-react": "^4.3.0",
- "vite": "^6.0.0"
- }
-}
diff --git a/apps/backoffice/src/Layout.tsx b/apps/backoffice/src/Layout.tsx
deleted file mode 100644
index f077201..0000000
--- a/apps/backoffice/src/Layout.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { useEffect, useRef } from 'react'
-import { NavLink, Outlet } from 'react-router-dom'
-import { Badge, ThemeSwitcher } from '@sims/ui'
-import { NAV, isLocked } from './nav'
-
-export function Layout(props: { userName: string; onLogout: () => void }) {
- const searchRef = useRef(null)
-
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- if (e.ctrlKey && e.key.toLowerCase() === 'k') {
- e.preventDefault()
- searchRef.current?.focus()
- }
- }
- window.addEventListener('keydown', onKey)
- return () => window.removeEventListener('keydown', onKey)
- }, [])
-
- return (
-
-
-
- SiMS Next
- Back Office · wireframe
-
- {NAV.map((mod) => (
-
-
{mod.icon} {mod.label}
- {mod.pages.map((p) => (
-
(isActive ? 'active' : '')} end={p.path === '/'}>
- {p.label}
- {isLocked(p) && 🔒 {p.edition} }
-
- ))}
-
- ))}
-
-
-
- )
-}
diff --git a/apps/backoffice/src/Login.tsx b/apps/backoffice/src/Login.tsx
deleted file mode 100644
index 95bd252..0000000
--- a/apps/backoffice/src/Login.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { useState } from 'react'
-import { Button, Field, Notice } from '@sims/ui'
-import { login } from './api'
-
-/** Back-office login — server-side scrypt verification + lockout. */
-export function Login(props: { onLogin: (name: string) => void }) {
- const [user, setUser] = useState('thomas')
- const [pw, setPw] = useState('')
- const [busy, setBusy] = useState(false)
- const [error, setError] = useState()
-
- const submit = async () => {
- if (busy) return
- setBusy(true)
- setError(undefined)
- try {
- props.onLogin(await login(user.trim(), pw))
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err))
- setBusy(false)
- }
- }
-
- return (
-
- )
-}
diff --git a/apps/backoffice/src/api.ts b/apps/backoffice/src/api.ts
deleted file mode 100644
index ca6cad3..0000000
--- a/apps/backoffice/src/api.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/** Back office ↔ store-server client; session token survives reloads. */
-import type { Gstr1, Gstr3b, HsnRow, Reconciliation, TaxAmounts } from '@sims/billing-engine'
-
-let token = sessionStorage.getItem('bo.token') ?? ''
-
-export function hasSession(): boolean {
- return token !== ''
-}
-
-export function clearSession(): void {
- token = ''
- sessionStorage.removeItem('bo.token')
-}
-
-async function call(path: string, init?: RequestInit): Promise {
- const res = await fetch(`/api${path}`, {
- ...init,
- headers: {
- 'content-type': 'application/json',
- ...(token !== '' ? { authorization: `Bearer ${token}` } : {}),
- },
- }).catch(() => undefined)
- if (res === undefined) throw new Error('Store-server unreachable (E-3301)')
- if (res.status === 401) clearSession()
- const body = (await res.json().catch(() => ({}))) as T & { error?: string }
- if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`)
- return body
-}
-
-export async function login(username: string, password: string): Promise {
- const s = await call<{ token: string; displayName: string }>('/auth/login', {
- method: 'POST',
- body: JSON.stringify({ username, password }),
- })
- token = s.token
- sessionStorage.setItem('bo.token', s.token)
- return s.displayName
-}
-
-/** End the session server-side (M4) then clear it locally. Best-effort on the network call. */
-export async function logout(): Promise {
- try { await call('/auth/logout', { method: 'POST' }) } catch { /* server down: clear locally anyway */ }
- clearSession()
-}
-
-export const getItems = (q?: string): Promise[]> =>
- call(`/items${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`)
-export const createItem = (body: Record): Promise> =>
- call('/items', { method: 'POST', body: JSON.stringify(body) })
-export const getParties = (kind?: string): Promise[]> =>
- call(`/parties${kind !== undefined ? `?kind=${kind}` : ''}`)
-export const getBills = (date?: string, limit?: number, offset?: number): Promise[]> => {
- const qs = new URLSearchParams()
- if (date !== undefined) qs.set('date', date)
- if (limit !== undefined) qs.set('limit', String(limit))
- if (offset !== undefined) qs.set('offset', String(offset))
- return call(`/bills${qs.toString() !== '' ? `?${qs}` : ''}`)
-}
-export const getStock = (): Promise[]> => call('/stock')
-/** Credit notes (SALE_RETURN docs) — tenant-scoped, paginated; feeds the Returns page. */
-export const getReturns = (limit?: number, offset?: number): Promise[]> => {
- const qs = new URLSearchParams()
- if (limit !== undefined) qs.set('limit', String(limit))
- if (offset !== undefined) qs.set('offset', String(offset))
- return call(`/returns${qs.toString() !== '' ? `?${qs}` : ''}`)
-}
-/** S5: per-batch on-hand + value inputs joined to the item — feeds the expiry
- * dashboard and the Stock page's batch breakdown. */
-export const getExpiry = (): Promise[]> => call('/expiry')
-export const getAudit = (limit?: number, offset?: number): Promise[]> => {
- const qs = new URLSearchParams()
- if (limit !== undefined) qs.set('limit', String(limit))
- if (offset !== undefined) qs.set('offset', String(offset))
- return call(`/audit${qs.toString() !== '' ? `?${qs}` : ''}`)
-}
-
-export const getPurchases = (): Promise[]> => call('/purchases')
-export const getLastCosts = (supplierId?: string): Promise> =>
- call(`/purchases/last-costs${supplierId !== undefined ? `?supplierId=${supplierId}` : ''}`)
-export const postPurchase = (body: Record): Promise<{ id: string; totalPaise: number }> =>
- call('/purchases', { method: 'POST', body: JSON.stringify(body) })
-export const getCostHistory = (itemId: string, supplierId?: string): Promise<{ cost: number; date: string; supplier: string }[]> =>
- call(`/purchases/cost-history?itemId=${itemId}${supplierId !== undefined ? `&supplierId=${supplierId}` : ''}`)
-export const getBootstrapPublic = (): Promise<{ taxClasses: { classCode: string; ratePctBp: number; cessPctBp: number; effectiveFrom: string; effectiveTo?: string }[] }> =>
- call('/bootstrap?counter=C1')
-
-export interface Seller { id: string; name: string; storeCode: string; stateCode: string; gstin?: string }
-/** Seller header for GST invoices/labels — store name, GSTIN, state (from bootstrap). */
-export const getSeller = (): Promise =>
- call<{ store: Seller }>('/bootstrap?counter=C1').then((b) => b.store)
-
-// GST returns — READ-ONLY reporting over a period's immutable documents. The server folds them
-// through the shared engine and returns each section plus a reconciliation proof (see repos-gst).
-export interface Gstr1Response extends Gstr1 { period: string; storeStateCode: string; reconciliation: Reconciliation }
-export interface Gstr3bResponse extends Gstr3b { period: string; storeStateCode: string; reconciliation: Reconciliation }
-export interface HsnResponse { period: string; storeStateCode: string; rows: HsnRow[]; totals: TaxAmounts; reconciliation: Reconciliation }
-
-export const getGstr1 = (period: string): Promise => call(`/gst/gstr1?period=${encodeURIComponent(period)}`)
-export const getGstr3b = (period: string): Promise => call(`/gst/gstr3b?period=${encodeURIComponent(period)}`)
-export const getGstHsn = (period: string): Promise => call(`/gst/hsn?period=${encodeURIComponent(period)}`)
-/** The pragmatic GSTN-shaped JSON export (documented in-payload as NOT a certified filing). */
-export const getGstr1Json = (period: string): Promise> => call(`/gst/gstr1.json?period=${encodeURIComponent(period)}`)
diff --git a/apps/backoffice/src/app.css b/apps/backoffice/src/app.css
deleted file mode 100644
index 013beee..0000000
--- a/apps/backoffice/src/app.css
+++ /dev/null
@@ -1,30 +0,0 @@
-.bo-shell { display: grid; grid-template-columns: 230px 1fr; height: 100vh; }
-.bo-side { background: var(--bg-raised); border-right: 1px solid var(--border); overflow-y: auto; padding: 12px 0; }
-.bo-brand { font-weight: 700; font-size: 16px; padding: 4px 16px 12px; }
-.bo-brand small { display: block; color: var(--text-dim); font-weight: 400; }
-.bo-module { margin: 10px 0 2px; padding: 0 16px; color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
-.bo-side a {
- display: flex; align-items: center; gap: 8px; padding: 6px 16px; color: var(--text);
- text-decoration: none; font-size: 13.5px;
-}
-.bo-side a:hover { background: var(--bg-hover); }
-.bo-side a.active { background: var(--bg-hover); box-shadow: inset 3px 0 0 var(--accent); }
-.bo-side a .lock { margin-left: auto; font-size: 11px; color: var(--warn); }
-.bo-main { display: flex; flex-direction: column; min-width: 0; }
-.bo-top {
- display: flex; gap: 12px; align-items: center; padding: 8px 20px;
- border-bottom: 1px solid var(--border); background: var(--bg-raised);
-}
-.bo-top input { max-width: 380px; }
-.bo-content { overflow-y: auto; flex: 1; }
-.upsell {
- border: 1px solid var(--warn); border-radius: var(--radius); padding: 24px; margin: 16px 0;
- background: color-mix(in srgb, var(--warn) 6%, var(--bg-raised));
-}
-.inbox-split { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 12px; }
-.inbox-doc {
- background: var(--bg-raised); border: 1px dashed var(--border); border-radius: var(--radius);
- padding: 18px; font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; color: var(--text-dim);
-}
-.matrix td:first-child { white-space: nowrap; }
-.matrix .cell { text-align: center; }
diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx
deleted file mode 100644
index 98e036c..0000000
--- a/apps/backoffice/src/main.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { useState } from 'react'
-import { createRoot } from 'react-dom/client'
-import { HashRouter, Route, Routes } from 'react-router-dom'
-import '@fontsource-variable/inter'
-import '@fontsource-variable/jetbrains-mono'
-import '../../../packages/ui/src/tokens.css'
-import './app.css'
-import { initTheme } from '@sims/ui'
-import { logout } from './api'
-import { Layout } from './Layout'
-import { Login } from './Login'
-import { Dashboard } from './pages/Dashboard'
-import { GenericPage } from './pages/GenericPage'
-import { PurchaseInbox } from './pages/PurchaseInbox'
-import { SettingsPage } from './pages/SettingsPage'
-import { UsersRoles } from './pages/UsersRoles'
-import { AuditPage, BillsPage, CustomersPage, ExpiryPage, ItemsPage, ReturnsPage, StockPage } from './pages/live'
-import { Gstr1Page, Gstr3bPage, HsnPage } from './pages/gst'
-import { LabelsPage } from './pages/labels'
-import { PurchaseEntryPage, PurchasesListPage } from './pages/purchase-entry'
-import { PAGES } from './pages/registry'
-
-/** Custom/live pages override registry configs; everything else renders generically. */
-const CUSTOM: Record React.JSX.Element> = {
- '/purchases/inbox': PurchaseInbox,
- '/admin/settings': SettingsPage,
- '/admin/users': UsersRoles,
- '/catalog/items': ItemsPage,
- '/catalog/labels': LabelsPage,
- '/sales/customers': CustomersPage,
- '/sales/bills': BillsPage,
- '/sales/returns': ReturnsPage,
- '/inventory/stock': StockPage,
- '/inventory/expiry': ExpiryPage,
- '/gst/gstr1': Gstr1Page,
- '/gst/gstr3b': Gstr3bPage,
- '/gst/hsn': HsnPage,
- '/admin/audit': AuditPage,
- '/purchases/entry': PurchaseEntryPage,
- '/purchases/list': PurchasesListPage,
-}
-
-function App() {
- const [userName, setUserName] = useState()
- if (userName === undefined) return
- return (
-
-
- { void logout(); setUserName(undefined) }} />}>
- } />
- {Object.entries(CUSTOM).map(([path, Page]) => (
- } />
- ))}
- {PAGES.filter((p) => !(p.path in CUSTOM)).map((p) => (
- } />
- ))}
- } />
-
-
-
- )
-}
-
-initTheme()
-createRoot(document.getElementById('root')!).render( )
diff --git a/apps/backoffice/src/nav.ts b/apps/backoffice/src/nav.ts
deleted file mode 100644
index c29bd45..0000000
--- a/apps/backoffice/src/nav.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * The back-office menu tree (09-UX §2). Editions gate visibility: locked pages
- * render as discoverable upsells, never hidden (09-UX §5.5). This is data on
- * purpose — in production it comes from the DB with role filtering applied.
- */
-export type Edition = 'L' | 'S' | 'P' | 'E'
-
-export interface NavPage {
- path: string
- label: string
- edition: Edition
-}
-
-export interface NavModule {
- key: string
- label: string
- icon: string
- pages: NavPage[]
-}
-
-export const NAV: NavModule[] = [
- {
- key: 'dashboard', label: 'Dashboard', icon: '◧',
- pages: [{ path: '/', label: 'Overview', edition: 'L' }],
- },
- {
- key: 'catalog', label: 'Catalog', icon: '⌘',
- pages: [
- { path: '/catalog/items', label: 'Items', edition: 'L' },
- { path: '/catalog/categories', label: 'Categories', edition: 'L' },
- { path: '/catalog/price-lists', label: 'Price Lists', edition: 'S' },
- { path: '/catalog/schemes', label: 'Schemes & Offers', edition: 'S' },
- { path: '/catalog/labels', label: 'Label Printing', edition: 'S' },
- ],
- },
- {
- key: 'sales', label: 'Sales', icon: '₹',
- pages: [
- { path: '/sales/bills', label: 'Bills', edition: 'L' },
- { path: '/sales/returns', label: 'Returns & Credit Notes', edition: 'L' },
- { path: '/sales/customers', label: 'Customers', edition: 'L' },
- { path: '/sales/khata', label: 'Khata & Collections', edition: 'L' },
- { path: '/sales/loyalty', label: 'Loyalty', edition: 'P' },
- ],
- },
- {
- key: 'purchases', label: 'Purchases', icon: '⇩',
- pages: [
- { path: '/purchases/list', label: 'Purchase List', edition: 'L' },
- { path: '/purchases/entry', label: 'Purchase Entry', edition: 'L' },
- { path: '/purchases/inbox', label: 'Purchase Inbox', edition: 'P' },
- { path: '/purchases/suppliers', label: 'Suppliers', edition: 'L' },
- { path: '/purchases/gstr2b', label: 'GSTR-2B Reconciliation', edition: 'P' },
- ],
- },
- {
- key: 'inventory', label: 'Inventory', icon: '▤',
- pages: [
- { path: '/inventory/stock', label: 'Stock', edition: 'L' },
- { path: '/inventory/adjustments', label: 'Adjustments', edition: 'L' },
- { path: '/inventory/transfers', label: 'Transfers', edition: 'E' },
- { path: '/inventory/stock-take', label: 'Stock Take', edition: 'P' },
- { path: '/inventory/expiry', label: 'Expiry Dashboard', edition: 'P' },
- ],
- },
- {
- key: 'accounting', label: 'Accounting', icon: '≡',
- pages: [
- { path: '/accounting/vouchers', label: 'Vouchers', edition: 'S' },
- { path: '/accounting/ledgers', label: 'Ledgers', edition: 'L' },
- { path: '/accounting/day-book', label: 'Day Book', edition: 'S' },
- { path: '/accounting/pnl', label: 'P&L / Balance Sheet', edition: 'P' },
- { path: '/accounting/tally', label: 'Tally Export', edition: 'S' },
- ],
- },
- {
- key: 'gst', label: 'GST', icon: '§',
- pages: [
- { path: '/gst/gstr1', label: 'GSTR-1', edition: 'S' },
- { path: '/gst/gstr3b', label: 'GSTR-3B', edition: 'S' },
- { path: '/gst/einvoice', label: 'e-Invoice Queue', edition: 'P' },
- { path: '/gst/eway', label: 'e-Way Bills', edition: 'P' },
- { path: '/gst/hsn', label: 'HSN Summary', edition: 'L' },
- ],
- },
- {
- key: 'reports', label: 'Reports', icon: '◔',
- pages: [
- { path: '/reports/sales', label: 'Sales Reports', edition: 'L' },
- { path: '/reports/margins', label: 'Margins', edition: 'S' },
- { path: '/reports/stock-aging', label: 'Stock Aging', edition: 'S' },
- { path: '/reports/day-end', label: 'Day-End Digest', edition: 'L' },
- ],
- },
- {
- key: 'admin', label: 'Admin', icon: '⚙',
- pages: [
- { path: '/admin/users', label: 'Users & Roles', edition: 'S' },
- { path: '/admin/stores', label: 'Stores & Counters', edition: 'L' },
- { path: '/admin/settings', label: 'Settings', edition: 'L' },
- { path: '/admin/templates', label: 'Print Templates', edition: 'L' },
- { path: '/admin/integrations', label: 'Integrations', edition: 'L' },
- { path: '/admin/subscription', label: 'Subscription & Plan', edition: 'L' },
- { path: '/admin/audit', label: 'Audit Log', edition: 'S' },
- { path: '/admin/sync', label: 'Sync & Devices', edition: 'L' },
- { path: '/admin/backups', label: 'Backups', edition: 'L' },
- { path: '/admin/import', label: 'Data Import', edition: 'L' },
- ],
- },
-]
-
-/** The wireframe tenant runs Pro; Enterprise pages render as upsells. */
-export const CURRENT_EDITION: Edition = 'P'
-export const EDITION_ORDER: Edition[] = ['L', 'S', 'P', 'E']
-
-export function isLocked(page: NavPage): boolean {
- return EDITION_ORDER.indexOf(page.edition) > EDITION_ORDER.indexOf(CURRENT_EDITION)
-}
diff --git a/apps/backoffice/src/pages/Dashboard.tsx b/apps/backoffice/src/pages/Dashboard.tsx
deleted file mode 100644
index f625321..0000000
--- a/apps/backoffice/src/pages/Dashboard.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Badge, DataTable, PageHeader, StatCard, Stats } from '@sims/ui'
-
-export function Dashboard() {
- return (
-
-
-
-
-
-
-
-
-
-
-
Alerts
-
high, what: 'Coca-Cola 1.25L out of stock, weekend ahead', where: 'ST1', age: '2 h' },
- { sev: med , what: 'ST2-C1 sync lag 22 min (LAN?)', where: 'ST2', age: '25 min' },
- { sev: med , what: '17 draft items from POS quick-add need completion', where: 'Catalog', age: 'today' },
- { sev: med , what: 'e-Invoice GSP error on ST1C1/26-000355', where: 'GST', age: '3 h' },
- { sev: info , what: 'Onam scheme starts 20 Aug — stock plan?', where: 'Schemes', age: '—' },
- ]}
- />
- Counters now
- billing },
- { counter: 'ST1-C2', cashier: 'Ramesh', bills: '214', last: '18:42', state: billing },
- { counter: 'ST2-C1', cashier: 'Manu', bills: '86', last: '18:20', state: sync lag },
- ]}
- />
-
- )
-}
diff --git a/apps/backoffice/src/pages/GenericPage.tsx b/apps/backoffice/src/pages/GenericPage.tsx
deleted file mode 100644
index 19cc43f..0000000
--- a/apps/backoffice/src/pages/GenericPage.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useLocation } from 'react-router-dom'
-import { Button, DataTable, EmptyState, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
-import { NAV, isLocked } from '../nav'
-import type { PageConfig } from './registry'
-
-/** Renders any list-page config; locked pages render the upsell, never a wall. */
-export function GenericPage(props: { cfg: PageConfig }) {
- const { cfg } = props
- const location = useLocation()
- const navPage = NAV.flatMap((m) => m.pages).find((p) => p.path === location.pathname)
- const locked = navPage !== undefined && isLocked(navPage)
-
- return (
-
-
{cfg.actions.map((a) => {a} )}>}
- />
- {locked && (
-
-
This is a {navPage.edition === 'E' ? 'Enterprise' : 'higher-plan'} feature.
-
- You can see exactly what it does below — upgrading unlocks it in place, no reinstall
- (paywall-as-demo, 09-UX §5.5). The preview uses sample data.
-
-
See upgrade options
-
- )}
- {cfg.filters !== undefined && (
-
- {cfg.filters.map((f) => (
-
- {f}: All
-
- ))}
-
-
-
- )}
- {cfg.stats !== undefined && (
-
- {cfg.stats.map((s) => )}
-
- )}
- {cfg.rows.length === 0 ? (
- No records yet — wireframe.
- ) : (
- undefined} />
- )}
-
- Wireframe page — every record opens a detail drawer with its audit trail in the real build
- (cross-page conventions, 09-UX §2).
-
-
- )
-}
diff --git a/apps/backoffice/src/pages/PurchaseInbox.tsx b/apps/backoffice/src/pages/PurchaseInbox.tsx
deleted file mode 100644
index 3950b25..0000000
--- a/apps/backoffice/src/pages/PurchaseInbox.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { Badge, Button, DataTable, Notice, PageHeader, Toolbar } from '@sims/ui'
-
-/** The flagship differentiator — side-by-side review of AI-parsed supplier bills (09-UX §3). */
-export function PurchaseInbox() {
- return (
-
-
Upload bill Email-in settings >}
- />
-
- 3 to review
- 38 posted this month
- item-match memory: 1,204 learned
-
-
-
-
Source — HUL_Invoice_HD2651.pdf
-
{`HINDUSTAN UNILEVER DISTRIBUTOR
-Invoice HD/2651 · 09-07-2026 · GSTIN 32AABCH1234K1Z6
-
-SURF EXCEL MATIC 1KG x48 @122.00 5,856.00
-RIN BAR 250G B12 x144 @10.20 1,468.80
-CLINIC+ SHAMPOO 340ML x24 @168.00 4,032.00
-
- Taxable 11,356.80
- CGST 9% 1,022.11
- SGST 9% 1,022.11
- TOTAL 13,401.02`}
-
-
-
Draft purchase — review & post
-
remembered },
- { supplier: 'RIN BAR 250G B12', ours: 'Rin Bar 250g (207)', qty: '144', cost: '10.20', conf: 98% },
- { supplier: 'CLINIC+ SHAMPOO 340ML', ours: choose item… , qty: '24', cost: '168.00', conf: new mapping },
- ]}
- />
-
- Total check: parsed ₹13,401.02 = draft ₹13,401.02 ✓ · Tax check: CGST+SGST @9% ✓ ·
- Duplicate check: HD/2651 not seen before ✓
-
-
- Post purchase
- Save draft
- Reject
-
-
- Confirming the shampoo mapping teaches the matcher — next month it posts with one click.
- MRP changed on Surf (₹150 → ₹152): label reprint will be queued automatically.
-
-
-
-
- )
-}
diff --git a/apps/backoffice/src/pages/SettingsPage.tsx b/apps/backoffice/src/pages/SettingsPage.tsx
deleted file mode 100644
index 75880f2..0000000
--- a/apps/backoffice/src/pages/SettingsPage.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { useState } from 'react'
-import { Badge, Button, DataTable, Notice, PageHeader } from '@sims/ui'
-
-const SCOPES = ['System default', 'Tenant', 'Store: ST1', 'Counter: ST1-C2', 'User: Ramesh'] as const
-
-/** The settings hierarchy made visible — nearest scope wins (11-ADMIN §2). */
-export function SettingsPage() {
- const [scope, setScope] = useState<(typeof SCOPES)[number]>('Store: ST1')
- return (
-
-
-
- {SCOPES.map((s) => (
- setScope(s)}>
- {s}
-
- ))}
-
- Show changed-from-default only
-
-
override here, act: Reset to inherited },
- { key: 'gst.roundToRupee', value: 'true', from: System , act: Override… },
- { key: 'negativeStock.policy', value: 'warn (allow with supervisor)', from: Tenant , act: Override… },
- { key: 'print.language', value: 'English + Malayalam', from: override here , act: Reset to inherited },
- { key: 'khata.defaultLimit', value: '₹5,000', from: Tenant , act: Override… },
- { key: 'approval.timeoutSec', value: '45', from: System , act: Override… },
- ]}
- />
-
- Wireframe of `setting(scope_type, scope_id, key, value, effective_from)` resolved by
- @sims/config — already implemented and tested in packages/config.
-
-
- )
-}
diff --git a/apps/backoffice/src/pages/UsersRoles.tsx b/apps/backoffice/src/pages/UsersRoles.tsx
deleted file mode 100644
index 085382f..0000000
--- a/apps/backoffice/src/pages/UsersRoles.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Badge, Button, DataTable, Notice, PageHeader, Toolbar } from '@sims/ui'
-
-const CHECK = '✓'
-const PIN = 'PIN'
-
-/** Users + the role→action matrix with supervisor-PIN gates (09-UX §2, @sims/auth). */
-export function UsersRoles() {
- return (
-
-
New user New role >}
- />
- Users
- active },
- { name: 'Suresh', roles: 'supervisor', surfaces: 'POS', stores: 'ST1', lang: 'Malayalam', status: active },
- { name: 'Ramesh', roles: 'cashier', surfaces: 'POS', stores: 'ST1', lang: 'Malayalam', status: active },
- { name: 'Divya', roles: 'cashier', surfaces: 'POS', stores: 'ST1', lang: 'Tamil', status: active },
- { name: 'Old Cashier', roles: 'cashier', surfaces: '—', stores: '—', lang: '—', status: deactivated },
- ]}
- />
- Permission matrix
-
- PIN = allowed via supervisor PIN overlay
- matrix rows are DB data (config-driven) — editable without release
-
-
-
-
-
- Matrix edits show “affects N users” before saving and land in the audit log. Backed by
- @sims/auth `DEFAULT_ROLE_GRANTS` + `gateFor()` — implemented and tested.
-
-
- )
-}
diff --git a/apps/backoffice/src/pages/gst.tsx b/apps/backoffice/src/pages/gst.tsx
deleted file mode 100644
index 73423ce..0000000
--- a/apps/backoffice/src/pages/gst.tsx
+++ /dev/null
@@ -1,351 +0,0 @@
-import { useEffect, useState } from 'react'
-import { formatINR } from '@sims/domain'
-import { taxOf, type CreditNote, type Gstr1SectionTotals, type HsnRow, type Reconciliation, type TaxAmounts } from '@sims/billing-engine'
-import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
-import { getGstHsn, getGstr1, getGstr1Json, getGstr3b, type Gstr1Response } from '../api'
-
-/**
- * GST returns — LIVE pages (GSTR-1, GSTR-3B, HSN summary). Every number is folded server-side by
- * the shared engine from the period's immutable documents and re-verified by a reconciliation proof
- * shown at the top of each page. These are decision-support / export views for a CA or GSP, NOT a
- * certified filing (stated on every page). Replaces the registry wireframes via the CUSTOM map.
- */
-
-const inr = (p: unknown): string => formatINR(Number(p), { symbol: false })
-const rateLabel = (bp: number): string => (bp === 0 ? '0%' : `${bp / 100}%`)
-const thisMonth = (): string => new Date().toISOString().slice(0, 7)
-
-function useData(loader: () => Promise, deps: unknown[]): { data?: T; error?: string } {
- const [data, setData] = useState()
- const [error, setError] = useState()
- useEffect(() => {
- setData(undefined)
- setError(undefined)
- loader().then(setData).catch((e: Error) => setError(e.message))
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, deps)
- return { data, error }
-}
-
-function MonthInput(props: { period: string; onChange: (p: string) => void }) {
- return (
- props.onChange(e.target.value)}
- />
- )
-}
-
-/** The reconciliation proof — green when sections == source == HSN == 3B, red (loud) otherwise. */
-function ReconBanner(props: { recon: Reconciliation }) {
- const r = props.recon
- if (r.ok) {
- return (
-
- Reconciled: GSTR-1 sections = source documents = HSN summary = GSTR-3B outward.
- Net taxable {formatINR(r.sourceTaxablePaise)}, net tax {formatINR(r.sourceTaxPaise)} (returns reduce liability at their original rate).
-
- )
- }
- return (
-
- DOES NOT RECONCILE — do not file. {r.mismatches.join('; ')}. Source taxable {formatINR(r.sourceTaxablePaise)} vs
- GSTR-1 {formatINR(r.gstr1NetTaxablePaise)} vs HSN {formatINR(r.hsnTaxablePaise)} vs 3B {formatINR(r.gstr3bTaxablePaise)}.
-
- )
-}
-
-function NotCertified() {
- return (
-
-
- Not a certified filing. These figures are computed from your counters' immutable documents for review and
- hand-off to your CA / GSP; real e-filing goes through a GSP. Nil-rated/exempt/export splits, ITC (GSTR-2A/2B),
- amendments and the cross-period filing-freeze are follow-ups — see the deferred list.
-
-
- )
-}
-
-function downloadJson(obj: unknown, filename: string): void {
- const url = URL.createObjectURL(new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }))
- const a = document.createElement('a')
- a.href = url
- a.download = filename
- a.click()
- setTimeout(() => URL.revokeObjectURL(url), 10_000)
-}
-
-const AMOUNT_COLS = [
- { key: 'taxable', label: 'Taxable', numeric: true }, { key: 'igst', label: 'IGST', numeric: true },
- { key: 'cgst', label: 'CGST', numeric: true }, { key: 'sgst', label: 'SGST', numeric: true },
- { key: 'cess', label: 'Cess', numeric: true },
-]
-const amountCells = (a: TaxAmounts): Record => ({
- taxable: inr(a.taxablePaise), igst: inr(a.igstPaise), cgst: inr(a.cgstPaise), sgst: inr(a.sgstPaise), cess: inr(a.cessPaise),
-})
-
-/** GSTR-1 section-total summary (returns shown as reductions), ending in the reconciled Net row. */
-function SectionSummary(props: { totals: Gstr1SectionTotals }) {
- const t = props.totals
- const neg = (a: TaxAmounts): TaxAmounts => ({ taxablePaise: -a.taxablePaise, igstPaise: -a.igstPaise, cgstPaise: -a.cgstPaise, sgstPaise: -a.sgstPaise, cessPaise: -a.cessPaise })
- const rows = [
- { section: 'B2B (4A) — registered', ...amountCells(t.b2b) },
- { section: 'B2C Large (5) — inter-state', ...amountCells(t.b2cl) },
- { section: 'B2C Small (7)', ...amountCells(t.b2cs) },
- { section: 'CDNR (9B) — reg. credit notes', ...amountCells(neg(t.cdnr)) },
- { section: 'CDNUR (9B) — unreg. credit notes', ...amountCells(neg(t.cdnur)) },
- { section: 'Net outward', ...amountCells(t.net) },
- ]
- return (
- <>
- Section summary
-
- >
- )
-}
-
-function B2BTable(props: { groups: Gstr1Response['b2b'] }) {
- const rows = props.groups.flatMap((g) =>
- g.rates.map((r, i) => ({
- gstin: i === 0 ? g.ctin : '', invoices: i === 0 ? String(g.invoiceCount) : '',
- rate: rateLabel(r.rateBp), ...amountCells(r),
- })),
- )
- return (
- <>
- B2B — invoices to registered buyers (Table 4A)
-
- >
- )
-}
-
-function B2CSTable(props: { buckets: Gstr1Response['b2cs'] }) {
- const rows = props.buckets.map((b) => ({
- pos: b.pos, type: b.supplyType === 'INTER' ? 'Inter' : 'Intra', rate: rateLabel(b.rateBp), ...amountCells(b),
- }))
- return (
- <>
- B2C Small — aggregated by place of supply + rate (Table 7)
-
- >
- )
-}
-
-function B2CLTable(props: { invoices: Gstr1Response['b2cl'] }) {
- const rows = props.invoices.flatMap((inv) =>
- inv.rates.map((r, i) => ({
- no: i === 0 ? inv.docNo : '', date: i === 0 ? inv.businessDate : '', pos: inv.pos,
- value: i === 0 ? inr(inv.invoiceValuePaise) : '', rate: rateLabel(r.rateBp),
- taxable: inr(r.taxablePaise), igst: inr(r.igstPaise), cess: inr(r.cessPaise),
- })),
- )
- return (
- <>
- B2C Large — inter-state invoices above ₹2,50,000 (Table 5)
-
- >
- )
-}
-
-function CDNTable(props: { title: string; notes: CreditNote[]; showCtin?: boolean }) {
- const rows = props.notes.flatMap((n) =>
- n.rates.map((r, i) => ({
- no: i === 0 ? n.noteDocNo : '', ctin: i === 0 ? (n.ctin ?? '—') : '',
- against: i === 0 ? (n.originalDocNo ?? '—') : '', rate: rateLabel(r.rateBp), ...amountCells(r),
- })),
- )
- const cols = [
- { key: 'no', label: 'Credit Note' },
- ...(props.showCtin === true ? [{ key: 'ctin', label: 'Buyer GSTIN' }] : []),
- { key: 'against', label: 'Against Bill' }, { key: 'rate', label: 'Rate' }, ...AMOUNT_COLS,
- ]
- return (
- <>
- {props.title}
-
- >
- )
-}
-
-function HsnTable(props: { rows: HsnRow[]; totals: TaxAmounts }) {
- const rows: Record[] = props.rows.map((h) => ({
- hsn: h.hsn, rate: rateLabel(h.rateBp), uqc: h.uqc,
- qty: String(Math.round(h.qty * 1000) / 1000), ...amountCells(h),
- }))
- rows.push({ hsn: 'Total', rate: '', uqc: '', qty: '', ...amountCells(props.totals) })
- return (
- <>
- HSN summary (Table 12)
-
- >
- )
-}
-
-function DocsTable(props: { docs: Gstr1Response['docs'] }) {
- if (props.docs.length === 0) return null
- return (
- <>
- Documents issued (Table 13)
- ({
- nature: d.natureOfDocument, from: d.fromDocNo, to: d.toDocNo,
- total: String(d.totalNumber), cancel: String(d.cancelled), net: String(d.net),
- }))}
- />
- >
- )
-}
-
-export function Gstr1Page() {
- const [period, setPeriod] = useState(thisMonth())
- const { data, error } = useData(() => getGstr1(period), [period])
- const [jsonErr, setJsonErr] = useState()
- const download = (): void => {
- setJsonErr(undefined)
- getGstr1Json(period).then((obj) => downloadJson(obj, `GSTR1-${period}.json`)).catch((e: Error) => setJsonErr(e.message))
- }
- return (
-
-
Download JSON}
- />
-
-
- Period {period}
- {data !== undefined && {data.counts.saleDocs} invoices · {data.counts.returnDocs} credit notes }
-
- {error !== undefined && {error} }
- {jsonErr !== undefined && {jsonErr} }
- {data === undefined && error === undefined ? Loading… : data !== undefined && (
- <>
-
-
-
-
-
-
-
- {data.b2b.length > 0 && }
- {data.b2cs.length > 0 && }
- {data.b2cl.length > 0 && }
- {data.cdnr.length > 0 && }
- {data.cdnur.length > 0 && }
-
-
-
- >
- )}
-
- )
-}
-
-export function Gstr3bPage() {
- const [period, setPeriod] = useState(thisMonth())
- const { data, error } = useData(() => getGstr3b(period), [period])
- return (
-
-
-
-
- Period {period}
-
- {error !== undefined &&
{error} }
- {data === undefined && error === undefined ?
Loading… : data !== undefined && (
- <>
-
-
-
-
-
-
-
3.1 Details of outward supplies
-
-
How 3.1(a) is derived
-
-
- {data.itc.note}
-
-
- >
- )}
-
- )
-}
-
-export function HsnPage() {
- const [period, setPeriod] = useState(thisMonth())
- const { data, error } = useData(() => getGstHsn(period), [period])
- return (
-
-
-
-
- Period {period}
- {data !== undefined && {data.rows.length} HSN rows }
-
- {error !== undefined &&
{error} }
- {data === undefined && error === undefined ?
Loading… : data !== undefined && (
- <>
-
-
-
-
-
- {data.rows.length === 0 ?
No outward supply in {period}.
- :
}
-
- >
- )}
-
- )
-}
diff --git a/apps/backoffice/src/pages/labels.tsx b/apps/backoffice/src/pages/labels.tsx
deleted file mode 100644
index 5ac819d..0000000
--- a/apps/backoffice/src/pages/labels.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { useState } from 'react'
-import { formatINR } from '@sims/domain'
-import { renderLabelSheetHtml, type LabelInput, type LabelSheetOptions } from '@sims/printing'
-import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
-import { getItems } from '../api'
-
-/**
- * Opens a printable label sheet in a new window (blob URL — no document.write).
- * Shared by the label queue (Catalog → Label Printing) and the purchase-post
- * "Print labels" prompt, so both produce the same sheet (09-UX §5.4 routing).
- */
-export function openLabelSheet(labels: LabelInput[], opts?: Partial): string | undefined {
- if (labels.length === 0) return 'No labels to print.'
- const sheet: LabelSheetOptions = { cols: opts?.cols ?? 4, labelWmm: opts?.labelWmm ?? 48, labelHmm: opts?.labelHmm ?? 30, ...(opts?.title !== undefined ? { title: opts.title } : {}) }
- const url = URL.createObjectURL(new Blob([renderLabelSheetHtml(labels, sheet)], { type: 'text/html' }))
- const win = window.open(url, '_blank')
- if (win === null) { URL.revokeObjectURL(url); return 'Allow pop-ups to open the label sheet.' }
- setTimeout(() => URL.revokeObjectURL(url), 60_000)
- return undefined
-}
-
-/** Item master → label inputs: MRP falls back to sale price; first barcode is the EAN. */
-export function itemToLabel(item: Record): LabelInput {
- const barcodes = (item['barcodes'] as string[] | undefined) ?? []
- const mrp = item['mrpPaise'] !== undefined ? Number(item['mrpPaise']) : Number(item['salePricePaise'])
- return {
- name: String(item['name']),
- mrpPaise: mrp,
- pricePaise: Number(item['salePricePaise']),
- ...(barcodes[0] !== undefined && barcodes[0] !== '' ? { code13: barcodes[0] } : {}),
- }
-}
-
-const inr = (p: unknown) => formatINR(Number(p), { symbol: false })
-
-export function LabelsPage() {
- const [items, setItems] = useState[] | undefined>()
- const [error, setError] = useState()
- const [cols, setCols] = useState(4)
- const [size, setSize] = useState<'small' | 'medium' | 'large'>('medium')
- const [note, setNote] = useState()
-
- if (items === undefined && error === undefined) {
- getItems().then(setItems).catch((e: Error) => setError(e.message))
- }
-
- const dims = size === 'small' ? { labelWmm: 38, labelHmm: 25 } : size === 'large' ? { labelWmm: 63, labelHmm: 38 } : { labelWmm: 48, labelHmm: 30 }
-
- const print = () => {
- if (items === undefined) return
- const labels = items.map(itemToLabel)
- setNote(openLabelSheet(labels, { cols, ...dims, title: 'Catalog labels' }))
- }
-
- return (
-
-
Print labels ↗}
- />
-
-
- setCols(Number(e.target.value))}>
- {[2, 3, 4, 5].map((c) => {c} )}
-
-
-
- setSize(e.target.value as typeof size)}>
- Small (38×25 mm)
- Medium (48×30 mm)
- Large (63×38 mm)
-
-
- {items?.length ?? '…'} items
-
- {error !== undefined && {error} }
- {note !== undefined && {note} }
- {items === undefined ? Loading… : items.length === 0 ? (
- No items yet — add some in Catalog → Items.
- ) : (
- {
- const barcodes = (i['barcodes'] as string[] | undefined) ?? []
- return {
- name: String(i['name']),
- barcode: barcodes[0] !== undefined && barcodes[0] !== '' ? barcodes[0] : text only ,
- mrp: i['mrpPaise'] !== undefined ? inr(i['mrpPaise']) : '—',
- price: inr(i['salePricePaise']),
- }
- })}
- />
- )}
-
- )
-}
diff --git a/apps/backoffice/src/pages/live.tsx b/apps/backoffice/src/pages/live.tsx
deleted file mode 100644
index 63a4a41..0000000
--- a/apps/backoffice/src/pages/live.tsx
+++ /dev/null
@@ -1,474 +0,0 @@
-import { useEffect, useState } from 'react'
-import { formatINR, type BillLine, type BillTotals } from '@sims/domain'
-import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing'
-import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
-import { createItem, getAudit, getBills, getExpiry, getItems, getParties, getReturns, getSeller, getStock, type Seller } from '../api'
-
-/** The first live pages — real data from the store-server (the slice). */
-
-const inr = (p: unknown) => formatINR(Number(p), { symbol: false })
-
-function useData(loader: () => Promise, deps: unknown[] = []): { data?: T; error?: string; reload: () => void } {
- const [data, setData] = useState()
- const [error, setError] = useState()
- const reload = () => {
- setError(undefined)
- loader().then(setData).catch((e: Error) => setError(e.message))
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(reload, deps)
- return { data, error, reload }
-}
-
-const PAGE = 100
-
-/** Offset pagination with an explicit "Load more" — R13: a cap the operator can lift, never silent. */
-function usePaged(
- loader: (limit: number, offset: number) => Promise,
- deps: unknown[],
-): { rows: T[]; error?: string; hasMore: boolean; loading: boolean; loadMore: () => void } {
- const [rows, setRows] = useState([])
- const [error, setError] = useState()
- const [hasMore, setHasMore] = useState(false)
- const [loading, setLoading] = useState(false)
- const load = (offset: number) => {
- setLoading(true)
- setError(undefined)
- loader(PAGE, offset)
- .then((page) => {
- setRows((prev) => (offset === 0 ? page : [...prev, ...page]))
- setHasMore(page.length === PAGE)
- })
- .catch((e: Error) => setError(e.message))
- .finally(() => setLoading(false))
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(() => { setRows([]); load(0) }, deps)
- return { rows, error, hasMore, loading, loadMore: () => load(rows.length) }
-}
-
-export function ItemsPage() {
- const [q, setQ] = useState('')
- const { data, error, reload } = useData(() => getItems(q), [q])
- const [creating, setCreating] = useState(false)
-
- return (
-
- )
-}
-
-function NewItemModal(props: { onClose: () => void; onCreated: () => void }) {
- const [f, setF] = useState({ code: '', name: '', hsn: '', taxClassCode: 'GST18', unitCode: 'PCS', priceRs: '', mrpRs: '', barcode: '' })
- const [error, setError] = useState()
- const set = (k: keyof typeof f) => (e: { target: { value: string } }) => setF((p) => ({ ...p, [k]: e.target.value }))
-
- const save = () => {
- createItem({
- code: f.code, name: f.name, hsn: f.hsn, taxClassCode: f.taxClassCode,
- unitCode: f.unitCode, unitDecimals: f.unitCode === 'KG' ? 3 : 0,
- salePricePaise: Math.round(Number(f.priceRs) * 100),
- ...(f.mrpRs !== '' ? { mrpPaise: Math.round(Number(f.mrpRs) * 100) } : {}),
- ...(f.barcode !== '' ? { barcode: f.barcode } : {}),
- }).then(props.onCreated).catch((e: Error) => setError(e.message))
- }
-
- return (
-
-
-
-
-
-
- {['ZERO', 'GST5', 'GST12', 'GST18', 'DEMERIT'].map((c) => {c} )}
-
-
-
-
- PCS KG
-
-
-
-
-
- {error !== undefined && {error} }
-
- Save item
- Cancel
-
-
- )
-}
-
-export function CustomersPage() {
- const { data, error } = useData(() => getParties('customer'))
- return (
-
-
- {error !== undefined &&
{error} }
- {data === undefined ?
Loading… : (
-
({
- code: String(p['code']), name: String(p['name']),
- phone: p['phone'] !== undefined ? String(p['phone']) : '—',
- gstin: p['gstin'] !== undefined ? {String(p['gstin'])} : '—',
- limit: p['creditLimitPaise'] !== undefined ? inr(p['creditLimitPaise']) : '—',
- }))}
- />
- )}
-
- )
-}
-
-/** Build the A4 GST invoice for a bill row and open it in a print-ready window. */
-function openInvoice(bill: Record, seller: Seller | undefined): string | undefined {
- let payload: { lines: BillLine[]; totals: BillTotals; payments?: { mode: string }[] }
- try {
- payload = JSON.parse(String(bill['payload'])) as typeof payload
- } catch {
- return 'This bill has no stored line detail to invoice.'
- }
- // The B2B buyer + place of supply are snapshotted on the bill at commit (S3),
- // so the invoice reads them from the document, not from today's customer row.
- const buyerGstin = bill['buyer_gstin'] !== null && bill['buyer_gstin'] !== undefined ? String(bill['buyer_gstin']) : undefined
- const placeOfSupply = bill['place_of_supply'] !== null && bill['place_of_supply'] !== undefined ? String(bill['place_of_supply']) : undefined
- const posState = placeOfSupply ?? seller?.stateCode
- const doc: InvoiceDoc = {
- seller: {
- name: seller?.name ?? 'Store',
- ...(seller?.gstin !== undefined ? { gstin: seller.gstin } : {}),
- ...(seller?.stateCode !== undefined ? { stateCode: seller.stateCode } : {}),
- },
- ...(bill['customer_name'] !== null && bill['customer_name'] !== undefined
- ? {
- buyer: {
- name: String(bill['customer_name']),
- ...(buyerGstin !== undefined ? { gstin: buyerGstin } : {}),
- ...(placeOfSupply !== undefined ? { stateCode: placeOfSupply } : {}),
- },
- }
- : {}),
- invoiceNo: String(bill['doc_no']),
- invoiceDate: String(bill['business_date']),
- ...(posState !== undefined ? { placeOfSupplyStateCode: posState } : {}),
- lines: payload.lines,
- totals: payload.totals,
- ...(payload.payments?.[0]?.mode !== undefined ? { paymentLabel: payload.payments[0].mode } : {}),
- }
- // Serve the self-contained HTML as a blob URL — no document.write, and the new
- // window loads it as a real document (so its own Print A4 button works).
- const url = URL.createObjectURL(new Blob([renderInvoiceHtml(doc)], { type: 'text/html' }))
- const win = window.open(url, '_blank')
- if (win === null) { URL.revokeObjectURL(url); return 'Allow pop-ups to open the invoice (it prints in a new tab).' }
- setTimeout(() => URL.revokeObjectURL(url), 60_000)
- return undefined
-}
-
-export function BillsPage() {
- const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
- const { rows, error, hasMore, loading, loadMore } = usePaged>(
- (limit, offset) => getBills(date, limit, offset), [date],
- )
- const { data: seller } = useData(() => getSeller())
- const [printErr, setPrintErr] = useState()
- const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0)
- return (
-
-
-
- setDate(e.target.value)} />
- {rows.length}{hasMore ? '+' : ''} bills
- {formatINR(total)}
-
- {error !== undefined &&
{error} }
- {printErr !== undefined &&
{printErr} }
- {rows.length === 0 && loading ?
Loading…
- : rows.length === 0 && error === undefined ?
No bills on {date}. Make one at the POS! : (
-
setPrintErr(openInvoice(rows[i]!, seller))}
- rows={rows.map((b) => ({
- no: String(b['doc_no']),
- time: new Date(String(b['created_at_wall'])).toLocaleTimeString(),
- cashier: String(b['cashier_name'] ?? ''),
- customer: b['customer_name'] !== null ? String(b['customer_name']) : 'walk-in',
- gst: inr(Number(b['cgst_paise']) + Number(b['sgst_paise']) + Number(b['igst_paise']) + Number(b['cess_paise'])),
- amount: inr(b['payable_paise']),
- invoice: Print A4 ↗ ,
- }))}
- />
- )}
- {hasMore && (
-
- {loading ? 'Loading…' : 'Load more'}
-
- )}
-
- )
-}
-
-/**
- * Sales → Returns & Credit Notes — LIVE. Every SALE_RETURN document, tenant-scoped and
- * paginated (R13). A credit note references its original bill (Against Bill) and reverses tax
- * at the original bill-date rates; the refund is the immutable document's payable. Replaces the
- * registry wireframe via the CUSTOM map.
- */
-export function ReturnsPage() {
- const { rows, error, hasMore, loading, loadMore } = usePaged>(
- (limit, offset) => getReturns(limit, offset), [],
- )
- const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0)
- const reasonOf = (b: Record): string => {
- try { return String((JSON.parse(String(b['payload'])) as { reason?: string }).reason ?? '') } catch { return '' }
- }
- return (
-
-
-
- {rows.length}{hasMore ? '+' : ''} credit notes
- {formatINR(total)} refunded
-
- {error !== undefined &&
{error} }
- {rows.length === 0 && loading ?
Loading…
- : rows.length === 0 && error === undefined ?
No credit notes yet — process a return at the POS (F9). : (
-
({
- no: String(b['doc_no']),
- against: b['original_doc_no'] !== null && b['original_doc_no'] !== undefined ? String(b['original_doc_no']) : '—',
- date: new Date(String(b['created_at_wall'])).toLocaleString(),
- cashier: String(b['cashier_name'] ?? ''),
- customer: b['customer_name'] !== null && b['customer_name'] !== undefined ? String(b['customer_name']) : 'walk-in',
- reason: reasonOf(b),
- amount: inr(b['payable_paise']),
- }))}
- />
- )}
- {hasMore && (
-
- {loading ? 'Loading…' : 'Load more'}
-
- )}
-
- )
-}
-
-export function StockPage() {
- const { data, error } = useData(() => getStock())
- // S5: the per-batch breakdown for batch-tracked items (a second table, kept simple).
- const { data: batches } = useData(() => getExpiry())
- const today = new Date().toISOString().slice(0, 10)
- return (
-
-
- {error !== undefined &&
{error} }
- {data === undefined ?
Loading… : (
-
{
- const onHand = Number(s['on_hand'])
- return {
- code: String(s['code']), name: String(s['name']),
- onhand: `${Math.round(onHand * 1000) / 1000} ${String(s['unit_code'])}`,
- tracked: Number(s['batch_tracked']) === 1 ? batch : '—',
- state: onHand <= 0 ? out : onHand < 15 ? low : ok ,
- }
- })}
- />
- )}
- {batches !== undefined && batches.length > 0 && (
- <>
- Batch-tracked stock
- {
- const onHand = Number(b['on_hand'])
- const expiry = b['expiry_date'] !== null ? String(b['expiry_date']) : undefined
- const expired = expiry !== undefined && expiry < today
- return {
- item: String(b['name']),
- batch: String(b['batch_no']),
- expiry: expiry === undefined ? no expiry
- : expired ? {expiry} : expiry,
- onhand: `${Math.round(onHand * 1000) / 1000} ${String(b['unit_code'])}`,
- value: inr(onHand * Number(b['sale_price_paise'])),
- }
- })}
- />
- >
- )}
-
- )
-}
-
-export function AuditPage() {
- const { rows, error, hasMore, loading, loadMore } = usePaged>(
- (limit, offset) => getAudit(limit, offset), [],
- )
- return (
-
-
- {error !== undefined &&
{error} }
- {rows.length === 0 && loading ?
Loading…
- : rows.length === 0 && error === undefined ?
No audit entries yet. : (
-
({
- at: new Date(String(a['at_wall'])).toLocaleString(),
- who: String(a['user_name'] ?? a['user_id']),
- action: {String(a['action'])} ,
- // Override rows carry a before_json (who approved what change from what) —
- // show before → after so PRICE/QTY/DISCOUNT overrides read at a glance.
- detail: `${String(a['entity'])} ${a['before_json'] !== null ? `${String(a['before_json']).slice(0, 60)} → ` : ''}${a['after_json'] !== null ? String(a['after_json']).slice(0, 90) : ''}`,
- }))}
- />
- )}
- {hasMore && (
-
- {loading ? 'Loading…' : 'Load more'}
-
- )}
-
- )
-}
-
-/**
- * S5 Expiry Dashboard — live. Every in-stock batch, bucketed by the working date:
- * expired-with-stock, expiring ≤7 days, ≤30 days. Per-batch on-hand is derived from
- * movements (R7); value is on-hand × the item's current sale price. Replaces the
- * registry wireframe via the CUSTOM map.
- */
-interface ExpiryRow { id: string; code: string; name: string; batchNo: string; expiry?: string; onHand: number; unit: string; value: number }
-
-function bucketTable(title: string, tone: 'err' | 'warn' | undefined, rows: ExpiryRow[]) {
- if (rows.length === 0) return null
- return (
- <>
- {title}
- ({
- item: r.name, batch: r.batchNo,
- expiry: r.expiry === undefined ? '—'
- : tone === 'err' ? {r.expiry}
- : tone === 'warn' ? {r.expiry}
- : r.expiry,
- qty: `${Math.round(r.onHand * 1000) / 1000} ${r.unit}`,
- value: inr(r.value),
- }))}
- />
- >
- )
-}
-
-export function ExpiryPage() {
- const { data, error } = useData(() => getExpiry())
- const today = new Date().toISOString().slice(0, 10)
- const plusDays = (n: number): string => {
- const d = new Date()
- d.setDate(d.getDate() + n)
- return d.toISOString().slice(0, 10)
- }
- const in7 = plusDays(7)
- const in30 = plusDays(30)
-
- const rows: ExpiryRow[] = (data ?? []).map((r) => ({
- id: String(r['id']), code: String(r['code']), name: String(r['name']),
- batchNo: String(r['batch_no']),
- expiry: r['expiry_date'] !== null && r['expiry_date'] !== undefined ? String(r['expiry_date']) : undefined,
- onHand: Number(r['on_hand']), unit: String(r['unit_code']),
- value: Number(r['on_hand']) * Number(r['sale_price_paise']),
- }))
- const dated = rows.filter((r) => r.expiry !== undefined)
- const expired = dated.filter((r) => r.expiry! < today)
- const le7 = dated.filter((r) => r.expiry! >= today && r.expiry! <= in7)
- const le30 = dated.filter((r) => r.expiry! > in7 && r.expiry! <= in30)
- const far = rows.filter((r) => r.expiry === undefined || r.expiry > in30)
- const sum = (list: ExpiryRow[]): number => list.reduce((a, r) => a + r.value, 0)
-
- return (
-
-
- {error !== undefined &&
{error} }
- {data === undefined ?
Loading… : rows.length === 0 ? (
-
No batch-tracked stock yet — receive a batch in Purchase Entry.
- ) : (
- <>
-
-
-
-
-
- {bucketTable('Expired — clear or write off', 'err', expired)}
- {bucketTable('Expiring within 7 days', 'warn', le7)}
- {bucketTable('Expiring within 30 days', undefined, le30)}
-
-
- Dead-stock: {far.length} batch{far.length === 1 ? '' : 'es'} carry {formatINR(sum(far))} of stock dated beyond 30 days.
- Slow-mover ranking by sales velocity is a follow-up — this view flags by expiry today.
-
-
- >
- )}
-
- )
-}
diff --git a/apps/backoffice/src/pages/purchase-entry.tsx b/apps/backoffice/src/pages/purchase-entry.tsx
deleted file mode 100644
index 879f915..0000000
--- a/apps/backoffice/src/pages/purchase-entry.tsx
+++ /dev/null
@@ -1,533 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
-import { formatINR } from '@sims/domain'
-import { parseEntry } from '@sims/scanning'
-import { resolveTaxClass, type TaxClassRow } from '@sims/billing-engine'
-import type { LabelInput } from '@sims/printing'
-import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
-import { getBootstrapPublic, getCostHistory, getItems, getLastCosts, getParties, getPurchases, getStock, postPurchase } from '../api'
-import { openLabelSheet } from './labels'
-
-/** Crash-proof draft (spec P1-5): a 180-line invoice must survive a browser crash. */
-const DRAFT_KEY = 'pe.draft'
-interface Draft {
- supplier?: { id: string; name: string }
- invoiceNo: string
- invoiceDate: string
- lines: PLine[]
-}
-
-/**
- * Purchase Entry — the second crown-jewel screen (09-UX §3): a purchaser types
- * a 50–200 line supplier invoice as fast as the paper can be read.
- * Flow per line: scan/type item → qty → cost (last-cost prefilled) → Enter
- * returns to the scan box. Cost changes prompt a price revision inline.
- */
-
-interface ItemLite {
- id: string; code: string; name: string; taxClassCode: string
- salePricePaise: number; mrpPaise?: number
- unit: { code: string; decimals: number }; barcodes: string[]
- batchTracked: boolean
-}
-
-interface PLine {
- itemId: string; name: string; unitCode: string
- qty: number; unitCostPaise: number; taxRateBp: number
- lastCostPaise?: number
- currentSalePaise: number; currentMrpPaise?: number
- newSalePricePaise?: number; newMrpPaise?: number
- /** S5: batch-tracked items extend the Enter chain qty→cost→batch→expiry→scan. */
- batchTracked: boolean
- batchNo?: string; expiryDate?: string
-}
-
-const inr = (p: number) => formatINR(p, { symbol: false })
-const rs = (s: string) => Math.round(Number(s) * 100)
-
-export function PurchaseEntryPage() {
- const today = new Date().toISOString().slice(0, 10)
- const [items, setItems] = useState([])
- const [taxClasses, setTaxClasses] = useState([])
- const [suppliers, setSuppliers] = useState<{ id: string; name: string }[]>([])
- const [supplier, setSupplier] = useState<{ id: string; name: string } | undefined>()
- const [supplierQuery, setSupplierQuery] = useState('')
- const [lastCost, setLastCost] = useState>({})
- const [invoiceNo, setInvoiceNo] = useState('')
- const [invoiceDate, setInvoiceDate] = useState(today)
- const [lines, setLines] = useState([])
- const anyBatch = lines.some((l) => l.batchTracked)
- const [entry, setEntry] = useState('')
- const [printedTotal, setPrintedTotal] = useState('')
- const [notice, setNotice] = useState<{ tone: 'ok' | 'warn' | 'err'; text: string } | undefined>()
- const [busy, setBusy] = useState(false)
- /** Filled on post so the receiving clerk can print labels for what just came in. */
- const [labelQueue, setLabelQueue] = useState()
- /** Which cell owns focus: the scan box, or qty/cost/batch/expiry of a line. */
- const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' | 'batch' | 'expiry' } | undefined>()
-
- const entryRef = useRef(null)
- const cellRef = useRef(null)
-
- const [stockMap, setStockMap] = useState>(new Map())
- const [draft, setDraft] = useState()
- const [costHist, setCostHist] = useState<{ cost: number; date: string; supplier: string }[] | undefined>()
-
- useEffect(() => {
- Promise.all([getItems(), getParties('supplier'), getBootstrapPublic(), getStock()])
- .then(([its, sups, boot, stock]) => {
- setItems(its as unknown as ItemLite[])
- setSuppliers(sups.map((s) => ({ id: String(s['id']), name: String(s['name']) })))
- setTaxClasses(boot.taxClasses)
- setStockMap(new Map(stock.map((s) => [String(s['code']), Number(s['on_hand'])])))
- })
- .catch((e: Error) => setNotice({ tone: 'err', text: e.message }))
- // resume a crashed entry?
- try {
- const raw = localStorage.getItem(DRAFT_KEY)
- if (raw !== null) {
- const d = JSON.parse(raw) as Draft
- if (d.lines.length > 0) setDraft(d)
- }
- } catch { /* corrupted draft — ignore */ }
- }, [])
-
- // journal every change; cleared on post
- useEffect(() => {
- if (lines.length > 0) {
- localStorage.setItem(DRAFT_KEY, JSON.stringify({ supplier, invoiceNo, invoiceDate, lines } satisfies Draft))
- }
- }, [lines, supplier, invoiceNo, invoiceDate])
-
- // right-rail glanceability (spec P1-1): cost history for the line being edited
- useEffect(() => {
- const line = focusCell !== undefined ? lines[focusCell.line] : undefined
- if (line === undefined) return setCostHist(undefined)
- getCostHistory(line.itemId, supplier?.id).then(setCostHist).catch(() => setCostHist(undefined))
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [focusCell?.line])
-
- useEffect(() => {
- getLastCosts(supplier?.id).then(setLastCost).catch(() => undefined)
- }, [supplier?.id])
-
- useEffect(() => {
- if (focusCell !== undefined) {
- const el = cellRef.current
- el?.focus()
- // select() only text-likes; a date cell (expiry) would throw, so focus only.
- if (el !== null && (focusCell.cell === 'qty' || focusCell.cell === 'cost')) el.select()
- } else {
- entryRef.current?.focus()
- }
- }, [focusCell, lines.length])
-
- const say = (tone: 'ok' | 'warn' | 'err', text: string) => setNotice({ tone, text })
-
- const rateOf = (item: ItemLite): number => {
- try {
- const r = resolveTaxClass(taxClasses, item.taxClassCode, invoiceDate)
- return r.ratePctBp + r.cessPctBp
- } catch {
- return 0
- }
- }
-
- const addItem = (item: ItemLite, qty = 1) => {
- const memory = lastCost[item.id]
- const prefill = memory?.supplierLast ?? memory?.last ?? 0
- setLines((prev) => {
- setFocusCell({ line: prev.length, cell: 'qty' })
- return [...prev, {
- itemId: item.id, name: item.name, unitCode: item.unit.code,
- qty, unitCostPaise: prefill, taxRateBp: rateOf(item),
- ...(memory?.supplierLast !== undefined || memory?.last !== undefined
- ? { lastCostPaise: memory.supplierLast ?? memory.last }
- : {}),
- currentSalePaise: item.salePricePaise,
- ...(item.mrpPaise !== undefined ? { currentMrpPaise: item.mrpPaise } : {}),
- batchTracked: item.batchTracked === true,
- }]
- })
- setNotice(undefined)
- }
-
- const handleEntry = () => {
- const parsed = parseEntry(entry)
- setEntry('')
- const find = (code: string) =>
- items.find((i) => i.barcodes.includes(code)) ?? items.find((i) => i.code === code)
- switch (parsed.kind) {
- case 'empty': return
- case 'multiplier': {
- if (parsed.rest === '') return say('warn', 'Type the item after the quantity, e.g. 24*surf')
- const item = /^\d+$/.test(parsed.rest)
- ? find(parsed.rest)
- : items.find((i) => i.name.toLowerCase().includes(parsed.rest.toLowerCase()))
- return item !== undefined ? addItem(item, parsed.qty) : say('err', `No item for "${parsed.rest}" (E-1102)`)
- }
- case 'barcode':
- case 'code': {
- const item = find(parsed.code)
- return item !== undefined ? addItem(item) : say('err', `Unknown code ${parsed.code} (E-1102)`)
- }
- case 'search': {
- const q = parsed.text.toLowerCase()
- const item = items.filter((i) => i.name.toLowerCase().includes(q))
- .sort((a, b) => Number(b.name.toLowerCase().startsWith(q)) - Number(a.name.toLowerCase().startsWith(q)))[0]
- return item !== undefined ? addItem(item) : say('err', `No item matches "${parsed.text}" (E-1102)`)
- }
- }
- }
-
- const setLine = (i: number, patch: Partial) =>
- setLines((prev) => prev.map((l, j) => (j === i ? { ...l, ...patch } : l)))
-
- const totals = useMemo(() => {
- let taxable = 0, tax = 0
- for (const l of lines) {
- const t = Math.round(l.qty * l.unitCostPaise)
- taxable += t
- tax += Math.round((t * l.taxRateBp) / 10_000)
- }
- return { taxable, tax, total: taxable + tax }
- }, [lines])
-
- const printedPaise = printedTotal === '' ? undefined : rs(printedTotal)
- const crossCheck = printedPaise === undefined ? 'none' : printedPaise === totals.total ? 'match' : 'mismatch'
-
- const post = () => {
- if (busy) return
- if (supplier === undefined) return say('warn', 'Pick the supplier first')
- if (invoiceNo.trim() === '') return say('warn', 'Enter the supplier invoice number')
- if (lines.length === 0) return say('warn', 'No lines yet — scan or type the first item')
- if (crossCheck === 'mismatch') {
- return say('warn', `Printed total ${formatINR(printedPaise!)} ≠ computed ${formatINR(totals.total)} — fix a line or clear the check field to post anyway`)
- }
- setBusy(true)
- postPurchase({
- storeId: 's1', supplierId: supplier.id, invoiceNo: invoiceNo.trim(), invoiceDate,
- businessDate: today,
- lines: lines.map((l) => ({
- itemId: l.itemId, name: l.name, qty: l.qty, unitCostPaise: l.unitCostPaise,
- taxRateBp: l.taxRateBp,
- ...(l.newSalePricePaise !== undefined ? { newSalePricePaise: l.newSalePricePaise } : {}),
- ...(l.newMrpPaise !== undefined ? { newMrpPaise: l.newMrpPaise } : {}),
- // S5: batch/expiry ride with a batch-tracked line; the server upserts the lot.
- ...(l.batchTracked && l.batchNo !== undefined && l.batchNo !== '' ? { batchNo: l.batchNo } : {}),
- ...(l.batchTracked && l.expiryDate !== undefined && l.expiryDate !== '' ? { expiryDate: l.expiryDate } : {}),
- })),
- clientTotalPaise: totals.total,
- })
- .then((res) => {
- say('ok', `Purchase posted — ${lines.length} lines, ${formatINR(res.totalPaise)}. Stock is in; price updates applied.`)
- // Label queue (09-UX §3.2): received qty each, MRP/price effective after the post,
- // first barcode from the item master. The clerk prints on the spot.
- setLabelQueue(lines.map((l): LabelInput => {
- const item = items.find((i) => i.id === l.itemId)
- const mrp = l.newMrpPaise ?? l.currentMrpPaise ?? l.newSalePricePaise ?? l.currentSalePaise
- const barcode = item?.barcodes.find((b) => b !== '')
- return {
- name: l.name, mrpPaise: mrp, pricePaise: l.newSalePricePaise ?? l.currentSalePaise,
- ...(barcode !== undefined ? { code13: barcode } : {}),
- }
- }))
- setLines([]); setInvoiceNo(''); setPrintedTotal('')
- localStorage.removeItem(DRAFT_KEY)
- getLastCosts(supplier.id).then(setLastCost).catch(() => undefined)
- })
- .catch((err: Error) => say('err', `${err.message} — nothing was posted`))
- .finally(() => setBusy(false))
- }
-
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- if (e.key === 'F12' || (e.ctrlKey && e.key === 'Enter')) { e.preventDefault(); post() }
- if (e.key === 'Escape') { setFocusCell(undefined); setNotice(undefined) }
- }
- window.addEventListener('keydown', onKey)
- return () => window.removeEventListener('keydown', onKey)
- })
-
- const supplierMatches = supplierQuery === '' ? [] :
- suppliers.filter((s) => s.name.toLowerCase().includes(supplierQuery.toLowerCase())).slice(0, 6)
-
- return (
-
-
{busy ? 'Posting…' : 'Post purchase'}}
- />
-
-
-
- {supplier === undefined ? (
- <>
-
setSupplierQuery(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && supplierMatches[0] !== undefined) {
- setSupplier(supplierMatches[0]); setSupplierQuery('')
- }
- }}
- />
- {supplierMatches.length > 0 && (
-
- {supplierMatches.map((s) => (
-
{ setSupplier(s); setSupplierQuery('') }}>{s.name}
- ))}
-
- )}
- >
- ) : (
-
{supplier.name} ✕
- )}
- {supplier !== undefined && (
-
setSupplier(undefined)}>Change
- )}
-
- setInvoiceNo(e.target.value)} />
- setInvoiceDate(e.target.value)} />
-
-
- setEntry(e.target.value)}
- onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleEntry() } }}
- />
- {notice !== undefined && {notice.text} }
-
- {labelQueue !== undefined && (
-
- Print labels for {labelQueue.length} received item(s)?{' '}
- { const err = openLabelSheet(labelQueue); if (err !== undefined) say('warn', err) }}>Print labels ↗ {' '}
- setLabelQueue(undefined)}>Dismiss
-
- )}
-
- {draft !== undefined && (
-
- Unfinished entry found — {draft.invoiceNo !== '' ? draft.invoiceNo : 'no invoice no'} with {draft.lines.length} line(s).{' '}
- {
- setLines(draft.lines)
- setInvoiceNo(draft.invoiceNo)
- setInvoiceDate(draft.invoiceDate)
- if (draft.supplier !== undefined) setSupplier(draft.supplier)
- setDraft(undefined)
- }}>Resume {' '}
- { localStorage.removeItem(DRAFT_KEY); setDraft(undefined) }}>Discard
-
- )}
-
- {focusCell !== undefined && lines[focusCell.line] !== undefined && (() => {
- const l = lines[focusCell.line]!
- const item = items.find((i) => i.id === l.itemId)
- const margin = l.unitCostPaise > 0 ? ((l.currentSalePaise - l.unitCostPaise) / l.unitCostPaise) * 100 : 0
- return (
-
- {l.name} · stock {item !== undefined ? (stockMap.get(item.code) ?? '—') : '—'} {l.unitCode}
- · sale {inr(l.currentSalePaise)}{l.currentMrpPaise !== undefined ? ` · MRP ${inr(l.currentMrpPaise)}` : ''}
- · margin at this cost {margin.toFixed(1)}%
- {costHist !== undefined && costHist.length > 0 && (
- <> · last costs: {costHist.map((h) => `${inr(Number(h.cost))} (${String(h.date)})`).join(' · ')}>
- )}
-
- )
- })()}
-
- {lines.length === 0 ? (
- Pick the supplier, then scan the first line of the invoice.
- ) : (
-
-
-
- # Item Qty Cost ₹
- {anyBatch && <>Batch Expiry >}
- Last GST Line total Price
-
-
-
- {lines.map((l, i) => {
- const lineTaxable = Math.round(l.qty * l.unitCostPaise)
- const lineTax = Math.round((lineTaxable * l.taxRateBp) / 10_000)
- const costChanged = l.lastCostPaise !== undefined && l.unitCostPaise !== l.lastCostPaise && l.unitCostPaise > 0
- const editing = (kind: 'qty' | 'cost' | 'batch' | 'expiry') =>
- focusCell?.line === i && focusCell.cell === kind
- // Enter chain: qty → cost → (batch-tracked ? batch → expiry :) scan box.
- const cell = (kind: 'qty' | 'cost' | 'batch' | 'expiry') => {
- if (editing(kind) && kind === 'expiry') {
- return (
- {
- if (e.key !== 'Enter' && e.key !== 'Tab') return
- e.preventDefault()
- const v = (e.target as HTMLInputElement).value
- setLine(i, { expiryDate: v !== '' ? v : undefined })
- setFocusCell(undefined) // blank Enter skips expiry for non-perishables → scan box
- }}
- />
- )
- }
- if (editing(kind) && kind === 'batch') {
- return (
- {
- if (e.key !== 'Enter' && e.key !== 'Tab') return
- e.preventDefault()
- const v = (e.target as HTMLInputElement).value.trim()
- setLine(i, { batchNo: v !== '' ? v : undefined })
- setFocusCell({ line: i, cell: 'expiry' })
- }}
- />
- )
- }
- if (editing(kind) && (kind === 'qty' || kind === 'cost')) {
- return (
- {
- if (e.key !== 'Enter' && e.key !== 'Tab') return
- e.preventDefault()
- const v = (e.target as HTMLInputElement).value
- if (kind === 'qty') {
- const q = Number(v)
- if (q > 0) setLine(i, { qty: q })
- setFocusCell({ line: i, cell: 'cost' })
- } else {
- setLine(i, { unitCostPaise: rs(v) })
- // batch-tracked lines continue to batch → expiry; others return to the scan box
- setFocusCell(l.batchTracked ? { line: i, cell: 'batch' } : undefined)
- }
- }}
- />
- )
- }
- // display / click-to-focus
- if (kind === 'batch' || kind === 'expiry') {
- if (!l.batchTracked) return —
- const shown = kind === 'batch' ? (l.batchNo ?? 'set…') : (l.expiryDate ?? '—')
- return (
- setFocusCell({ line: i, cell: kind })}>{shown}
- )
- }
- return (
- setFocusCell({ line: i, cell: kind })}>
- {kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)}
-
- )
- }
- return (
-
- {i + 1}
- {l.name}
- {cell('qty')}
- {cell('cost')}
- {anyBatch && <>{cell('batch')} {cell('expiry')} >}
- {l.lastCostPaise !== undefined ? inr(l.lastCostPaise) : '—'}
- {l.taxRateBp / 100}%
- {inr(lineTaxable + lineTax)}
-
- {costChanged ? (
- setLine(i, p)} />
- ) : l.newSalePricePaise !== undefined ? (
- new ₹{(l.newSalePricePaise / 100).toFixed(2)}
- ) : (
- keep
- )}
-
-
- )
- })}
-
-
- )}
-
-
- Taxable {formatINR(totals.taxable)}
- GST {formatINR(totals.tax)}
- Total {formatINR(totals.total)}
-
-
- setPrintedTotal(e.target.value)} />
-
- {crossCheck === 'match' && totals match ✓ }
- {crossCheck === 'mismatch' && ≠ computed — check a line }
-
-
- )
-}
-
-/** Inline price-revision prompt when the cost moved — margin at a glance. */
-function PriceRevision(props: { line: PLine; onSet: (patch: Partial) => void }) {
- const { line } = props
- const [open, setOpen] = useState(false)
- const [sale, setSale] = useState((line.currentSalePaise / 100).toFixed(2))
- const [mrp, setMrp] = useState(line.currentMrpPaise !== undefined ? (line.currentMrpPaise / 100).toFixed(2) : '')
- const marginBp = line.unitCostPaise > 0
- ? Math.round(((line.currentSalePaise - line.unitCostPaise) / line.unitCostPaise) * 10_000)
- : 0
-
- if (!open) {
- return (
-
- cost changed · margin {(marginBp / 100).toFixed(1)}% {' '}
- setOpen(true)}>Revise price…
-
- )
- }
- return (
-
- setSale(e.target.value)} placeholder="Sale ₹" />
- setMrp(e.target.value)} placeholder="MRP ₹" />
- {
- props.onSet({
- newSalePricePaise: rs(sale),
- ...(mrp !== '' ? { newMrpPaise: rs(mrp) } : {}),
- })
- setOpen(false)
- }}>Set
-
- )
-}
-
-export function PurchasesListPage() {
- const [data, setData] = useState[] | undefined>()
- const [error, setError] = useState()
- useEffect(() => {
- getPurchases().then(setData).catch((e: Error) => setError(e.message))
- }, [])
- return (
-
-
- {error !== undefined &&
{error} }
- {data === undefined ?
Loading… : data.length === 0 ? (
-
No purchases yet — enter one in Purchase Entry.
- ) : (
-
({
- inv: String(p['invoice_no']), supplier: String(p['supplier_name'] ?? ''),
- date: String(p['invoice_date']), lines: String(p['line_count']),
- total: inr(Number(p['total_paise'])),
- }))}
- />
- )}
-
- )
-}
diff --git a/apps/backoffice/src/pages/registry.tsx b/apps/backoffice/src/pages/registry.tsx
deleted file mode 100644
index ff0c61e..0000000
--- a/apps/backoffice/src/pages/registry.tsx
+++ /dev/null
@@ -1,673 +0,0 @@
-import type { Column } from '@sims/ui'
-import type { ReactNode } from 'react'
-import { Badge } from '@sims/ui'
-
-/**
- * Wireframe pages as data: one config per list page, one generic renderer.
- * Sample rows are grocery-retail realistic so flows read true in review.
- */
-export interface PageConfig {
- path: string
- title: string
- desc: string
- actions: string[]
- filters?: string[]
- stats?: { label: string; value: string; hint?: string }[]
- columns: Column[]
- rows: Record[]
-}
-
-const ok = (t: string) => {t}
-const warn = (t: string) => {t}
-const err = (t: string) => {t}
-const dim = (t: string) => {t}
-
-export const PAGES: PageConfig[] = [
- {
- path: '/catalog/items',
- title: 'Items',
- desc: 'The item master — HSN, GST class, MRP, barcodes, units. Bulk edit and Excel import/export.',
- actions: ['New item', 'Import Excel', 'Export', 'Bulk edit'],
- filters: ['Category', 'GST class', 'Status', 'Batch-tracked'],
- stats: [
- { label: 'Active items', value: '12,482' },
- { label: 'Draft (from POS quick-add)', value: '17', hint: 'need completion' },
- { label: 'Without barcode', value: '318' },
- ],
- columns: [
- { key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, { key: 'hsn', label: 'HSN' },
- { key: 'gst', label: 'GST' }, { key: 'mrp', label: 'MRP', numeric: true },
- { key: 'price', label: 'Sale Price', numeric: true }, { key: 'stock', label: 'Stock', numeric: true },
- { key: 'status', label: 'Status' },
- ],
- rows: [
- { code: '101', name: 'Aashirvaad Atta 5kg', hsn: '1101', gst: '0%', mrp: '285.00', price: '270.00', stock: '142', status: ok('active') },
- { code: '103', name: 'Surf Excel 1kg', hsn: '3402', gst: '18%', mrp: '150.00', price: '145.00', stock: '86', status: ok('active') },
- { code: '42', name: 'Tomato (loose)', hsn: '0702', gst: '0%', mrp: '—', price: '32.00/kg', stock: '48.2 kg', status: ok('active') },
- { code: 'D-2201', name: 'ORS Sachet (POS quick-add)', hsn: '—', gst: '—', mrp: '—', price: '20.00', stock: '—', status: warn('draft') },
- ],
- },
- {
- path: '/catalog/categories',
- title: 'Categories',
- desc: 'Category tree with GST-class defaults that new items inherit.',
- actions: ['New category'],
- columns: [
- { key: 'name', label: 'Category' }, { key: 'items', label: 'Items', numeric: true },
- { key: 'gst', label: 'Default GST' }, { key: 'margin', label: 'Target margin', numeric: true },
- ],
- rows: [
- { name: 'Staples › Flour & Grains', items: '214', gst: '0%', margin: '8%' },
- { name: 'Home Care › Detergents', items: '186', gst: '18%', margin: '14%' },
- { name: 'Fresh › Vegetables', items: '64', gst: '0%', margin: '22%' },
- ],
- },
- {
- path: '/catalog/price-lists',
- title: 'Price Lists',
- desc: 'MRP, margin and customer-category pricing with effective dates.',
- actions: ['New price list', 'Bulk revise'],
- columns: [
- { key: 'name', label: 'Price list' }, { key: 'basis', label: 'Basis' },
- { key: 'items', label: 'Items', numeric: true }, { key: 'from', label: 'Effective from' },
- ],
- rows: [
- { name: 'Retail (default)', basis: 'MRP − margin', items: '12,482', from: '2026-04-01' },
- { name: 'Wholesale', basis: 'Cost + 4%', items: '2,140', from: '2026-04-01' },
- ],
- },
- {
- path: '/catalog/schemes',
- title: 'Schemes & Offers',
- desc: 'Buy-X-get-Y, slab discounts, date-bound offers — evaluated by the billing engine.',
- actions: ['New scheme'],
- columns: [
- { key: 'name', label: 'Scheme' }, { key: 'kind', label: 'Type' },
- { key: 'window', label: 'Window' }, { key: 'status', label: 'Status' },
- ],
- rows: [
- { name: 'Atta+Ghee combo −₹40', kind: 'Basket', window: '01 Jul – 31 Jul', status: ok('live') },
- { name: 'Onam 5% off staples', kind: 'Category %', window: '20 Aug – 05 Sep', status: dim('scheduled') },
- ],
- },
- {
- path: '/catalog/labels',
- title: 'Label Printing',
- desc: 'Barcode/MRP label queue — filled automatically from purchases.',
- actions: ['Print queue', 'Design labels'],
- columns: [
- { key: 'item', label: 'Item' }, { key: 'batch', label: 'Batch' },
- { key: 'qty', label: 'Labels', numeric: true }, { key: 'src', label: 'Source' },
- ],
- rows: [
- { item: 'Surf Excel 1kg', batch: 'B-0714', qty: '48', src: 'Purchase PI-000212' },
- { item: 'Amul Butter 500g', batch: 'B-0713', qty: '24', src: 'Purchase PI-000211' },
- ],
- },
- {
- path: '/sales/bills',
- title: 'Bills',
- desc: 'Every bill from every counter — immutable documents; corrections are credit notes.',
- actions: ['Export', 'Print duplicate'],
- filters: ['Store', 'Counter', 'Cashier', 'Payment mode', 'Date'],
- stats: [
- { label: "Today's sales", value: '₹1,84,320' },
- { label: 'Bills', value: '412' },
- { label: 'Avg bill value', value: '₹447' },
- ],
- columns: [
- { key: 'no', label: 'Bill No' }, { key: 'time', label: 'Time' }, { key: 'counter', label: 'Counter' },
- { key: 'cashier', label: 'Cashier' }, { key: 'mode', label: 'Mode' },
- { key: 'amount', label: 'Amount', numeric: true },
- ],
- rows: [
- { no: 'ST1C2/26-000412', time: '18:42', counter: 'C2', cashier: 'Ramesh', mode: dim('UPI'), amount: '1,431.00' },
- { no: 'ST1C1/26-000388', time: '18:41', counter: 'C1', cashier: 'Divya', mode: dim('CASH'), amount: '284.00' },
- { no: 'ST1C2/26-000411', time: '18:39', counter: 'C2', cashier: 'Ramesh', mode: dim('KHATA'), amount: '2,110.00' },
- ],
- },
- {
- path: '/sales/returns',
- title: 'Returns & Credit Notes',
- desc: 'Returns reference the original bill; taxes reverse at the original bill-date rates.',
- actions: ['New return'],
- columns: [
- { key: 'no', label: 'Credit Note' }, { key: 'against', label: 'Against Bill' },
- { key: 'reason', label: 'Reason' }, { key: 'amount', label: 'Amount', numeric: true },
- ],
- rows: [
- { no: 'CN/26-000031', against: 'ST1C2/26-000381', reason: 'Damaged pack', amount: '145.00' },
- { no: 'CN/26-000030', against: 'ST1C1/26-000352', reason: 'Wrong size', amount: '499.00' },
- ],
- },
- {
- path: '/sales/customers',
- title: 'Customers',
- desc: 'Phone-number keyed profiles with purchase history, khata and loyalty.',
- actions: ['New customer', 'Import', 'Export'],
- filters: ['Khata due', 'Loyalty tier', 'Inactive 90d'],
- columns: [
- { key: 'phone', label: 'Phone' }, { key: 'name', label: 'Name' },
- { key: 'visits', label: 'Visits', numeric: true }, { key: 'khata', label: 'Khata', numeric: true },
- { key: 'points', label: 'Points', numeric: true },
- ],
- rows: [
- { phone: '98400 12345', name: 'Lakshmi', visits: '86', khata: '1,240.00 dr', points: '320' },
- { phone: '98470 55210', name: 'Joseph', visits: '41', khata: '—', points: '110' },
- ],
- },
- {
- path: '/sales/khata',
- title: 'Khata & Collections',
- desc: 'Credit ledger with ageing and WhatsApp payment reminders + UPI collect links.',
- actions: ['Record payment', 'Send reminders'],
- stats: [
- { label: 'Outstanding', value: '₹86,420' },
- { label: 'Over 30 days', value: '₹12,340', hint: '9 customers' },
- ],
- columns: [
- { key: 'name', label: 'Customer' }, { key: 'due', label: 'Due', numeric: true },
- { key: 'age', label: 'Oldest', numeric: true }, { key: 'last', label: 'Last payment' },
- { key: 'remind', label: 'Reminder' },
- ],
- rows: [
- { name: 'Lakshmi', due: '1,240.00', age: '12 d', last: '28 Jun', remind: ok('sent today') },
- { name: 'Anand Stores (B2B)', due: '22,600.00', age: '38 d', last: '01 Jun', remind: warn('due') },
- ],
- },
- {
- path: '/sales/loyalty',
- title: 'Loyalty',
- desc: 'Points earn/redeem rules; redemption happens at the counter.',
- actions: ['Configure rules'],
- columns: [
- { key: 'tier', label: 'Tier' }, { key: 'members', label: 'Members', numeric: true },
- { key: 'earn', label: 'Earn rate' }, { key: 'redeemed', label: 'Redeemed MTD', numeric: true },
- ],
- rows: [
- { tier: 'Green', members: '3,204', earn: '1 pt / ₹100', redeemed: '₹8,420' },
- { tier: 'Gold', members: '412', earn: '2 pt / ₹100', redeemed: '₹6,110' },
- ],
- },
- {
- path: '/purchases/list',
- title: 'Purchase List',
- desc: 'All purchase invoices — manual entry or Purchase Inbox.',
- actions: ['New purchase', 'Export'],
- filters: ['Supplier', 'Status', 'Date'],
- columns: [
- { key: 'no', label: 'Purchase' }, { key: 'supplier', label: 'Supplier' }, { key: 'date', label: 'Date' },
- { key: 'src', label: 'Source' }, { key: 'amount', label: 'Amount', numeric: true }, { key: 'status', label: 'Status' },
- ],
- rows: [
- { no: 'PI-000212', supplier: 'HUL Distributor', date: '08 Jul', src: dim('Inbox'), amount: '48,210.00', status: ok('posted') },
- { no: 'PI-000211', supplier: 'Amul Agency', date: '08 Jul', src: dim('manual'), amount: '22,400.00', status: ok('posted') },
- { no: 'PI-000210', supplier: 'Local Farm Veg', date: '07 Jul', src: dim('manual'), amount: '8,150.00', status: warn('draft') },
- ],
- },
- {
- path: '/purchases/entry',
- title: 'Purchase Entry',
- desc: 'Speed-first manual entry: supplier → lines by barcode/search → margin & MRP prompts → label queue.',
- actions: ['Start entry (wireframe form)'],
- columns: [
- { key: 'step', label: '#' }, { key: 'what', label: 'Flow step (09-UX §3)' }, { key: 'keys', label: 'Keystrokes' },
- ],
- rows: [
- { step: '1', what: 'Pick supplier (type-ahead, remembers last)', keys: '~4' },
- { step: '2', what: 'Invoice no + date (defaults to today)', keys: '~8' },
- { step: '3', what: 'Scan/type item → qty → cost; price-change prompt if cost moved', keys: '~6/line' },
- { step: '4', what: 'Post → stock in, labels queued, supplier ledger updated', keys: '1' },
- ],
- },
- {
- path: '/purchases/suppliers',
- title: 'Suppliers',
- desc: 'Supplier master with GSTIN, ledgers, price history and pending documents.',
- actions: ['New supplier'],
- columns: [
- { key: 'name', label: 'Supplier' }, { key: 'gstin', label: 'GSTIN' },
- { key: 'due', label: 'Payable', numeric: true }, { key: 'last', label: 'Last purchase' },
- ],
- rows: [
- { name: 'HUL Distributor', gstin: '32AABCH1234K1Z6', due: '96,400.00', last: '08 Jul' },
- { name: 'Amul Agency', gstin: '32AAACA5678L1Z2', due: '22,400.00', last: '08 Jul' },
- ],
- },
- {
- path: '/purchases/gstr2b',
- title: 'GSTR-2B Reconciliation',
- desc: 'Portal data vs entered purchases — exact, fuzzy and unmatched tiers.',
- actions: ['Pull 2B', 'Auto-match'],
- stats: [
- { label: 'Matched', value: '218', hint: 'exact' },
- { label: 'Fuzzy', value: '11', hint: 'review' },
- { label: 'Missing in books', value: '3' },
- ],
- columns: [
- { key: 'inv', label: 'Supplier invoice' }, { key: 'supplier', label: 'Supplier' },
- { key: 'portal', label: 'Portal ITC', numeric: true }, { key: 'books', label: 'Books', numeric: true },
- { key: 'state', label: 'Match' },
- ],
- rows: [
- { inv: 'HD/2647', supplier: 'HUL Distributor', portal: '7,344.00', books: '7,344.00', state: ok('exact') },
- { inv: 'AA/889', supplier: 'Amul Agency', portal: '2,688.00', books: '2,688.10', state: warn('off by 0.10') },
- { inv: 'XY/102', supplier: 'Unknown GSTIN', portal: '1,120.00', books: '—', state: err('missing') },
- ],
- },
- {
- path: '/inventory/stock',
- title: 'Stock',
- desc: 'Live stock by store — derived from movements, never a stored number (invariant I4).',
- actions: ['Export', 'Reorder suggestions'],
- filters: ['Store', 'Category', 'Below reorder', 'Batch'],
- columns: [
- { key: 'item', label: 'Item' }, { key: 'store', label: 'Store' },
- { key: 'qty', label: 'On hand', numeric: true }, { key: 'value', label: 'Value', numeric: true },
- { key: 'reorder', label: 'Reorder' },
- ],
- rows: [
- { item: 'Aashirvaad Atta 5kg', store: 'ST1', qty: '142', value: '35,742.00', reorder: ok('ok') },
- { item: 'Tata Salt 1kg', store: 'ST1', qty: '12', value: '288.00', reorder: warn('below min 24') },
- { item: 'Coca-Cola 1.25L', store: 'ST1', qty: '0', value: '0.00', reorder: err('out') },
- ],
- },
- {
- path: '/inventory/adjustments',
- title: 'Adjustments',
- desc: 'Stock corrections with reason codes and approval — every one audit-logged.',
- actions: ['New adjustment'],
- columns: [
- { key: 'no', label: 'Adj No' }, { key: 'item', label: 'Item' }, { key: 'qty', label: 'Qty', numeric: true },
- { key: 'reason', label: 'Reason' }, { key: 'by', label: 'By' }, { key: 'status', label: 'Status' },
- ],
- rows: [
- { no: 'ADJ-0087', item: 'Tomato (loose)', qty: '−2.4 kg', reason: 'Wastage', by: 'Suresh', status: ok('approved') },
- { no: 'ADJ-0086', item: 'Parle-G 800g', qty: '−3', reason: 'Damaged', by: 'Ramesh', status: warn('pending') },
- ],
- },
- {
- path: '/inventory/transfers',
- title: 'Transfers',
- desc: 'Inter-store transfers with in-transit state and e-Way bills where value demands.',
- actions: ['New transfer'],
- columns: [
- { key: 'no', label: 'Transfer' }, { key: 'from', label: 'From' }, { key: 'to', label: 'To' },
- { key: 'items', label: 'Items', numeric: true }, { key: 'state', label: 'State' },
- ],
- rows: [
- { no: 'TR-0012', from: 'ST1', to: 'ST2', items: '38', state: warn('in transit') },
- { no: 'TR-0011', from: 'ST2', to: 'ST1', items: '12', state: ok('received') },
- ],
- },
- {
- path: '/inventory/stock-take',
- title: 'Stock Take',
- desc: 'Full and cycle counts with mobile scanning; variances become adjustments.',
- actions: ['Start session'],
- columns: [
- { key: 'session', label: 'Session' }, { key: 'scope', label: 'Scope' },
- { key: 'counted', label: 'Counted', numeric: true }, { key: 'variance', label: 'Variance', numeric: true },
- { key: 'status', label: 'Status' },
- ],
- rows: [
- { session: 'CT-2026-07A', scope: 'Fresh + Staples', counted: '278 / 278', variance: '−₹1,240', status: ok('closed') },
- { session: 'CT-2026-07B', scope: 'Home Care', counted: '112 / 186', variance: '—', status: warn('counting') },
- ],
- },
- {
- path: '/inventory/expiry',
- title: 'Expiry Dashboard',
- desc: 'Near-expiry, dead stock, fast/slow movers — act before it becomes wastage.',
- actions: ['Markdown near-expiry'],
- stats: [
- { label: 'Expiring ≤ 7 days', value: '₹4,320', hint: '18 batches' },
- { label: 'Dead stock (90d)', value: '₹18,200' },
- ],
- columns: [
- { key: 'item', label: 'Item' }, { key: 'batch', label: 'Batch' },
- { key: 'expiry', label: 'Expiry' }, { key: 'qty', label: 'Qty', numeric: true },
- ],
- rows: [
- { item: 'Amul Butter 500g', batch: 'B-0698', expiry: '14 Jul', qty: '9' },
- { item: 'Maggi 12-pack', batch: 'B-0641', expiry: '19 Jul', qty: '14' },
- ],
- },
- {
- path: '/accounting/vouchers',
- title: 'Vouchers',
- desc: 'Payment, receipt and expense vouchers feeding the ledgers.',
- actions: ['New voucher'],
- columns: [
- { key: 'no', label: 'Voucher' }, { key: 'kind', label: 'Type' }, { key: 'party', label: 'Party' },
- { key: 'amount', label: 'Amount', numeric: true },
- ],
- rows: [
- { no: 'PV-0231', kind: 'Payment', party: 'HUL Distributor', amount: '50,000.00' },
- { no: 'RV-0140', kind: 'Receipt', party: 'Anand Stores', amount: '10,000.00' },
- { no: 'EX-0088', kind: 'Expense', party: 'KSEB (electricity)', amount: '8,420.00' },
- ],
- },
- {
- path: '/accounting/ledgers',
- title: 'Ledgers',
- desc: 'Party and account ledgers with ageing.',
- actions: ['Export', 'Statement'],
- columns: [
- { key: 'ledger', label: 'Ledger' }, { key: 'group', label: 'Group' },
- { key: 'dr', label: 'Dr', numeric: true }, { key: 'cr', label: 'Cr', numeric: true },
- { key: 'bal', label: 'Balance', numeric: true },
- ],
- rows: [
- { ledger: 'Cash in hand', group: 'Cash', dr: '2,84,000', cr: '2,10,000', bal: '74,000 dr' },
- { ledger: 'HUL Distributor', group: 'Sundry Creditors', dr: '3,90,000', cr: '4,86,400', bal: '96,400 cr' },
- ],
- },
- {
- path: '/accounting/day-book',
- title: 'Day Book',
- desc: 'Everything that happened on a working date — the OG Day concept, reported.',
- actions: ['Print', 'Export'],
- columns: [
- { key: 'time', label: 'Time' }, { key: 'doc', label: 'Document' }, { key: 'party', label: 'Party' },
- { key: 'dr', label: 'Dr', numeric: true }, { key: 'cr', label: 'Cr', numeric: true },
- ],
- rows: [
- { time: '18:42', doc: 'Bill ST1C2/26-000412', party: 'Lakshmi', dr: '—', cr: '1,431.00' },
- { time: '17:10', doc: 'PV-0231', party: 'HUL Distributor', dr: '50,000.00', cr: '—' },
- ],
- },
- {
- path: '/accounting/pnl',
- title: 'P&L / Balance Sheet',
- desc: 'Financial statements (Pro). Most tenants export to Tally for their CA first (D6).',
- actions: ['This FY', 'Export'],
- columns: [
- { key: 'line', label: 'Line' }, { key: 'mtd', label: 'MTD', numeric: true }, { key: 'ytd', label: 'YTD', numeric: true },
- ],
- rows: [
- { line: 'Sales', mtd: '₹41,20,000', ytd: '₹1,42,80,000' },
- { line: 'COGS', mtd: '₹35,40,000', ytd: '₹1,22,10,000' },
- { line: 'Gross profit', mtd: '₹5,80,000', ytd: '₹20,70,000' },
- ],
- },
- {
- path: '/accounting/tally',
- title: 'Tally Export',
- desc: 'Masters + vouchers as Tally XML — the CA keeps their workflow, we keep the customer.',
- actions: ['Export this month', 'Configure mapping'],
- columns: [
- { key: 'run', label: 'Export run' }, { key: 'range', label: 'Range' },
- { key: 'vouchers', label: 'Vouchers', numeric: true }, { key: 'status', label: 'Status' },
- ],
- rows: [
- { run: 'TX-0007', range: 'Jun 2026', vouchers: '1,842', status: ok('downloaded') },
- { run: 'TX-0006', range: 'May 2026', vouchers: '1,711', status: ok('downloaded') },
- ],
- },
- {
- path: '/gst/gstr1',
- title: 'GSTR-1',
- desc: 'Outward supplies return — B2B/B2C summaries and JSON export for the portal.',
- actions: ['Prepare Jun 2026', 'Export JSON'],
- stats: [
- { label: 'B2B invoices', value: '214' },
- { label: 'B2C (large)', value: '3' },
- { label: 'B2C (others)', value: '₹38,41,200' },
- ],
- columns: [
- { key: 'section', label: 'Section' }, { key: 'count', label: 'Docs', numeric: true },
- { key: 'taxable', label: 'Taxable', numeric: true }, { key: 'tax', label: 'Tax', numeric: true },
- ],
- rows: [
- { section: 'B2B', count: '214', taxable: '₹6,84,200', tax: '₹88,340' },
- { section: 'B2CS', count: '—', taxable: '₹32,10,400', tax: '₹2,41,080' },
- { section: 'CDNR (credit notes)', count: '31', taxable: '−₹41,200', tax: '−₹5,120' },
- ],
- },
- {
- path: '/gst/gstr3b',
- title: 'GSTR-3B',
- desc: 'Summary return with ITC set-off preview.',
- actions: ['Prepare Jun 2026'],
- columns: [
- { key: 'table', label: 'Table' }, { key: 'igst', label: 'IGST', numeric: true },
- { key: 'cgst', label: 'CGST', numeric: true }, { key: 'sgst', label: 'SGST', numeric: true },
- ],
- rows: [
- { table: '3.1 Outward supplies', igst: '12,400', cgst: '1,58,200', sgst: '1,58,200' },
- { table: '4 Eligible ITC', igst: '8,120', cgst: '1,12,340', sgst: '1,12,340' },
- ],
- },
- {
- path: '/gst/einvoice',
- title: 'e-Invoice Queue',
- desc: 'IRN registration for B2B bills — offline bills queue and register when online; the escalation ladder guards the reporting window (Issue GST-8).',
- actions: ['Retry failed'],
- stats: [
- { label: 'Registered today', value: '38' },
- { label: 'Queued (offline)', value: '2', hint: 'oldest 40 min' },
- { label: 'Failed', value: '1', hint: 'needs attention' },
- ],
- columns: [
- { key: 'bill', label: 'Bill' }, { key: 'buyer', label: 'Buyer GSTIN' },
- { key: 'state', label: 'IRN' }, { key: 'age', label: 'Age' },
- ],
- rows: [
- { bill: 'ST1C1/26-000372', buyer: '32AABCA1111M1Z3', state: ok('registered'), age: '2 min' },
- { bill: 'ST1C2/26-000398', buyer: '29AAACB2222N1Z5', state: warn('queued'), age: '40 min' },
- { bill: 'ST1C1/26-000355', buyer: '32AABCC3333P1Z7', state: err('GSP error 2150'), age: '3 h' },
- ],
- },
- {
- path: '/gst/eway',
- title: 'e-Way Bills',
- desc: 'Generated for goods movement above threshold — transfers and B2B deliveries.',
- actions: ['New e-Way'],
- columns: [
- { key: 'no', label: 'e-Way No' }, { key: 'doc', label: 'Against' },
- { key: 'vehicle', label: 'Vehicle' }, { key: 'valid', label: 'Valid till' },
- ],
- rows: [
- { no: 'EWB-3312 4410 0921', doc: 'TR-0012', vehicle: 'KL-07-AB-1234', valid: '10 Jul 23:59' },
- ],
- },
- {
- path: '/gst/hsn',
- title: 'HSN Summary',
- desc: 'HSN-wise outward summary as GSTR-1 wants it.',
- actions: ['Export'],
- columns: [
- { key: 'hsn', label: 'HSN' }, { key: 'desc', label: 'Description' },
- { key: 'qty', label: 'Qty', numeric: true }, { key: 'taxable', label: 'Taxable', numeric: true },
- { key: 'tax', label: 'Tax', numeric: true },
- ],
- rows: [
- { hsn: '1101', desc: 'Wheat flour', qty: '2,140', taxable: '₹5,64,200', tax: '₹0' },
- { hsn: '3402', desc: 'Detergents', qty: '860', taxable: '₹1,10,300', tax: '₹19,854' },
- ],
- },
- {
- path: '/reports/sales',
- title: 'Sales Reports',
- desc: 'By day, category, counter, cashier, payment mode — export everywhere.',
- actions: ['Export', 'Schedule digest'],
- filters: ['Range', 'Store', 'Group by'],
- columns: [
- { key: 'day', label: 'Day' }, { key: 'bills', label: 'Bills', numeric: true },
- { key: 'sales', label: 'Sales', numeric: true }, { key: 'gst', label: 'GST', numeric: true },
- { key: 'abv', label: 'Avg bill', numeric: true },
- ],
- rows: [
- { day: 'Wed 08 Jul', bills: '498', sales: '₹2,21,400', gst: '₹18,320', abv: '₹445' },
- { day: 'Tue 07 Jul', bills: '451', sales: '₹1,98,100', gst: '₹16,110', abv: '₹439' },
- ],
- },
- {
- path: '/reports/margins',
- title: 'Margins',
- desc: 'Realized margin by item/category — where the money actually is.',
- actions: ['Export'],
- columns: [
- { key: 'cat', label: 'Category' }, { key: 'sales', label: 'Sales', numeric: true },
- { key: 'margin', label: 'Margin ₹', numeric: true }, { key: 'pct', label: 'Margin %', numeric: true },
- ],
- rows: [
- { cat: 'Fresh › Vegetables', sales: '₹28,400', margin: '₹6,240', pct: '22.0%' },
- { cat: 'Staples', sales: '₹84,100', margin: '₹6,730', pct: '8.0%' },
- ],
- },
- {
- path: '/reports/stock-aging',
- title: 'Stock Aging',
- desc: 'How long stock has been sitting, by bucket.',
- actions: ['Export'],
- columns: [
- { key: 'bucket', label: 'Age bucket' }, { key: 'items', label: 'Items', numeric: true },
- { key: 'value', label: 'Value', numeric: true },
- ],
- rows: [
- { bucket: '0–30 days', items: '9,240', value: '₹18,42,000' },
- { bucket: '31–90 days', items: '2,180', value: '₹4,10,000' },
- { bucket: '> 90 days', items: '412', value: '₹98,000' },
- ],
- },
- {
- path: '/reports/day-end',
- title: 'Day-End Digest',
- desc: 'The one-page close of day per store — also lands on WhatsApp at Day End.',
- actions: ['Print today', 'Send to WhatsApp'],
- columns: [
- { key: 'k', label: '' }, { key: 'v', label: 'Value', numeric: true },
- ],
- rows: [
- { k: 'Sales (412 bills)', v: '₹1,84,320' },
- { k: 'Cash / UPI / Card / Khata', v: '₹92,140 / 61,180 / 18,600 / 12,400' },
- { k: 'Cash variance (C1, C2)', v: '+₹10 / −₹0' },
- { k: 'Returns', v: '₹644' },
- { k: 'New khata given', v: '₹12,400' },
- ],
- },
- {
- path: '/admin/stores',
- title: 'Stores & Counters',
- desc: 'Stores, counters, per-counter GST series and print profiles, Store Hub role.',
- actions: ['New store', 'New counter'],
- columns: [
- { key: 'store', label: 'Store' }, { key: 'counter', label: 'Counter' },
- { key: 'series', label: 'Series' }, { key: 'printer', label: 'Printer' }, { key: 'status', label: 'Status' },
- ],
- rows: [
- { store: 'ST1 T.Nagar', counter: 'C1', series: 'ST1C1/26-000388', printer: '192.168.1.101', status: ok('online') },
- { store: 'ST1 T.Nagar', counter: 'C2', series: 'ST1C2/26-000412', printer: '192.168.1.100', status: ok('online') },
- { store: 'ST2 Velachery', counter: 'C1', series: 'ST2C1/26-000221', printer: '192.168.2.100', status: warn('sync lag 22 min') },
- ],
- },
- {
- path: '/admin/templates',
- title: 'Print Templates',
- desc: 'Receipt/invoice/label layouts with preview — per-tenant customization without code.',
- actions: ['New template', 'Preview on counter'],
- columns: [
- { key: 'name', label: 'Template' }, { key: 'kind', label: 'Kind' },
- { key: 'paper', label: 'Paper' }, { key: 'usedBy', label: 'Used by' },
- ],
- rows: [
- { name: 'Receipt (default)', kind: 'receipt', paper: '3" thermal', usedBy: 'All counters' },
- { name: 'B2B Invoice', kind: 'invoice', paper: 'A4', usedBy: 'Back office' },
- { name: 'MRP Label 38×25', kind: 'label', paper: 'Label roll', usedBy: 'Label queue' },
- ],
- },
- {
- path: '/admin/integrations',
- title: 'Integrations',
- desc: 'WhatsApp, UPI, GSP (e-Invoice/e-Way), email-in address for the Purchase Inbox.',
- actions: ['Connect new'],
- columns: [
- { key: 'name', label: 'Integration' }, { key: 'purpose', label: 'Purpose' }, { key: 'status', label: 'Status' },
- ],
- rows: [
- { name: 'WhatsApp Business', purpose: 'e-bills, khata reminders, digests', status: warn('cloud tier (D14)') },
- { name: 'UPI (PSP)', purpose: 'dynamic QR + auto-confirm', status: warn('cloud tier (D14)') },
- { name: 'GSP', purpose: 'e-Invoice IRN, e-Way', status: warn('choose vendor — D4') },
- { name: 'Purchase Inbox email', purpose: 'bills@st1.sims.in', status: warn('Phase 3') },
- ],
- },
- {
- path: '/admin/subscription',
- title: 'Subscription & Plan',
- desc: 'Plan, counters, renewal — plans and prices are DB rows (D5), upgrades apply in place.',
- actions: ['Change plan', 'Add counter'],
- stats: [
- { label: 'Plan', value: 'Pro' },
- { label: 'Counters', value: '3' },
- { label: 'Renews', value: '01 Apr 2027' },
- ],
- columns: [
- { key: 'feature', label: 'Feature' }, { key: 'plan', label: 'In your plan' },
- ],
- rows: [
- { feature: 'Multi-counter billing', plan: ok('included') },
- { feature: 'Purchase Inbox (AI)', plan: ok('included') },
- { feature: 'Head-office console (multi-store)', plan: warn('Enterprise — tap to see demo') },
- ],
- },
- {
- path: '/admin/audit',
- title: 'Audit Log',
- desc: 'Append-only, before/after values, every sensitive action — the MCA-grade trail.',
- actions: ['Export', 'Filter'],
- filters: ['User', 'Action', 'Date'],
- columns: [
- { key: 'at', label: 'When' }, { key: 'who', label: 'Who' }, { key: 'what', label: 'Action' },
- { key: 'detail', label: 'Before → After' },
- ],
- rows: [
- { at: '18:12', who: 'Suresh', what: 'PRICE_OVERRIDE (approved Ramesh)', detail: 'Surf Excel 145.00 → 140.00 on ST1C2/26-000402' },
- { at: '17:55', who: 'Thomas', what: 'SETTING_CHANGE', detail: 'discount.capBp store ST1: 1000 → 1500' },
- { at: '17:40', who: 'Ramesh', what: 'BILL_REPRINT', detail: 'ST1C2/26-000396 (copy 1, DUPLICATE)' },
- ],
- },
- {
- path: '/admin/sync',
- title: 'Sync & Devices',
- desc: 'Counter devices, outbox depth, last sync — dormant in local-only v1 (D14), observable from day one.',
- actions: ['Re-pair device'],
- columns: [
- { key: 'device', label: 'Device' }, { key: 'counter', label: 'Counter' },
- { key: 'outbox', label: 'Outbox rows', numeric: true }, { key: 'last', label: 'Last drain' }, { key: 'state', label: 'State' },
- ],
- rows: [
- { device: 'POS-7F3A', counter: 'ST1-C1', outbox: '1,204', last: '— (cloud tier off)', state: dim('dormant') },
- { device: 'POS-9B21', counter: 'ST1-C2', outbox: '1,412', last: '— (cloud tier off)', state: dim('dormant') },
- ],
- },
- {
- path: '/admin/backups',
- title: 'Backups',
- desc: 'Store DB snapshots — local now, shipped to cloud when the tier lands. Restore drills are scheduled, not hoped.',
- actions: ['Backup now', 'Restore drill'],
- columns: [
- { key: 'at', label: 'When' }, { key: 'what', label: 'Scope' }, { key: 'size', label: 'Size', numeric: true },
- { key: 'verified', label: 'Verified' },
- ],
- rows: [
- { at: 'Today 02:00', what: 'Oracle store DB + counter SQLite', size: '412 MB', verified: ok('checksum ok') },
- { at: 'Yesterday 02:00', what: 'Oracle store DB + counter SQLite', size: '410 MB', verified: ok('checksum ok') },
- ],
- },
- {
- path: '/admin/import',
- title: 'Data Import',
- desc: 'Excel/CSV for masters + the SiMS Classic migration tool with its verification report.',
- actions: ['Import Excel', 'Run Classic migration'],
- columns: [
- { key: 'run', label: 'Run' }, { key: 'kind', label: 'Kind' }, { key: 'rows', label: 'Rows', numeric: true },
- { key: 'result', label: 'Result' },
- ],
- rows: [
- { run: 'IMP-0004', kind: 'Items (Excel)', rows: '12,482', result: ok('ok — 3 duplicates skipped') },
- { run: 'IMP-0003', kind: 'Classic migration (trial)', rows: '48,211', result: warn('verification: 2 ledger mismatches') },
- ],
- },
-]
diff --git a/apps/backoffice/tsconfig.json b/apps/backoffice/tsconfig.json
deleted file mode 100644
index bf5a36d..0000000
--- a/apps/backoffice/tsconfig.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "include": ["src/**/*"]
-}
diff --git a/apps/backoffice/vite.config.ts b/apps/backoffice/vite.config.ts
deleted file mode 100644
index 4fc47c6..0000000
--- a/apps/backoffice/vite.config.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-import { fileURLToPath } from 'node:url'
-
-const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url))
-
-export default defineConfig({
- base: './',
- plugins: [react()],
- resolve: {
- alias: {
- '@sims/domain': p('../../packages/domain/src/index.ts'),
- '@sims/ui': p('../../packages/ui/src/index.ts'),
- '@sims/scanning': p('../../packages/scanning/src/index.ts'),
- '@sims/search-core': p('../../packages/search-core/src/index.ts'),
- '@sims/billing-engine': p('../../packages/billing-engine/src/index.ts'),
- '@sims/printing': p('../../packages/printing/src/index.ts'),
- },
- },
- server: { port: 5180 },
-})
diff --git a/apps/hq-web/package.json b/apps/hq-web/package.json
index e723952..01de30b 100644
--- a/apps/hq-web/package.json
+++ b/apps/hq-web/package.json
@@ -11,7 +11,6 @@
},
"dependencies": {
"@sims/domain": "*",
- "@sims/printing": "*",
"@sims/ui": "*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
diff --git a/apps/hq-web/vite.config.ts b/apps/hq-web/vite.config.ts
index 28951f2..ba5ce6e 100644
--- a/apps/hq-web/vite.config.ts
+++ b/apps/hq-web/vite.config.ts
@@ -11,7 +11,6 @@ export default defineConfig({
alias: {
'@sims/domain': p('../../packages/domain/src/index.ts'),
'@sims/ui': p('../../packages/ui/src/index.ts'),
- '@sims/printing': p('../../packages/printing/src/index.ts'),
},
},
// 5182 = apps/hq server (5181 store-server, 5180 backoffice dev). /share is the public
diff --git a/apps/pos/README.md b/apps/pos/README.md
deleted file mode 100644
index 0e62bd6..0000000
--- a/apps/pos/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# apps/pos — POS Counter (Electron + React)
-
-The counter shell. Lands with the Phase-0 hardware spike (ESC/POS printing, drawer kick,
-wedge scanner, scale). It is deliberately thin: billing math lives in
-`@sims/billing-engine`, scan handling in `@sims/scanning`, login/PIN/gates in
-`@sims/auth`, settings/messages in `@sims/config` — all already built and tested.
-Screen specs: docs/09-UX-FLOWS-AND-MENUS.md §1.
diff --git a/apps/pos/build-main.mjs b/apps/pos/build-main.mjs
deleted file mode 100644
index a852530..0000000
--- a/apps/pos/build-main.mjs
+++ /dev/null
@@ -1,16 +0,0 @@
-import { build } from 'esbuild'
-
-for (const [entry, out] of [
- ['electron/main.ts', 'dist-electron/main.cjs'],
- ['electron/preload.ts', 'dist-electron/preload.cjs'],
-]) {
- await build({
- entryPoints: [entry],
- outfile: out,
- bundle: true,
- platform: 'node',
- format: 'cjs',
- external: ['electron'],
- })
-}
-console.log('electron main + preload built')
diff --git a/apps/pos/dist-electron/main.cjs b/apps/pos/dist-electron/main.cjs
deleted file mode 100644
index fd44967..0000000
--- a/apps/pos/dist-electron/main.cjs
+++ /dev/null
@@ -1,62 +0,0 @@
-"use strict";
-var __create = Object.create;
-var __defProp = Object.defineProperty;
-var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
-var __getOwnPropNames = Object.getOwnPropertyNames;
-var __getProtoOf = Object.getPrototypeOf;
-var __hasOwnProp = Object.prototype.hasOwnProperty;
-var __copyProps = (to, from, except, desc) => {
- if (from && typeof from === "object" || typeof from === "function") {
- for (let key of __getOwnPropNames(from))
- if (!__hasOwnProp.call(to, key) && key !== except)
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
- }
- return to;
-};
-var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
- // If the importer is in node compatibility mode or this is not an ESM
- // file that has been converted to a CommonJS file using a Babel-
- // compatible transform (i.e. "__esModule" has not been set), then set
- // "default" to the CommonJS "module.exports" for node compatibility.
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
- mod
-));
-
-// electron/main.ts
-var import_electron = require("electron");
-var import_node_net = __toESM(require("node:net"), 1);
-var import_node_path = __toESM(require("node:path"), 1);
-function createWindow() {
- const win = new import_electron.BrowserWindow({
- width: 1366,
- height: 820,
- backgroundColor: "#0f1216",
- autoHideMenuBar: true,
- webPreferences: {
- preload: import_node_path.default.join(__dirname, "preload.cjs"),
- contextIsolation: true,
- nodeIntegration: false
- }
- });
- void win.loadFile(import_node_path.default.join(__dirname, "../dist/index.html"));
-}
-import_electron.ipcMain.handle("printers:list", async (event) => {
- const printers = await event.sender.getPrintersAsync();
- return printers.map((p) => ({ name: p.name, displayName: p.displayName }));
-});
-import_electron.ipcMain.handle("print:raw9100", (_event, host, port, bytes) => {
- return new Promise((resolve, reject) => {
- const socket = import_node_net.default.createConnection({ host, port, timeout: 3e3 });
- socket.on("connect", () => {
- socket.end(Buffer.from(bytes));
- });
- socket.on("close", () => resolve("sent"));
- socket.on("timeout", () => {
- socket.destroy();
- reject(new Error(`Printer ${host}:${port} timed out`));
- });
- socket.on("error", (err) => reject(err));
- });
-});
-import_electron.app.whenReady().then(createWindow);
-import_electron.app.on("window-all-closed", () => import_electron.app.quit());
diff --git a/apps/pos/dist-electron/preload.cjs b/apps/pos/dist-electron/preload.cjs
deleted file mode 100644
index eeff08f..0000000
--- a/apps/pos/dist-electron/preload.cjs
+++ /dev/null
@@ -1,8 +0,0 @@
-"use strict";
-
-// electron/preload.ts
-var import_electron = require("electron");
-import_electron.contextBridge.exposeInMainWorld("pos", {
- listPrinters: () => import_electron.ipcRenderer.invoke("printers:list"),
- printRaw9100: (host, port, bytes) => import_electron.ipcRenderer.invoke("print:raw9100", host, port, bytes)
-});
diff --git a/apps/pos/electron/main.ts b/apps/pos/electron/main.ts
deleted file mode 100644
index 1414f2d..0000000
--- a/apps/pos/electron/main.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { app, BrowserWindow, ipcMain } from 'electron'
-import net from 'node:net'
-import path from 'node:path'
-
-/**
- * Phase-0 spike main process. Print transport is network ESC/POS (port 9100)
- * — the zero-driver path every thermal printer brand supports. USB-shared
- * printers come later via the raw-spool decision (see BUILDING.md).
- */
-function createWindow(): void {
- const win = new BrowserWindow({
- width: 1366,
- height: 820,
- backgroundColor: '#0f1216',
- autoHideMenuBar: true,
- webPreferences: {
- preload: path.join(__dirname, 'preload.cjs'),
- contextIsolation: true,
- nodeIntegration: false,
- },
- })
- void win.loadFile(path.join(__dirname, '../dist/index.html'))
-}
-
-ipcMain.handle('printers:list', async (event) => {
- const printers = await event.sender.getPrintersAsync()
- return printers.map((p) => ({ name: p.name, displayName: p.displayName }))
-})
-
-ipcMain.handle('print:raw9100', (_event, host: string, port: number, bytes: Uint8Array) => {
- return new Promise((resolve, reject) => {
- const socket = net.createConnection({ host, port, timeout: 3000 })
- socket.on('connect', () => {
- socket.end(Buffer.from(bytes))
- })
- socket.on('close', () => resolve('sent'))
- socket.on('timeout', () => {
- socket.destroy()
- reject(new Error(`Printer ${host}:${port} timed out`))
- })
- socket.on('error', (err) => reject(err))
- })
-})
-
-app.whenReady().then(createWindow)
-app.on('window-all-closed', () => app.quit())
diff --git a/apps/pos/electron/preload.ts b/apps/pos/electron/preload.ts
deleted file mode 100644
index 0f45d23..0000000
--- a/apps/pos/electron/preload.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { contextBridge, ipcRenderer } from 'electron'
-
-contextBridge.exposeInMainWorld('pos', {
- listPrinters: (): Promise<{ name: string; displayName: string }[]> =>
- ipcRenderer.invoke('printers:list'),
- printRaw9100: (host: string, port: number, bytes: Uint8Array): Promise =>
- ipcRenderer.invoke('print:raw9100', host, port, bytes),
-})
diff --git a/apps/pos/index.html b/apps/pos/index.html
deleted file mode 100644
index 58b0339..0000000
--- a/apps/pos/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- SiMS Next POS
-
-
-
-
-
-
diff --git a/apps/pos/package.json b/apps/pos/package.json
deleted file mode 100644
index 822ed6b..0000000
--- a/apps/pos/package.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "name": "@sims/pos",
- "version": "0.1.0",
- "private": true,
- "type": "module",
- "main": "dist-electron/main.cjs",
- "scripts": {
- "build": "vite build && node build-main.mjs",
- "start": "npm run build && electron .",
- "dev": "vite",
- "typecheck": "tsc -p tsconfig.json"
- },
- "dependencies": {
- "@sims/auth": "*",
- "@sims/billing-engine": "*",
- "@sims/config": "*",
- "@sims/domain": "*",
- "@sims/printing": "*",
- "@sims/scanning": "*",
- "@sims/ui": "*",
- "react": "^19.0.0",
- "react-dom": "^19.0.0"
- },
- "devDependencies": {
- "@types/react": "^19.0.0",
- "@types/react-dom": "^19.0.0",
- "@vitejs/plugin-react": "^4.3.0",
- "electron": "^43.0.0",
- "esbuild": "^0.25.0",
- "vite": "^6.0.0"
- }
-}
diff --git a/apps/pos/public/sw.js b/apps/pos/public/sw.js
deleted file mode 100644
index bed009c..0000000
--- a/apps/pos/public/sw.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SiMS POS app-shell cache (S4 deliverable 4). Dead-simple cache-first for
- * same-origin GET so /pos/ still loads when the store-server is down.
- *
- * Deliberate limitations (documented, not bugs):
- * - The data API is NEVER cached: any /api/* request goes straight to the network
- * and is allowed to fail when the server is down, so the app's offline queue and
- * the health-ping drain logic stay in control (a cached /api/health would break
- * the drain). Only GET is handled; bill POSTs pass through untouched.
- * - Hashed assets are cached on first fetch (runtime), not precached at build time
- * — the first load must happen online. The navigation root is best-effort
- * precached on install.
- * - Cache-first means a new deploy is picked up only after CACHE is bumped below;
- * for a counter that values offline resilience over instant updates this is the
- * right trade, and hashed asset names make stale bundles self-correcting.
- */
-const CACHE = 'sims-pos-shell-v1'
-
-self.addEventListener('install', (event) => {
- event.waitUntil(
- caches.open(CACHE).then((c) => c.addAll(['./', './index.html']).catch(() => undefined)),
- )
- self.skipWaiting()
-})
-
-self.addEventListener('activate', (event) => {
- event.waitUntil(
- caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))),
- )
- self.clients.claim()
-})
-
-self.addEventListener('fetch', (event) => {
- const req = event.request
- if (req.method !== 'GET') return
- const url = new URL(req.url)
- if (url.origin !== self.location.origin) return
- if (url.pathname.startsWith('/api/')) return // never cache the data/print/health API
-
- event.respondWith(
- caches.match(req).then((hit) => {
- if (hit !== undefined) return hit
- return fetch(req)
- .then((res) => {
- if (res.ok && res.type === 'basic') {
- const copy = res.clone()
- void caches.open(CACHE).then((c) => c.put(req, copy))
- }
- return res
- })
- .catch(() => hit ?? Response.error())
- }),
- )
-})
diff --git a/apps/pos/src/App.tsx b/apps/pos/src/App.tsx
deleted file mode 100644
index f25b93a..0000000
--- a/apps/pos/src/App.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { useEffect, useState } from 'react'
-import { Button, Notice } from '@sims/ui'
-import { uuidv7, type Item } from '@sims/domain'
-import { fetchBootstrap, fetchItemsCache, hasSession, logout, type Bootstrap, type PosUser } from './api'
-import { clearCatalogCache, loadCatalogCache, saveCatalogCache } from './offline'
-import { Login } from './Login'
-import { ShiftOpen } from './ShiftOpen'
-import { BillingScreen } from './BillingScreen'
-
-/** Shift context kept small enough to resume from sessionStorage after a refresh. */
-interface Shift { id: string; floatPaise: number }
-
-/**
- * Resume context (S4): the authenticated user + open shift, persisted in
- * sessionStorage so a mid-shift browser refresh with the server DOWN lands back on
- * the billing screen (paired with the cached catalog in IndexedDB and the session
- * token, also in sessionStorage). Dies with the tab — a fresh login still needs
- * the server (a locally-cached PIN hash login is deliberately OUT OF SCOPE).
- */
-const RESUME_KEY = 'pos.resume'
-const readResume = (): { user: PosUser; shift: Shift } | undefined => {
- try {
- const raw = sessionStorage.getItem(RESUME_KEY)
- return raw !== null ? (JSON.parse(raw) as { user: PosUser; shift: Shift }) : undefined
- } catch { return undefined }
-}
-const writeResume = (user: PosUser, shift: Shift): void => {
- try { sessionStorage.setItem(RESUME_KEY, JSON.stringify({ user, shift })) } catch { /* ignore */ }
-}
-const clearResume = (): void => {
- try { sessionStorage.removeItem(RESUME_KEY) } catch { /* ignore */ }
-}
-
-/** POS flow: bootstrap from store-server → login (PIN) → shift open → billing. */
-export function App() {
- const [boot, setBoot] = useState()
- const [bootError, setBootError] = useState()
- const [user, setUser] = useState()
- const [items, setItems] = useState- ()
- const [shift, setShift] = useState
()
- // True when this session booted from the IndexedDB cache (server was unreachable).
- const [cachedCatalog, setCachedCatalog] = useState(false)
-
- const load = () => {
- setBootError(undefined)
- const counter = new URLSearchParams(location.search).get('counter') ?? 'C2'
- fetchBootstrap(counter)
- .then(setBoot)
- .catch(async () => {
- // Server unreachable. If we still hold an authenticated session and a cached
- // catalog + resume context, reopen the billing screen offline (S4). Otherwise
- // surface the error — a fresh login needs the server.
- const [cache, resume] = [await loadCatalogCache().catch(() => undefined), readResume()]
- if (cache !== undefined && resume !== undefined && hasSession()) {
- setBoot({ store: cache.store, users: cache.users, taxClasses: cache.taxClasses })
- setItems(cache.items)
- setUser(resume.user)
- setShift(resume.shift)
- setCachedCatalog(true)
- } else {
- setBootError('Store-server unreachable (E-3301)')
- }
- })
- }
- useEffect(load, [])
-
- // Keep the resume context current while a shift is open; clear it on lock.
- useEffect(() => {
- if (user !== undefined && shift !== undefined) writeResume(user, shift)
- }, [user, shift])
-
- if (bootError !== undefined) {
- return (
-
-
-
SiMS Next POS
- {bootError} — is the store-server running?
- Retry
-
-
- )
- }
- if (boot === undefined) return Connecting to store-server…
-
- if (user === undefined || items === undefined) {
- return (
- {
- // M1: the pre-auth bootstrap omits each user's role and the seller GSTIN. Now that we
- // hold a session, re-fetch it AUTHENTICATED so the supervisor gate (user.role), the
- // approver list (users[].role) and the receipt's seller GSTIN have real data. Falls
- // back to the pre-auth boot if the refetch fails (offline right after login).
- const counter = new URLSearchParams(location.search).get('counter') ?? 'C2'
- const authedBoot = await fetchBootstrap(counter).catch(() => boot)
- setBoot(authedBoot)
- const enrichedUser = authedBoot.users.find((x) => x.id === u.id) ?? u
- setUser(enrichedUser)
- const its = await fetchItemsCache()
- setItems(its)
- // Persist the catalog snapshot so a mid-shift refresh with the server down
- // can still reach billing (S4 deliverable 3).
- void saveCatalogCache({
- store: authedBoot.store, users: authedBoot.users, taxClasses: authedBoot.taxClasses,
- items: its, savedAt: new Date().toISOString(),
- }).catch(() => undefined)
- }}
- />
- )
- }
- if (shift === undefined) {
- return (
- setShift({ id: uuidv7(), floatPaise })}
- onBack={() => {
- setUser(undefined)
- setItems(undefined)
- }}
- />
- )
- }
- return (
- {
- // M4: end the session server-side so the bearer token stops working immediately
- // (best-effort — the local state is cleared regardless of whether the server answers).
- void logout()
- clearResume()
- // M11: wipe the cached catalog + staff roster from IndexedDB so no customer/staff
- // data lingers on this shared PC after lock. Un-drained offline bills are kept
- // (a lock must never lose a sale) and the catalog is re-fetched on the next login.
- void clearCatalogCache().catch(() => undefined)
- setShift(undefined)
- setUser(undefined)
- setItems(undefined)
- }}
- />
- )
-}
diff --git a/apps/pos/src/BillingScreen.tsx b/apps/pos/src/BillingScreen.tsx
deleted file mode 100644
index b799897..0000000
--- a/apps/pos/src/BillingScreen.tsx
+++ /dev/null
@@ -1,1576 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
-import { Badge, Button, Field, Modal, Notice, ThemeSwitcher } from '@sims/ui'
-import { deriveSupply, formatINR, isExpired, pickFefoBatch, sortFefo, validateGstin, type BatchStock, type Item, type PaymentMode, type RoleCode } from '@sims/domain'
-// Subpath import: the @sims/auth barrel re-exports pin.ts (node:crypto), which the
-// browser bundle can't include. permissions.ts is pure (type-only imports), so the
-// role→gate matrix stays the single source of truth without dragging in scrypt.
-import { gateFor } from '@sims/auth/permissions'
-import { computeBill, computeReturn, type LineInput, type TaxClassRow } from '@sims/billing-engine'
-import { parseEntry, parseScaleBarcode, WedgeDetector, type ScaleBarcodeProfile } from '@sims/scanning'
-import { buildIndex, querySearch } from '@sims/search-core'
-import { renderReceipt, receiptLines, type ReceiptDoc } from '@sims/printing'
-import { createCustomer, createDraftItem, fetchBatches, fetchBills, fetchReturnable, fetchStock, findCustomers, lookupBillByDocNo, lookupBillsByPhone, NetworkError, pingHealth, postBill, postBillOffline, postReturn, verifySupervisorPin, type BillLookupResult, type BillOverride, type BootstrapStore, type OverrideContext, type PosBatchRow, type PosCustomer, type PosUser, type ReturnableBill } from './api'
-import { buildQueuedBill, countQueuedBills, enqueueBill, listQueuedBills, nextOffSeq, removeQueuedBill, toOfflineBillBody } from './offline'
-import { beepErr, beepOk, beepWarn } from './audio'
-import { loadPrinterCfg, sendRasterReceipt, sendToPrinter } from './print'
-import { SettingsModal } from './Settings'
-
-/**
- * Counter limits. Module consts today; per 16-PROJECT-RULES R9 these become DB
- * `setting` rows (system→tenant→store→counter→user hierarchy) once the settings
- * surface lands — the migration is then a single import swap, not a code change.
- */
-const POS_LIMITS = {
- PRICE_BAND_PCT: 10, // price deviation beyond ±this % of the item's sale price raises the supervisor gate
- DISC_WARN_PCT: 10, // a line discount beyond this % of line value raises the supervisor gate
-} as const
-
-/**
- * A supervisor's approval, resolved by the inline gate panel. `approvalId` is the
- * single-use token the server minted on the verified PIN; it rides with the override
- * so the server records the approver from its own row (SEC fix) — the client no longer
- * asserts an approver user id.
- */
-interface Approval { approvalId: string; approvedByName: string }
-
-const SCALE_PROFILE: ScaleBarcodeProfile = {
- prefixes: ['21'],
- pluLength: 5,
- valueKind: 'weight',
- valueDivisor: 1000,
-}
-
-const QUICK_KEYS = ['42', '43', '44', '108'] // per-counter DB config later
-
-interface CommittedBill { docNo: string; payablePaise: number; mode: PaymentMode; at: string }
-type NoticeMsg = { tone: 'ok' | 'warn' | 'err'; text: string }
-type Overlay = 'none' | 'pay' | 'settings' | 'history' | 'dayend' | 'customer' | 'returns'
-
-/** Return reasons offered at the counter (09-UX returns/exchange). 'Other' is the free catch-all. */
-const RETURN_REASONS = ['Damaged', 'Wrong item', 'Expired', 'Customer changed mind', 'Other'] as const
-
-/** Group /api/batches rows into a per-item map (S5) — the FEFO picker's cache. */
-function groupBatches(rows: PosBatchRow[]): Map {
- const m = new Map()
- for (const r of rows) {
- const b: BatchStock = {
- id: r.id, itemId: r.item_id, batchNo: r.batch_no,
- ...(r.expiry_date !== null ? { expiryDate: r.expiry_date } : {}),
- onHand: Number(r.on_hand),
- }
- const arr = m.get(b.itemId)
- if (arr !== undefined) arr.push(b)
- else m.set(b.itemId, [b])
- }
- return m
-}
-
-export function BillingScreen(props: {
- user: PosUser
- users: PosUser[]
- store: BootstrapStore
- taxClasses: TaxClassRow[]
- items: Item[]
- shiftId: string
- floatPaise: number
- /** True when the app booted from the IndexedDB cache (server was down) — shows the offline banner. */
- cachedCatalog?: boolean
- onLock: () => void
-}) {
- const { store, taxClasses } = props
- const today = new Date().toISOString().slice(0, 10)
- /** Local item cache — starts from the server snapshot, grows with quick-adds. */
- const [cat, setCat] = useState- (props.items)
- const [lines, setLines] = useState
([])
- const [sugg, setSugg] = useState<{ list: Item[]; hi: number } | undefined>()
- const [editCell, setEditCell] = useState<{ line: number; field: 'qty' | 'price' | 'discount' } | undefined>()
- const [quickAdd, setQuickAdd] = useState<{ code: string; name?: string } | undefined>()
- // Supervisor-approved overrides accumulate here and ride with postBill; the server
- // audits each in the commit transaction (spec P0-2). Cleared on commit / cart-clear.
- const [overrides, setOverrides] = useState([])
- const [supPanel, setSupPanel] = useState<{ desc: string; override: OverrideContext; resolve: (a: Approval | undefined) => void } | undefined>()
- // S5: per-item batch cache for the FEFO picker, and the active pick (item + qty
- // waiting for a batch choice). Refreshed at mount and after each online commit.
- const [batchMap, setBatchMap] = useState>(new Map())
- const [batchPick, setBatchPick] = useState<{ item: Item; qty: number; batches: BatchStock[] } | undefined>()
- const [stockMap, setStockMap] = useState>(new Map())
- const [lastBillLines, setLastBillLines] = useState([])
- const [changeFlash, setChangeFlash] = useState()
- const [selected, setSelected] = useState(0)
- const [input, setInput] = useState('')
- const [pendingQty, setPendingQty] = useState()
- const [notice, setNotice] = useState()
- const [held, setHeld] = useState<{ token: number; lines: LineInput[] }[]>([])
- const [tokenSeq, setTokenSeq] = useState(1)
- const [dayLog, setDayLog] = useState([])
- const [customer, setCustomer] = useState()
- const [overlay, setOverlay] = useState('none')
- const [busy, setBusy] = useState(false)
- const [serverBills, setServerBills] = useState[] | undefined>()
- // S4: count of bills committed offline and still waiting to drain (the amber chip).
- const [offlineCount, setOfflineCount] = useState(0)
-
- const inputRef = useRef(null)
- const wedge = useRef(new WedgeDetector())
- const overlayRef = useRef(overlay)
- overlayRef.current = overlay
- const suggRef = useRef(false)
- suggRef.current = sugg !== undefined
- const inlineEditRef = useRef(false)
- inlineEditRef.current = editCell !== undefined || quickAdd !== undefined
- // The supervisor panel holds focus (its PIN field) and owns every key but Esc.
- const supPanelRef = useRef(undefined)
- supPanelRef.current = supPanel
- // The batch picker owns arrows/Enter while open; the global handler bails for it.
- const batchPickRef = useRef(undefined)
- batchPickRef.current = batchPick
-
- // B2B at the counter (S3): an attached customer with a GSTIN moves the place of
- // supply to their state — the server derives this same decision independently
- // and both engine runs must agree to the paisa (R5).
- const supply = useMemo(() => deriveSupply(customer, store.stateCode), [customer, store.stateCode])
-
- const computed = useMemo(() => {
- if (lines.length === 0) return undefined
- return computeBill(lines, {
- businessDate: today,
- supplyStateCode: store.stateCode,
- placeOfSupplyStateCode: supply.placeOfSupplyStateCode,
- roundToRupee: true,
- }, taxClasses)
- }, [lines, today, store.stateCode, supply.placeOfSupplyStateCode, taxClasses])
-
- const sayTimer = useRef | undefined>(undefined)
- /** ok auto-clears in 4s; err/warn persist until Esc or the next action (spec P1-4). */
- const say = (tone: NoticeMsg['tone'], text: string) => {
- if (sayTimer.current !== undefined) clearTimeout(sayTimer.current)
- setNotice({ tone, text })
- if (tone === 'err') beepErr()
- else if (tone === 'warn') beepWarn()
- if (tone === 'ok') sayTimer.current = setTimeout(() => setNotice(undefined), 4000)
- }
-
- useEffect(() => {
- fetchStock()
- .then((rows) => setStockMap(new Map(rows.map((r) => [r.code, Number(r.on_hand)]))))
- .catch(() => undefined)
- }, [])
-
- // S5: load the FEFO batch cache at login (mount) and again after each commit so
- // per-batch on-hand stays live. Keyed by item id. Silent on failure (offline).
- const refreshBatches = (): void => {
- fetchBatches()
- .then((rows) => setBatchMap(groupBatches(rows)))
- .catch(() => undefined)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(() => { refreshBatches() }, [])
-
- // S4 reconnect drain: every 15s (and on the browser 'online' event) ping health;
- // when the store-server is back, POST queued bills oldest-first to the idempotent
- // /api/bills/offline and map each OFF-n to its real GST doc no. Also runs once on
- // mount, to drain a queue a prior session/refresh may have left behind.
- useEffect(() => {
- let cancelled = false
- let draining = false
- const drain = async () => {
- if (cancelled || draining) return
- draining = true
- try {
- if (!(await pingHealth())) return
- const queued = await listQueuedBills() // oldest-first
- if (queued.length === 0) return
- const mapped: string[] = []
- let sessionExpired = false
- for (const q of queued) {
- if (cancelled) break
- try {
- const res = await postBillOffline(toOfflineBillBody(q))
- await removeQueuedBill(q.id) // idempotent: a re-drain returns the same doc no
- mapped.push(`${q.provisionalToken} → ${res.docNo}`)
- } catch (err) {
- // The drain runs authenticated (SEC-1). A 401 means the session lapsed —
- // typically the server restarted — so the queue is intact but can't sync
- // until the counter signs in again; flag it and stop. Any other error:
- // stop and retry next tick.
- if (err instanceof Error && /E-6103|not signed in/i.test(err.message)) sessionExpired = true
- break
- }
- }
- const remaining = await countQueuedBills()
- if (cancelled) return
- setOfflineCount(remaining)
- if (mapped.length > 0) say('ok', `Synced ${mapped.length} offline bill(s): ${mapped.join(', ')}`)
- // Prompt re-login WITHOUT a beep (setNotice, not say) so the 15s retry loop
- // isn't noisy; it clears itself once a login refreshes the session and the
- // next drain succeeds.
- if (sessionExpired && remaining > 0) {
- setNotice({ tone: 'warn', text: `Sign in to sync ${remaining} offline bill${remaining === 1 ? '' : 's'} — the session expired after a reconnect` })
- }
- } finally {
- draining = false
- }
- }
- countQueuedBills().then((n) => { if (!cancelled) setOfflineCount(n) }).catch(() => undefined)
- const iv = setInterval(() => { void drain() }, 15000)
- const onOnline = (): void => { void drain() }
- window.addEventListener('online', onOnline)
- void drain()
- return () => { cancelled = true; clearInterval(iv); window.removeEventListener('online', onOnline) }
- }, [])
-
- const addItem = (item: Item, qty?: number) => {
- const addQty = qty ?? pendingQty ?? 1
- // Whole quantities only for unit-less items; keep the multiplier for a rescan.
- if (item.unit.decimals === 0 && !Number.isInteger(addQty)) {
- return say('err', `Whole quantities only for ${item.unit.code} — rescan (E-1104)`)
- }
- // S5: a batch-tracked item routes through the inline FEFO picker before the cart.
- if (item.batchTracked) {
- const avail = (batchMap.get(item.id) ?? []).filter((b) => b.onHand > 0)
- if (avail.length > 0) {
- setPendingQty(undefined)
- setSugg(undefined)
- setBatchPick({ item, qty: addQty, batches: avail })
- return
- }
- // No lot on hand (batch-tracked item never received with a batch): never block
- // the counter (R18) — bill it un-batched and warn.
- say('warn', `No batch on hand for ${item.name} — billing without a batch`)
- }
- finalizeAdd(item, addQty, undefined, qty)
- }
-
- /**
- * Append the (optionally batch-picked) line to the cart — the tail of addItem,
- * extracted so the batch picker can finalise after a FEFO/expired decision.
- * `qtyParam` mirrors addItem's original qty arg: a bare unit scan (undefined) still
- * merges into an existing un-batched line; a batch line always stays distinct.
- */
- const finalizeAdd = (item: Item, addQty: number, batchId: string | undefined, qtyParam?: number) => {
- setPendingQty(undefined)
- beepOk()
- const onHand = stockMap.get(item.code)
- if (onHand !== undefined && onHand <= 0) {
- say('warn', `${item.name} shows out of stock — the shelf is the arbiter, billing anyway`)
- }
- setLines((prev) => {
- if (item.unit.decimals === 0 && qtyParam === undefined && batchId === undefined) {
- const i = prev.findIndex((l) => l.itemId === item.id && l.batchId === undefined)
- if (i >= 0) {
- const copy = [...prev]
- copy[i] = { ...copy[i]!, qty: copy[i]!.qty + addQty }
- setSelected(i)
- return copy
- }
- }
- setSelected(prev.length)
- return [...prev, {
- itemId: item.id, name: item.name, hsn: item.hsn, qty: addQty,
- unitCode: item.unit.code, unitPricePaise: item.salePricePaise,
- priceIncludesTax: item.priceIncludesTax, taxClassCode: item.taxClassCode,
- ...(item.mrpPaise !== undefined ? { mrpPaise: item.mrpPaise } : {}),
- ...(batchId !== undefined ? { batchId } : {}),
- }]
- })
- setNotice(undefined)
- }
-
- // Tiered phonetic search (spec P0-1): index rebuilt only when the catalog changes
- // (quick-adds grow `cat`), queried synchronously per keystroke — no debounce.
- const searchIndex = useMemo(() => buildIndex(cat), [cat])
- const findByName = (text: string): Item[] => querySearch(searchIndex, text)
-
- const resolveCode = (code: string, qty?: number) => {
- const scale = parseScaleBarcode(code, SCALE_PROFILE)
- if (scale.kind === 'scale-weight') {
- const item = cat.find((i) => i.code === String(Number(scale.plu)))
- if (item !== undefined) return addItem(item, scale.qty)
- return say('err', `Scale PLU ${scale.plu} not in catalog (E-1102)`)
- }
- if (scale.kind === 'invalid') return say('err', 'Bad scale barcode — reweigh the item (E-1103)')
- const item = cat.find((i) => i.barcodes.includes(code)) ?? cat.find((i) => i.code === code)
- if (item !== undefined) return addItem(item, qty)
- // Errors never trap (09-UX §1): unknown code opens the inline quick-add.
- setQuickAdd({ code })
- }
-
- const search = (text: string, qty?: number) => {
- const first = findByName(text)[0]
- if (first === undefined) return say('err', `No item matches "${text}" (E-1102)`)
- addItem(first, qty)
- }
-
- /** Live suggestions: recomputed synchronously per keystroke (no debounce). */
- const updateSuggestions = (value: string) => {
- const parsed = parseEntry(value)
- const text = parsed.kind === 'search' ? parsed.text
- : parsed.kind === 'multiplier' && !/^\d*$/.test(parsed.rest) ? parsed.rest
- : undefined
- if (text !== undefined && text.length >= 2) {
- setSugg({ list: findByName(text).slice(0, 8), hi: 0 })
- } else {
- setSugg(undefined)
- }
- }
-
- const pickSuggestion = () => {
- if (sugg === undefined) return
- const parsed = parseEntry(input)
- const qty = parsed.kind === 'multiplier' ? parsed.qty : undefined
- const hit = sugg.list[sugg.hi]
- setSugg(undefined)
- setInput('')
- if (hit !== undefined) return addItem(hit, qty)
- // 0 hits → quick-add with the typed name prefilled (errors never trap)
- const name = parsed.kind === 'search' ? parsed.text : parsed.kind === 'multiplier' ? parsed.rest : input
- setQuickAdd({ code: '', name })
- }
-
- const handleEnter = () => {
- const burst = wedge.current.terminate()
- if (burst.type === 'scan') {
- setInput('')
- setSugg(undefined)
- return resolveCode(burst.code)
- }
- if (sugg !== undefined) return pickSuggestion()
- const parsed = parseEntry(input)
- setInput('')
- switch (parsed.kind) {
- case 'empty': return
- case 'multiplier':
- if (parsed.rest === '') {
- setPendingQty(parsed.qty)
- return say('ok', `Quantity ${parsed.qty} × — scan or type the item`)
- }
- return /^\d+$/.test(parsed.rest) ? resolveCode(parsed.rest, parsed.qty) : search(parsed.rest, parsed.qty)
- case 'barcode':
- case 'code': return resolveCode(parsed.code)
- case 'search': return search(parsed.text)
- }
- }
-
- // The logged-in cashier's authority — owner/manager/supervisor self-authorise
- // price/discount overrides ('allow'); a cashier must raise the PIN gate.
- const userGate = (action: 'PRICE_OVERRIDE' | 'DISCOUNT_OVER_CAP') =>
- gateFor({ roles: [props.user.role as RoleCode], active: true }, action)
-
- /**
- * Reusable in-flow supervisor gate (spec P0-2): shows the inline panel (never a
- * modal), resolves with the approver on success or undefined on cancel / 3 fails.
- * F4 discounts (P1-3) and future Del/void reuse this exact promise.
- */
- const supervisorGate = (desc: string, override: OverrideContext): Promise =>
- new Promise((resolve) => setSupPanel({ desc, override, resolve }))
-
- /** Append an override that rides with the bill; the server binds/audits it (spec P0-2, H7). */
- const pushOverride = (o: BillOverride) => setOverrides((prev) => [...prev, o])
-
- /**
- * The inline FEFO picker resolved a batch (spec S5). A non-expired lot adds the line
- * straight away. An EXPIRED lot is blocked with E-1310 unless approved: a supervisor+
- * at the counter sells it on their own authority; a cashier raises the supervisor gate
- * and the approval rides along as a `batch` override (audited BATCH_OVERRIDE server-side).
- */
- const acceptBatch = async (batch: BatchStock) => {
- const pick = batchPick
- setBatchPick(undefined)
- if (pick === undefined) return
- const { item, qty } = pick
- if (isExpired(batch, today)) {
- if (userGate('PRICE_OVERRIDE') === 'allow') {
- // H7: even a self-authorised expired-batch sale is audited server-side.
- pushOverride({ itemId: item.id, kind: 'batch', before: 0, after: 0 })
- finalizeAdd(item, qty, batch.id)
- say('warn', `EXPIRED batch ${batch.batchNo} (${batch.expiryDate ?? ''}) sold on your authority (E-1310)`)
- inputRef.current?.focus()
- return
- }
- const approval = await supervisorGate(
- `${item.name}: batch ${batch.batchNo} EXPIRED ${batch.expiryDate ?? ''} — approve sale? (E-1310)`,
- { itemId: item.id, kind: 'batch', before: 0, after: 0 },
- )
- if (approval === undefined) {
- say('warn', `Expired batch ${batch.batchNo} not sold — no supervisor approval`)
- inputRef.current?.focus()
- return
- }
- pushOverride({ itemId: item.id, kind: 'batch', before: 0, after: 0, approvalId: approval.approvalId })
- finalizeAdd(item, qty, batch.id)
- say('ok', `Expired batch ${batch.batchNo} approved by ${approval.approvedByName}`)
- inputRef.current?.focus()
- return
- }
- finalizeAdd(item, qty, batch.id)
- inputRef.current?.focus()
- }
-
- const cancelBatchPick = () => {
- setBatchPick(undefined)
- say('warn', 'Batch selection cancelled — item not added')
- inputRef.current?.focus()
- }
-
- const applyPrice = (i: number, paise: number) => {
- setLines((prev) => prev.map((x, j) => (j === i ? { ...x, unitPricePaise: paise } : x)))
- inputRef.current?.focus()
- }
-
- // F3 price override (spec P0-2). price>MRP is a hard block; a deviation beyond the
- // ±band — or ANY change by a cashier — raises the supervisor gate before it applies.
- // The deviation's `before` is the item MASTER price (`ref`), which is exactly what the
- // server anchors to and binds the token against (H4) — so the two always agree.
- const commitPriceEdit = async (i: number, v: string) => {
- setEditCell(undefined)
- const line = lines[i]
- const paise = Math.round(Number(v) * 100)
- if (line === undefined || !Number.isFinite(paise) || paise < 0) { inputRef.current?.focus(); return }
- const mrp = line.mrpPaise
- if (mrp !== undefined && paise > mrp) {
- say('err', `Price above MRP ${formatINR(mrp)} is not allowed (E-1301)`)
- inputRef.current?.focus()
- return
- }
- if (paise === line.unitPricePaise) { inputRef.current?.focus(); return } // no-op
- const ref = cat.find((c) => c.id === line.itemId)?.salePricePaise ?? line.unitPricePaise
- const beyondBand = Math.abs(paise - ref) > ref * POS_LIMITS.PRICE_BAND_PCT / 100
- if (userGate('PRICE_OVERRIDE') === 'allow') {
- applyPrice(i, paise) // supervisor+ self-authorises
- // H7: a self-authorised deviation from the master is still audited server-side.
- if (paise !== ref) pushOverride({ itemId: line.itemId, kind: 'price', before: ref, after: paise })
- return
- }
- const approval = await supervisorGate(
- `${line.name}: rate ${formatINR(line.unitPricePaise)} → ${formatINR(paise)}${beyondBand ? ` (beyond ±${POS_LIMITS.PRICE_BAND_PCT}% band)` : ''}`,
- { itemId: line.itemId, kind: 'price', before: ref, after: paise },
- )
- if (approval === undefined) { say('warn', 'Price override cancelled — no supervisor approval'); inputRef.current?.focus(); return }
- applyPrice(i, paise)
- pushOverride({ itemId: line.itemId, kind: 'price', before: ref, after: paise, approvalId: approval.approvalId })
- say('ok', `Rate override approved by ${approval.approvedByName}`)
- }
-
- // F4 line discount (spec P1-3). A discount beyond DISC_WARN_PCT of the line value
- // raises the gate for a cashier; owner/manager/supervisor apply it directly.
- const commitDiscountEdit = async (i: number, v: string) => {
- setEditCell(undefined)
- const line = lines[i]
- if (line === undefined) { inputRef.current?.focus(); return }
- // Grammar per 09-UX §1.3: `5` = ₹5 off, `5%` = 5 percent; 0/empty clears it.
- const m = /^(\d+(?:\.\d{1,2})?)(%?)$/.exec(v.trim())
- if (m === null || Number(m[1]) === 0) {
- setLines((prev) => prev.map((x, j) => { if (j !== i) return x; const { discount: _drop, ...rest } = x; return rest }))
- inputRef.current?.focus()
- return
- }
- const isPct = m[2] === '%'
- const discount: LineInput['discount'] = isPct
- ? { kind: 'percentBp', value: Math.round(Number(m[1]) * 100) }
- : { kind: 'amount', paise: Math.round(Number(m[1]) * 100) }
- const lineValue = line.qty * line.unitPricePaise
- const discPaise = isPct ? Math.round(lineValue * Number(m[1]) / 100) : Math.round(Number(m[1]) * 100)
- const overCap = discPaise > lineValue * POS_LIMITS.DISC_WARN_PCT / 100
- const applyDiscount = () => {
- setLines((prev) => prev.map((x, j) => (j === i ? { ...x, discount } : x)))
- inputRef.current?.focus()
- }
- if (!overCap) { applyDiscount(); return } // within the band: no approval, no override
- if (userGate('DISCOUNT_OVER_CAP') === 'allow') {
- applyDiscount()
- // H7: an over-cap discount applied on the user's own authority is still audited.
- pushOverride({ itemId: line.itemId, kind: 'discount', before: 0, after: discPaise })
- return
- }
- const approval = await supervisorGate(
- `${line.name}: discount ${formatINR(discPaise)} (> ${POS_LIMITS.DISC_WARN_PCT}% of line)`,
- { itemId: line.itemId, kind: 'discount', before: 0, after: discPaise },
- )
- if (approval === undefined) { say('warn', 'Discount cancelled — no supervisor approval'); inputRef.current?.focus(); return }
- applyDiscount()
- pushOverride({ itemId: line.itemId, kind: 'discount', before: 0, after: discPaise, approvalId: approval.approvalId })
- say('ok', `Discount approved by ${approval.approvedByName}`)
- }
-
- type Computed = NonNullable
-
- /**
- * Print a receipt. `docNo` is the GST doc no online, or "PROVISIONAL OFF-n
- * (offline)" for a queued bill. Reads `customer`/`supply` from the render's
- * closure — correct even after the cart-clear setState calls, which don't
- * mutate these locals.
- */
- const printBill = (docNo: string, snapshot: Computed, mode: PaymentMode): Promise => {
- const cfg = loadPrinterCfg()
- const kick = mode === 'CASH'
- const receiptDoc: ReceiptDoc = {
- storeName: store.name, gstin: store.gstin, docNo, businessDate: today,
- counterCode: store.counterCode, cashierName: props.user.name,
- ...(customer !== undefined ? { buyerName: customer.name } : {}),
- ...(supply.buyerGstin !== undefined ? { buyerGstin: supply.buyerGstin } : {}),
- lines: snapshot.lines, totals: snapshot.totals, paymentLabel: mode,
- }
- // Indic receipts rasterize (09-UX §5.3); ASCII stays the fast text path.
- // Same content model (receiptLines) so item names match on either path.
- return cfg.script === 'raster'
- ? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick)
- : sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick }))
- }
-
- /** Cart-clear + optimistic local stock + change flash, shared by both commit paths. */
- const clearAfterCommit = (docNo: string, snapshot: Computed, mode: PaymentMode, committedLines: LineInput[], tenderedPaise?: number) => {
- setDayLog((d) => [...d, { docNo, payablePaise: snapshot.totals.payablePaise, mode, at: new Date().toLocaleTimeString() }])
- setLastBillLines(committedLines) // Ctrl+B repeat (spec P1-1)
- // decrement local stock view optimistically (spec P1-5)
- setStockMap((m) => {
- const next = new Map(m)
- for (const l of committedLines) {
- const item = cat.find((i) => i.id === l.itemId)
- if (item !== undefined && next.has(item.code)) next.set(item.code, next.get(item.code)! - l.qty)
- }
- return next
- })
- if (tenderedPaise !== undefined && tenderedPaise > snapshot.totals.payablePaise) {
- setChangeFlash(tenderedPaise - snapshot.totals.payablePaise)
- setTimeout(() => setChangeFlash(undefined), 4000)
- }
- setLines([])
- setPendingQty(undefined)
- setCustomer(undefined)
- setOverrides([]) // override records belong to the committed cart only
- setOverlay('none')
- }
-
- /**
- * Store-server unreachable (S4): instead of losing the sale, persist it to the
- * IndexedDB queue with a client UUIDv7 + provisional token (OFF-n), clear the
- * cart, and keep billing. The reconnect drain assigns the real GST doc no later.
- * Enqueue is awaited BEFORE the cart clears so a queue write failure keeps the
- * cart on screen (R18: never lost).
- */
- const queueOffline = async (mode: PaymentMode, snapshot: Computed, committedLines: LineInput[], tenderedPaise?: number) => {
- try {
- const seq = nextOffSeq()
- const q = buildQueuedBill({
- seq,
- storeId: store.id, counterId: store.counterId, cashierId: props.user.id, shiftId: props.shiftId,
- // M11: persist ONLY the customer id — never the buyer's name or GSTIN. The server
- // re-resolves buyer identity from the id at drain (same as an online bill), so no
- // customer PII is written to this shared PC. The provisional receipt printed below
- // still names the buyer, from live in-memory state — not from the queued record.
- ...(customer !== undefined ? { customerId: customer.id } : {}),
- businessDate: today,
- lines: committedLines,
- payments: [{ mode, amountPaise: snapshot.totals.payablePaise }],
- totals: snapshot.totals,
- ...(overrides.length > 0 ? { overrides } : {}),
- })
- await enqueueBill(q)
- clearAfterCommit(q.provisionalToken, snapshot, mode, committedLines, tenderedPaise)
- setOfflineCount((n) => n + 1)
- // Provisional receipt: "PROVISIONAL OFF-n (offline)" instead of a GST doc no.
- void printBill(`PROVISIONAL ${q.provisionalToken} (offline)`, snapshot, mode).catch(() => undefined)
- say('warn', `Store-server offline — saved ${q.provisionalToken}, cart cleared; will sync when it returns`)
- } catch (err) {
- // Even the local queue failed — keep the cart rather than lose the sale.
- say('err', `Could not queue the bill offline: ${err instanceof Error ? err.message : String(err)} — cart kept`)
- }
- }
-
- const commit = (mode: PaymentMode, tenderedPaise?: number) => {
- if (busy) return
- if (computed === undefined) return say('warn', 'Nothing to bill')
- setBusy(true)
- const snapshot: Computed = computed
- const committedLines = lines
- postBill({
- storeId: store.id, counterId: store.counterId, shiftId: props.shiftId,
- ...(customer !== undefined ? { customerId: customer.id } : {}),
- businessDate: today, lines,
- payments: [{ mode, amountPaise: snapshot.totals.payablePaise }],
- clientPayablePaise: snapshot.totals.payablePaise,
- ...(overrides.length > 0 ? { overrides } : {}), // audited server-side in the commit txn (spec P0-2)
- })
- .then((res) => {
- // The bill is committed server-side; printing is a consequence (09-UX §5.4).
- clearAfterCommit(res.docNo, snapshot, mode, committedLines, tenderedPaise)
- refreshBatches() // per-batch on-hand changed server-side (S5)
- return printBill(res.docNo, snapshot, mode)
- .then(() => say('ok', `Bill ${res.docNo} · ${formatINR(snapshot.totals.payablePaise)} — printed`))
- .catch((err: unknown) =>
- say('warn', `Bill ${res.docNo} saved — print: ${err instanceof Error ? err.message : String(err)} (E-2101)`))
- })
- .catch((err: unknown) => {
- // A NETWORK failure (store-server dead) queues offline; the cart is not lost.
- // Any other error (engine mismatch, payments, 4xx) keeps the cart on screen.
- if (err instanceof NetworkError) return queueOffline(mode, snapshot, committedLines, tenderedPaise)
- say('err', `${err instanceof Error ? err.message : String(err)} — bill NOT saved, cart kept`)
- return undefined
- })
- .finally(() => {
- setBusy(false)
- inputRef.current?.focus()
- })
- }
-
- const hold = () => {
- if (lines.length === 0) return
- setHeld((h) => [...h, { token: tokenSeq, lines }])
- setTokenSeq((t) => t + 1)
- setLines([])
- setOverrides([]) // approvals belong to the cart on screen; a held cart re-approves on resume
- say('ok', `Held as token ${tokenSeq}`)
- }
-
- const resume = () => {
- if (lines.length > 0) return say('warn', 'Hold the current bill first (F7)')
- const last = held[held.length - 1]
- if (last === undefined) return say('warn', 'No held bills')
- setHeld((h) => h.slice(0, -1))
- setLines(last.lines)
- say('ok', `Resumed token ${last.token}`)
- }
-
- const openHistory = () => {
- setOverlay('history')
- setServerBills(undefined)
- fetchBills(today, store.counterId)
- .then(setServerBills)
- .catch((err: Error) => say('err', err.message))
- }
-
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- if (supPanelRef.current !== undefined) {
- supPanelRef.current.resolve(undefined) // cancel the pending gate; the edit is dropped
- setSupPanel(undefined)
- inputRef.current?.focus()
- return
- }
- if (batchPickRef.current !== undefined) { // cancel the FEFO pick; item not added
- cancelBatchPick()
- return
- }
- setOverlay('none')
- setNotice(undefined)
- setInput('')
- setSugg(undefined)
- setEditCell(undefined)
- setQuickAdd(undefined)
- setPendingQty(undefined) // stale 3* must never multiply an unrelated scan
- inputRef.current?.focus()
- return
- }
- // The supervisor panel owns every key but Esc — its PIN field has focus and
- // F12/commit must never fire mid-approval.
- if (supPanelRef.current !== undefined) return
- // The batch picker owns arrows/Enter (handled on its own focused element); the
- // global handler must not fire F12/commit or F-keys while a lot is being picked.
- if (batchPickRef.current !== undefined) return
- if (overlayRef.current === 'settings' || overlayRef.current === 'customer' || overlayRef.current === 'returns') return
- // Suggestion list owns arrows+Enter; inline editors own everything.
- if (suggRef.current && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter')) return
- if (inlineEditRef.current && e.key !== 'F12') return
- // Cash close, with tender-in-the-box (spec P0-4): ≤6 digits ≥ payable = tendered.
- if (e.code === 'NumpadAdd' || e.key === 'F12') {
- e.preventDefault()
- const raw = inputRef.current?.value ?? ''
- if (/^\d{1,6}$/.test(raw)) {
- const tendered = Number(raw) * 100
- const payable = computed?.totals.payablePaise ?? 0
- if (tendered >= payable && payable > 0) {
- setInput('')
- setSugg(undefined)
- commit('CASH', tendered)
- } else {
- say('warn', 'Tendered is less than payable — finish the line (Enter) or clear (Esc)')
- }
- } else if (raw !== '') {
- say('warn', 'Finish the line first (Enter) or clear (Esc)')
- } else {
- commit('CASH')
- }
- return
- }
- // Repeat last bill (spec P1-1): the daily milk-and-bread bill in one key.
- if (e.ctrlKey && e.key.toLowerCase() === 'b') {
- e.preventDefault()
- if (lines.length > 0) return say('warn', 'Hold or close the current bill first')
- if (lastBillLines.length === 0) return say('warn', 'No previous bill this shift')
- let missing = 0
- for (const l of lastBillLines) {
- const item = cat.find((i) => i.id === l.itemId && i.status !== 'inactive')
- if (item !== undefined) addItem(item, l.qty) // current price/tax apply — never resurrect old prices
- else missing++
- }
- say(missing > 0 ? 'warn' : 'ok', `Repeated last bill${missing > 0 ? ` — ${missing} item(s) no longer available` : ''}`)
- return
- }
- switch (e.key) {
- case 'F2':
- e.preventDefault()
- if (lines.length > 0) setEditCell({ line: selected, field: 'qty' })
- break
- case 'F3':
- e.preventDefault()
- if (lines.length > 0) setEditCell({ line: selected, field: 'price' })
- break
- case 'F4':
- e.preventDefault()
- if (lines.length > 0) setEditCell({ line: selected, field: 'discount' })
- break
- case 'F6': e.preventDefault(); setOverlay('customer'); break
- case 'F7': e.preventDefault(); hold(); break
- case 'F8': e.preventDefault(); resume(); break
- case 'F9': e.preventDefault(); setOverlay('returns'); break
- case 'F10': e.preventDefault(); openHistory(); break
- case 'F11': e.preventDefault(); setOverlay('pay'); break
- case 'Delete':
- setLines((prev) => prev.filter((_, i) => i !== selected))
- setSelected((s) => Math.max(0, s - 1))
- break
- case 'ArrowUp': e.preventDefault(); setSelected((s) => Math.max(0, s - 1)); break
- case 'ArrowDown': e.preventDefault(); setSelected((s) => Math.min(lines.length - 1, s + 1)); break
- case ',': if (e.ctrlKey) { e.preventDefault(); setOverlay('settings') } break
- }
- }
- window.addEventListener('keydown', onKey)
- return () => window.removeEventListener('keydown', onKey)
- })
-
- const totals = computed?.totals
- const cfg = loadPrinterCfg()
-
- return (
-
-
- {store.name}
- Counter {store.counterCode}
- {props.user.name} ({props.user.role})
- Day {today}
-
-
- setOverlay('dayend')}>Day-End
- Lock
-
-
- {props.cachedCatalog === true && (
-
- OFFLINE — using cached catalog. Bills are queued locally and sync when the store-server returns.
-
- )}
-
-
-
- {
- setInput(e.target.value)
- updateSuggestions(e.target.value)
- }}
- onKeyDown={(e) => {
- if (sugg !== undefined && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
- e.preventDefault()
- const n = sugg.list.length
- if (n > 0) {
- setSugg({ ...sugg, hi: (sugg.hi + (e.key === 'ArrowDown' ? 1 : n - 1)) % n })
- }
- return
- }
- // Instant multiplier (spec P1-2): `3` `*` scan = 2 keys, chip visible before the scan.
- if (e.key === '*' && /^\d{1,4}(\.\d{1,3})?$/.test(input)) {
- e.preventDefault()
- setPendingQty(Number(input))
- setInput('')
- setSugg(undefined)
- return
- }
- if (e.key === 'Enter') { e.preventDefault(); handleEnter() }
- else if (e.key.length === 1) wedge.current.feed(e.key, e.timeStamp)
- }}
- onBlur={() => {
- if (overlayRef.current === 'none' && !inlineEditRef.current && supPanelRef.current === undefined && batchPickRef.current === undefined) {
- setTimeout(() => inputRef.current?.focus(), 0)
- }
- }}
- />
- {sugg !== undefined && (
-
- {sugg.list.length === 0 ? (
-
- No item matches (E-1102) · Enter quick-add · Esc clear
-
- ) : (
- sugg.list.map((it, i) => (
-
{ setSugg({ ...sugg, hi: i }); pickSuggestion() }}
- >
- {it.name}
- {it.code}
- {(() => {
- const oh = stockMap.get(it.code)
- return oh === undefined ? —
- : oh <= 0 ? out
- : oh < 15 ? {Math.round(oh)} left
- : {Math.round(oh)}
- })()}
-
- {formatINR(it.salePricePaise, { symbol: false })}{it.unit.code === 'KG' ? '/kg' : ''}
-
-
- ))
- )}
-
- )}
- {quickAdd !== undefined && (
- t.classCode)}
- onDone={(item) => {
- setCat((c) => [...c, item])
- setQuickAdd(undefined)
- addItem(item)
- say('ok', `${item.name} added as draft — complete it in Back Office → Items`)
- }}
- onCancel={() => { setQuickAdd(undefined); inputRef.current?.focus() }}
- />
- )}
- {pendingQty !== undefined && (
-
- 50 ? 'warn' : 'accent'}>{pendingQty} ×
-
- )}
- {supPanel !== undefined ? (
- u.role !== 'cashier')}
- onApprove={(a) => { supPanel.resolve(a); setSupPanel(undefined) }}
- onCancel={() => { supPanel.resolve(undefined); setSupPanel(undefined) }}
- />
- ) : batchPick !== undefined ? (
- { void acceptBatch(b) }}
- onCancel={cancelBatchPick}
- />
- ) : notice !== undefined ? (
- {notice.text}
- ) : null}
-
-
- {lines.length === 0 ? (
-
Scan the first item to start a bill
- Try: 8901063014357 · tomato · 3*104 · scale code 2100042012509
-
- ) : (
-
-
-
- # Item Qty Rate
- {(lines.some((l) => l.discount !== undefined) || editCell?.field === 'discount') && Disc }
- Amount
-
-
-
- {computed?.lines.map((l, i) => (
- setSelected(i)}>
- {i + 1}
- {l.name}
-
- {editCell?.line === i && editCell.field === 'qty' ? (
- {
- const q = Number(v)
- if (q > 0) setLines((prev) => prev.map((x, j) => (j === i ? { ...x, qty: q } : x)))
- else if (q === 0) setLines((prev) => prev.filter((_, j) => j !== i))
- setEditCell(undefined)
- inputRef.current?.focus()
- }}
- />
- ) : (
- <>{l.qty} {l.unitCode}>
- )}
-
-
- {editCell?.line === i && editCell.field === 'price' ? (
- { void commitPriceEdit(i, v) }}
- />
- ) : (
- formatINR(l.unitPricePaise, { symbol: false })
- )}
-
- {(lines.some((x) => x.discount !== undefined) || editCell?.field === 'discount') && (
-
- {editCell?.line === i && editCell.field === 'discount' ? (
- { void commitDiscountEdit(i, v) }}
- />
- ) : l.discountPaise > 0 ? (
- '-' + formatINR(l.discountPaise, { symbol: false })
- ) : (
- '—'
- )}
-
- )}
- {formatINR(l.lineTotalPaise, { symbol: false })}
-
- ))}
-
-
- )}
-
-
-
- {QUICK_KEYS.map((code, i) => {
- const it = cat.find((c) => c.code === code)
- return it === undefined ? null : (
- resolveCode(code)}>
- {it.name.replace(' (loose)', '')}
- Ctrl+{i + 1}
-
- )
- })}
-
-
-
-
- setOverlay('customer')} style={{ cursor: 'pointer' }}>
- Customer: {customer !== undefined ?
{customer.name} :
walk-in } — attach by phone
F6
- {supply.b2b && (
-
- B2B
- {supply.buyerGstin}
-
- )}
-
-
-
Items / Qty {lines.length} / {lines.reduce((a, l) => a + l.qty, 0)}
-
Gross {formatINR(totals?.grossPaise ?? 0)}
-
- GST{supply.interState ? <> IGST · inter-state > : ''}
- {formatINR((totals?.cgstPaise ?? 0) + (totals?.sgstPaise ?? 0) + (totals?.igstPaise ?? 0) + (totals?.cessPaise ?? 0))}
-
-
Round off {formatINR(totals?.roundOffPaise ?? 0)}
-
-
- PAYABLE
- {formatINR(totals?.payablePaise ?? 0)}
-
- {changeFlash !== undefined && (
-
- CHANGE
- {formatINR(changeFlash)}
-
- )}
- {(totals?.savingsVsMrpPaise ?? 0) > 0 && (
- You saved {formatINR(totals!.savingsVsMrpPaise)} vs MRP
- )}
-
- commit('CASH')} hotkey="F12">{busy ? 'Saving…' : 'Cash'}
- setOverlay('pay')} hotkey="F11">Pay…
- Hold
- Resume ({held.length})
-
-
-
-
-
-
- {cfg.enabled ? `Printer ${cfg.host}` : 'Printer off'}
- {window.pos !== undefined ? ' · kiosk shell' : ' · web'}
- {offlineCount > 0 && (
- OFFLINE · {offlineCount} queued
- )}
- Bills today: {dayLog.length}
- Last: {dayLog[dayLog.length - 1]?.docNo ?? '—'}
- Held: {held.length}
- Float: {formatINR(props.floatPaise)}
-
- DB: store-server (SQLite slice, D12 Oracle adapter next)
- v0.2
-
-
- {overlay === 'pay' && (
-
setOverlay('none')}>
-
- {(['CASH', 'UPI', 'CARD', 'KHATA'] as PaymentMode[]).map((m) => (
- commit(m)}>{m}
- ))}
-
- Split payments and UPI dynamic QR come with the payments integration (01-SCOPE §2).
-
- )}
-
- {overlay === 'customer' && (
-
{ setOverlay('none'); inputRef.current?.focus() }}
- onAttach={(c) => {
- setCustomer(c)
- setOverlay('none')
- say('ok', c.gstin !== undefined ? `B2B customer: ${c.name} · ${c.gstin}` : `Customer: ${c.name}`)
- }}
- />
- )}
-
- {overlay === 'returns' && (
- { setOverlay('none'); inputRef.current?.focus() }}
- onCommitted={(docNo, refundPaise, reason) => {
- setOverlay('none')
- // Stock went UP server-side (R7) — refresh the local views so the counter sees it.
- fetchStock().then((rows) => setStockMap(new Map(rows.map((r) => [r.code, Number(r.on_hand)])))).catch(() => undefined)
- refreshBatches()
- say('ok', `Credit note ${docNo} · refund ${formatINR(refundPaise)} (${reason})`)
- inputRef.current?.focus()
- }}
- />
- )}
-
- {overlay === 'settings' && setOverlay('none')} />}
-
- {overlay === 'history' && (
- setOverlay('none')}>
- {serverBills === undefined ? Loading… : serverBills.length === 0 ? (
- No bills yet today on this counter.
- ) : (
-
- Bill Cashier Customer Amount
-
- {serverBills.map((b) => (
-
- {String(b['doc_no'])}
- {String(b['cashier_name'] ?? '')}
- {b['customer_name'] !== null ? String(b['customer_name']) : 'walk-in'}
- {formatINR(Number(b['payable_paise']))}
-
- ))}
-
-
- )}
-
- )}
-
- {overlay === 'dayend' && (
- setOverlay('none')}>
- {(['CASH', 'UPI', 'CARD', 'KHATA'] as PaymentMode[]).map((m) => (
-
-
{m} ({dayLog.filter((b) => b.mode === m).length} bills)
- {formatINR(dayLog.filter((b) => b.mode === m).reduce((a, b) => a + b.payablePaise, 0))}
-
- ))}
-
- This shift
-
- {formatINR(dayLog.reduce((a, b) => a + b.payablePaise, 0))}
-
-
- Full day-end (all counters, variance vs float) comes with shift-close on the server.
-
- )}
-
- )
-}
-
-/** Inline cell editor for F2/F3 — never a modal; Enter commits, Esc handled globally. */
-function EditCell(props: { initial: string; onCommit: (value: string) => void }) {
- return (
- e.target.select()}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === 'Tab') {
- e.preventDefault()
- props.onCommit((e.target as HTMLInputElement).value)
- }
- }}
- />
- )
-}
-
-/**
- * Inline FEFO batch picker (spec S5) — never a modal (R18), replaces the notice area.
- * Lots are FEFO-sorted (earliest expiry first); the first sellable lot is preselected;
- * ↑/↓ move, Enter accepts, Esc cancels (Esc is handled by the screen's global handler).
- * Expired lots are flagged red — selecting one raises the E-1310 gate upstream. The root
- * div takes focus so the scan box never sees these keys mid-pick.
- */
-function BatchPicker(props: {
- item: Item
- qty: number
- batches: BatchStock[]
- today: string
- onPick: (b: BatchStock) => void
- onCancel: () => void
-}) {
- const sorted = useMemo(() => sortFefo(props.batches), [props.batches])
- const [hi, setHi] = useState(() => {
- const pick = pickFefoBatch(props.batches, props.today)
- const idx = pick !== undefined ? sorted.findIndex((b) => b.id === pick.id) : 0
- return idx < 0 ? 0 : idx
- })
- const rootRef = useRef(null)
- useEffect(() => { rootRef.current?.focus() }, [])
-
- return (
- {
- if (e.key === 'ArrowDown') { e.preventDefault(); setHi((h) => (h + 1) % sorted.length) }
- else if (e.key === 'ArrowUp') { e.preventDefault(); setHi((h) => (h + sorted.length - 1) % sorted.length) }
- else if (e.key === 'Enter') { e.preventDefault(); const b = sorted[hi]; if (b !== undefined) props.onPick(b) }
- }}
- >
-
- Pick batch — {props.item.name} × {props.qty}
- FEFO · ↑/↓ move · Enter accept · Esc cancel
-
-
- {sorted.map((b, i) => {
- const expired = isExpired(b, props.today)
- return (
-
props.onPick(b)}
- style={{
- display: 'flex', gap: 12, alignItems: 'center', padding: '4px 8px', borderRadius: 4,
- cursor: 'pointer', fontWeight: i === hi ? 600 : 400,
- border: i === hi ? '1px solid var(--accent)' : '1px solid transparent',
- color: expired ? 'var(--err)' : undefined,
- }}
- >
- {b.batchNo}
- {b.expiryDate !== undefined ? `exp ${b.expiryDate}` : 'no expiry'}
- {Math.round(b.onHand)} on hand
- {expired ? EXPIRED : i === hi ? FEFO : null}
-
- )
- })}
-
-
Cancel (Esc)
-
- )
-}
-
-/**
- * Inline supervisor gate (spec P0-2) — never a modal, replaces the notice area.
- * Pick an approver (bootstrap users with cashiers excluded), enter their 4–6 digit
- * PIN, verify via /auth/verify-pin (no session created, cashier stays logged in).
- * Three wrong PINs closes the panel and cancels the edit.
- */
-function SupervisorPanel(props: {
- desc: string
- override: OverrideContext
- approvers: PosUser[]
- onApprove: (a: Approval) => void
- onCancel: () => void
-}) {
- const [idx, setIdx] = useState(0)
- const [pin, setPin] = useState('')
- const [fails, setFails] = useState(0)
- const [busy, setBusy] = useState(false)
- const [error, setError] = useState()
- const pinRef = useRef(null)
-
- const submit = () => {
- const who = props.approvers[idx]
- if (who === undefined || pin.length < 4 || busy) return
- setBusy(true)
- setError(undefined)
- verifySupervisorPin(who.id, pin, props.override)
- .then((r) => props.onApprove({ approvalId: r.approvalId, approvedByName: r.displayName }))
- .catch((e: Error) => {
- const n = fails + 1
- setFails(n)
- setPin('')
- setBusy(false)
- if (n >= 3) return props.onCancel() // 3 failures closes + cancels the edit
- setError(`${e.message} — attempt ${n} of 3`)
- pinRef.current?.focus()
- })
- }
-
- return (
-
- Supervisor approval:
- {props.desc}
- {props.approvers.length === 0 ? (
- No supervisor available — cancel (Esc)
- ) : (
- <>
- setIdx(Number(e.target.value))}
- onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); pinRef.current?.focus() } }}
- >
- {props.approvers.map((u, i) => {u.name} ({u.role}) )}
-
- setPin(e.target.value.replace(/\D/g, ''))}
- onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit() } }}
- />
- {busy ? 'Checking…' : 'Approve'}
- >
- )}
- Cancel (Esc)
- {error !== undefined && {error} }
-
- )
-}
-
-/** Unknown-code quick-add: 3 fields, item is billable immediately, draft-flagged for back office. */
-function QuickAddForm(props: {
- seed: { code: string; name?: string }
- taxClasses: string[]
- onDone: (item: Item) => void
- onCancel: () => void
-}) {
- const [name, setName] = useState(props.seed.name ?? '')
- const [priceRs, setPriceRs] = useState('')
- const [taxClass, setTaxClass] = useState('GST18')
- const [error, setError] = useState()
- const priceRef = useRef(null)
-
- const save = () => {
- const paise = Math.round(Number(priceRs) * 100)
- if (name.trim() === '' || !(paise > 0)) return setError('Name and price are required')
- createDraftItem({
- code: props.seed.code !== '' ? props.seed.code : `D-${Date.now() % 100000}`,
- name: name.trim(), hsn: '', taxClassCode: taxClass,
- unitCode: 'PCS', unitDecimals: 0, salePricePaise: paise,
- ...(props.seed.code.length >= 8 ? { barcode: props.seed.code } : {}),
- status: 'draft',
- }).then(props.onDone).catch((e: Error) => setError(e.message))
- }
-
- return (
-
- Quick-add{props.seed.code !== '' ? ` ${props.seed.code}` : ''}:
- setName(e.target.value)}
- onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); priceRef.current?.focus() } }}
- />
- setPriceRs(e.target.value)}
- onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); save() } }}
- />
- setTaxClass(e.target.value)}>
- {props.taxClasses.map((c) => {c} )}
-
- Add & bill
- Skip (Esc)
- {error !== undefined && {error} }
-
- )
-}
-
-function CustomerModal(props: { onClose: () => void; onAttach: (c: PosCustomer) => void }) {
- const [phone, setPhone] = useState('')
- const [name, setName] = useState('')
- const [gstin, setGstin] = useState('')
- const [results, setResults] = useState([])
- const [error, setError] = useState()
-
- const lookup = (p: string) => {
- setPhone(p)
- setError(undefined)
- if (p.length >= 4) {
- findCustomers(p).then(setResults).catch((e: Error) => setError(e.message))
- } else {
- setResults([])
- }
- }
-
- // Optional B2B identity — validated inline so the cashier sees checksum/state
- // feedback before the server rejects a typo (the server re-validates anyway).
- const gstinTrimmed = gstin.trim().toUpperCase()
- const gstinCheck = gstinTrimmed === '' ? undefined : validateGstin(gstinTrimmed)
- const gstinBad = gstinCheck !== undefined && !gstinCheck.ok
-
- const create = () => {
- if (gstinBad) return setError('Fix the GSTIN or clear it before creating the customer')
- createCustomer(name === '' ? `Customer ${phone.slice(-4)}` : name, phone, gstinTrimmed === '' ? undefined : gstinTrimmed)
- .then(props.onAttach)
- .catch((e: Error) => setError(e.message))
- }
-
- return (
-
-
- lookup(e.target.value.replace(/\D/g, ''))} placeholder="98400…" />
-
- {results.length > 0 && (
-
-
- {results.map((c) => (
- props.onAttach(c)}>
- {c.name}{c.gstin !== undefined ? {c.gstin} : ''}
- {c.phone}
- {c.gstin !== undefined ? B2B : attach }
-
- ))}
-
-
- )}
- {phone.length >= 10 && results.length === 0 && (
- <>
-
- setName(e.target.value)} />
-
-
- setGstin(e.target.value.toUpperCase())}
- onKeyDown={(e) => { if (e.key === 'Enter' && !gstinBad) { e.preventDefault(); create() } }}
- />
-
- {gstinCheck !== undefined && (
- gstinCheck.ok
- ? B2B · place of supply state {gstinCheck.stateCode} — GSTIN valid
- : GSTIN {gstinCheck.reason === 'checksum' ? 'checksum is wrong' : 'format is wrong (15 chars)'} — fix or leave blank for a B2C bill
- )}
- Create & attach
- >
- )}
- {error !== undefined && {error} }
-
- )
-}
-
-/**
- * F9 returns / credit note (01-SCOPE Sales→Returns, 09-UX returns/exchange). Look up the
- * ORIGINAL bill by doc no or customer phone, choose returnable quantities per line, pick a
- * reason + refund tender, and commit. The refund is computed by the SHARED computeReturn from
- * the original bill's own amounts (so it matches the server to the paisa, R5), but the server
- * is authoritative — it re-anchors to its stored bill, blocks over-return, and re-verifies the
- * refund (409 on mismatch). Keyboard-first, errors inline, Esc closes (R18/R19).
- */
-function ReturnsModal(props: {
- store: BootstrapStore
- user: PosUser
- shiftId: string
- today: string
- onClose: () => void
- onCommitted: (docNo: string, refundPaise: number, reason: string) => void
-}) {
- const { store, user, today } = props
- const [mode, setMode] = useState<'docno' | 'phone'>('docno')
- const [query, setQuery] = useState('')
- const [results, setResults] = useState()
- const [returnable, setReturnable] = useState()
- const [qtys, setQtys] = useState>({})
- const [reason, setReason] = useState(RETURN_REASONS[0])
- const [refundMode, setRefundMode] = useState('CASH')
- const [error, setError] = useState()
- const [busy, setBusy] = useState(false)
-
- const lookup = () => {
- const q = query.trim()
- if (q === '') return
- setError(undefined)
- setResults(undefined)
- const p = mode === 'docno' ? lookupBillByDocNo(q) : lookupBillsByPhone(q)
- p.then((r) => {
- setResults(r)
- if (r.length === 0) setError(mode === 'docno' ? `No bill "${q}" found` : `No bills for phone ${q}`)
- }).catch((e: Error) => setError(e.message))
- }
-
- const chooseBill = (billId: string) => {
- setError(undefined)
- fetchReturnable(billId)
- .then((rb) => {
- setReturnable(rb)
- setQtys({})
- if (rb.lines.every((l) => l.returnableQty <= 0)) setError('Every line on this bill has already been fully returned.')
- })
- .catch((e: Error) => setError(e.message))
- }
-
- // The requested lines: every line with a positive typed quantity.
- const requested = returnable === undefined ? [] : returnable.lines.flatMap((l) => {
- const q = Number(qtys[l.lineIndex] ?? '')
- return Number.isFinite(q) && q > 0 ? [{ lineIndex: l.lineIndex, qty: q }] : []
- })
- const overReturn = returnable !== undefined && returnable.lines.some((l) => {
- const q = Number(qtys[l.lineIndex] ?? '')
- return Number.isFinite(q) && q > l.returnableQty
- })
- // Live refund preview via the SHARED engine (server stays authoritative).
- let preview: ReturnType | undefined
- try {
- preview = returnable !== undefined && requested.length > 0 && !overReturn
- ? computeReturn(returnable.originalLines, requested, { roundToRupee: true })
- : undefined
- } catch { preview = undefined }
-
- const printCreditNote = (docNo: string, computed: ReturnType): Promise => {
- const cfg = loadPrinterCfg()
- const kick = refundMode === 'CASH'
- const receiptDoc: ReceiptDoc = {
- storeName: store.name, gstin: store.gstin, docNo, businessDate: today,
- counterCode: store.counterCode, cashierName: user.name,
- ...(returnable?.customerName !== undefined ? { buyerName: returnable.customerName } : {}),
- lines: computed.lines, totals: computed.totals, paymentLabel: refundMode,
- variant: 'credit-note', againstDocNo: returnable?.docNo ?? '', reason,
- }
- return cfg.script === 'raster'
- ? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick)
- : sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick }))
- }
-
- const commit = () => {
- if (returnable === undefined || busy) return
- if (requested.length === 0) { setError('Enter a return quantity on at least one line.'); return }
- if (overReturn) { setError('A return quantity exceeds what is left to return.'); return }
- if (preview === undefined) { setError('Could not compute the refund — check the quantities.'); return }
- const snapshot = preview
- setBusy(true)
- setError(undefined)
- postReturn({
- storeId: store.id, counterId: store.counterId, shiftId: props.shiftId, businessDate: today,
- originalBillId: returnable.id, lines: requested, reason, refundMode,
- clientRefundPaise: snapshot.totals.payablePaise,
- })
- .then((res) => {
- // The credit note is committed server-side; printing is a consequence (never blocks it).
- void printCreditNote(res.docNo, snapshot).catch(() => undefined)
- props.onCommitted(res.docNo, res.refundPaise, reason)
- })
- .catch((e: Error) => { setError(e.message); setBusy(false) })
- }
-
- return (
-
- {returnable === undefined ? (
- <>
-
- { setMode(e.target.value as 'docno' | 'phone'); setResults(undefined); setError(undefined) }}
- >
- By bill no
- By phone
-
- setQuery(mode === 'phone' ? e.target.value.replace(/\D/g, '') : e.target.value)}
- onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookup() } }}
- />
- Find
-
- {results !== undefined && results.length > 0 && (
-
- Bill Date Cashier Customer Amount
-
- {results.map((b) => (
- chooseBill(b.id)}>
- {b.doc_no}
- {b.business_date}
- {b.cashier_name ?? ''}
- {b.customer_name !== null && b.customer_name !== undefined ? b.customer_name : 'walk-in'}
- {formatINR(b.payable_paise)}
-
- ))}
-
-
- )}
- >
- ) : (
- <>
-
- Returning against {returnable.docNo} · {returnable.businessDate}
- {returnable.customerName !== undefined ? <> · {returnable.customerName}> : null}
-
- { setReturnable(undefined); setResults(undefined); setError(undefined) }}>Change bill
-
-
-
-
-
- setReason(e.target.value)}>
- {RETURN_REASONS.map((r) => {r} )}
-
-
-
- setRefundMode(e.target.value as PaymentMode)}>
- {(['CASH', 'UPI', 'CARD', 'KHATA'] as PaymentMode[]).map((m) => {m} )}
-
-
-
- {overReturn && A return quantity exceeds what is left to return. }
-
- REFUND
- {formatINR(preview?.totals.payablePaise ?? 0)}
-
- {busy ? 'Processing…' : 'Commit return & print credit note'}
- >
- )}
- {error !== undefined && {error} }
-
- )
-}
diff --git a/apps/pos/src/Login.tsx b/apps/pos/src/Login.tsx
deleted file mode 100644
index d29b5ae..0000000
--- a/apps/pos/src/Login.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useState } from 'react'
-import { Notice } from '@sims/ui'
-import { pinLogin, type BootstrapStore, type PosUser } from './api'
-
-/**
- * POS login (09-UX §1): name tiles + PIN pad. Verification is server-side —
- * scrypt hash + lockout reducer in the store-server. Submit on ✓ or Enter.
- */
-export function Login(props: { store: BootstrapStore; users: PosUser[]; onLogin: (u: PosUser) => Promise }) {
- const [selected, setSelected] = useState(props.users[0]!)
- const [pin, setPin] = useState('')
- const [busy, setBusy] = useState(false)
- const [error, setError] = useState()
-
- const submit = async () => {
- if (pin.length < 4 || busy) return
- setBusy(true)
- setError(undefined)
- try {
- await pinLogin(selected.id, pin)
- await props.onLogin(selected)
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err))
- setPin('')
- setBusy(false)
- }
- }
-
- const press = (d: string) => {
- setError(undefined)
- if (d === '⌫') return setPin((p) => p.slice(0, -1))
- if (d === '✓') return void submit()
- if (pin.length < 6) setPin((p) => p + d)
- }
-
- return (
- {
- if (/^\d$/.test(e.key)) press(e.key)
- if (e.key === 'Backspace') press('⌫')
- if (e.key === 'Enter') void submit()
- }}
- >
-
-
{props.store.name}
-
- Counter {props.store.counterCode} · SiMS Next POS
-
-
- {props.users.map((u) => (
-
{
- setSelected(u)
- setPin('')
- }}
- >
- {u.name}
- {u.role}
-
- ))}
-
-
- {[0, 1, 2, 3, 4, 5].map((i) => (
-
- ))}
-
-
- {['1', '2', '3', '4', '5', '6', '7', '8', '9', '⌫', '0', '✓'].map((d) => (
- press(d)}>
- {d}
-
- ))}
-
- {error !== undefined &&
{error} }
- {busy &&
Signing in… }
-
-
- )
-}
diff --git a/apps/pos/src/Settings.tsx b/apps/pos/src/Settings.tsx
deleted file mode 100644
index 526e3f0..0000000
--- a/apps/pos/src/Settings.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useState } from 'react'
-import { Button, Field, Modal, Notice } from '@sims/ui'
-import { EscPos } from '@sims/printing'
-import { loadPrinterCfg, savePrinterCfg, sendRasterReceipt, sendToPrinter, type PrinterCfg } from './print'
-
-/** POS settings (Ctrl+,) — printer transport, test print, drawer kick. */
-export function SettingsModal(props: { onClose: () => void }) {
- const [cfg, setCfg] = useState(loadPrinterCfg())
- const [status, setStatus] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>()
-
- const save = (next: PrinterCfg) => {
- setCfg(next)
- savePrinterCfg(next)
- }
-
- const attempt = async (bytes: Uint8Array, okText: string) => {
- try {
- await sendToPrinter({ ...cfg, enabled: true }, bytes)
- setStatus({ tone: 'ok', text: okText })
- } catch (err) {
- setStatus({ tone: 'err', text: err instanceof Error ? err.message : String(err) })
- }
- }
-
- const testPrint = async () => {
- try {
- if (cfg.script === 'raster') {
- await sendRasterReceipt(
- { ...cfg, enabled: true },
- ['SiMS NEXT TEST PRINT', 'रास्टर / റാസ്റ്റർ / Raster', new Date().toLocaleString()],
- false,
- )
- } else {
- await sendToPrinter(
- { ...cfg, enabled: true },
- new EscPos().init().align('center').bold(true).line('SiMS NEXT TEST PRINT').bold(false)
- .line(new Date().toLocaleString()).feed(4).cut().build(),
- )
- }
- setStatus({ tone: 'ok', text: `Test page sent (${cfg.script})` })
- } catch (err) {
- setStatus({ tone: 'err', text: err instanceof Error ? err.message : String(err) })
- }
- }
-
- return (
-
-
- save({ ...cfg, host: e.target.value })} />
-
-
- save({ ...cfg, port: Number(e.target.value) })}
- />
-
-
- save({ ...cfg, width: Number(e.target.value) as 32 | 42 })}
- >
- 2 inch (32 chars)
- 3 inch (42 chars)
-
-
-
- save({ ...cfg, script: e.target.value as PrinterCfg['script'] })}
- >
- ASCII (fast, Latin only)
- Raster (Indic — Hindi/Malayalam item names)
-
-
-
- save({ ...cfg, enabled: e.target.value === 'on' })}
- >
- Enabled
- Disabled (bills still commit)
-
-
-
- void testPrint()}>
- Test print
-
- void attempt(new EscPos().drawerKick().build(), 'Drawer kick sent')}>
- Kick drawer
-
- Close
-
- {status !== undefined && {status.text} }
-
- Printing route: {window.pos !== undefined ? 'Electron kiosk bridge (direct)' : 'store-server /api/print (web app)'}.
- USB printers: share via a print server or use the printer's network port —
- driver-spooled USB raw printing is a Phase-1 decision (see BUILDING.md).
-
-
- )
-}
diff --git a/apps/pos/src/ShiftOpen.tsx b/apps/pos/src/ShiftOpen.tsx
deleted file mode 100644
index 03d1f18..0000000
--- a/apps/pos/src/ShiftOpen.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useState } from 'react'
-import { Button, Field } from '@sims/ui'
-import { formatINR } from '@sims/domain'
-
-const DENOMS = [50000, 20000, 10000, 5000, 2000, 1000, 500, 200, 100] // paise per note/coin
-
-/** Shift open with denomination count (09-UX §1.5) — the OG Day-Begin ritual's counter-level sibling. */
-export function ShiftOpen(props: { userName: string; onOpen: (floatPaise: number) => void; onBack: () => void }) {
- const [counts, setCounts] = useState>({})
- const total = DENOMS.reduce((a, d) => a + d * (counts[d] ?? 0), 0)
-
- return (
-
-
-
Open shift — {props.userName}
-
- {DENOMS.map((d) => (
-
- setCounts((c) => ({ ...c, [d]: Number(e.target.value) }))}
- />
-
- ))}
-
-
- Opening float
- {formatINR(total)}
-
-
- props.onOpen(total)}>Open shift
- props.onOpen(0)}>Skip count
- Back
-
-
-
- )
-}
diff --git a/apps/pos/src/api.ts b/apps/pos/src/api.ts
deleted file mode 100644
index e7e5be2..0000000
--- a/apps/pos/src/api.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import type { BillLine, Item } from '@sims/domain'
-import type { LineInput, TaxClassRow } from '@sims/billing-engine'
-
-/** POS ↔ store-server client. Same origin in production (served under /pos/). */
-
-export interface BootstrapStore {
- id: string; name: string; storeCode: string; stateCode: string
- gstin: string; counterId: string; counterCode: string
-}
-export interface PosUser { id: string; name: string; role: string }
-export interface Bootstrap { store: BootstrapStore; users: PosUser[]; taxClasses: TaxClassRow[] }
-
-/**
- * Thrown ONLY when the store-server is physically unreachable (fetch rejected) —
- * as opposed to a server that answered with an error (4xx/5xx). The counter's
- * offline path (S4) keys off this: a NetworkError means "queue the bill and keep
- * billing"; any other error means the server rejected the bill and the cart is
- * kept on screen instead (R18: the cart is never lost, either way).
- */
-export class NetworkError extends Error {
- constructor(message = 'Store-server unreachable (E-3301)') {
- super(message)
- this.name = 'NetworkError'
- }
-}
-
-// The POS session token survives a browser refresh in sessionStorage (S4): a
-// mid-shift reload while the server is down can resume the already-authenticated
-// session. sessionStorage (not localStorage) so it dies with the tab, never
-// outlives the shift. Guarded for non-browser contexts (unit tests).
-const TOKEN_KEY = 'pos.token'
-const readStoredToken = (): string => {
- try { return sessionStorage.getItem(TOKEN_KEY) ?? '' } catch { return '' }
-}
-let token = readStoredToken()
-
-function setToken(t: string): void {
- token = t
- try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* non-browser: in-memory only */ }
-}
-
-/** True when an authenticated session token is present (used by offline resume). */
-export const hasSession = (): boolean => token !== ''
-
-async function call(path: string, init?: RequestInit): Promise {
- const res = await fetch(`/api${path}`, {
- ...init,
- headers: {
- 'content-type': 'application/json',
- ...(token !== '' ? { authorization: `Bearer ${token}` } : {}),
- },
- }).catch(() => undefined)
- if (res === undefined) throw new NetworkError()
- const body = (await res.json().catch(() => ({}))) as T & { error?: string }
- if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`)
- return body
-}
-
-/** Unauthenticated liveness probe — the drain loop pings this before draining. */
-export const pingHealth = (): Promise =>
- fetch('/api/health').then((r) => r.ok).catch(() => false)
-
-export const fetchBootstrap = (counter: string): Promise =>
- call(`/bootstrap?counter=${encodeURIComponent(counter)}`)
-
-export async function pinLogin(userId: string, pin: string): Promise {
- const s = await call<{ token: string }>('/auth/pin', {
- method: 'POST',
- body: JSON.stringify({ userId, pin }),
- })
- setToken(s.token)
-}
-
-/**
- * End the session server-side (M4) then drop the local token. Best-effort: if the store-server
- * is unreachable we still forget the token locally so the counter locks. Called from "Lock".
- */
-export async function logout(): Promise {
- try { await call('/auth/logout', { method: 'POST' }) } catch { /* server down: drop the token anyway */ }
- setToken('')
-}
-
-/**
- * The exact deviation a supervisor is approving. It is sent to /auth/verify-pin so the
- * minted token is BOUND to this item+kind+before+after (H4 fix) — the server re-checks it
- * against the line's own server-computed deviation at commit, so an approval for one line
- * can never be redirected to another.
- */
-export interface OverrideContext {
- itemId: string
- kind: 'price' | 'qty' | 'discount' | 'batch'
- /** Server-anchored "before" value (the item master price for a price override; 0 otherwise). */
- before: number
- after: number
-}
-
-/**
- * In-flow supervisor gate (spec P0-2): verify a supervisor's PIN without touching
- * the module-level `token` — the cashier stays logged in and no session is created.
- * Distinct from `pinLogin` on purpose; reusing that would hijack the cashier's session.
- * The bound override context rides along so the server mints a token pinned to it.
- */
-export const verifySupervisorPin = (userId: string, pin: string, override: OverrideContext): Promise<{ ok: true; userId: string; displayName: string; approvalId: string }> =>
- call('/auth/verify-pin', { method: 'POST', body: JSON.stringify({ userId, pin, override }) })
-
-// The counter caches the whole sellable catalog (no silent cap — R13); ?all=1
-// returns every non-inactive item unpaginated.
-export const fetchItemsCache = (): Promise- => call('/items?all=1')
-
-export const fetchStock = (): Promise<{ code: string; on_hand: number }[]> => call('/stock')
-
-export interface CommitResult { id: string; docNo: string; payablePaise: number }
-
-/**
- * Supervisor-approved override, audited server-side in the commit transaction (spec
- * P0-2). `approvalId` is the single-use token returned by verifySupervisorPin — the
- * server redeems it and records the approver from its own row, so the client never
- * asserts who approved (SEC fix).
- */
-export interface BillOverride {
- itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string
-}
-
-export const postBill = (body: {
- storeId: string; counterId: string; shiftId: string; customerId?: string
- businessDate: string; lines: LineInput[]
- payments: { mode: string; amountPaise: number }[]
- clientPayablePaise: number
- overrides?: BillOverride[]
-}): Promise
=> call('/bills', { method: 'POST', body: JSON.stringify(body) })
-
-/**
- * Batches with derived on-hand > 0 for the FEFO picker (S5). The POS caches these
- * keyed by item at login and refreshes after each commit so per-batch on-hand stays
- * live. Raw server rows; the billing screen maps them to the domain BatchStock shape.
- */
-export interface PosBatchRow { id: string; item_id: string; batch_no: string; expiry_date: string | null; on_hand: number }
-export const fetchBatches = (): Promise => call('/batches')
-
-/**
- * The drain payload for a bill queued offline (S4). Unlike the online postBill it
- * carries its own `id` (client uuidv7, the idempotency key). Tenant and cashier are
- * NOT in the body — the drain runs under the cashier's live session and the server
- * takes both from it (SEC fix); if the session has expired the drain 401s and the
- * queue waits for re-login.
- */
-export interface OfflineBillBody {
- id: string
- storeId: string; counterId: string; shiftId: string; customerId?: string
- businessDate: string; lines: LineInput[]
- payments: { mode: string; amountPaise: number }[]
- clientPayablePaise: number
- overrides?: BillOverride[]
-}
-
-/** Drain one queued bill; the server assigns/returns its real GST doc no. */
-export const postBillOffline = (body: OfflineBillBody): Promise =>
- call('/bills/offline', { method: 'POST', body: JSON.stringify(body) })
-
-export const fetchBills = (date: string, counterId: string): Promise[]> =>
- call(`/bills?date=${date}&counterId=${encodeURIComponent(counterId)}`)
-
-export const createDraftItem = (body: {
- code: string; name: string; hsn: string; taxClassCode: string
- unitCode: string; unitDecimals: number; salePricePaise: number
- barcode?: string; status: string
-}): Promise- => call('/items', { method: 'POST', body: JSON.stringify(body) })
-
-/** Attached-customer identity the counter needs: name plus B2B place-of-supply inputs. */
-export interface PosCustomer { id: string; name: string; phone?: string; gstin?: string; stateCode?: string }
-
-export const findCustomers = (phone: string): Promise
=>
- call(`/parties?phone=${encodeURIComponent(phone)}`)
-
-export const createCustomer = (name: string, phone: string, gstin?: string): Promise =>
- call('/parties', {
- method: 'POST',
- body: JSON.stringify({ name, phone, ...(gstin !== undefined && gstin !== '' ? { gstin } : {}) }),
- })
-
-// ---------- returns / credit notes ----------
-
-/** A found original SALE bill for the return lookup (doc-no or phone search). */
-export interface BillLookupResult {
- id: string; doc_no: string; business_date: string; payable_paise: number
- cashier_name?: string; customer_name?: string | null
-}
-
-/** One original line annotated with sold / already-returned / still-returnable quantities. */
-export interface ReturnableLine {
- lineIndex: number
- itemId: string; name: string; hsn: string; unitCode: string
- unitPricePaise: number; taxRateBp: number; lineTotalPaise: number
- soldQty: number; returnedQty: number; returnableQty: number
- batchId?: string
-}
-
-/** The original bill + its returnable lines + the raw lines for the shared refund preview. */
-export interface ReturnableBill {
- id: string; docNo: string; businessDate: string
- customerId?: string; customerName?: string
- lines: ReturnableLine[]
- originalLines: BillLine[]
-}
-
-export interface ReturnResult { id: string; docNo: string; refundPaise: number }
-
-/** Look up an original SALE bill by its exact doc no (returns 0 or 1 result). */
-export const lookupBillByDocNo = (docNo: string): Promise =>
- call(`/bills/lookup?docNo=${encodeURIComponent(docNo)}`)
-
-/** Look up recent original SALE bills for a customer phone. */
-export const lookupBillsByPhone = (phone: string): Promise =>
- call(`/bills/lookup?phone=${encodeURIComponent(phone)}`)
-
-/** Fetch the original bill's returnable lines (sold / returned / returnable + raw lines). */
-export const fetchReturnable = (billId: string): Promise =>
- call(`/bills/${encodeURIComponent(billId)}/returnable`)
-
-/**
- * Commit a return / credit note. The server anchors everything to OUR stored original bill,
- * blocks over-return, computes the refund itself and re-verifies clientRefundPaise (409 on
- * mismatch). Tenant + cashier come from the session, never the body.
- */
-export const postReturn = (body: {
- storeId: string; counterId: string; shiftId: string; businessDate: string
- originalBillId: string; lines: { lineIndex: number; qty: number }[]
- reason: string; refundMode: string; clientRefundPaise: number
-}): Promise => call('/returns', { method: 'POST', body: JSON.stringify(body) })
diff --git a/apps/pos/src/audio.ts b/apps/pos/src/audio.ts
deleted file mode 100644
index db5a5f7..0000000
--- a/apps/pos/src/audio.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Audio feedback (09-UX §5.6): cashiers scan without looking — sound is the
- * primary channel. WebAudio oscillators, zero assets. Counter-mute later.
- */
-let ctx: AudioContext | undefined
-
-function tone(freqHz: number, ms: number, delayMs = 0): void {
- try {
- ctx ??= new AudioContext()
- const osc = ctx.createOscillator()
- const gain = ctx.createGain()
- osc.frequency.value = freqHz
- gain.gain.value = 0.08
- osc.connect(gain).connect(ctx.destination)
- const at = ctx.currentTime + delayMs / 1000
- osc.start(at)
- osc.stop(at + ms / 1000)
- } catch {
- /* no audio device — silent is fine */
- }
-}
-
-export const beepOk = (): void => tone(880, 60)
-export const beepWarn = (): void => tone(440, 100)
-export const beepErr = (): void => {
- tone(220, 120)
- tone(220, 120, 180)
-}
diff --git a/apps/pos/src/main.tsx b/apps/pos/src/main.tsx
deleted file mode 100644
index 45eb1ca..0000000
--- a/apps/pos/src/main.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { createRoot } from 'react-dom/client'
-import '@fontsource-variable/inter'
-import '@fontsource-variable/jetbrains-mono'
-import '../../../packages/ui/src/tokens.css'
-import './pos.css'
-import { initTheme } from '@sims/ui'
-import { App } from './App'
-
-initTheme()
-createRoot(document.getElementById('root')!).render( )
-
-// S4 app-shell offline: register the cache-first service worker (scope /pos/) so the
-// POS loads from cache when the store-server is down. Best-effort — a failure here
-// never blocks billing (the IndexedDB queue + cached catalog do the real work).
-if ('serviceWorker' in navigator) {
- window.addEventListener('load', () => {
- void navigator.serviceWorker.register('./sw.js').catch(() => undefined)
- })
-}
diff --git a/apps/pos/src/offline.ts b/apps/pos/src/offline.ts
deleted file mode 100644
index 1836642..0000000
--- a/apps/pos/src/offline.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { uuidv7 } from '@sims/domain'
-import type { BillTotals, Item } from '@sims/domain'
-import type { LineInput, TaxClassRow } from '@sims/billing-engine'
-import type { BillOverride, BootstrapStore, OfflineBillBody, PosUser } from './api'
-
-/**
- * S4 counter resilience — the browser POS survives the store-server dying mid-shift.
- * Two responsibilities, one IndexedDB database (`sims-pos-offline`):
- *
- * 1. `bills` — bills committed while the server was unreachable. Each is a client-
- * UUIDv7 fact with a provisional token (OFF-1, OFF-2…); the reconnect drain
- * POSTs them oldest-first and the server assigns the real GST doc no.
- * 2. `cache` — the catalog snapshot (items + tax classes + store/users) persisted
- * at login so a mid-shift refresh with the server down still reaches billing.
- *
- * Raw IndexedDB, no new dependency (R-parity with the zero-runtime-dep discipline).
- * The pure helpers below (token/record/body shaping) hold the serialization logic
- * and are unit-tested without a browser; everything that touches `indexedDB` stays
- * inside a function body so this module still imports in Node (vitest).
- *
- * M11 (offline PII): the queued bill persists only `customerId` — never the buyer's
- * name or GSTIN — and the server re-resolves buyer identity from that id at drain, so
- * no customer PII is written to a shared counter PC. The catalog `cache` (which also
- * holds the staff roster) is wiped on Lock/logout via `clearCatalogCache`. NOTE: this
- * removes the standing PII, not the queue itself — true at-rest ENCRYPTION of the
- * remaining offline queue is a follow-up (server-tier SQLCipher is finding H3).
- */
-
-// ---------- queued bill record ----------
-
-export interface QueuedBill {
- /** Client-generated bill id (uuidv7) — the server's idempotency key on drain. */
- id: string
- /** Monotonic provisional sequence (1, 2, 3…) — drives OFF-n and drain order. */
- seq: number
- /** Human token shown on the provisional receipt and in drain notices ("OFF-1"). */
- provisionalToken: string
- storeId: string
- counterId: string
- /** Local provenance only — who rang it up on this device. The server attributes the
- * drained bill to the session that drains it, never to this field (SEC fix). */
- cashierId: string
- shiftId: string
- /** The ONLY buyer identity persisted offline (M11): the server re-resolves the name,
- * GSTIN and place-of-supply from this id + tenant at drain, exactly as for an online
- * bill — so no customer PII (name/GSTIN) is ever written to this device. A walk-in
- * bill has no customerId and stores nothing extra. */
- customerId?: string
- businessDate: string
- lines: LineInput[]
- payments: { mode: string; amountPaise: number }[]
- /** Full totals snapshot (immutable fact); `payablePaise` becomes clientPayablePaise on drain. */
- totals: BillTotals
- overrides?: BillOverride[]
- createdAt: string
-}
-
-// ---------- pure helpers (unit-tested; no IndexedDB) ----------
-
-/** The provisional token printed instead of a GST doc no while offline. */
-export function nextProvisionalToken(seq: number): string {
- return `OFF-${seq}`
-}
-
-/** Build the immutable queued-bill record from the cart at commit time. */
-export function buildQueuedBill(input: {
- seq: number
- storeId: string; counterId: string; cashierId: string; shiftId: string
- customerId?: string
- businessDate: string
- lines: LineInput[]
- payments: { mode: string; amountPaise: number }[]
- totals: BillTotals
- overrides?: BillOverride[]
- /** Injectable clock for deterministic tests; defaults to now. */
- now?: number
-}): QueuedBill {
- const at = input.now ?? Date.now()
- return {
- id: uuidv7(at),
- seq: input.seq,
- provisionalToken: nextProvisionalToken(input.seq),
- storeId: input.storeId,
- counterId: input.counterId,
- cashierId: input.cashierId,
- shiftId: input.shiftId,
- ...(input.customerId !== undefined ? { customerId: input.customerId } : {}),
- businessDate: input.businessDate,
- lines: input.lines,
- payments: input.payments,
- totals: input.totals,
- ...(input.overrides !== undefined && input.overrides.length > 0 ? { overrides: input.overrides } : {}),
- createdAt: new Date(at).toISOString(),
- }
-}
-
-/**
- * Shape a queued bill into the /api/bills/offline drain payload. Drops display-only
- * fields AND the local cashierId — the server takes tenant + cashier from the live
- * session, never from the body (SEC fix).
- */
-export function toOfflineBillBody(q: QueuedBill): OfflineBillBody {
- return {
- id: q.id,
- storeId: q.storeId,
- counterId: q.counterId,
- shiftId: q.shiftId,
- ...(q.customerId !== undefined ? { customerId: q.customerId } : {}),
- businessDate: q.businessDate,
- lines: q.lines,
- payments: q.payments,
- clientPayablePaise: q.totals.payablePaise,
- ...(q.overrides !== undefined ? { overrides: q.overrides } : {}),
- }
-}
-
-/**
- * The provisional-token sequence, persisted in localStorage so OFF-n stays
- * monotonic across refreshes and never reuses a number after a bill drains and
- * leaves the queue (deriving from the queue's max seq would recycle numbers).
- */
-const SEQ_KEY = 'pos.offSeq'
-export function nextOffSeq(): number {
- let n = 0
- try { n = Number(localStorage.getItem(SEQ_KEY) ?? '0') } catch { n = 0 }
- const next = (Number.isFinite(n) ? n : 0) + 1
- try { localStorage.setItem(SEQ_KEY, String(next)) } catch { /* non-browser: caller supplies seq */ }
- return next
-}
-
-// ---------- IndexedDB access (browser-only) ----------
-
-const DB_NAME = 'sims-pos-offline'
-const DB_VERSION = 1
-const BILLS = 'bills'
-const CACHE = 'cache'
-
-function openIdb(): Promise {
- return new Promise((resolve, reject) => {
- const req = indexedDB.open(DB_NAME, DB_VERSION)
- req.onupgradeneeded = () => {
- const db = req.result
- if (!db.objectStoreNames.contains(BILLS)) db.createObjectStore(BILLS, { keyPath: 'id' })
- if (!db.objectStoreNames.contains(CACHE)) db.createObjectStore(CACHE, { keyPath: 'key' })
- }
- req.onsuccess = () => resolve(req.result)
- req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'))
- })
-}
-
-/** Run one request in its own transaction; resolves on transaction commit (durable). */
-function run(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest): Promise {
- return openIdb().then((db) => new Promise((resolve, reject) => {
- const tx = db.transaction(store, mode)
- const req = fn(tx.objectStore(store))
- let result: T
- req.onsuccess = () => { result = req.result }
- tx.oncomplete = () => { db.close(); resolve(result) }
- tx.onerror = () => { db.close(); reject(tx.error ?? new Error('IndexedDB tx failed')) }
- tx.onabort = () => { db.close(); reject(tx.error ?? new Error('IndexedDB tx aborted')) }
- }))
-}
-
-export async function enqueueBill(bill: QueuedBill): Promise {
- await run(BILLS, 'readwrite', (s) => s.put(bill))
-}
-
-/** All queued bills, oldest-first (the drain order). */
-export async function listQueuedBills(): Promise {
- const all = await run(BILLS, 'readonly', (s) => s.getAll() as IDBRequest)
- return all.sort((a, b) => a.seq - b.seq)
-}
-
-export async function removeQueuedBill(id: string): Promise {
- await run(BILLS, 'readwrite', (s) => s.delete(id) as unknown as IDBRequest)
-}
-
-export async function countQueuedBills(): Promise {
- return run(BILLS, 'readonly', (s) => s.count())
-}
-
-// ---------- catalog cache (browser-only) ----------
-
-export interface CatalogCache {
- key: 'catalog'
- store: BootstrapStore
- users: PosUser[]
- taxClasses: TaxClassRow[]
- items: Item[]
- savedAt: string
-}
-
-export async function saveCatalogCache(data: Omit): Promise {
- await run(CACHE, 'readwrite', (s) => s.put({ key: 'catalog', ...data }))
-}
-
-export async function loadCatalogCache(): Promise {
- return run(CACHE, 'readonly', (s) => s.get('catalog') as IDBRequest)
-}
-
-/**
- * Wipe the catalog/roster cache on Lock/logout (M11). Clears the whole `cache` store —
- * store details, staff roster and item master — so nothing about the shop or its staff
- * lingers on a shared counter PC once the cashier locks. Un-drained queued bills live in
- * a SEPARATE store (`bills`) and are intentionally left intact: a lock must never lose a
- * sale (R18); the catalog is re-fetched fresh on the next login. Post-lock the resume
- * context is cleared too (App.onLock), so this cache would never be read again anyway.
- */
-export async function clearCatalogCache(): Promise {
- await run(CACHE, 'readwrite', (s) => s.clear() as unknown as IDBRequest)
-}
diff --git a/apps/pos/src/pos.css b/apps/pos/src/pos.css
deleted file mode 100644
index 64edf3e..0000000
--- a/apps/pos/src/pos.css
+++ /dev/null
@@ -1,74 +0,0 @@
-/* POS-specific layout on top of @sims/ui tokens — dense, keyboard-first. */
-.pos-shell { display: flex; flex-direction: column; height: 100vh; }
-.pos-header {
- display: flex; gap: 16px; align-items: center; padding: 6px 14px;
- background: var(--bg-raised); border-bottom: 1px solid var(--border);
- font-size: 13px; color: var(--text-dim);
-}
-.pos-header .store { color: var(--text); font-weight: 600; }
-.pos-main { flex: 1; display: grid; grid-template-columns: 1fr 380px; min-height: 0; }
-.pos-left { display: flex; flex-direction: column; padding: 12px; min-width: 0; }
-.pos-right { border-left: 1px solid var(--border); display: flex; flex-direction: column; padding: 12px; gap: 10px; }
-
-.scanbox {
- font-size: 20px; padding: 12px 14px !important; font-family: var(--mono);
-}
-.sugg-list {
- position: absolute; z-index: 30; margin-top: 4px; min-width: 480px;
- background: var(--bg-raised); border: 1px solid var(--border-strong);
- border-radius: var(--radius); box-shadow: var(--shadow-lg); overflow: hidden;
-}
-.sugg-row {
- display: flex; gap: 16px; justify-content: space-between; align-items: baseline;
- padding: 8px 12px; cursor: pointer; font-size: 15px;
-}
-.sugg-row.hi { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
-.sugg-row:hover { background: var(--bg-hover); }
-.pos-left { position: relative; }
-
-.lines-wrap { flex: 1; overflow: auto; margin-top: 10px; }
-.lines-wrap table.wf { font-size: 15px; }
-.lines-wrap tr.selected td { background: var(--bg-hover); box-shadow: inset 3px 0 0 var(--accent); }
-
-.quick-keys { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-top: 10px; }
-.quick-keys button.wf { padding: 10px 6px; font-size: 13px; }
-.quick-keys button.wf kbd { display: block; margin: 4px 0 0; }
-
-.customer-strip {
- background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
- padding: 10px 12px; font-size: 13px; color: var(--text-dim);
-}
-.totals {
- background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
- padding: 12px; display: flex; flex-direction: column; gap: 4px;
-}
-.totals .row { display: flex; justify-content: space-between; color: var(--text-dim); }
-.totals .row .num { color: var(--text); }
-.payable {
- background: var(--bg); border: 2px solid var(--accent); border-radius: var(--radius);
- padding: 10px 14px; display: flex; justify-content: space-between; align-items: baseline;
-}
-.payable .amount { font-size: 44px; font-weight: 700; }
-.saved { text-align: center; color: var(--ok); font-size: 13px; }
-.pay-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
-.pay-buttons button.wf { padding: 14px; font-size: 16px; }
-
-.pos-status {
- display: flex; gap: 18px; padding: 5px 14px; font-size: 12px; color: var(--text-dim);
- background: var(--bg-raised); border-top: 1px solid var(--border);
-}
-.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; }
-.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.err { background: var(--err); }
-
-/* Login */
-.login-wrap { height: 100vh; display: flex; align-items: center; justify-content: center; gap: 40px; }
-.login-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; padding: 28px; width: 380px; }
-.user-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0; }
-.user-tiles button.wf { padding: 18px 10px; font-size: 15px; }
-.user-tiles button.wf.selected { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
-.pin-dots { display: flex; gap: 10px; justify-content: center; margin: 14px 0; }
-.pin-dots span { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--text-dim); }
-.pin-dots span.filled { background: var(--accent); border-color: var(--accent); }
-.pin-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
-.pin-pad button.wf { padding: 16px; font-size: 18px; }
-.denoms { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 16px; }
diff --git a/apps/pos/src/print.ts b/apps/pos/src/print.ts
deleted file mode 100644
index 5eb588b..0000000
--- a/apps/pos/src/print.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-export interface PrinterCfg {
- host: string
- port: number
- width: 32 | 42
- enabled: boolean
- /** ASCII text mode (fast, Latin only) vs raster bit-image (Indic scripts). */
- script: 'ascii' | 'raster'
-}
-
-const KEY = 'pos.printer'
-const DEFAULTS: PrinterCfg = { host: '192.168.1.100', port: 9100, width: 42, enabled: false, script: 'ascii' }
-
-export function loadPrinterCfg(): PrinterCfg {
- try {
- const raw = localStorage.getItem(KEY)
- // Merge over defaults so configs saved before `script` existed still load.
- if (raw !== null) return { ...DEFAULTS, ...(JSON.parse(raw) as Partial) }
- } catch {
- /* corrupted config falls back to defaults */
- }
- return { ...DEFAULTS }
-}
-
-export function savePrinterCfg(cfg: PrinterCfg): void {
- localStorage.setItem(KEY, JSON.stringify(cfg))
-}
-
-/**
- * Sends ESC/POS bytes to the printer. Web-first (D2 revision): the browser
- * can't open sockets, so the store-server does it (/api/print). The Electron
- * kiosk shell keeps its direct bridge when present.
- */
-export async function sendToPrinter(cfg: PrinterCfg, bytes: Uint8Array): Promise {
- if (!cfg.enabled) throw new Error('Printer disabled in settings (Ctrl+,)')
- if (window.pos !== undefined) {
- await window.pos.printRaw9100(cfg.host, cfg.port, bytes)
- return
- }
- const res = await fetch('/api/print', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ host: cfg.host, port: cfg.port, bytes: Array.from(bytes) }),
- }).catch(() => undefined)
- if (res === undefined) throw new Error('Store-server unreachable — bill is saved, print queued')
- if (!res.ok) {
- const body = (await res.json().catch(() => undefined)) as { error?: string } | undefined
- throw new Error(body?.error ?? `Print failed (HTTP ${res.status})`)
- }
-}
-
-/**
- * Print a receipt as an Indic raster bit-image (GS v 0). Same transport as
- * sendToPrinter; the content is drawn to a canvas first (raster.ts) so
- * non-Latin item names survive. Used when the counter's script = 'raster'.
- */
-export async function sendRasterReceipt(cfg: PrinterCfg, lines: string[], kickDrawer: boolean): Promise {
- const { renderRasterReceipt } = await import('./raster')
- await sendToPrinter(cfg, renderRasterReceipt(lines, { width: cfg.width, kickDrawer }))
-}
diff --git a/apps/pos/src/raster.ts b/apps/pos/src/raster.ts
deleted file mode 100644
index 926b79d..0000000
--- a/apps/pos/src/raster.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { EscPos, bitonalFromRGBA, rasterToEscPos } from '@sims/printing'
-
-/**
- * Indic raster receipt (09-UX §5.3): ESC/POS text mode can't render Devanagari
- * or Malayalam, so non-Latin receipts are drawn to an offscreen canvas and sent
- * as a GS v 0 bit-image. Canvas is a browser API, so this lives in the app; the
- * thresholding + bit-packing are the pure @sims/printing package (raster.ts).
- *
- * v1 caveat (documented): browsers fall back to the OS fonts for canvas text, so
- * Devanagari/Malayalam coverage depends on the shop PC having a suitable font
- * (Windows ships Nirmala UI). The raster *pipeline* is what S2 delivers; bundled
- * canvas webfonts are a follow-up. Latin/ASCII always renders.
- */
-
-/** Print-head pixel width: 384 dots for 2" (32 col), 576 for 3" (42 col). */
-export function headWidth(cols: 32 | 42): number {
- return cols === 32 ? 384 : 576
-}
-
-export function renderRasterReceipt(lines: string[], opts: { width: 32 | 42; kickDrawer?: boolean }): Uint8Array {
- const W = headWidth(opts.width)
- const fontPx = opts.width === 32 ? 20 : 22
- const lineH = Math.round(fontPx * 1.35)
- const padTop = 8
- const padBottom = 24
- const H = padTop + lines.length * lineH + padBottom
-
- const canvas = document.createElement('canvas')
- canvas.width = W
- canvas.height = H
- const ctx = canvas.getContext('2d')
- if (ctx === null) throw new Error('Canvas 2D unavailable — cannot rasterize receipt')
- ctx.fillStyle = '#fff'
- ctx.fillRect(0, 0, W, H)
- ctx.fillStyle = '#000'
- ctx.textBaseline = 'top'
- // A font stack the browser can fall back to for Indic scripts on the shop PC.
- ctx.font = `${fontPx}px "Noto Sans", "Noto Sans Devanagari", "Nirmala UI", "Segoe UI", monospace`
- lines.forEach((text, i) => {
- const y = padTop + i * lineH
- if (i === 0) {
- ctx.textAlign = 'center'
- ctx.fillText(text, W / 2, y)
- } else {
- ctx.textAlign = 'left'
- ctx.fillText(text, 4, y)
- }
- })
-
- const img = ctx.getImageData(0, 0, W, H)
- const body = rasterToEscPos(bitonalFromRGBA(img.data, W, H))
-
- const head = new EscPos().init()
- if (opts.kickDrawer === true) head.drawerKick()
- const prefix = head.align('left').build()
- const tail = new EscPos().feed(4).cut().build()
-
- const out = new Uint8Array(prefix.length + body.length + tail.length)
- out.set(prefix, 0)
- out.set(body, prefix.length)
- out.set(tail, prefix.length + body.length)
- return out
-}
diff --git a/apps/pos/src/types.d.ts b/apps/pos/src/types.d.ts
deleted file mode 100644
index ec3fe53..0000000
--- a/apps/pos/src/types.d.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/** Bridge exposed by electron/preload.ts; absent when running in a plain browser. */
-interface PosBridge {
- listPrinters(): Promise<{ name: string; displayName: string }[]>
- printRaw9100(host: string, port: number, bytes: Uint8Array): Promise
-}
-
-interface Window {
- pos?: PosBridge
-}
diff --git a/apps/pos/test/offline.test.ts b/apps/pos/test/offline.test.ts
deleted file mode 100644
index a6e8d87..0000000
--- a/apps/pos/test/offline.test.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import type { BillTotals } from '@sims/domain'
-import type { LineInput } from '@sims/billing-engine'
-import { buildQueuedBill, nextProvisionalToken, toOfflineBillBody, type QueuedBill } from '../src/offline'
-
-const TOTALS: BillTotals = {
- grossPaise: 14_500, discountPaise: 0, taxablePaise: 12_288,
- cgstPaise: 1_106, sgstPaise: 1_106, igstPaise: 0, cessPaise: 0,
- roundOffPaise: 0, payablePaise: 14_500, savingsVsMrpPaise: 500,
-}
-const LINES: LineInput[] = [{
- itemId: 'i-103', name: 'Surf Excel 1kg', hsn: '3402', qty: 1,
- unitCode: 'PCS', unitPricePaise: 14_500, priceIncludesTax: true, taxClassCode: 'GST18',
-}]
-
-describe('S4 offline queue — provisional tokens', () => {
- it('formats OFF-n from the sequence', () => {
- expect(nextProvisionalToken(1)).toBe('OFF-1')
- expect(nextProvisionalToken(42)).toBe('OFF-42')
- })
-})
-
-describe('S4 offline queue — buildQueuedBill', () => {
- const base = {
- seq: 2, storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- businessDate: '2026-07-10', lines: LINES,
- payments: [{ mode: 'CASH', amountPaise: 14_500 }],
- totals: TOTALS, now: 1_700_000_000_000,
- }
-
- it('assigns a client uuidv7 id, a matching provisional token, and an ISO createdAt', () => {
- const q = buildQueuedBill(base)
- expect(q.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
- expect(q.provisionalToken).toBe('OFF-2')
- expect(q.seq).toBe(2)
- expect(q.createdAt).toBe(new Date(1_700_000_000_000).toISOString())
- expect(q.totals.payablePaise).toBe(14_500)
- })
-
- it('omits optional customer / override fields when absent', () => {
- const q = buildQueuedBill(base)
- expect('customerId' in q).toBe(false)
- expect('customerName' in q).toBe(false)
- expect('buyerGstin' in q).toBe(false)
- expect('overrides' in q).toBe(false)
- })
-
- it('keeps only the customer id and non-empty overrides — never customer PII (M11)', () => {
- const q = buildQueuedBill({
- ...base,
- customerId: 'p1',
- overrides: [{ itemId: 'i-103', kind: 'price', before: 15_000, after: 14_500, approvalId: 'a1' }],
- })
- expect(q.customerId).toBe('p1')
- // M11: the queued record must NOT carry the buyer's name or GSTIN — the server
- // re-resolves both from customerId at drain, so no PII lands on the shared counter PC.
- expect('customerName' in q).toBe(false)
- expect('buyerGstin' in q).toBe(false)
- expect(q.overrides).toHaveLength(1)
- })
-
- it('drops an empty overrides array rather than persisting it', () => {
- const q = buildQueuedBill({ ...base, overrides: [] })
- expect('overrides' in q).toBe(false)
- })
-})
-
-describe('S4 offline queue — toOfflineBillBody', () => {
- it('maps to the drain payload: keeps the client id, sets clientPayablePaise, drops display-only fields', () => {
- const q: QueuedBill = buildQueuedBill({
- seq: 1, storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- customerId: 'p1',
- businessDate: '2026-07-10', lines: LINES,
- payments: [{ mode: 'CASH', amountPaise: 14_500 }], totals: TOTALS,
- })
- const body = toOfflineBillBody(q)
- expect(body.id).toBe(q.id)
- expect(body.clientPayablePaise).toBe(14_500)
- expect(body.customerId).toBe('p1')
- // SEC fix: cashier/tenant come from the server session, never the body
- expect('cashierId' in body).toBe(false)
- // M11: no customer PII is stored offline, so none can travel in the drain body
- expect('customerName' in body).toBe(false)
- expect('buyerGstin' in body).toBe(false)
- expect('totals' in body).toBe(false)
- expect('provisionalToken' in body).toBe(false)
- })
-})
diff --git a/apps/pos/tsconfig.json b/apps/pos/tsconfig.json
deleted file mode 100644
index 52e1679..0000000
--- a/apps/pos/tsconfig.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "include": ["src/**/*", "electron/**/*"]
-}
diff --git a/apps/pos/vite.config.ts b/apps/pos/vite.config.ts
deleted file mode 100644
index e198c7f..0000000
--- a/apps/pos/vite.config.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-import { fileURLToPath } from 'node:url'
-
-const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url))
-
-export default defineConfig({
- base: './',
- plugins: [react()],
- resolve: {
- alias: {
- '@sims/domain': p('../../packages/domain/src/index.ts'),
- '@sims/billing-engine': p('../../packages/billing-engine/src/index.ts'),
- '@sims/config': p('../../packages/config/src/index.ts'),
- // Browser-safe subpath (pure role→gate matrix) — must precede the barrel alias,
- // whose index re-exports scrypt (node:crypto) and cannot enter the web bundle.
- '@sims/auth/permissions': p('../../packages/auth/src/permissions.ts'),
- '@sims/auth': p('../../packages/auth/src/index.ts'),
- '@sims/scanning': p('../../packages/scanning/src/index.ts'),
- '@sims/search-core': p('../../packages/search-core/src/index.ts'),
- '@sims/printing': p('../../packages/printing/src/index.ts'),
- '@sims/ui': p('../../packages/ui/src/index.ts'),
- },
- },
- build: { outDir: 'dist' },
-})
diff --git a/apps/store-server/.env.example b/apps/store-server/.env.example
deleted file mode 100644
index 11ca796..0000000
--- a/apps/store-server/.env.example
+++ /dev/null
@@ -1,44 +0,0 @@
-# SiMS store-server environment — copy to a real env/secret store for production.
-# None of these are required for a dev run (npm start sets SIMS_SEED_DEMO for you), but
-# production MUST set the security-critical ones marked below.
-
-# H3 — full-DB encryption at rest (REQUIRED in production).
-# When set, the whole SQLite store (sims.db + its WAL/SHM sidecars) is SQLCipher-encrypted on disk,
-# so a copied or snooped shop PC yields no customer PII (phones/GSTINs — DPDP), costs & margins,
-# sales history, party ledgers or PIN hashes. Held ONLY here / in the OS secret store — NEVER in the
-# repo or the DB. On Windows, deliver it via a machine-scoped DPAPI secret (or an OS keychain), not a
-# plaintext file next to the DB. Use a long random value, e.g.:
-# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
-# WARNING: this keys the database. An EXISTING plaintext sims.db must be migrated ONCE offline
-# (sqlcipher_export — see openDb() in src/db.ts) before the first encrypted boot; a fresh/empty DB
-# is created encrypted automatically. Leave unset only in dev (the file is then plaintext).
-SIMS_DB_KEY=
-
-# M3 — server-held PIN/password pepper (REQUIRED in production).
-# HMAC-SHA256'd into every PIN/password before scrypt, so a stolen DB cannot be brute-forced
-# against the tiny 4-digit PIN keyspace. Keep it out of the DB and out of source control; store
-# it in the OS secret store / process env. Use a long random value, e.g.:
-# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
-# WARNING: enabling or changing this invalidates every existing hash — PINs/passwords must be
-# re-enrolled (a dev re-seed handles it automatically). Leave unset only in dev.
-SIMS_PIN_PEPPER=
-
-# M10 — demo seed gate. '1' seeds the MegaMart demo (known-credential owner thomas/9174/sims)
-# into an empty DB. Leave UNSET in production so a real empty DB is never auto-seeded. The dev
-# start script (npm start → dev-start.mjs) sets this to '1' for you.
-SIMS_SEED_DEMO=
-
-# M13 — TLS (production should serve HTTPS). Paths to a cert + key; when both are set the server
-# serves HTTPS, otherwise plain HTTP for dev. Make a throwaway dev cert with:
-# openssl req -x509 -newkey rsa:2048 -nodes -keyout dev-key.pem -out dev-cert.pem -days 365 -subj "/CN=localhost"
-SIMS_TLS_CERT=
-SIMS_TLS_KEY=
-
-# H1 — extra allowlisted LAN printer ports (comma-separated) beyond the defaults {9100,9101,9102}.
-SIMS_PRINTER_PORTS=
-
-# Isolated data dir for a second instance (load tests, throwaway runs). Unset ⇒ ../data.
-SIMS_DATA_DIR=
-
-# HTTP port (defaults to 5181; 8080–8085 are Windows-reserved on shop PCs).
-PORT=
diff --git a/apps/store-server/README.md b/apps/store-server/README.md
deleted file mode 100644
index 0116e0e..0000000
--- a/apps/store-server/README.md
+++ /dev/null
@@ -1,6 +0,0 @@
-# apps/store-server — Store Service (Node)
-
-Runs on the store server beside the existing Oracle 12c (D12): owns Oracle access via
-node-oracledb thin mode behind the repository layer, receives document envelopes from
-counters over LAN, serves the back-office web app locally, and (when the cloud tier
-arrives, D14) hosts the sync agent that drains the outbox upward.
diff --git a/apps/store-server/build-server.mjs b/apps/store-server/build-server.mjs
deleted file mode 100644
index 259ab28..0000000
--- a/apps/store-server/build-server.mjs
+++ /dev/null
@@ -1,14 +0,0 @@
-import { build } from 'esbuild'
-
-await build({
- entryPoints: ['src/server.ts'],
- outfile: 'dist/server.cjs',
- bundle: true,
- platform: 'node',
- format: 'cjs',
- // Native modules can't be bundled. db.ts imports better-sqlite3-multiple-ciphers (the
- // SQLCipher-compatible drop-in — H3 encryption at rest); better-sqlite3 stays externalled
- // too in case any transitive path still references it.
- external: ['better-sqlite3-multiple-ciphers', 'better-sqlite3'],
-})
-console.log('store-server built')
diff --git a/apps/store-server/dev-start.mjs b/apps/store-server/dev-start.mjs
deleted file mode 100644
index fae7485..0000000
--- a/apps/store-server/dev-start.mjs
+++ /dev/null
@@ -1,6 +0,0 @@
-// Dev launcher: enables the demo seed (M10) then runs the built server. Production must NOT use
-// this — it runs `node dist/server.cjs` directly with SIMS_SEED_DEMO unset, so a real empty DB is
-// never auto-seeded with the known-credential demo owner. Pure Node so it works on Windows + POSIX
-// without a cross-env dependency. An explicit SIMS_SEED_DEMO (e.g. '0') is respected.
-process.env.SIMS_SEED_DEMO = process.env.SIMS_SEED_DEMO ?? '1'
-await import('./dist/server.cjs')
diff --git a/apps/store-server/load/loadtest.mjs b/apps/store-server/load/loadtest.mjs
deleted file mode 100644
index b6b380c..0000000
--- a/apps/store-server/load/loadtest.mjs
+++ /dev/null
@@ -1,306 +0,0 @@
-// S7 load verification (docs/17 §S7, R25) — scripted load against ONE store-server.
-//
-// Fires 2,500 bill commits distributed across 10 counters at a concurrency of 10 (one
-// in-flight request per counter, realistic for a single store-server), then a burst
-// phase (100 bills at concurrency 50) to find the knee. Measures wall-clock, throughput,
-// and p50/p95/p99/max latency, then runs six integrity checks directly against the load
-// DB with better-sqlite3 and prints a pass/fail table.
-//
-// Plain node (node 22, global fetch). The only imports are better-sqlite3 and esbuild's
-// absence — no engine bundle is needed because every billable seed item is priced
-// tax-inclusive with no discount, so the payable is exactly roundToRupee(Σ qty·price)
-// (billing-engine compute.ts: priceIncludesTax ⇒ lineTotal = net = round(price·qty);
-// payable = round(Σ lineTotal / 100)·100 — independent of the tax rate). The server
-// re-verifies every bill to the paisa with the real shared engine (R5) and rejects any
-// mismatch, so a wrong client payable would surface immediately as failed commits.
-import Database from 'better-sqlite3'
-import os from 'node:os'
-import path from 'node:path'
-import process from 'node:process'
-
-const BASE = process.env.BASE ?? 'http://localhost:5281'
-const DATA_DIR = process.env.SIMS_DATA_DIR
-if (!DATA_DIR) { console.error('Set SIMS_DATA_DIR to the load target data dir'); process.exit(1) }
-const DB_PATH = path.join(DATA_DIR, 'sims.db')
-
-const TENANT = 't1'
-const STORE = 's1'
-const BUSINESS_DATE = '2026-07-10'
-const N_COUNTERS = 10
-const TOTAL_BILLS = 2500
-const BURST_BILLS = 100
-const BURST_CONC = 50
-// The 4 seeded users (sessions are per-token, so reusing them across counters is fine).
-const USERS = [
- { id: 'u1', pin: '4728' }, { id: 'u2', pin: '8265' },
- { id: 'u3', pin: '9174' }, { id: 'u4', pin: '3591' },
-]
-const COUNTERS = Array.from({ length: N_COUNTERS }, (_, i) => ({
- id: `c${i + 1}`, code: `C${i + 1}`,
-}))
-
-const rng = (n) => Math.floor(Math.random() * n)
-const pct = (arr, p) => {
- if (arr.length === 0) return 0
- const s = [...arr].sort((a, b) => a - b)
- const idx = Math.min(s.length - 1, Math.ceil((p / 100) * s.length) - 1)
- return s[Math.max(0, idx)]
-}
-const round2 = (n) => Math.round(n * 100) / 100
-
-// ---- setup: ensure 10 real counters (seed makes only C1/C2) ----
-function ensureCounters() {
- const db = new Database(DB_PATH)
- db.pragma('busy_timeout = 5000')
- const ins = db.prepare(
- `INSERT OR IGNORE INTO counter (id, tenant_id, store_id, code, name) VALUES (?, ?, ?, ?, ?)`,
- )
- const tx = db.transaction(() => {
- for (const c of COUNTERS) ins.run(c.id, TENANT, STORE, c.code, `Counter ${c.code.slice(1)}`)
- })
- tx()
- const n = db.prepare(`SELECT COUNT(*) n FROM counter WHERE tenant_id=? AND store_id=?`).get(TENANT, STORE).n
- db.close()
- return n
-}
-
-async function login(user) {
- const r = await fetch(`${BASE}/api/auth/pin`, {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ userId: user.id, pin: user.pin }),
- })
- if (!r.ok) throw new Error(`login ${user.id} failed: ${r.status} ${await r.text()}`)
- return (await r.json()).token
-}
-
-async function loadCatalog(token) {
- const r = await fetch(`${BASE}/api/items?all=1`, { headers: { Authorization: `Bearer ${token}` } })
- if (!r.ok) throw new Error(`items failed: ${r.status}`)
- const items = await r.json()
- // Skip batch-tracked items (they need a valid batchId); everything else is
- // tax-inclusive, so the payable is trivially roundToRupee(Σ qty·price).
- return items
- .filter((it) => !it.batchTracked && it.priceIncludesTax)
- .map((it) => ({
- itemId: it.id, name: it.name, hsn: it.hsn, unitCode: it.unit.code,
- unitPricePaise: it.salePricePaise, priceIncludesTax: true, taxClassCode: it.taxClassCode,
- }))
-}
-
-// Build a random 1–5 line bill from distinct catalog items, integer qty 1–5.
-function buildBill(catalog, counterId) {
- const nLines = 1 + rng(5)
- const pool = [...catalog]
- const lines = []
- for (let i = 0; i < nLines && pool.length > 0; i++) {
- const idx = rng(pool.length)
- const it = pool.splice(idx, 1)[0]
- const qty = 1 + rng(5)
- lines.push({ ...it, qty })
- }
- const total = lines.reduce((a, l) => a + l.unitPricePaise * l.qty, 0)
- const payable = Math.round(total / 100) * 100 // roundToRupee (ctx.roundToRupee=true)
- return {
- body: {
- storeId: STORE, counterId, shiftId: 'load-sh1', businessDate: BUSINESS_DATE,
- lines, payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
- },
- lines,
- }
-}
-
-async function postBill(token, bill, tally) {
- const t0 = performance.now()
- let status = 0, errText = ''
- try {
- const r = await fetch(`${BASE}/api/bills`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
- body: JSON.stringify(bill.body),
- })
- status = r.status
- if (!r.ok) errText = await r.text()
- else {
- await r.json()
- for (const l of bill.lines) tally.items[l.itemId] = (tally.items[l.itemId] ?? 0) + l.qty
- tally.lines += bill.lines.length
- }
- } catch (e) {
- errText = String(e)
- }
- const dt = performance.now() - t0
- return { ok: status >= 200 && status < 300, status, errText, dt }
-}
-
-// ---- steady phase: one worker per counter, 250 sequential bills each ----
-async function steadyPhase(tokenByCounter, catalog, tally) {
- const perCounter = Math.floor(TOTAL_BILLS / N_COUNTERS)
- const latencies = []
- let success = 0
- const errors = {}
- const t0 = performance.now()
- await Promise.all(COUNTERS.map(async (counter) => {
- const token = tokenByCounter[counter.id]
- for (let i = 0; i < perCounter; i++) {
- const bill = buildBill(catalog, counter.id)
- const res = await postBill(token, bill, tally)
- latencies.push(res.dt)
- if (res.ok) success++
- else errors[res.status] = (errors[res.status] ?? 0) + 1, (tally.firstErr ??= res.errText)
- }
- }))
- const wallMs = performance.now() - t0
- return { attempted: perCounter * N_COUNTERS, success, errors, latencies, wallMs }
-}
-
-// ---- burst phase: 100 bills, concurrency 50 (find the knee) ----
-async function burstPhase(tokens, catalog, tally) {
- const latencies = []
- let success = 0
- const errors = {}
- let next = 0
- const t0 = performance.now()
- async function worker() {
- while (true) {
- const i = next++
- if (i >= BURST_BILLS) return
- const counter = COUNTERS[i % N_COUNTERS]
- const token = tokens[i % tokens.length]
- const bill = buildBill(catalog, counter.id)
- const res = await postBill(token, bill, tally)
- latencies.push(res.dt)
- if (res.ok) success++
- else errors[res.status] = (errors[res.status] ?? 0) + 1
- }
- }
- await Promise.all(Array.from({ length: BURST_CONC }, worker))
- const wallMs = performance.now() - t0
- return { attempted: BURST_BILLS, success, errors, latencies, wallMs }
-}
-
-// ---- integrity checks against the load DB ----
-function integrity(tally, expectedBills, expectedLines) {
- const db = new Database(DB_PATH, { readonly: true })
- db.pragma('busy_timeout = 5000')
- const checks = []
- const add = (name, pass, detail) => checks.push({ name, pass, detail })
-
- // (a) bill count == successful commits
- const billCount = db.prepare(`SELECT COUNT(*) n FROM bill`).get().n
- add('(a) bill rows == successful commits', billCount === expectedBills,
- `bills=${billCount}, successes=${expectedBills}`)
-
- // (b) every counter's SALE series is gap-free and duplicate-free (1..n)
- const bills = db.prepare(`SELECT counter_id, doc_no FROM bill WHERE doc_type='SALE'`).all()
- const byCounter = {}
- for (const b of bills) {
- const seq = parseInt(b.doc_no.slice(b.doc_no.lastIndexOf('-') + 1), 10)
- ;(byCounter[b.counter_id] ??= []).push(seq)
- }
- let seriesOk = true
- const seriesDetail = []
- for (const [cid, seqs] of Object.entries(byCounter)) {
- seqs.sort((a, b) => a - b)
- const dup = new Set(seqs).size !== seqs.length
- const gap = seqs.some((s, i) => s !== i + 1)
- if (dup || gap) seriesOk = false
- seriesDetail.push(`${cid}:1..${seqs.length}${dup ? ' DUP' : ''}${gap ? ' GAP' : ''}`)
- }
- add('(b) per-counter series gap-free & dup-free', seriesOk, seriesDetail.join(' '))
-
- // (c) SUM(movements per item) == opening − billed qty; and one SALE movement per line
- let stockOk = true
- const stockDetail = []
- for (const [itemId, billedQty] of Object.entries(tally.items)) {
- const opening = db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=? AND reason='OPENING'`).get(itemId).s
- const final = db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=?`).get(itemId).s
- const saleQty = -db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=? AND reason='SALE'`).get(itemId).s
- const ok = final === opening - billedQty && saleQty === billedQty
- if (!ok) { stockOk = false; stockDetail.push(`${itemId} open=${opening} billed=${billedQty} final=${final} saleMv=${saleQty}`) }
- }
- const saleMoves = db.prepare(`SELECT COUNT(*) n FROM stock_movement WHERE reason='SALE'`).get().n
- if (saleMoves !== expectedLines) { stockOk = false; stockDetail.push(`SALE movements ${saleMoves} != lines ${expectedLines}`) }
- add('(c) stock derived == opening − billed (per item)', stockOk,
- stockOk ? `${Object.keys(tally.items).length} items reconciled; ${saleMoves} SALE movements == ${expectedLines} lines` : stockDetail.join(' | '))
-
- // (d) every bill's payments sum == payable
- const payRows = db.prepare(`SELECT payable_paise, payload FROM bill`).all()
- let payMismatch = 0
- for (const row of payRows) {
- const payments = JSON.parse(row.payload).payments ?? []
- const sum = payments.reduce((a, p) => a + p.amountPaise, 0)
- if (sum !== row.payable_paise) payMismatch++
- }
- add('(d) payments sum == payable (every bill)', payMismatch === 0,
- `${payRows.length} bills checked, ${payMismatch} mismatch`)
-
- // (e) audit BILL_COMMIT count == bill count
- const auditCommits = db.prepare(`SELECT COUNT(*) n FROM audit_log WHERE action='BILL_COMMIT'`).get().n
- add('(e) audit BILL_COMMIT == bill count', auditCommits === billCount,
- `BILL_COMMIT=${auditCommits}, bills=${billCount}`)
-
- // (f) outbox rows == bill count
- const outbox = db.prepare(`SELECT COUNT(*) n FROM outbox`).get().n
- add('(f) outbox rows == bill count', outbox === billCount,
- `outbox=${outbox}, bills=${billCount}`)
-
- db.close()
- return checks
-}
-
-async function main() {
- console.log('=== S7 LOAD VERIFICATION ===')
- const cpu = os.cpus()
- console.log(`env: node ${process.version} | ${cpu.length}× ${cpu[0].model.trim()} | ${round2(os.totalmem() / 1024 ** 3)} GB RAM | ${os.platform()} ${os.release()}`)
- console.log(`target: ${BASE} db: ${DB_PATH}`)
-
- const counterCount = ensureCounters()
- console.log(`counters ready: ${counterCount}`)
-
- const tokens = []
- for (const u of USERS) tokens.push(await login(u))
- console.log(`logged in ${tokens.length} users`)
- const tokenByCounter = {}
- COUNTERS.forEach((c, i) => { tokenByCounter[c.id] = tokens[i % tokens.length] })
-
- const catalog = await loadCatalog(tokens[0])
- console.log(`billable catalog: ${catalog.length} items (batch-tracked skipped)`)
-
- const tally = { items: {}, lines: 0, firstErr: undefined }
-
- console.log(`\n--- STEADY: ${TOTAL_BILLS} bills across ${N_COUNTERS} counters, concurrency ${N_COUNTERS} ---`)
- const steady = await steadyPhase(tokenByCounter, catalog, tally)
- const sThr = steady.success / (steady.wallMs / 1000)
- console.log(`attempted=${steady.attempted} success=${steady.success} errors=${JSON.stringify(steady.errors)}`)
- console.log(`wall=${round2(steady.wallMs / 1000)}s throughput=${round2(sThr)} bills/s`)
- console.log(`latency ms: p50=${round2(pct(steady.latencies, 50))} p95=${round2(pct(steady.latencies, 95))} p99=${round2(pct(steady.latencies, 99))} max=${round2(Math.max(...steady.latencies))}`)
- if (steady.firstErr) console.log(`first error: ${steady.firstErr}`)
- if (tally.firstErr) console.log(`sample error body: ${tally.firstErr}`)
-
- console.log(`\n--- BURST: ${BURST_BILLS} bills, concurrency ${BURST_CONC} ---`)
- const burst = await burstPhase(tokens, catalog, tally)
- const bThr = burst.success / (burst.wallMs / 1000)
- console.log(`attempted=${burst.attempted} success=${burst.success} errors=${JSON.stringify(burst.errors)}`)
- console.log(`wall=${round2(burst.wallMs / 1000)}s throughput=${round2(bThr)} bills/s`)
- console.log(`latency ms: p50=${round2(pct(burst.latencies, 50))} p95=${round2(pct(burst.latencies, 95))} p99=${round2(pct(burst.latencies, 99))} max=${round2(Math.max(...burst.latencies))}`)
-
- const totalSuccess = steady.success + burst.success
- console.log(`\n--- INTEGRITY (load DB) ---`)
- const checks = integrity(tally, totalSuccess, tally.lines)
- for (const c of checks) console.log(`${c.pass ? 'PASS' : 'FAIL'} ${c.name} [${c.detail}]`)
- const allPass = checks.every((c) => c.pass)
- console.log(`\nINTEGRITY: ${allPass ? 'ALL PASS' : 'FAILURES PRESENT'}`)
-
- // Machine-readable summary for transcription into STATUS.md.
- const summary = {
- env: { node: process.version, cpus: cpu.length, cpuModel: cpu[0].model.trim(), ramGB: round2(os.totalmem() / 1024 ** 3), platform: `${os.platform()} ${os.release()}` },
- steady: { attempted: steady.attempted, success: steady.success, errors: steady.errors, wallS: round2(steady.wallMs / 1000), throughput: round2(sThr), p50: round2(pct(steady.latencies, 50)), p95: round2(pct(steady.latencies, 95)), p99: round2(pct(steady.latencies, 99)), max: round2(Math.max(...steady.latencies)) },
- burst: { attempted: burst.attempted, success: burst.success, errors: burst.errors, wallS: round2(burst.wallMs / 1000), throughput: round2(bThr), p50: round2(pct(burst.latencies, 50)), p95: round2(pct(burst.latencies, 95)), p99: round2(pct(burst.latencies, 99)), max: round2(Math.max(...burst.latencies)) },
- totalSuccess, totalLines: tally.lines,
- checks, allPass,
- }
- console.log(`\n===SUMMARY_JSON===\n${JSON.stringify(summary)}\n===END_SUMMARY_JSON===`)
- process.exit(allPass && totalSuccess === TOTAL_BILLS + BURST_BILLS ? 0 : 1)
-}
-
-main().catch((e) => { console.error(e); process.exit(2) })
diff --git a/apps/store-server/package.json b/apps/store-server/package.json
deleted file mode 100644
index 66df9fa..0000000
--- a/apps/store-server/package.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "name": "@sims/store-server",
- "version": "0.1.0",
- "private": true,
- "type": "module",
- "scripts": {
- "build": "node build-server.mjs",
- "start": "npm run build && node dev-start.mjs",
- "start:prod": "npm run build && node dist/server.cjs",
- "typecheck": "tsc -p tsconfig.json"
- },
- "dependencies": {
- "@sims/auth": "*",
- "@sims/billing-engine": "*",
- "@sims/domain": "*",
- "better-sqlite3-multiple-ciphers": "^11.10.0",
- "express": "^4.21.0",
- "helmet": "^8.3.0"
- },
- "devDependencies": {
- "@types/better-sqlite3": "^7.6.11",
- "@types/express": "^4.17.21",
- "esbuild": "^0.25.0"
- }
-}
diff --git a/apps/store-server/src/api.ts b/apps/store-server/src/api.ts
deleted file mode 100644
index 515de81..0000000
--- a/apps/store-server/src/api.ts
+++ /dev/null
@@ -1,610 +0,0 @@
-import { Router, type Request, type Response, type NextFunction } from 'express'
-import { randomUUID } from 'node:crypto'
-import net from 'node:net'
-import dns from 'node:dns'
-import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, can, type LockoutState, type ActionCode } from '@sims/auth'
-import type { RoleCode } from '@sims/domain'
-import type { LineInput } from '@sims/billing-engine'
-import type { DB } from './db'
-import {
- batchesForStore, commitBill, createCustomer, createItem, createOverrideApproval, expiryView,
- listAudit, listBills, listItems, listParties, listTaxClasses, resolveDefaultTaxClass,
- resolveDraftMaxPricePaise, stockView, verifyAuditChain, writeAudit,
-} from './repos'
-import { commitPurchase, costHistory, lastCosts, listPurchases, type CommitPurchaseInput } from './repos-purchase'
-import { commitReturn, listReturns, lookupBillByDocNo, lookupBillsByPhone, returnableLines } from './repos-returns'
-import { gstr1, gstr3b, hsnSummary, gstr1PortalJson } from './repos-gst'
-import {
- ValidationError, assertString, optString, assertInt, assertNonNegInt, assertPositiveInt,
- assertPaise, assertQty, assertBusinessDate, assertPeriod, assertArray, clampLimit, clampOffset, PAYMENT_MODES,
-} from './validate'
-import {
- createRateLimiter, authRatePerIpFromEnv, authRateGlobalFromEnv, type RateLimitPolicy,
-} from './rate-limit'
-import { isSessionExpired, sessionTtlFromEnv, type SessionTtl } from './session-policy'
-import { checkPrintEnvelope, checkPrintAddress, parsePrinterPorts } from './print-guard'
-
-interface Session {
- token: string; userId: string; tenantId: string; displayName: string; roles: string[]
- // M4: sessions now have a lifetime — created-at (absolute cap) + last-seen (idle cap).
- createdMs: number; lastSeenMs: number
-}
-
-interface UserRow {
- id: string; tenant_id: string; username: string; display_name: string
- roles: string; active: number
- pin_salt: string | null; pin_hash: string | null
- pw_salt: string | null; pw_hash: string | null
-}
-
-/** Injection points so tests can force tiny TTLs / rate caps / a stub DNS resolver. */
-export interface ApiRouterOpts {
- sessionTtl?: SessionTtl
- authRatePerIp?: RateLimitPolicy
- authRateGlobal?: RateLimitPolicy
- printerPorts?: ReadonlySet
- resolveHost?: (host: string) => Promise
-}
-
-/** The bearer token carried on a request, if any. */
-const bearer = (req: Request): string => (req.headers.authorization ?? '').replace('Bearer ', '')
-
-const sess = (req: Request): Session => (req as Request & { session: Session }).session
-const asUser = (s: Session): { roles: RoleCode[]; active: boolean } => ({ roles: s.roles as RoleCode[], active: true })
-
-/**
- * Per-route server-side authorization (C1/C4), deny-by-default. Each mutating or sensitive
- * route names the ActionCode(s) that grant it; a session whose roles grant NONE is 403'd
- * (E-6105) before the handler runs. The same role→action matrix the React app uses
- * (permissions.ts) is now enforced on the server — the client is no longer trusted to gate.
- */
-const requireAnyPerm = (...actions: ActionCode[]) =>
- (req: Request, res: Response, next: NextFunction): void => {
- const user = asUser(sess(req))
- if (actions.some((a) => can(user, a))) { next(); return }
- res.status(403).json({ error: 'You are not permitted to do that (E-6105)', code: 'E-6105' })
- }
-
-const OVERRIDE_KINDS = new Set(['price', 'qty', 'discount', 'batch'])
-
-/** Validate the shared bill body shape (M7) — deep money truth is re-derived in commitBill. */
-function validateBillBody(raw: unknown): {
- storeId: string; counterId: string; shiftId: string; customerId?: string
- businessDate: string; lines: LineInput[]
- payments: { mode: string; amountPaise: number; reference?: string }[]
- clientPayablePaise: number
- overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string }[]
-} {
- const b = (raw ?? {}) as Record
- const storeId = assertString(b['storeId'], 'storeId')
- const counterId = assertString(b['counterId'], 'counterId')
- const shiftId = assertString(b['shiftId'], 'shiftId')
- const customerId = optString(b['customerId'], 'customerId')
- const businessDate = assertBusinessDate(b['businessDate'])
- const rawLines = assertArray>(b['lines'], 'lines', 500)
- const lines = rawLines.map((l): LineInput => {
- assertString(l['itemId'], 'line itemId')
- assertQty(l['qty'], 'line qty')
- assertPaise(l['unitPricePaise'], 'line price')
- return l as unknown as LineInput
- })
- const rawPayments = assertArray>(b['payments'], 'payments', 50)
- const payments = rawPayments.map((p) => {
- const amountPaise = assertInt(p['amountPaise'], 'payment amount')
- if (amountPaise <= 0) throw new ValidationError('Every payment must be a positive amount', 'E-1107')
- const mode = assertString(p['mode'], 'payment mode')
- if (!PAYMENT_MODES.has(mode)) throw new ValidationError('Unknown payment mode', 'E-1108')
- return { mode, amountPaise, ...(optString(p['reference'], 'reference') !== undefined ? { reference: optString(p['reference'], 'reference')! } : {}) }
- })
- const clientPayablePaise = assertPaise(b['clientPayablePaise'], 'clientPayablePaise')
- const overrides = b['overrides'] === undefined ? undefined
- : (assertArray>(b['overrides'], 'overrides', 500)).map((o) => {
- const kind = assertString(o['kind'], 'override kind')
- if (!OVERRIDE_KINDS.has(kind)) throw new ValidationError('Unknown override kind', 'E-1001')
- return {
- itemId: assertString(o['itemId'], 'override itemId'),
- kind: kind as 'price' | 'qty' | 'discount' | 'batch',
- before: assertInt(o['before'], 'override before'),
- after: assertInt(o['after'], 'override after'),
- ...(optString(o['approvalId'], 'approvalId') !== undefined ? { approvalId: optString(o['approvalId'], 'approvalId')! } : {}),
- }
- })
- return {
- storeId, counterId, shiftId, ...(customerId !== undefined ? { customerId } : {}),
- businessDate, lines, payments, clientPayablePaise, ...(overrides !== undefined ? { overrides } : {}),
- }
-}
-
-/** Validate the return / credit-note body (M7). Deep money truth is re-derived in commitReturn. */
-function validateReturnBody(raw: unknown): {
- storeId: string; counterId: string; shiftId: string; businessDate: string
- originalBillId: string; lines: { lineIndex: number; qty: number }[]
- reason: string; refundMode: string; clientRefundPaise?: number
-} {
- const b = (raw ?? {}) as Record
- const storeId = assertString(b['storeId'], 'storeId')
- const counterId = assertString(b['counterId'], 'counterId')
- const shiftId = assertString(b['shiftId'], 'shiftId')
- const businessDate = assertBusinessDate(b['businessDate'])
- const originalBillId = assertString(b['originalBillId'], 'originalBillId')
- const rawLines = assertArray>(b['lines'], 'lines', 500)
- const lines = rawLines.map((l) => ({
- lineIndex: assertNonNegInt(l['lineIndex'], 'line index'),
- qty: assertQty(l['qty'], 'return qty'),
- }))
- const reason = assertString(b['reason'], 'reason')
- const refundMode = assertString(b['refundMode'], 'refundMode')
- if (!PAYMENT_MODES.has(refundMode)) throw new ValidationError('Unknown refund mode', 'E-1326')
- const clientRefundPaise = b['clientRefundPaise'] === undefined ? undefined : assertPaise(b['clientRefundPaise'], 'clientRefundPaise')
- return {
- storeId, counterId, shiftId, businessDate, originalBillId, lines, reason, refundMode,
- ...(clientRefundPaise !== undefined ? { clientRefundPaise } : {}),
- }
-}
-
-export function apiRouter(db: DB, opts: ApiRouterOpts = {}): Router {
- const r = Router()
-
- // Edge state is per-router-instance (not module-global) so each mounted server — the prod
- // process or a test harness — is fully isolated: its own sessions, lockouts and rate-limit
- // windows, with no cross-instance bleed.
- const sessions = new Map()
- const lockouts = new Map()
- const ttl = opts.sessionTtl ?? sessionTtlFromEnv()
- const authPerIp = createRateLimiter(opts.authRatePerIp ?? authRatePerIpFromEnv())
- const authGlobal = createRateLimiter(opts.authRateGlobal ?? authRateGlobalFromEnv())
- const printerPorts = opts.printerPorts ?? parsePrinterPorts(process.env['SIMS_PRINTER_PORTS'])
- const resolveHost = opts.resolveHost ?? (async (h: string): Promise =>
- (await dns.promises.lookup(h, { all: true })).map((a) => a.address))
-
- const sessionDto = (s: Session): Record =>
- ({ token: s.token, userId: s.userId, tenantId: s.tenantId, displayName: s.displayName, roles: s.roles })
-
- /**
- * Authenticated-session gate with lifetime enforcement (M4): a token must map to a live
- * session inside BOTH its idle and absolute TTL, else it is evicted and the caller gets 401
- * (E-6110) and re-logs in. Each authenticated hit slides the idle window (refreshes last-seen).
- */
- const requireAuth = (req: Request, res: Response, next: NextFunction): void => {
- const token = bearer(req)
- const session = sessions.get(token)
- if (session === undefined) {
- res.status(401).json({ error: 'Not signed in (E-6103)', code: 'E-6103' })
- return
- }
- const now = Date.now()
- if (isSessionExpired(session, now, ttl)) {
- sessions.delete(token)
- res.status(401).json({ error: 'Session expired — sign in again (E-6110)', code: 'E-6110' })
- return
- }
- session.lastSeenMs = now
- ;(req as Request & { session: Session }).session = session
- next()
- }
-
- /**
- * Global + per-IP throttle for /auth/* (M2/M4, rt-F7). Layered ON TOP of the per-user scrypt
- * lockout: the lockout stops repeated failures against ONE user; this blunts a parallel grind
- * across the whole roster from one source, whichever user each attempt names.
- */
- const authRateLimit = (req: Request, res: Response, next: NextFunction): void => {
- const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown'
- const now = Date.now()
- if (!authPerIp.hit(ip, now) || !authGlobal.hit('*', now)) {
- res.status(429).json({ error: 'Too many attempts — try again shortly (E-6109)', code: 'E-6109' })
- return
- }
- next()
- }
-
- const startSession = (u: UserRow): Session => {
- const now = Date.now()
- const s: Session = {
- token: randomUUID(), userId: u.id, tenantId: u.tenant_id,
- displayName: u.display_name, roles: JSON.parse(u.roles) as string[],
- createdMs: now, lastSeenMs: now,
- }
- sessions.set(s.token, s)
- return s
- }
-
- const attempt = (u: UserRow | undefined, secret: string, salt: string | null, hash: string | null): Session | { error: string; status: number } => {
- const key = u?.id ?? 'unknown'
- const now = Date.now()
- const lock = lockouts.get(key) ?? FRESH_LOCKOUT
- if (isLocked(lock, now)) return { error: 'Too many attempts — locked for a minute (E-6104)', status: 429 }
- if (u === undefined || u.active !== 1 || salt === null || hash === null || !verifyPin(secret, { salt, hash })) {
- lockouts.set(key, recordFailure(lock, now))
- if (u !== undefined) writeAudit(db, u.tenant_id, u.id, 'LOGIN_FAILED', 'app_user', u.id)
- return { error: 'Wrong credentials (E-6101)', status: 401 }
- }
- lockouts.set(key, recordSuccess())
- return startSession(u)
- }
-
- // POS: name-tile + PIN
- r.post('/auth/pin', authRateLimit, (req, res) => {
- const { userId, pin } = req.body as { userId?: string; pin?: string }
- const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined
- const out = attempt(u, pin ?? '', u?.pin_salt ?? null, u?.pin_hash ?? null)
- 'token' in out ? res.json(sessionDto(out)) : res.status(out.status).json({ error: out.error })
- })
-
- // Back office: username + password
- r.post('/auth/login', authRateLimit, (req, res) => {
- const { username, password } = req.body as { username?: string; password?: string }
- const u = db.prepare(`SELECT * FROM app_user WHERE username=?`).get((username ?? '').toLowerCase()) as UserRow | undefined
- const out = attempt(u, password ?? '', u?.pw_salt ?? null, u?.pw_hash ?? null)
- 'token' in out ? res.json(sessionDto(out)) : res.status(out.status).json({ error: out.error })
- })
-
- // In-flow supervisor gate (spec P0-2): verify a PIN WITHOUT creating a session —
- // the cashier's login must never be disturbed and `sessions` must not grow. Shares
- // the same lockout + scrypt verification as attempt(); audits PIN_VERIFY on success
- // and a PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor / manager / owner roles
- // may approve an override (09-UX §5.5) — a valid-PIN cashier is rejected loudly.
- //
- // H4 fix: the minted token is BOUND to the exact override being approved
- // ({itemId, kind, before, after}) — the commit re-verifies this against the line's
- // server-computed deviation so an approval can never be re-attached to another line.
- const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
- // M2: verify-pin is only ever called mid-billing by an already-logged-in cashier, so it now
- // REQUIRES a valid caller session (requireAuth) and is rate-limited — closing its use as an
- // UNAUTHENTICATED PIN oracle / token-mint vector. The approver's PIN still rides in the body;
- // the caller session just proves a real counter is asking.
- r.post('/auth/verify-pin', authRateLimit, requireAuth, (req, res) => {
- const body = (req.body ?? {}) as { userId?: string; pin?: string; override?: Record }
- // Bound override context is required — a PIN verification with nothing to authorise is
- // rejected rather than minting an unbound (redirectable) token.
- const ovRaw = body.override
- if (ovRaw === undefined || ovRaw === null || typeof ovRaw !== 'object') {
- res.status(400).json({ error: 'Missing the override this PIN is approving (E-1001)', code: 'E-1001' })
- return
- }
- let ov: { itemId: string; kind: string; before: number; after: number }
- try {
- const kind = assertString(ovRaw['kind'], 'override kind')
- if (!OVERRIDE_KINDS.has(kind)) throw new ValidationError('Unknown override kind', 'E-1001')
- ov = {
- itemId: assertString(ovRaw['itemId'], 'override itemId'),
- kind,
- before: assertInt(ovRaw['before'], 'override before'),
- after: assertInt(ovRaw['after'], 'override after'),
- }
- } catch (err) {
- const code = err instanceof ValidationError ? err.code : 'E-1001'
- res.status(400).json({ error: 'Override context is invalid (E-1001)', code }); return
- }
- const { userId, pin } = body
- const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined
- const key = u?.id ?? 'unknown'
- const now = Date.now()
- const lock = lockouts.get(key) ?? FRESH_LOCKOUT
- if (isLocked(lock, now)) {
- res.status(429).json({ error: 'Too many attempts — locked for a minute (E-6104)' })
- return
- }
- if (u === undefined || u.active !== 1 || u.pin_salt === null || u.pin_hash === null
- || !verifyPin(pin ?? '', { salt: u.pin_salt, hash: u.pin_hash })) {
- lockouts.set(key, recordFailure(lock, now))
- if (u !== undefined) writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY_FAILED', 'app_user', u.id)
- res.status(401).json({ error: 'Wrong PIN (E-6101)' })
- return
- }
- // Identity proven — clear the lockout — but authorisation is a separate gate.
- lockouts.set(key, recordSuccess())
- const roles = JSON.parse(u.roles) as string[]
- if (!roles.some((role) => APPROVER_ROLES.has(role))) {
- res.status(403).json({ error: 'This user cannot approve overrides — pick a supervisor, manager or owner (E-6105)', code: 'E-6105' })
- return
- }
- const approvalId = createOverrideApproval(db, u.tenant_id, u.id, {
- itemId: ov.itemId, kind: ov.kind as 'price' | 'qty' | 'discount' | 'batch',
- beforePaise: ov.before, afterPaise: ov.after,
- })
- writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY', 'app_user', u.id)
- res.json({ ok: true, userId: u.id, displayName: u.display_name, approvalId })
- })
-
- // POS bootstrap: store/counter context, login tiles, tax classes. This is UNAUTHENTICATED so
- // the POS can paint its login screen — which means it must NOT leak targeting data (M1). The
- // pre-auth payload therefore DROPS each user's role and the tenant GSTIN. Both are restored
- // when the SAME endpoint is called WITH a valid session (the POS re-fetches right after login;
- // back office is always authed), so the supervisor gate, the approver list and the receipt's
- // seller GSTIN keep working — only the anonymous, pre-login caller is starved of the roster's
- // roles and the store's GSTIN.
- r.get('/bootstrap', (req, res) => {
- const authed = sessions.has(bearer(req))
- const counterCode = String(req.query['counter'] ?? 'C2')
- const store = db.prepare(`SELECT * FROM store LIMIT 1`).get() as Record
- const counter = db.prepare(`SELECT * FROM counter WHERE store_id=? AND code=?`).get(store['id'], counterCode) as Record | undefined
- const tenant = db.prepare(`SELECT * FROM tenant WHERE id=?`).get(store['tenant_id']) as Record
- const users = db.prepare(`SELECT id, display_name, roles FROM app_user WHERE active=1 AND pin_hash IS NOT NULL`).all() as Record[]
- res.json({
- store: {
- id: store['id'], name: store['name'], storeCode: store['code'],
- stateCode: store['state_code'], counterId: counter?.['id'] ?? 'c2', counterCode,
- ...(authed ? { gstin: tenant['gstin'] } : {}),
- },
- users: users.map((u) => ({
- id: u['id'], name: u['display_name'],
- ...(authed ? { role: (JSON.parse(u['roles'] as string) as string[])[0] } : {}),
- })),
- taxClasses: listTaxClasses(db),
- })
- })
-
- r.use(requireAuth)
-
- // M4: end a session on demand — POS "Lock" and back-office "Logout" call this so the token
- // stops working immediately (a leaked/rotated token is cut off without waiting for a restart).
- r.post('/auth/logout', (req, res) => {
- sessions.delete(bearer(req))
- res.json({ ok: true })
- })
-
- // H1: ESC/POS print relay, now constrained to a LAN printer on an allowlisted port with a
- // capped payload (print-guard.ts). Authenticated (it sits below requireAuth). The host is
- // resolved and EVERY resolved IP must be private/LAN; we then connect to the resolved IP —
- // not the original name — so a hostname can't DNS-rebind between the check and the connect.
- r.post('/print', async (req, res, next) => {
- try {
- const { host, port, bytes } = req.body as { host?: unknown; port?: unknown; bytes?: unknown }
- if (typeof host !== 'string' || host === '' || typeof port !== 'number' || !Array.isArray(bytes)) {
- res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }', code: 'E-2200' })
- return
- }
- const envelope = checkPrintEnvelope(bytes.length, port, printerPorts)
- if (!envelope.ok) { res.status(envelope.status).json({ ok: false, error: envelope.error, code: envelope.code }); return }
- let addrs: string[]
- try { addrs = await resolveHost(host) } catch { addrs = [] }
- if (addrs.length === 0) {
- res.status(400).json({ ok: false, error: 'Printer host is not a private/LAN address', code: 'E-2203' })
- return
- }
- for (const ip of addrs) {
- const chk = checkPrintAddress(ip)
- if (!chk.ok) { res.status(chk.status).json({ ok: false, error: chk.error, code: chk.code }); return }
- }
- const target = addrs[0]!
- // 'close' always follows 'error'/'timeout' on net sockets — respond exactly once.
- let done = false
- const finish = (status: number, body: object): void => { if (done) return; done = true; res.status(status).json(body) }
- const socket = net.createConnection({ host: target, port, timeout: 3000 })
- socket.on('connect', () => socket.end(Buffer.from(bytes as number[])))
- socket.on('close', () => finish(200, { ok: true }))
- socket.on('timeout', () => { socket.destroy(); finish(504, { ok: false, error: `Printer ${host}:${port} timed out` }) })
- socket.on('error', (e: Error) => finish(502, { ok: false, error: e.message }))
- } catch (e) { next(e) }
- })
-
- // Catalog reads are needed to bill, so they stay open to any authenticated session —
- // but pagination is clamped so a hostile limit can never dump the whole table (M6).
- r.get('/items', (req, res) => {
- res.json(listItems(db, sess(req).tenantId, {
- ...(req.query['q'] !== undefined ? { query: String(req.query['q']) } : {}),
- all: req.query['all'] === '1',
- limit: clampLimit(req.query['limit'], 5000, 5000),
- offset: clampOffset(req.query['offset']),
- }))
- })
- // A cashier (BILL_CREATE) may quick-add a DRAFT item mid-bill; any non-draft item — or a
- // caller without BILL_CREATE — requires MASTER_EDIT (C1: no editing the master as cashier).
- r.post('/items', (req, res) => {
- const s = sess(req)
- const b = (req.body ?? {}) as Record
- const status = optString(b['status'], 'status') ?? 'active'
- const user = asUser(s)
- const isMasterEditor = can(user, 'MASTER_EDIT')
- if (!(isMasterEditor || (status === 'draft' && can(user, 'BILL_CREATE')))) {
- res.status(403).json({ error: 'You are not permitted to add or edit items (E-6105)', code: 'E-6105' })
- return
- }
- const salePricePaise = assertPositiveInt(b['salePricePaise'], 'salePricePaise')
- // C3 fix: a cashier-created draft's tax class is FORCED to the store default — the
- // client's taxClassCode is ignored so a taxable good can't be self-added as ZERO to
- // evade GST. A MASTER_EDIT role (manager/owner) still sets the class explicitly.
- const taxClassCode = isMasterEditor
- ? assertString(b['taxClassCode'], 'taxClassCode')
- : resolveDefaultTaxClass(db, s.tenantId)
- // C2 bound: a cashier-created draft's price IS its sell price, so cap it — an
- // expensive unknown must go through a supervisor / back office, not be self-priced
- // at the counter. (E-1108 here is the draft-cap denial, distinct endpoint from the
- // bill-path payment-mode use of the same code.)
- if (!isMasterEditor && salePricePaise > resolveDraftMaxPricePaise(db, s.tenantId)) {
- res.status(400).json({
- error: 'This price is too high to quick-add at the counter — call a supervisor to add it in back office (E-1108)',
- code: 'E-1108',
- })
- return
- }
- const input = {
- code: assertString(b['code'], 'code'),
- name: assertString(b['name'], 'name'),
- hsn: optString(b['hsn'], 'hsn') ?? '',
- taxClassCode,
- unitCode: optString(b['unitCode'], 'unitCode') ?? 'PCS',
- unitDecimals: assertNonNegInt(b['unitDecimals'] ?? 0, 'unitDecimals'),
- salePricePaise,
- ...(b['mrpPaise'] !== undefined ? { mrpPaise: assertNonNegInt(b['mrpPaise'], 'mrpPaise') } : {}),
- ...(optString(b['barcode'], 'barcode') !== undefined ? { barcode: optString(b['barcode'], 'barcode')! } : {}),
- status,
- }
- const item = createItem(db, s.tenantId, input)
- writeAudit(db, s.tenantId, s.userId, 'ITEM_CREATE', 'item', item.id, undefined, item)
- res.json(item)
- })
-
- // Party lookup/create is part of billing (attach a customer at the counter) — open to any
- // authenticated session; the GSTIN is validated server-side in createCustomer.
- r.get('/parties', (req, res) => {
- res.json(listParties(db, sess(req).tenantId, req.query['kind'] as string | undefined, req.query['phone'] as string | undefined))
- })
- r.post('/parties', (req, res) => {
- const b = (req.body ?? {}) as Record
- const name = assertString(b['name'], 'name')
- const phone = assertString(b['phone'], 'phone')
- const gstin = optString(b['gstin'], 'gstin')
- const stateCode = optString(b['stateCode'], 'stateCode')
- const p = createCustomer(db, sess(req).tenantId, name, phone, {
- ...(gstin !== undefined ? { gstin } : {}),
- ...(stateCode !== undefined ? { stateCode } : {}),
- })
- writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p)
- res.json(p)
- })
-
- r.post('/bills', requireAnyPerm('BILL_CREATE'), (req, res) => {
- const body = validateBillBody(req.body)
- res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }))
- })
-
- // S4 reconnect drain (authenticated): bills a browser POS queued while the store-server
- // was unreachable are drained here under the cashier's live session — tenant and cashier
- // come from the session, NEVER the body. The client bill id is the idempotency key.
- r.post('/bills/offline', requireAnyPerm('BILL_CREATE'), (req, res) => {
- const b = (req.body ?? {}) as Record
- const clientId = assertString(b['id'], 'id')
- const body = validateBillBody(req.body)
- res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId, clientId }))
- })
-
- // A cashier may read bills, but only their OWN — a caller without REPORT_VIEW is pinned to
- // the session user id server-side, so a BILL_CREATE-only cashier can never enumerate other
- // counters'/cashiers' bills (residual cross-counter read fix). A REPORT_VIEW/manager role
- // keeps the full store-wide view. Pagination is clamped (M6).
- r.get('/bills', requireAnyPerm('BILL_CREATE', 'REPORT_VIEW'), (req, res) => {
- const s = sess(req)
- const ownBillsOnly = !can(asUser(s), 'REPORT_VIEW')
- res.json(listBills(
- db, s.tenantId,
- req.query['date'] as string | undefined, req.query['counterId'] as string | undefined,
- clampLimit(req.query['limit'], 100, 1000),
- clampOffset(req.query['offset']),
- ownBillsOnly ? s.userId : undefined,
- ))
- })
-
- // Returns / credit notes. A cashier has RETURN_CREATE (permissions.ts), so returns ride the
- // same money/commit path the red-team hardened: commitReturn anchors everything to OUR stored
- // original bill (never the client), blocks over-return, computes the refund server-side, and
- // writes an immutable SALE_RETURN doc + credit-note series + stock-back movements + refund leg
- // + hash-chained audit row in ONE transaction.
- r.post('/returns', requireAnyPerm('RETURN_CREATE'), (req, res) => {
- const body = validateReturnBody(req.body)
- res.json(commitReturn(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }))
- })
- // Credit-note list — tenant-scoped, paginated (M6). A caller without REPORT_VIEW is pinned to
- // their own credit notes, the same scoping the sales list uses.
- r.get('/returns', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
- const s = sess(req)
- const ownOnly = !can(asUser(s), 'REPORT_VIEW')
- res.json(listReturns(
- db, s.tenantId, clampLimit(req.query['limit'], 100, 1000), clampOffset(req.query['offset']),
- ownOnly ? s.userId : undefined,
- ))
- })
- // The return lookup: find the original SALE by exact doc no, or recent sales for a phone. A
- // targeted, tenant-scoped lookup for issuing a return (not the bills-list enumeration) so a
- // cashier can return a bill a colleague rang up.
- r.get('/bills/lookup', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
- const s = sess(req)
- const docNo = optString(req.query['docNo'], 'docNo')
- const phone = optString(req.query['phone'], 'phone')
- if (docNo !== undefined) {
- const bill = lookupBillByDocNo(db, s.tenantId, docNo)
- res.json(bill === undefined ? [] : [bill])
- return
- }
- if (phone !== undefined) { res.json(lookupBillsByPhone(db, s.tenantId, phone)); return }
- res.status(400).json({ error: 'Provide a docNo or phone to look up (E-1327)', code: 'E-1327' })
- })
- // The original bill's lines with sold / already-returned / returnable quantities + the raw
- // lines for the POS refund preview.
- r.get('/bills/:id/returnable', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
- res.json(returnableLines(db, sess(req).tenantId, assertString(req.params['id'], 'id')))
- })
-
- // GST returns — READ-ONLY compliance reporting, gated REPORT_VIEW, tenant-scoped. Each folds the
- // period's immutable SALE + SALE_RETURN documents through the shared engine (never recomputes tax)
- // and returns the sections + a reconciliation proof. The .json route is a pragmatic GSTN-shaped
- // export for a CA / GSP — documented in the payload as NOT a certified filing.
- r.get('/gst/gstr1', requireAnyPerm('REPORT_VIEW'), (req, res) => {
- res.json(gstr1(db, sess(req).tenantId, assertPeriod(req.query['period'])))
- })
- r.get('/gst/gstr3b', requireAnyPerm('REPORT_VIEW'), (req, res) => {
- res.json(gstr3b(db, sess(req).tenantId, assertPeriod(req.query['period'])))
- })
- r.get('/gst/hsn', requireAnyPerm('REPORT_VIEW'), (req, res) => {
- res.json(hsnSummary(db, sess(req).tenantId, assertPeriod(req.query['period'])))
- })
- r.get('/gst/gstr1.json', requireAnyPerm('REPORT_VIEW'), (req, res) => {
- res.json(gstr1PortalJson(db, sess(req).tenantId, assertPeriod(req.query['period'])))
- })
-
- r.get('/stock', (req, res) => {
- const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
- res.json(stockView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
- })
-
- // S5: batches with derived on-hand > 0 (POS FEFO picker cache, keyed by item).
- r.get('/batches', (req, res) => {
- const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
- res.json(batchesForStore(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
- })
- // S5 expiry dashboard feed — a reporting view (stock value/expiry), not a counter path.
- r.get('/expiry', requireAnyPerm('REPORT_VIEW'), (req, res) => {
- const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
- res.json(expiryView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
- })
-
- r.get('/audit', requireAnyPerm('AUDIT_VIEW'), (req, res) => {
- res.json(listAudit(
- db, sess(req).tenantId,
- clampLimit(req.query['limit'], 100, 1000),
- clampOffset(req.query['offset']),
- ))
- })
- // H2 — recompute the tenant's tamper-evident audit hash chain. {ok:true} means untampered;
- // {ok:false, brokenAtId} names the first row whose content or prev-link no longer verifies.
- r.get('/audit/verify', requireAnyPerm('AUDIT_VIEW'), (req, res) => {
- res.json(verifyAuditChain(db, sess(req).tenantId))
- })
-
- r.get('/purchases', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
- res.json(listPurchases(db, sess(req).tenantId))
- })
- r.get('/purchases/last-costs', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
- res.json(lastCosts(db, sess(req).tenantId, req.query['supplierId'] as string | undefined))
- })
- r.get('/purchases/cost-history', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
- res.json(costHistory(db, sess(req).tenantId, String(req.query['itemId'] ?? ''), req.query['supplierId'] as string | undefined))
- })
- r.post('/purchases', requireAnyPerm('PURCHASE_ENTRY'), (req, res) => {
- const b = (req.body ?? {}) as Record
- const supplierId = assertString(b['supplierId'], 'supplierId')
- const invoiceNo = assertString(b['invoiceNo'], 'invoiceNo')
- const invoiceDate = assertBusinessDate(b['invoiceDate'], 'invoiceDate')
- const businessDate = assertBusinessDate(b['businessDate'])
- const rawLines = assertArray>(b['lines'], 'lines', 500)
- const lines = rawLines.map((l) => {
- assertString(l['itemId'], 'line itemId')
- assertString(l['name'], 'line name')
- assertQty(l['qty'], 'line qty')
- assertNonNegInt(l['unitCostPaise'], 'line cost')
- assertNonNegInt(l['taxRateBp'], 'line tax rate')
- if (l['newSalePricePaise'] !== undefined) assertPositiveInt(l['newSalePricePaise'], 'new sale price')
- if (l['newMrpPaise'] !== undefined) assertPositiveInt(l['newMrpPaise'], 'new MRP')
- return l as unknown as CommitPurchaseInput['lines'][number]
- })
- const clientTotalPaise = assertPaise(b['clientTotalPaise'], 'clientTotalPaise')
- res.json(commitPurchase(db, {
- tenantId: sess(req).tenantId, userId: sess(req).userId,
- storeId: assertString(b['storeId'], 'storeId'),
- supplierId, invoiceNo, invoiceDate, businessDate, lines, clientTotalPaise,
- }))
- })
-
- return r
-}
diff --git a/apps/store-server/src/db.ts b/apps/store-server/src/db.ts
deleted file mode 100644
index 11c55c3..0000000
--- a/apps/store-server/src/db.ts
+++ /dev/null
@@ -1,285 +0,0 @@
-// H3 (red-team): better-sqlite3-multiple-ciphers is an API-compatible drop-in for
-// better-sqlite3 that adds SQLCipher, so the whole DB file can be encrypted at rest with a
-// single `pragma('key=...')`. It ships its own DefinitelyTyped-derived declarations (identical
-// shape to @types/better-sqlite3), so `Database.Database` and `db.pragma()` type exactly as
-// before — no shim needed. See openDb() for the keying / dev-fallback logic.
-import Database from 'better-sqlite3-multiple-ciphers'
-import fs from 'node:fs'
-import path from 'node:path'
-
-/**
- * Store DB — SQLite for the slice, behind portable repositories (repos.ts).
- * The Oracle 12c adapter (D12) implements the same functions when the Classic
- * DDL arrives; SQL here stays standard where possible, quirks are commented.
- */
-export type DB = Database.Database
-
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS tenant (
- id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL,
- gstin TEXT, gst_scheme TEXT NOT NULL DEFAULT 'regular',
- fy_start_month INTEGER NOT NULL DEFAULT 4, created_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS store (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
- name TEXT NOT NULL, state_code TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1
-);
-CREATE TABLE IF NOT EXISTS counter (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
- code TEXT NOT NULL, name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1
-);
-CREATE TABLE IF NOT EXISTS app_user (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, username TEXT NOT NULL UNIQUE,
- display_name TEXT NOT NULL, roles TEXT NOT NULL, store_ids TEXT NOT NULL,
- language TEXT NOT NULL DEFAULT 'en', active INTEGER NOT NULL DEFAULT 1,
- pin_salt TEXT, pin_hash TEXT, pw_salt TEXT, pw_hash TEXT
-);
-CREATE TABLE IF NOT EXISTS item (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
- name TEXT NOT NULL, name_secondary TEXT, hsn TEXT NOT NULL DEFAULT '',
- tax_class_code TEXT NOT NULL, unit_code TEXT NOT NULL DEFAULT 'PCS',
- unit_decimals INTEGER NOT NULL DEFAULT 0, mrp_paise INTEGER,
- sale_price_paise INTEGER NOT NULL, price_includes_tax INTEGER NOT NULL DEFAULT 1,
- barcodes TEXT NOT NULL DEFAULT '[]', batch_tracked INTEGER NOT NULL DEFAULT 0,
- status TEXT NOT NULL DEFAULT 'active',
- UNIQUE (tenant_id, code)
-);
-CREATE TABLE IF NOT EXISTS party (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
- name TEXT NOT NULL, kind TEXT NOT NULL, phone TEXT, gstin TEXT,
- state_code TEXT, credit_limit_paise INTEGER
-);
-CREATE TABLE IF NOT EXISTS tax_class (
- class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL,
- cess_pct_bp INTEGER NOT NULL DEFAULT 0,
- effective_from TEXT NOT NULL, effective_to TEXT
-);
-CREATE TABLE IF NOT EXISTS doc_series (
- tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, counter_id TEXT NOT NULL,
- doc_type TEXT NOT NULL, fy TEXT NOT NULL, prefix TEXT NOT NULL,
- next_seq INTEGER NOT NULL,
- PRIMARY KEY (tenant_id, store_id, counter_id, doc_type, fy)
-);
-CREATE TABLE IF NOT EXISTS bill (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
- counter_id TEXT NOT NULL, doc_type TEXT NOT NULL, doc_no TEXT NOT NULL,
- business_date TEXT NOT NULL, cashier_id TEXT NOT NULL, shift_id TEXT NOT NULL,
- customer_id TEXT, ref_doc_id TEXT,
- gross_paise INTEGER NOT NULL, discount_paise INTEGER NOT NULL,
- taxable_paise INTEGER NOT NULL, cgst_paise INTEGER NOT NULL,
- sgst_paise INTEGER NOT NULL, igst_paise INTEGER NOT NULL,
- cess_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL,
- payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL,
- buyer_gstin TEXT, place_of_supply TEXT,
- client_id TEXT,
- payload TEXT NOT NULL, created_at_wall TEXT NOT NULL,
- lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '',
- device_seq INTEGER NOT NULL DEFAULT 0,
- UNIQUE (tenant_id, doc_no)
-);
-CREATE TABLE IF NOT EXISTS stock_movement (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
- item_id TEXT NOT NULL, qty_delta REAL NOT NULL, reason TEXT NOT NULL,
- ref_doc_id TEXT, batch_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL
-);
--- S5 batch/expiry: a batch is an item's lot with an optional expiry (nullable —
--- non-perishables are batch-tracked without a date). Stock stays derived (R7):
--- a batch's on-hand = SUM(stock_movement.qty_delta WHERE batch_id = the batch).
-CREATE TABLE IF NOT EXISTS batch (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
- item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT,
- created_at TEXT NOT NULL,
- UNIQUE (tenant_id, store_id, item_id, batch_no)
-);
-CREATE TABLE IF NOT EXISTS setting (
- scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, key TEXT NOT NULL,
- value TEXT NOT NULL, effective_from TEXT
-);
-CREATE TABLE IF NOT EXISTS message_catalog (
- code TEXT NOT NULL, channel TEXT NOT NULL, lang TEXT NOT NULL, template TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS audit_log (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, at_wall TEXT NOT NULL,
- user_id TEXT NOT NULL, action TEXT NOT NULL, entity TEXT NOT NULL,
- entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT,
- -- H2 (red-team): per-tenant tamper-evident hash chain. entry_hash =
- -- sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity,
- -- entity_id, before_json, after_json)); prev_hash = the previous entry's
- -- entry_hash for the tenant (genesis = ''). Any out-of-band UPDATE/DELETE/INSERT
- -- to this table breaks the chain, which GET /api/audit/verify recomputes. This is
- -- tamper-EVIDENCE, not prevention — the file is still writable (that is H3's job).
- prev_hash TEXT, entry_hash TEXT
-);
-CREATE TABLE IF NOT EXISTS outbox (
- op_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, device_id TEXT NOT NULL,
- device_seq INTEGER NOT NULL, doc_type TEXT NOT NULL, doc_id TEXT NOT NULL,
- envelope TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL
-);
--- Single-use supervisor-approval tokens (SEC fix): /auth/verify-pin mints one on a
--- valid supervisor PIN; the bill commit redeems it (used=0 -> 1, age < 5 min, approver
--- role re-checked) so the client can never forge who approved an override.
--- H4 fix: the token is BOUND at mint to the exact override it authorises —
--- item_id, kind, before_paise (server-computed master value) and after_paise. The
--- commit re-verifies these against the specific line's server-computed deviation, so
--- a "Milk ₹50→45" approval can never be re-attached to "Whiskey ₹2000→200".
-CREATE TABLE IF NOT EXISTS override_approval (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
- approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL,
- used INTEGER NOT NULL DEFAULT 0,
- item_id TEXT, kind TEXT, before_paise INTEGER, after_paise INTEGER,
- cashier_user_id TEXT
-);
-CREATE TABLE IF NOT EXISTS purchase (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
- supplier_id TEXT NOT NULL, invoice_no TEXT NOT NULL, invoice_date TEXT NOT NULL,
- business_date TEXT NOT NULL, taxable_paise INTEGER NOT NULL, tax_paise INTEGER NOT NULL,
- total_paise INTEGER NOT NULL, created_by TEXT NOT NULL, created_at_wall TEXT NOT NULL,
- payload TEXT NOT NULL,
- UNIQUE (tenant_id, supplier_id, invoice_no)
-);
-CREATE TABLE IF NOT EXISTS purchase_line (
- purchase_id TEXT NOT NULL, line_no INTEGER NOT NULL, item_id TEXT NOT NULL,
- name TEXT NOT NULL, qty REAL NOT NULL, unit_cost_paise INTEGER NOT NULL,
- tax_rate_bp INTEGER NOT NULL, tax_paise INTEGER NOT NULL, line_total_paise INTEGER NOT NULL,
- new_sale_price_paise INTEGER, new_mrp_paise INTEGER, batch_id TEXT,
- PRIMARY KEY (purchase_id, line_no)
-);
-CREATE INDEX IF NOT EXISTS idx_pline_item ON purchase_line (item_id);
-CREATE INDEX IF NOT EXISTS idx_item_name ON item (tenant_id, name);
-CREATE INDEX IF NOT EXISTS idx_bill_date ON bill (tenant_id, business_date);
-CREATE INDEX IF NOT EXISTS idx_move_item ON stock_movement (tenant_id, store_id, item_id);
--- idx_move_batch lives in migrate() only: on an upgraded dev DB the batch_id column is
--- added by ALTER there, so indexing it in SCHEMA (which runs first) would fail. The
--- batch table is fully defined above, so its index is safe to keep here.
-CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id);
-CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (tenant_id, at_wall);
-`
-
-/**
- * Additive migrations for dev DBs seeded before a column existed. Fresh DBs get
- * the columns from CREATE TABLE above; this only fills the gap for the running
- * slice. Each step is guarded by a PRAGMA table_info check so it is idempotent.
- * The Oracle adapter (D12) manages its own DDL versioning.
- */
-function migrate(db: DB): void {
- const cols = db.prepare(`PRAGMA table_info(bill)`).all() as { name: string }[]
- const has = (c: string): boolean => cols.some((x) => x.name === c)
- // S3 B2B: buyer GSTIN + place-of-supply snapshot on the immutable bill.
- if (!has('buyer_gstin')) db.exec(`ALTER TABLE bill ADD COLUMN buyer_gstin TEXT`)
- if (!has('place_of_supply')) db.exec(`ALTER TABLE bill ADD COLUMN place_of_supply TEXT`)
- // S4 offline queue: the client bill id is the idempotency key for the reconnect
- // drain. It is NULL for normal (online) bills and non-null only for a bill that
- // was queued offline; a partial unique index enforces one committed bill per
- // (tenant, client id) so a retried drain can never duplicate a bill or its stock.
- // (A partial index, not a table UNIQUE, because SQLite ALTER TABLE cannot add a
- // constraint and NULLs must stay non-conflicting for the online path.)
- if (!has('client_id')) db.exec(`ALTER TABLE bill ADD COLUMN client_id TEXT`)
- db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bill_client ON bill (tenant_id, client_id) WHERE client_id IS NOT NULL`)
- // SEC fix — single-use supervisor-approval tokens. A whole new table, so the
- // idempotent guard is the table itself (CREATE TABLE IF NOT EXISTS, which SCHEMA
- // also runs on every open); re-stated here so the migration is explicit for dev
- // DBs seeded before the table existed.
- db.exec(`CREATE TABLE IF NOT EXISTS override_approval (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
- approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL,
- used INTEGER NOT NULL DEFAULT 0
- )`)
- // H4 fix — bind the approval token to the override it authorises. Additive columns
- // for dev DBs seeded before the binding existed; fresh DBs get them from SCHEMA above.
- const oaCols = db.prepare(`PRAGMA table_info(override_approval)`).all() as { name: string }[]
- const oaHas = (c: string): boolean => oaCols.some((x) => x.name === c)
- if (!oaHas('item_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN item_id TEXT`)
- if (!oaHas('kind')) db.exec(`ALTER TABLE override_approval ADD COLUMN kind TEXT`)
- if (!oaHas('before_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN before_paise INTEGER`)
- if (!oaHas('after_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN after_paise INTEGER`)
- if (!oaHas('cashier_user_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN cashier_user_id TEXT`)
- // S5 batch/expiry — the batch table (SCHEMA also creates it; re-stated so the
- // migration is explicit for dev DBs seeded before it existed), plus the nullable
- // batch_id columns on stock_movement and purchase_line. Per-batch on-hand stays
- // derived from movements (R7); batch_id is NULL for un-batched (non-tracked) stock.
- db.exec(`CREATE TABLE IF NOT EXISTS batch (
- id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
- item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT,
- created_at TEXT NOT NULL,
- UNIQUE (tenant_id, store_id, item_id, batch_no)
- )`)
- const moveCols = db.prepare(`PRAGMA table_info(stock_movement)`).all() as { name: string }[]
- if (!moveCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE stock_movement ADD COLUMN batch_id TEXT`)
- const plineCols = db.prepare(`PRAGMA table_info(purchase_line)`).all() as { name: string }[]
- if (!plineCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE purchase_line ADD COLUMN batch_id TEXT`)
- db.exec(`CREATE INDEX IF NOT EXISTS idx_move_batch ON stock_movement (batch_id)`)
- db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id)`)
- // H2 — tamper-evident audit hash chain. Additive columns for dev DBs seeded before the
- // chain existed; fresh DBs get them from SCHEMA above. Pre-existing rows stay NULL (they are
- // not retro-hashed) — on a real upgrade the chain is consistent from the first row written
- // after this migration; the dev DB is reset for this slice so every row is chained from genesis.
- const auditCols = db.prepare(`PRAGMA table_info(audit_log)`).all() as { name: string }[]
- const auditHas = (c: string): boolean => auditCols.some((x) => x.name === c)
- if (!auditHas('prev_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT`)
- if (!auditHas('entry_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN entry_hash TEXT`)
-}
-
-/**
- * H3 (red-team): full-DB encryption at rest via SQLCipher. When SIMS_DB_KEY is set we key the
- * database BEFORE any read, so the file on disk — main db AND its WAL/SHM sidecars — is ciphertext.
- * A copied or snooped sims.db then yields no customer phone numbers/GSTINs (DPDP), no item/purchase
- * costs & margins, no sales history, no party ledgers and no PIN salts/hashes: every page is
- * encrypted under a key held ONLY in the server's environment — a machine-scoped secret (Windows
- * DPAPI / OS keychain), NEVER in the repo or the DB itself.
- *
- * When SIMS_DB_KEY is UNSET we open unencrypted exactly as before (dev fallback, so :5181 keeps
- * working) and warn ONCE that production MUST set it. Mirrors the SIMS_PIN_PEPPER pattern.
- *
- * The key must be applied immediately after opening, before journal_mode / schema touch the file
- * (SQLCipher derives the page key from it on first access). Single quotes in the secret are escaped
- * by doubling so an arbitrary passphrase is a safe SQL string literal.
- *
- * One-time migration of an EXISTING plaintext sims.db to encrypted (not needed for the empty dev DB,
- * which is simply recreated by deleting data/): open the plaintext file, then
- * ATTACH DATABASE 'sims-enc.db' AS enc KEY '';
- * SELECT sqlcipher_export('enc'); DETACH DATABASE enc;
- * and swap sims-enc.db in. Do this offline, once, before first encrypted boot.
- */
-let dbKeyWarned = false
-function applyKeyIfSet(db: DB): void {
- const key = process.env['SIMS_DB_KEY']
- if (key === undefined || key === '') {
- if (!dbKeyWarned) {
- dbKeyWarned = true
- // eslint-disable-next-line no-console
- console.warn(
- '[store-server] SIMS_DB_KEY is not set — the store DB is written UNENCRYPTED on disk. A copied '
- + 'sims.db then yields customer PII (phones/GSTINs), costs & margins, the full sales history, party '
- + 'ledgers and PIN hashes. Production MUST set a machine-scoped SIMS_DB_KEY (SQLCipher at rest).',
- )
- }
- return
- }
- db.pragma(`cipher='sqlcipher'`)
- db.pragma(`key='${key.replace(/'/g, "''")}'`)
-}
-
-/**
- * Open the store DB. Defaults to the on-disk WAL file; pass an explicit path
- * (e.g. `:memory:`) for hermetic tests. Same schema + migrations either way.
- */
-export function openDb(dbPath?: string): DB {
- let file: string
- if (dbPath !== undefined) {
- file = dbPath
- } else {
- // SIMS_DATA_DIR lets a second instance run against an isolated data dir (e.g. the
- // S7 load target) without touching the dev DB; unset ⇒ the standard ../data path.
- const envDir = process.env['SIMS_DATA_DIR']
- const dir = envDir !== undefined && envDir !== '' ? path.resolve(envDir) : path.resolve(__dirname, '../data')
- fs.mkdirSync(dir, { recursive: true })
- file = path.join(dir, 'sims.db')
- }
- const db = new Database(file)
- applyKeyIfSet(db) // H3 — key BEFORE any read (journal_mode/schema touch the file)
- db.pragma('journal_mode = WAL')
- db.pragma('foreign_keys = ON')
- db.exec(SCHEMA)
- migrate(db)
- return db
-}
diff --git a/apps/store-server/src/print-guard.ts b/apps/store-server/src/print-guard.ts
deleted file mode 100644
index 8b20bbc..0000000
--- a/apps/store-server/src/print-guard.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * /api/print SSRF hardening (red-team H1). The print relay opens a raw TCP socket to a
- * caller-named host:port and writes caller-supplied bytes — an authenticated cashier could
- * turn it into an internal port scanner or an egress channel to any public host. These pure
- * guards constrain the destination to a LAN printer:
- * (a) the target IP must be private/loopback (127/8, 10/8, 172.16/12, 192.168/16) —
- * public IPs and link-local (169.254/16) are rejected; a hostname is resolved by the
- * caller (server.ts) and every resolved address is run through `isLanIPv4` here;
- * (b) the port must be on a small allowlist (ESC/POS raw ports, env-overridable);
- * (c) the payload is capped so the socket can't be used to stream unbounded data.
- * No new deps — all of this is a few string/number checks.
- */
-
-/** Max ESC/POS payload accepted by /api/print (64 KiB — a receipt is a few KB). */
-export const MAX_PRINT_BYTES = 64 * 1024
-
-/** ESC/POS raw-printing ports allowed by default (JetDirect 9100 and its two siblings). */
-export const DEFAULT_PRINTER_PORTS: readonly number[] = [9100, 9101, 9102]
-
-export interface PrintGuardOk { ok: true }
-export interface PrintGuardErr { ok: false; status: number; code: string; error: string }
-export type PrintGuardResult = PrintGuardOk | PrintGuardErr
-
-const ok: PrintGuardOk = { ok: true }
-const err = (code: string, error: string, status = 400): PrintGuardErr => ({ ok: false, status, code, error })
-
-/**
- * Parse SIMS_PRINTER_PORTS ("9100,9101,9200") into an allowlist, falling back to the
- * default set when unset/empty/garbage. Only 1..65535 integers survive.
- */
-export function parsePrinterPorts(raw: string | undefined): Set {
- if (raw === undefined || raw.trim() === '') return new Set(DEFAULT_PRINTER_PORTS)
- const ports = raw.split(',')
- .map((s) => Number(s.trim()))
- .filter((n) => Number.isInteger(n) && n >= 1 && n <= 65535)
- return ports.length > 0 ? new Set(ports) : new Set(DEFAULT_PRINTER_PORTS)
-}
-
-/** True only for a dotted-quad IPv4 literal (no hostnames, no IPv6). */
-function parseIPv4(ip: string): [number, number, number, number] | undefined {
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
- if (m === null) return undefined
- const parts = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])] as [number, number, number, number]
- return parts.every((n) => n >= 0 && n <= 255) ? parts : undefined
-}
-
-/**
- * True when `ip` is a private/loopback IPv4 address a store printer can legitimately have:
- * 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. Everything else — public
- * addresses, link-local 169.254/16, 0.0.0.0, broadcast — is rejected. IPv6 loopback ::1
- * is treated as LAN by the caller; every other non-IPv4 string is off-LAN here.
- */
-export function isLanIPv4(ip: string): boolean {
- const p = parseIPv4(ip)
- if (p === undefined) return false
- const [a, b] = p
- if (a === 127) return true // loopback
- if (a === 10) return true // private class A
- if (a === 172 && b >= 16 && b <= 31) return true // private class B
- if (a === 192 && b === 168) return true // private class C
- return false // public / link-local / everything else
-}
-
-/** ::1 is loopback; a bracketless IPv6 loopback string also passes. Nothing else IPv6 is LAN. */
-export function isLanAddress(ip: string): boolean {
- if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true
- return isLanIPv4(ip)
-}
-
-/**
- * Validate the parts of a print request that need no DNS: payload size and destination port.
- * Host reachability is checked separately (an IP literal via `isLanAddress`, a hostname via
- * DNS resolution in the route). Returns the first violation as a safe { status, code, error }.
- */
-export function checkPrintEnvelope(bytesLen: number, port: number, allowedPorts: ReadonlySet): PrintGuardResult {
- if (!Number.isInteger(bytesLen) || bytesLen < 0) return err('E-2201', 'Bad print payload')
- if (bytesLen > MAX_PRINT_BYTES) return err('E-2201', `Print payload too large (max ${MAX_PRINT_BYTES} bytes)`)
- if (!Number.isInteger(port) || !allowedPorts.has(port)) return err('E-2202', 'Printer port not allowed')
- return ok
-}
-
-/** Validate a resolved IP as a permitted LAN printer address. */
-export function checkPrintAddress(ip: string): PrintGuardResult {
- return isLanAddress(ip) ? ok : err('E-2203', 'Printer host is not a private/LAN address')
-}
diff --git a/apps/store-server/src/rate-limit.ts b/apps/store-server/src/rate-limit.ts
deleted file mode 100644
index b873bc5..0000000
--- a/apps/store-server/src/rate-limit.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * A tiny in-memory sliding-window rate limiter (red-team M2/M4 rt-F7). It layers on top of
- * the existing per-user scrypt lockout to blunt *parallel* credential grinding across the
- * roster from one source: the lockout stops N failures against ONE user, this stops a flood
- * of attempts from one IP (or globally) regardless of which user each one targets. Pure and
- * clock-injectable so it unit-tests without timers; no new deps.
- */
-export interface RateLimitPolicy {
- windowMs: number
- max: number
-}
-
-export interface RateLimiter {
- /** Record an attempt for `key`; returns true if it is within the limit, false if it exceeds it. */
- hit(key: string, now?: number): boolean
- /** Test/inspection helper: attempts remaining in the current window for `key`. */
- remaining(key: string, now?: number): number
- reset(): void
-}
-
-export function createRateLimiter(policy: RateLimitPolicy): RateLimiter {
- const hits = new Map()
- const prune = (arr: number[], now: number): number[] => arr.filter((t) => now - t < policy.windowMs)
- return {
- hit(key, now = Date.now()) {
- const arr = prune(hits.get(key) ?? [], now)
- if (arr.length >= policy.max) {
- hits.set(key, arr) // keep the pruned window; do NOT count the rejected attempt
- return false
- }
- arr.push(now)
- hits.set(key, arr)
- return true
- },
- remaining(key, now = Date.now()) {
- const arr = prune(hits.get(key) ?? [], now)
- return Math.max(0, policy.max - arr.length)
- },
- reset() { hits.clear() },
- }
-}
-
-/**
- * Default auth throttle: 20 attempts/min/IP with a 200/min global ceiling. Comfortably above
- * an honest counter's login + in-flow supervisor-PIN rate, far below what a scripted roster
- * grind needs. Env-overridable (SIMS_AUTH_RATE_PER_MIN / SIMS_AUTH_RATE_GLOBAL_PER_MIN).
- */
-export const DEFAULT_AUTH_RATE_PER_IP: RateLimitPolicy = { windowMs: 60_000, max: 20 }
-export const DEFAULT_AUTH_RATE_GLOBAL: RateLimitPolicy = { windowMs: 60_000, max: 200 }
-
-export function authRatePerIpFromEnv(env: NodeJS.ProcessEnv = process.env): RateLimitPolicy {
- const max = Number(env['SIMS_AUTH_RATE_PER_MIN'])
- return { windowMs: 60_000, max: Number.isFinite(max) && max > 0 ? Math.floor(max) : DEFAULT_AUTH_RATE_PER_IP.max }
-}
-
-export function authRateGlobalFromEnv(env: NodeJS.ProcessEnv = process.env): RateLimitPolicy {
- const max = Number(env['SIMS_AUTH_RATE_GLOBAL_PER_MIN'])
- return { windowMs: 60_000, max: Number.isFinite(max) && max > 0 ? Math.floor(max) : DEFAULT_AUTH_RATE_GLOBAL.max }
-}
diff --git a/apps/store-server/src/repos-gst.ts b/apps/store-server/src/repos-gst.ts
deleted file mode 100644
index 268f76b..0000000
--- a/apps/store-server/src/repos-gst.ts
+++ /dev/null
@@ -1,263 +0,0 @@
-import {
- B2CL_THRESHOLD_PAISE, computeGstr1, computeGstr3b, reconcile, taxOf,
- type GstDocLine, type GstDocument, type Gstr1, type Gstr3b, type Reconciliation,
-} from '@sims/billing-engine'
-import type { DB } from './db'
-
-/**
- * GST returns — the READ-ONLY reporting repo (COMPLIANCE-CRITICAL). It reads the period's
- * IMMUTABLE outward documents (SALE + SALE_RETURN) straight from the `bill` table and folds them
- * through the shared pure engine (@sims/billing-engine/gst-returns). It writes nothing and
- * recomputes no tax — every amount is the one captured on the document (R5/R6, GST-2/GST-3).
- *
- * Not certified filing: the JSON export is a pragmatic approximation of the GSTN GSTR-1 schema for
- * a CA / GSP to consume, NOT a signed return. Real e-filing goes via a GSP (doc 17 §S2), deferred.
- */
-
-interface GstBillRow {
- doc_type: string
- doc_no: string
- business_date: string
- buyer_gstin: string | null
- place_of_supply: string | null
- payable_paise: number
- store_state: string
- customer_name: string | null
- original_doc_no: string | null
- payload: string
-}
-
-interface StoredLine {
- hsn?: string; qty?: number; unitCode?: string
- taxablePaise?: number; cgstPaise?: number; sgstPaise?: number
- igstPaise?: number; cessPaise?: number; taxRateBp?: number
-}
-
-/** [start, end) ISO-date bounds for a 'YYYY-MM' period (returns are filed by calendar month). */
-export function periodBounds(period: string): { start: string; end: string } {
- const y = Number(period.slice(0, 4))
- const m = Number(period.slice(5, 7))
- const nextY = m === 12 ? y + 1 : y
- const nextM = m === 12 ? 1 : m + 1
- return {
- start: `${period}-01`,
- end: `${String(nextY).padStart(4, '0')}-${String(nextM).padStart(2, '0')}-01`,
- }
-}
-
-/** The B2CL threshold as a dated tenant setting (R9/R10); default ₹2,50,000 if no row. */
-export function resolveB2clThresholdPaise(db: DB, tenantId: string): number {
- const row = db.prepare(
- `SELECT value FROM setting WHERE key='gst.b2clThresholdPaise'
- AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
- ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
- ).get(tenantId) as { value: string } | undefined
- const n = row !== undefined ? Number(row.value) : NaN
- return Number.isFinite(n) && n > 0 ? n : B2CL_THRESHOLD_PAISE
-}
-
-/**
- * Load the period's outward documents as engine `GstDocument[]`. Each bill carries its own store
- * state (so intra/inter is decided per document) and its snapshotted buyer GSTIN + place of supply.
- */
-export function loadGstDocuments(
- db: DB, tenantId: string, period: string,
-): { docs: GstDocument[]; storeStateCode: string } {
- const { start, end } = periodBounds(period)
- const rows = db.prepare(
- `SELECT b.doc_type, b.doc_no, b.business_date, b.buyer_gstin, b.place_of_supply,
- b.payable_paise, s.state_code AS store_state, p.name AS customer_name,
- o.doc_no AS original_doc_no, b.payload
- FROM bill b
- JOIN store s ON s.id = b.store_id
- LEFT JOIN party p ON p.id = b.customer_id
- LEFT JOIN bill o ON o.id = b.ref_doc_id
- WHERE b.tenant_id = ? AND b.doc_type IN ('SALE','SALE_RETURN')
- AND b.business_date >= ? AND b.business_date < ?
- ORDER BY b.business_date, b.doc_no`,
- ).all(tenantId, start, end) as GstBillRow[]
-
- const storeStateRow = db.prepare(
- `SELECT state_code FROM store WHERE tenant_id=? ORDER BY code LIMIT 1`,
- ).get(tenantId) as { state_code: string } | undefined
- const storeStateCode = storeStateRow?.state_code ?? '00'
-
- const docs: GstDocument[] = rows.map((r) => {
- let lines: GstDocLine[] = []
- try {
- const payload = JSON.parse(r.payload) as { lines?: StoredLine[] }
- lines = (payload.lines ?? []).map((l) => ({
- hsn: typeof l.hsn === 'string' && l.hsn !== '' ? l.hsn : 'UNCLASSIFIED',
- qty: Number(l.qty ?? 0),
- unitCode: typeof l.unitCode === 'string' && l.unitCode !== '' ? l.unitCode : 'NA',
- taxablePaise: Number(l.taxablePaise ?? 0),
- cgstPaise: Number(l.cgstPaise ?? 0),
- sgstPaise: Number(l.sgstPaise ?? 0),
- igstPaise: Number(l.igstPaise ?? 0),
- cessPaise: Number(l.cessPaise ?? 0),
- taxRateBp: Number(l.taxRateBp ?? 0),
- }))
- } catch { lines = [] }
- return {
- docType: r.doc_type === 'SALE_RETURN' ? 'SALE_RETURN' : 'SALE',
- docNo: r.doc_no,
- businessDate: r.business_date,
- ...(r.buyer_gstin !== null && r.buyer_gstin !== '' ? { buyerGstin: r.buyer_gstin } : {}),
- placeOfSupply: r.place_of_supply !== null && r.place_of_supply !== '' ? r.place_of_supply : r.store_state,
- supplyStateCode: r.store_state,
- ...(r.customer_name !== null ? { customerName: r.customer_name } : {}),
- invoiceValuePaise: Number(r.payable_paise),
- ...(r.original_doc_no !== null ? { originalDocNo: r.original_doc_no } : {}),
- lines,
- }
- })
- return { docs, storeStateCode }
-}
-
-export interface Gstr1Report extends Gstr1 {
- period: string
- storeStateCode: string
- reconciliation: Reconciliation
-}
-
-/** GSTR-1 for a period: all sections + the reconciliation proof. */
-export function gstr1(db: DB, tenantId: string, period: string): Gstr1Report {
- const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
- const opts = { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) }
- const g1 = computeGstr1(docs, opts)
- const g3 = computeGstr3b(docs)
- return { period, storeStateCode, ...g1, reconciliation: reconcile(docs, g1, g3) }
-}
-
-export interface Gstr3bReport extends Gstr3b {
- period: string
- storeStateCode: string
- reconciliation: Reconciliation
-}
-
-/** GSTR-3B 3.1(a) for a period + the same reconciliation proof. */
-export function gstr3b(db: DB, tenantId: string, period: string): Gstr3bReport {
- const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
- const opts = { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) }
- const g1 = computeGstr1(docs, opts)
- const g3 = computeGstr3b(docs)
- return { period, storeStateCode, ...g3, reconciliation: reconcile(docs, g1, g3) }
-}
-
-/** HSN summary projection (GSTR-1 Table 12) for the dedicated /gst/hsn page. */
-export function hsnSummary(db: DB, tenantId: string, period: string): {
- period: string; storeStateCode: string; rows: Gstr1['hsn']; totals: Gstr1['totals']['hsn']; reconciliation: Reconciliation
-} {
- const rep = gstr1(db, tenantId, period)
- return { period, storeStateCode: rep.storeStateCode, rows: rep.hsn, totals: rep.totals.hsn, reconciliation: rep.reconciliation }
-}
-
-// ---------- GST-portal-shaped JSON (pragmatic approximation, NOT a certified filing) ----------
-
-interface JsonItm { num: number; itm_det: { rt: number; txval: number; iamt: number; camt: number; samt: number; csamt: number } }
-interface RateAcc { txval: number; iamt: number; camt: number; samt: number; csamt: number }
-
-/**
- * A best-effort GSTN GSTR-1 JSON. Amounts are in RUPEES with 2 decimals (the portal convention),
- * derived once from the integer-paise ledger at the edge — the internal math stays in paise (R4).
- * Schema keys mirror the offline-tool format (b2b/b2cl/b2cs/cdnr/cdnur/hsn/doc_issue) but this is
- * an APPROXIMATION for a CA / GSP to import and validate, not a signed return.
- */
-export function gstr1PortalJson(db: DB, tenantId: string, period: string): Record {
- const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
- const g1 = computeGstr1(docs, { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) })
- const tenant = db.prepare(`SELECT gstin FROM tenant WHERE id=?`).get(tenantId) as { gstin: string | null } | undefined
-
- const rs = (paise: number): number => Number((paise / 100).toFixed(2))
- const rt = (bp: number): number => bp / 100
- const idt = (iso: string): string => `${iso.slice(8, 10)}-${iso.slice(5, 7)}-${iso.slice(0, 4)}`
- const fp = `${period.slice(5, 7)}${period.slice(0, 4)}`
-
- const ratesToItms = (m: Map): JsonItm[] =>
- [...m.entries()].sort((a, b) => a[0] - b[0]).map(([bp, a], i) => ({
- num: i + 1,
- itm_det: { rt: rt(bp), txval: rs(a.txval), iamt: rs(a.iamt), camt: rs(a.camt), samt: rs(a.samt), csamt: rs(a.csamt) },
- }))
- const accLines = (d: GstDocument): Map => {
- const m = new Map()
- for (const l of d.lines) {
- let a = m.get(l.taxRateBp)
- if (a === undefined) { a = { txval: 0, iamt: 0, camt: 0, samt: 0, csamt: 0 }; m.set(l.taxRateBp, a) }
- a.txval += l.taxablePaise; a.iamt += l.igstPaise; a.camt += l.cgstPaise; a.samt += l.sgstPaise; a.csamt += l.cessPaise
- }
- return m
- }
-
- // b2b: per counterparty GSTIN → per invoice → rate-wise items (built from raw docs).
- const b2bByCtin = new Map[]>()
- for (const d of docs) {
- if (d.docType !== 'SALE' || d.buyerGstin === undefined) continue
- const inv = { inum: d.docNo, idt: idt(d.businessDate), val: rs(d.invoiceValuePaise), pos: d.placeOfSupply, rchrg: 'N', inv_typ: 'R', itms: ratesToItms(accLines(d)) }
- const arr = b2bByCtin.get(d.buyerGstin) ?? []
- arr.push(inv)
- b2bByCtin.set(d.buyerGstin, arr)
- }
- const b2b = [...b2bByCtin.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([ctin, inv]) => ({ ctin, inv }))
-
- // b2cl: inter-state B2C invoices above threshold, grouped by place of supply.
- const b2clByPos = new Map[]>()
- for (const d of docs) {
- if (d.docType !== 'SALE' || d.buyerGstin !== undefined) continue
- if (!g1.b2cl.some((i) => i.docNo === d.docNo)) continue
- const inv = { inum: d.docNo, idt: idt(d.businessDate), val: rs(d.invoiceValuePaise), itms: ratesToItms(accLines(d)) }
- const arr = b2clByPos.get(d.placeOfSupply) ?? []
- arr.push(inv)
- b2clByPos.set(d.placeOfSupply, arr)
- }
- const b2cl = [...b2clByPos.entries()].map(([pos, inv]) => ({ pos, inv }))
-
- // b2cs: aggregated by supply type + place of supply + rate.
- const b2cs = g1.b2cs.map((b) => ({
- sply_ty: b.supplyType, pos: b.pos, typ: 'OE', rt: rt(b.rateBp),
- txval: rs(b.taxablePaise), iamt: rs(b.igstPaise), camt: rs(b.cgstPaise), samt: rs(b.sgstPaise), csamt: rs(b.cessPaise),
- }))
-
- // cdnr: registered credit notes, grouped by counterparty GSTIN.
- const cdnrByCtin = new Map[]>()
- for (const n of g1.cdnr) {
- const rateMap = new Map()
- for (const r of n.rates) rateMap.set(r.rateBp, { txval: r.taxablePaise, iamt: r.igstPaise, camt: r.cgstPaise, samt: r.sgstPaise, csamt: r.cessPaise })
- const nt = { ntty: 'C', nt_num: n.noteDocNo, nt_dt: idt(n.noteDate), val: rs(n.noteValuePaise), itms: ratesToItms(rateMap) }
- const ctin = n.ctin ?? ''
- const arr = cdnrByCtin.get(ctin) ?? []
- arr.push(nt)
- cdnrByCtin.set(ctin, arr)
- }
- const cdnr = [...cdnrByCtin.entries()].map(([ctin, nt]) => ({ ctin, nt }))
-
- // cdnur: unregistered inter-state large credit notes.
- const cdnur = g1.cdnur.map((n) => {
- const rateMap = new Map()
- for (const r of n.rates) rateMap.set(r.rateBp, { txval: r.taxablePaise, iamt: r.igstPaise, camt: r.cgstPaise, samt: r.sgstPaise, csamt: r.cessPaise })
- return { typ: 'B2CL', ntty: 'C', nt_num: n.noteDocNo, nt_dt: idt(n.noteDate), pos: n.pos, val: rs(n.noteValuePaise), itms: ratesToItms(rateMap) }
- })
-
- // hsn: Table 12 data rows.
- const hsn = {
- data: g1.hsn.map((h, i) => ({
- num: i + 1, hsn_sc: h.hsn, uqc: h.uqc, qty: Number(h.qty.toFixed(3)), rt: rt(h.rateBp),
- txval: rs(h.taxablePaise), iamt: rs(h.igstPaise), camt: rs(h.cgstPaise), samt: rs(h.sgstPaise), csamt: rs(h.cessPaise),
- })),
- }
-
- // doc_issue: Table 13 document ranges.
- const doc_issue = {
- doc_det: g1.docs.map((d, i) => ({
- doc_num: i + 1,
- docs: [{ num: 1, from: d.fromDocNo, to: d.toDocNo, totnum: d.totalNumber, cancel: d.cancelled, net_issue: d.net }],
- })),
- }
-
- return {
- _note: 'Pragmatic approximation of the GSTN GSTR-1 offline-tool JSON — for CA / GSP import and review, NOT a certified/signed filing. Amounts in rupees. Real e-filing rides a GSP (doc 17 §S2).',
- gstin: tenant?.gstin ?? '',
- fp,
- gt: rs(taxOf(g1.totals.net) + g1.totals.net.taxablePaise),
- b2b, b2cl, b2cs, cdnr, cdnur, hsn, doc_issue,
- }
-}
diff --git a/apps/store-server/src/repos-purchase.ts b/apps/store-server/src/repos-purchase.ts
deleted file mode 100644
index 96f9236..0000000
--- a/apps/store-server/src/repos-purchase.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { uuidv7, type RoleCode } from '@sims/domain'
-import { can } from '@sims/auth'
-import type { DB } from './db'
-import { upsertBatch, writeAudit } from './repos'
-
-/**
- * Purchases — stock IN, cost memory, and price updates in one transaction.
- * Same portable-repository contract as repos.ts (D12).
- */
-
-export interface PurchaseLineInput {
- itemId: string
- name: string
- qty: number
- unitCostPaise: number
- taxRateBp: number
- /** Optional price revisions decided at entry time (cost-changed prompt). */
- newSalePricePaise?: number
- newMrpPaise?: number
- /** S5: batch/expiry captured for a batch-tracked item. batchNo upserts the batch
- * (found by unique key or created); expiryDate is optional (undated lots allowed). */
- batchNo?: string
- expiryDate?: string
-}
-
-export interface CommitPurchaseInput {
- tenantId: string
- storeId: string
- userId: string
- supplierId: string
- invoiceNo: string
- invoiceDate: string
- businessDate: string
- lines: PurchaseLineInput[]
- clientTotalPaise: number
-}
-
-function lineMath(l: PurchaseLineInput): { taxable: number; tax: number; total: number } {
- const taxable = Math.round(l.qty * l.unitCostPaise)
- const tax = Math.round((taxable * l.taxRateBp) / 10_000)
- return { taxable, tax, total: taxable + tax }
-}
-
-export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string; totalPaise: number } {
- if (input.lines.length === 0) throw Object.assign(new Error('Purchase has no lines'), { status: 400 })
- if (input.invoiceNo.trim() === '') throw Object.assign(new Error('Supplier invoice number is required'), { status: 400 })
- for (const l of input.lines) {
- if (!(l.qty > 0)) throw Object.assign(new Error(`Quantity must be positive for "${l.name}"`), { status: 400 })
- if (!Number.isInteger(l.unitCostPaise) || l.unitCostPaise < 0) {
- throw Object.assign(new Error(`Cost must be non-negative for "${l.name}"`), { status: 400 })
- }
- }
-
- const math = input.lines.map(lineMath)
- const taxable = math.reduce((a, m) => a + m.taxable, 0)
- const tax = math.reduce((a, m) => a + m.tax, 0)
- const total = taxable + tax
- // Same-math-everywhere idiom: the entry screen and the server must agree.
- if (total !== input.clientTotalPaise) {
- throw Object.assign(new Error('Totals mismatch between entry screen and server'), { status: 409 })
- }
-
- const dup = db.prepare(
- `SELECT id FROM purchase WHERE tenant_id=? AND supplier_id=? AND invoice_no=?`,
- ).get(input.tenantId, input.supplierId, input.invoiceNo)
- if (dup !== undefined) {
- throw Object.assign(new Error(`Invoice ${input.invoiceNo} from this supplier is already entered (duplicate)`), { status: 409 })
- }
-
- // C4 fix: receiving stock (PURCHASE_ENTRY, gated at the route) is separate from moving
- // the item master. Only a MASTER_EDIT holder may push newSalePricePaise / newMrpPaise;
- // for anyone else the stock still comes in but the price revision is ignored and noted.
- const buyer = db.prepare(`SELECT roles FROM app_user WHERE id=? AND tenant_id=?`).get(input.userId, input.tenantId) as { roles: string } | undefined
- const canEditMaster = buyer !== undefined && can({ roles: JSON.parse(buyer.roles) as RoleCode[], active: true }, 'MASTER_EDIT')
- let priceUpdatesSkipped = 0
-
- const id = uuidv7()
- const now = new Date().toISOString()
-
- db.transaction(() => {
- db.prepare(
- `INSERT INTO purchase (id, tenant_id, store_id, supplier_id, invoice_no, invoice_date, business_date,
- taxable_paise, tax_paise, total_paise, created_by, created_at_wall, payload)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
- id, input.tenantId, input.storeId, input.supplierId, input.invoiceNo, input.invoiceDate,
- input.businessDate, taxable, tax, total, input.userId, now, JSON.stringify(input.lines),
- )
- const insLine = db.prepare(
- `INSERT INTO purchase_line (purchase_id, line_no, item_id, name, qty, unit_cost_paise, tax_rate_bp, tax_paise, line_total_paise, new_sale_price_paise, new_mrp_paise, batch_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- )
- const move = db.prepare(
- `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall)
- VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?, ?)`,
- )
- input.lines.forEach((l, i) => {
- const m = math[i]!
- // S5: a batch-tracked line names its batch — upsert it (unique key or new) and
- // carry the batch_id onto both the purchase line and the stock-IN movement so
- // per-batch on-hand derives from movements (R7). Un-batched lines pass NULL.
- const batchId = l.batchNo !== undefined && l.batchNo.trim() !== ''
- ? upsertBatch(db, input.tenantId, input.storeId, l.itemId, l.batchNo.trim(), l.expiryDate)
- : null
- const requestedPriceChange = l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined
- const applyPriceChange = requestedPriceChange && canEditMaster
- insLine.run(
- id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total,
- applyPriceChange ? (l.newSalePricePaise ?? null) : null,
- applyPriceChange ? (l.newMrpPaise ?? null) : null,
- batchId,
- )
- move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, batchId, input.businessDate, now)
- if (applyPriceChange) {
- const before = db.prepare(`SELECT sale_price_paise, mrp_paise FROM item WHERE id=?`).get(l.itemId)
- db.prepare(
- `UPDATE item SET sale_price_paise = COALESCE(?, sale_price_paise), mrp_paise = COALESCE(?, mrp_paise) WHERE id=? AND tenant_id=?`,
- ).run(l.newSalePricePaise ?? null, l.newMrpPaise ?? null, l.itemId, input.tenantId)
- writeAudit(db, input.tenantId, input.userId, 'ITEM_PRICE_UPDATE', 'item', l.itemId, before, {
- salePricePaise: l.newSalePricePaise, mrpPaise: l.newMrpPaise, source: `purchase ${input.invoiceNo}`,
- })
- } else if (requestedPriceChange) {
- priceUpdatesSkipped++
- }
- })
- writeAudit(db, input.tenantId, input.userId, 'PURCHASE_COMMIT', 'purchase', id, undefined, {
- invoiceNo: input.invoiceNo, supplier: input.supplierId, total,
- ...(priceUpdatesSkipped > 0 ? { priceUpdatesSkipped, reason: 'caller lacks MASTER_EDIT' } : {}),
- })
- })()
-
- return { id, totalPaise: total }
-}
-
-/** Last known unit cost per item — overall and for one supplier (prefill + change detection). */
-export function lastCosts(db: DB, tenantId: string, supplierId?: string): Record {
- const out: Record = {}
- const rows = db.prepare(
- `SELECT pl.item_id, pl.unit_cost_paise, p.supplier_id
- FROM purchase_line pl JOIN purchase p ON p.id = pl.purchase_id
- WHERE p.tenant_id=? ORDER BY p.created_at_wall ASC`,
- ).all(tenantId) as { item_id: string; unit_cost_paise: number; supplier_id: string }[]
- for (const r of rows) {
- const e = (out[r.item_id] ??= {})
- e.last = r.unit_cost_paise
- if (supplierId !== undefined && r.supplier_id === supplierId) e.supplierLast = r.unit_cost_paise
- }
- return out
-}
-
-/** Last 3 costs for one item (right-rail glanceability, spec P1-1). */
-export function costHistory(db: DB, tenantId: string, itemId: string, supplierId?: string): Record[] {
- const args: unknown[] = [tenantId, itemId]
- let sql = `SELECT pl.unit_cost_paise AS cost, p.invoice_date AS date, pa.name AS supplier
- FROM purchase_line pl JOIN purchase p ON p.id = pl.purchase_id
- LEFT JOIN party pa ON pa.id = p.supplier_id
- WHERE p.tenant_id=? AND pl.item_id=?`
- if (supplierId !== undefined) { sql += ` AND p.supplier_id=?`; args.push(supplierId) }
- sql += ` ORDER BY p.created_at_wall DESC LIMIT 3`
- return db.prepare(sql).all(...args) as Record[]
-}
-
-export function listPurchases(db: DB, tenantId: string): Record[] {
- return db.prepare(
- `SELECT pu.*, pa.name AS supplier_name,
- (SELECT COUNT(*) FROM purchase_line pl WHERE pl.purchase_id = pu.id) AS line_count
- FROM purchase pu LEFT JOIN party pa ON pa.id = pu.supplier_id
- WHERE pu.tenant_id=? ORDER BY pu.created_at_wall DESC LIMIT 300`,
- ).all(tenantId) as Record[]
-}
diff --git a/apps/store-server/src/repos-returns.ts b/apps/store-server/src/repos-returns.ts
deleted file mode 100644
index d33c06b..0000000
--- a/apps/store-server/src/repos-returns.ts
+++ /dev/null
@@ -1,307 +0,0 @@
-import {
- CREDIT_NOTE_SEQ_WIDTH, creditNotePrefix, formatDocNo, fyOf, uuidv7, type BillLine, type BillTotals,
-} from '@sims/domain'
-import { computeReturn, type ReturnLineRequest } from '@sims/billing-engine'
-import type { DB } from './db'
-import { PAYMENT_MODES, assertBusinessDate } from './validate'
-import { writeAudit } from './repos'
-
-/**
- * Returns / credit notes — the money/commit half of the SALE_RETURN document. This rides the
- * same trust boundary the red-team hardened (docs/18): it anchors EVERYTHING to OUR stored
- * original bill, never the client. Prices, tax rates and the CGST/SGST/IGST split come from the
- * original bill's payload, so a return reverses tax at the rate on the bill (R5/R10). The refund
- * is computed server-side and re-verified against any client-declared total (409 on mismatch,
- * mirroring commitBill). Over-return is blocked. Everything lands in ONE transaction (R17): the
- * immutable SALE_RETURN doc, a per-counter credit-note series number, stock ADDED BACK, a refund
- * leg, a dormant outbox row, and a hash-chained audit row.
- */
-
-/** Tag an error with an HTTP status + a safe client code (the error handler never leaks a stack). */
-const httpErr = (status: number, message: string, code: string): Error =>
- Object.assign(new Error(message), { status, code })
-
-interface BillRow {
- id: string; tenant_id: string; store_id: string; counter_id: string
- doc_no: string; business_date: string; cashier_id: string
- customer_id: string | null; buyer_gstin: string | null; place_of_supply: string | null
- payload: string; created_at_wall: string
-}
-
-interface StoredPayload {
- lines: BillLine[]
- payments?: { mode: string; amountPaise: number }[]
- totals: BillTotals
-}
-
-/** Load an original SALE bill for the tenant (never cross-tenant, never a credit note), or 400. */
-function loadOriginalSale(db: DB, tenantId: string, billId: string): { row: BillRow; payload: StoredPayload } {
- const row = db.prepare(
- `SELECT * FROM bill WHERE id=? AND tenant_id=? AND doc_type='SALE'`,
- ).get(billId, tenantId) as BillRow | undefined
- if (row === undefined) {
- throw httpErr(400, 'Original bill not found for this store (E-1320)', 'E-1320')
- }
- let payload: StoredPayload
- try {
- payload = JSON.parse(row.payload) as StoredPayload
- } catch {
- throw httpErr(400, 'Original bill has no stored line detail to return (E-1320)', 'E-1320')
- }
- if (!Array.isArray(payload.lines) || payload.lines.length === 0) {
- throw httpErr(400, 'Original bill has no returnable lines (E-1320)', 'E-1320')
- }
- return { row, payload }
-}
-
-/**
- * How much of each original line has ALREADY been credited by prior SALE_RETURN documents
- * referencing this bill — summed per original line index (each credit note's payload lines
- * carry `originalLineIndex`). This is the returnable-quantity ledger: returned-so-far + a new
- * return's qty must never exceed the quantity originally sold.
- */
-function returnedByIndex(db: DB, tenantId: string, originalBillId: string): Map {
- const rows = db.prepare(
- `SELECT payload FROM bill WHERE tenant_id=? AND ref_doc_id=? AND doc_type='SALE_RETURN'`,
- ).all(tenantId, originalBillId) as { payload: string }[]
- const m = new Map()
- for (const r of rows) {
- let p: { lines?: { originalLineIndex?: number; qty?: number }[] }
- try { p = JSON.parse(r.payload) as typeof p } catch { continue }
- for (const l of p.lines ?? []) {
- if (typeof l.originalLineIndex === 'number' && typeof l.qty === 'number') {
- m.set(l.originalLineIndex, (m.get(l.originalLineIndex) ?? 0) + l.qty)
- }
- }
- }
- return m
-}
-
-export interface ReturnableLine {
- lineIndex: number
- itemId: string; name: string; hsn: string; unitCode: string
- unitPricePaise: number; taxRateBp: number; lineTotalPaise: number
- soldQty: number; returnedQty: number; returnableQty: number
- batchId?: string
-}
-
-export interface ReturnableBill {
- id: string; docNo: string; businessDate: string
- customerId?: string; customerName?: string
- lines: ReturnableLine[]
- /** The raw original bill lines, so the POS can run the SHARED computeReturn for a live
- * refund preview that matches the server to the paisa (R5). */
- originalLines: BillLine[]
-}
-
-/**
- * The original bill's lines annotated with sold / already-returned / still-returnable quantities
- * — drives the POS return screen and the back-office return detail. Read-only; the authoritative
- * returnable check is re-run inside commitReturn against the same ledger.
- */
-export function returnableLines(db: DB, tenantId: string, billId: string): ReturnableBill {
- const { row, payload } = loadOriginalSale(db, tenantId, billId)
- const returned = returnedByIndex(db, tenantId, billId)
- const customer = row.customer_id !== null
- ? db.prepare(`SELECT name FROM party WHERE id=? AND tenant_id=?`).get(row.customer_id, tenantId) as { name: string } | undefined
- : undefined
- const lines: ReturnableLine[] = payload.lines.map((o, i) => {
- const returnedQty = returned.get(i) ?? 0
- return {
- lineIndex: i, itemId: o.itemId, name: o.name, hsn: o.hsn, unitCode: o.unitCode,
- unitPricePaise: o.unitPricePaise, taxRateBp: o.taxRateBp, lineTotalPaise: o.lineTotalPaise,
- soldQty: o.qty, returnedQty, returnableQty: o.qty - returnedQty,
- ...(o.batchId !== undefined ? { batchId: o.batchId } : {}),
- }
- })
- return {
- id: row.id, docNo: row.doc_no, businessDate: row.business_date,
- ...(row.customer_id !== null ? { customerId: row.customer_id } : {}),
- ...(customer !== undefined ? { customerName: customer.name } : {}),
- lines, originalLines: payload.lines,
- }
-}
-
-export interface CommitReturnInput {
- tenantId: string; storeId: string; counterId: string
- cashierId: string; shiftId: string; businessDate: string
- originalBillId: string
- lines: ReturnLineRequest[]
- reason: string
- refundMode: string
- /** If present, must equal the SERVER-computed refund or the commit is rejected 409 (E-1325). */
- clientRefundPaise?: number
-}
-
-export function commitReturn(
- db: DB, input: CommitReturnInput,
-): { id: string; docNo: string; refundPaise: number } {
- assertBusinessDate(input.businessDate)
- if (!PAYMENT_MODES.has(input.refundMode)) throw httpErr(400, 'Unknown refund mode (E-1326)', 'E-1326')
-
- // Tenant-scope the store/counter exactly like commitBill (M5): unknown-for-tenant → 400 E-1110.
- const store = db.prepare(`SELECT * FROM store WHERE id=? AND tenant_id=?`).get(input.storeId, input.tenantId) as { code: string } | undefined
- const counter = db.prepare(`SELECT * FROM counter WHERE id=? AND tenant_id=?`).get(input.counterId, input.tenantId) as { code: string } | undefined
- if (store === undefined || counter === undefined) {
- throw httpErr(400, 'Unknown store or counter for this tenant (E-1110)', 'E-1110')
- }
- const committer = db.prepare(`SELECT display_name FROM app_user WHERE id=? AND tenant_id=?`).get(input.cashierId, input.tenantId) as { display_name: string } | undefined
- if (committer === undefined) throw httpErr(403, 'Unknown cashier (E-6103)', 'E-6103')
-
- // Anchor to OUR original bill (cross-tenant / nonexistent / non-sale → 400 E-1320).
- const { row: orig, payload } = loadOriginalSale(db, input.tenantId, input.originalBillId)
- const returned = returnedByIndex(db, input.tenantId, input.originalBillId)
-
- if (!Array.isArray(input.lines) || input.lines.length === 0) {
- throw httpErr(400, 'Select at least one line to return (E-1322)', 'E-1322')
- }
- // Validate each requested line against the original: a real, unique index; a positive qty;
- // a whole quantity on a whole-unit line; and returned-so-far + this qty ≤ sold (over-return).
- const seen = new Set()
- const requested: ReturnLineRequest[] = input.lines.map((r) => {
- const lineIndex = r.lineIndex
- if (!Number.isInteger(lineIndex) || lineIndex < 0 || lineIndex >= payload.lines.length) {
- throw httpErr(400, 'Return references a line that is not on the bill (E-1321)', 'E-1321')
- }
- if (seen.has(lineIndex)) throw httpErr(400, 'A line can appear only once in a return (E-1321)', 'E-1321')
- seen.add(lineIndex)
- const o = payload.lines[lineIndex]!
- const qty = r.qty
- if (typeof qty !== 'number' || !Number.isFinite(qty) || qty <= 0) {
- throw httpErr(400, `Return quantity for "${o.name}" must be positive (E-1323)`, 'E-1323')
- }
- // The original line qty tells us the unit shape: a whole-unit item was sold as an integer,
- // so it must be returned whole; a weighed (KG) line was sold fractional and may be partial.
- if (Number.isInteger(o.qty) && !Number.isInteger(qty)) {
- throw httpErr(400, `"${o.name}" is sold whole — return a whole quantity (E-1323)`, 'E-1323')
- }
- const already = returned.get(lineIndex) ?? 0
- if (already + qty > o.qty) {
- throw httpErr(400, `Cannot return ${qty} of "${o.name}" — only ${o.qty - already} left to return (E-1324)`, 'E-1324')
- }
- return { lineIndex, qty }
- })
-
- // The refund is computed by the SHARED engine from the ORIGINAL bill's amounts (R5). The client
- // is never trusted for it: if it declared a total, it must match to the paisa.
- const computed = computeReturn(payload.lines, requested, { roundToRupee: true })
- if (input.clientRefundPaise !== undefined && input.clientRefundPaise !== computed.totals.payablePaise) {
- throw httpErr(409, 'Refund total does not match the original bill — refresh and retry (E-1325)', 'E-1325')
- }
-
- const reason = typeof input.reason === 'string' && input.reason.trim() !== ''
- ? input.reason.trim().slice(0, 60) : 'Unspecified'
- const id = uuidv7()
- const now = new Date().toISOString()
- const fy = fyOf(input.businessDate)
- const t = computed.totals
-
- const txn = db.transaction(() => {
- // Per-counter, per-FY credit-note series, allocated inside the commit transaction — the same
- // gap-free, offline-safe pattern as the sale series, on its own CN-prefixed doc_type.
- let series = db.prepare(
- `SELECT prefix, next_seq FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE_RETURN' AND fy=?`,
- ).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined
- if (series === undefined) {
- series = { prefix: creditNotePrefix(store.code, counter.code, fy), next_seq: 1 }
- db.prepare(
- `INSERT INTO doc_series (tenant_id, store_id, counter_id, doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 'SALE_RETURN', ?, ?, 1)`,
- ).run(input.tenantId, input.storeId, input.counterId, fy, series.prefix)
- }
- const docNo = formatDocNo(series.prefix, series.next_seq, CREDIT_NOTE_SEQ_WIDTH)
- db.prepare(
- `UPDATE doc_series SET next_seq = next_seq + 1 WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE_RETURN' AND fy=?`,
- ).run(input.tenantId, input.storeId, input.counterId, fy)
-
- const storedPayload = JSON.stringify({
- lines: computed.lines,
- // The refund leg: the amount paid back, in the chosen tender.
- payments: [{ mode: input.refundMode, amountPaise: t.payablePaise }],
- totals: t, reason, refundMode: input.refundMode, originalDocNo: orig.doc_no,
- })
- // The immutable SALE_RETURN document: its own row, refDocId → the original bill, the buyer +
- // place-of-supply snapshotted FROM THE ORIGINAL (so the reversal is attributed to the same
- // place of supply the sale used).
- db.prepare(
- `INSERT INTO bill (id, tenant_id, store_id, counter_id, doc_type, doc_no, business_date, cashier_id, shift_id, customer_id, ref_doc_id,
- gross_paise, discount_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, cess_paise, round_off_paise, payable_paise, savings_paise,
- buyer_gstin, place_of_supply, client_id, payload, created_at_wall)
- VALUES (?, ?, ?, ?, 'SALE_RETURN', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
- id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate,
- input.cashierId, input.shiftId, orig.customer_id, input.originalBillId,
- t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise,
- t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise,
- orig.buyer_gstin, orig.place_of_supply, null, storedPayload, now,
- )
- // Stock is ADDED BACK (positive qty_delta) so on-hand derives correctly (R7); each movement
- // carries the ORIGINAL line's batch id (from our stored payload, not the client) so a
- // batch-tracked return credits the exact lot it was sold from.
- const move = db.prepare(
- `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall)
- VALUES (?, ?, ?, ?, ?, 'SALE_RETURN', ?, ?, ?, ?)`,
- )
- for (const l of computed.lines) {
- move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, l.batchId ?? null, input.businessDate, now)
- }
- // Dormant outbox row (D14/R15), same transaction.
- db.prepare(
- `INSERT INTO outbox (op_id, tenant_id, device_id, device_seq, doc_type, doc_id, envelope, created_at_wall, lamport)
- VALUES (?, ?, 'store-server', 0, 'SALE_RETURN', ?, ?, ?, 0)`,
- ).run(uuidv7(), input.tenantId, id, storedPayload, now)
- // Hash-chained audit row (H2/R17), in the same transaction.
- writeAudit(db, input.tenantId, input.cashierId, 'RETURN_COMMIT', 'bill', id,
- { originalDocNo: orig.doc_no, originalBillId: input.originalBillId },
- { docNo, refundPaise: t.payablePaise, reason, refundMode: input.refundMode })
- return docNo
- })
-
- const docNo = txn()
- return { id, docNo, refundPaise: t.payablePaise }
-}
-
-/**
- * List credit notes (SALE_RETURN docs), tenant-scoped, newest-first, paginated. The original
- * sale's doc no is joined in for the "against bill" column. A caller without REPORT_VIEW is
- * pinned to their own credit notes (cashierId set) — the same scoping the sales list uses.
- */
-export function listReturns(
- db: DB, tenantId: string, limit = 100, offset = 0, cashierId?: string,
-): Record[] {
- let sql = `SELECT b.*, u.display_name AS cashier_name, p.name AS customer_name, o.doc_no AS original_doc_no
- FROM bill b
- LEFT JOIN app_user u ON u.id=b.cashier_id
- LEFT JOIN party p ON p.id=b.customer_id
- LEFT JOIN bill o ON o.id=b.ref_doc_id
- WHERE b.tenant_id=? AND b.doc_type='SALE_RETURN'`
- const args: unknown[] = [tenantId]
- if (cashierId !== undefined) { sql += ` AND b.cashier_id=?`; args.push(cashierId) }
- sql += ` ORDER BY b.created_at_wall DESC LIMIT ? OFFSET ?`
- args.push(limit, offset)
- return db.prepare(sql).all(...args) as Record[]
-}
-
-/**
- * Find one original SALE bill by its exact doc no (the POS return lookup). Tenant-scoped and
- * SALE-only — a targeted lookup for issuing a return, not the cross-counter enumeration the
- * bills-list scoping guards against.
- */
-export function lookupBillByDocNo(db: DB, tenantId: string, docNo: string): Record | undefined {
- return db.prepare(
- `SELECT b.id, b.doc_no, b.business_date, b.payable_paise, b.customer_id,
- u.display_name AS cashier_name, p.name AS customer_name
- FROM bill b LEFT JOIN app_user u ON u.id=b.cashier_id LEFT JOIN party p ON p.id=b.customer_id
- WHERE b.tenant_id=? AND b.doc_type='SALE' AND b.doc_no=?`,
- ).get(tenantId, docNo) as Record | undefined
-}
-
-/** Recent original SALE bills for customers matching a phone (the POS phone-lookup return path). */
-export function lookupBillsByPhone(db: DB, tenantId: string, phone: string, limit = 20): Record[] {
- return db.prepare(
- `SELECT b.id, b.doc_no, b.business_date, b.payable_paise,
- u.display_name AS cashier_name, p.name AS customer_name
- FROM bill b JOIN party p ON p.id=b.customer_id LEFT JOIN app_user u ON u.id=b.cashier_id
- WHERE b.tenant_id=? AND b.doc_type='SALE' AND p.phone LIKE ?
- ORDER BY b.created_at_wall DESC LIMIT ?`,
- ).all(tenantId, `%${phone}%`, limit) as Record[]
-}
diff --git a/apps/store-server/src/repos.ts b/apps/store-server/src/repos.ts
deleted file mode 100644
index 340f210..0000000
--- a/apps/store-server/src/repos.ts
+++ /dev/null
@@ -1,721 +0,0 @@
-import { createHash } from 'node:crypto'
-import {
- deriveSupply, formatDocNo, fyOf, seriesPrefix, uuidv7, validateGstin,
- type Item, type Party, type RoleCode,
-} from '@sims/domain'
-import { can, type ActionCode } from '@sims/auth'
-import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
-import type { DB } from './db'
-import { PAYMENT_MODES, assertBusinessDate } from './validate'
-
-/** Tag an error with an HTTP status + a safe client code (the error handler never leaks a stack). */
-const httpErr = (status: number, message: string, code: string): Error =>
- Object.assign(new Error(message), { status, code })
-
-/**
- * Repositories — the portable data layer (D12 guardrail). Plain functions over
- * a handle; the Oracle adapter reimplements these signatures, nothing above
- * this file changes.
- */
-
-// ---------- items ----------
-interface ItemRow {
- id: string; tenant_id: string; code: string; name: string; name_secondary: string | null
- hsn: string; tax_class_code: string; unit_code: string; unit_decimals: number
- mrp_paise: number | null; sale_price_paise: number; price_includes_tax: number
- barcodes: string; batch_tracked: number; status: string
-}
-
-function toItem(r: ItemRow): Item {
- return {
- id: r.id, tenantId: r.tenant_id, code: r.code, name: r.name,
- ...(r.name_secondary !== null ? { nameSecondary: r.name_secondary } : {}),
- hsn: r.hsn, taxClassCode: r.tax_class_code,
- unit: { code: r.unit_code, decimals: r.unit_decimals },
- ...(r.mrp_paise !== null ? { mrpPaise: r.mrp_paise } : {}),
- salePricePaise: r.sale_price_paise, priceIncludesTax: r.price_includes_tax === 1,
- barcodes: JSON.parse(r.barcodes) as string[], batchTracked: r.batch_tracked === 1,
- status: r.status as Item['status'],
- }
-}
-
-/**
- * The item master. `all` returns every non-inactive item unpaginated — the POS
- * cache path, which must never be silently truncated (R13); every other path
- * paginates via limit/offset (default 5000, generous but bounded).
- */
-export function listItems(db: DB, tenantId: string, opts: {
- query?: string; all?: boolean; limit?: number; offset?: number
-} = {}): Item[] {
- const { query, all = false } = opts
- const limit = opts.limit ?? 5000
- const offset = opts.offset ?? 0
- let rows: ItemRow[]
- if (all) {
- rows = db.prepare(
- `SELECT * FROM item WHERE tenant_id=? AND status <> 'inactive' ORDER BY name`,
- ).all(tenantId) as ItemRow[]
- } else if (query !== undefined && query !== '') {
- rows = db.prepare(
- `SELECT * FROM item WHERE tenant_id=? AND (name LIKE ? OR code=? OR barcodes LIKE ?) ORDER BY name LIMIT ? OFFSET ?`,
- ).all(tenantId, `%${query}%`, query, `%"${query}"%`, limit, offset) as ItemRow[]
- } else {
- rows = db.prepare(`SELECT * FROM item WHERE tenant_id=? ORDER BY name LIMIT ? OFFSET ?`).all(tenantId, limit, offset) as ItemRow[]
- }
- return rows.map(toItem)
-}
-
-export function createItem(db: DB, tenantId: string, input: {
- code: string; name: string; hsn: string; taxClassCode: string
- unitCode: string; unitDecimals: number; mrpPaise?: number
- salePricePaise: number; barcode?: string; status?: string
-}): Item {
- const id = uuidv7()
- db.prepare(
- `INSERT INTO item (id, tenant_id, code, name, hsn, tax_class_code, unit_code, unit_decimals, mrp_paise, sale_price_paise, price_includes_tax, barcodes, status)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
- ).run(
- id, tenantId, input.code, input.name, input.hsn, input.taxClassCode,
- input.unitCode, input.unitDecimals, input.mrpPaise ?? null, input.salePricePaise,
- JSON.stringify(input.barcode !== undefined && input.barcode !== '' ? [input.barcode] : []),
- input.status ?? 'active',
- )
- return toItem(db.prepare(`SELECT * FROM item WHERE id=?`).get(id) as ItemRow)
-}
-
-// ---------- parties ----------
-export function listParties(db: DB, tenantId: string, kind?: string, phone?: string): Party[] {
- let rows: unknown[]
- if (phone !== undefined && phone !== '') {
- rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? AND phone LIKE ?`).all(tenantId, `%${phone}%`)
- } else if (kind !== undefined) {
- rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? AND kind=? ORDER BY name`).all(tenantId, kind)
- } else {
- rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? ORDER BY name`).all(tenantId)
- }
- return (rows as Record[]).map((r) => ({
- id: r['id'] as string, tenantId: r['tenant_id'] as string, code: r['code'] as string,
- name: r['name'] as string, kind: r['kind'] as Party['kind'],
- ...(r['phone'] !== null ? { phone: r['phone'] as string } : {}),
- ...(r['gstin'] !== null ? { gstin: r['gstin'] as string } : {}),
- ...(r['state_code'] !== null ? { stateCode: r['state_code'] as string } : {}),
- ...(r['credit_limit_paise'] !== null ? { creditLimitPaise: r['credit_limit_paise'] as number } : {}),
- }))
-}
-
-export function createCustomer(
- db: DB, tenantId: string, name: string, phone: string,
- opts: { gstin?: string; stateCode?: string } = {},
-): Party {
- // B2B identity is validated server-side (never trust the client): a bad GSTIN
- // checksum is rejected loudly; the state code is derived from the GSTIN's first
- // two digits when the caller didn't pass one.
- let gstin: string | null = null
- let stateCode: string | null = opts.stateCode !== undefined && opts.stateCode !== '' ? opts.stateCode : null
- if (opts.gstin !== undefined && opts.gstin !== '') {
- const v = validateGstin(opts.gstin)
- if (!v.ok) {
- throw Object.assign(
- new Error(v.reason === 'checksum'
- ? 'GSTIN checksum is invalid — re-check the number (E-1401)'
- : 'GSTIN format is invalid — 15 chars, e.g. 27ABCDE1234F1Z5 (E-1402)'),
- { status: 400 },
- )
- }
- gstin = opts.gstin.toUpperCase().trim()
- if (stateCode === null) stateCode = v.stateCode ?? null
- }
- const id = uuidv7()
- const n = (db.prepare(`SELECT COUNT(*) AS n FROM party WHERE tenant_id=?`).get(tenantId) as { n: number }).n
- db.prepare(
- `INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code) VALUES (?, ?, ?, ?, 'customer', ?, ?, ?)`,
- ).run(id, tenantId, `CU${String(n + 1).padStart(3, '0')}`, name, phone, gstin, stateCode)
- return listParties(db, tenantId, undefined, phone)[0]!
-}
-
-// ---------- tax ----------
-export function listTaxClasses(db: DB): TaxClassRow[] {
- const rows = db.prepare(`SELECT * FROM tax_class`).all() as Record[]
- return rows.map((r) => ({
- classCode: r['class_code'] as string,
- ratePctBp: r['rate_pct_bp'] as number,
- cessPctBp: r['cess_pct_bp'] as number,
- effectiveFrom: r['effective_from'] as string,
- ...(r['effective_to'] !== null ? { effectiveTo: r['effective_to'] as string } : {}),
- }))
-}
-
-// ---------- bills ----------
-export interface CommitBillInput {
- tenantId: string; storeId: string; counterId: string
- cashierId: string; shiftId: string; customerId?: string
- businessDate: string
- lines: LineInput[]
- payments: { mode: string; amountPaise: number; reference?: string }[]
- clientPayablePaise: number
- /**
- * Supervisor-approved counter overrides (spec P0-2 / P1-3): each price / qty /
- * discount deviation that raised the supervisor PIN gate rides along with the
- * bill so its audit row is written in the SAME transaction as the commit (R17).
- * `approvalId` is the single-use token minted by /auth/verify-pin — the server
- * redeems it and reads the approver identity from ITS OWN row, never trusting a
- * client-supplied user id (SEC fix).
- */
- overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string }[]
- /**
- * S4 offline queue: the client-generated bill id (uuidv7). Present only when a
- * bill made while the store-server was unreachable is drained on reconnect; it
- * is the idempotency key — a re-drained payload returns the already-assigned doc
- * no instead of committing a second bill (02-ARCHITECTURE §3 outbox pattern).
- */
- clientId?: string
-}
-
-const OVERRIDE_ACTION = {
- price: 'PRICE_OVERRIDE', qty: 'QTY_OVERRIDE', discount: 'DISCOUNT_OVERRIDE',
- // S5: a supervisor approved selling an EXPIRED batch (E-1310 gate). before/after
- // are 0 — the audited fact is "who approved the expired-batch sale for this item".
- batch: 'BATCH_OVERRIDE',
-} as const
-
-/** Roles allowed to approve a counter override (09-UX §5.5). */
-const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
-/** A supervisor-approval token is valid for five minutes after it is minted. */
-const APPROVAL_TTL_MS = 5 * 60 * 1000
-
-/** The bound context a supervisor is approving — re-verified at redeem (H4). */
-export interface OverrideContext {
- itemId: string
- kind: 'price' | 'qty' | 'discount' | 'batch'
- beforePaise: number
- afterPaise: number
- /** The cashier the approval was granted to (advisory; not enforced at redeem). */
- cashierUserId?: string
-}
-
-/**
- * Mint a single-use supervisor-approval token (SEC fix), BOUND to the exact override it
- * authorises (H4 fix). Called by /auth/verify-pin once a supervisor PIN is proven; the
- * bill commit later redeems it and re-verifies item/kind/before/after against the line's
- * server-computed deviation, so a token minted for one line can never move to another.
- */
-export function createOverrideApproval(
- db: DB, tenantId: string, approverUserId: string, ctx: OverrideContext,
-): string {
- const id = uuidv7()
- db.prepare(
- `INSERT INTO override_approval (id, tenant_id, approver_user_id, created_at, used, item_id, kind, before_paise, after_paise, cashier_user_id)
- VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`,
- ).run(id, tenantId, approverUserId, new Date().toISOString(), ctx.itemId, ctx.kind, ctx.beforePaise, ctx.afterPaise, ctx.cashierUserId ?? null)
- return id
-}
-
-/**
- * The discount cap (basis points) beyond which a line discount is a deviation needing a
- * supervisor override. A dated tenant setting (R9/R10): tenant row wins over the system
- * default; 1000 bp (10%) if neither is present.
- */
-function resolveDiscountCapBp(db: DB, tenantId: string): number {
- const row = db.prepare(
- `SELECT value FROM setting WHERE key='discount.capBp'
- AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
- ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
- ).get(tenantId) as { value: string } | undefined
- const n = row !== undefined ? Number(row.value) : NaN
- return Number.isFinite(n) && n >= 0 ? n : 1000
-}
-
-/**
- * The tax class the server FORCES onto a cashier-created draft item (C3 fix). A cashier
- * quick-adding an unknown good must not be able to choose ZERO (or any class) to evade GST;
- * the server ignores the client's taxClassCode and assigns the store's standard rate. A
- * dated tenant setting (R9/R10): tenant row wins over the system default; 'GST18' if neither
- * is present. A MASTER_EDIT role (manager/owner) keeps its chosen class — back office
- * completes the draft's real HSN/tax class later.
- */
-export function resolveDefaultTaxClass(db: DB, tenantId: string): string {
- const row = db.prepare(
- `SELECT value FROM setting WHERE key='catalog.defaultTaxClass'
- AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
- ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
- ).get(tenantId) as { value: string } | undefined
- return row !== undefined && row.value !== '' ? row.value : 'GST18'
-}
-
-/**
- * The price ceiling (paise) a cashier-created draft may carry (C2 bound). A quick-add's
- * price IS its sell price (inherent to the flow), so a genuinely expensive unknown must go
- * through a supervisor / back office rather than be self-priced arbitrarily at the counter.
- * A dated tenant setting (R9/R10): tenant row wins over the system default; ₹5,000
- * (500000 paise) if neither is present.
- */
-export function resolveDraftMaxPricePaise(db: DB, tenantId: string): number {
- const row = db.prepare(
- `SELECT value FROM setting WHERE key='catalog.draftMaxPricePaise'
- AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
- ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
- ).get(tenantId) as { value: string } | undefined
- const n = row !== undefined ? Number(row.value) : NaN
- return Number.isFinite(n) && n > 0 ? n : 500_000
-}
-
-export function commitBill(
- db: DB, input: CommitBillInput,
-): { id: string; docNo: string; payablePaise: number; duplicate?: boolean } {
- // Idempotent drain (S4): if this client bill id already committed, return the
- // doc no it was assigned — never allocate a new series number or a second stock
- // movement. The partial unique index on (tenant_id, client_id) is the backstop.
- if (input.clientId !== undefined) {
- const existing = db.prepare(
- `SELECT id, doc_no, payable_paise FROM bill WHERE tenant_id=? AND client_id=?`,
- ).get(input.tenantId, input.clientId) as { id: string; doc_no: string; payable_paise: number } | undefined
- if (existing !== undefined) {
- return { id: existing.id, docNo: existing.doc_no, payablePaise: existing.payable_paise, duplicate: true }
- }
- }
- // The business date is a real, sanely-bounded ISO day (H6) even when a caller reaches
- // commitBill directly — a garbage date must never mint a doc series or postdate a bill.
- assertBusinessDate(input.businessDate)
- const store = db.prepare(`SELECT * FROM store WHERE id=? AND tenant_id=?`).get(input.storeId, input.tenantId) as { code: string; state_code: string } | undefined
- const counter = db.prepare(`SELECT * FROM counter WHERE id=? AND tenant_id=?`).get(input.counterId, input.tenantId) as { code: string } | undefined
- if (store === undefined || counter === undefined) {
- throw httpErr(400, 'Unknown store or counter for this tenant (E-1110)', 'E-1110')
- }
- const rates = listTaxClasses(db)
-
- // The committing user's authority (C2/H7): owner/manager/supervisor may self-authorise a
- // deviation from the master; everyone else needs a bound supervisor token per deviation.
- const committer = db.prepare(
- `SELECT display_name, roles FROM app_user WHERE id=? AND tenant_id=?`,
- ).get(input.cashierId, input.tenantId) as { display_name: string; roles: string } | undefined
- if (committer === undefined) throw httpErr(403, 'Unknown cashier (E-6103)', 'E-6103')
- const committerRoles = JSON.parse(committer.roles) as RoleCode[]
- const committerCan = (action: ActionCode): boolean => can({ roles: committerRoles, active: true }, action)
- const capBp = resolveDiscountCapBp(db, input.tenantId)
-
- // Place of supply is decided from OUR copy of the customer row, never the
- // client's claim: a B2B buyer's GSTIN state drives IGST vs CGST/SGST (R5).
- const customer = input.customerId !== undefined
- ? db.prepare(`SELECT gstin, state_code FROM party WHERE id=? AND tenant_id=?`).get(input.customerId, input.tenantId) as { gstin: string | null; state_code: string | null } | undefined
- : undefined
- const supply = deriveSupply(
- customer !== undefined
- ? { ...(customer.gstin !== null ? { gstin: customer.gstin } : {}), ...(customer.state_code !== null ? { stateCode: customer.state_code } : {}) }
- : undefined,
- store.state_code,
- )
-
- // ---- Item-master anchoring (C2, C3, H5, M8) ----
- // Every line is rebuilt from OUR item row: tax class, HSN, MRP, unit and the inclusive-
- // price flag come from the master; the client's values for them are ignored. The unit
- // price is accepted only when it equals the master sale price — any other value is a
- // deviation that must be authorised below (a bound token, or the committer's own
- // authority). A price above MRP is a hard block even with a token (E-1301).
- interface Deviation { kind: 'price' | 'discount'; itemId: string; before: number; after: number; name: string }
- const deviations: Deviation[] = []
- // Draft-item sales (C2/C3 review trail): a cashier-authored quick-add item is sold on the
- // same session that created it, so its only master-side trace is ITEM_CREATE. Every line
- // whose master row is still status='draft' is flagged here and audited DRAFT_SALE in the
- // commit transaction below, so a self-authored-item sale is never invisible in a review.
- interface DraftSale { itemId: string; name: string; pricePaise: number }
- const draftSales: DraftSale[] = []
- const anchored: LineInput[] = input.lines.map((line) => {
- const item = db.prepare(`SELECT * FROM item WHERE id=? AND tenant_id=?`).get(line.itemId, input.tenantId) as ItemRow | undefined
- if (item === undefined) {
- throw httpErr(400, 'A billed item is not in this store catalogue (E-1105)', 'E-1105')
- }
- if (!Number.isFinite(line.qty) || line.qty <= 0 || line.qty > 1e12) {
- throw httpErr(400, `Quantity is out of range for "${item.name}" (E-1104)`, 'E-1104')
- }
- if (item.unit_decimals === 0 && !Number.isInteger(line.qty)) {
- throw httpErr(400, `"${item.name}" is sold whole — a fractional quantity is not allowed (E-1104)`, 'E-1104')
- }
- const effPrice = line.unitPricePaise
- if (!Number.isInteger(effPrice) || effPrice < 0) {
- throw httpErr(400, `Unit price is invalid for "${item.name}" (E-1103)`, 'E-1103')
- }
- if (item.status === 'draft') {
- draftSales.push({ itemId: line.itemId, name: item.name, pricePaise: effPrice })
- }
- const masterPrice = item.sale_price_paise
- if (effPrice !== masterPrice) {
- if (item.mrp_paise !== null && effPrice > item.mrp_paise) {
- throw httpErr(400, `Price for "${item.name}" is above its MRP — not allowed (E-1301)`, 'E-1301')
- }
- deviations.push({ kind: 'price', itemId: line.itemId, before: masterPrice, after: effPrice, name: item.name })
- }
- if (line.discount !== undefined) {
- const gross = Math.round(effPrice * line.qty)
- const discPaise = line.discount.kind === 'percentBp'
- ? Math.round((gross * line.discount.value) / 10_000)
- : line.discount.paise
- if (discPaise > Math.floor((gross * capBp) / 10_000)) {
- deviations.push({ kind: 'discount', itemId: line.itemId, before: 0, after: discPaise, name: item.name })
- }
- }
- return {
- itemId: line.itemId,
- name: item.name,
- hsn: item.hsn,
- qty: line.qty,
- unitCode: item.unit_code,
- unitPricePaise: effPrice,
- priceIncludesTax: item.price_includes_tax === 1,
- taxClassCode: item.tax_class_code,
- ...(item.mrp_paise !== null ? { mrpPaise: item.mrp_paise } : {}),
- ...(line.discount !== undefined ? { discount: line.discount } : {}),
- ...(line.batchId !== undefined ? { batchId: line.batchId } : {}),
- }
- })
-
- // Same engine everywhere, now on the ANCHORED lines: the server recomputes from its own
- // prices/tax and must still agree with the client to the paisa (R5) — a client that
- // under-computed GST (C3) or forged a total is rejected here.
- const computed = computeBill(anchored, {
- businessDate: input.businessDate,
- supplyStateCode: store.state_code,
- placeOfSupplyStateCode: supply.placeOfSupplyStateCode,
- roundToRupee: true,
- }, rates)
- if (computed.totals.payablePaise !== input.clientPayablePaise) {
- throw httpErr(409, 'Bill total does not match the item master — refresh and retry (E-1302)', 'E-1302')
- }
- // Payments (M8): every leg a positive amount with an allowlisted mode, summing to payable.
- for (const p of input.payments) {
- if (!Number.isInteger(p.amountPaise) || p.amountPaise <= 0) {
- throw httpErr(400, 'Every payment must be a positive amount (E-1107)', 'E-1107')
- }
- if (!PAYMENT_MODES.has(p.mode)) throw httpErr(400, 'Unknown payment mode (E-1108)', 'E-1108')
- }
- const paid = input.payments.reduce((a, p) => a + p.amountPaise, 0)
- if (paid !== computed.totals.payablePaise) {
- throw httpErr(400, 'Payments do not sum to the payable amount (E-1109)', 'E-1109')
- }
-
- const id = uuidv7()
- const now = new Date().toISOString()
- const fy = fyOf(input.businessDate)
-
- // Redeem a bound supervisor token for a specific server-computed deviation (H4): the
- // token must exist, be unused, be < 5 min old, belong to an approver role, AND match the
- // exact item/kind/before/after — then it is marked used in THIS transaction and the
- // approver identity is read from the server's own row, never the client's.
- const redeemToken = (
- approvalId: string, kind: Deviation['kind'] | 'batch', itemId: string, before: number, after: number,
- ): { name: string; userId: string } => {
- const appr = db.prepare(
- `SELECT approver_user_id, created_at, used, item_id, kind, before_paise, after_paise FROM override_approval WHERE id=? AND tenant_id=?`,
- ).get(approvalId, input.tenantId) as {
- approver_user_id: string; created_at: string; used: number
- item_id: string | null; kind: string | null; before_paise: number | null; after_paise: number | null
- } | undefined
- if (appr === undefined) throw httpErr(403, 'Override approval is not recognised (E-6107)', 'E-6107')
- if (appr.used !== 0) throw httpErr(403, 'Override approval was already used (E-6108)', 'E-6108')
- if (Date.now() - Date.parse(appr.created_at) > APPROVAL_TTL_MS) {
- throw httpErr(403, 'Override approval has expired — re-approve at the counter (E-6109)', 'E-6109')
- }
- if (appr.item_id !== itemId || appr.kind !== kind || appr.before_paise !== before || appr.after_paise !== after) {
- throw httpErr(403, 'Override approval does not match this line — re-approve at the counter (E-6108)', 'E-6108')
- }
- const approver = db.prepare(
- `SELECT display_name, roles FROM app_user WHERE id=? AND tenant_id=?`,
- ).get(appr.approver_user_id, input.tenantId) as { display_name: string; roles: string } | undefined
- if (approver === undefined || !(JSON.parse(approver.roles) as string[]).some((role) => APPROVER_ROLES.has(role))) {
- throw httpErr(403, 'Override approver is not authorised (E-6110)', 'E-6110')
- }
- const redeemed = db.prepare(
- `UPDATE override_approval SET used=1 WHERE id=? AND tenant_id=? AND used=0`,
- ).run(approvalId, input.tenantId)
- if (redeemed.changes !== 1) throw httpErr(403, 'Override approval was already used (E-6108)', 'E-6108')
- return { name: approver.display_name, userId: appr.approver_user_id }
- }
-
- const txn = db.transaction(() => {
- // per-counter, per-FY series allocated inside the commit transaction
- let series = db.prepare(
- `SELECT * FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`,
- ).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined
- if (series === undefined) {
- series = { prefix: seriesPrefix(store.code, counter.code, fy), next_seq: 1 }
- db.prepare(
- `INSERT INTO doc_series (tenant_id, store_id, counter_id, doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 'SALE', ?, ?, 1)`,
- ).run(input.tenantId, input.storeId, input.counterId, fy, series.prefix)
- }
- const docNo = formatDocNo(series.prefix, series.next_seq)
- db.prepare(
- `UPDATE doc_series SET next_seq = next_seq + 1 WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`,
- ).run(input.tenantId, input.storeId, input.counterId, fy)
-
- const t = computed.totals
- const payload = JSON.stringify({ lines: computed.lines, payments: input.payments, totals: t })
- db.prepare(
- `INSERT INTO bill (id, tenant_id, store_id, counter_id, doc_type, doc_no, business_date, cashier_id, shift_id, customer_id, ref_doc_id,
- gross_paise, discount_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, cess_paise, round_off_paise, payable_paise, savings_paise,
- buyer_gstin, place_of_supply, client_id, payload, created_at_wall) VALUES (?, ?, ?, ?, 'SALE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
- id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate,
- input.cashierId, input.shiftId, input.customerId ?? null,
- t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise,
- t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise,
- supply.buyerGstin ?? null, supply.placeOfSupplyStateCode, input.clientId ?? null,
- payload, now,
- )
- const move = db.prepare(
- `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall)
- VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?, ?)`,
- )
- for (const line of computed.lines) {
- // S5: a batch-picked line carries a batchId; the sale movement is attributed to
- // it so per-batch on-hand derives correctly (R7). Trust nothing — the batch must
- // belong to THIS item and store, or the whole commit rolls back (E-1311).
- let batchId: string | null = null
- if (line.batchId !== undefined) {
- const belongs = db.prepare(
- `SELECT 1 FROM batch WHERE id=? AND tenant_id=? AND store_id=? AND item_id=?`,
- ).get(line.batchId, input.tenantId, input.storeId, line.itemId)
- if (belongs === undefined) {
- throw Object.assign(new Error(`Batch does not belong to "${line.name}" at this store (E-1311)`), { status: 400 })
- }
- batchId = line.batchId
- }
- move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, batchId, input.businessDate, now)
- }
- // dormant outbox row (D14): written in the same transaction, drained by nothing yet
- db.prepare(
- `INSERT INTO outbox (op_id, tenant_id, device_id, device_seq, doc_type, doc_id, envelope, created_at_wall, lamport)
- VALUES (?, ?, 'store-server', 0, 'SALE', ?, ?, ?, 0)`,
- ).run(uuidv7(), input.tenantId, id, payload, now)
- writeAudit(db, input.tenantId, input.cashierId, 'BILL_COMMIT', 'bill', id, undefined, { docNo, payable: t.payablePaise })
-
- // Flag every draft-item line for back-office review (C2/C3): one DRAFT_SALE row per
- // draft sold, in THIS transaction, carrying the item + the price it sold at — so a
- // cashier who quick-adds and sells a self-priced item leaves an explicit trail.
- for (const d of draftSales) {
- writeAudit(db, input.tenantId, input.cashierId, 'DRAFT_SALE', 'bill', id,
- undefined, { itemId: d.itemId, name: d.name, pricePaise: d.pricePaise, docNo })
- }
-
- // Authorise + audit every SERVER-detected price/discount deviation (C2, H4, H7). Each
- // deviation is approved by a bound token that matches it exactly, OR — for owner /
- // manager / supervisor — the committer's own authority; either way an override audit
- // row is written in-transaction (R17). An unauthorised deviation throws and the whole
- // commit rolls back, so a mispriced line can never reach the ledger unaudited.
- const usedOverride = new Set()
- for (const dev of deviations) {
- const action: ActionCode = dev.kind === 'price' ? 'PRICE_OVERRIDE' : 'DISCOUNT_OVER_CAP'
- const auditAction = dev.kind === 'price' ? OVERRIDE_ACTION.price : OVERRIDE_ACTION.discount
- const idx = (input.overrides ?? []).findIndex((o, i) =>
- !usedOverride.has(i) && o.approvalId !== undefined && o.kind === dev.kind && o.itemId === dev.itemId && o.after === dev.after)
- if (idx >= 0) {
- usedOverride.add(idx)
- const approvalId = input.overrides![idx]!.approvalId!
- const appr = redeemToken(approvalId, dev.kind, dev.itemId, dev.before, dev.after)
- writeAudit(db, input.tenantId, input.cashierId, auditAction, 'bill', id,
- { before: dev.before, itemId: dev.itemId },
- { after: dev.after, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId })
- } else if (committerCan(action)) {
- writeAudit(db, input.tenantId, input.cashierId, auditAction, 'bill', id,
- { before: dev.before, itemId: dev.itemId },
- { after: dev.after, approvedBy: committer.display_name, approvedByUserId: input.cashierId, selfAuthorized: true })
- } else {
- const code = dev.kind === 'price' ? 'E-1302' : 'E-1303'
- throw httpErr(403, `"${dev.name}": ${dev.kind} change needs supervisor approval (${code})`, code)
- }
- }
-
- // Client-declared batch overrides (expired-batch sale, S5): no server-side price
- // deviation to detect, but the approval is bound + audited the same way (H4/H7).
- for (const o of input.overrides ?? []) {
- if (o.kind !== 'batch') continue
- if (o.approvalId !== undefined) {
- const appr = redeemToken(o.approvalId, 'batch', o.itemId, 0, 0)
- writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id,
- { before: 0, itemId: o.itemId },
- { after: 0, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId: o.approvalId })
- } else if (committerCan('PRICE_OVERRIDE')) {
- writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id,
- { before: 0, itemId: o.itemId },
- { after: 0, approvedBy: committer.display_name, approvedByUserId: input.cashierId, selfAuthorized: true })
- } else {
- throw httpErr(403, 'Expired-batch sale needs supervisor approval (E-1310)', 'E-1310')
- }
- }
- return docNo
- })
-
- const docNo = txn()
- return { id, docNo, payablePaise: computed.totals.payablePaise }
-}
-
-export function listBills(
- db: DB, tenantId: string, date?: string, counterId?: string, limit = 100, offset = 0,
- cashierId?: string,
-): Record[] {
- // SALE only — credit notes (SALE_RETURN) are their own document and live on the Returns list,
- // never mixed into the sales list or the POS day history.
- let sql = `SELECT b.*, u.display_name AS cashier_name, p.name AS customer_name
- FROM bill b LEFT JOIN app_user u ON u.id=b.cashier_id LEFT JOIN party p ON p.id=b.customer_id
- WHERE b.tenant_id=? AND b.doc_type='SALE'`
- const args: unknown[] = [tenantId]
- if (date !== undefined) { sql += ` AND b.business_date=?`; args.push(date) }
- if (counterId !== undefined) { sql += ` AND b.counter_id=?`; args.push(counterId) }
- // Cross-counter read scope (residual LOW/MED): when the caller lacks REPORT_VIEW the route
- // pins this to the session's own user id, so a cashier sees only their OWN bills — never
- // every counter's/cashier's. A REPORT_VIEW/manager role passes undefined and keeps the
- // full store-wide view.
- if (cashierId !== undefined) { sql += ` AND b.cashier_id=?`; args.push(cashierId) }
- sql += ` ORDER BY b.created_at_wall DESC LIMIT ? OFFSET ?`
- args.push(limit, offset)
- return db.prepare(sql).all(...args) as Record[]
-}
-
-// ---------- stock ----------
-export function stockView(db: DB, tenantId: string, storeId: string): Record[] {
- return db.prepare(
- `SELECT i.code, i.name, i.unit_code, i.sale_price_paise, i.batch_tracked,
- COALESCE(SUM(m.qty_delta), 0) AS on_hand
- FROM item i LEFT JOIN stock_movement m ON m.item_id=i.id AND m.store_id=?
- WHERE i.tenant_id=? GROUP BY i.id ORDER BY i.name`,
- ).all(storeId, tenantId) as Record[]
-}
-
-// ---------- batches (S5) ----------
-/**
- * Find a batch by its unique key (tenant, store, item, batch_no); create it if new.
- * Returns the batch id either way. Called at purchase commit. When an expiry is
- * supplied and the existing lot had none (or a different date), the date is filled
- * in — a later invoice can date a batch first captured undated. Client-generated
- * ids per R14. Callers run this inside their own transaction.
- */
-export function upsertBatch(
- db: DB, tenantId: string, storeId: string, itemId: string, batchNo: string, expiryDate?: string,
-): string {
- const existing = db.prepare(
- `SELECT id, expiry_date FROM batch WHERE tenant_id=? AND store_id=? AND item_id=? AND batch_no=?`,
- ).get(tenantId, storeId, itemId, batchNo) as { id: string; expiry_date: string | null } | undefined
- if (existing !== undefined) {
- if (expiryDate !== undefined && expiryDate !== '' && expiryDate !== existing.expiry_date) {
- db.prepare(`UPDATE batch SET expiry_date=? WHERE id=?`).run(expiryDate, existing.id)
- }
- return existing.id
- }
- const id = uuidv7()
- db.prepare(
- `INSERT INTO batch (id, tenant_id, store_id, item_id, batch_no, expiry_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
- ).run(id, tenantId, storeId, itemId, batchNo, expiryDate !== undefined && expiryDate !== '' ? expiryDate : null, new Date().toISOString())
- return id
-}
-
-/**
- * Every batch at a store with derived on-hand > 0 (R7) — the POS caches this keyed
- * by item to drive the FEFO picker. Stock is SUM(movements) per batch, never stored.
- */
-export function batchesForStore(db: DB, tenantId: string, storeId: string): Record[] {
- return db.prepare(
- `SELECT b.id, b.item_id, b.batch_no, b.expiry_date,
- COALESCE(SUM(m.qty_delta), 0) AS on_hand
- FROM batch b LEFT JOIN stock_movement m ON m.batch_id=b.id AND m.store_id=b.store_id
- WHERE b.tenant_id=? AND b.store_id=?
- GROUP BY b.id HAVING on_hand > 0
- ORDER BY b.item_id, b.expiry_date`,
- ).all(tenantId, storeId) as Record[]
-}
-
-/**
- * The expiry dashboard feed: each in-stock batch with its item, derived on-hand and
- * value at the item's current sale price. Buckets (≤7d, ≤30d, expired) are computed
- * by the page against the business date so the numbers stay live.
- */
-export function expiryView(db: DB, tenantId: string, storeId: string): Record[] {
- return db.prepare(
- `SELECT b.id, b.item_id, b.batch_no, b.expiry_date,
- i.code, i.name, i.unit_code, i.sale_price_paise,
- COALESCE(SUM(m.qty_delta), 0) AS on_hand
- FROM batch b
- JOIN item i ON i.id=b.item_id
- LEFT JOIN stock_movement m ON m.batch_id=b.id AND m.store_id=b.store_id
- WHERE b.tenant_id=? AND b.store_id=?
- GROUP BY b.id HAVING on_hand > 0
- ORDER BY b.expiry_date IS NULL, b.expiry_date`,
- ).all(tenantId, storeId) as Record[]
-}
-
-// ---------- audit (H2: tamper-evident hash chain) ----------
-/**
- * The canonical bytes an audit entry hashes over (H2). A JSON array is an injective,
- * delimiter-safe encoding: `null` stays distinct from an empty string, and every field is
- * escaped, so no two different entries can serialise to the same bytes. `beforeJson`/`afterJson`
- * are the EXACT stored column values (a JSON string or SQL NULL→JS null) so verify recomputes
- * byte-identically to what writeAudit hashed. The chain is per-tenant: prevHash is the previous
- * entry's entry_hash for the tenant, genesis '' for the first.
- */
-function auditEntryHash(
- prevHash: string, tenantId: string, atWall: string, userId: string, action: string,
- entity: string, entityId: string, beforeJson: string | null, afterJson: string | null,
-): string {
- const canonical = JSON.stringify([prevHash, tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson])
- return createHash('sha256').update(canonical, 'utf8').digest('hex')
-}
-
-/**
- * Append one audit entry, linked into the tenant's hash chain (H2). The previous entry is the
- * tenant's last-inserted row (SQLite rowid order = insertion order under the single-writer
- * model); verifyAuditChain recomputes in the same order. Called inside the existing commit
- * transactions — the prev-row read sees earlier in-transaction inserts on the same connection,
- * so several audit rows written in one commit chain correctly among themselves.
- */
-export function writeAudit(
- db: DB, tenantId: string, userId: string, action: string,
- entity: string, entityId: string, before?: unknown, after?: unknown,
-): void {
- const prev = db.prepare(
- `SELECT entry_hash FROM audit_log WHERE tenant_id=? ORDER BY rowid DESC LIMIT 1`,
- ).get(tenantId) as { entry_hash: string | null } | undefined
- const prevHash = prev?.entry_hash ?? ''
- const atWall = new Date().toISOString()
- const beforeJson = before === undefined ? null : JSON.stringify(before)
- const afterJson = after === undefined ? null : JSON.stringify(after)
- const entryHash = auditEntryHash(prevHash, tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson)
- db.prepare(
- `INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(uuidv7(), tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson, prevHash, entryHash)
-}
-
-/**
- * Recompute the tenant's audit hash chain and report the first entry that fails (H2). Returns
- * `{ ok: true }` when every row's stored prev_hash matches the running chain AND its entry_hash
- * matches a recompute over its own content; otherwise `{ ok: false, brokenAtId }` names the first
- * broken row. This makes any out-of-band tamper VISIBLE: an UPDATE changes a row's content so its
- * entry_hash no longer recomputes; a DELETE breaks the next row's prev_hash link; a naive INSERT
- * lands with a hash the chain can't reproduce. It is tamper-EVIDENCE, not prevention — an attacker
- * with file write access could re-chain forward (the chain is unsigned); sealing the file is H3.
- */
-export function verifyAuditChain(db: DB, tenantId: string): { ok: boolean; brokenAtId?: string } {
- const rows = db.prepare(
- `SELECT id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash
- FROM audit_log WHERE tenant_id=? ORDER BY rowid ASC`,
- ).all(tenantId) as {
- id: string; at_wall: string; user_id: string; action: string; entity: string
- entity_id: string; before_json: string | null; after_json: string | null
- prev_hash: string | null; entry_hash: string | null
- }[]
- let prevHash = ''
- for (const r of rows) {
- if ((r.prev_hash ?? '') !== prevHash) return { ok: false, brokenAtId: r.id }
- const expected = auditEntryHash(
- prevHash, tenantId, r.at_wall, r.user_id, r.action, r.entity, r.entity_id, r.before_json, r.after_json,
- )
- if (expected !== r.entry_hash) return { ok: false, brokenAtId: r.id }
- prevHash = r.entry_hash
- }
- return { ok: true }
-}
-
-export function listAudit(db: DB, tenantId: string, limit = 100, offset = 0): Record[] {
- return db.prepare(
- `SELECT a.*, u.display_name AS user_name FROM audit_log a
- LEFT JOIN app_user u ON u.id=a.user_id
- WHERE a.tenant_id=? ORDER BY a.at_wall DESC LIMIT ? OFFSET ?`,
- ).all(tenantId, limit, offset) as Record[]
-}
diff --git a/apps/store-server/src/seed.ts b/apps/store-server/src/seed.ts
deleted file mode 100644
index 9b4cbda..0000000
--- a/apps/store-server/src/seed.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { hashPin } from '@sims/auth'
-import { uuidv7 } from '@sims/domain'
-import type { DB } from './db'
-
-/** Idempotent demo seed — real installs get the onboarding wizard instead. */
-export function seedIfEmpty(db: DB): void {
- const has = db.prepare('SELECT COUNT(*) AS n FROM tenant').get() as { n: number }
- if (has.n > 0) return
-
- const now = new Date().toISOString()
- db.prepare(
- `INSERT INTO tenant (id, code, name, gstin, gst_scheme, fy_start_month, created_at)
- VALUES ('t1', 'MEGA', 'MegaMart Retail', '32ABCDE1234F1Z5', 'regular', 4, ?)`,
- ).run(now)
- db.prepare(
- `INSERT INTO store (id, tenant_id, code, name, state_code) VALUES ('s1', 't1', 'ST1', 'MegaMart T.Nagar', '32')`,
- ).run()
- db.prepare(`INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('c1','t1','s1','C1','Counter 1')`).run()
- db.prepare(`INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('c2','t1','s1','C2','Counter 2')`).run()
-
- const user = db.prepare(
- `INSERT INTO app_user (id, tenant_id, username, display_name, roles, store_ids, language, pin_salt, pin_hash, pw_salt, pw_hash)
- VALUES (?, 't1', ?, ?, ?, '"all"', ?, ?, ?, ?, ?)`,
- )
- const mk = (id: string, uname: string, name: string, roles: string[], lang: string, pin?: string, pw?: string) => {
- const p = pin !== undefined ? hashPin(pin) : undefined
- const w = pw !== undefined ? hashPin(pw) : undefined
- user.run(id, uname, name, JSON.stringify(roles), lang, p?.salt ?? null, p?.hash ?? null, w?.salt ?? null, w?.hash ?? null)
- }
- mk('u1', 'ramesh', 'Ramesh', ['cashier'], 'ml', '4728')
- mk('u2', 'suresh', 'Suresh', ['supervisor'], 'ml', '8265')
- mk('u3', 'thomas', 'Thomas', ['owner'], 'en', '9174', 'sims')
- mk('u4', 'divya', 'Divya', ['cashier'], 'ta', '3591')
-
- const tax = db.prepare(
- `INSERT INTO tax_class (class_code, rate_pct_bp, cess_pct_bp, effective_from) VALUES (?, ?, ?, '2017-07-01')`,
- )
- for (const [c, r, x] of [['ZERO', 0, 0], ['GST5', 500, 0], ['GST12', 1200, 0], ['GST18', 1800, 0], ['DEMERIT', 2800, 1200]] as const) {
- tax.run(c, r, x)
- }
-
- const item = db.prepare(
- `INSERT INTO item (id, tenant_id, code, name, hsn, tax_class_code, unit_code, unit_decimals, mrp_paise, sale_price_paise, price_includes_tax, barcodes)
- VALUES (?, 't1', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
- )
- const rows: [string, string, string, string, string, number, number | null, number, string[]][] = [
- ['101', 'Aashirvaad Atta 5kg', '1101', 'ZERO', 'PCS', 0, 28_500, 27_000, ['8901063014357']],
- ['102', 'Tata Salt 1kg', '2501', 'ZERO', 'PCS', 0, 3_000, 2_800, ['8904063202016']],
- ['103', 'Surf Excel 1kg', '3402', 'GST18', 'PCS', 0, 15_000, 14_500, ['8901030704833']],
- ['104', 'Parle-G 800g', '1905', 'GST5', 'PCS', 0, 9_500, 9_000, ['8901063092730']],
- ['105', 'Maggi Noodles 12-pack', '1902', 'GST12', 'PCS', 0, 14_400, 14_000, ['8901058851298']],
- ['106', 'Coca-Cola 1.25L', '2202', 'DEMERIT', 'PCS', 0, 6_500, 6_500, ['8901764012341']],
- ['107', 'Amul Butter 500g', '0405', 'GST12', 'PCS', 0, 28_000, 27_500, ['8901262010337']],
- ['108', 'Carry Bag', '3923', 'GST18', 'PCS', 0, null, 500, []],
- ['42', 'Tomato (loose)', '0702', 'ZERO', 'KG', 3, null, 3_200, []],
- ['43', 'Onion (loose)', '0703', 'ZERO', 'KG', 3, null, 4_500, []],
- ['44', 'Potato (loose)', '0701', 'ZERO', 'KG', 3, null, 2_600, []],
- ]
- for (const [code, name, hsn, cls, unit, dec, mrp, price, barcodes] of rows) {
- item.run(uuidv7(), code, name, hsn, cls, unit, dec, mrp, price, JSON.stringify(barcodes))
- // opening stock so the Stock page has something honest to derive
- db.prepare(
- `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, business_date, created_at_wall)
- SELECT ?, 't1', 's1', id, ?, 'OPENING', ?, ? FROM item WHERE tenant_id='t1' AND code=?`,
- ).run(uuidv7(), unit === 'KG' ? 50 : 120, now.slice(0, 10), now, code)
- }
-
- const party = db.prepare(
- `INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code, credit_limit_paise)
- VALUES (?, 't1', ?, ?, ?, ?, ?, ?, ?)`,
- )
- party.run(uuidv7(), 'CU001', 'Lakshmi', 'customer', '9840012345', null, '32', 500_000)
- party.run(uuidv7(), 'CU002', 'Joseph', 'customer', '9847055210', null, '32', null)
- party.run(uuidv7(), 'SU001', 'HUL Distributor', 'supplier', '9847100001', '32AABCH1234K1Z6', '32', null)
- party.run(uuidv7(), 'SU002', 'Amul Agency', 'supplier', '9847100002', '32AAACA5678L1Z2', '32', null)
-
- const setting = db.prepare(
- `INSERT INTO setting (scope_type, scope_id, key, value) VALUES (?, ?, ?, ?)`,
- )
- setting.run('system', '', 'gst.roundToRupee', 'true')
- setting.run('system', '', 'discount.capBp', '1000')
- setting.run('tenant', 't1', 'discount.capBp', '1500')
- // Cashier quick-add draft guards (C2/C3): the tax class the server forces onto a
- // cashier-created draft (never the client's — no ZERO-tax GST evasion), and the price
- // ceiling above which a draft must go through a supervisor / back office. Tenant rows may
- // override; the resolvers fall back to these same defaults if a row is missing.
- setting.run('system', '', 'catalog.defaultTaxClass', 'GST18')
- setting.run('system', '', 'catalog.draftMaxPricePaise', '500000')
- // GST returns: the B2C-Large threshold (a B2C inter-state invoice ABOVE this is B2CL, else
- // B2CS). Dated config (R10) — the classic ₹2,50,000; a tenant row overrides per period.
- setting.run('system', '', 'gst.b2clThresholdPaise', '25000000')
-
- db.prepare(`INSERT INTO message_catalog (code, channel, lang, template) VALUES ('BILL_ESHARE','whatsapp','en','Hi {name}, your bill of {amount} from {store}. Thank you!')`).run()
-
- ensureBatchTrackingSeed(db)
-}
-
-/**
- * S5 demo batches. Idempotent and safe on ANY db — it runs both on a fresh seed and
- * against the already-populated dev db (where seedIfEmpty is a no-op, so batches must
- * be backfilled here). Marks the two demo perishables batch-tracked, then, if no batch
- * exists yet, splits each item's opening stock into dated lots. Total on-hand is
- * preserved (R7): the per-lot OPENING movements are offset by one negative un-batched
- * OPENING adjustment per item, so re-attributing stock into lots never inflates the
- * derived quantity. One lot per item is deliberately expired-with-stock to exercise the
- * E-1310 gate and the dashboard's expired section.
- */
-export function ensureBatchTrackingSeed(db: DB): void {
- db.prepare(`UPDATE item SET batch_tracked=1 WHERE tenant_id='t1' AND code IN ('105','107')`).run()
- const existing = (db.prepare(`SELECT COUNT(*) AS n FROM batch WHERE tenant_id='t1'`).get() as { n: number }).n
- if (existing > 0) return
-
- const now = new Date()
- const iso = (d: Date): string => d.toISOString().slice(0, 10)
- const plusDays = (days: number): string => {
- const d = new Date(now)
- d.setDate(d.getDate() + days)
- return iso(d)
- }
- // code → lots to create: batch_no, expiry offset in days (negative = already
- // expired), opening qty. Quantities are re-attributed from the item's opening 120.
- const plan: Record = {
- // Amul Butter 500g (₹275): an expired lot WITH stock (proves the block + the
- // expired-with-stock section), a ≤7-day lot, and a ~6-month lot.
- '107': [
- { no: 'B-107X', days: -3, qty: 8 },
- { no: 'B-107A', days: 5, qty: 12 },
- { no: 'B-107B', days: 180, qty: 30 },
- ],
- // Maggi 12-pack (₹140): a ≤7-day lot and a ~6-month lot.
- '105': [
- { no: 'B-105A', days: 4, qty: 10 },
- { no: 'B-105B', days: 175, qty: 25 },
- ],
- }
- const nowIso = now.toISOString()
- const today = iso(now)
- const insBatch = db.prepare(
- `INSERT INTO batch (id, tenant_id, store_id, item_id, batch_no, expiry_date, created_at) VALUES (?, 't1', 's1', ?, ?, ?, ?)`,
- )
- const insMove = db.prepare(
- `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, batch_id, business_date, created_at_wall)
- VALUES (?, 't1', 's1', ?, ?, 'OPENING', ?, ?, ?)`,
- )
- db.transaction(() => {
- for (const [code, lots] of Object.entries(plan)) {
- const item = db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string } | undefined
- if (item === undefined) continue
- let batched = 0
- for (const lot of lots) {
- const batchId = uuidv7()
- insBatch.run(batchId, item.id, lot.no, plusDays(lot.days), nowIso)
- insMove.run(uuidv7(), item.id, lot.qty, batchId, today, nowIso)
- batched += lot.qty
- }
- // Offsetting un-batched OPENING adjustment: total on-hand stays exactly what it
- // was (R7) — we split existing stock into lots, we never conjure new stock.
- insMove.run(uuidv7(), item.id, -batched, null, today, nowIso)
- }
- })()
-}
diff --git a/apps/store-server/src/server.ts b/apps/store-server/src/server.ts
deleted file mode 100644
index 5279276..0000000
--- a/apps/store-server/src/server.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import express from 'express'
-import path from 'node:path'
-import fs from 'node:fs'
-import https from 'node:https'
-import helmet from 'helmet'
-import { openDb } from './db'
-import { ensureBatchTrackingSeed, seedIfEmpty } from './seed'
-import { apiRouter } from './api'
-
-/**
- * The store server (02-ARCHITECTURE topology, web-first per D2 revision):
- * one Node process on the store's server machine —
- * / → back office web app
- * /pos → POS web app (counters browse here; zero installs)
- * /api/print → ESC/POS bytes to a LAN network printer (authenticated + allowlisted, H1)
- * /api/health → monitoring handshake
- * The Oracle 12c repository layer (D12) and the outbox sync agent (D14) land here next.
- */
-const app = express()
-app.disable('x-powered-by') // no framework banner (M14)
-
-// M13 — TLS is available but NOT forced in dev. Production MUST supply a cert + key via
-// SIMS_TLS_CERT / SIMS_TLS_KEY (paths); when both are set we serve HTTPS, otherwise plain
-// HTTP on :5181 so the existing dev/browser/verification flow is unchanged. Once prod is
-// HTTPS the bearer token no longer rides the LAN in cleartext (closes M13) and the
-// cache-first service worker can no longer be MITM-poisoned over HTTP (closes M15).
-// Make a throwaway dev cert (self-signed, 1 year) with:
-// openssl req -x509 -newkey rsa:2048 -nodes -keyout dev-key.pem -out dev-cert.pem -days 365 -subj "/CN=localhost"
-// then start with: SIMS_TLS_CERT=dev-cert.pem SIMS_TLS_KEY=dev-key.pem npm start
-const TLS_CERT = process.env['SIMS_TLS_CERT']
-const TLS_KEY = process.env['SIMS_TLS_KEY']
-const tlsEnabled = TLS_CERT !== undefined && TLS_CERT !== '' && TLS_KEY !== undefined && TLS_KEY !== ''
-
-// M14 — security headers via helmet. The CSP is tuned for the built SPAs: they load their own
-// module script + stylesheet from 'self', use inline style attributes (React `style={{}}`),
-// open invoice/label tabs as blob: documents (which inherit this policy) whose ONLY inline
-// script is a `window.print()` button — permitted by a single hash under 'unsafe-hashes'
-// rather than opening script-src up to 'unsafe-inline' — and embed fonts as data:. The POS
-// service worker + assets are all 'self'. `upgrade-insecure-requests` is emitted ONLY under
-// TLS, so the dev HTTP instance is never told to upgrade to a port that serves no HTTPS.
-app.use(helmet({
- contentSecurityPolicy: {
- useDefaults: false,
- directives: {
- defaultSrc: ["'self'"],
- baseUri: ["'self'"],
- objectSrc: ["'none'"],
- frameAncestors: ["'none'"],
- formAction: ["'self'"],
- imgSrc: ["'self'", 'data:', 'blob:'],
- fontSrc: ["'self'", 'data:'],
- styleSrc: ["'self'", "'unsafe-inline'"],
- scriptSrc: ["'self'", "'unsafe-hashes'", "'sha256-MguIPR6qNR8D3B+eAlK+bIRTZe8t3wkOY4B/56Me9FU='"],
- connectSrc: ["'self'"],
- workerSrc: ["'self'"],
- frameSrc: ["'self'", 'blob:'],
- ...(tlsEnabled ? { upgradeInsecureRequests: [] } : {}),
- },
- },
- // helmet only offers X-Frame-Options: SAMEORIGIN; the review asks for DENY, so set it below.
- frameguard: false,
- // HSTS only means anything over TLS; emitting it on the dev HTTP instance would be a lie.
- hsts: tlsEnabled ? { maxAge: 15_552_000, includeSubDomains: true } : false,
- crossOriginEmbedderPolicy: false,
-}))
-app.use((_req, res, next) => { res.setHeader('X-Frame-Options', 'DENY'); next() })
-
-app.use(express.json({ limit: '1mb' }))
-
-const POS_DIST = path.resolve(__dirname, '../../pos/dist')
-const BACKOFFICE_DIST = path.resolve(__dirname, '../../backoffice/dist')
-
-app.use('/pos', express.static(POS_DIST))
-app.use('/', express.static(BACKOFFICE_DIST))
-
-app.get('/api/health', (_req, res) => {
- res.json({ ok: true, service: 'sims-store-server', version: '0.2.0' })
-})
-
-const db = openDb()
-// M10 — the demo seed creates a known-credential owner (thomas / PIN 9174 / pw 'sims') and the
-// rest of the MegaMart demo. That must NEVER auto-materialise on a real, empty production DB, so
-// seeding runs ONLY when explicitly enabled with SIMS_SEED_DEMO=1. The dev start script sets it
-// (see package.json `start` → dev-start.mjs), so :5181 still seeds; a production deployment runs
-// the built server directly with the flag unset, leaving an empty DB empty for the onboarding
-// wizard. ensureBatchTrackingSeed (which backfills demo batches, hardcoded to tenant 't1') is
-// gated with it — on a non-demo DB there is no 't1' data for it to touch anyway.
-if (process.env['SIMS_SEED_DEMO'] === '1') {
- seedIfEmpty(db)
- // S5: backfill batch-tracking + demo batches on the already-populated dev DB, where
- // seedIfEmpty short-circuits. Idempotent, so it is a no-op once the batches exist.
- ensureBatchTrackingSeed(db)
-} else {
- console.warn('[store-server] SIMS_SEED_DEMO is not set — skipping demo seed (an empty DB stays empty; set SIMS_SEED_DEMO=1 for the dev demo data).')
-}
-// The data API — including the now-authenticated + allowlisted /api/print relay (H1),
-// /api/auth/logout (M4) and the rate-limited /api/auth/* routes — lives in apiRouter.
-app.use('/api', apiRouter(db))
-
-/**
- * Generic error handler (M7): validators and repositories throw errors tagged with a safe
- * `status` + `code`; anything untagged is treated as an internal fault and answered with a
- * generic message. A stack trace or install path is NEVER sent to the client — unexpected
- * errors are logged server-side only. Must be the LAST middleware and take 4 args.
- */
-app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
- const e = (err ?? {}) as { status?: number; code?: string; message?: string }
- const status = typeof e.status === 'number' ? e.status : 500
- if (status >= 500) console.error('[store-server] unhandled error:', err)
- if (res.headersSent) return
- res.status(status).json({
- error: status >= 500 ? 'Something went wrong (E-5000)' : (e.message ?? 'Bad request'),
- code: e.code ?? (status >= 500 ? 'E-5000' : 'E-1000'),
- })
-})
-
-// 8080–8085 sit in Windows' Hyper-V excluded port ranges on shop PCs too — avoid them.
-const PORT = Number(process.env['PORT'] ?? 5181)
-const banner = (proto: string) => () => {
- console.log(`SiMS store-server listening on ${proto}://localhost:${PORT}`)
- console.log(` Back office: ${proto}://localhost:${PORT}/`)
- console.log(` POS: ${proto}://localhost:${PORT}/pos/`)
-}
-if (tlsEnabled) {
- const creds = { cert: fs.readFileSync(TLS_CERT!), key: fs.readFileSync(TLS_KEY!) }
- https.createServer(creds, app).listen(PORT, banner('https'))
-} else {
- app.listen(PORT, banner('http'))
-}
diff --git a/apps/store-server/src/session-policy.ts b/apps/store-server/src/session-policy.ts
deleted file mode 100644
index 31551bc..0000000
--- a/apps/store-server/src/session-policy.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Session lifetime policy (red-team M4). Sessions were valid until the process restarted —
- * a leaked bearer token never expired and a fired employee could not be cut off. Every
- * session now carries a creation time and a last-seen time; `requireAuth` evicts it once it
- * passes either bound. Pure + clock-injectable so the check unit-tests without waiting.
- */
-export interface SessionTtl {
- /** Hard cap from login, regardless of activity. */
- absoluteMs: number
- /** Idle cap since the last authenticated request. */
- idleMs: number
-}
-
-export interface SessionTiming {
- createdMs: number
- lastSeenMs: number
-}
-
-/** 12-hour absolute lifetime, 30-minute idle timeout — a trading shift with a lunch gap. */
-export const DEFAULT_SESSION_TTL: SessionTtl = {
- absoluteMs: 12 * 60 * 60 * 1000,
- idleMs: 30 * 60 * 1000,
-}
-
-/** True once a session has outlived its absolute lifetime OR sat idle past the idle timeout. */
-export function isSessionExpired(s: SessionTiming, now: number, ttl: SessionTtl): boolean {
- if (now - s.createdMs >= ttl.absoluteMs) return true
- if (now - s.lastSeenMs >= ttl.idleMs) return true
- return false
-}
-
-/** Read TTL overrides from env (ms), else the defaults. Lets prod/tests tighten without code. */
-export function sessionTtlFromEnv(env: NodeJS.ProcessEnv = process.env): SessionTtl {
- const abs = Number(env['SIMS_SESSION_ABS_MS'])
- const idle = Number(env['SIMS_SESSION_IDLE_MS'])
- return {
- absoluteMs: Number.isFinite(abs) && abs > 0 ? abs : DEFAULT_SESSION_TTL.absoluteMs,
- idleMs: Number.isFinite(idle) && idle > 0 ? idle : DEFAULT_SESSION_TTL.idleMs,
- }
-}
diff --git a/apps/store-server/src/validate.ts b/apps/store-server/src/validate.ts
deleted file mode 100644
index 07c17dc..0000000
--- a/apps/store-server/src/validate.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * Hand-rolled request validators (SEC / red-team M6, M7, M8, M12, H6). No new runtime
- * deps by design (16-PROJECT-RULES: keep the slice lean) — small guards that each throw
- * a `{ status: 400, code }` error with a SAFE, generic message. The Express error handler
- * (server.ts) turns any un-tagged throw into a generic 500 with no stack, so a wrong type
- * can never leak an install path again (M7).
- */
-
-export class ValidationError extends Error {
- status = 400
- code: string
- constructor(message: string, code: string) {
- super(message)
- this.name = 'ValidationError'
- this.code = code
- }
-}
-
-const fail = (message: string, code = 'E-1001'): never => {
- throw new ValidationError(message, code)
-}
-
-export function assertString(v: unknown, field: string, code = 'E-1001'): string {
- if (typeof v !== 'string' || v === '') fail(`${field} is required`, code)
- return v as string
-}
-
-export function optString(v: unknown, field: string, code = 'E-1001'): string | undefined {
- if (v === undefined || v === null) return undefined
- if (typeof v !== 'string') fail(`${field} must be text`, code)
- return v as string
-}
-
-export function assertInt(v: unknown, field: string, code = 'E-1001'): number {
- if (typeof v !== 'number' || !Number.isInteger(v)) fail(`${field} must be a whole number`, code)
- return v as number
-}
-
-export function assertNonNegInt(v: unknown, field: string, code = 'E-1001'): number {
- const n = assertInt(v, field, code)
- if (n < 0) fail(`${field} must not be negative`, code)
- return n
-}
-
-export function assertPositiveInt(v: unknown, field: string, code = 'E-1001'): number {
- const n = assertInt(v, field, code)
- if (n <= 0) fail(`${field} must be positive`, code)
- return n
-}
-
-/** A finite, positive quantity, clamped well under 2^53 so ledger math never loses precision (M8). */
-export function assertQty(v: unknown, field: string, code = 'E-1001'): number {
- if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) fail(`${field} must be a positive number`, code)
- if ((v as number) > 1e12) fail(`${field} is implausibly large`, code)
- return v as number
-}
-
-/** A paise amount: a finite integer within a sane ledger range (no NaN, no 2^53 blow-ups). */
-export function assertPaise(v: unknown, field: string, code = 'E-1001'): number {
- const n = assertInt(v, field, code)
- if (n < 0) fail(`${field} must not be negative`, code)
- if (n > 1e15) fail(`${field} is out of range`, code)
- return n
-}
-
-/**
- * An ISO YYYY-MM-DD business date within a sane window (H6): a real calendar date, not
- * before 2015, not after tomorrow. Rejects "not-a-date", "2050-07-10", and NaN-FY inputs.
- */
-export function assertBusinessDate(v: unknown, field = 'businessDate', code = 'E-1106'): string {
- const s = assertString(v, field, code)
- if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) fail(`${field} must be an ISO date (YYYY-MM-DD)`, code)
- const t = Date.parse(`${s}T00:00:00Z`)
- if (Number.isNaN(t)) fail(`${field} is not a real date`, code)
- // Round-trip to reject impossible days like 2026-02-31 that Date.parse would roll over.
- if (new Date(t).toISOString().slice(0, 10) !== s) fail(`${field} is not a real date`, code)
- const tomorrow = Date.now() + 36 * 60 * 60 * 1000 // today + ~1 day of slack for TZ
- if (t > tomorrow) fail(`${field} is in the future`, code)
- if (t < Date.parse('2015-01-01T00:00:00Z')) fail(`${field} is implausibly old`, code)
- return s
-}
-
-/**
- * A GST return period 'YYYY-MM' within a sane window (a real month, 2017-07..next month). GST
- * returns are filed monthly, so the period is the unit of a GSTR-1/3B run.
- */
-export function assertPeriod(v: unknown, field = 'period', code = 'E-1330'): string {
- const s = assertString(v, field, code)
- if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(s)) fail(`${field} must be a month (YYYY-MM)`, code)
- // Not before GST (July 2017); not more than ~one month ahead of today.
- if (s < '2017-07') fail(`${field} is before GST commenced`, code)
- const now = new Date()
- const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString().slice(0, 7)
- if (s > next) fail(`${field} is in the future`, code)
- return s
-}
-
-export function assertArray(v: unknown, field: string, max: number, code = 'E-1001'): T[] {
- if (!Array.isArray(v)) fail(`${field} must be a list`, code)
- const a = v as T[]
- if (a.length === 0) fail(`${field} must not be empty`, code)
- if (a.length > max) fail(`${field} has too many entries (max ${max})`, code)
- return a
-}
-
-/** Clamp a client-supplied limit to [1, max] (M6/M13): NaN/negative/huge never dump a table. */
-export function clampLimit(raw: unknown, def: number, max = 1000): number {
- const n = Number(raw)
- if (!Number.isFinite(n)) return def
- return Math.min(Math.max(Math.floor(n), 1), max)
-}
-
-/** Clamp a client-supplied offset to >= 0 (M6): NaN/negative floor to 0. */
-export function clampOffset(raw: unknown): number {
- const n = Number(raw)
- if (!Number.isFinite(n) || n < 0) return 0
- return Math.floor(n)
-}
-
-export const PAYMENT_MODES = new Set(['CASH', 'UPI', 'CARD', 'KHATA'])
diff --git a/apps/store-server/test/anchoring.test.ts b/apps/store-server/test/anchoring.test.ts
deleted file mode 100644
index a14d42d..0000000
--- a/apps/store-server/test/anchoring.test.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { openDb, type DB } from '../src/db'
-import { seedIfEmpty } from '../src/seed'
-import { commitBill } from '../src/repos'
-
-/**
- * Item-master anchoring (red-team C2, C3, H5, H7 + MRP/unit guards). commitBill is the
- * trust boundary: it rebuilds every line from OUR item row and treats any price the client
- * couldn't have gotten from the master as a deviation that needs a bound token or the
- * committer's own authority. Hermetic in-memory DB; seed items: 107 Amul Butter (master
- * sale 27500, MRP 28000, GST12, whole unit), 42 Tomato loose (KG, 3 decimals).
- */
-function fresh(): DB {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- return db
-}
-const itemId = (db: DB, code: string): string =>
- (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
-const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
-
-const line = (id: string, over: Partial<{ unitPricePaise: number; taxClassCode: string; qty: number }> = {}) => ({
- itemId: id, name: 'Amul Butter 500g', hsn: '0405', qty: over.qty ?? 1, unitCode: 'PCS',
- unitPricePaise: over.unitPricePaise ?? 27_500, priceIncludesTax: true, taxClassCode: over.taxClassCode ?? 'GST12',
-})
-const bill = (cashierId: string, l: ReturnType, payable: number): Parameters[1] => ({
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId, shiftId: 'sh1', businessDate: '2026-07-10',
- lines: [l], payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
-})
-
-describe('item-master anchoring', () => {
- it('commits a normal bill at the master price (happy path)', () => {
- const db = fresh()
- const out = commitBill(db, bill('u1', line(itemId(db, '107')), 27_500))
- expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9); business date 2026-07-10 → FY 2026-27
- expect(billCount(db)).toBe(1)
- })
-
- it('C2: a cashier underpricing a ₹275 item to ₹1 without approval is rejected (4xx)', () => {
- const db = fresh()
- const err = (() => { try { commitBill(db, bill('u1', line(itemId(db, '107'), { unitPricePaise: 100 }), 100)); return undefined } catch (e) { return e as Error & { status?: number } } })()
- expect(err).toBeDefined()
- expect(err!.status).toBeGreaterThanOrEqual(400)
- expect(err!.status).toBeLessThan(500)
- expect(billCount(db)).toBe(0)
- })
-
- it('C3: a forged ZERO tax class is ignored — the bill is taxed at the master GST12 rate', () => {
- const db = fresh()
- // Inclusive price, so the ₹275 total is identical under ZERO or GST12; only the split
- // differs. The server anchors GST12, so the committed bill still carries CGST+SGST.
- commitBill(db, bill('u1', line(itemId(db, '107'), { taxClassCode: 'ZERO' }), 27_500))
- const b = db.prepare(`SELECT cgst_paise, sgst_paise, taxable_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number; taxable_paise: number }
- expect(b.cgst_paise + b.sgst_paise).toBeGreaterThan(0)
- expect(b.taxable_paise).toBeLessThan(27_500) // some of the sticker price is GST, not taxable base
- })
-
- it('H5: a line for a non-existent item is rejected, nothing committed', () => {
- const db = fresh()
- expect(() => commitBill(db, bill('u1', line('DOES-NOT-EXIST'), 27_500))).toThrow(/E-1105|catalogue/i)
- expect(billCount(db)).toBe(0)
- })
-
- it('H7: an owner self-authorises a price change and it is audited from their identity', () => {
- const db = fresh()
- commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 25_000 }), 25_000)) // u3 = owner
- const audit = db.prepare(`SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'`).get() as { after_json: string } | undefined
- expect(audit).toBeDefined()
- const after = JSON.parse(audit!.after_json) as { approvedByUserId: string; selfAuthorized?: boolean }
- expect(after.approvedByUserId).toBe('u3')
- expect(after.selfAuthorized).toBe(true)
- expect(billCount(db)).toBe(1)
- })
-
- it('hard-blocks a price above MRP even for an owner (E-1301)', () => {
- const db = fresh()
- expect(() => commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 30_000 }), 30_000))).toThrow(/E-1301|MRP/i)
- expect(billCount(db)).toBe(0)
- })
-
- it('rejects a fractional quantity on a whole-unit item (E-1104)', () => {
- const db = fresh()
- expect(() => commitBill(db, bill('u1', line(itemId(db, '107'), { qty: 1.5 }), 41_300))).toThrow(/E-1104|whole/i)
- expect(billCount(db)).toBe(0)
- })
-})
diff --git a/apps/store-server/test/audit-chain.test.ts b/apps/store-server/test/audit-chain.test.ts
deleted file mode 100644
index 9c83e6d..0000000
--- a/apps/store-server/test/audit-chain.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { openDb, type DB } from '../src/db'
-import { seedIfEmpty } from '../src/seed'
-import { commitBill, verifyAuditChain, writeAudit } from '../src/repos'
-
-/**
- * H2 (red-team): the audit log is a per-tenant hash chain. Each row's entry_hash covers the
- * previous entry's hash plus its own content, so any out-of-band UPDATE/DELETE/INSERT to the
- * table breaks the chain, which verifyAuditChain / GET /api/audit/verify recomputes. This is
- * tamper-EVIDENCE, not prevention (the SQLite file is still writable — sealing it is H3). These
- * tests build a chain, prove it verifies, then tamper directly via better-sqlite3 and re-verify.
- */
-function fresh(): DB {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- return db
-}
-const itemId = (db: DB, code: string): string =>
- (db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code=?").get(code) as { id: string }).id
-interface AuditRow { id: string; prev_hash: string | null; entry_hash: string | null }
-const auditRows = (db: DB): AuditRow[] =>
- db.prepare("SELECT id, prev_hash, entry_hash FROM audit_log WHERE tenant_id='t1' ORDER BY rowid").all() as AuditRow[]
-
-describe('H2 — tamper-evident audit hash chain', () => {
- it('a genuine sequence verifies, and each row links to the previous (genesis = "")', () => {
- const db = fresh()
- // a real commit writes a BILL_COMMIT row inside its transaction...
- commitBill(db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- businessDate: '2026-07-10',
- lines: [{ itemId: itemId(db, '108'), name: 'Carry Bag', hsn: '3923', qty: 1, unitCode: 'PCS', unitPricePaise: 500, priceIncludesTax: true, taxClassCode: 'GST18' }],
- payments: [{ mode: 'CASH', amountPaise: 500 }], clientPayablePaise: 500,
- })
- // ...and a couple of plain rows chain on after it
- writeAudit(db, 't1', 'u1', 'TEST_A', 'x', 'x1')
- writeAudit(db, 't1', 'u3', 'TEST_B', 'x', 'x2', { a: 1 }, { b: 2 })
- const rows = auditRows(db)
- expect(rows.length).toBeGreaterThanOrEqual(3)
- expect(rows[0]!.prev_hash).toBe('') // genesis
- for (let i = 1; i < rows.length; i++) expect(rows[i]!.prev_hash).toBe(rows[i - 1]!.entry_hash)
- expect(verifyAuditChain(db, 't1')).toEqual({ ok: true })
- })
-
- it('detects an out-of-band UPDATE, pointing at the changed row', () => {
- const db = fresh()
- writeAudit(db, 't1', 'u1', 'A', 'e', '1')
- writeAudit(db, 't1', 'u1', 'B', 'e', '2')
- writeAudit(db, 't1', 'u1', 'C', 'e', '3')
- expect(verifyAuditChain(db, 't1').ok).toBe(true)
- const target = auditRows(db)[1]!.id
- db.prepare('UPDATE audit_log SET after_json=? WHERE id=?').run(JSON.stringify({ forged: true }), target)
- const res = verifyAuditChain(db, 't1')
- expect(res.ok).toBe(false)
- expect(res.brokenAtId).toBe(target)
- })
-
- it('detects an out-of-band DELETE, pointing at the row after the gap', () => {
- const db = fresh()
- writeAudit(db, 't1', 'u1', 'A', 'e', '1')
- writeAudit(db, 't1', 'u1', 'B', 'e', '2')
- writeAudit(db, 't1', 'u1', 'C', 'e', '3')
- const rows = auditRows(db)
- db.prepare('DELETE FROM audit_log WHERE id=?').run(rows[1]!.id) // remove the middle row
- const res = verifyAuditChain(db, 't1')
- expect(res.ok).toBe(false)
- expect(res.brokenAtId).toBe(rows[2]!.id) // its prev_hash no longer matches the running chain
- })
-
- it('detects a forged INSERT appended out-of-band', () => {
- const db = fresh()
- writeAudit(db, 't1', 'u1', 'A', 'e', '1')
- db.prepare(
- `INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash)
- VALUES ('forged-id', 't1', ?, 'attacker', 'FORGED', 'e', '9', NULL, ?, 'deadbeef', 'cafebabe')`,
- ).run(new Date().toISOString(), JSON.stringify({ evil: true }))
- const res = verifyAuditChain(db, 't1')
- expect(res.ok).toBe(false)
- expect(res.brokenAtId).toBe('forged-id')
- })
-
- it('scopes per tenant — one tenant’s tamper does not fail another tenant’s chain', () => {
- const db = fresh()
- db.prepare("INSERT INTO tenant (id, code, name, gst_scheme, fy_start_month, created_at) VALUES ('t2','T2','Other','regular',4,?)").run(new Date().toISOString())
- writeAudit(db, 't1', 'u1', 'A', 'e', '1')
- writeAudit(db, 't2', 'z9', 'A', 'e', '1')
- writeAudit(db, 't2', 'z9', 'B', 'e', '2')
- // tamper only t1
- db.prepare('UPDATE audit_log SET action=? WHERE tenant_id=?').run('HACKED', 't1')
- expect(verifyAuditChain(db, 't1').ok).toBe(false)
- expect(verifyAuditChain(db, 't2')).toEqual({ ok: true })
- })
-})
diff --git a/apps/store-server/test/batch.test.ts b/apps/store-server/test/batch.test.ts
deleted file mode 100644
index 69a5858..0000000
--- a/apps/store-server/test/batch.test.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { openDb, type DB } from '../src/db'
-import { ensureBatchTrackingSeed, seedIfEmpty } from '../src/seed'
-import { batchesForStore, commitBill, createOverrideApproval, expiryView, stockView } from '../src/repos'
-import { commitPurchase } from '../src/repos-purchase'
-
-/**
- * S5 batch/expiry — server layer against a hermetic in-memory DB. seedIfEmpty already
- * calls ensureBatchTrackingSeed, so the demo perishables (105 Maggi, 107 Amul Butter)
- * arrive batch-tracked with dated lots and an on-hand-preserving offset.
- */
-function fresh(): DB {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- return db
-}
-const itemId = (db: DB, code: string): string =>
- (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
-const onHand = (db: DB, code: string): number =>
- Number((stockView(db, 't1', 's1').find((r) => r['code'] === code) as { on_hand: number }).on_hand)
-
-describe('S5 seed — batch tracking + preserved totals', () => {
- it('marks Amul Butter (107) and Maggi (105) batch-tracked', () => {
- const db = fresh()
- for (const code of ['105', '107']) {
- const it = db.prepare(`SELECT batch_tracked FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { batch_tracked: number }
- expect(it.batch_tracked).toBe(1)
- }
- })
-
- it('splits opening stock into lots WITHOUT changing total on-hand', () => {
- const db = fresh()
- // seed opening was 120 each; the lot movements are offset by a negative un-batched OPENING.
- expect(onHand(db, '107')).toBe(120)
- expect(onHand(db, '105')).toBe(120)
- // the lots themselves carry the re-attributed quantities
- const batches = batchesForStore(db, 't1', 's1')
- const sum = batches.filter((b) => b['item_id'] === itemId(db, '107')).reduce((a, b) => a + Number(b['on_hand']), 0)
- expect(sum).toBe(8 + 12 + 30)
- })
-
- it('is idempotent — a second call adds no batches', () => {
- const db = fresh()
- const before = batchesForStore(db, 't1', 's1').length
- ensureBatchTrackingSeed(db)
- expect(batchesForStore(db, 't1', 's1').length).toBe(before)
- })
-
- it('exposes an expired-with-stock lot and near-expiry lots to the dashboard feed', () => {
- const db = fresh()
- const today = new Date().toISOString().slice(0, 10)
- const rows = expiryView(db, 't1', 's1')
- const expired = rows.filter((r) => r['expiry_date'] !== null && String(r['expiry_date']) < today)
- expect(expired.length).toBeGreaterThan(0)
- // value at sale price is derivable: Amul 107 expired lot = 8 × ₹275
- const amulExpired = expired.find((r) => r['code'] === '107')!
- expect(Number(amulExpired['on_hand']) * Number(amulExpired['sale_price_paise'])).toBe(8 * 27_500)
- })
-})
-
-describe('S5 purchase capture — batch upsert + stock IN', () => {
- it('creates the batch, attributes the IN movement to it, and re-purchase reuses the same lot', () => {
- const db = fresh()
- const amul = itemId(db, '107')
- const supplier = (db.prepare(`SELECT id FROM party WHERE tenant_id='t1' AND code='SU002'`).get() as { id: string }).id
- const before = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul).length
-
- commitPurchase(db, {
- tenantId: 't1', storeId: 's1', userId: 'u3', supplierId: supplier,
- invoiceNo: 'AA-900', invoiceDate: '2026-07-10', businessDate: '2026-07-10',
- lines: [{ itemId: amul, name: 'Amul Butter 500g', qty: 20, unitCostPaise: 24_000, taxRateBp: 1_200, batchNo: 'B-FRESH', expiryDate: '2026-12-01' }],
- clientTotalPaise: 20 * 24_000 + Math.round(20 * 24_000 * 1_200 / 10_000),
- })
- const after = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul)
- expect(after.length).toBe(before + 1)
- const fresh1 = after.find((b) => b['batch_no'] === 'B-FRESH')!
- expect(Number(fresh1['on_hand'])).toBe(20)
-
- // a second invoice for the same batch_no upserts (no duplicate lot), on-hand adds up
- commitPurchase(db, {
- tenantId: 't1', storeId: 's1', userId: 'u3', supplierId: supplier,
- invoiceNo: 'AA-901', invoiceDate: '2026-07-10', businessDate: '2026-07-10',
- lines: [{ itemId: amul, name: 'Amul Butter 500g', qty: 5, unitCostPaise: 24_000, taxRateBp: 1_200, batchNo: 'B-FRESH', expiryDate: '2026-12-01' }],
- clientTotalPaise: 5 * 24_000 + Math.round(5 * 24_000 * 1_200 / 10_000),
- })
- const reused = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul && b['batch_no'] === 'B-FRESH')
- expect(reused.length).toBe(1)
- expect(Number(reused[0]!['on_hand'])).toBe(25)
- })
-})
-
-describe('S5 sale — batch-attributed movement + belongs validation', () => {
- const line = (id: string, batchId?: string) => ({
- itemId: id, name: 'Amul Butter 500g', hsn: '0405', qty: 2, unitCode: 'PCS',
- unitPricePaise: 27_500, priceIncludesTax: true, taxClassCode: 'GST12',
- ...(batchId !== undefined ? { batchId } : {}),
- })
-
- it('decrements the picked batch, not the item at large', () => {
- const db = fresh()
- const amul = itemId(db, '107')
- const far = batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-107B')!
- const farId = String(far['id'])
- const before = Number(far['on_hand'])
- commitBill(db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- businessDate: '2026-07-10', lines: [line(amul, farId)],
- payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000,
- })
- const after = batchesForStore(db, 't1', 's1').find((b) => b['id'] === farId)!
- expect(Number(after['on_hand'])).toBe(before - 2)
- })
-
- it('rejects a sale whose batch belongs to a different item (E-1311) — nothing committed', () => {
- const db = fresh()
- const amul = itemId(db, '107')
- const maggiBatch = String(batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-105A')!['id'])
- const billCount = () => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
- expect(() => commitBill(db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- businessDate: '2026-07-10', lines: [line(amul, maggiBatch)],
- payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000,
- })).toThrow(/E-1311/)
- expect(billCount()).toBe(0)
- })
-
- it('audits a BATCH_OVERRIDE when an expired-batch sale is approved', () => {
- const db = fresh()
- const amul = itemId(db, '107')
- const expired = String(batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-107X')!['id'])
- const approvalId = createOverrideApproval(db, 't1', 'u2', { itemId: amul, kind: 'batch', beforePaise: 0, afterPaise: 0 }) // Suresh
- commitBill(db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- businessDate: '2026-07-10', lines: [line(amul, expired)],
- payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000,
- overrides: [{ itemId: amul, kind: 'batch', before: 0, after: 0, approvalId }],
- })
- const audit = db.prepare(`SELECT after_json FROM audit_log WHERE action='BATCH_OVERRIDE'`).get() as { after_json: string } | undefined
- expect(audit).toBeDefined()
- expect((JSON.parse(audit!.after_json) as { approvedBy: string }).approvedBy).toBe('Suresh')
- })
-})
diff --git a/apps/store-server/test/draft-guard.test.ts b/apps/store-server/test/draft-guard.test.ts
deleted file mode 100644
index 8be4bd5..0000000
--- a/apps/store-server/test/draft-guard.test.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import express from 'express'
-import type { AddressInfo } from 'node:net'
-import type { Server } from 'node:http'
-import { openDb, type DB } from '../src/db'
-import { seedIfEmpty } from '../src/seed'
-import { apiRouter } from '../src/api'
-import { commitBill, createItem } from '../src/repos'
-
-/**
- * Cashier draft-item quick-add guards (red-team follow-up: the draft path reproduced C2/C3).
- * A cashier (BILL_CREATE, no MASTER_EDIT) could POST /items status:'draft' with an arbitrary
- * price AND an arbitrary taxClassCode, then bill it with the server anchoring to that just-
- * authored master — arbitrary price + GST evasion (ZERO tax on a taxable good) with no
- * deviation audit row. These tests drive REAL HTTP against a mounted apiRouter (the exact
- * surface of the hole) plus commitBill directly for the audit-row assertion.
- *
- * Seed users: u1 Ramesh/cashier (PIN 4728), u4 Divya/cashier (PIN 3591), u3 Thomas/owner
- * (PIN 9174, MASTER_EDIT). Store s1, counter c2. Surf Excel (103) master sale 14500, GST18.
- */
-
-interface Harness { db: DB; url: string; server: Server }
-
-async function start(): Promise {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- const app = express()
- app.use(express.json())
- app.use('/api', apiRouter(db))
- // Mirror server.ts's generic error handler so tagged throws surface as { error, code }.
- app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
- const e = (err ?? {}) as { status?: number; code?: string; message?: string }
- const status = typeof e.status === 'number' ? e.status : 500
- if (res.headersSent) return
- res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
- })
- const server: Server = await new Promise((resolve) => {
- const s = app.listen(0, () => resolve(s))
- })
- const port = (server.address() as AddressInfo).port
- return { db, url: `http://127.0.0.1:${port}`, server }
-}
-
-async function login(url: string, userId: string, pin: string): Promise {
- const res = await fetch(`${url}/api/auth/pin`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ userId, pin }),
- })
- const body = (await res.json()) as { token?: string }
- if (body.token === undefined) throw new Error(`login failed for ${userId}`)
- return body.token
-}
-
-const post = (url: string, path: string, token: string, body: unknown) =>
- fetch(`${url}${path}`, {
- method: 'POST',
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
- body: JSON.stringify(body),
- })
-
-const get = (url: string, path: string, token: string) =>
- fetch(`${url}${path}`, { headers: { authorization: `Bearer ${token}` } })
-
-const itemIdOf = (db: DB, code: string): string =>
- (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
-
-const sellLine = (itemId: string, price: number) =>
- ({ itemId, name: 'x', hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: price, priceIncludesTax: true, taxClassCode: 'GST18' })
-
-let h: Harness
-beforeEach(async () => { h = await start() })
-afterEach(() => { h.server.close() })
-
-describe('cashier draft tax-class forcing (C3)', () => {
- it('IGNORES a cashier-chosen ZERO tax class and assigns the store default (GST18)', async () => {
- const token = await login(h.url, 'u1', '4728')
- const res = await post(h.url, '/api/items', token, {
- code: 'RT-WHISKEY', name: 'Redteam Whiskey', taxClassCode: 'ZERO',
- salePricePaise: 200_000, status: 'draft',
- })
- expect(res.status).toBe(200)
- const item = (await res.json()) as { taxClassCode: string; status: string }
- expect(item.taxClassCode).toBe('GST18') // server default, NOT the client's ZERO
- expect(item.status).toBe('draft')
- })
-
- it('lets a MASTER_EDIT role (owner) keep its chosen tax class', async () => {
- const token = await login(h.url, 'u3', '9174') // owner
- const res = await post(h.url, '/api/items', token, {
- code: 'OWN-ZERO', name: 'Owner Zero Good', taxClassCode: 'ZERO',
- salePricePaise: 5_000, status: 'draft',
- })
- expect(res.status).toBe(200)
- const item = (await res.json()) as { taxClassCode: string }
- expect(item.taxClassCode).toBe('ZERO') // owner's explicit choice honoured
- })
-
- it('a ZERO-forged draft, once billed, is taxed at the forced GST18 (GST evasion closed)', async () => {
- const token = await login(h.url, 'u1', '4728')
- const created = await (await post(h.url, '/api/items', token, {
- code: 'RT-TAXED', name: 'Redteam Taxed Good', taxClassCode: 'ZERO',
- salePricePaise: 10_000, status: 'draft',
- })).json() as { id: string }
- const bill = await post(h.url, '/api/bills', token, {
- storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-13',
- lines: [sellLine(created.id, 10_000)],
- payments: [{ mode: 'CASH', amountPaise: 10_000 }], clientPayablePaise: 10_000,
- })
- expect(bill.status).toBe(200)
- const row = h.db.prepare(`SELECT cgst_paise, sgst_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number }
- expect(row.cgst_paise + row.sgst_paise).toBeGreaterThan(0) // real GST charged, not ₹0
- })
-})
-
-describe('cashier draft price cap (C2 bound)', () => {
- it('rejects a draft priced above the ₹5,000 cap with E-1108', async () => {
- const token = await login(h.url, 'u1', '4728')
- const res = await post(h.url, '/api/items', token, {
- code: 'RT-EXP', name: 'Expensive Unknown', taxClassCode: 'GST18',
- salePricePaise: 999_900, status: 'draft', // ₹9,999 > ₹5,000
- })
- expect(res.status).toBe(400)
- const body = (await res.json()) as { code: string }
- expect(body.code).toBe('E-1108')
- expect(h.db.prepare(`SELECT COUNT(*) n FROM item WHERE code='RT-EXP'`).get()).toMatchObject({ n: 0 })
- })
-
- it('allows a reasonable draft under the cap', async () => {
- const token = await login(h.url, 'u1', '4728')
- const res = await post(h.url, '/api/items', token, {
- code: 'RT-OK', name: 'Reasonable Unknown', taxClassCode: 'GST18',
- salePricePaise: 45_000, status: 'draft', // ₹450 < ₹5,000
- })
- expect(res.status).toBe(200)
- })
-
- it('lets a MASTER_EDIT role (owner) create above the cap', async () => {
- const token = await login(h.url, 'u3', '9174') // owner
- const res = await post(h.url, '/api/items', token, {
- code: 'OWN-EXP', name: 'Owner Expensive', taxClassCode: 'GST18',
- salePricePaise: 999_900, status: 'draft',
- })
- expect(res.status).toBe(200)
- })
-})
-
-describe('draft sale is audited DRAFT_SALE', () => {
- it('writes one DRAFT_SALE row (itemId, name, price) in the commit transaction', () => {
- // Direct repos: a draft item sold at its own price is not a deviation, so absent this
- // flag the only trace would be ITEM_CREATE — prove the explicit sale trail exists.
- const draft = createItem(h.db, 't1', {
- code: 'RT-DRAFT', name: 'Redteam Draft', hsn: '', taxClassCode: 'GST18',
- unitCode: 'PCS', unitDecimals: 0, salePricePaise: 10_000, status: 'draft',
- })
- commitBill(h.db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- businessDate: '2026-07-13',
- lines: [{ itemId: draft.id, name: draft.name, hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: 10_000, priceIncludesTax: true, taxClassCode: 'GST18' }],
- payments: [{ mode: 'CASH', amountPaise: 10_000 }], clientPayablePaise: 10_000,
- })
- const audit = h.db.prepare(`SELECT after_json FROM audit_log WHERE action='DRAFT_SALE'`).get() as { after_json: string } | undefined
- expect(audit).toBeDefined()
- const after = JSON.parse(audit!.after_json) as { itemId: string; name: string; pricePaise: number }
- expect(after.itemId).toBe(draft.id)
- expect(after.name).toBe('Redteam Draft')
- expect(after.pricePaise).toBe(10_000)
- })
-
- it('an active (non-draft) item sale writes NO DRAFT_SALE row', () => {
- const surf = itemIdOf(h.db, '103')
- commitBill(h.db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- businessDate: '2026-07-13',
- lines: [{ itemId: surf, name: 'Surf', hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: 14_500, priceIncludesTax: true, taxClassCode: 'GST18' }],
- payments: [{ mode: 'CASH', amountPaise: 14_500 }], clientPayablePaise: 14_500,
- })
- expect(h.db.prepare(`SELECT COUNT(*) n FROM audit_log WHERE action='DRAFT_SALE'`).get()).toMatchObject({ n: 0 })
- })
-})
-
-describe('GET /bills is scoped for a cashier (cross-counter read residual)', () => {
- it('a cashier sees only their OWN bills; an owner (REPORT_VIEW) sees all', async () => {
- const surf = itemIdOf(h.db, '103')
- const t1 = await login(h.url, 'u1', '4728') // Ramesh
- const t4 = await login(h.url, 'u4', '3591') // Divya
- const billBody = {
- storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-13',
- lines: [sellLine(surf, 14_500)],
- payments: [{ mode: 'CASH', amountPaise: 14_500 }], clientPayablePaise: 14_500,
- }
- expect((await post(h.url, '/api/bills', t1, billBody)).status).toBe(200) // Ramesh's bill
- expect((await post(h.url, '/api/bills', t4, billBody)).status).toBe(200) // Divya's bill
-
- const rameshView = (await (await get(h.url, '/api/bills', t1)).json()) as { cashier_id: string }[]
- expect(rameshView.length).toBe(1)
- expect(rameshView.every((b) => b.cashier_id === 'u1')).toBe(true)
-
- const divyaView = (await (await get(h.url, '/api/bills', t4)).json()) as { cashier_id: string }[]
- expect(divyaView.length).toBe(1)
- expect(divyaView.every((b) => b.cashier_id === 'u4')).toBe(true)
-
- // Owner has REPORT_VIEW → full cross-counter/cashier view (both bills).
- const owner = await login(h.url, 'u3', '9174')
- const ownerView = (await (await get(h.url, '/api/bills', owner)).json()) as unknown[]
- expect(ownerView.length).toBe(2)
- })
-})
diff --git a/apps/store-server/test/edge-guards.test.ts b/apps/store-server/test/edge-guards.test.ts
deleted file mode 100644
index 219aa09..0000000
--- a/apps/store-server/test/edge-guards.test.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import {
- isLanIPv4, isLanAddress, parsePrinterPorts, checkPrintEnvelope, checkPrintAddress,
- DEFAULT_PRINTER_PORTS, MAX_PRINT_BYTES,
-} from '../src/print-guard'
-import { createRateLimiter } from '../src/rate-limit'
-import { isSessionExpired, DEFAULT_SESSION_TTL } from '../src/session-policy'
-
-/**
- * Pure guards behind the P1 edge hardening (red-team H1, M2, M4): the LAN/port/byte print
- * validator, the sliding-window rate limiter, and the session-TTL check. Clock-injectable, so
- * they test without timers or sockets. The HTTP-surface behaviour lives in edge.test.ts.
- */
-
-// ---- H1: /api/print SSRF allowlist ----------------------------------------------------------
-describe('print-guard: LAN address classification (H1)', () => {
- it('accepts loopback + RFC-1918 private ranges', () => {
- for (const ip of ['127.0.0.1', '127.255.255.254', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.100']) {
- expect(isLanIPv4(ip)).toBe(true)
- }
- })
- it('rejects public IPs, link-local, and the 172.16/12 edges outside the block', () => {
- for (const ip of ['8.8.8.8', '1.1.1.1', '169.254.1.1', '172.15.255.255', '172.32.0.1', '0.0.0.0', '255.255.255.255', 'not-an-ip', '']) {
- expect(isLanIPv4(ip)).toBe(false)
- }
- })
- it('treats IPv6 loopback ::1 as LAN, other IPv6 as off-LAN', () => {
- expect(isLanAddress('::1')).toBe(true)
- expect(isLanAddress('2001:4860:4860::8888')).toBe(false)
- })
-})
-
-describe('print-guard: port allowlist + payload cap (H1)', () => {
- it('defaults to {9100,9101,9102} and parses SIMS_PRINTER_PORTS', () => {
- expect([...parsePrinterPorts(undefined)].sort()).toEqual([...DEFAULT_PRINTER_PORTS])
- expect([...parsePrinterPorts('')].sort()).toEqual([...DEFAULT_PRINTER_PORTS])
- expect([...parsePrinterPorts('abc,,-1,70000')].sort()).toEqual([...DEFAULT_PRINTER_PORTS]) // all invalid → default
- expect([...parsePrinterPorts('9100, 9200')].sort()).toEqual([9100, 9200])
- })
- it('checkPrintEnvelope enforces byte cap and port allowlist', () => {
- const ports = new Set(DEFAULT_PRINTER_PORTS)
- expect(checkPrintEnvelope(100, 9100, ports)).toEqual({ ok: true })
- expect(checkPrintEnvelope(MAX_PRINT_BYTES + 1, 9100, ports)).toMatchObject({ ok: false, code: 'E-2201' })
- expect(checkPrintEnvelope(100, 80, ports)).toMatchObject({ ok: false, code: 'E-2202' })
- })
- it('checkPrintAddress rejects a public IP, accepts a private one', () => {
- expect(checkPrintAddress('127.0.0.1')).toEqual({ ok: true })
- expect(checkPrintAddress('8.8.8.8')).toMatchObject({ ok: false, code: 'E-2203' })
- })
-})
-
-// ---- Rate limiter (M2/M4) -------------------------------------------------------------------
-describe('rate limiter: sliding window (M2/M4)', () => {
- it('allows up to max within the window then rejects, and slides', () => {
- const rl = createRateLimiter({ windowMs: 1000, max: 2 })
- expect(rl.hit('ip', 0)).toBe(true)
- expect(rl.hit('ip', 100)).toBe(true)
- expect(rl.hit('ip', 200)).toBe(false) // 3rd within window blocked
- expect(rl.remaining('ip', 200)).toBe(0)
- expect(rl.hit('ip', 1101)).toBe(true) // first attempt (t=0) aged out → allowed again
- })
- it('keys are independent', () => {
- const rl = createRateLimiter({ windowMs: 1000, max: 1 })
- expect(rl.hit('a', 0)).toBe(true)
- expect(rl.hit('b', 0)).toBe(true)
- expect(rl.hit('a', 0)).toBe(false)
- })
-})
-
-// ---- Session TTL (M4) -----------------------------------------------------------------------
-describe('session TTL check (M4)', () => {
- const ttl = { absoluteMs: 1000, idleMs: 100 }
- it('is live within both bounds', () => {
- expect(isSessionExpired({ createdMs: 0, lastSeenMs: 0 }, 50, ttl)).toBe(false)
- })
- it('expires past the idle timeout even with recent creation', () => {
- expect(isSessionExpired({ createdMs: 0, lastSeenMs: 0 }, 150, ttl)).toBe(true)
- })
- it('expires past the absolute lifetime even when recently seen', () => {
- expect(isSessionExpired({ createdMs: 0, lastSeenMs: 990 }, 1001, ttl)).toBe(true)
- })
- it('ships sane defaults (12h absolute / 30m idle)', () => {
- expect(DEFAULT_SESSION_TTL.absoluteMs).toBe(12 * 60 * 60 * 1000)
- expect(DEFAULT_SESSION_TTL.idleMs).toBe(30 * 60 * 1000)
- })
-})
diff --git a/apps/store-server/test/edge.test.ts b/apps/store-server/test/edge.test.ts
deleted file mode 100644
index 35a017a..0000000
--- a/apps/store-server/test/edge.test.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import express from 'express'
-import type { AddressInfo } from 'node:net'
-import type { Server } from 'node:http'
-import { openDb, type DB } from '../src/db'
-import { seedIfEmpty } from '../src/seed'
-import { apiRouter, type ApiRouterOpts } from '../src/api'
-import { MAX_PRINT_BYTES } from '../src/print-guard'
-
-/**
- * P1 edge / transport hardening (red-team H1, M1, M2, M4) over the REAL HTTP surface: a mounted
- * apiRouter, driven with fetch — the exact surface each finding lives on. Pure guards are in
- * edge-guards.test.ts. Seed users: u1 Ramesh/cashier (4728), u2 Suresh/supervisor (8265),
- * u3 Thomas/owner (9174).
- */
-interface Harness { db: DB; url: string; server: Server }
-
-async function start(opts?: ApiRouterOpts): Promise {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- const app = express()
- app.use(express.json({ limit: '1mb' }))
- app.use('/api', apiRouter(db, opts))
- app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
- const e = (err ?? {}) as { status?: number; code?: string; message?: string }
- const status = typeof e.status === 'number' ? e.status : 500
- if (res.headersSent) return
- res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
- })
- const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) })
- const port = (server.address() as AddressInfo).port
- return { db, url: `http://127.0.0.1:${port}`, server }
-}
-
-async function login(url: string, userId: string, pin: string): Promise {
- const res = await fetch(`${url}/api/auth/pin`, {
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }),
- })
- const body = (await res.json()) as { token?: string }
- if (body.token === undefined) throw new Error(`login failed for ${userId}`)
- return body.token
-}
-
-const post = (url: string, path: string, token: string | undefined, body: unknown) =>
- fetch(`${url}${path}`, {
- method: 'POST',
- headers: { 'content-type': 'application/json', ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) },
- body: JSON.stringify(body),
- })
-
-const get = (url: string, path: string, token?: string) =>
- fetch(`${url}${path}`, { headers: token !== undefined ? { authorization: `Bearer ${token}` } : {} })
-
-const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
-
-let h: Harness
-afterEach(() => { h.server.close() })
-
-describe('M1 — /bootstrap minimize', () => {
- beforeEach(async () => { h = await start() })
- it('unauthenticated payload leaks NO role and NO gstin', async () => {
- const boot = (await (await get(h.url, '/api/bootstrap')).json()) as { store: Record; users: Record[] }
- expect(boot.store['gstin']).toBeUndefined()
- expect(boot.users.length).toBeGreaterThan(0)
- for (const u of boot.users) {
- expect(u['role']).toBeUndefined()
- expect(u['name']).toBeDefined() // display name kept
- expect(u['id']).toBeDefined() // id kept (PIN login target)
- }
- })
- it('an authenticated caller gets role + gstin back', async () => {
- const token = await login(h.url, 'u1', '4728')
- const boot = (await (await get(h.url, '/api/bootstrap', token)).json()) as { store: Record; users: Record[] }
- expect(boot.store['gstin']).toBeDefined()
- expect(boot.users.every((u) => u['role'] !== undefined)).toBe(true)
- })
-})
-
-describe('M2 — /auth/verify-pin behind auth', () => {
- beforeEach(async () => { h = await start() })
- const override = { itemId: 'i-x', kind: 'price', before: 10000, after: 9000 }
- it('is 401 UNAUTHENTICATED (no caller session)', async () => {
- const res = await post(h.url, '/api/auth/verify-pin', undefined, { userId: 'u2', pin: '8265', override })
- expect(res.status).toBe(401)
- })
- it('still mints a bound token when the caller is a logged-in cashier', async () => {
- const cashier = await login(h.url, 'u1', '4728')
- const res = await post(h.url, '/api/auth/verify-pin', cashier, { userId: 'u2', pin: '8265', override })
- expect(res.status).toBe(200)
- const body = (await res.json()) as { approvalId?: string }
- expect(body.approvalId).toBeDefined()
- })
- it('a wrong approver PIN is still rejected (401) even with a valid caller', async () => {
- const cashier = await login(h.url, 'u1', '4728')
- const res = await post(h.url, '/api/auth/verify-pin', cashier, { userId: 'u2', pin: '0000', override })
- expect(res.status).toBe(401)
- })
-})
-
-describe('M4 — session TTL + logout', () => {
- it('evicts a session past its idle TTL (401 E-6110)', async () => {
- h = await start({ sessionTtl: { absoluteMs: 3_600_000, idleMs: 20 } })
- const token = await login(h.url, 'u1', '4728')
- expect((await get(h.url, '/api/items', token)).status).toBe(200) // fresh: allowed
- await sleep(80)
- const res = await get(h.url, '/api/items', token)
- expect(res.status).toBe(401)
- expect(((await res.json()) as { code: string }).code).toBe('E-6110')
- })
- it('logout makes the token stop working immediately', async () => {
- h = await start()
- const token = await login(h.url, 'u1', '4728')
- expect((await get(h.url, '/api/items', token)).status).toBe(200)
- expect((await post(h.url, '/api/auth/logout', token, {})).status).toBe(200)
- expect((await get(h.url, '/api/items', token)).status).toBe(401)
- })
-})
-
-describe('M4/M2 — auth rate limit', () => {
- it('429s once the per-IP cap is exceeded, before the handler runs', async () => {
- h = await start({ authRatePerIp: { windowMs: 60_000, max: 2 } })
- const r1 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
- const r2 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
- const r3 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
- expect(r1.status).toBe(401) // wrong pin, but processed
- expect(r2.status).toBe(401)
- expect(r3.status).toBe(429) // rate-limited before reaching the handler
- })
-})
-
-describe('H1 — /api/print allowlist over HTTP', () => {
- beforeEach(async () => { h = await start() })
- it('rejects an unauthenticated caller (401)', async () => {
- const res = await post(h.url, '/api/print', undefined, { host: '127.0.0.1', port: 9100, bytes: [1] })
- expect(res.status).toBe(401)
- })
- it('rejects a PUBLIC IP even on an allowed port (E-2203, no socket opened)', async () => {
- const token = await login(h.url, 'u1', '4728')
- const res = await post(h.url, '/api/print', token, { host: '8.8.8.8', port: 9100, bytes: [1, 2, 3] })
- expect(res.status).toBe(400)
- expect(((await res.json()) as { code: string }).code).toBe('E-2203')
- })
- it('rejects a disallowed port (E-2202)', async () => {
- const token = await login(h.url, 'u1', '4728')
- const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 80, bytes: [1] })
- expect(((await res.json()) as { code: string }).code).toBe('E-2202')
- })
- it('rejects an over-cap payload (E-2201)', async () => {
- const token = await login(h.url, 'u1', '4728')
- const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 9100, bytes: new Array(MAX_PRINT_BYTES + 1).fill(0) })
- expect(((await res.json()) as { code: string }).code).toBe('E-2201')
- })
- it('ALLOWS a LAN target on an allowed port through the guard (reaches the socket → connection refused)', async () => {
- const token = await login(h.url, 'u1', '4728')
- // Nothing is listening on 127.0.0.1:9100 in the test, so the guard passing is proven by the
- // response being a socket outcome (502 refused / 504 timeout), NOT a 400 guard rejection.
- const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 9100, bytes: [27, 64] })
- expect([502, 504, 200]).toContain(res.status)
- })
-})
-
-describe('H2 — GET /audit/verify (AUDIT_VIEW gated)', () => {
- beforeEach(async () => { h = await start() })
- it('an owner gets ok:true on an untampered chain (with a real audit row); a cashier is 403', async () => {
- // a wrong-PIN attempt writes a real LOGIN_FAILED row, so the chain isn't empty
- await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
- const owner = await login(h.url, 'u3', '9174')
- const res = await get(h.url, '/api/audit/verify', owner)
- expect(res.status).toBe(200)
- expect(((await res.json()) as { ok: boolean }).ok).toBe(true)
- const cashier = await login(h.url, 'u1', '4728')
- expect((await get(h.url, '/api/audit/verify', cashier)).status).toBe(403)
- })
-})
diff --git a/apps/store-server/test/encryption.test.ts b/apps/store-server/test/encryption.test.ts
deleted file mode 100644
index c802c68..0000000
--- a/apps/store-server/test/encryption.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { afterEach, describe, expect, it } from 'vitest'
-import fs from 'node:fs'
-import os from 'node:os'
-import path from 'node:path'
-import { openDb, type DB } from '../src/db'
-
-/**
- * H3 (red-team): the store DB is encrypted at rest with SQLCipher (better-sqlite3-multiple-ciphers)
- * when SIMS_DB_KEY is set. These tests write a customer PII row (a phone number) through the real
- * openDb() path, then prove against the ON-DISK bytes that:
- * - a keyed file's header is NOT the plaintext "SQLite format 3\0" magic, and the phone number
- * never appears in cleartext ANYWHERE in the file (main db + WAL);
- * - the keyed data round-trips (reopen WITH the key reads the phone back);
- * - opening WITHOUT the key, or with the WRONG key, fails ("file is not a database");
- * - with SIMS_DB_KEY UNSET the file IS plaintext (the dev fallback) AND the phone IS present in
- * cleartext — a positive control proving the byte-scan above genuinely detects plaintext.
- * Tamper-evidence (H2) proves the file wasn't edited; H3 proves a stolen copy is unreadable.
- */
-const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1') // 16 bytes
-const PHONE = '9998887776' // stand-in customer PII we hunt for in the raw file
-const GSTIN = '27ABCDE1234F1Z0'
-
-const tmpFiles: string[] = []
-function tmpDbPath(): string {
- const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sims-enc-')), 'sims.db')
- tmpFiles.push(p)
- return p
-}
-afterEach(() => {
- for (const p of tmpFiles.splice(0)) {
- for (const f of [p, `${p}-wal`, `${p}-shm`]) { try { fs.unlinkSync(f) } catch { /* ignore */ } }
- try { fs.rmdirSync(path.dirname(p)) } catch { /* ignore */ }
- }
-})
-
-/** Run fn with SIMS_DB_KEY = key (or unset when key === null), restoring the prior env after. */
-function withKey(key: string | null, fn: () => T): T {
- const prev = process.env['SIMS_DB_KEY']
- if (key === null) delete process.env['SIMS_DB_KEY']
- else process.env['SIMS_DB_KEY'] = key
- try {
- return fn()
- } finally {
- if (prev === undefined) delete process.env['SIMS_DB_KEY']
- else process.env['SIMS_DB_KEY'] = prev
- }
-}
-
-/** Insert one party carrying PII, checkpoint so it lands on disk, close. */
-function writePartyAndClose(db: DB): void {
- db.prepare(
- "INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code) "
- + "VALUES ('p-enc', 't1', 'C-ENC', 'Encrypted Customer', 'customer', ?, ?, '27')",
- ).run(PHONE, GSTIN)
- db.pragma('wal_checkpoint(TRUNCATE)') // force the row out of the WAL into the main file we read
- db.close()
-}
-
-/** Concatenated raw bytes of the main db file and any WAL sidecar. */
-function rawBytes(file: string): Buffer {
- const parts = [fs.readFileSync(file)]
- if (fs.existsSync(`${file}-wal`)) parts.push(fs.readFileSync(`${file}-wal`))
- return Buffer.concat(parts)
-}
-
-describe('H3 — full-DB encryption at rest (SQLCipher)', () => {
- it('a keyed DB is ciphertext on disk: no SQLite magic header, no cleartext PII', () => {
- const file = tmpDbPath()
- withKey('unit-test-secret-key', () => {
- writePartyAndClose(openDb(file))
- })
- const head = fs.readFileSync(file).subarray(0, 16)
- expect(head.equals(SQLITE_MAGIC)).toBe(false) // not "SQLite format 3\0"
- const raw = rawBytes(file)
- expect(raw.includes(Buffer.from(PHONE, 'latin1'))).toBe(false) // phone never in cleartext
- expect(raw.includes(Buffer.from(GSTIN, 'latin1'))).toBe(false) // nor the GSTIN
- })
-
- it('the keyed data round-trips — reopening WITH the key reads the PII back', () => {
- const file = tmpDbPath()
- withKey('unit-test-secret-key', () => {
- writePartyAndClose(openDb(file))
- const db2 = openDb(file)
- const row = db2.prepare("SELECT phone, gstin FROM party WHERE id='p-enc'").get() as
- { phone: string; gstin: string } | undefined
- db2.close()
- expect(row?.phone).toBe(PHONE)
- expect(row?.gstin).toBe(GSTIN)
- })
- })
-
- it('opening a keyed file WITHOUT the key fails (a stolen copy is unreadable)', () => {
- const file = tmpDbPath()
- withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) })
- expect(() => withKey(null, () => openDb(file))).toThrow(/not a database/i)
- })
-
- it('opening a keyed file with the WRONG key fails', () => {
- const file = tmpDbPath()
- withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) })
- expect(() => withKey('the-wrong-key', () => openDb(file))).toThrow(/not a database/i)
- })
-
- it('dev fallback (key UNSET) writes plaintext — positive control for the byte-scan', () => {
- const file = tmpDbPath()
- withKey(null, () => { writePartyAndClose(openDb(file)) })
- const head = fs.readFileSync(file).subarray(0, 16)
- expect(head.equals(SQLITE_MAGIC)).toBe(true) // unencrypted: real SQLite magic
- expect(rawBytes(file).includes(Buffer.from(PHONE, 'latin1'))).toBe(true) // scan really finds plaintext
- })
-})
diff --git a/apps/store-server/test/gst.test.ts b/apps/store-server/test/gst.test.ts
deleted file mode 100644
index 701fd81..0000000
--- a/apps/store-server/test/gst.test.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import { afterEach, describe, expect, it } from 'vitest'
-import express from 'express'
-import type { AddressInfo } from 'node:net'
-import type { Server } from 'node:http'
-import { openDb, type DB } from '../src/db'
-import { seedIfEmpty } from '../src/seed'
-import { commitBill, createCustomer } from '../src/repos'
-import { commitReturn } from '../src/repos-returns'
-import { gstr1, gstr3b, hsnSummary, gstr1PortalJson } from '../src/repos-gst'
-import { apiRouter } from '../src/api'
-
-/**
- * GST returns — READ-ONLY compliance reporting over the period's immutable documents. These lock:
- * the B2B/B2CS split by buyer GSTIN, B2CS aggregation by rate, a credit note reducing its rate
- * bucket, HSN totals, the GSTR-3B 3.1(a) outward figures hand-checked against the bills, the
- * reconciliation identity (sections == source == HSN == 3B), the REPORT_VIEW gate, and valid JSON.
- *
- * Hermetic in-memory seed: item 103 Surf Excel (GST18, ₹145 incl), 107 Amul Butter (GST12, ₹275),
- * 104 Parle-G (GST5, ₹90). Store state 32. u1 Ramesh/cashier (4728), u3 Thomas/owner (9174).
- */
-const PERIOD = '2026-07'
-const DATE = '2026-07-10'
-const CTIN = '27ABCDE1234F1Z0' // registered buyer in state 27 → inter-state B2B (IGST)
-
-function itemId(db: DB, code: string): string {
- return (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
-}
-
-interface Built { custA: string; bill1: string; bill2: string; bill3: string }
-
-/** Build the known document set for a single month and return the ids. */
-function buildMonth(db: DB): Built {
- const custA = createCustomer(db, 't1', 'Anand Traders', '9876543210', { gstin: CTIN }).id
- const line = (code: string, qty: number, price: number, cls: string, hsn: string, name: string) => ({
- itemId: itemId(db, code), name, hsn, qty, unitCode: 'PCS', unitPricePaise: price, priceIncludesTax: true, taxClassCode: cls,
- })
- const commit = (lines: ReturnType[], payable: number, customerId?: string) =>
- commitBill(db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: DATE,
- lines, payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
- ...(customerId !== undefined ? { customerId } : {}),
- })
- // Bill 1 — B2B: 2 × Surf Excel to the GSTIN buyer (inter-state → IGST).
- const bill1 = commit([line('103', 2, 14_500, 'GST18', '3402', 'Surf Excel 1kg')], 29_000, custA).id
- // Bill 2 — B2C cash: 1 × Surf Excel + 1 × Amul Butter (intra → CGST/SGST).
- const bill2 = commit([line('103', 1, 14_500, 'GST18', '3402', 'Surf Excel 1kg'), line('107', 1, 27_500, 'GST12', '0405', 'Amul Butter 500g')], 42_000).id
- // Bill 3 — B2C cash: 3 × Parle-G.
- const bill3 = commit([line('104', 3, 9_000, 'GST5', '1905', 'Parle-G 800g')], 27_000).id
- // Return — the full Amul line from bill 2 (index 1) → nets into B2CS at GST12.
- commitReturn(db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: DATE,
- originalBillId: bill2, lines: [{ lineIndex: 1, qty: 1 }], reason: 'Damaged', refundMode: 'CASH',
- })
- return { custA, bill1, bill2, bill3 }
-}
-
-function fresh(): DB {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- return db
-}
-
-describe('gstr1 — sections over the known month', () => {
- it('B2B shows the GSTIN invoice; B2CS aggregates the cash bills by rate; the return nets out', () => {
- const db = fresh()
- buildMonth(db)
- const r = gstr1(db, 't1', PERIOD)
-
- // B2B: one party, one invoice, GST18, taxable 24576 + IGST 4424 (₹290 incl of 2×₹145).
- expect(r.b2b).toHaveLength(1)
- expect(r.b2b[0]!.ctin).toBe(CTIN)
- expect(r.b2b[0]!.invoiceCount).toBe(1)
- expect(r.b2b[0]!.totals.taxablePaise).toBe(24_576)
- expect(r.b2b[0]!.totals.igstPaise).toBe(4_424)
-
- // B2CS: Surf (GST18) and Parle (GST5) survive; the Amul (GST12) bucket nets to zero and is dropped.
- const b18 = r.b2cs.find((b) => b.rateBp === 1800)!
- expect(b18.taxablePaise).toBe(12_288)
- expect(b18.cgstPaise).toBe(1_106)
- expect(r.b2cs.find((b) => b.rateBp === 500)!.taxablePaise).toBe(25_714)
- expect(r.b2cs.some((b) => b.rateBp === 1200)).toBe(false)
-
- // No B2CL / CDNR / CDNUR in this set (nothing large, no registered return).
- expect(r.b2cl).toHaveLength(0)
- expect(r.cdnr).toHaveLength(0)
- expect(r.cdnur).toHaveLength(0)
-
- // HSN: Surf 3402 aggregates B2B + B2C (qty 3); Amul 0405 nets to zero and drops.
- const surf = r.hsn.find((h) => h.hsn === '3402')!
- expect(surf.taxablePaise).toBe(36_864)
- expect(surf.qty).toBe(3)
- expect(r.hsn.some((h) => h.hsn === '0405')).toBe(false)
- expect(r.totals.hsn.taxablePaise).toBe(62_578)
-
- // Documents issued: 3 invoices + 1 credit note.
- expect(r.docs.find((d) => d.natureOfDocument === 'Invoices for outward supply')!.totalNumber).toBe(3)
- expect(r.docs.find((d) => d.natureOfDocument === 'Credit notes')!.totalNumber).toBe(1)
- })
-})
-
-describe('gstr3b — 3.1(a) hand-checked against the bills', () => {
- it('outward = Σ bill tax − credit-note tax, at captured rates', () => {
- const db = fresh()
- buildMonth(db)
- const r = gstr3b(db, 't1', PERIOD)
- // Sales tax: IGST 4424 + CGST 3222 + SGST 3222 = 10868; credit note: CGST 1473 + SGST 1473 = 2946.
- expect(r.salesTax.igstPaise + r.salesTax.cgstPaise + r.salesTax.sgstPaise).toBe(10_868)
- expect(r.returnsTax.cgstPaise + r.returnsTax.sgstPaise).toBe(2_946)
- expect(r.outward.taxablePaise).toBe(62_578)
- expect(r.outward.igstPaise).toBe(4_424)
- expect(r.outward.cgstPaise).toBe(1_749)
- expect(r.outward.sgstPaise).toBe(1_749)
- expect(r.outward.igstPaise + r.outward.cgstPaise + r.outward.sgstPaise).toBe(7_922) // 10868 − 2946
- expect(r.itc.igstPaise).toBe(0) // ITC stub
- })
-})
-
-describe('reconciliation — the compliance gate ties out', () => {
- it('GSTR-1 sections == source == HSN == GSTR-3B (taxable and tax)', () => {
- const db = fresh()
- buildMonth(db)
- const r = gstr1(db, 't1', PERIOD)
- expect(r.reconciliation.ok).toBe(true)
- expect(r.reconciliation.mismatches).toEqual([])
- expect(r.reconciliation.sourceTaxablePaise).toBe(62_578)
- expect(r.reconciliation.gstr1NetTaxablePaise).toBe(62_578)
- expect(r.reconciliation.hsnTaxablePaise).toBe(62_578)
- expect(r.reconciliation.gstr3bTaxablePaise).toBe(62_578)
- expect(r.reconciliation.sourceTaxPaise).toBe(7_922)
- expect(r.reconciliation.gstr3bTaxPaise).toBe(7_922)
- })
-
- it('an empty period reconciles to zero, not an error', () => {
- const db = fresh()
- const r = gstr1(db, 't1', '2026-01')
- expect(r.reconciliation.ok).toBe(true)
- expect(r.reconciliation.sourceTaxablePaise).toBe(0)
- expect(r.b2b).toHaveLength(0)
- expect(r.docs).toHaveLength(0)
- })
-})
-
-describe('gstr1PortalJson — pragmatic GSTN-shaped export', () => {
- it('produces valid, serialisable JSON with the seller GSTIN and section keys', () => {
- const db = fresh()
- buildMonth(db)
- const j = gstr1PortalJson(db, 't1', PERIOD)
- // Round-trips through JSON.stringify/parse (no NaN, no cycles) and carries the shape.
- const round = JSON.parse(JSON.stringify(j)) as Record
- expect(round['gstin']).toBe('32ABCDE1234F1Z5')
- expect(round['fp']).toBe('072026')
- expect(Array.isArray(round['b2b'])).toBe(true)
- expect((round['b2b'] as unknown[]).length).toBe(1)
- expect(round['_note']).toMatch(/NOT a certified/i)
- const hsn = round['hsn'] as { data: unknown[] }
- expect(Array.isArray(hsn.data)).toBe(true)
- })
-})
-
-// ---- HTTP surface: the REPORT_VIEW gate + period validation ----
-interface Harness { db: DB; url: string; server: Server }
-async function start(): Promise {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- buildMonth(db)
- const app = express()
- app.use(express.json({ limit: '1mb' }))
- app.use('/api', apiRouter(db))
- app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
- const e = (err ?? {}) as { status?: number; code?: string; message?: string }
- const status = typeof e.status === 'number' ? e.status : 500
- if (res.headersSent) return
- res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
- })
- const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) })
- const port = (server.address() as AddressInfo).port
- return { db, url: `http://127.0.0.1:${port}`, server }
-}
-async function login(url: string, userId: string, pin: string): Promise {
- const res = await fetch(`${url}/api/auth/pin`, {
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }),
- })
- const body = (await res.json()) as { token?: string }
- if (body.token === undefined) throw new Error(`login failed for ${userId}`)
- return body.token
-}
-const get = (url: string, path: string, token?: string) =>
- fetch(`${url}${path}`, { headers: { ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) } })
-
-describe('GST HTTP routes — REPORT_VIEW gate + validation', () => {
- let h: Harness
- afterEach(() => { h.server.close() })
-
- it('owner (REPORT_VIEW) reads the returns; a cashier is 403; unauth is 401; a bad period is 400', async () => {
- h = await start()
- // unauthenticated
- expect((await get(h.url, `/api/gst/gstr1?period=${PERIOD}`)).status).toBe(401)
- // a cashier lacks REPORT_VIEW → 403 E-6105
- const cashier = await login(h.url, 'u1', '4728')
- const denied = await get(h.url, `/api/gst/gstr1?period=${PERIOD}`, cashier)
- expect(denied.status).toBe(403)
- expect(((await denied.json()) as { code?: string }).code).toBe('E-6105')
- // the owner reads it → 200 with the reconciled numbers
- const owner = await login(h.url, 'u3', '9174')
- const ok = await get(h.url, `/api/gst/gstr1?period=${PERIOD}`, owner)
- expect(ok.status).toBe(200)
- const body = (await ok.json()) as { reconciliation: { ok: boolean }; totals: { net: { taxablePaise: number } } }
- expect(body.reconciliation.ok).toBe(true)
- expect(body.totals.net.taxablePaise).toBe(62_578)
- // 3B + hsn + json all 200 for the owner
- expect((await get(h.url, `/api/gst/gstr3b?period=${PERIOD}`, owner)).status).toBe(200)
- expect((await get(h.url, `/api/gst/hsn?period=${PERIOD}`, owner)).status).toBe(200)
- const j = await get(h.url, `/api/gst/gstr1.json?period=${PERIOD}`, owner)
- expect(j.status).toBe(200)
- expect(((await j.json()) as { fp?: string }).fp).toBe('072026')
- // a malformed period → 400 E-1330
- const bad = await get(h.url, `/api/gst/gstr1?period=2026-13`, owner)
- expect(bad.status).toBe(400)
- expect(((await bad.json()) as { code?: string }).code).toBe('E-1330')
- })
-})
diff --git a/apps/store-server/test/override-approval.test.ts b/apps/store-server/test/override-approval.test.ts
deleted file mode 100644
index 790bdfc..0000000
--- a/apps/store-server/test/override-approval.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { openDb, type DB } from '../src/db'
-import { seedIfEmpty } from '../src/seed'
-import { commitBill, createOverrideApproval } from '../src/repos'
-
-/**
- * SEC-2 / H4: supervisor-override approvals are server-minted, single-use tokens BOUND to
- * the exact override they authorise. The client sends an `approvalId`, never an approver
- * identity — commitBill re-detects the deviation from the item master, redeems the token
- * only if item/kind/before/after match, and reads the approver from its own row. These
- * tests drive commitBill against a hermetic in-memory DB (seed users: u1 Ramesh/cashier,
- * u2 Suresh/supervisor). Surf Excel (103) has master sale 14500, MRP 15000.
- */
-function fresh(): DB {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- return db
-}
-const surfId = (db: DB): string =>
- (db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='103'").get() as { id: string }).id
-
-/** A bill that sells Surf Excel at 12000 (a deviation from the 14500 master), overridden. */
-const billWith = (db: DB, approvalId: string): Parameters[1] => {
- const itemId = surfId(db)
- return {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
- businessDate: '2026-07-10',
- lines: [{ itemId, name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS', unitPricePaise: 12_000, priceIncludesTax: true, taxClassCode: 'GST18' }],
- payments: [{ mode: 'CASH', amountPaise: 12_000 }],
- clientPayablePaise: 12_000,
- overrides: [{ itemId, kind: 'price', before: 14_500, after: 12_000, approvalId }],
- }
-}
-const priceCtx = (db: DB) => ({ itemId: surfId(db), kind: 'price' as const, beforePaise: 14_500, afterPaise: 12_000 })
-const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
-
-describe('SEC-2 / H4 bound override approval tokens', () => {
- it('rejects a commit whose override carries a fabricated approvalId — nothing is committed', () => {
- const db = fresh()
- expect(() => commitBill(db, billWith(db, '00000000-0000-7000-8000-000000000000'))).toThrow(/approval/i)
- expect(billCount(db)).toBe(0)
- })
-
- it('accepts a genuine bound token, audits the SERVER-side approver, and rejects a replay', () => {
- const db = fresh()
- const approvalId = createOverrideApproval(db, 't1', 'u2', priceCtx(db)) // Suresh (supervisor)
- const out = commitBill(db, billWith(db, approvalId))
- expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9)
- const audit = db.prepare("SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'").get() as { after_json: string }
- const after = JSON.parse(audit.after_json) as { approvedBy: string; approvedByUserId: string }
- expect(after.approvedByUserId).toBe('u2') // read from the server's own row, not the client
- expect(after.approvedBy).toBe('Suresh')
- // token is single-use: a replay is rejected and no second bill is written
- expect(() => commitBill(db, billWith(db, approvalId))).toThrow(/already used/i)
- expect(billCount(db)).toBe(1)
- })
-
- it('rejects a token minted for a non-approver role (a cashier)', () => {
- const db = fresh()
- const cashierToken = createOverrideApproval(db, 't1', 'u1', priceCtx(db)) // u1 is a cashier
- expect(() => commitBill(db, billWith(db, cashierToken))).toThrow(/not authorised/i)
- expect(billCount(db)).toBe(0)
- })
-
- it('rejects an override with no approvalId at all (deviation unauthorised)', () => {
- const db = fresh()
- const itemId = surfId(db)
- const input = billWith(db, 'placeholder')
- input.overrides = [{ itemId, kind: 'price', before: 14_500, after: 12_000 }]
- expect(() => commitBill(db, input)).toThrow(/supervisor approval|E-1302/i)
- expect(billCount(db)).toBe(0)
- })
-
- it('H4: a token minted for item A cannot be redirected to item B', () => {
- const db = fresh()
- // Mint a genuine supervisor token bound to a DIFFERENT item (105 Maggi 14000→10000)...
- const maggiId = (db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='105'").get() as { id: string }).id
- const strayToken = createOverrideApproval(db, 't1', 'u2', { itemId: maggiId, kind: 'price', beforePaise: 14_000, afterPaise: 10_000 })
- // ...and try to spend it on the Surf Excel deviation. The bound item mismatches → reject.
- expect(() => commitBill(db, billWith(db, strayToken))).toThrow(/does not match|not recognised|approval/i)
- expect(billCount(db)).toBe(0)
- })
-})
diff --git a/apps/store-server/test/returns.test.ts b/apps/store-server/test/returns.test.ts
deleted file mode 100644
index cb5f19d..0000000
--- a/apps/store-server/test/returns.test.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { afterEach, describe, expect, it } from 'vitest'
-import express from 'express'
-import type { AddressInfo } from 'node:net'
-import type { Server } from 'node:http'
-import { hashPin } from '@sims/auth'
-import { openDb, type DB } from '../src/db'
-import { seedIfEmpty } from '../src/seed'
-import { commitBill } from '../src/repos'
-import { commitReturn, listReturns, returnableLines } from '../src/repos-returns'
-import { apiRouter } from '../src/api'
-
-/**
- * Returns / credit notes ride the money/commit trust boundary the red-team hardened (docs/18):
- * everything anchors to OUR stored original bill, never the client. These tests lock the security
- * properties: refund computed from the original (not client), tax reversed at the original rate,
- * over-return blocked, cross-tenant/nonexistent original rejected, the doc no within the GST
- * 16-char rule, and the POST /returns route gated by RETURN_CREATE.
- *
- * Hermetic in-memory DB; seed item 107 = Amul Butter (master 27500, GST12, whole unit); seed users
- * u1 Ramesh/cashier (4728), u3 Thomas/owner (9174).
- */
-function fresh(): DB {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- return db
-}
-const itemId = (db: DB, code: string): string =>
- (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
-const onHand = (db: DB, code: string): number =>
- Number((db.prepare(`SELECT COALESCE(SUM(qty_delta),0) n FROM stock_movement WHERE item_id=?`).get(itemId(db, code)) as { n: number }).n)
-const returnCount = (db: DB): number =>
- (db.prepare(`SELECT COUNT(*) n FROM bill WHERE doc_type='SALE_RETURN'`).get() as { n: number }).n
-
-/** Sell 3 × Amul Butter at the master price (₹825.00 inclusive), return the committed bill id. */
-function sell3(db: DB): { billId: string; payable: number } {
- const line = {
- itemId: itemId(db, '107'), name: 'Amul Butter 500g', hsn: '0405', qty: 3, unitCode: 'PCS',
- unitPricePaise: 27_500, priceIncludesTax: true, taxClassCode: 'GST12',
- }
- const out = commitBill(db, {
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
- lines: [line], payments: [{ mode: 'CASH', amountPaise: 82_500 }], clientPayablePaise: 82_500,
- })
- return { billId: out.id, payable: 82_500 }
-}
-
-const ret = (
- billId: string, lines: { lineIndex: number; qty: number }[], over: Record = {},
-): Parameters[1] => ({
- tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
- originalBillId: billId, lines, reason: 'Damaged', refundMode: 'CASH', ...over,
-})
-
-const caught = (fn: () => unknown): (Error & { status?: number; code?: string }) | undefined => {
- try { fn(); return undefined } catch (e) { return e as Error & { status?: number; code?: string } }
-}
-
-describe('commitReturn — anchored to the original bill', () => {
- it('commits a 2-of-3 return: credit-note doc no, stock back up by 2, refund 2/3 of the taxed line', () => {
- const db = fresh()
- const { billId } = sell3(db)
- const before = onHand(db, '107')
- const out = commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }]))
- expect(out.docNo).toMatch(/^CNST1C22026-\d{4}$/) // CN-marked, 4-digit-FY (M9)
- expect(out.docNo.length).toBeLessThanOrEqual(16) // GST 16-char rule
- expect(out.refundPaise).toBe(55_000) // 2/3 of ₹825.00
- expect(onHand(db, '107')).toBe(before + 2) // stock ADDED BACK (R7)
- expect(returnCount(db)).toBe(1)
- })
-
- it('reverses GST at the ORIGINAL rate — the credit note carries CGST+SGST, not ₹0', () => {
- const db = fresh()
- const { billId } = sell3(db)
- commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }]))
- const b = db.prepare(`SELECT cgst_paise, sgst_paise, igst_paise, taxable_paise, payable_paise FROM bill WHERE doc_type='SALE_RETURN'`).get() as {
- cgst_paise: number; sgst_paise: number; igst_paise: number; taxable_paise: number; payable_paise: number
- }
- expect(b.cgst_paise + b.sgst_paise).toBeGreaterThan(0)
- expect(b.igst_paise).toBe(0)
- expect(b.taxable_paise).toBeLessThan(b.payable_paise) // some of the refund is reversed tax
- })
-
- it('computes the refund itself — a client-inflated refund total is rejected 409 (E-1325)', () => {
- const db = fresh()
- const { billId } = sell3(db)
- const err = caught(() => commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }], { clientRefundPaise: 100 })))
- expect(err?.status).toBe(409)
- expect(err?.code).toBe('E-1325')
- expect(returnCount(db)).toBe(0) // nothing committed
- })
-
- it('blocks over-return: after returning 2 of 3, a second 2-unit return is rejected (E-1324); the last 1 is allowed', () => {
- const db = fresh()
- const { billId } = sell3(db)
- commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }]))
- const err = caught(() => commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 2 }])))
- expect(err?.status).toBe(400)
- expect(err?.code).toBe('E-1324')
- // exactly 1 remains returnable
- const rl = returnableLines(db, 't1', billId)
- expect(rl.lines[0]!.returnedQty).toBe(2)
- expect(rl.lines[0]!.returnableQty).toBe(1)
- const ok = commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 1 }]))
- expect(ok.docNo).toMatch(/^CNST1C22026-\d{4}$/)
- expect(returnableLines(db, 't1', billId).lines[0]!.returnableQty).toBe(0)
- })
-
- it('rejects a fractional return of a whole-unit line (E-1323)', () => {
- const db = fresh()
- const { billId } = sell3(db)
- const err = caught(() => commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 1.5 }])))
- expect(err?.status).toBe(400)
- expect(err?.code).toBe('E-1323')
- })
-
- it('rejects a nonexistent / cross-tenant original bill (400 E-1320) and a non-sale reference', () => {
- const db = fresh()
- const { billId } = sell3(db)
- expect(caught(() => commitReturn(db, ret('DOES-NOT-EXIST', [{ lineIndex: 0, qty: 1 }])))?.code).toBe('E-1320')
- // A credit note is not a returnable SALE — returning against one is rejected.
- const cn = commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 1 }]))
- expect(caught(() => commitReturn(db, ret(cn.id, [{ lineIndex: 0, qty: 1 }])))?.code).toBe('E-1320')
- })
-
- it('records the credit note in listReturns with its against-bill and reason', () => {
- const db = fresh()
- const { billId } = sell3(db)
- const out = commitReturn(db, ret(billId, [{ lineIndex: 0, qty: 1 }], { reason: 'Wrong item' }))
- const rows = listReturns(db, 't1')
- expect(rows.length).toBe(1)
- expect(rows[0]!['doc_no']).toBe(out.docNo)
- const origDocNo = (db.prepare(`SELECT doc_no FROM bill WHERE id=?`).get(billId) as { doc_no: string }).doc_no
- expect(rows[0]!['original_doc_no']).toBe(origDocNo)
- expect(String(rows[0]!['payload'])).toContain('Wrong item')
- })
-})
-
-// ---- HTTP surface: the RETURN_CREATE gate on POST /returns ----
-interface Harness { db: DB; url: string; server: Server }
-async function start(): Promise {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- const app = express()
- app.use(express.json({ limit: '1mb' }))
- app.use('/api', apiRouter(db))
- app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
- const e = (err ?? {}) as { status?: number; code?: string; message?: string }
- const status = typeof e.status === 'number' ? e.status : 500
- if (res.headersSent) return
- res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
- })
- const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) })
- const port = (server.address() as AddressInfo).port
- return { db, url: `http://127.0.0.1:${port}`, server }
-}
-async function login(url: string, userId: string, pin: string): Promise {
- const res = await fetch(`${url}/api/auth/pin`, {
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }),
- })
- const body = (await res.json()) as { token?: string }
- if (body.token === undefined) throw new Error(`login failed for ${userId}`)
- return body.token
-}
-const postReturn = (url: string, token: string | undefined, body: unknown) =>
- fetch(`${url}/api/returns`, {
- method: 'POST',
- headers: { 'content-type': 'application/json', ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) },
- body: JSON.stringify(body),
- })
-
-describe('POST /returns — RETURN_CREATE gate', () => {
- let h: Harness
- afterEach(() => { h.server.close() })
-
- it('a cashier (RETURN_CREATE) commits; an accountant (no RETURN_CREATE) is 403; unauth is 401', async () => {
- h = await start()
- const { billId } = sell3(h.db)
- const body = {
- storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-10',
- originalBillId: billId, lines: [{ lineIndex: 0, qty: 1 }], reason: 'Damaged', refundMode: 'CASH',
- }
- // unauthenticated → 401
- expect((await postReturn(h.url, undefined, body)).status).toBe(401)
-
- // an accountant lacks RETURN_CREATE → 403 E-6105
- const p = hashPin('1111')
- h.db.prepare(
- `INSERT INTO app_user (id, tenant_id, username, display_name, roles, store_ids, language, pin_salt, pin_hash)
- VALUES ('u9','t1','acct','Accountant','["accountant"]','"all"','en',?,?)`,
- ).run(p.salt, p.hash)
- const acctToken = await login(h.url, 'u9', '1111')
- const denied = await postReturn(h.url, acctToken, body)
- expect(denied.status).toBe(403)
- expect(((await denied.json()) as { code?: string }).code).toBe('E-6105')
-
- // the cashier has RETURN_CREATE → 200 + a credit-note doc no
- const cashier = await login(h.url, 'u1', '4728')
- const ok = await postReturn(h.url, cashier, body)
- expect(ok.status).toBe(200)
- const res = (await ok.json()) as { docNo: string; refundPaise: number }
- expect(res.docNo).toMatch(/^CNST1C22026-\d{4}$/)
- })
-})
diff --git a/apps/store-server/test/tenant-scope.test.ts b/apps/store-server/test/tenant-scope.test.ts
deleted file mode 100644
index 3191ed7..0000000
--- a/apps/store-server/test/tenant-scope.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { openDb, type DB } from '../src/db'
-import { seedIfEmpty } from '../src/seed'
-import { commitBill } from '../src/repos'
-
-/**
- * M5 (red-team): commitBill's store/counter lookup is tenant-scoped — `WHERE id=? AND tenant_id=?`
- * for both — and an unknown-for-tenant store or counter is rejected 400 (E-1110) BEFORE any money
- * logic, which also removes the old undefined-deref 500. (E-1110, not the task's suggested E-1109,
- * because E-1109 is already the "payments do not sum to payable" code — reusing it would be
- * ambiguous.) The /bills and /bills/offline routes both funnel through commitBill, so both paths
- * are covered. Hermetic in-memory DB; Carry Bag (108) has master sale 500, no MRP, GST18.
- */
-function fresh(): DB {
- const db = openDb(':memory:')
- seedIfEmpty(db)
- return db
-}
-const carryBag = (db: DB): string =>
- (db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='108'").get() as { id: string }).id
-const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
-const base = (db: DB): Omit[1], 'storeId'> => ({
- tenantId: 't1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
- lines: [{ itemId: carryBag(db), name: 'Carry Bag', hsn: '3923', qty: 1, unitCode: 'PCS', unitPricePaise: 500, priceIncludesTax: true, taxClassCode: 'GST18' }],
- payments: [{ mode: 'CASH', amountPaise: 500 }], clientPayablePaise: 500,
-})
-const thrown = (fn: () => unknown): (Error & { status?: number; code?: string }) | undefined => {
- try { fn(); return undefined } catch (e) { return e as Error & { status?: number; code?: string } }
-}
-
-describe('M5 — commitBill store/counter are tenant-scoped', () => {
- it('a nonexistent storeId is rejected 400 (E-1110) — no 500, nothing committed', () => {
- const db = fresh()
- const err = thrown(() => commitBill(db, { ...base(db), storeId: 'does-not-exist' }))
- expect(err).toBeDefined()
- expect(err!.status).toBe(400)
- expect(err!.code).toBe('E-1110')
- expect(billCount(db)).toBe(0)
- })
-
- it('a nonexistent counterId is rejected 400 (E-1110)', () => {
- const db = fresh()
- const err = thrown(() => commitBill(db, { ...base(db), storeId: 's1', counterId: 'nope' }))
- expect(err!.status).toBe(400)
- expect(err!.code).toBe('E-1110')
- expect(billCount(db)).toBe(0)
- })
-
- it('a store/counter belonging to ANOTHER tenant is rejected (true cross-tenant scoping)', () => {
- const db = fresh()
- db.prepare("INSERT INTO tenant (id, code, name, gst_scheme, fy_start_month, created_at) VALUES ('t2','T2','Other','regular',4,?)").run(new Date().toISOString())
- db.prepare("INSERT INTO store (id, tenant_id, code, name, state_code) VALUES ('s2','t2','ST9','Other Store','29')").run()
- db.prepare("INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('cc2','t2','s2','C1','C')").run()
- // commit as tenant t1 but point at t2's store/counter → must reject, never bill against t2's store
- const err = thrown(() => commitBill(db, { ...base(db), storeId: 's2', counterId: 'cc2' }))
- expect(err!.code).toBe('E-1110')
- expect(billCount(db)).toBe(0)
- })
-
- it('the honest same-tenant store/counter still commits (4-digit-FY doc no)', () => {
- const db = fresh()
- const out = commitBill(db, { ...base(db), storeId: 's1' })
- expect(out.docNo).toMatch(/^ST1C22026-/)
- expect(out.docNo.length).toBeLessThanOrEqual(16)
- expect(billCount(db)).toBe(1)
- })
-})
diff --git a/apps/store-server/tsconfig.json b/apps/store-server/tsconfig.json
deleted file mode 100644
index bf5a36d..0000000
--- a/apps/store-server/tsconfig.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "include": ["src/**/*"]
-}
diff --git a/docs/00-VISION.md b/docs/00-VISION.md
deleted file mode 100644
index 47c5572..0000000
--- a/docs/00-VISION.md
+++ /dev/null
@@ -1,85 +0,0 @@
-# 00 — Vision & Positioning
-
-## The one-line vision
-
-**The fastest counter in India, with a back office that runs itself.**
-
-Every design decision gets tested against two questions:
-1. Does this make billing at the counter faster or slower?
-2. Does this reduce the daily data-entry burden on the shop, or add to it?
-
-## Where we are today (SiMS Classic)
-
-- Oracle Forms & Reports, fully offline, client-server on the shop's own machine.
-- Proven domain coverage: inventory, billing, accounting, GST — customers pay for it today.
-- Problems: aging tech nobody can hire for, accumulated bugs, no multi-store story,
- no remote visibility for owners, every support issue needs someone at the machine,
- purchase entry is manual and slow, and the market is shifting to connected software.
-
-## What carries over (non-negotiable)
-
-These are the things existing customers would riot over if lost:
-
-1. **Counter speed.** Oracle Forms was keyboard-driven and instant. The new POS must be
- keyboard-first and *feel* faster than Classic, not just look prettier.
-2. **Works with no internet.** Billing can never stop because the connection dropped.
-3. **Full GST compliance** — invoices, returns data, e-Invoice, e-Way bill.
-4. **Depth of the domain model** — batches, expiry, MRP, multi-unit packs, schemes,
- party ledgers, credit (khata) — everything Classic already handles.
-5. **Their data.** Every existing customer migrates with full history via a Classic importer.
-
-## What changes (the upgrade)
-
-| Classic | Next |
-|---------|------|
-| Offline only, data trapped in the shop | Offline-first with cloud sync — bill offline, see everything online |
-| Manual purchase entry, item by item | **Purchase Inbox**: supplier bills arrive by email/WhatsApp/upload, AI parses them, staff just confirms |
-| Single store, single machine mindset | Multi-store, multi-counter, head-office console |
-| Owner must be in the shop to know anything | Owner mobile app: live sales, stock alerts, approvals from anywhere |
-| Support = remote desktop into the shop | Central telemetry, remote diagnostics, silent auto-updates |
-| Piracy/licensing enforced awkwardly | Subscription tied to cloud account, editions unlocked by plan |
-| Paper receipts only | WhatsApp e-bills, UPI dynamic QR at counter, loyalty |
-
-## Segments & editions — one codebase, four plans
-
-Do **not** build separate products per store size. Build one codebase with feature flags;
-the plan unlocks features. This keeps mini stores cheap to serve and gives them a natural
-upgrade path as they grow — which is exactly the "serve them better as they get bigger" goal.
-
-| Edition | Target | Shape |
-|---------|--------|-------|
-| **Lite** | Mini/small shops, single counter | POS + inventory + GST invoices + khata + WhatsApp bills. Dead simple. |
-| **Standard** | Medium stores | + multi-counter, multi-user roles, schemes/offers, barcode label printing, full purchase cycle |
-| **Pro** | Large stores & supermarkets | + weighing scale integration, batch/expiry at scale, loyalty, accounting module, e-Invoice/e-Way, Purchase Inbox |
-| **Enterprise** | Supermarket chains, multi-outlet | + head-office console, central pricing, inter-store transfers, consolidated GST & reporting, API access |
-
-## Business model
-
-- **Subscription, priced per store + per additional counter**, billed annually
- (Indian retail strongly prefers yearly; offer monthly only on Lite).
-- **In-app upgrades**: hitting a locked feature shows what it does and upgrades in place —
- the paywall is a demo, not a wall.
-- **Dealer/partner channel**: Indian retail software sells through local dealers who install,
- train, and support (the Tally/Marg model). Plan dealer margins, a dealer portal, and
- white-label invoicing for partners from the start — this is a distribution decision,
- not an afterthought.
-- **Migration offer**: existing Classic customers get assisted migration + a loyalty price.
- They are the beachhead — reference customers for everyone else.
-
-## Differentiators to lead with
-
-1. **Purchase Inbox (AI)** — forward the supplier's bill, confirm, done. Nobody in the
- Indian SMB segment does this well yet. This is the demo moment.
-2. **Counter speed** — publish the numbers: scan-to-line < 150 ms, bill closed in one key.
-3. **True offline-first** — competitors are either offline-only (like Classic) or
- cloud-first-with-excuses. Being genuinely both is the moat.
-4. **Owner app** — live business in the owner's pocket, in their language.
-5. **WhatsApp-native** — e-bills, payment reminders for khata credit, offer broadcasts.
-
-## Success metrics
-
-- Time per billed item at counter (target: ≤ Classic, measured, not vibes)
-- % of purchase lines entered via Purchase Inbox vs manually (target: 70%+ by Phase 3)
-- Classic → Next migration rate (target: 80% of active base in year 1)
-- Sync health: % of stores fully synced within 5 min of connectivity (target: 99%)
-- Support tickets per store per month (target: half of Classic's rate)
diff --git a/docs/01-SCOPE.md b/docs/01-SCOPE.md
deleted file mode 100644
index 29fe1fa..0000000
--- a/docs/01-SCOPE.md
+++ /dev/null
@@ -1,137 +0,0 @@
-# 01 — Product Scope: Modules & Features
-
-Priorities: **M** = MVP (Phase 1), **2/3/4** = later phase, per [04-ROADMAP.md](04-ROADMAP.md).
-Edition column shows the lowest plan that includes it (L=Lite, S=Standard, P=Pro, E=Enterprise).
-
-## 1. Master data
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Items: name (multi-language), HSN, GST rate, MRP, cost, margin, barcode(s) | M | L |
-| Multi-unit packs (box ↔ piece ↔ kg conversions, buy in one unit sell in another) | M | L |
-| Batch & expiry tracking (FMCG, pharma, agri-input) | M | P (flag off below) |
-| Item variants (size/color) for apparel & footwear | 2 | S |
-| Price lists: MRP-based, margin-based, customer-category pricing | M | S |
-| GST rate masters with **effective dates** (rates change — Sept 2025 GST 2.0 proved it) | M | L |
-| Parties: customers & suppliers with GSTIN validation, credit limits, ledgers | M | L |
-| Schemes & offers: buy-X-get-Y, slab discounts, happy hours, date-bound | 2 | S |
-| Bulk import/export (Excel/CSV) for every master | M | L |
-| Item images, shelf location, rack labels | 3 | S |
-
-## 2. POS / Counter billing (the crown jewel)
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Keyboard-first billing: scan/type → line added, one key to close bill | M | L |
-| Barcode scanner (keyboard wedge) support | M | L |
-| Fast fuzzy item search (name, code, partial, phonetic) — results < 150 ms | M | L |
-| Hold / resume multiple bills per counter | M | L |
-| Weighing-scale barcodes (EAN-13 `2x` prefix embedding PLU + weight/price) | 2 | P |
-| Direct weighing-scale port integration (weight into line) | 3 | P |
-| Payment split: cash / UPI / card / credit(khata) / mixed | M | L |
-| UPI dynamic QR on screen & customer display, auto-confirm on webhook (online) | 2 | S |
-| Thermal print (ESC/POS 2"/3"), A4/A5 invoice formats, print profiles per counter | M | L |
-| WhatsApp/SMS e-bill instead of (or with) paper | 2 | L |
-| Returns & exchanges against original bill; credit notes | M | L |
-| Salesman tagging per bill/line (commission reporting) | 2 | S |
-| Customer display (second screen: lines, total, QR, promos) | 3 | P |
-| Counter shift open/close, cash denominations count, cashier reconciliation | M | S |
-| Offline always: every one of the above works with zero internet | M | L |
-| Multi-counter per store, per-counter GST-compliant invoice series (e.g. `ST1C2-00123`) | M | S |
-| Day-end closing report (sales, payments, cash variance) auto-generated | M | L |
-
-## 3. Purchases
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Purchase invoice entry with margin/MRP capture and auto price update prompts | M | L |
-| PO → GRN → Invoice flow with pending-PO tracking | 2 | P |
-| **Purchase Inbox**: email-in address + upload + WhatsApp forward; AI parses PDF/image/e-invoice JSON into draft purchase; staff reviews & confirms | 3 | P |
-| Item matching memory (supplier's item name ↔ our item, learns once, reuses forever) | 3 | P |
-| GSTR-2B reconciliation: pull portal data, match against entered purchases | 3 | P |
-| Barcode label printing from purchase (batch, MRP, packed date) | M | S |
-| Supplier price history & last-cost warnings | 2 | S |
-| Purchase returns / debit notes | M | L |
-
-## 4. Inventory
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Live stock by store/location, valuation (FIFO / weighted avg) | M | L |
-| Stock adjustments with reason codes & approval | M | L |
-| Stock take: full & cycle counts, mobile scanning app for counting | 3 | P |
-| Reorder levels, auto reorder suggestions (by sales velocity) | 2 | S |
-| Inter-store transfer with in-transit state, e-Way bill where applicable | 3 | E |
-| Expiry dashboard: near-expiry, dead stock, fast/slow movers | 2 | P |
-| Wastage/damage logging | M | S |
-
-## 5. Accounting & GST compliance
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Party ledgers, receivables/payables, ageing, collection reminders (WhatsApp) | M | L |
-| Cash & bank books, payment/receipt vouchers, expense entry | M | S |
-| Day book, trial balance, P&L, balance sheet | 2 | P |
-| **Tally export** (masters + vouchers) for customers whose CA lives in Tally | 2 | S |
-| GST invoice formats (B2B/B2C), HSN summary | M | L |
-| GSTR-1 & GSTR-3B report/JSON export | 2 | S |
-| e-Invoice (IRN via GSP API) — mandatory for B2B above turnover threshold | 2 | P |
-| e-Way bill generation | 2 | P |
-| Tamper-evident audit trail / edit log (MCA audit-trail rule for company clients) | M | S |
-| Financial year close, data lock dates | 2 | S |
-
-## 6. Multi-store & head office
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Store onboarding under one tenant, central item/price masters with store overrides | 3 | E |
-| HO console: consolidated sales, stock, GST across outlets | 3 | E |
-| Central purchasing & distribution to outlets | 4 | E |
-| Franchise/outlet-wise P&L | 4 | E |
-
-## 7. Users, roles, audit
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Role-based permissions (cashier, supervisor, purchaser, accountant, owner, auditor) | M | S |
-| Sensitive-action gates: discount above X%, bill void, price override → supervisor PIN | M | S |
-| Append-only audit log of every edit/delete with before/after values | M | S |
-| Per-user activity reports | 2 | S |
-
-## 8. Reports, analytics, owner app
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Core reports: sales, margins, GST, stock, party outstanding — with export | M | L |
-| Owner mobile app: today's sales live, payments, stock alerts, approvals | 3 | S |
-| Dashboards: trends, category performance, counter productivity | 3 | P |
-| Scheduled reports to email/WhatsApp (daily digest) | 3 | S |
-
-## 9. Customer engagement
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Customer profiles with purchase history (phone-number keyed) | M | L |
-| Loyalty points, redemption at counter | 3 | P |
-| Khata credit with WhatsApp payment reminders + UPI collect link | 2 | L |
-| Offer broadcasts (WhatsApp, opt-in) | 4 | P |
-
-## 10. Platform (invisible but decisive)
-
-| Feature | Priority | Edition |
-|---------|----------|---------|
-| Licensing & subscription service; editions via feature flags; in-app upgrade | M | — |
-| Silent auto-update with staged rollout & rollback | M | — |
-| Sync engine + sync health dashboard (see 02-ARCHITECTURE) | M | — |
-| Automatic encrypted cloud backup of every store DB | M | — |
-| Telemetry & crash reporting (respecting data privacy / DPDP Act) | M | — |
-| **SiMS Classic migration tool** (Oracle schema → Next), with verification report | M | — |
-| Multi-language UI: English + Hindi + regional (start with your customer base's top 2) | 2 | L |
-| Onboarding wizard: GSTIN fetch → store profile → import items → first bill in 15 min | 2 | — |
-
-## Explicitly out of scope for v1
-
-- Restaurant/QSR mode (tables, KOT) — different product shape; revisit after Phase 3.
-- E-commerce storefront / ONDC integration — Phase 4+ candidate.
-- Manufacturing/BOM — only if the existing base demands it.
-- Payroll — never; integrate instead.
diff --git a/docs/02-ARCHITECTURE.md b/docs/02-ARCHITECTURE.md
deleted file mode 100644
index 74f8ba5..0000000
--- a/docs/02-ARCHITECTURE.md
+++ /dev/null
@@ -1,196 +0,0 @@
-# 02 — Architecture: Offline vs Online, Sync, Stack
-
-## 1. The offline/online question (the brainstorm you asked for)
-
-### Option A — Pure offline (what Classic is)
-
-**Benefits**
-- Counter never depends on internet; zero latency; works in tier-3 towns with bad connectivity.
-- No hosting cost; customer feels "my data is mine, in my shop."
-- Simple mental model, simple debugging (one machine, one DB).
-
-**Defects**
-- No multi-store, no owner-away-from-shop visibility, no Purchase Inbox (needs cloud).
-- Support requires reaching into the shop machine; updates are a door-to-door problem.
-- Data loss when the shop PC dies and backups were "supposed to happen."
-- Licensing/piracy enforcement is weak; subscriptions are hard to enforce.
-- No telemetry: you learn about bugs from angry calls.
-
-### Option B — Pure cloud (browser POS, server is truth)
-
-**Benefits**
-- One deployment, instant updates, central data, easy multi-store, easy licensing.
-- Any device with a browser becomes a counter.
-
-**Defects**
-- **Internet dies → billing dies. Fatal in India.** This alone disqualifies pure cloud
- for the counter. Competitors who went cloud-first all retrofitted awkward offline modes.
-- Every scan pays network latency; "fast counter" becomes a fight with physics.
-- Browser hardware access (thermal printers, cash drawers, scales) is painful.
-- Customer trust: "my sales data lives on your server?" is a real sales objection.
-
-### Option C — Local-first hybrid (the answer)
-
-**The rule: the counter reads and writes a local database, always. The cloud is a
-sync target, a backup, a reporting brain, and a licensing authority — never a
-dependency for billing.**
-
-**Benefits**
-- Billing works identically with or without internet — offline is not a "mode," it's
- the architecture. Sync happens in the background when connectivity exists.
-- Gets everything cloud promises: multi-store, owner app, Purchase Inbox, central
- reporting, subscriptions, telemetry, auto-update, off-site backup.
-- Latency at counter is local-DB latency (~0 ms network).
-
-**Defects (and how we contain them)**
-- *Sync is genuinely hard.* → Contain it with strict design rules (§3) and buy, don't
- build, where possible. Sync is the single biggest technical risk of this project —
- it gets a proof-of-concept before anything else (see roadmap Phase 0).
-- *Conflicts.* → Design them away: transactional documents are append-only and
- counter-scoped (they cannot conflict); only master-data edits can conflict, and those
- get last-write-wins + full audit trail + rare-enough-to-review.
-- *Version skew* (store app v2.3 talking to cloud v2.5). → Versioned sync protocol,
- additive-only schema changes between forced upgrades.
-- *Harder debugging.* → Sync health dashboard per store, op logs, replayability.
-
-**Decision: Option C.** Back office can lean online (it tolerates a spinner; the counter
-cannot), owner app is online-first with cached reads, POS is local-first, period.
-
-## 2. System topology
-
-```
- ┌──────────────────────────────────────────┐
- │ CLOUD │
- │ Multi-tenant Postgres (RLS by tenant) │
- │ API + Sync service Licensing/Plans │
- │ Purchase Inbox (email ⇢ AI parse) │
- │ Reporting/analytics Backups │
- │ GSP bridge (e-Invoice / e-Way) │
- │ WhatsApp/SMS/UPI integrations │
- └─────────▲──────────────────▲─────────────┘
- │ sync (queued ops) │ https
- ┌────────────────────┴───┐ ┌────┴─────────────┐
- │ STORE (each) │ │ Back-office Web │
- │ ┌──────────────────┐ │ │ (browser, admin │
- │ │ Store Hub (opt.) │ │ │ & reports) │
- │ │ local sync fan-in│ │ └──────────────────┘
- │ └───▲────────▲─────┘ │ ┌──────────────────┐
- │ ┌───┴───┐ ┌──┴────┐ │ │ Owner Mobile │
- │ │ POS 1 │ │ POS 2 │… │ │ (online + cache)│
- │ │SQLite │ │SQLite │ │ └──────────────────┘
- │ └───────┘ └───────┘ │
- │ printers, scanner, │
- │ scale, cash drawer, │
- │ customer display │
- └────────────────────────┘
-```
-
-- **POS client**: desktop app, embedded SQLite is the source of truth for that counter.
-- **Store Hub** (optional, Standard+): one machine in the store acts as LAN relay so
- counters see each other's bills/stock instantly even when the internet is down;
- it also fan-ins sync to cloud. For single-counter Lite, the POS syncs direct to cloud.
-- **Cloud**: multi-tenant; per-tenant row isolation; all cross-store logic lives here.
-
-## 3. Sync design rules (write these on the wall)
-
-1. **Client-generated IDs everywhere** (UUIDv7). No "wait for server to get an ID."
-2. **Transactional documents are immutable events.** A bill, once closed, is an
- append-only fact. Corrections are new documents (credit note, amendment) referencing
- the original. This kills 90% of conflict scenarios and satisfies audit requirements
- in one stroke.
-3. **Invoice numbering is per-counter series** — e.g. `ST1C2-000123` (GST allows multiple
- series; ≤16 chars, unique & consecutive within a series per FY). Counters can therefore
- number bills offline with zero coordination.
-4. **Outbox/inbox pattern**: every local write that must reach the cloud goes into a local
- outbox table in the same SQLite transaction as the write itself; a background worker
- drains it with retries + idempotency keys. Downstream (cloud→store) is a cursor-based
- pull of changes since last sync.
-5. **Master data flows down, transactions flow up.** Masters are edited in back office
- (cloud) and pushed to stores; store-side master edits (allowed on lower plans) sync up
- with last-write-wins + audit log.
-6. **Stock is derived, never synced as a number.** Each store's stock = fold of its local
- events; cloud recomputes global views. Never ship "stock = 47" between nodes — ship the
- movements.
-7. **Clocks lie.** Order by (lamport counter, device id), keep wall-clock only for display.
-8. **Sync must be observable**: per-store last-sync time, pending-op count, error surface
- in both the POS status bar and a support dashboard.
-
-**Build vs buy:** evaluate PowerSync and ElectricSQL (Postgres ⇄ SQLite sync engines)
-against a hand-rolled outbox in the Phase-0 spike. Rule of thumb: buy the pipe if it fits,
-but the *semantic* rules above stay ours either way.
-
-## 4. Multi-tenancy & audit
-
-- Single Postgres cluster, `tenant_id` on every row, enforced with Row-Level Security;
- the API sets tenant context from the auth token. (Schema-per-tenant only if a large
- Enterprise chain demands isolation — don't start there.)
-- Tenant = the client business; stores, counters, users hang off it. All client-specific
- compliance settings (GST scheme, FY, invoice formats, rounding rules) are tenant config.
-- **Audit**: append-only `audit_log` (who, what, before, after, when, where) written in the
- same transaction as the change, synced up, never deletable — this covers the MCA
- audit-trail requirement for company clients and your own support forensics.
-- Backups: continuous cloud PITR + nightly encrypted per-tenant export; store-side SQLite
- snapshots shipped to cloud so a dead shop PC costs minutes, not the business.
-- DPDP Act hygiene: customer phone numbers are personal data — encrypt at rest, purge on
- request, keep telemetry free of PII.
-
-## 5. GST/compliance layer (data-driven, never hard-coded)
-
-- GST rates, cess, and slab structures are **dated configuration**, not code — the
- Sept 2025 GST 2.0 slab overhaul is the proof that rates change under you.
-- e-Invoice (IRN) and e-Way bill via a **GSP aggregator API** (ClearTax / Masters India /
- similar) rather than direct IRP integration — one decision in [06-DECISIONS.md](06-DECISIONS.md).
-- Queue-and-forward: bills that need IRN but were made offline are queued and registered
- when online, within the reporting window; the POS shows pending-IRN status.
-
-## 6. Tech stack — recommendation
-
-**Primary recommendation: TypeScript end-to-end.** One language, the largest hiring pool
-in India, one skill set across all four surfaces, and the web back office shares
-components with the POS UI.
-
-| Layer | Choice | Why |
-|-------|--------|-----|
-| POS desktop | **Electron + React + SQLite** | Mature hardware story on Windows (ESC/POS, serialport for scales/drawers/displays), offline SQLite, auto-update solved, huge ecosystem. Tauri is the leaner alternative — decide after the Phase-0 hardware spike. |
-| Back office | **React SPA** (same design system as POS) | Browser-only, no install; admins tolerate online-only. |
-| Owner app | **React Native** | Shares TS models/API client; Android-first (your market), iOS later. |
-| API/backend | **Node.js + NestJS + PostgreSQL + Redis** | Structured enough for a long-lived product; trivially hireable. |
-| Sync | PowerSync / ElectricSQL **or** custom outbox — Phase-0 spike decides | Biggest risk, prove it first. |
-| AI (Purchase Inbox) | Claude API (vision + structured output) behind our own parsing service | PDF/photo invoice → structured draft purchase; item-matching memory in Postgres. |
-| Infra | Managed Postgres + containers on an Indian region (data residency comfort) | Mumbai region; boring and reliable. |
-
-> **Note (D12, decided 2026-07-09):** the store-tier DB remains the existing **Oracle 12c**
-> for now — it is shared infrastructure running other systems. Counters keep their local
-> SQLite buffer (offline bill queue + item cache); the Store Hub role is played by the
-> Oracle box; sync is a custom outbox (D3 → build). Adapted topology, guardrails, and
-> revisit triggers: [07-DB-AND-CONFIG.md](07-DB-AND-CONFIG.md) §3.
->
-> **Staging (D14, 2026-07-09):** the cloud tier in the topology above is switched on
-> *after* the local-only v1 ships. From the first build, all writes carry client UUIDs
-> and dormant outbox rows so cloud enablement is a switch-on, not a migration.
-
-**Considered alternatives** (kept honest in one line each):
-- **Flutter everywhere**: one codebase POS-desktop + Android-POS + owner app; weaker
- Windows-desktop/hardware maturity and you'd still want a web back office. Strong option
- if Android POS terminals are a v1 target — see 06-DECISIONS.
-- **.NET (WPF/WinUI + ASP.NET Core)**: best raw Windows hardware story; smaller shared-code
- story across web/mobile, second hiring pool. Solid, less leverage.
-
-## 7. Hardware integration checklist (POS)
-
-Barcode scanners (keyboard wedge — free), ESC/POS thermal printers 2"/3" (network/USB),
-A4/A5 laser printing, cash drawers (printer-kick), weighing scales (RS-232/USB, and
-label-scale EAN-13 `2x` barcodes), customer-facing display (second monitor or pole
-display), UPS-friendly crash recovery (journaled SQLite, resume mid-bill after power cut).
-
-## 8. Non-functional targets
-
-| Metric | Target |
-|--------|--------|
-| Scan/type → line rendered | < 150 ms |
-| Item search (50k SKUs) | < 150 ms |
-| Bill close + print spool | < 1 s |
-| POS cold start to billable | < 5 s |
-| Offline duration tolerated | Unlimited (sync catches up) |
-| Store→cloud sync lag when online | < 5 min p99 |
-| POS crash data loss | Zero committed lines (journaled writes) |
diff --git a/docs/03-FRONTEND.md b/docs/03-FRONTEND.md
deleted file mode 100644
index 3215011..0000000
--- a/docs/03-FRONTEND.md
+++ /dev/null
@@ -1,92 +0,0 @@
-# 03 — Frontend Plan
-
-Four surfaces, one design system, one TypeScript codebase philosophy.
-
-## Surfaces
-
-| Surface | Tech | Mode | Users |
-|---------|------|------|-------|
-| **POS Counter** (desktop) | Electron + React | Local-first, offline always | Cashiers, supervisors |
-| **Back Office** (web) | React SPA | Online (tolerates it) | Owner, manager, purchaser, accountant |
-| **Owner App** (mobile) | React Native, Android-first | Online + cached | Owner on the move |
-| **Customer Display** | Second window/screen from POS | Local | The person in the queue |
-
-## The counter creed (UX principles for POS)
-
-1. **Keyboard is king.** Every billing action reachable without a mouse. Classic users'
- muscle memory is the migration moat — publish the keymap, keep it configurable,
- offer a "Classic keys" preset that mirrors the old Oracle Forms bindings.
-2. **The scan line is sacred.** Focus always returns to the scan/search box. No dialog,
- toast, or sync event may steal focus mid-bill. Ever.
-3. **One screen, no navigation.** Billing is a single screen: item entry, bill lines,
- totals, payment. Payment is an overlay, not a page. Target: close a cash bill with
- **one keystroke** from the last scanned item.
-4. **Errors never trap.** Unknown barcode → inline quick-add or skip, keep billing.
- Printer jam → bill saves, reprint later. Sync down → icon changes color, nothing else.
-5. **Speed is visible.** Show per-bill timing internally (telemetry); we optimize what
- we measure. The queue is the enemy.
-6. **Touch is secondary but real** — large hit targets on the right rail for
- touch-counter deployments; never at the cost of keyboard flow.
-7. **Language switch per user** — cashier sees Hindi/regional, owner sees English,
- same install.
-8. **Every state is recoverable** — power cut mid-bill → app reopens with the bill
- intact (journaled local writes).
-
-## POS screen inventory
-
-| Screen | Notes |
-|--------|-------|
-| Login / shift open | PIN-fast; denomination count on open |
-| **Billing** (the screen) | Scan box, lines grid, totals, customer picker (phone), hold/resume tray, payment overlay (cash/UPI-QR/card/khata/split), quick-keys row for loose produce |
-| Returns/exchange | Lookup by bill no / phone / scan of e-bill QR |
-| Bill history & reprint | Day-scoped by default |
-| Item quick-add | Minimal fields, flagged for back-office completion |
-| Shift close / day-end | Cash count, variance, day report print |
-| Supervisor overlay | PIN-gated: void, discount override, price change |
-| Settings/diagnostics | Printers, scale, display, sync status, keymap |
-
-## Back office screen inventory (by module)
-
-- **Dashboard**: today across counters/stores, alerts (low stock, near expiry, khata due, sync health).
-- **Catalog**: items list (bulk edit, import), item editor, categories, price lists, schemes, label printing queue.
-- **Purchases**: purchase list, entry form, **Purchase Inbox review queue** (parsed draft ⇄ source PDF side-by-side, item-match confirmations), supplier ledger, GSTR-2B reconciliation.
-- **Inventory**: stock view (filters: store/category/batch/expiry), adjustments, transfers, stock-take sessions.
-- **Sales**: bill browser, returns, customer profiles, loyalty, khata & collections (WhatsApp reminder actions).
-- **Accounting**: vouchers, ledgers, day book, P&L/BS, Tally export, GST returns (GSTR-1/3B), e-Invoice/e-Way queues.
-- **HO console** (Enterprise): store comparison, central pricing pushes, consolidated GST.
-- **Admin**: users/roles/permission matrix, audit log browser, stores & counters, subscription/plan, backup status.
-
-## Owner app (v1 scope, deliberately small)
-
-Today's sales (live), payments breakdown, top items, stock alerts, khata outstanding,
-approval requests (big discounts/voids), daily digest notification. Read-mostly; one
-write action: approve/deny.
-
-## Design system
-
-- **Tokens first** (color/spacing/type in one package) — POS, web, and mobile consume the
- same tokens; theme per surface (POS: high-contrast, dense; back office: comfortable).
-- Dark and light from day one (night-shift counters love dark).
-- Numerals: tabular figures everywhere money appears; ₹ formatting with Indian digit
- grouping (1,23,456.78) in one shared formatter.
-- Grid/table component gets the most investment — half the back office is tables.
- Virtualized, keyboard-navigable, exportable.
-- Print templates (thermal 2"/3", A4/A5 invoice, labels) as data-driven templates with a
- visual preview editor in settings — per-tenant customization without code. This was a
- Reports-era pain point; make it a strength.
-- Component stack suggestion: Radix primitives + Tailwind + our tokens (or Mantine if
- speed beats control). Icon set: Lucide. Charts: lightweight (recharts) in back office only.
-
-## Frontend engineering rules
-
-- Local SQLite is the POS source of truth; UI reads via a thin repository layer —
- no component ever awaits the network on the billing path.
-- State: keep it boring — TanStack Query for server state (back office), Zustand for
- POS session state; no global state kitchen-sink.
-- Every screen testable headless: business logic in plain TS modules, UI is a shell.
- Billing engine (pricing, tax, rounding, schemes) is a **pure, versioned package** with
- golden-file tests — the same engine runs in POS, back office, and cloud so a bill
- computes identically everywhere.
-- Feature flags evaluated client-side from the license token; locked features render
- as discoverable upsells, not hidden.
-- i18n from the first commit (string files, no literals) — retrofitting is misery.
diff --git a/docs/04-ROADMAP.md b/docs/04-ROADMAP.md
deleted file mode 100644
index ba04038..0000000
--- a/docs/04-ROADMAP.md
+++ /dev/null
@@ -1,125 +0,0 @@
-# 04 — Roadmap
-
-Assumes a core team of ~4–6 (2–3 app devs, 1–2 backend, 1 QA/support who knows Classic).
-Durations stretch/shrink with team size; the **sequence and exit criteria** are the point.
-Dates below start from project kickoff.
-
-> ## ⚡ Staging update (founder call, 2026-07-09 — see D14)
-> **Build the local product first; add the cloud tier afterwards.** Phase 1 ships a
-> fully working local-only system: POS counters (SQLite buffer) + the existing in-store
-> Oracle 12c as store master (D12), offline licensing as Classic does today, local +
-> exported backups. The cloud tier (Postgres, sync agent, licensing service, auto-update
-> channel) is stood up as its own workstream and switched on per store afterwards.
->
-> **The one non-negotiable that makes this safe:** even in local-only v1, every write
-> uses client-generated UUIDs and also writes its outbox row (dormant, drained by
-> nothing). Cloud enablement then = install sync agent + drain backlog — a switch-on,
-> not a data migration. Skip this and "add cloud later" becomes a second migration project.
->
-> **Deferred until the cloud tier exists** (moves with it, not with the phase numbers
-> below): owner mobile app, Purchase Inbox, WhatsApp e-bills/reminders, UPI webhook
-> auto-confirm, central backups, silent auto-update, HQ fleet dashboards (the HQ *ops*
-> console starts earlier — see the HQ Ops workstream below), e-Invoice via
-> GSP (can interim-run through a manual portal flow if needed before then).
-
-## Phase 0 — Prove the risky bits (4–6 weeks)
-
-Goal: kill the project-killers before writing product code.
-
-- **Sync spike**: two POS clients + cloud; bills offline on both, kill the network, sync,
- verify stock & numbering correctness. Evaluate PowerSync/ElectricSQL vs custom outbox.
-- **Hardware spike**: Electron app printing ESC/POS (2"/3"), cash-drawer kick, scanner
- input, one weighing scale. This decides Electron vs Tauri (vs Flutter).
-- **Billing engine v0**: pure-TS pricing/GST/rounding engine with golden tests derived
- from real Classic bills (grab 100 real bills, new engine must reproduce them to the paisa).
-- **Classic data audit**: map the Oracle schema; export 2–3 real customer datasets;
- identify the ugly bits (they exist).
-- **UX prototype**: clickable billing screen; put it in front of 3 real cashiers; time them.
-- Founder decisions locked: 06-DECISIONS items D1–D5.
-
-**Exit criteria:** sync demo survives a pulled cable; printed bill from Electron; engine
-reproduces Classic bills; stack decision written down.
-
-## Phase 1 — MVP: one store bills on it all day (3–4 months)
-
-Scope (all "M" items in [01-SCOPE.md](01-SCOPE.md)): masters + import, POS billing
-offline with print, payments (cash/UPI-static/card/khata), returns, purchases (manual),
-stock, day-end, party ledgers, GST invoice formats, core reports, users/roles/audit,
-licensing skeleton, auto-update, cloud backup, **Classic migration tool alpha**.
-
-**Pilot:** 3–5 friendly existing customers (one mini, one medium, one busy counter) run
-Next **in parallel with Classic** for 2+ weeks, then cut over. Their cashiers' stopwatch
-beats our opinions.
-
-**Exit criteria:** a real store bills a full day with zero Classic fallback; migration
-tool moves a real customer with verification report green; counter timing ≤ Classic.
-
-## Phase 2 — Compliance depth + counter polish (2–3 months)
-
-GSTR-1/3B exports, e-Invoice + e-Way via GSP, Tally export, schemes/offers, UPI dynamic
-QR with auto-confirm, WhatsApp e-bills & khata reminders, multi-counter + Store Hub LAN
-sync, weighing-scale barcodes, salesman tracking, accounting vouchers/day book,
-multi-language UI (top 2 languages), onboarding wizard.
-
-**Exit criteria:** a supermarket with 3+ counters runs through GST filing month entirely
-on Next; dealer can onboard a new store without us on the call.
-
-## Phase 3 — The differentiators (2–3 months)
-
-**Purchase Inbox** (email/upload → AI parse → review queue → posted purchase, with
-item-matching memory), GSTR-2B reconciliation, owner mobile app v1, stock-take mobile
-scanning, dashboards & scheduled digests, loyalty, PO/GRN flow, expiry dashboards.
-
-**Exit criteria:** ≥50% of pilot stores' purchase lines flow through the Inbox;
-owner app DAU > 60% of owners.
-
-## Phase 4 — Scale-out (ongoing)
-
-Multi-store HO console, central pricing, inter-store transfers, consolidated reporting,
-Enterprise onboarding for chains, API access, customer display/promo engine, offer
-broadcasts, then evaluate: Android POS terminals, ONDC/e-commerce bridges, restaurant mode.
-
-## Migration & go-to-market thread (runs across all phases)
-
-1. Phase 1: migration tool + parallel-run playbook; migrate the 5 pilots.
-2. Phase 2: dealer training kit, migration-week checklist, Classic price-lock offer;
- migrate the top 20% (largest/most engaged) of the base.
-3. Phase 3: mass migration waves by region with dealer incentives; Classic enters
- maintenance-only mode (security/GST patches).
-4. Phase 4: Classic sunset date announced — only after >80% of active base has moved
- and Next's support ticket rate is demonstrably lower.
-
-## HQ Ops workstream — the internal console (parallel track)
-
-New workstream (founder call, 2026-07-09 — **D15 ✅ RESOLVED**, [06-DECISIONS.md](06-DECISIONS.md)):
-build `apps/hq` per [14-SPEC-HQ-CONSOLE.md](14-SPEC-HQ-CONSOLE.md) — the
-[doc-11 HQ Console](11-ADMIN-SUPPORT-CONSOLE.md) **started early**, aimed at the
-**current ~300 Classic clients** (quotations/proforma/invoices in minutes, recurring
-billing + reminders, AMC, per-client AWS cost, interaction log), replacing the internal
-Oracle APEX app entirely. Runs **in parallel with Phases 0–4**, staffed lightly (~1 dev),
-reusing the tested foundation packages (billing-engine, domain, ui, auth patterns) — it
-doesn't compete with the product track for people.
-
-**Reconciling with the staging note above:** this does **not** change D14 — the store
-product stays local. The HQ Console is *vendor-side* cloud, which doc 11 already
-prescribes. The **fleet half** (fleet dashboards, migration tracker UI, remote support)
-still waits for the cloud tier as deferred above — only the **ops/billing slice** starts
-now, on the same client registry the fleet features will later share.
-
-| Phase | Ships (condensed from spec §6) |
-|---|---|
-| **HQ-1** | Skeleton + auth/audit; client registry (APEX import + verification); module catalog + module_price_book; client_module tracking; **quotation/proforma/invoice + letterhead PDF + Gmail send + payment recording** |
-| **HQ-2** | Recurring plans + reminder engine (auto + manual queue); AMC contracts; money dashboard + follow-ups; interaction/visit/call/training log |
-| **HQ-3** | AWS usage & cost per client + margin view; reports (dues aging, module revenue, client profitability); Client 360° polish |
-| **HQ-4** | Convergence with doc-11 fleet features once SiMS Next ships — migration tracker, fleet dashboards, support queue on the same registry, no rebuild |
-
-## Standing risks to watch
-
-| Risk | Mitigation |
-|------|-----------|
-| Sync correctness bugs erode trust fast | Phase-0 spike, property-based tests, sync health dashboard, replayable op logs |
-| New POS feels slower than Classic to veteran cashiers | Keyboard-first + "Classic keys" preset; measure per-bill timings in pilots; don't ship until ≤ Classic |
-| GST rule changes mid-build | Rates/thresholds as dated config; GSP handles portal churn |
-| Migration data horror stories | Verification report (counts, balances, stock match) per migration; parallel-run period mandatory |
-| Scope creep from Classic parity ("but it had X") | Parity backlog triaged against usage data from Classic telemetry/pilots, not memory |
-| Team spread across 4 surfaces | Phase 1 ships POS + minimal back office only; owner app waits for Phase 3 |
diff --git a/docs/05-CHECKLIST.md b/docs/05-CHECKLIST.md
deleted file mode 100644
index 30de47c..0000000
--- a/docs/05-CHECKLIST.md
+++ /dev/null
@@ -1,90 +0,0 @@
-# 05 — Master Build Checklist
-
-Working checklist; tick as we go. Ordered roughly by phase. Details live in the other docs.
-
-## Foundations (Phase 0)
-- [ ] Product name + branding decided (D1)
-- [ ] Tech stack locked after spikes (D2)
-- [ ] Sync approach locked: buy (PowerSync/ElectricSQL) vs custom outbox (D3)
-- [ ] GSP vendor chosen for e-Invoice/e-Way (D4)
-- [ ] Pricing tiers & edition feature matrix signed off (D5)
-- [ ] Sync spike passes pulled-cable test
-- [ ] Hardware spike: ESC/POS print, drawer kick, scanner, scale — from chosen shell
-- [ ] Billing engine v0 reproduces 100 real Classic bills to the paisa
-- [ ] Oracle schema mapped; 3 real datasets exported and audited
-- [ ] Billing screen prototype tested with 3 real cashiers (timed)
-- [ ] Repo, CI, environments (dev/stage/prod), code review rules, error tracking
-
-## Data model & platform
-- [ ] Tenant → stores → counters → users model with RLS
-- [ ] Item master (HSN, GST dated rates, MRP, multi-unit, barcodes, batch/expiry)
-- [ ] Party master (GSTIN validation, credit limits)
-- [ ] Document model: append-only bills/notes with client UUIDs, per-counter series
-- [ ] Audit log: append-only, before/after, synced, unpurgeable
-- [ ] Outbox/inbox sync tables + background worker + idempotency
-- [ ] Licensing service: plans → feature flags → signed license token
-- [ ] Auto-update channel with staged rollout + rollback
-- [ ] Encrypted cloud backup of store DBs + restore drill actually performed
-- [ ] Telemetry + crash reporting (PII-free)
-
-## POS (Phase 1)
-- [ ] Billing screen: scan box, lines, totals, hold/resume, one-key cash close
-- [ ] Fuzzy item search < 150 ms on 50k SKUs
-- [ ] Payments: cash, card (record), UPI static QR, khata, split
-- [ ] Returns/exchange + credit notes
-- [ ] Thermal + A4/A5 printing, print profiles, reprint
-- [ ] Shift open/close, denomination count, day-end report
-- [ ] Supervisor PIN gates (void, discount cap, price override)
-- [ ] Power-cut recovery: reopen mid-bill intact
-- [ ] Offline soak test: 48h no internet, then clean sync
-- [ ] Keyboard map + "Classic keys" preset
-
-## Back office (Phase 1–2)
-- [ ] Item/party CRUD + Excel import/export
-- [ ] Purchase entry with price/MRP update prompts + label print queue
-- [ ] Stock views, adjustments, valuation
-- [ ] Ledgers, outstanding, ageing
-- [ ] Core reports + GST invoice formats, HSN summary
-- [ ] Users/roles/permission matrix + audit browser
-- [ ] Dashboard with sync-health per store
-
-## Compliance (Phase 2)
-- [ ] GSTR-1 / GSTR-3B JSON exports
-- [ ] e-Invoice IRN queue (offline-tolerant) via GSP
-- [ ] e-Way bill generation
-- [ ] Tally export (masters + vouchers)
-- [ ] FY close + lock dates
-- [ ] Audit-trail compliance review (MCA) for company clients
-
-## Engagement & polish (Phase 2)
-- [ ] WhatsApp e-bill + khata reminders + UPI collect links
-- [ ] UPI dynamic QR + webhook auto-confirm
-- [ ] Schemes/offers engine (golden tests)
-- [ ] Multi-counter + Store Hub LAN sync
-- [ ] Weighing-scale barcode support
-- [ ] Hindi + one regional language UI
-- [ ] Onboarding wizard: GSTIN → store → import → first bill < 15 min
-
-## Differentiators (Phase 3)
-- [ ] Purchase Inbox: email-in + upload + parse (AI) → draft → review UI → post
-- [ ] Item-matching memory (supplier name ⇄ our SKU)
-- [ ] GSTR-2B reconciliation
-- [ ] Owner app v1 (sales live, alerts, approvals)
-- [ ] Stock-take mobile scanning
-- [ ] Loyalty + redemption at counter
-- [ ] Dashboards + scheduled WhatsApp/email digests
-
-## Migration & GTM (continuous)
-- [ ] Classic → Next migration tool with verification report
-- [ ] Parallel-run playbook + cutover checklist
-- [ ] 5 pilot stores migrated and billing daily
-- [ ] Dealer kit: install, train, migrate, support scripts
-- [ ] Pricing/upgrade offers for Classic base
-- [ ] Support runbook + sync diagnostics dashboard for the support team
-
-## Scale-out (Phase 4)
-- [ ] HO console, central pricing, transfers with e-Way
-- [ ] Consolidated multi-store GST + reporting
-- [ ] API access for Enterprise
-- [ ] Android POS evaluation
-- [ ] Classic sunset plan (>80% migrated first)
diff --git a/docs/08-MARKET-ARCHITECTURES.md b/docs/08-MARKET-ARCHITECTURES.md
deleted file mode 100644
index e407597..0000000
--- a/docs/08-MARKET-ARCHITECTURES.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# 08 — How the Major Players Build It (settles D8)
-
-Brainstorm requested by founder: how do the major players architect these systems, and
-what does that mean for our Android-POS question? Based on market knowledge through
-early 2026 — verify specifics before quoting externally.
-
-## India — the incumbents and challengers
-
-| Player | Segment | Client architecture | Offline strategy | Lesson for us |
-|--------|---------|--------------------|--------------------|---------------|
-| **TallyPrime** | Accounting + inventory, universal SMB | Windows desktop, proprietary engine | Fully offline; remote access bolted on later | Keyboard-first speed built a 30-year moat. UI is famously *stable* — validates your "no major frontend changes" instinct. Weak at POS counters. |
-| **Marg ERP** | Pharma/FMCG retail & distribution | Windows desktop | Offline-first, cloud companion apps (owner app, ordering) | Vertical depth (batch/expiry, schemes) wins whole segments. Their mobile apps are satellites, not the core. |
-| **Busy** | SMB accounting/billing | Windows desktop | Offline, cloud add-ons | Same shape as Tally; dealer channel is the distribution engine. |
-| **GoFrugal** | Supermarkets, retail chains | Windows POS + store server, cloud HQ, mobile satellite apps | Local-first billing, cloud back office | **The closest analog to our plan.** Hybrid works at supermarket scale. Their weakness: complexity/onboarding — our opening. |
-| **Vyapar** | Micro/small merchants | **Android-first** + Windows desktop | Local SQLite + cloud sync, freemium | Mobile-first wins the micro segment on price & simplicity — but doesn't serve a busy supermarket counter. Don't fight them on price early. |
-| **QueueBuster** | Small-format retail, kiosks | **Android POS** | Cloud back office, offline-tolerant app | Android POS is viable for small format and line-busting — not for keyboard-speed checkout lanes. |
-| **Zoho Books/Inventory** | SMB, online-comfortable | Pure cloud web/mobile | Browser caching at best | Cloud-only fails the queue test; they don't win physical high-throughput counters. |
-| **Petpooja** | Restaurants | Local POS (Windows/Android) + cloud | Local server resilience | Even in restaurants, winners keep billing local. |
-| **Ginesys** | Enterprise retail chains | Store server + cloud HO | Store-level autonomy | Our Phase-4 Enterprise shape already matches this. |
-
-## Global reference points
-
-| Player | Architecture | Takeaway |
-|--------|--------------|----------|
-| **Square** | iPad/Android, cloud-first, limited offline mode | Wins on payments + simplicity in small format; their offline is a fallback, not a foundation — regularly hurts them in bad-connectivity markets. |
-| **Shopify POS** | Tablet, cloud-first | Retail-lite; not built for 40-items-a-minute lanes. |
-| **Toast** | Android terminals, local store-level resilience + cloud | Proof Android + local sync works at scale — but they control the hardware end-to-end, which we won't. |
-| **Lightspeed** | Cloud, iPad | Mid-market; same cloud-first ceiling. |
-
-## The patterns (what everyone converges on)
-
-1. **Nobody who wins high-throughput counters is cloud-only.** Every player serving
- supermarkets bills locally and syncs up. Our local-first hybrid is the market's
- convergent answer, not a contrarian bet.
-2. **Device follows segment.** Keyboard throughput (supermarket lanes) → Windows desktop.
- Mobility and price (micro merchants, aisle billing, events) → Android. No major player
- serves both segments with one client — they share the *platform*, not the screen.
-3. **Incumbent moats**: keyboard muscle memory, the CA/accountant ecosystem, and the
- dealer channel. **Challenger wedges**: price, UPI-native payments, onboarding speed,
- mobile. We hold incumbent-style moats (existing base, dealer path, domain depth) and
- can steal the challenger wedges (Purchase Inbox, WhatsApp, 15-minute onboarding).
-4. **Stable frontend, evolving backend** is literally how Tally kept its base for
- decades — the counter UI is boring and eternal; innovation ships in the engine,
- compliance, and automation. This is your stated principle, confirmed by the market.
-
-## Conclusion — D8 resolved
-
-**v1: Windows desktop POS (Electron).** Your existing base runs Windows; the supermarket
-segment demands keyboard speed, weighing scales, and ESC/POS — that's where desktop wins
-and where your revenue is.
-
-**Phase 3–4: Android companion app** — line-buster billing from the aisle during rush
-hours, stock-take scanning, owner approvals — sharing the same billing-engine package and
-sync protocol (which is why the engine is a pure TS package from day one).
-
-**Full Android POS parity: only if/when we deliberately go down-market** to micro
-merchants — a pricing and support decision, not a technical one, and not v1.
-
-This keeps D2 (Electron) intact and keeps the Android door permanently open at the
-architecture level.
diff --git a/docs/09-UX-FLOWS-AND-MENUS.md b/docs/09-UX-FLOWS-AND-MENUS.md
deleted file mode 100644
index 464746d..0000000
--- a/docs/09-UX-FLOWS-AND-MENUS.md
+++ /dev/null
@@ -1,378 +0,0 @@
-# 09 — Pages, Menus & Flows (UX Blueprint)
-
-This is the screen-level blueprint for all four surfaces — POS Counter (Electron), Back Office (web), Owner App (React Native), and Customer Display — plus the cross-cutting systems they share. It applies the locked decisions everywhere: local-first POS, keyboard-first flows, config-driven content (labels, menus, thresholds, keymaps, and messages are DB rows per 07-DB-AND-CONFIG — nothing below implies a frontend release to change), immutable documents, and edition gating via `plan_feature`. Keystroke convention: a scan = 0 keystrokes (wedge scanner sends code + Enter); every key press = 1; lane budget 40 items/min means any per-item UI interaction beyond zero keystrokes in the common path is a defect.
-
----
-
-## 1. POS Counter
-
-### 1.1 Billing screen — zones
-
-```
-┌──────────────────────────────────────────────────────────────────────────────┐
-│ HEADER SiMS · MegaMart T.Nagar · Counter 2 · Ramesh (Cashier) · Shift #14 │
-│ Bill: (assigned on close) · Token 07 Tue 08 Jul 2026 6:42 PM │
-├──────────────────────────────────────────────┬───────────────────────────────┤
-│ ┌──────────────────────────────────────────┐ │ CUSTOMER STRIP (F6) │
-│ │ SCAN / SEARCH ▌ (48px, always focused) │ │ 98400 12345 · Lakshmi │
-│ └──────────────────────────────────────────┘ │ Khata ₹1,240 dr · 320 pts │
-│ LINES GRID (virtualized, 36px rows) ├───────────────────────────────┤
-│ # Item Qty MRP Rate Amt │ TOTALS Items 12 · Qty 14.750 │
-│ 1 Aashirvaad Atta 5kg 1 285 270 270 │ Gross 1,412.00 · Disc −42.00 │
-│ 2 Tomato (loose) 1.250 — 32/kg 40 │ GST (breakup ▾) +61.20 │
-│ ▶ last-added row highlighted │ ┌───────────────────────────┐ │
-│ │ │ PAYABLE ₹1,431 (64pt) │ │
-│ │ └───────────────────────────┘ │
-│ QUICK-KEYS ROW (Ctrl+1…Ctrl+0, touch tiles) │ You saved ₹57 vs MRP │
-│ [Tomato][Onion][Potato][Bag ₹5] … │ [F12 Cash] [F11 Pay] [F7 Hold]│
-├──────────────────────────────────────────────┴───────────────────────────────┤
-│ STATUS BAR ● Synced 2m ago · Printer OK · Scale 0.000kg · Held: 2 · │
-│ Drawer ₹8,140 · Keymap: Classic · v2.3.1 │
-└──────────────────────────────────────────────────────────────────────────────┘
-```
-
-| Zone | Notes |
-|---|---|
-| **Scan/search box** | The sacred line. Focus always returns here after any action, overlay, or error — enforced by a focus sentinel; no dialog, toast, or sync event may steal it. |
-| **Lines grid** (~62%) | Columns: #, name (+ regional secondary name, HSN small, batch/expiry chip when tracked), qty, unit, MRP, rate, disc, GST%, amount. Last-added row highlighted, auto-scrolled; ↑/↓ selects for edits. Repeat scans merge into one line (default ON; auto OFF for batch-tracked items). |
-| **Totals panel** (~38%) | Mirrors the customer display so both parties read the same numbers. PAYABLE huge (cashier speaks it aloud); GST breakup collapsible — compliance lives on the print. "You saved ₹X vs MRP" is the trust line. |
-| **Customer strip** | Optional per bill: phone, name, khata balance chip, loyalty points. Sits by totals because khata/loyalty affect payment. F6 focuses. |
-| **Quick-keys row** | 10 configurable tiles (produce PLUs, carry bag), Ctrl+1…Ctrl+0 and touch. Per-counter DB config; hidden if unconfigured (typical Lite). |
-| **Status bar** | Sync dot, printer, live scale weight, held-bills badge, drawer cash (role-visible), keymap preset, version. Everything informs, nothing interrupts. |
-| **Header** | Store, counter, cashier, shift, clock. Bill number reads "(assigned on close)" — the per-counter GST series number is allocated only at payment commit, so held/abandoned carts never create series gaps. Held carts carry a Token number instead. |
-
-Touch: totals buttons and quick-key tiles ≥ 48 px; touch never adds steps to the keyboard flow.
-
-### 1.2 The smart scan box — four grammars, no modes
-
-| Input | Behavior |
-|---|---|
-| Digits + Enter (or scan) | Barcode/item-code lookup → line added. Weighing-scale EAN-13 (`20`–`29` prefix) parsed per counter profile (PLU digits + embedded weight/price, configurable divisor). |
-| Letters | Live fuzzy/phonetic search < 150 ms (see §5.1); ↑/↓ + Enter picks; Esc clears. |
-| `n*` prefix then scan/code | Quantity multiplier: `3*` + scan = qty 3 on one line. |
-| Enter on empty box | No-op (double-Enter guard). |
-
-### 1.3 Keymap — "Next" preset (DB profile; per preset / counter / user override)
-
-| Key | Action | Gate |
-|---|---|---|
-| Enter / Esc | Confirm · Cancel/back one level (Esc never destroys lines) | — |
-| ↑ ↓ PgUp PgDn | Line/list navigation | — |
-| `*` | Qty-multiplier prefix | — |
-| F1 | Keymap cheat-sheet overlay (printable card) | — |
-| F2 / F3 | Edit qty / price override on selected line | F3: supervisor beyond ±X% band |
-| F4 / F5 | Line / bill discount (`5` = ₹, `5%` = percent) | Supervisor above X% |
-| F6 | Customer strip: attach by phone | — |
-| F7 / F8 | Hold bill / resume tray | — |
-| F9 | Returns/exchange mode | Setting: open vs supervisor |
-| F10 | Bill history & reprint (day-scoped) | — |
-| F11 | Payment overlay (all tenders, split) | — |
-| **F12 / Numpad +** | **Cash close — the one key** | — |
-| Del / Ctrl+Del | Remove line (inline confirm) / cancel cart (reason) | Supervisor per setting |
-| Ctrl+P | Reprint last bill | Copies > N → supervisor |
-| Ctrl+F / Ctrl+M | Open drawer no-sale / cash in-out | Supervisor (drawer always audit-logged; paid-out above ₹X) |
-| Ctrl+1…Ctrl+0 | Quick-key tiles | — |
-| Ctrl+L / Alt+S / Ctrl+, | Lock counter · salesman tag (Standard+) · settings | Settings: supervisor/manager |
-
-Numpad-only right hand is deliberate: scanner in left hand, right hand never leaves the numpad (digits, `*`, `+` = cash close, Enter). A second shipped preset, **Classic keys**, mirrors SiMS Classic's Oracle Forms bindings key-for-key — migrating cashiers keep 100% muscle memory on day one. Rebinds are per-action with conflict detection; changes sync as counter-scope settings.
-
-### 1.4 Billing flows (keystrokes excl. scans and free-text typing)
-
-**Normal cash sale (the 90% path):** scan items (0) → state total → **F12**: series number assigned, bill committed (immutable document + stock movements + outbox row in one SQLite transaction), drawer kicks, print spools async, customer display thanks, scan box refocused. **1 keystroke.** With change: type tendered digits first, then F12 → change flashes 4 s on both displays. **5 keystrokes.**
-
-**Weighed produce:** (A) integrated scale, Pro — quick-key tile + Enter on live stable weight = **2**; (B) manual weight, all editions day 1 — tile + `.750` Enter ≈ **6**; (C) label-scale barcode (`2x` EAN-13) — **0**, the correct answer for high-volume supermarket produce.
-
-**Edits:** re-scan merges qty (0) · `3*`+scan (2) · F2 qty (3) · F3 price (5, +PIN out of band) · F4/F5 discount (4–5, +PIN over threshold) · Del remove (2–3). All overrides write before/after values to the append-only audit log with cashier + approver identity.
-
-**Unknown barcode (errors never trap):** no match → non-blocking inline panel under the scan box: "Unknown code… [Enter] Quick-add · [Esc] Skip" — further scans queue behind it. Enter → 3-field micro-form (Name, Price/MRP, GST picklist defaulting to store slab) → item saved as `draft`, line added, flagged for back-office completion. **3 keystrokes + typing**; skip = 1.
-
-**Customer attach:** F6 → 10-digit phone (live match after 4 digits) → Enter. **≤12.** No match → Enter again creates the customer phone-only. Attached customer ⇒ WhatsApp e-bill offered at close (auto/ask/off setting).
-
-**Hold/resume:** F7 parks the cart under its Token instantly (**1**); held carts are journaled — they survive restarts and power cuts, and have no invoice number. F8 → tray → Enter (**2–3**). Day-end forces the tray empty.
-
-**Payment overlay (F11)** — for everything that isn't exact cash. Payable on top; tender legs left; amount field prefilled with remainder; method hotkeys **1** Cash **2** UPI **3** Card **4** Khata **5** Other. Grammar: *(optional amount) + method key* adds a leg; Enter on zero remainder commits + prints; Esc abandons, cart untouched.
-
-| Tender | Flow | Keys |
-|---|---|---|
-| Cash w/ change via overlay | F11 → `2000` → `1` → Enter | 7 |
-| UPI dynamic QR (Standard+, online) | F11 → `2` → amount-embedded QR on customer display → webhook auto-confirms → auto-close | **2** |
-| UPI offline | Static VPA QR; cashier sights success screen → Enter (payment flagged `manually_confirmed`) | 3 |
-| Card | F11 → `3` → amount pushed to terminal → Enter on approval | 3 |
-| Khata | F11 → `4` → requires attached customer; shows new balance vs credit limit → Enter; over limit → supervisor PIN | 3 (+PIN) |
-| Split ₹500 cash + UPI | F11 → `500` → `1` → `2` → QR remainder | 6 |
-
-Bill-close latency budget: commit + print spool < 1 s; the lane never waits on the printer.
-
-**Cancel/void (bills are immutable — a "void" is a new document):** Ctrl+Del cancels an open cart with reason picklist (+supervisor PIN if it has lines; abandoned carts logged with line snapshot for shrink analytics). Voiding a closed bill (same day, pre-filing): F10 → find → `V` → reason → supervisor PIN → a **cancellation document** is appended referencing the bill; the bill stays in the series marked CANCELLED (series stays consecutive — GST-safe), stock reversed by counter-movements, payments reversed. Registered IRN → IRN-cancel queued within the 24 h window; outside it the flow refuses and routes to Returns (credit note).
-
-**Returns/exchange (F9):** find the bill by scanning the receipt QR (0), bill no, or phone lookup → original lines listed with returnable qty netted → Space toggles, F2 partial → grid enters RETURN mode (red accent, watermark, negative lines; batch items return to their original batch). Exchange = keep scanning positive lines in the same document. Settle net via F11 (cash refund / khata credit); committed as a **credit note** in its own per-counter series referencing the original. No-receipt return: supervisor PIN, current-rate floor, audit-flagged. Simple 1-line refund ≈ **6 keystrokes**.
-
-**Reprint:** Ctrl+P = last bill (**1**). Older: F10 → find → `P`; `W` resends the WhatsApp e-bill. Beyond N copies → supervisor; every reprint audit-logged and printed "DUPLICATE".
-
-### 1.5 Offline-degraded state — almost nothing changes
-
-Offline is the architecture, not a mode. Scanning, search, lines, totals, print, drawer, cash/card/khata/hold/returns/reprint: identical. What differs: status dot goes grey ("Offline — 34 queued", count ticks — that's the whole ceremony); UPI falls back to static VPA QR + manual confirm; WhatsApp e-bills queue; e-Invoice bills print with "IRN pending" and register on reconnect; customer/khata lookups use the local replica (limit checked against last-synced balance, flagged; multi-counter stores keep a live LAN view via Store Hub); offline loyalty redemption capped per setting. No banners, no modals, no focus theft. Reconnect drains the outbox silently.
-
-### 1.6 Shift & day flows
-
-Lite skips the ceremony — login straight to billing, day-end report auto-generates. Standard+:
-
-- **Login:** cashier tiles + 4–6 digit PIN, < 5 s cold start; 5 wrong PINs → 60 s lockout + audit event. Ctrl+L locks mid-day (held carts and open bill preserved).
-- **Shift open:** denomination grid (₹500/200/100/50/20/10/coins, Tab advances, live total ≈ 25 keys); variance vs previous shift's declared float; over tolerance → supervisor + reason. Quick-open setting for small stores: single declared amount, ≈ 6 keys. Emits immutable `shift_opened`.
-- **Cash in/out (Ctrl+M):** direction → amount → reason picklist (float top-up, safe drop, petty expense, owner drawing) → drawer kicks, voucher slip prints. Above ₹X → supervisor. ≈ 6–10 keys.
-- **Shift close:** **blind count** by default (expected hidden until submitted) → variance with reason + supervisor PIN outside tolerance → held-cart tray must be empty → declare leave-behind float → X-report prints (bills, ABV, tenders, discounts, voids, returns, cash movements, variance, names). Synced; owner app gets the summary.
-- **Day-end:** one Enter → Z-report consolidating all counters (via Store Hub): GST summary by rate, tenders vs counted cash, top items, voids/returns, khata sales, pending-sync count. Prints + queues to owner digest; triggers the cloud backup snapshot. Fully offline-capable (report syncs later, with a pending-sync warning line if the outbox isn't empty).
-
-### 1.7 Supervisor overlay — the one sanctioned interruption (always user-initiated)
-
-Gated actions (all thresholds are DB settings per tenant/store): price override beyond band, discounts above X%, cancel-with-lines/void, no-receipt return, khata over limit, drawer no-sale, reprint > N, paid-out > ₹X, shift variance, offline loyalty above cap, tax-exempt sale, settings/keymap entry, training-mode toggle (manager).
-
-Flow: compact overlay atop the visible bill — action description, before → after values, reason picklist (list is DB config), PIN field. Supervisor authorizes by user-code + PIN, **badge barcode/card swipe** (0 keys — the fast path), or (Phase 3, online) **remote approve** pushed to the owner app — overlay shows "awaiting approval", cashier can Esc and hold the bill so the queue keeps moving. Approve → executes; audit row (action, cashier, approver, counter, bill ref, before/after, timestamp) syncs to the back-office audit browser and owner feed. Deny/30 s timeout → clean cancel, focus → scan box. 3 wrong PINs → 60 s counter lockout + owner alert. Supervisor authority is itself a role permission from the DB matrix.
-
-### 1.8 POS settings (Ctrl+, — supervisor gated; never required mid-bill)
-
-Counter-scope DB settings (port/device bindings stay local-only): **Printers** (profiles, per-document routing, test print/drawer kick, template preview — templates edited in back office); **Scale** (port + protocol presets shipped as data, live readout, label-barcode scheme editor with test-parse); **Customer display** (monitor, orientation, playlist preview, language); **Sync status** (pending ops by type, per-item retry, force-sync, Store Hub state, one-click "send diagnostics" bundle — no PII); **Keymap** (preset picker Next/Classic/Custom, rebind grid, quick-key tile editor, print cheat card); **Hardware wizard** (first-run guided detection, re-runnable); **About/License** (edition, locked features as discoverable upsells, update channel).
-
-### 1.9 Customer display (Pro; second monitor, same Electron app, local)
-
-| State | Content |
-|---|---|
-| Idle/promo | DB-driven playlist: offers, house ads, WhatsApp e-bill opt-in card. |
-| Billing | Last 5–6 lines large (customer's script, MRP struck-through when rate < MRP — kills above-MRP disputes), running total huge, "You saved ₹X", updates inside the 150 ms scan budget. |
-| QR payment | Full-screen dynamic QR with amount ("Scan to pay ₹1,431") → green PAID on webhook; offline: static VPA QR + amount. |
-| Change due / Thank-you | "Change ₹569" 4 s · total, saved, loyalty earned, "bill sent on WhatsApp", catalog message per language, 5 s. |
-| Return mode | Refund lines + net, red accent — transparency during returns. |
-
-The display never shows sync/printer/internal errors.
-
-### 1.10 Keystroke scoreboard (published, telemetry-verified)
-
-Cash exact **1** · cash w/ change 5 · UPI 2 (offline 3) · card 3 · khata 3 · split 6 · scale produce 2 (manual ~6, label 0) · qty 0/2/3 · price/discount 4–5 · quick-add 3+typing · customer attach ≤12 · hold 1 / resume 2–3 · cancel cart 2–3+PIN · void 5–10+PIN · 1-line return ~6 · reprint 1 · login 5–8 · shift open/close ~25–35 · day-end 1. Telemetry: per-bill timing, scan-to-line histogram, keystrokes-per-bill, overlay dwell — the "fastest counter in India" number we publish.
-
----
-
-## 2. Back-Office IA & Menu Tree
-
-### 2.1 Design stance (defaults, not options)
-
-Table-first workbench: every module opens on a virtualized, keyboard-navigable grid; details are drawers by default, full pages on demand. **Two levels of navigation, never three** — Module → Page; anything deeper is a tab or filter. **Hide by role, lock by edition**: a user never sees a page their role can't use; owners/managers see edition-locked pages as discoverable upsells. The whole menu is data: `menu_item(code, module_code, route, icon, sort, min_edition, label_key)` + `menu_item_role` — support reorders/relabels/regates with zero frontend release.
-
-### 2.2 Menu tree — eleven modules, ordered by daily usage `[L/S/P/E]` = min edition
-
-- **🏠 Dashboard** — Overview `L` (today's sales/payments live; alert cards deep-linking to the fixing page) · Approvals & Tasks `S` (discount/void approvals, adjustment approvals, POS quick-adds needing completion, failed IRNs).
-- **🧾 Sales** — Bills `L` · Returns & Credit Notes `L` · Shifts & Day-End `L` (recon features `S`).
-- **📥 Purchases** — Purchase Inbox `P` (flagship queue, unread badge, first item) · Purchase Invoices `L` · Orders & GRNs `P` · Purchase Returns / Debit Notes `L` · Suppliers `L` · GSTR-2B Reconciliation `P`. (Label printing stays under Catalog — purchases hand off to it.)
-- **📦 Inventory** — Stock on Hand `L` · Stock Movements `L` (the append-only ledger behind every number — stock is derived, never typed) · Adjustments `L` (reason-coded, approval workflow) · Stock Takes `P` · Transfers `E` (in-transit state, e-Way hook) · Expiry & Ageing `P` (action lists, not charts) · Reorder `S` (velocity suggestions → one-click draft PO).
-- **🏷️ Catalog** — Items `L` · Categories & Brands `L` · Price Lists `S` (effective-dated, store overrides) · Schemes & Offers `S` · Label Printing `S`.
-- **👥 Customers** — Customers `L` (phone-keyed) · Khata & Collections `L` (WhatsApp reminders + UPI collect links, single or bulk) · Loyalty `P` · Campaigns `P`.
-- **📒 Accounting** — Ledgers `L` · Vouchers `S` · Cash & Bank Books `S` · Day Book `P` · Financial Statements `P` · Tally Export `S` · Year Close & Locks `S`.
-- **🏛️ GST & Compliance** — GST Returns `S` (GSTR-1/3B prep, JSON export, filing status) · e-Invoice Queue `P` · e-Way Bills `P` · HSN & Tax Rates `L` (effective-dated — the GST-2.0-proof page).
-- **📊 Reports** — Sales `L` · Stock & Valuation `L` · Party Outstanding `L` · GST Registers `L` (the CA hand-off pack) · Staff & Counters `S` · Analytics `P` · Scheduled Reports `S`.
-- **🏢 Head Office** `E` — Store Comparison · Central Pricing · Central Purchasing (Ph 4) · Consolidated GST (multi-GSTIN) · Outlet P&L (Ph 4).
-- **⚙️ Admin** — Users & Roles `S` · Stores & Counters `L` (per-counter series, print profiles, Store Hub role) · Settings `L` (tenant→store→counter→user hierarchy) · Templates `L` (visual editor + preview) · Integrations `L` (WhatsApp, UPI, GSP, email-in address) · Subscription & Plan `L` · Audit Log `S` · Sync & Devices `L` · Backups `L` · Data Import `L` (Excel/CSV + Classic migration tool with verification report) · API Access `E`.
-
-**Not in the tenant menu:** plan/price-book editing, the global message catalog, staged rollouts, and cross-tenant telemetry live in the internal **SiMS Control Panel** — same engine, vendor tenant scope. Never expose vendor-level config in customer IA.
-
-### 2.3 Navigation model
-
-- **Left sidebar** (240 px, collapsible to icon rail): accordion modules, active one auto-expands. **Top bar**: scope switcher (store selector — "All stores" for Enterprise owners — plus a date-range chip every page inherits), Ctrl+K, notifications bell (fed by Approvals & Tasks), sync dot, user menu (per-user language). **Breadcrumb** `Module / Page / Record`; every record has a stable, copyable URL.
-- **Ctrl+K command palette** — the second navigation system. Four ranked groups: Records (bill no, item, party, GSTIN — same fuzzy/phonetic engine as POS), Pages, Actions ("Export GSTR-1 June", "Send khata reminders"), Recents (last 15 records per user, server-side, cross-device). Prefix operators: `#` bill/doc no · `@` party · `>` action · `?` help/settings. The palette respects role visibility and edition gating — locked actions show a lock glyph and open the upsell sheet; role-hidden items never appear.
-- **Keyboard:** `g` + module key (`g s` Sales, `g p` Purchases, `g i` Inventory, `g c` Catalog, `g a` Accounting, `g g` GST, `g r` Reports, `g x` Admin); `/` focuses the grid filter; `n` = page's primary create; `e` = export. Keymaps are DB data, same mechanism as POS.
-
-### 2.4 Visibility & gating rules
-
-| Rule | Behavior |
-|---|---|
-| Role lacks access | Page absent from sidebar, palette, routes (deep link → 403 with catalog message). |
-| Read-only role | Page visible; create/edit/post disabled with tooltip; export still works. |
-| Below edition, viewer owner/manager | Lock badge; click → **upsell sheet**: 30-sec demo, one-paragraph pitch, "Included in Pro — Upgrade" upgrading in place. |
-| Below edition, viewer staff | Hidden — upsells pitch buyers, not cashiers. |
-| Cashier | Zero back-office pages; login rejected with "use the counter app". Cashiers exist only in POS. |
-| Module empties after filtering | Module header disappears too — except Purchases, GST, and Reports keep their locked flagship pages (Purchase Inbox, e-Invoice, Analytics) visible to owners: the top three upgrade drivers. |
-
-**Default role → module map** (the Admin permission matrix refines to page level with None/Read/Full cells; Auditor is read-everything by definition; Owner is Full at their edition): Supervisor — Dashboard, Sales, Inventory (no Transfers), Customers, Catalog (read), sales/stock Reports. Purchaser — Purchases, Catalog, Inventory (Stock/Reorder/Expiry), stock/purchase Reports. Accountant — Accounting, GST, Sales/Purchases (read), Khata, GST/party Reports. Net effect: the Lite solo owner sees a 7-module ~20-page app; a Pro accountant sees a compliance cockpit; the Enterprise owner sees all eleven — same engine, same routes, different rows in `menu_item_role` + `plan_feature`.
-
-### 2.5 Anatomy of the pages that matter most
-
-- **Catalog → Items** (biggest grid): virtualized columns (name, code, barcodes, category, HSN, GST, MRP, cost, margin %, stock, status incl. **incomplete** — POS quick-adds land here); saved-filter tabs; side-peek preview on row focus; bulk actions (effective-dated category/GST/price edits, labels, merge duplicates); empty state = *Import from Excel / Migrate from Classic / Add first item* — replaced by the "N counter quick-adds need details" worklist when applicable.
-- **Item editor**: sections — Identity (multi-language names, HSN with rate auto-fill), Barcodes (many, incl. `2x` PLU config on Pro), Units & Packs conversion matrix, Pricing (per-price-list effective-dated rows), Batch & Expiry toggle (Pro), Reorder. Right rail: live stock by store, last 5 purchases/sales, audit drawer. Only name + GST rate hard-required — everything else completable later. Effective-dated fields edit via a "Change from…" timeline dialog, never in-place.
-- **Sales → Bills**: grid with payment-mode chips, e-bill and IRN status; side-peek renders the bill exactly as printed (same template engine) with the banner "Bills are permanent records — corrections create a credit note." Actions: Reprint, Send e-Bill, Create Return, Generate e-Way. Per-counter sync-lag indicator so "no data" and "not synced yet" are never confused.
-- **Inventory → Stock on Hand**: group-by item/category/store/batch; valuation (FIFO/weighted avg per tenant setting); row expansion shows the last 30 movements inline — "why is this number what it is" is a first-class answer. Filters include negative!, zero, below-reorder, dead > 90 days, expiry windows.
-- **GST → GST Returns**: period board Draft → Reviewed → Exported → Filed (ARN stored); **exceptions worklist first** (missing GSTIN, rate mismatches, missing HSN, unregistered IRNs) — the return can't reach Reviewed with open exceptions (owner-PIN override, audit-logged). `Prepare` locks a snapshot; fixes deep-link and re-snapshot deltas. Cross-link to GSTR-2B Recon for the inward side.
-- **Admin → Users & Roles**: Users tab (surfaces allowed: POS/back office/owner app) + Roles tab (permission matrix + sensitive-action gates: max discount %, void rights, override band, adjustment threshold, PIN policy). Deactivate, never delete; at least one owner must remain; matrix edits show "affects N users" and land in the audit log.
-
-### 2.6 Cross-page conventions (the engine's grammar)
-
-| Convention | Behavior |
-|---|---|
-| List → detail | `↵` = side-peek drawer; `Shift+↵` = full page with own URL; browser back restores grid scroll/filter state. |
-| Saved filters | Named views as tabs above the grid; personal by default, shareable tenant-wide; support can seed per-vertical presets as DB rows. |
-| Export everywhere | `e` → Excel/CSV/PDF honoring filters/columns/sort; > 10k rows async with bell notification; exports audit-logged. |
-| Audit drawer | Every record: chronological who/what/when with field-level before → after diffs from `audit_log`; same component everywhere. |
-| Effective-dated edits | Timeline dialog: past (grayed, immutable) → current → "new value from [date]"; scheduled changes show as a chip; deleting a scheduled change is allowed, rewriting history is not. |
-| Immutable documents | Posted docs render read-only with "Permanent record — correct via Credit Note / Debit Note / Reversal"; the correction pre-fills a linked document; both cross-link forever. |
-| Import on every master | Typed template → upload → staged preview with row-level errors → partial commit; error rows re-downloadable. Same component serves Classic-migration verification. |
-| Empty states teach | One sentence (from `message_catalog`, localized) + primary CTA + import path. Never a blank table. |
-| Sync honesty | Store-originated data shows a freshness chip ("Store 2: synced 2 min ago"); stale > 15 min turns amber and links to Sync & Devices. |
-| Money & scope | Tabular figures, Indian grouping from the one shared formatter; pack context ("2 box + 3 pc"); top-bar store + date scope follows the user across pages. |
-
----
-
-## 3. Purchases & Purchase Inbox
-
-All three entry paths — manual, PO/GRN, Inbox — converge on **one posting pipeline** producing the same immutable `purchase_invoice` document.
-
-### 3.1 Placement — POS "Back Room" mode
-
-The Electron POS gets a supervisor-gated **Back Room mode** (separate from the sacred billing screen) running the *same React screens* as the web back office, but against local SQLite, syncing up. Rationale: Lite shops have one machine and shaky internet; goods receiving happens where the scanner and label printer are; purchase documents flow up cleanly and can never conflict. Zero new frontend surface.
-
-| Function | POS Back Room (offline) | Back office web | Owner app |
-|---|---|---|---|
-| Manual entry, GRN, purchase returns, label printing | ✔ default home (scanner + label printer here) | ✔ identical screens (label queue mgmt only) | — |
-| PO create/approve | — | ✔ | approve/deny |
-| **Purchase Inbox review** | — (AI + raw docs live in cloud) | ✔ only | badge + camera capture |
-| Suppliers | read-only ledger lookup | ✔ full | outstanding summary |
-| GSTR-2B recon | — | ✔ only | — |
-
-Inbox postings sync the document *down* to the assigned store, which applies stock-in movements locally — stock stays derived from local events.
-
-### 3.2 Manual purchase entry — the speed flow (MVP, Lite+)
-
-Creed mirrors the counter: nothing interrupts line entry; all prompts (price changes, labels, mismatches) batch to post time; focus returns to the scan box. Layout: header strip (supplier · invoice no · date · Local/Interstate auto from GSTIN state · terms); lines grid `Item | Batch | Expiry | Qty | Free | Unit | Cost | Disc% | Tax% | MRP | Margin% | Sell | Amount` — which columns the cursor **stops in** is a per-store "column profile" setting (a kirana stops at Qty+Cost; a pharmacy adds Batch/Expiry); right rail item card (photo, stock, **last 3 costs from this supplier with dates**, live margin %); footer tax breakup + **"Bill total (as printed)"** field — the single most effective error catcher in purchase entry.
-
-Flow: `Ctrl+P` new → supplier by 3-char fuzzy match → invoice no (inline duplicate check on supplier + no + FY) → date defaults today → scan (line pre-fills last cost, HSN tax, current MRP) → qty → cost (deviation beyond a % setting shows an inline chip `↑ ₹2.10 vs 04-Jun`, never a dialog) → Enter blows through skipped columns; new MRP silently queues a price decision → `Ctrl+↵` close → **totals-check overlay**: type the printed grand total; delta beyond tolerance (default ₹0.99) shows per-line hints and an inline "other charges" line → Post → **one consolidated price-change dialog** (suggested sell per the item's pricing rule, checkboxes default ON, audit-logged) → label prompt ("Print labels for 5 new/changed items?", quantities default to received qty, jobs to the Label Print Queue).
-
-**Budget: ~5 keys/line, ~24 keys overhead → a 12-line bill in ≈ 85 keystrokes, under 2.5 minutes.** Publish this like scan-to-line < 150 ms. Unknown barcode → inline quick-create flagged `needs_completion`, exactly like POS quick-add. Unit conversion from the pack master (CASE(24) → PCS), cost per stocking unit live. Below-floor margin renders amber, never blocks. Posting = one local transaction: immutable document, batch-wise stock-in movements, supplier ledger credit, price-change audit entries, label jobs. Corrections are debit notes or reversal + re-entry — never edits.
-
-### 3.3 PO → GRN → Invoice (Pro, Phase 3)
-
-Direct entry stays the default; PO mode is a store setting (`purchases.mode = direct | po_optional | po_required`). **PO**: `Draft → Approved (threshold-gated; owner-app approve/deny) → Sent (PDF via email/WhatsApp from template) → Partially Received → Received → Closed | Short-closed | Cancelled`, lines carrying ordered/received/invoiced qtys; reorder suggestions can prefill from sales velocity. **GRN** (POS Back Room, scanner-first): pick PO → lines prefill remaining qty → scan to verify (scan-vs-typed mismatch highlighted) → over-receipt beyond tolerance % → supervisor PIN → batch/expiry per flagged line, damaged-on-arrival to wastage → post: **stock-in immediately** (goods are sellable the moment they hit the shelf — never make the counter wait for accounts) + "GRN pending invoice" accrual at PO cost; the label prompt here is the natural supermarket labeling moment. **Invoice** matches one or more GRNs (or one partially) and does only the financial part — ledger credit, cost true-up via weighted-avg adjustment movement, tax capture; no double stock-in. Inbox drafts auto-propose open GRNs for the supplier; variances beyond tolerance route to approval.
-
-### 3.4 Purchase Inbox (Pro, Phase 3 — the flagship)
-
-North star: **≥ 70% of purchase lines via Inbox** (Phase-3 exit ≥ 50%). The pitch: *forward the bill, confirm, done.*
-
-**Intake:** auto-provisioned email address `bills-{tenant}@in.sims.app` (+`+{store}` aliases for routing; printed on our POs so suppliers self-onboard; sender learned per supplier) · drag-drop/bulk upload with auto-split of stacked-scan PDFs · owner-app camera (fast-follow) · WhatsApp forward (fast-follow) · **e-Invoice JSON/QR parsed deterministically** — no AI, 100% confidence; scanning the IRN QR with the Back Room scanner also creates a doc.
-
-**Pipeline:** SHA-256 dedupe → object storage (Mumbai) → classify (invoice / CDN / statement / junk — non-invoices to an "Other" tab) → parse (deterministic for e-invoice JSON and text-layer PDFs; Claude vision for photos) yielding header + lines, **each field with confidence + source bounding box** → supplier match (GSTIN exact → auto; fuzzy + learned sender → suggested) → item match in three tiers: **A** alias-memory hit via `supplier_item_alias` (mapped once, remembered forever, green) · **B** barcode or HSN+fuzzy suggestion (amber, one-key confirm) · **C** unmatched (red) → validations (duplicate by supplier+no+FY plus fuzzy same-amount-same-week, totals recompute, line tax vs our dated rate, CGST/SGST vs IGST vs state, MRP/cost jumps) → state `Ready` (all green) or `Needs Review`. `inbox.autopost_trusted_suppliers` (default off) auto-posts clean docs from whitelisted suppliers — the endgame for FMCG distributors. States are an append-only log: `Received → Parsing → Needs Review | Ready → Posted | Discarded | Duplicate`.
-
-**Review screen (the demo moment):** three zones — queue rail (`J/K` next/prev, source badge, age, confidence %), center source viewer (zoom/rotate/thumbnails), right draft form (**the same grid component as manual entry**). Clicking any field highlights its source region in the PDF and vice versa — trust is built by making verification effortless. Confidence tinting: green ≥ 95 auto, amber 70–94 (`↵` accepts, `Tab` edits), red < 70. Header verdict math: `Printed ₹12,480.50 · Computed ₹12,480.50 ✓`. Tax mismatch banner offers three actions: *use bill rate for this purchase* (ITC follows the bill; default), *update item master* (dated edit, never silent), *flag to accountant*. Red-line mapping keeps the supplier's raw text visible with type-ahead or pre-filled Create-item; differing units get a one-time "CTN = how many PCS? [24]" stored on the alias. Microcopy under every mapping: *"Will be remembered for Vikram Distributors."* That sentence is the product promise. `Ctrl+↵` posts → syncs down → store applies movements; price-change and label prompts fire on the store machine. Target: a 30-line bill confirmed in under 60 seconds; no bulk-post by design — every posting is a human confirmation. Header stat: "This month: 84% of purchase lines via Inbox."
-
-**Exceptions (never a dead end):** unknown supplier → GSTIN public lookup pre-fills create, or link-to-existing fuzzy list; unknown items → per-line map-or-create (bulk "create all N" gated to purchaser role); exact duplicates can never post, fuzzy ones show side-by-side compare with reason-coded override; total mismatch → ranked causes (add-charges one-click, tax-inclusive basis toggle, jump-to-worst-cell), post blocked until resolved or supervisor-overridden; unreadable scans → request re-upload (polite catalog template) or manual entry pre-filled with whatever parsed; multi-invoice files auto-split; statements file under Other and feed ledger reconciliation later.
-
-### 3.5 Suppliers
-
-List: name, GSTIN, outstanding, overdue, last purchase, MTD volume. Detail tabs: **Overview** (ageing buckets, terms vs actual days-to-pay, 12-month trend, top items) `L/1` · **Ledger** (running balance, drill into any document, Record-payment with oldest-first allocation, WhatsApp statement) `L/1` · **Price history** (item × date cost grid, trend flags — the data behind the last-cost chips) `S/2` · **Pending POs/GRNs** (accrual exposure totaled, one-click remind) `P/3` · **Items & aliases** (the matching memory, editable, CSV-seedable for big distributors) `P/3` · **Documents** (all Inbox docs) `P/3` · **Settings** (GSTIN, terms/limit, tax basis, recognized senders, trusted-auto-post, bank details) `L/1`. Back Room gets read-only ledger lookup — the supplier's rep at the back door gets his balance offline.
-
-### 3.6 GSTR-2B reconciliation (Pro, Phase 3)
-
-Pick period → pull via GSP (or upload portal JSON — fallback always available) → summary tiles (*ITC per 2B · per books · Matched % · At-risk ₹*) → work buckets top-down; period locks when 3B files. Buckets: **Exact** (auto, collapsed) · **Fuzzy** (side-by-side, `↵` confirms — confirmations train the per-supplier invoice-no normalizer) · **Value mismatch** (accept-with-note / raise DN-CN / flag supplier) · **In 2B, not in books** — the killer feature: **"Create draft in Purchase Inbox"** from the 2B row (header pre-filled, lines pending until the paper bill surfaces); nothing missed stays missed · **In books, not in 2B** — ITC-at-risk list with WhatsApp/email nudge to the supplier, ITC-hold flag surfacing in 3B prep, auto-recheck next period. All actions audit-logged; recon state append-only per period.
-
-### 3.7 Purchase returns / debit notes (MVP, Lite+)
-
-Immutable DNs in their own per-store series (`ST1-DN-00042`), referencing the original where linked. Flow: pick supplier → pick original invoice (scan any item to filter) or unlinked mode → return qty per line with **mandatory DB-configured reason codes** (damaged/expired/short-supplied/rate difference/scheme not passed/quality), batch defaulting to received batch → **rate-difference-only type**: amounts without quantities — no stock movement, pure ledger + GST document → tax auto-reverses at the **original invoice's rates** (rates are dated config; the original document is the truth) → post (above-₹X → supervisor PIN): stock-out movements, ledger debit → print/PDF/WhatsApp travels with the goods; e-Way queue picks it up over threshold. Settlement nets against the next payment allocation or a replacement GRN referencing the DN.
-
-### 3.8 Config & metrics
-
-Everything tunable is `setting` rows (`purchases.mode/column_profile/cost_warn_pct/margin_floor_pct/margin_basis/total_tolerance/over_receipt_pct`, `inbox.autopost_trusted_suppliers`, `dn.approval_threshold`, `gstr2b.amount_tolerance`) plus `supplier_item_alias`, `inbox_document/extraction`, `price_change_event`, `label_job` tables. Instrument from day one: keys/seconds per manual line (≤ 5 / ≤ 8 s), % lines via Inbox, % docs auto-Ready, median review time (< 60 s), alias hit rate (→ 95% per supplier by the third bill), duplicate and total-mismatch catches, 2B matched-% and ITC-at-risk per tenant.
-
----
-
-## 4. Owner App & Onboarding
-
-### 4.1 Owner app (React Native, Android-first; Phase 3)
-
-**Stance:** online-first, cache-always — every screen renders instantly from a local SQLite snapshot, then refreshes; a permanent freshness ribbon ("As of 12:41 PM · Store synced 3 min ago") because "live" is really "as-synced"; never a spinner over a cached screen. **One write action: approve/deny** — everything else is read; new alert types, digest sections, and report tiles are DB config, not app releases. Language per user setting, all text from `message_catalog` (Hindi + English at launch). Auth: phone + OTP, then a 4-digit app PIN (doubles as approval PIN) with biometric; max 3 devices, revocable from Admin. On **Lite**, only Today tiles and the Day-End Digest render; Alerts/Approvals/Reports show as locked cards with one-tap upgrade.
-
-**Pages** (four bottom tabs + header store switcher/profile): **Today** — net sales vs same weekday last week, bills count + bills/hour sparkline, payments donut (Cash/UPI/Card/Khata), ABV, returns, khata given, per-counter open/closed status, sync freshness chips, Day-2 checklist card, top 3 alerts; 60 s auto-refresh. **Store switcher** — sheet with per-store sales + freshness dots (green ≤ 5 min / amber ≤ 30 / red > 30 during open hours), "All stores" rollup first; single-store tenants never see it. **Alerts feed** — 30 days cached, day-grouped, filter chips (Stock/Expiry/Khata/Sync/Discounts/System); every alert deep-links; long-press mutes the type (user-scope setting); dismiss state syncs later. **Approvals** — pending cards (type, store+counter, cashier, bill detail, **live countdown to POS timeout**, Approve/Deny behind PIN) + 7-day history showing who decided; **approve/deny never queue offline** — a stale approval is worse than none; offline shows "counter will use supervisor PIN". **Reports Lite** — deliberately five, no builder: sales trend, top-20 items, category performance, khata outstanding (tap → server-side WhatsApp reminder), month-to-date GST snapshot; tile definitions are DB rows, so a sixth report is config. **Day-End Digest** — rendered digest per store + date, last 14 cached (the guaranteed-offline artifact), back-swipe browses days. **Settings** — language, per-event Push/Digest/Off, quiet hours, PIN, devices, plan (read + upgrade CTA), support routed to the dealer first via tenant config.
-
-**Flow — remote approval (the one write):** the invariant rules it — billing never waits on network, so remote approval is a fast path *layered on* the always-available supervisor-PIN gate, never a replacement. Threshold trips → POS overlay offers Supervisor PIN (always) and "Ask owner" (only when online with a live owner push token) → `approval_request` outbox doc → high-priority FCM, body resolved server-side per device language → owner taps, PIN, decides → decision pushes down. Round-trip target < 10 s; POS timeout 45 s (counter setting); on timeout/offline/deny the overlay falls back to PIN or cancel, and the cashier can hold the bill and serve the next customer. Late decisions are discarded and logged. Every override — however resolved — still emits a post-facto owner alert: remote approval is convenience; the audit trail is the guarantee. Offline owner (train, basement): every tab renders cached with a grey ribbon, zero blank screens; reconnect refreshes silently.
-
-### 4.2 Notification strategy
-
-**The owner's phone is sacred**: default posture is digest; events earn a push; one coalesced push beats twelve. Three priority classes assigned per event in a `notification_rule` table (event → class, coalesce window, quiet-hours override, channel — team-editable, no release): **P0** interrupt now (bypasses quiet hours) · **P1** push, batched, respects quiet hours (default 22:00–07:00) · **P2** digest/feed only. All texts resolve server-side from `message_catalog` keyed `(code, channel, lang)` — push is one line, WhatsApp is a structured template. Channels: FCM primary; WhatsApp for the day-end digest to app-less owners; SMS only for license-expiry final notice; no email. Per-user overrides to Push/Digest/Off — except P0 sync-down and license expiry, reducible only to Digest.
-
-Defaults: approval requests **P0** (uncoalesced) · store sync-silent > 30 min during open hours **P0** (one per incident + recovery push) · license expiry **P0** T-7/3/1 (+SMS at T-1 if push undelivered) · day-end digest **P1** at close or 21:30 · cash variance > ₹100 **P1** · big discount/void **P1** coalesced in 15-min batches ("3 overrides at Store 1") · khata limit crossed **P1** (max 1/customer/day) · khata ageing, low stock (coalesced counts), near-expiry (30/15/7-day horizons), record-day delight, app updates, backup status **P2** (backup failed 2 days running escalates to P1).
-
-**The Day-End Digest** is the product's daily heartbeat: one push per store (+ chain rollup) — net sales & bills vs last same weekday → payments split → cash variance per counter → khata given/collected → top 5 items → alerts summary → one Day-2-checklist nudge. Each section is a DB-configured digest block, reorderable without release. Ships in **Phase 2 as WhatsApp-only** (needs no app); the Phase 3 app is mostly a rendering client.
-
-### 4.3 Onboarding wizard (Phase 2; back office `/setup`, resumable)
-
-Target: **first real bill within 15 minutes**, measured. Every completed step writes real tenant config immediately (no final submit — abandonment leaves a usable half-configured tenant); every skip is a stored decision that creates its Day-2 checklist item; per-step 30-second Hindi/English videos make both self-serve and dealer-assisted modes work without us on a call (the Phase-2 exit criterion). No step can dead-end: GSTIN API down → manual entry + auto-retry verify; printer undetected → skip; import errors → quarantine.
-
-| # | Step | Highlights | Skippable |
-|---|---|---|---|
-| 0 | Account & plan | Phone + OTP → tenant; plan picker rendered from `plan`/`price_book` rows; default 14-day full-Pro trial; language sets tenant + user default | Plan deferrable via trial |
-| 1 | GSTIN → business | GSP auto-fetch of legal name/address/state/registration type; **"No GSTIN"** path = unregistered mode (Bill of Supply) — the mini-shop reality; adding GSTIN later upgrades the invoice format automatically | Yes |
-| 2 | Store & counters | Store 1 pre-filled from GSTIN; each counter auto-assigns a GST-legal series prefix (`ST1C1-`, editable) in `doc_series` | Counters addable later |
-| 3 | Tax & pricing defaults | Three pre-answered confirmations: MRP tax-inclusive ON, round to nearest rupee, B2C thermal default; composition preset; GST rate master preloaded system-wide — never owner-entered | Yes |
-| 4 | Items | (a) Excel upload with synonym-dictionary column auto-mapping ("MRP/Rate/Price", "विवरण"), 20-row preview, good rows import in background, bad rows quarantined — never block on row 4,812; (b) business-type template catalogs (kirana, pharmacy, apparel… — DB data, dealer-extendable); (c) skip — POS quick-add covers day one | **Yes — the most important skip** |
-| 5 | Staff | Cashiers with POS PINs; owner is default supervisor until staff added | Yes |
-| 6 | Install & pair POS | Download link SMS'd to the counter PC; POS first-run takes a 6-digit pairing code (15-min TTL) → binds machine, pulls license, starts master sync; import progress visible | Pairing deferrable |
-| 7 | Printer setup (on POS) | Auto-detect ESC/POS, paper width, test print (₹ + Hindi line verifies code page), drawer-kick test | **Yes** — "No printer / WhatsApp e-bills only" is a first-class choice |
-| 8 | First test bill | Guided overlay on the real billing screen; **TRAINING watermark, separate `doc_type=TRAINING` series** — never pollutes the GST series; the dealer's proof-of-done | No — the "aha" |
-| 9 | Go live | Confirm series starts → Start Billing flips training off; confetti once; owner-app link SMS'd | No |
-
-Progress lives in `onboarding_step(tenant, step_code, status, completed_by, at)` rows, feeding the checklist and the dealer hand-off record; every step is reopenable from Settings forever.
-
-### 4.4 Day-2 checklist
-
-One data-driven "Finish setting up" surface: `checklist_item(code, title_msg_code, deep_link, completion_probe, edition_min, priority, snooze_days)` — items added/retired without release; **completion is detected by server-side probes, never self-reported** (UPI item completes the moment the setting row exists). Surfaces: back-office dashboard card (progress ring + next 3, deep-linked), owner-app Today card, one idle POS status-bar chip ("Setup 70%" — never on the billing path), and exactly one line in the day-end digest. **No pushes for checklist items, ever.** Default items in priority order: complete/verify GSTIN → fix quarantined import rows → items missing HSN/GST rate (blocks clean GSTR-1 — chase early) → MRP/cost on template items (auto-completes via purchases) → UPI ID for dynamic QR → WhatsApp e-bills → top-5 suppliers → reorder levels (auto-suggest from 2 weeks of sales) → khata opening balances → staff + discount threshold → shop logo → owner-app install (auto-completes on first login) → barcode labels → store timings (drives sync-health alerts and digest timing) → backup/sync green (surfaces only if red). Done (auto) / Snooze 7 days / Dismiss forever; the card retires at 100% or 60 days and moves to Settings → Setup.
-
-### 4.5 Dealer machinery (Phase 2 minimum; portal is Phase 4)
-
-`dealer(code, name, phone, region, white_label_profile)` rows; every tenant carries `dealer_code` from signup — attribution for margins from day one, before any dealer UI exists. Dealer-assisted signup: dealer enters code + owner's phone; **the owner receives the OTP** and reads it out (consent stays with the owner; a consent SMS names the dealer). The dealer's login gets a scoped `installer` role: wizard steps 1–8, settings, imports, printer — **no sales data, ledgers, or reports** — auto-expiring 7 days after go-live, owner-re-grantable. The wizard's resumability *is* the dealer workflow: pre-provision steps 1–5 from the office the evening before, then on-site pairing → printer → training bill with the actual cashier at the keyboard → go-live in under 10 minutes. USB offline installer (shop bandwidth reality; pairing needs one moment of connectivity, initial payload masters-only). Classic customers: the migration tool replaces step 4 and marks steps 1–5 done from imported data. On go-live a **hand-off summary** generated from `onboarding_step` rows goes to the owner by WhatsApp — disputes settled by data. The training series doubles as the post-go-live staff-training tool; `support.channel` routes the owner's Support button to the dealer first. Deferred to the portal: commission statements, fleet view, white-label UI — the attribution rows above make them pure additions.
-
----
-
-## 5. Cross-Cutting UX Systems
-
-One meta-rule governs everything here: *nothing on the billing path may steal focus, block on network, or require reading a paragraph.* Each system is one pure-TS package (`@sims/search-core`, `ui-states`, `i18n`, `print-queue`, `gates`, `error-catalog`) consumed by all four surfaces; behavior ships in git once, content is tuned in the DB forever.
-
-### 5.1 Search — one engine, two profiles
-
-The POS scan box (local SQLite, items only, results painted **< 100 ms** — headroom inside the 150 ms scan-to-line budget) and the back-office Ctrl+K omnibox (Postgres `pg_trgm` + the same key functions ported, < 400 ms p95, items/parties/bills/actions/settings with `#`/`@`/`>` prefixes) share one normalizer, phonetic function, and ranking — a cashier promoted to back-office work never relearns search.
-
-**Match tiers (short-circuit):** 1 barcode exact (≥ 8 digits or wedge suffix; `2x` scale barcodes parsed *before* search) → 2 item-code exact (Classic codes carry over) → 3 token prefix on every word of every name/alias → 4 phonetic key → 5 fuzzy (edit-distance ≤ 2), running only when tiers 1–4 return < 8 results. Rank within tier by 90-day sales velocity, then shorter name — popularity re-ranking makes 100k SKUs feel small.
-
-**Hinglish, three mechanisms, all DB data:** multi-script names auto-transliterated at save (चावल → `chaval`); a shared Indic phonetic key applied to index and query (`aa→a, ee→i, w→v, ph→f, chh→ch, sh→s`… so typed `chawal` and indexed `chaval` both collapse to `caval`; dhaniya/dhania/daniya collide correctly); and `item_alias(item_id, alias_text, source)` fed by imports, Purchase Inbox matching memory, and a one-key POS gesture — when a cashier's search fails but they find the item another way, POS offers "remember *chawal* for this item? [Enter]". The catalog teaches itself the shop's vocabulary; a Hindi commodity-term seed pack ships as rows.
-
-**Performance at 100k SKUs:** FTS5 with 2–3-char prefixes + a phonetic `search_key` table; top-5,000-by-velocity hot set in memory at startup (< 300 ms, inside the 5 s cold start) painting in < 20 ms with full-index merge behind it; no debounce (debounce = felt lag) — each keystroke cancels the in-flight query on a worker thread; results capped 50, fuzzy candidates bounded ≤ 500. p50/p95 latency is telemetry with regression alerts — we publish the number.
-
-### 5.2 State & feedback vocabulary
-
-Five tokens, one meaning each, everywhere: `ok` green (done/synced/paid) · `busy` amber (queued/pending) · `alert` red (a human must act **now**) · `idle` grey (offline/disabled — *calm*) · `info` blue. **Offline is grey, never red** — offline is the architecture, not a failure; red spent on "meh" kills the vocabulary. Every state renders icon + color + text/number via shared components that physically cannot render color alone. Back office uses skeletons (never a bare spinner > 300 ms); the POS billing path has no loading states at all. One document-status chip set spans POS lines, the bill browser, and the owner app: Draft · Held · Closed · Print queued/Printed · e-bill sent · IRN pending/registered · Sync pending/Synced · Cancelled.
-
-**The sync chip (canonical spec, same component on all surfaces):** Synced (grey-green check) · Syncing (amber + `↑ 12`) · Offline (grey slash + quiet count) · Attention (red triangle — sync *errors* retry won't fix). State changes animate the chip only — no toast, no sound, no focus change. Clicking it (or Ctrl+, → Sync) opens a slide-over: last sync, pending ops by type, per-item errors with codes and action buttons, [Sync now], [Copy diagnostics]; Esc returns focus to the scan box.
-
-**The Focus Doctrine:** on the billing screen, no system-initiated toast, dialog, popover, or focus change — not sync, not updates, not license warnings. Ambient information goes to a non-focusable **notice rail** above the status bar (icon + short text + key hint chips, max 3, overflow collapses to a count). The only sanctioned overlays are user-initiated by keystroke, and Esc always returns to the scan box. Back office may toast and dialog normally — it tolerates interruption; the counter does not.
-
-### 5.3 i18n & bilingual printing
-
-The full `message_catalog` (keyed by code, per language, per channel `ui|print|whatsapp|sms`, ICU MessageFormat) syncs into POS SQLite like any master — language never depends on network; per-user language applies instantly at login. Fallback: user → store default → English → (dev builds) the raw key, so gaps surface before rollout; a missing-strings report + machine-draft/human-confirm workflow makes language N a data task. Rendering rules: bundle Noto Sans + Devanagari (+ launch scripts) in app and print renderer — never trust shop-PC fonts; **ASCII digits always**, in every language, wherever money/qty appears; one shared `formatINR()` and FY-aware date formatter; layouts CI-tested against a +40% pseudo-locale (Hindi runs ~25% longer); key hints (`F8`) never localize.
-
-Printing language policy lives in the template, not code: GSTIN/HSN/tax summary/legal footer always English (stable for auditors); header and thank-you in the store print language; item names per `{{item.name:print}}` policy (English-only default for thermal, bilingual default for A4/A5). ESC/POS text mode can't render Indic scripts — non-Latin receipts rasterize in bit-image mode; if raster pushes close-to-print past 1 s, POS suggests English thermal + bilingual A4. Fast by default, pretty on request.
-
-### 5.4 Print system
-
-`print_profile(counter, purpose ∈ receipt|invoice_a4|label|report, printer, paper, template, copies, drawer_kick…)` — **routing is a rule, not a question**: B2C → thermal, B2B-with-GSTIN → A4, labels → label printer; the cashier never picks a printer mid-bill (Ctrl+P on a closed bill offers the override list). Profiles are settings rows editable from POS *and* back office — support fixes a store's printing remotely; the back-office template editor can queue a test page to any counter via sync. **The iron rule: the bill is legal the moment it commits to SQLite; printing is a consequence, never a condition.** Drawer-kick fires on payment confirm, not print success — cash must open even with a jammed printer. Failed prints stay `Queued` (billing is already on the next customer), retry every 15 s, auto-drain oldest-first on recovery (prompt-first is a config alternative); a job queued > 60 s with a customer phone offers the WhatsApp e-bill from the notice rail. Reprints: same-day from F10 ungated; every copy prints "DUPLICATE" and is audit-logged; older/> 2 copies → supervisor. Silent printing always — the OS dialog exists only in Settings → Advanced.
-
-### 5.5 Permissions UX — two gates, two looks
-
-**Edition gates lock (and sell); role gates hide — except in-flow actions, which challenge.** Locked features render normally + lock glyph + edition badge (`` reading license-token flags); activation opens the upsell panel (screenshot/15 s demo, all DB content marketing can tune) — CTA by role: owner → in-place upgrade, staff → "Ask owner" request. Staff hitting paywalls become the sales channel. Never gate mid-bill: edition-gated POS features simply don't bind keys on the billing screen; the lock lives in Settings/menu. Role gates: navigation hidden (the cashier's world contains no Accounting menu — a smaller world trains in 10 minutes), but in-flow sensitive actions stay *visible* and raise the supervisor overlay (§1.7) — hiding them breaks flow and teaches nothing.
-
-### 5.6 Accessibility & the training reality
-
-Design target: a cashier who reads numbers fluently, reads text slowly, may be new to computers, and must be productive in 10 minutes. Defaults: distinct audio for scan-OK / error / attention (cashiers scan without looking — sound *is* the primary feedback channel); totals the largest element on screen, tabular numerals, WCAG AA high-contrast dense theme with dark mode for night shifts; every actionable button shows its key (`भुगतान F11`), with a printed keyboard sticker sheet in the dealer install kit — the F-row is the UI for a non-reader; touch targets ≥ 48 px, quick-keys as image + name + code (recognition beats reading); counter-scope Normal/Large font setting. Back office is semantic HTML + ARIA, keyboard-complete; POS v1's accessibility investment is keyboard + audio, full screen-reader support honestly deferred.
-
-**Practice mode (Phase 2):** a `Practice` button on the login screen, no role needed. Separate SQLite DB seeded from a read-only copy of the store's *real* item master (muscle memory forms on the real catalog) + fake customers/khata, reset nightly. Unmistakable: diagonal `PRACTICE / अभ्यास` watermark, a reserved purple top bar, PRACTICE banner on the customer display. Contained: `TRAIN-####` series (the same mechanism as onboarding step 8's training bills — never touches the GST series), prints blocked by default, no transactional sync ever leaves, drawer-kick disabled. A guided first run coaches a 5-bill script (scan, qty, unknown-barcode recovery, hold/resume, cash close, UPI, one return) with completion recorded per user ("trained ✓" in Admin); script content is catalog rows, dealer-localizable. Practice mode doubles as the dealer's in-shop demo and the upsell demo surface.
-
-### 5.7 Errors a human can act on
-
-Every error, every surface: **[icon] what happened (one plain sentence) · what to do (imperative, one or two steps) · action buttons that perform the fix · a small code** (`E-2101`) — the support-call handshake replacing five minutes of description. Codes: `E-1xxx` billing · `2xxx` print/hardware · `3xxx` sync · `4xxx` payment/UPI · `5xxx` GST · `6xxx` auth/license · `7xxx` master data · `9xxx` internal. `error_catalog(code, severity, title_key, body_key, actions, support_note)` — texts translated and support-editable without release; `support_note` is the internal playbook line; code frequencies feed the tickets-per-store metric.
-
-Presentation: billing-path errors are inline only (attached to the line or beside the scan box — never modal); ambient failures go to the notice rail + chips; **blocking is reserved for exactly three conditions** — shift not open, local DB integrity failure, license hard-expiry after the grace path (grace itself is a notice; never strand a shop with a queue mid-day). No raw exceptions ever: anything unmapped renders as `E-9001` ("Your bills are safe. Restart; if it repeats, call support with this code") and auto-files a crash report. Destructive confirmations exist only off the billing path and name the object — never "Are you sure?". Day-one catalog examples: E-1102 barcode not found ([Enter] quick-add · [Esc] skip) · E-1310 batch expired ([Pick batch] · [Supervisor]) · E-1405 over credit limit ([Take payment] · [Supervisor]) · E-2101 printer not responding ("Bill is saved… prints automatically when it's back" · [Retry] · [Send e-bill]) · E-4203 UPI unconfirmed ([Mark paid] · [Wait] · [Cash instead]) · E-3302 cloud rejected changes ("Nothing is lost" · [Open sync panel]) · E-5110 IRN pending offline (notice only) · E-6201 subscription expiring ([Remind owner]).
-
-### Phase mapping
-
-Search (POS profile), state vocabulary, print system, and errors are MVP-critical — they *are* the counter. i18n ships its mechanism in MVP (from first commit) with Hindi content in Phase 2. The supervisor PIN overlay is MVP; practice mode and the onboarding wizard + WhatsApp digest + Day-2 checklist are Phase 2; owner app, push notifications, remote approvals, Purchase Inbox, PO/GRN, and GSTR-2B recon are Phase 3. Every metric named in this document — keystrokes per flow, scan-to-line, wizard-to-first-bill, Inbox line share, approval round-trip, push opt-out rate — is instrumented from day one: we publish what we measure.
\ No newline at end of file
diff --git a/docs/10-ISSUES-REGISTER.md b/docs/10-ISSUES-REGISTER.md
deleted file mode 100644
index 114ca2e..0000000
--- a/docs/10-ISSUES-REGISTER.md
+++ /dev/null
@@ -1,357 +0,0 @@
-# 10 — The Issues Register (what kills products like this)
-
-This is the consolidated failure-mode register for SiMS Next, distilled from six specialist reviews — sync & data integrity, GST & Indian compliance, counter speed & POS UX, hardware & shop environment, migration from SiMS Classic, and security/licensing/operations. It exists to be triaged into the roadmap: every entry says what goes wrong, why, what it costs, how we contain it, and the earliest phase the mitigation must land. Where one failure surfaced through several lenses (power cuts, clock drift, scanner config, wrong GST rates, invoice-series collisions, customer-phone handling) it is merged and stated once, in the section that owns it, with cross-references — nothing distinct has been dropped.
-
-Per decision **D12 (2026-07-09)**, the store-tier database remains **Oracle 12c** for now: it is shared infrastructure already running the shop's other systems, so we buffer against it rather than replace it. Each counter still carries its own **local SQLite buffer** so billing survives when the store-tier box or the network is down; the cloud remains multi-tenant **Postgres**. Most mitigations below are engine-agnostic. Where the fix genuinely differs under an Oracle store tier versus a Postgres one — chiefly the sync pipe (PowerSync / ElectricSQL are Postgres⇄SQLite engines), the store-level "second copy" disaster answer, and the Classic extraction path — the difference is called out inline.
-
-Issue IDs are stable and cross-referenceable: **SY** (sync), **GST**, **POS** (counter UX), **HW** (hardware), **M** (migration, numbering carried from the parity register), **SEC** (security/ops).
-
----
-
-## Sync & Data Integrity
-
-Every mitigation here defends seven invariants. A sync bug is by definition a violation of one of them, and the simulation suite (SY-11) asserts them mechanically from Phase 0.
-
-| # | Invariant |
-|---|-----------|
-| I1 | No committed bill is ever lost or changed — byte-identical forever, on the counter that made it and eventually the cloud. |
-| I2 | Every op applies exactly once *in effect* (at-least-once delivery + idempotent apply). |
-| I3 | Documents are atomic — no node ever sees a bill header without its lines, payments, and stock movements. |
-| I4 | Stock and khata balances are folds of movements — never transmitted, never trusted as stored numbers. |
-| I5 | Per-counter invoice series are gap-explained and duplicate-free within a financial year. |
-| I6 | The counter can always bill, whatever the state of network, cloud, store tier, or license grace. |
-| I7 | Every mutation is audit-logged in the same local transaction that made it. |
-
-**SY-1 · Clock skew & causal ordering** *(Phase 0)*
-Shop PCs drift minutes to hours; two counters bill "simultaneously" with wall clocks 40 minutes apart, and any logic that sorts by wall-clock silently reorders reality. **Impact:** master edits resolve the wrong way ("I changed the price and it changed back"), day-end reports disagree between POS and cloud, owner loses trust in the numbers. **Mitigation:** causal order is **(Lamport counter, device_id)**, bumped on every local write and merged on every message received; wall clock is display metadata only. Each op carries a per-device monotonic `device_seq` so the server can spot holes. Because stock math is commutative (I4), ordering only decides presentation and last-write-wins — never build logic that needs true global order. Retrofitting ordering is a rewrite, so it ships *in* the Phase-0 spike.
-
-**SY-2 · Backward clock jumps (dead CMOS, deliberate back-dating)** *(Phase 1)*
-A ₹40 RTC battery nobody replaces boots the PC into 2017; staff also set the clock back deliberately to back-date bills. **Impact:** bills stamped into the wrong GST period → wrong GSTR-1 and penalty exposure; FY series logic picks the wrong year; expiry checks pass on expired batches. **Mitigation:** POS stores `max_wall_clock_seen` and **refuses to open a shift when current time is behind that watermark** (supervisor-PIN override, audit-logged). Online, compare against signed server time every handshake: drift > 3 min warns, > 24 h blocks new-bill creation until acknowledged. Bill timestamps also carry `server_received_at` so cloud reporting can flag back-dating. (Licensing anti-tamper reuse of this watermark: SEC-D2; GST date consequences: GST-2, GST-5.)
-
-**SY-3 · Duplicate delivery & idempotency** *(Phase 0)*
-The outbox worker sends a batch; the network dies after the server committed but before the ACK — so the client *must* retry, and the server *will* receive duplicates. On Indian mobile links this happens daily. **Impact:** without dedupe, doubled bills, doubled stock deductions, doubled khata debits, doubled GSTR-1 lines — one doubled sales report during filing month ends the relationship. **Mitigation:** every op carries a client-generated UUIDv7 `op_id`; server apply is `INSERT … ON CONFLICT (op_id) DO NOTHING` plus an `applied_ops` table for fast whole-batch dedupe; downstream apply is idempotent the same way. Rule for the wall: **retry forever, dedupe always, never "send once carefully."** The ACK returns the highest *contiguous* `device_seq` accepted, so the client trims its outbox only on confirmed contiguity.
-
-**SY-4 · Torn batches → atomic document envelopes** *(Phase 0)*
-A bill is a header + N lines + payments + stock movements + audit rows; if those travel as independent ops, a mid-batch drop leaves the cloud with a header and no lines. **Impact:** phantom ₹0 bills in reports, stock deducted without a visible sale, returns against lines that don't exist yet, tickets impossible to reproduce. **Mitigation:** the unit of sync is the **document changeset**, not the row — one checksummed envelope containing every row of one document (or one master edit), written to the outbox in the same SQLite transaction as the document itself (I3, I7). The server applies the whole envelope in one transaction or rejects it whole; the cursor advances only after commit. **Never expose row-level sync in the protocol** — this single rule kills the largest class of bugs, and it is a hard acceptance test on any bought engine.
-
-**SY-5 · Cursor loss & full-resync storms → snapshot bootstrap** *(Phase 1)*
-A client's "last pulled position" gets lost (DB restore, bug, engine swap) and it re-pulls everything; a 50k-SKU store re-downloading all masters over a weak link blocks useful sync for hours. **Impact:** hours-long stalls, and if apply weren't idempotent, re-pull would corrupt local state. **Mitigation:** idempotent apply (SY-3) makes re-pull *safe*, only slow; keep cursors server-side per device so a lost client asks "where was I?" instead of guessing zero; support **snapshot bootstrap** — a fresh/reset client pulls a compacted snapshot (current masters + open documents) rather than replaying history, then tails from the snapshot's position. This one flow is also onboarding *and* disaster recovery (SY-9, M-19) — build once, use three ways.
-
-**SY-6 · Schema/protocol version skew** *(Phase 1; version-matrix CI Phase 2)*
-Auto-update is staged and shops power off for festival weeks; cloud is at v2.5 when a POS wakes at v2.1 and starts syncing. **Impact:** the bug class that corrupts *many tenants at once* — the server misreads old-shaped ops, or new downstream changes crash the old client's apply loop. **Mitigation:** every envelope carries `schema_version` + `protocol_version`; schema changes are **additive-only between forced-upgrade fences** (nullable/defaulted columns, no renames, tombstone-don't-drop); the server keeps up-converters for the last N (default 4) client versions and refuses older with a machine-readable `UPGRADE_REQUIRED` while the POS keeps billing (I6); old clients ignore unknown fields by contract (tested). A **version-matrix CI job** syncs every release against clients v(N-1)…v(N-4) asserting I1–I5 — no release ships without it green. You cannot add version discipline after v1 clients exist in the wild.
-
-**SY-7 · Concurrency — stock races & the negative-stock policy** *(Phase 1; reconciliation Phase 2)*
-Two counters sell the last packet of Parle-G in the same minute; each derived-stock view says 1, reality is 0 after the first sale. **Impact:** if the POS *blocks* the sale it violates I6 and makes a queue (the cardinal sin); if it silently allows anything, stock data rots. **Mitigation:** **default: never block a sale on stock.** Policy is per-store config with three levels — `allow` (Lite default, silent), `warn` (Standard/Pro default, amber line indicator that never steals focus), `block-with-PIN` (batch-mandatory verticals like pharma only). Every negative crossing auto-creates a back-office **reconciliation task** feeding cycle counts. Because stock is a fold of movements (I4), the race corrupts nothing — it surfaces the truth that the physical shelf, not the DB, was the arbiter. (Imported negative stock: M-03. Non-blocking exception UX: POS-2.)
-
-**SY-8 · Concurrent master edits & offline-duplicate masters** *(Phase 1; review/merge tools Phase 2)*
-Back office edits an item price in the cloud while a supervisor edits the same item offline; separately, two counters quick-add the same customer during one offline hour and UUIDs guarantee they *don't* collide — two records for one human. **Impact:** "the price keeps reverting" (LWW complaint) or a Franken-record blending two edits; split khata balances the owner *will* notice as missing money. **Mitigation:** LWW resolves at **whole-record level per edit op**, ordered by (Lamport, device_id) — never field-interleave; the losing edit lands in an "overwritten edits" review queue with one-click re-apply, never discarded. Rate/price/tax are **snapshotted onto each bill line at billing time** so no master resolution ever changes a past bill (I1, GST-2). Duplicate masters are auto-merged at the cloud on natural key — customers by (tenant, phone), items by (tenant, barcode) — canonical = earliest, all documents re-pointed via a `merged_into` alias kept forever; balances are folds so merging is just re-pointing movements.
-
-**SY-9 · Machine death, restore, and cloned identity** *(Phase 1; Hub-copy layer Phase 2)*
-A shop PC dies / is stolen with N offline bills never synced — *the* nightmare question every dealer will be asked; and support later restores a week-old snapshot whose `next_no` and cursor are stale, or a dealer "helpfully" images one POS onto a second machine. **Impact:** lost sales records and a hole in the GST series to explain to an auditor; **duplicate invoice numbers** in a series (I5 violation, unfixable once filed); scrambled per-device sync state. **Mitigation (layered):** (1) online, the outbox drains ≤ 5 min p99; (2) **the store-tier Oracle 12c box is the always-on second copy** — under D12 this is a stronger disaster answer than a "checkbox on the billing PC," and every closed bill's envelope is pushed to it within ~1 s of close, giving RPO ≈ 0 even with the internet down for days (relay-never-truth still holds: a counter whose Oracle box is unreachable bills locally and syncs **direct to cloud**); (3) single-counter Lite relies on hourly encrypted cloud snapshots plus the always-present paper/WhatsApp copy, with an escalating "3 days not backed up" indicator. On any restore or re-pair the client must complete a **series handshake** (cloud/Oracle returns max synced number; `next_no = max(local, remote) + 1`, gap recorded) and billing uses a temporary sub-series suffix until it completes. Cloned identity is contained by a **device lease + epoch fencing**: the cloud accepts ops only from the current epoch and fences the impostor with `IDENTITY_CONFLICT` (it keeps billing under quarantine and screams on screen) — this is also the licensing primitive (SEC-D). (Backup/restore drills: SEC-G2.)
-
-**SY-10 · Long-offline backlog & mass-reconnect herd** *(Phase 1; server admission control Phase 2)*
-A store bills three weeks offline then reconnects with 15,000 upstream envelopes and three weeks of downstream master changes on a flaky link; a regional ISP outage ends and 2,000 stores reconnect in the same minute. **Impact:** naive drain saturates the link for hours and restarts from scratch; a giant LWW wave lets 3-week-old edits stomp fresh cloud edits; the sync service and Postgres fall over, causing a second outage exactly when the dashboard matters. **Mitigation:** **priority lanes** in the outbox — P0 documents, P1 master edits, P2 audit, P3 telemetry — so reporting is useful after minutes; resumable chunked batches (~500 envelopes) with contiguous-ACK trimming so progress is monotonic on a link dropping every 90 s; snapshot-delta downstream when backlog exceeds a threshold; and because merge-on-receive bumps the reconnecting counter's Lamport clock *past* everything the cloud has seen, its stale edits correctly lose despite arriving later (assert this explicitly — it is the subtlest correctness point in the system). Clients ship **jittered exponential backoff and `429 + Retry-After` honoring from day one** (uncrackable server-side admission control comes later); load-test at 10× projected fleet before any mass-migration wave.
-
-**SY-11 · Proving any of this — simulation testing & chaos drills** *(Phase 0 → ongoing)*
-Example-based tests cannot cover interleavings, and every issue above is an interleaving bug that appears on schedule #40,412. **Mitigation:** a **deterministic simulation harness** (TypeScript + fast-check) runs N virtual counters + store tier + cloud in one process with simulated network (drop/duplicate/reorder/partition), simulated clocks, and crashes injected at any await point including mid-commit; every run is seeded so **any failure replays exactly**, and every schedule asserts I1–I7 (fold-equality, no lost/dup op_ids, no torn docs, series contiguity-or-gap, convergence, idempotent replay). This is only possible if the sync engine is plain TS with injected I/O — enforce that architecture from the spike. Alongside it, a **bench rig** (two shop-grade PCs + switchable power strip + controllable router) runs weekly scripted plug-pulls during commit, network flaps mid-batch, clock-set-back, disk-fill-to-99%, and snapshot-restore-verifies-series-handshake; pilot stores get monthly "pull the router Friday, reconnect Monday" drills; production runs **continuous server-side invariant checkers** (fold mismatches, series anomalies, epoch conflicts) that page support before the customer calls. "Survives a pulled cable" must mean 100,000 seeded pulled cables.
-
-> **Build vs buy (D12 note).** PowerSync and ElectricSQL sync Postgres⇄SQLite. With an Oracle store tier between counter SQLite and cloud Postgres, neither sync edge is a clean Postgres⇄SQLite pair, so the off-the-shelf engines fit poorly at the store tier and a **custom outbox (or Oracle-aware CDC) is the likely answer** — but the semantic rules above (atomic envelopes, client IDs, idempotency, Lamport order, lease/epoch) stay ours regardless of engine. The Phase-0 spike decides, and the pass/fail acceptance test for any bought engine is hard: it must prove transactional, batch-consistent apply of whole document envelopes (SY-4) and survive the seeded pulled-cable suite (SY-11), or D3 resolves to the custom outbox.
-
----
-
-## GST & Compliance
-
-Every threshold, rate, window, and deadline named here lives in the **dated-config tables of 07-DB**, never in code. Each issue is tagged **[UNFIXABLE]** (cannot be repaired after the fact — prevent by design) or **[CORRECTABLE]** (repairable via amendment/credit note, at a cost). Three invariants neutralize most of the list: bills snapshot their full tax context and are never recomputed; every threshold is a dated row synced *ahead* of its effective date; and every compliance state machine has loud, escalating, owner-facing alarms — because in a local-first system the deadliest failure is a queue that stalls *silently* while a legal clock runs.
-
-**GST-1 · Mid-year rate changes** — **[UNFIXABLE if billed at wrong rate]** *(engine Phase 1; rate-change wizard Phase 2)*
-GST slabs change with days of notice — GST 2.0 (22 Sept 2025) collapsed 12%/28% into 5%/18%, added a 40% demerit rate, and zeroed compensation cess on most goods overnight. A POS storing "GST rate" as one column on the item is wrong the morning after. **Impact:** wrong tax collected = short-payment (interest + penalty) or over-collection (Section 76: payable to government anyway); thousands of bills that cannot be un-issued. **Mitigation:** `tax_rate(hsn_or_class, rate, cess, condition, effective_from, effective_to)` is the *only* rate source, and the engine resolves rate by **invoice date/time**, never "current rate" (a counter open across midnight applies the old rate at 23:59, new at 00:00 — no manual "switch now" button). Cess is a dated field, zeroed by date, never deleted. Future-dated rates sync down the moment CBIC notifies so offline stores already hold them (SY-10 covers the still-offline exposure). A back-office **rate-change wizard** handles the MRP-inclusive repricing that a rate cut forces (Legal Metrology re-stickering, GST-16), including **weighing-scale PLU re-push** — stale scale labels are a silent wrong-price generator for days. Golden-file tests must include bills straddling a rate boundary.
-
-**GST-2 · Returns & credit notes against OLD bills** — **[UNFIXABLE if recomputed]** *(Phase 1)*
-An item bought in August at 12% and returned in October must carry **12%** on the credit note, not today's 5% (Section 34 adjusts the *original* supply's tax). **Impact:** wrong adjustment in GSTR-1, mismatch with the buyer's ITC reversal, amendment-by-amendment cleanup — and after a slab overhaul *every* return of pre-change stock hits this. **Mitigation:** each immutable bill line stores rate, cess, HSN, taxable value, and tax amounts **as computed** (SY-8), so the bill is self-describing; **credit notes copy from the original lines, never recompute**. The only case that consults the dated rate table is a return with no traceable original (walk-in, lost receipt), keyed to the customer-claimed purchase date and supervisor-gated.
-
-**GST-3 · Rounding & paisa mismatches vs the IRP and Tally** — **[CORRECTABLE, but erodes trust]** *(engine Phase 0/1; IRP corpus Phase 2)*
-Per-line vs per-bill tax, truncation vs rounding, CGST/SGST split rounding, and invoice round-off all produce paisa deltas — and the IRP *validates* the arithmetic on submission (±₹1 tolerance; exceed it and the IRN is rejected). **Impact:** IRN rejections at submit time (the offline queue jams on arithmetic, not connectivity), GSTR-1-vs-books deltas, Tally exports that don't tally. **Mitigation:** **one rounding spec, one implementation** — the pure billing-engine package is the *only* place tax math exists; POS, back office, cloud, GSTR-1 JSON, e-invoice payload, and Tally export all call it, never re-derive. Default (shipped, "no rounding" not offered): per-line taxable at 2 decimals → per-line CGST/SGST/cess at 2 decimals half-up → single `ROUND_OFF` line to the nearest rupee, posted to a rounding ledger. Use **decimal arithmetic** (integer paise or a decimal lib); IEEE floats fail the golden files — non-negotiable. Phase-2 e-invoice work validates a payload corpus (999 × ₹0.01 lines, mixed rates, cess) against the GSP sandbox.
-
-**GST-4 · Mixed exempt/taxable baskets & charge-line treatment** — **[CORRECTABLE]** *(Phase 1)*
-A kirana basket mixes exempt staples, 0%, 5%, and 18% goods (strictly a bill of supply vs a tax invoice); stores add delivery/packing lines that are composite supply (principal item's rate), not a hardcoded 18% service. **Impact:** wrong document titles, exempt turnover misreported in GSTR-1, small per-bill tax errors × volume, audit findings. **Mitigation:** document-type resolution in the engine — unregistered buyer + mixed basket → **invoice-cum-bill-of-supply** (Rule 46A, title from template config), registered buyer + mixed → single tax invoice with nil-tax exempt lines; exempt/nil/non-GST flags are item-master fields feeding the right GSTR-1 tables. Charge lines carry a `tax_treatment` config defaulting to `composite_with_goods` (engine apportions across the bill's rates), with `independent_sac` as the alternative.
-
-**GST-5 · Per-counter series + FY reset without collisions** — **[UNFIXABLE once filed]** *(Phase 1)*
-Rule 46: invoice numbers ≤ 16 chars, unique and consecutive within a series within a financial year, resetting on 1 April. The offline design (per-counter series, SY-9) solves same-day numbering; the FY rollover is the trap. **Impact:** duplicate numbers within GSTIN+FY are rejected by the IRP and filing, and are misery to unwind; non-consecutive gaps are audit red flags. **Mitigation:** **embed the FY in the series prefix** — `ST1C2/26-27/000123` — derived from the invoice date (close time), so a new FY mechanically means a new series string and cross-FY collision is structurally impossible; fits 16 chars. `doc_series.next_no` lives in the counter's SQLite, incremented in the same transaction as the bill insert; the cloud *monitors* for duplicates (detection, not allocation). FY-N+1 series rows are pre-provisioned in February so offline counters roll correctly at midnight 31 March, guarded by the clock gate (SY-2). Voided numbers are consumed with a recorded reason, never reused. FY-rollover simulation (31 Mar offline → 1 Apr sync) is a Phase-1 exit criterion.
-
-**GST-6 · Composition dealers must NOT collect tax** — **[UNFIXABLE: tax collected illegally]** *(Phase 1; watchdog Phase 2)*
-Composition-scheme dealers (turnover ≤ ₹1.5 cr — many Lite shops) issue a **Bill of Supply**, show no GST, print the statutory declaration, cannot sell inter-state, and file CMP-08/GSTR-4. **Impact:** if the POS prints CGST/SGST lines, the dealer has collected tax without authority — Section 76 makes it payable to government plus penalty, caused by *our* default. **Mitigation:** GST scheme is a **dated, GSTIN-scoped setting** (dealers switch schemes at FY start or on crossing the limit) that drives document type/title, hides tax lines (still computed internally for CMP-08 turnover-tax), prints the declaration, blocks inter-state B2B at the POS, and disables e-invoice. A cloud **turnover watchdog** alerts at 80%/95% of the limit with a guided "switch to regular" flow. Read the scheme from the GSTIN itself at onboarding — don't ask the shopkeeper a question they may answer wrong.
-
-**GST-7 · B2B billed as B2C** — **[CORRECTABLE; UNFIXABLE for mandated sellers past the IRN window]** *(Phase 1; auto-fetch/repair Phase 2)*
-The POS default is anonymous B2C cash; a trade buyer says "GST bill" and the cashier closes B2C anyway. **Impact:** the buyer loses ITC and comes back angry; converting later means GSTR-1 amendments, and for an e-invoice-mandated seller there is **no IRN** on the original, meaning it was never a legally valid invoice (GST-8). **Mitigation:** one-key **"B2B" toggle in the payment overlay** — cashier keys the GSTIN (checksum-validated offline; legal name auto-fetched from the GSTN public API when online and cached); place of supply defaults to store state and **flips to IGST automatically** when the GSTIN's state differs (cashiers never choose CGST/SGST vs IGST by hand); a mandated tenant's B2B bill is marked `IRN_PENDING` at close with no extra cashier step. Back office gets a guided B2C→B2B repair, blocked for mandated sellers past the IRN window (force credit note + fresh invoice).
-
-**GST-8 · IRN for offline-made B2B bills — the window** — **[UNFIXABLE past the window]** *(plumbing Phase 1; enforcement Phase 2)*
-e-Invoice is mandatory for B2B/export of taxpayers with AATO > ₹5 cr; since 1 Apr 2025, taxpayers ≥ ₹10 cr **cannot report invoices older than 30 days** — the IRP hard-rejects them, the invoice is not valid, and the buyer's ITC dies with it. **This is the single most dangerous interaction between our locked local-first architecture and GST law.** A store offline a week with queued B2B bills is fine; a store whose sync silently failed for 35 days has produced invoices that can never be registered (penalty ₹10,000+ each). **Mitigation:** the pending-IRN queue is a first-class monitored entity — bill closes offline with `irn_status = PENDING` and prints immediately with an "IRN pending" strip (billing never waits, locked decision); the outbox drains → the cloud GSP bridge registers → the signed QR syncs back and the e-invoice auto-sends on WhatsApp. An **un-ignorable escalation ladder** on the age of the oldest pending IRN: > 24 h POS warning, > 72 h owner-app push, > 7 days daily WhatsApp + auto-raised ticket, approaching the window (dated config) a **red blocking banner** on POS start. When online, mandated tenants register **synchronously in the background at close** so PENDING is the exception. A void after the 24-hour IRP cancellation window (or with an active e-way bill) is **blocked in the UI** — the only path is a credit note.
-
-**GST-9 · e-Way bill thresholds & movement without EWB** — **[UNFIXABLE: offence happens in transit]** *(Phase 2)*
-EWB is required above ₹50,000 inter-state, with **per-state intra-state thresholds** (₹50k–₹2 lakh) and distance-based validity; since Jan 2025 EWBs can't be generated against documents older than 180 days. Our customers hit this on large delivered B2B sales, inter-store transfers, and purchase returns. **Impact:** goods detained in transit, Section 129 penalty up to 200% of tax — nothing fixes a completed movement without an EWB. **Mitigation:** an `ewb_threshold(state, goods_class, amount, effective_from)` config table; at bill close / dispatch the engine computes applicability from value + supply type + source/dest states and **prompts** ("needs an e-Way bill — generate now / not moving yet / customer transport"). Generation rides the same GSP bridge and offline queue, but unlike IRN the product **warns hard that movement must not start** until the EWB exists, and offers the manual portal/SMS fallback card (GST-11 outage ladder).
-
-**GST-10 · Credit notes across FY — the 30 November guillotine** — **[UNFIXABLE after deadline]** *(reference-keeping Phase 1; alerts Phase 2)*
-Section 34(2): a credit note *with GST adjustment* for an FY supply must be declared by **30 November of the following FY** (dated — it has moved before); after that only a *financial* credit note (no tax adjustment) is possible and the tax is sunk. Retail returns and expiry/scheme settlements routinely cross FYs. **Impact:** tax adjustment permanently lost; post-sale discounts issued as GST credit notes without the Section 15(3)(b) agreement become ITC disputes. **Mitigation:** credit-note creation resolves the original supply's FY from the referenced bill and **blocks GST-adjusted notes past the deadline**, offering the financial type with an explanation; an Oct–Nov back-office alert lists pending returns referencing the closing FY; the scheme engine marks agreed post-sale discounts, defaulting to financial credit note without the reference.
-
-**GST-11 · GSTR-1 vs books from late-syncing offline bills** — **[CORRECTABLE via amendment]** *(Phase 2; lock mechanics Phase 1)*
-Our immutable bills remove the classic cause (post-filing edits) but *add* a new one: a counter offline since the 3rd syncs on the 12th — after GSTR-1 was filed on the 10th — with bills dated the 28th of the filed month. Since July 2025 GSTR-3B liability auto-populates from GSTR-1 and is hard-locked, so GSTR-1 errors directly misstate tax payable. **Impact:** under-reported turnover, interest, amendment churn, CA distrust of the product. **Mitigation:** a **"filing freeze" flow** — opening GSTR-1 checks a **sync-completeness attestation** (every counter under the GSTIN synced through period-end; red counters named); only on green (or logged owner override) is the JSON generated and the period **soft-locked**, so any later-arriving document for a locked period is **quarantined** into a "post-filing arrivals" queue and resolved as a next-period amendment — nothing ever enters a filed period unnoticed. The reconciliation report is generated from the *same* engine numbers as the JSON (GST-3). GSP/IRP/EWB portal outages are absorbed by queue-and-forward, with **dual-GSP failover** (vendor is a config row) and in-product manual-fallback cards rather than a support call.
-
-**GST-12 · RCM purchases & mandatory self-invoicing** — **[UNFIXABLE past the time limit]** *(Phase 2)*
-Reverse-charge purchases (GTA freight is the universal retail case; notified goods/services from unregistered suppliers) require the *recipient* to issue a **self-invoice** within 30 days (since Nov 2024) and pay tax in cash before claiming ITC — almost universally mishandled by small-store software. **Impact:** ITC denial for missing/late self-invoices; unpaid RCM found in audit with interest. **Mitigation:** purchase entry carries an RCM flag per line/expense (freight defaults to it), auto-generating the self-invoice (own `doc_series`) and the GSTR-3B liability entry, with an age alert at 20 days unissued.
-
-**GST-13 · Wrong HSN at item-creation time** — **[CORRECTABLE, but cheap to prevent]** *(reference table Phase 1; inbox suggestions Phase 3)*
-Wrong or free-text HSN propagates to every invoice and the HSN summary; GSTR-1 Table 12 now forces dropdown-validated HSN (4-digit ≤ ₹5 cr, 6-digit above and all B2B), so invalid codes *block filing*. POS quick-add is deliberately minimal — a perfect wrong-HSN factory. **Impact:** filing blocks, HSN-summary notices, penalties, and a wrong rate inferred from a wrong HSN. **Mitigation:** ship the **official GSTN HSN master as a synced reference table**; HSN fields are **pick-only, no free text anywhere** including bulk import; an **HSN↔rate consistency check** blocks a rate unreachable from the chosen HSN; quick-add items get `hsn_status = INCOMPLETE`, complete in back office, and cannot appear on a B2B invoice while incomplete. Migration's HSN validator/fix-queue (M-04) cleans the imported base; Purchase Inbox later learns HSN from suppliers' signed e-invoice QR payloads.
-
-**GST-14 · MCA audit-trail (edit log) requirement** — **[UNFIXABLE: gaps can't be recreated]** *(Phase 1; hash chain + attestation Phase 2)*
-From FY 2023-24 a company's accounting software must keep an audit trail of every change, date-stamped, **that cannot be disabled**, preserved per the 8-year books rule; our Standard/Pro/Enterprise base includes Pvt Ltd companies. Our append-only design satisfies the substance; the pitfalls are an off-switch, local tampering, sync gaps, and retention. **Impact:** auditor qualification naming the software; for us a disqualifying sales objection. **Mitigation:** the audit log is written in the **same transaction** as every change with **no configuration, plan, role, or support tool that disables it** (a stated product guarantee); a per-device **hash chain** over entries is verified for continuity on sync so an out-of-band DB edit breaks the chain visibly; retention is an 8-year config floor; ship an **"MCA audit-trail attestation" report** the client's auditor can be handed — cheap to build, disproportionate sales value.
-
-**GST-15 · MRP enforcement (Legal Metrology)** — **[UNFIXABLE per sale]** *(Phase 1)*
-Selling above MRP is an offence; MRP varies per batch, and rate changes trigger re-stickering regimes. The enforcement point is the POS line. **Impact:** consumer complaints and Legal Metrology penalties on the store, per transaction. **Mitigation:** hard default `price ≤ selected batch's MRP` at line level (supervisor-PIN override, logged, config to disable override entirely); label and scale printing always pull MRP from the batch; the GST-1 rate-change wizard covers bulk re-stickering. DPDP handling of the customer phone data captured for e-bills/khata/loyalty is owned by SEC-F1 (consent-as-data, erasure = pseudonymization, GST 8-year retention wins over deletion).
-
-> **Architecture note — GSTIN-scoped config (GST-E5).** 02-ARCH calls compliance settings "tenant config," but registration is **per state**: one tenant = many GSTINs, and scheme, series, e-invoice applicability, EWB thresholds, and *all returns* resolve **per GSTIN**. Add `gstin` as an explicit level in the settings hierarchy in Phase 1 (one column, each store maps to one GSTIN) or Phase-4 Enterprise work forces a painful re-scoping of Phase-2 compliance code. "Consolidated GST" in the HO console is a *view across* per-GSTIN returns, never a merged return.
-
----
-
-## Counter Speed & POS UX
-
-The north-star metric — "the fastest counter in India, measured not vibed" — regresses one innocent PR at a time. The 150 ms scan-to-line NFR is decomposed into a per-stage budget measured on the **reference low-end machine** (4 GB RAM, dual-core Celeron, HDD, 1366×768) — bought in Phase 0 and made the CI perf target, not developer laptops. Per-bill telemetry (POS-9) is the instrument that keeps 1–8 honest.
-
-**POS-1 · Focus & interruption discipline** *(Phase 1)*
-Any modal, toast, snackbar, or focus change while a bill is open swallows scanner keystrokes (wedge input goes to whatever is focused) — the scanner beeped, the app didn't add the line, the bill is short and nobody noticed. Sync engines love to announce success; error handlers love modals. **Impact:** lost lines (revenue leakage *and* shrinkage), re-scans, 3–10 s stalls, destroyed trust in week one of a pilot. **Mitigation:** enforce architecturally, not by convention — the billing surface exports only `statusBar.set()` and `inlineHint.show()`; importing any dialog component into a billing-path module **fails CI (lint rule)**; a main-process **focus guard** re-focuses the scan box within 100 ms whenever an open bill exists; every scan produces an **app-side confirmation beep** distinct from the scanner's own (cashiers learn "scanner beeped, app didn't = lost scan"); a **focus-fuzz test** fires every async event the app can produce against an open bill and asserts focus never left the scan box (Phase-1 exit criterion). OS-level theft (Windows toasts, Hindi IME candidate windows, AV pop-ups) is contained by a **"Counter Mode"** setup step — Focus Assist on, active-hours set, auto-login, IME pinned to ASCII on the scan box regardless of UI language.
-
-**POS-2 · Blocking error dialogs that trap the cashier** *(Phase 1)*
-Unknown barcode, out-of-stock, expired batch, credit-limit-exceeded → an OK-button modal is the lazy default; every one is a mouse-reach or an Enter that double-fires into the next scan. **Impact:** 2–8 s per exception, and exceptions happen every few bills. **Mitigation:** all exceptions render as an **inline coloured line-slot** with a 2-key choice (unknown barcode → `[F2] Quick-add [Esc] Skip`), scan box stays focused, next scan never blocked. Stock-negative and near-expiry are **warnings, not blocks** by default; hard blocks (pharma expiry) are per-store config, not code (SY-7).
-
-**POS-3 · Scanner pipeline — config drift, dupes, wedge interleave, layout** *(Phase 0 spike; ships Phase 1)*
-Cheap unbranded scanners ship with wild factory config (no suffix, CR+LF double-submit, AIM prefixes like `]E0`), get silently reprogrammed when staff scan a config barcode from the manual, double-decode single triggers, interleave with cashier typing, and emit garbage under a Hindi layout or Caps Lock. **Impact:** "scanner doesn't work" support calls on day one; mystery "item not found" support cannot reproduce on their English-locale machine; over/under-counted quantities. **Mitigation:** don't depend on the suffix — **detect scan bursts by inter-keystroke timing** (≥ 6 chars < 20 ms apart = scan; commit on suffix *or* 50 ms silence), which also **splits an embedded scan burst from surrounding typed characters** and restores the typed fragment to the search box; strip AIM/symbology prefixes defensively; identical barcode within 150 ms with no intervening key = hardware double-read (discard + flash), ≥ 150 ms = intentional (increment qty on the last line); pin the scan box to ASCII (POS-1). A **Scanner Setup Wizard** auto-detects a per-counter profile from a test scan and prints common config barcodes. Hardware-side reset sheets and the profile library are HW-3.
-
-**POS-4 · Item search at 50–100k SKUs on cheap hardware** *(Phase 0 gate; ships Phase 1)*
-Type-ahead over 100k items in < 150 ms per keystroke on a Celeron with HDD is the **#2 queue-maker after print blocking**, because every failed scan (crumpled packs, frosted plastic, faded labels — 5–15% of items) funnels into search. `LIKE '%term%'` scans and naive Levenshtein are disqualified. **Impact:** 500 ms–2 s per keystroke → cashiers stop trusting search, memorize codes, training cost explodes. **Mitigation (opinionated stack):** SQLite **FTS5** with prefix indexes over name + aliases + code + short-code; an **in-memory hot index** of the top ~5,000 SKUs by 90-day velocity (95% of searches in < 5 ms); **phonetic/transliteration keys precomputed at save time** (a cashier types "chawal" and hits चावल, "maggie/magi/meggi" all resolve); **velocity-weighted ranking** so `Enter` on result #1 becomes the habit (rank-1 pick rate is a tracked metric); typed input debounced 30 ms, **scans bypass debounce entirely**; SQLite lives on a worker thread — the render thread never touches it. Every item gets an auto-assigned 3–5 digit **short-code/PLU** as a faster fallback than a third scan retry, plus a per-store quick-keys grid for unscannable produce. Warm the index in the background after first paint to kill the cold-cache morning outlier.
-
-**POS-5 · Electron on 4 GB shop PCs — footprint, leaks, cold start, stalls** *(budgets/soak Phase 1; watchdog Phase 2)*
-Electron baseline is 150–250 MB; a leaked listener or a chart lib accidentally imported into POS reaches 1.2 GB by hour 9 → paging → every scan janks ("it gets slow by evening, restart fixes it" — and evening is peak queue). Naive startup hits 15–25 s to billable on HDD; a GC pause or WAL checkpoint during a scan burst eats the budget invisibly. **Impact:** the classic Electron-POS complaint at exactly peak time; a power blip → 25 s reboot × queue = visible chaos. **Mitigation:** hard budgets enforced by CI — **working set < 450 MB after 12 simulated hours** (soak test replays 800 bills asserting heap/RSS), **no charting/reporting libs in the POS bundle** (bundle-size gate; back office is a separate build), completed bills evicted from renderer memory on close, an idle-time auto-restart watchdog above threshold. Startup is a **contract**: paint the billing shell (< 2 s, code-split entry chunk) → open SQLite + restore shift (< 1 s) → **billable now**; sync engine, license refresh, search warm, peripheral probes all come *after*, async. The **sync worker runs in a separate process** with its own connection; WAL checkpoints happen only at idle (no open bill, no keystroke in 5 s); a long-task observer ships any > 50 ms main-thread block with a stack in telemetry.
-
-**POS-6 · Print, drawer & the perceived-speed trick** *(Phase 1; spike measures Phase 0)*
-Waiting for the thermal printer (spooler + USB round-trip + 2–4 s of physical printing) before the next customer is the single biggest *fake* latency in POS software — 3–6 s dead time × 400 bills/day = 20–40 minutes of queue per counter. **Impact:** the north-star metric quietly dies to a well-meaning "print? Y/N" habit. **Mitigation — the commit/async contract, stated as an architecture rule:** the close key finalizes and **commits the bill to SQLite** (the *only* synchronous step, < 100 ms incl. fsync); **control returns to the cashier instantly** — grid clears, scan box focused, next scan accepted; then async and in parallel, **drawer kick first** (the cashier physically waits for the drawer, not the paper), then ESC/POS bytes, then WhatsApp e-bill, then customer-display thank-you. ESC/POS is **pre-rendered progressively** as lines are added so close only renders totals/footer/QR. Print failure surfaces as a status-bar colour only → auto-retry ×2 → one-key `Reprint last`; **billing continues uninterrupted** while the printer is red (offer the WhatsApp e-bill meanwhile). Raw ESC/POS over direct USB/socket **bypasses the Windows spooler** for thermal (spooler only for A4 laser).
-
-**POS-7 · Cashier error patterns, undo & returns mid-queue** *(Phase 1)*
-Fat-finger ×10 quantities, qty-vs-code confusion, wrong variant picked from search, and returns landing in a line of 3-item cash bills. If undo needs a mouse + confirmation, cashiers void the whole bill instead — slow, and "void whole bill" becomes the theft vector of choice. **Impact:** disputes, returns queue, shrinkage, 15–30 s bill-restarts. **Mitigation:** support **both quantity conventions** (a "Classic keys" preset matching Oracle Forms exactly, M-24) with a sanity guard (qty or line value over threshold → inline amber requiring one extra Enter, not a modal); `F9` **voids the last line instantly, no confirmation** (it's re-addable in one scan), voided lines stay struck-through and visible, void-rate per cashier is a canonical shrinkage metric (SEC-C); whole-bill void is supervisor-gated above a value threshold with a reason code. Committed bills are never edited — corrections are returns/credit notes, so the **returns flow must be fast**: one-key hold → returns mode, lookup by e-bill QR (bill id encoded, local, instant) or phone, simple same-day return in < 30 s.
-
-**POS-8 · Payment, one-key close & end-of-day** *(Phase 1; dynamic QR Phase 2; owner-app delivery Phase 3)*
-"One-key close" dies by a thousand cuts — a mandatory phone field, a tendered-amount prompt, a rounding confirmation, a "print? Y/N" — each taxing 100% of bills; UPI makes the queue wait on someone else's phone and a webhook; day-end becomes a 30–45 minute slog cashiers do sloppily. **Impact:** +3–8 s on every bill, undetected shrinkage, owners distrusting the numbers. **Mitigation — the close contract:** from the last line, **one key** closes as cash, exact-tender assumed, print + drawer async (POS-6); optional steps are *opt-in per keypress, never prompted* (type tendered → change flashes large; `F10` → payment overlay for UPI/card/khata/split; phone capture inline and optional); cash rounding auto-applies per tenant rule, never a prompt. UPI: the **dynamic QR renders on the customer display the instant the overlay opens** (amount-locked), the cashier can mark "UPI-claimed" on sight of the customer's success screen and close (webhook auto-reconciles later; mismatches surface at day-end), and the bill can be **parked-on-payment in one key** so the next customer's scanning begins — the queue never waits on NPCI. Day-end < 5 min: **running tender totals maintained through the day** (the report is a read, not a computation), **blind denomination count by default** (anti-fudge) before the expected figure is revealed, open holds and pending-UPI forced-resolved on the close screen. Khata is phone-keyed local lookup (< 50 ms) with an inline amber credit-limit warning (advisory at the counter, enforced in collections — SY, and the hold/override discipline below).
-
-**POS-9 · Workflow abuse, overrides & measuring it all** *(Phase 1; remote approval Phase 3)*
-Holds are the classic fraud hole (ring, hold, pocket cash, discard the hold at shift end); price overrides are a *social* problem (six people watching "arre ₹95 kar do") where pure security answers make queues and pure speed answers leak margin; and counter speed itself decays silently without production timing. **Impact:** direct theft, 2–5% margin erosion, and a core differentiator rotting undetected until a dealer's angry call. **Mitigation:** max concurrent holds per counter, every hold/resume/discard an audit event, discards need a reason code + supervisor PIN above a threshold, all holds **force-resolved at shift close**; a **two-band override policy** — Band A within tolerance (default 2% or ₹10) is one keystroke but **always logged** with per-cashier daily totals on the owner app, Band B beyond tolerance needs a supervisor PIN on the cashier's own keypad or a remote owner-app tap (Lite: Band A = 100%, everything logged). Every bill emits **one compact, PII-free telemetry record** (scan-to-line array, search rank picked, close-to-ready, commit/print/drawer timings, voids/overrides/holds, payment wait by tender, RSS hourly) with **staged-rollout gating** — an update ring is promoted only if its p95 scan-to-line and close-to-ready are within 10% of the prior version. Auto-update never applies with an open bill or shift, only in a maintenance window (SEC-E1).
-
----
-
-## Hardware & Environment
-
-The architectural answer to almost everything here is a **Device Profile layer that is data, not code** — the 07-DB config principle applied to hardware. A thin, stable driver engine (raw USB/serial/TCP-9100/spooler-RAW transport, an ESC/POS raster renderer, a serial framer) lives in git; the *variance* lives in `device_profile` rows that sync down like any master data, so support fixes a printer quirk for one store by editing a row and the fix reaches every store with that model — no app release. Unknown device → generic fallback + a first-run wizard. This table is also the backbone of the "SiMS Certified" program. Every mitigation below is a DB row, a wizard, a watchdog, or a checklist — never a per-shop code change.
-
-**Receipt printers, drawers, labels, scales, displays**
-
-| Issue | Impact | Mitigation (default) | Phase |
-|---|---|---|---|
-| ESC/POS brand zoo (TVS, Epson, Wep, and countless "Epson-compatible" clones implementing subsets); ₹/Hindi glyphs absent from firmware; 58 vs 80 mm and cutter/transport variance | Garbled/clipped/no-cut first-day failure; Hindi e-bills print `???` | Drive everything through `device_profile`; **default print path = raster** (`GS v 0`) using our own layout engine so ₹/Devanagari print pixel-identical to preview; width/dots/cut/transport per profile; first-run wizard test-prints a ruler bar + ₹ + Hindi line + cut + drawer-kick and tunes the profile | 0–1 |
-| Cash-drawer kick: pulse **pin (2 vs 5), duration, 12 V vs 24 V** vary by drawer × printer; cheapest printers have no DK port; jams/chewed cables | Drawer won't open at payment — the single most rage-inducing counter failure | Kick command embedded **inside the print job** (drawer opens iff the bill prints); wizard fires both pins and asks which opened; dedicated **re-kick key** re-fires without reprinting; failure never blocks bill close (POS-6) | 1 |
-| Scanner reset sheets & profile library; keyboard-wedge reprogrammed by staff, layout/CapsLock corruption | "Billing broken" calls; mystery item-not-found | Curated per-model **reset sheets** (factory-reset + our config barcodes) — the dealer's favourite artifact; profile + burst-detection parser (POS-3 owns the software side) | 1 |
-| Label printers: TSC (TSPL), Zebra (ZPL), Godex (EZPL) — three incompatible languages + gap/size calibration | Barcode-label printing fails per brand | Same profile approach, **default TSPL** (TSC dominates Indian SMB); data-driven label templates rendered per language; calibration button in wizard | 1 (TSPL), 2 (ZPL) |
-| Weighing scales: RS-232 protocols vary wildly (stream vs poll, framing, baud); PL2303 clone USB adapters have broken Win10/11 drivers; label-scale EAN-13 `2x` prefix schemes differ per store | Weight capture fails per model; weighed items mis-price (trust destroyed) | Scale profiles (poll command, frame regex, stability flag, divisor) with **Essae as launch default**; certified kit ships an **FTDI adapter** (PL2303 = known-bad); per-store scale-barcode scheme config with a live test-scan panel; PLU export in the vendor's format (POS-4/POS-8 own the read latency & parse UX; M-11 preserves PLUs) | 2–3 |
-| Customer display fragmentation (VFD pole vs 2nd monitor vs tablet) | Effort burned supporting all three | **Default = second monitor** rendering the customer-display window (lines, total, UPI QR); pole displays via profile for legacy Pro installs only; tablet not supported; remember display assignment by monitor ID and never move the billing window | 3 |
-
-**Power, environment, OS, and the shared PC**
-
-| Issue | Impact | Mitigation (default) | Phase |
-|---|---|---|---|
-| Power cuts mid-transaction; brownouts; UPS is a 600 VA unit with a dead battery; thermal printer on the UPS drains it in minutes | Half-bill lost; DB corruption on cheap disks; UPS dies mid-print | SQLite WAL + `synchronous=FULL`, **every line committed as entered** (SY-9 owns the salvage flow); install-checklist wiring rule **PC + monitor on UPS, printer on raw mains**; opinionated Lite default: **use a laptop as the POS** (built-in battery = free 2-hour UPS); read HID-battery status where exposed | 0–1 |
-| Clock drift (dead CMOS, pirated-Windows sync failure) | Invoice dates legally wrong; FY series breaks | NTP + compare against cloud on every sync; monotonic guard (a bill's timestamp may never precede the last); big date watermark on the day-open screen (SY-2 owns the shift gate; GST-5 the series impact) | 1 |
-| Antivirus/"freeze" software vs the local DB: Quick Heal/K7/Defender lock WAL files; disk cleaners delete "temp" files; Deep-Freeze/reboot-restore wipes writes nightly incl. unsynced bills | 500 ms+ write stalls (kills the 150 ms budget); "disk I/O error" bills; catastrophic silent data loss | DB under `%ProgramData%\SiMS\` with tight ACLs; installer offers an **admin-consented AV-exclusion step**; retry-with-backoff so a bill never errors to the cashier; **detect frozen volumes and refuse to run the DB on one**; EV-code-sign everything so the updater isn't quarantined (SEC-B1 owns encryption, SEC-E1 signing) | 1–2 |
-| Disk full on the shop's WhatsApp/YouTube PC; malware/keyloggers alongside khata + phone numbers | SQLite writes fail → **billing stops**; DPDP exposure | **Disk watchdog with a 512 MB billing-reserve ballast** we can delete to guarantee room to bill; degrade only non-billing functions, billing last to die; SQLCipher-at-rest (SEC-B1); optional one-click **"Counter Lock" kiosk mode** | 1–2 |
-| Windows spread (7/8.1/10/11, 32-bit, unactivated, never updated); mid-day Update reboots; DPI/resolution spread | Modern Electron can't run on Win 7/8; counter dies at 7 pm; clipped billing UI | **Decision: support Windows 10 1809+ / 11, 64-bit only**; installer hard-blocks unsupported OS with a printable spec sheet; Counter Mode sets Active Hours; design floor **1366×768 @ 100–125%** as a QA gate | 0–1 |
-| Dust/heat/insects/rats; 8-year-old HDDs; must stay fast on 4 GB HDD machines | Thermal throttling makes the "fast counter" slow; DB corruption; Electron reputation risk | Performance watchdog logs the cause bucket when scan-to-line exceeds 150 ms (support sees a dying PC before the shop does); SMART polling → dashboard flag; keep the **reference cheap machine on the QA bench** and measure all budgets on *it* | 0–2 |
-| Network flakiness: daily ISP drops, phone-hotspot CGNAT, ₹700 unmanaged LAN switch | Inbound/steady-connection features break; "multi-counter broken" calls; morning "printer gone" | Enforce **all cloud traffic is outbound HTTPS** (works behind CGNAT with zero config); sync backoff tuned for hotspots; status bar distinguishes **"internet down" vs "store box unreachable"** (different fixes, different colours); UPI degrades to static QR + manual confirm; wizard re-finds network printers by MAC after a DHCP reshuffle | 1–2 |
-
-**Remote support tooling** *(Phase 1 tier-1; Phase 2 tiers 2–3):* replace the Classic AnyDesk-into-the-shop model (also India's #1 scam vector, zero audit trail) with three tiers in order — (1) one-key **"Get Help"** uploads a scrubbed diagnostics bundle (logs, sync state, device profiles, integrity, perf buckets) to the support dashboard, billing untouched behind it; (2) **remote config push** — because everything is DB config, support fixes templates/profiles/settings from the cloud and it syncs down, resolving the majority of tickets with no screen access; (3) white-labelled **RustDesk with a self-hosted relay** for the rest, session started only by an explicit shopkeeper action, consent-bannered, audit-logged per session (SEC-H2/H3 own the consent/audit rules). Device profiles + OS/spec fingerprint sync up automatically so the dashboard shows each counter's exact hardware before anyone picks up the phone.
-
-**Hardware certification — "SiMS Certified"** *(informal Phase 1; formal program + dealer kits Phase 2):* dealers and shops buy random hardware and every new model is a potential first-day install fire; the Tally ecosystem solved this socially, and we solve it with the same `device_profile` layer plus a published tier list stored in `device_profile.cert_level` (so the in-app list, website, and dealer portal all read the same DB rows — no release to update). Tiers: **Certified** (on our QA bench, regression-tested every release, sold in dealer kits), **Compatible** (profile exists, field-verified by ≥ 3 stores without tickets), **Known-bad** (tested-and-fails or structurally unsupportable — PL2303 clones, Bluetooth receipt printers, Deep-Freeze PCs — listed *with the reason and the alternative*). Dealer kits the channel can bundle and margin on: **Lite Kit** (58 mm USB printer, 1D wedge scanner, spike guard), **Standard Kit** (80 mm cutter/DK printer, cash drawer, 2D scanner, UPS wiring), **Pro Kit** (+ Essae scale + FTDI adapter + second monitor + TSPL label printer). The Phase-0 hardware spike seeds the bench (TVS + Epson + one clone, two scanners, an Essae scale, a drawer, the reference cheap PC) and every bench device joins the release regression suite.
-
----
-
-## Migration & Parity
-
-Two framing rules defuse half of this. **The migration tool is a product, not a script** — idempotent, re-runnable, dry-run mode, schema fingerprinter, data profiler, verification report; it will run hundreds of times at dealer hands on horrible PCs, so budget it like a fifth surface. And **opening balances are authoritative; history is a read-only archive** — post-migration stock and ledgers derive from a single opening event per item/party at cutover plus Next-native movements, while full Classic history imports as searchable `source=classic` documents that never feed recomputation.
-
-> **D12 note.** Classic is Oracle 12c, and under D12 the Next **store tier stays Oracle 12c** — so the store-level data landing is intra-Oracle (schema transform within one engine, no cutover-night engine swap for the operational box), which *reduces* store-tier cutover risk. The charset, `LONG`, decimal, and empty-string gotchas below still apply in full to the **cloud-Postgres** path (the ora2pg-style transform is now only the cloud edge, not the store tier), and per-counter buffers remain SQLite.
-
-**M-01/02 · Duplicate items & shared barcodes** *(profiler Phase 0; workbench Phase 1)*
-The same product exists as 2–6 rows (spelling variants, re-created after a failed search), and one barcode legitimately maps to multiple items (same EAN at two MRPs after a revision). **Impact:** split stock, garbage analytics, and either a hard-fail on unique-barcode enforcement or silently billing the wrong MRP. **Mitigation:** a **dedupe workbench** clusters by barcode → HSN+trigram name → MRP+unit, auto-merging only barcode-exact duplicates and human-reviewing the rest (merge keeps both legacy codes as aliases, M-10); barcode→item is modelled **many-to-many with an MRP picker at the scan line** (POS-3, GST-2). Merges create movement entries so stock consolidates auditably and never block cutover.
-
-**M-03 · Negative stock baked in** *(policy Phase 0; enforced Phase 1)*
-Real datasets show items at −14 for years (sales without purchases, unit mismatches). **Impact:** migrating −14 poisons the derived-stock model; silently zeroing it invents phantom value and mismatches the customer's own books. **Mitigation:** import the **true signed quantity** as the opening movement, list every negative item with value impact in the verification report for owner sign-off, and offer a one-click "zero-out with adjustment voucher" only on approval. Next's POS itself allows negative stock by default (SY-7) — parity of *behaviour*, not just data.
-
-**M-04 · Inconsistent/missing HSN & rate mismatches** *(validator Phase 1; gate Phase 2)*
-HSN at mixed lengths, blank, pre-GST, or contradicting the stored rate; rates never updated for GST 2.0. **Impact:** e-invoice hard-rejects bad HSN, GSTR-1 summaries go wrong, and migrating wrong rates makes the shiny new system produce the same wrong invoices — now *our* bug. **Mitigation:** an **HSN validator + suggester** sorted by 12-month sales value (fix the 200 items that are 95% of revenue first); items still migrate but flagged `hsn_unverified` and blocked from e-invoice until cleared (GST-13 owns the ongoing pick-only enforcement).
-
-**M-05/06 · Orphan ledger entries & dirty party data** *(profiler Phase 0; tool Phase 1; portal re-verify Phase 2)*
-Vouchers pointing at deleted parties, payments without parent bills (Classic often ran with FKs off); invalid GSTINs, 8-digit phones, khata against a "CASH CUSTOMER" catch-all. **Impact:** dropping orphans silently changes the trial balance → the CA finds a mismatch → trust in the whole migration collapses; WhatsApp reminders spam wrong numbers (DPDP exposure). **Mitigation:** **never drop money** — orphan financial rows import against per-class **"Migration Suspense" ledgers** so the trial balance totals identically to Classic to the paisa, itemized for hypercare clearing; parties migrate as-is with GSTIN-checksum and E.164 validation, and khata reminders / e-invoice **refuse to fire on `unverified` data** while everything else works.
-
-**M-07/15 · Legacy-font text & charset conversion** *(detection Phase 0; transliteration Phase 1)*
-Hindi/regional names stored as Kruti-Dev-style Latin gibberish, and a DB charset of `WE8MSWIN1252`/`US7ASCII` with years of `NLS_LANG` mismatches. **Impact:** literal garbage in the cashier's primary surface and on WhatsApp bills; silent mojibake that row-count checks pass green. **Mitigation:** the Phase-0 profiler detects non-ASCII density and legacy-font byte signatures per column; extraction is **raw bytes → explicit decode** in our tool (never Oracle client-side conversion), the extractor refuses to run on an unexpected charset until mapped, and the verification report includes a **50-name side-by-side text-integrity sample** for human eyeball sign-off; unresolvable strings keep the Latin original in a `legacy_raw` column. A base with > 20% affected items gets a priced re-labeling pass.
-
-**M-08/09 · Stored stock vs derived; mutable/gapped historical bills** *(Phase 0 policy; Phase 1 tool)*
-Classic stores an updatable stock column hand-corrected over years, and allowed editing/deleting posted bills (gapped, re-used numbers). **Impact:** deriving opening stock from movement history resurrects every papered-over bug; Next's immutability/consecutive-series rules would *reject* honest historical data. **Mitigation:** **trust the stored number** (it's what the shop counts against) as the opening movement, reporting the derived delta as information only; all migrated documents carry `source=classic` and are **exempt from series-continuity and immutability validation**; Next-native series start fresh per counter with a new prefix from bill #1 (GST-legal, GST-5) — never "continue" a Classic series, which also keeps rollback clean (M-28).
-
-**M-10/11 · Memorized shortcodes & scale PLUs must keep working** *(Phase 1; scale export Phase 2)*
-Veteran cashiers bill by typing memorized numeric codes ("117 = Amul 500ml"); label scales hold PLU tables and thousands of printed shelf labels carry them. **Impact:** the single most visible migration failure — day-one counter slowdown dealers hear about loudest, and unscannable produce at the busiest supermarket counters. **Mitigation:** an `item_alias` table where **every legacy code (including codes of merged-away duplicates) resolves at the scan line forever** at full search speed, imported automatically and never garbage-collected (POS-4); the tool **exports scale PLU files in the vendor's format** so scales aren't reprogrammed at cutover, and `2x` barcode parsing resolves PLU→item through aliases.
-
-**M-12/13/14 · Hidden Forms logic, per-install schema drift, multi-firm databases** *(Phase 0 audit; Phase 1 tool)*
-Real behaviour lives in PL/SQL triggers, `.fmb/.pll` Forms code, and overloaded "magic" status columns; dealers patched individual customer DBs for years; some installs run multiple firms (separate GSTINs) in one database. **Impact:** ora2pg ports tables, not truth — the billing engine only reproduces bills if it reproduces *Forms'* rounding and scheme math; a tool built against the reference schema silently corrupts drifted sites; mis-mapping firm→store breaks GST isolation. **Mitigation:** the Phase-0 audit inventories every trigger (extract + annotate), decompiles Forms modules (`frmf2xml`) to grep for computation, and builds a **status-code dictionary** by profiling distinct values; a **100-real-bills golden corpus per distinct rounding/scheme config found in the base** is the executable spec for the engine (GST-3). The tool's first step is a **schema fingerprint** vs a known-versions registry — unknown drift makes it **refuse to run** and emit a drift report; multi-firm schemas force an explicit **one GSTIN = one store, one ownership group = one tenant** mapping recorded in the manifest (GST-E5).
-
-**M-16/17/18/19 · Oracle extraction gotchas** *(Phase 0 inventory/contract; Phase 1 extractor)*
-Oracle `DATE` carries meaningless time and sentinel "no-expiry" values (`31-DEC-4712`), `RR`-format two-digit years created bills dated 1925/2085; `LONG`/`LONG RAW` columns can't be read with normal SQL; unconstrained `NUMBER` exceeds double precision, `''` is NULL, `CHAR` is space-padded; and the source DB is on a decade-old shop box with a forgotten SYS password. **Impact:** batch/expiry logic misfires on garbage dates; naive extraction silently truncates free-text remarks; paisa-level ledger drift from floats and duplicate "distinct" codes differing only by trailing spaces; migration night stalls at step 1. **Mitigation:** an extraction-rules table (dates outside `[1990, cutover+1y]` → null+flag, sentinels → "no expiry", every transformation logged per row); a **self-contained JDBC extractor** (thin driver streams `LONG`s correctly, needs only a read-only TCP account) that runs throttled during business hours and resumes on interruption; a hard contract of **exact decimals end-to-end, uniform `TRIM` + empty-string→NULL, all logged**; and a "locked-out-of-Oracle" recovery runbook in the dealer kit.
-
-**M-20/21/22 · Opening balances, the verification report, cutover timing** *(Phase 1)*
-Cutover must seed stock, party ledgers **as open line-items not net figures** (ageing and bill-wise receipts need the unadjusted invoices), cash/bank, open khata, held documents, and GST-in-progress state; and it must not split a GST period, hit Diwali, or collide with filing week. **Impact:** a net balance kills ageing → wrong khata reminders on day 2 (WhatsApp to customers who owe nothing — trust incinerated); and any later discrepancy, *including ones that existed in Classic*, gets blamed on the migration. **Mitigation:** opening balances import as typed opening documents (one entry per open invoice per party, references preserved); a **verification report** printed and **signed by the owner before go-live** — item/party/stock counts and trial balance are **paisa-exact-or-block** (checks 1–9), with negative-stock/suspense/HSN-unverified/text-integrity/GSTIN lists as signed known-issues (checks 10–14); the dealer-portal scheduler simply **doesn't offer forbidden dates** (default: last evening of the month, live on the 1st, quietest weekday, never Diwali fortnight or 9th–21st), and the last Classic month files entirely from Classic (kept read-only, M-28).
-
-**M-23/24/25 · Parallel run, muscle memory, hidden features** *(Phase 0–1)*
-A classical double-key parallel run doubles counter work and staff quietly stop; veteran cashiers navigate Classic without looking, so any change in the keystroke *sequence* (not just bindings) slows them for weeks and they'll say "the new system is slow" even when scan-to-line is faster; and 5% of users depend on features nobody remembers. **Impact:** false-confidence parallel runs, pilot rejection that kills the 80%-in-year-1 target, and post-cutover emergencies over an obscure CA report. **Mitigation:** **nobody double-keys, ever** — a three-stage run of nightly **shadow replay** (extractor pulls the day's Classic transactions, replays into a staging Next tenant, auto-compares day-ends: engine bugs found for free) → 2 days **live-fire on one counter** (its own legal GST series) → cutover. Don't *document* the "Classic keys" preset — **record it**: screen-record 3 veteran cashiers, transcribe actual key sequences including double-Enter quirks, and ship it as the **default keymap for migrated stores** with a week-one on-screen hint strip; per-bill timing telemetry proves ≤ Classic before any store cuts over. Replace institutional memory with **four evidence streams** — a read-only DB usage probe (rows written per module in 12 months), a Forms/Reports source audit, 24 months of support-ticket mining, and a dealer survey — merged into a **parity heat-map** (P0 blocker for all waves / P1 blocker only for stores that use it / P2 kill-candidate the founder must veto explicitly) before Phase-1 scope freezes.
-
-**M-26 · Dealer & support-team retraining** *(kit Phase 1; certification gate Phase 2)*
-The dealers who install, train, and support Classic are the distribution channel, but their entire skill set is Forms-era — direct DB edits, remote-desktop fixes, "update the stock table" — and Next deliberately removes those levers, moving support to "diagnose via sync dashboard, fix via config." **Impact:** untrained dealers either flood HQ with tickets or find worse workarounds (hand-editing SQLite files — catastrophic under sync). **Mitigation:** **dealer certification is a migration-wave gate** — a resettable sandbox tenant seeded with a fake Classic dataset, the tool in training mode, a **"Classic reflex → Next action" translation table** ("update stock table directly" → "stock adjustment with reason code + approval"; "fix bill by editing a row" → "credit note + reissue"), a first-5-migrations-with-HQ-on-the-call rule, and a support runbook keyed to verification-report line items and sync-dashboard states. Commercial glue: a completion bounty + recurring subscription margin so dealers *want* waves. Our own support team certifies first and staffs the hypercare war-room.
-
-**M-27 · Customer trust when week one has a bug** *(protocol Phase 1; scales with certification Phase 2)*
-Some store's week one *will* hit a real defect — a wrong tax class, a printer regression, a sync stall — against a customer whose frame is "the old system worked for 15 years," with a CA and staff predisposed to blame the new thing. **Impact:** one badly-handled week-one incident becomes the story every dealer tells, and migration velocity collapses because word-of-mouth *is* the channel. **Mitigation:** a standard **hypercare protocol** for every migration — dealer physically present at opening day 1, a 7-day store+dealer+HQ war-room channel, an automated daily day-end sanity diff (Next vs trailing-4-week Classic baselines — catches wrong-tax classes fast), a < 24 h hotfix path via staged rollout + rollback (SEC-E1), and the trained **paper-never-stops guarantee**: any blocking failure → bill on the manual GST bill book every Indian shop has, enter later. Commercial backstop: a published incident/service-credit policy. Structural backstop: pilots are *friendly* customers, and no store cuts over until its own verification report (M-21) and timing gate (M-24) are green.
-
-**M-28 · Rollback: a migrated store must be able to return to Classic** *(Phase 1)*
-Some store will need to go back — a P1 parity miss, a hardware incompatibility, an owner who revolts — and promising "no rollback needed" is hubris; *having* a rollback is what makes owners willing to try at all. **Impact:** without a plan, a failed migration means either a trapped angry customer or a chaotic improvised reversal that corrupts their books. **Mitigation:** rollback is designed-in, cheap, and time-boxed — Classic is never uninstalled at cutover, only switched **read-only for 90 days** with its license intact; because Next uses fresh per-counter series (M-09), Classic's own series is untouched and simply **resumes** on rollback with no GST numbering damage either direction; do **not** build an automated Next→Oracle reverse-sync (huge effort, vanishing use) — instead the tool emits a printable **rollback pack** (the Next-period bills/purchases/payments as a register + CSV) that the dealer re-keys in one sitting (a 3-day gap is 1–3 hours). Rollback checkpoints at day 3 and day 7; past day 7 the default flips to **fix-forward**. Every rollback triggers a mandatory post-mortem feeding the parity heat-map — the same miss must not reach the next store. (Target: < 5% of migrations roll back, trending to ~0 by Phase 3.)
-
-**M-29 · Sequencing which customers migrate first** *(criteria Phase 1; scheduler Phase 2)*
-Each store archetype exercises a different feature surface, and early migrations are also the reference customers. **Impact:** wrong order concentrates failures early (complex supermarkets first would hit every parity gap at the highest-stakes sites, killing momentum) or leaves a permanent split base that doubles support cost forever. **Mitigation:** a wave plan aligned with the roadmap's migration thread, with M-22's scheduling constraints (month boundary, no festive season, no filing week) on every wave and accounting-heavy customers offered April-1 FY-boundary slots preferentially:
-
-| Wave | When | Who | Why them |
-|------|------|-----|----------|
-| 0 Pilots | Phase 1 | 3–5 friendlies (mini, medium, busy-counter), near HQ, good internet, patient owners | Maximum learning, minimum blast radius |
-| 1 Simple | Phase 2 start | Single-counter, low-SKU, cash-heavy, no accounting-module usage (per the M-25 probe) | Smallest feature surface; hardens tool + dealer process at volume |
-| 2 Engaged mid-size | Phase 2 | Top-20% most engaged/largest **whose P1 features are all shipped** | Revenue protection; reference logos |
-| 3 Supermarkets/Pro | Phase 2–3 | Multi-counter, scales, batch/expiry at scale | Needs Store Hub + scale barcodes shipped |
-| 4 Long tail + hard cases | Phase 3 | Heavy accounting, schema-drift (M-13), multi-firm (M-14), regionally batched | Their P1 gates are resolved by now |
-| — Sunset | Phase 4 | Announce only after > 80% migrated **and** Next ticket rate < Classic's | Forcing laggards earlier manufactures M-27 incidents |
-
-**M-30 · Perpetual-license → subscription resistance** *(offer structure with Phase-2 dealer kit)*
-Classic customers paid once and own it; Next asks for an annual subscription at the exact moment we also ask them to change their workflow — two asks stacked on one conversation, against real Indian-SMB price sensitivity. **Impact:** customers who *like* Next still stall on the commercial term, missing the migration-rate target for non-technical reasons. **Mitigation:** make the 00-VISION loyalty offer mechanical — a Classic-base price lock published before Phase-2 waves, migration service bundled free into the year-1 subscription, and the "paywall is a demo" upgrade path so they start on the cheapest plausible edition the M-25 usage probe proves they use. Plans and prices are DB rows, so wave-specific offers need no release.
-
----
-
-## Security, Licensing & Operations
-
-Framing principle for the whole section: **the counter must never be punishable** — not by licensing, not by updates, not by cloud outages, not by our own support tooling; every enforcement and security mechanism degrades *around* billing, never *through* it. And second: **encryption protects against outsiders; audit protects against insiders** — shops are insider-threat environments first, and the founder's customers lose more money to their own staff than to hackers, so "Loss Prevention" is a Pro *feature*, not just a control set.
-
-**SEC-A1 · RLS is one `WHERE` clause away from a cross-tenant breach** *(Phase 1)*
-Single-cluster Postgres with `tenant_id` + RLS fails silently in well-known ways — a table owner bypasses policies without `FORCE ROW LEVEL SECURITY`, a `BYPASSRLS`/superuser role skips it entirely, a connection pooler in transaction mode can leak a `SET` tenant context across requests, and a single new table without a policy defaults to *visible*, not hidden. **Impact:** critical — one leak between two shop owners who are competitors on the same dealer is a company-ending trust event that travels the channel faster than any patch. **Mitigation:** the app connects as a **non-owner, non-superuser role**; a migration lint **fails CI** if any table lacks both a `tenant_id` column and an `ENABLE + FORCE` policy; tenant context is `SET LOCAL app.tenant_id` (not `SET`) derived **only** from the verified JWT via a query-layer wrapper nobody can hand-write wrong; a **cross-tenant probe suite** logs in as tenant A against tenant B's IDs on every endpoint and report and expects 404/empty — a new endpoint without a probe fails the build; one shared default-deny policy template, no per-table creative writing.
-
-**SEC-A2 · Reports, exports, and materialized views are where RLS actually leaks** *(query layer Phase 1; GSTR/Tally Phase 2; scheduled reports Phase 3)*
-Row-level security guards base tables; the leak surface is everything *derived* — dashboard materialized views, report-builder raw SQL, scheduled CSV/GSTR/Tally exports, and background jobs that run with no user context and must set tenant scope explicitly. This is the path engineers treat as "just reads," written fast and often bypassing the ORM. **Impact:** critical, and *more likely* than SEC-A1 — a missing filter on an aggregate produces "someone else's sales in my dashboard." **Mitigation:** **no raw cross-tenant SQL in application paths** — all reporting goes through the query layer that injects tenant scope, and materialized views carry `tenant_id` + their own policies; background jobs (report scheduler, backup exporter, Purchase Inbox parser) run through a wrapper that **requires an explicit tenant parameter** and throws without one; every generated file (CSV, GSTR JSON, Tally XML, PDF) is written to a per-tenant object-storage prefix with a signed, expiring (24 h) URL, filename carrying tenant slug + requester, and an audit-logged row count; scheduled-report recipients are validated against registered users, changes supervisor-gated and audited.
-
-**SEC-A3 · Dealer portal and HO console are the dangerous kind of cross-tenant view** *(dealer Phase 2; HO Phase 4)*
-Dealers legitimately see many tenants (their installs) and Enterprise HO sees many stores — both are "authorized cross-tenant access" that punches holes in the tenant=boundary model, and a dealer account is a skeleton key to dozens of shops whose staff and laptops we don't manage. **Impact:** high — one phished dealer account exposes a whole region's businesses. **Mitigation:** dealer access is **grant-based, not query-based** (an explicit `dealer_tenant_grant` per tenant, created at onboarding, revocable by the owner from a "who can see my business" screen, and part of the RLS policy); dealer accounts see **operational health only by default** (license state, sync health, version, tickets — never sales figures or customer lists) with business-data access requiring per-tenant owner consent; mandatory 2FA (TOTP/WhatsApp OTP) and per-staff sub-logins, no shared credentials; HO console scopes stores as sub-scopes so a franchise manager cannot see sibling stores.
-
-**SEC-A4 · Sync API accepts forged or mis-scoped ops** *(design Phase 0; enforced Phase 1)*
-The outbox means the cloud ingests client-generated events, and anything on a shop PC is attacker-controllable — a tampered POS could push ops with someone else's `tenant_id`, backdated bills, or fabricated stock movements (fake ITC). **Impact:** high — cross-tenant *write* corruption is worse than a read leak. **Mitigation:** **device identity, not just user identity** — every counter enrolls once (owner-approved), receives a device keypair, and signs its ops; the server **derives** `tenant_id`/`store_id`/`counter_id` from the device record and **rejects any op claiming otherwise** (client scope fields checked, never trusted); idempotency keys double as replay protection; ingest sanity gates require the invoice series to match the device's assigned counter series and flag documents dated outside ±72 h of server time as `clock_suspect` (SEC-D2). This is the same device lease/epoch primitive that fences cloned machines (SY-9) and enforces licensing (SEC-D).
-
-**SEC-A5 · Offline bill drain is unauthenticated (found by automated review, 2026-07-10 — rated CRITICAL)** *(fix in Phase 1 before any pilot)*
-`POST /api/bills/offline` is registered *above* `r.use(requireAuth)` in `apps/store-server/src/api.ts`, and `commitBill` trusts `storeId`/`cashierId`/`shiftId` from the body — any device on the shop LAN can commit forged bills under any cashier's name with no login, and the override audit rows compound with SEC-C6 (client-asserted approver). This is SEC-A4's failure mode surfacing at the store tier before the sync pipe even exists. **Impact:** critical once any real store runs it — financial record forgery, GST series consumption, poisoned fraud analytics. **Mitigation (either, before first pilot):** (a) simplest — move the route below `requireAuth` and derive `tenantId`/`cashierId` from the session exactly like `/bills` (the POS already persists its session token, so the offline queue can drain with it after re-login); or (b) the D14-aligned answer — a per-counter device token in a `device` table, required as a header, with server-side cross-checks that the store belongs to the device's tenant and `cashierId`/`shiftId` belong to that store. Either way, reject body identities that don't resolve within the authenticated tenant.
-
-**SEC-B · Local data security at the shop** *(perf Phase 0; ship Phase 1; Hub auth Phase 2)*
-The counter's SQLite buffer is the whole business in one copyable file — items, costs, margins, full sales history, party ledgers, customer phone numbers — on a PC that staff, technicians, and the cyber-café repair guy all touch; and shop PCs get stolen, resold, or repossessed still enrolled. **Impact:** high — cost/margin data to a competitor, customer list to a rival, DPDP exposure, plus a rogue enrolled device that can still sync. **Mitigation:** **SQLCipher full-database encryption** on every counter buffer and the store tier (256-bit random key per device; verify the < 150 ms budget survives in the Phase-0 spike — expected single-digit % overhead), the key **wrapped by Windows DPAPI (machine scope)** so a copied file is useless off the machine and **escrowed to a per-tenant cloud key** so a support-led recovery can unlock a salvaged DB. An owner-facing **"deactivate device"** action revokes the device identity immediately and queues a **remote wipe** that executes if the machine ever comes online. Be honest in the threat-model doc: this stops the *copied file* and the *stolen PC*, not a person at the running app — that person is handled by roles + audit, not crypto. Store-tier and Hub↔counter LAN traffic reuse the device-identity mutual auth (the store's flat LAN has the CCTV DVR and the neighbour's laptop on it).
-
-Insider fraud is a product feature ("Loss Prevention"), sold as part of Pro, as much as a control set — because the founder's customers lose more to their own staff than to hackers.
-
-**SEC-C1 · Void abuse (ring, take cash, void the bill)** *(Phase 1)*
-The cashier rings the sale, the customer pays cash and leaves, the cashier voids the bill or a line and pockets the cash. Immutable bills help only if voids are themselves loud, attributed documents. **Impact:** high, continuous shrinkage the owner blames on the software when discovered. **Mitigation:** voids/cancellations are **new append-only documents** referencing the original (never a delete); post-payment line removal is impossible (the only path is a return document with the original bill reference); every void requires supervisor auth; voided-bill count and value appear on the day-end report **by cashier, unhideable**.
-
-**SEC-C2 · Discount abuse (sweethearting)** *(Phase 1)*
-The cashier applies unauthorized discounts for friends/family or overrides price downward — easy, socially normal, invisible without per-cashier analytics. **Impact:** medium-high margin leak. **Mitigation:** a discount above a configurable threshold (default 5% line / 3% bill, a `setting` row) requires supervisor auth; manual price entry below cost hard-blocks without supervisor; all discounts carry a reason code from a DB list; the two-band override policy (POS-9) gives the counter-speed side of this.
-
-**SEC-C3 · Reprint-and-pocket / no-bill sales** *(Phase 1; QR verification Phase 2)*
-Two variants: reprint an old bill and hand it to a new customer with identical items, pocketing the cash; or simply never ring the sale (drawer kept ajar). **Impact:** high in cash-heavy shops; no-bill sales are invisible to any system and caught only statistically and physically. **Mitigation:** every reprint is watermarked **"DUPLICATE — copy N, printed by "** and logged (next-day reprints need supervisor auth); **no-sale drawer-open is its own audited event with a mandatory reason** and drawers kick only via the app; the e-bill QR carries the bill id so a customer scanning a duplicate sees "issued " (customers become auditors); no-bill sales surface via shift-level cash variance at blind count (POS-8) plus the bills-per-hour-vs-peer metric below.
-
-**SEC-C4 · Returns/refund fraud (refund to nobody)** *(Phase 1)*
-The cashier processes a return with no customer present and pockets the refund — returns are the least-watched flow. **Impact:** medium-high. **Mitigation:** returns require an original bill reference (e-bill QR / bill-no lookup); no-reference returns require supervisor and are separately counted; cash refunds above a threshold (default ₹500, a `setting`) require supervisor; return rate per cashier feeds the analytics below.
-
-**SEC-C5 · The fraud-analytics counter-set (make audit data *do* something)** *(raw counters Phase 1; dashboard + digest Phase 3)*
-An append-only audit log without built-in analytics has zero deterrent value — no shop owner will ever query it. **Impact:** every C1–C4 mitigation underperforms without this. **Mitigation:** ship a **Loss Prevention dashboard + weekly WhatsApp digest to the owner** (thresholds are `setting` rows), because deterrence comes from staff *knowing* the owner sees a weekly digest. PINs must be **per-user, never per-role** (they become folklore — "it's 1234, Ramesh told me" — otherwise every audit line names the same supervisor; lockout after 5 failures, owner-app remote approval preferred, Phase 3).
-
-| Metric (per cashier / counter / week) | Catches | Default alert |
-|---|---|---|
-| Void count & value ÷ bills billed | C1 | > 2% of bills or > 2× peer median |
-| Line-deletes before payment per bill | C1 pre-close | > 3× peer median |
-| Discount % distribution + manual overrides | C2 | avg discount > 1.5× peer median |
-| Reprints of bills > 1 day old | C3 | any next-day reprint |
-| No-sale drawer opens | C3 | > 3/shift |
-| Cash variance at shift close | C3 | > ₹200 or recurring same-sign |
-| No-reference returns; cash-refund value | C4 | any, listed by name |
-| Supervisor approvals while approver off-shift | PIN abuse | any |
-| Bills per hour vs counter peer median | no-bill sales | < 60% of median sustained |
-| Khata credit to same phone-number cluster | fake-credit fraud | > threshold |
-
-**SEC-C6 · Override approval is client-asserted — audit forgery hole (found by automated review, 2026-07-10)** *(fix in Phase 1 before any pilot)*
-`POST /api/bills` → `commitBill` trusts `overrides[].approvedByUserId` straight from the request: `/auth/verify-pin` (the E-6105 supervisor gate) verifies a PIN but its result is never bound to the commit, so any client on the LAN can stamp "approved by