diff --git a/.gitignore b/.gitignore
index f89e6ef..5573059 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ apps/hq/data/
/data/
# Real client data staging for the APEX->HQ import — never committed
import-drop/
+# Local ruflo tooling artifact
+ruvector.db
diff --git a/BUILDING.md b/BUILDING.md
new file mode 100644
index 0000000..e4e63cc
--- /dev/null
+++ b/BUILDING.md
@@ -0,0 +1,101 @@
+# 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/README.md b/README.md
new file mode 100644
index 0000000..81b9cb1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,36 @@
+# SiMS Next — Store Management Suite (Rebuild)
+
+A ground-up rebuild of SiMS Store Software: GST-compliant retail management for India —
+inventory, POS billing, purchases, and accounting — for everything from a single-counter
+kirana shop to multi-outlet supermarket chains.
+
+**Why the rebuild:** the current product runs on Oracle Forms & Reports (hard to maintain,
+buggy, no path to cloud/multi-store), and the market is moving. Same customers, much better
+product.
+
+**North star:** the fastest, easiest counter workflow in the market. The customer in line
+never waits because of the software, and purchase entry is never a burden.
+
+## Planning documents
+
+| Doc | What it covers |
+|-----|----------------|
+| [docs/00-VISION.md](docs/00-VISION.md) | Product vision, segments/editions, business model, success metrics |
+| [docs/01-SCOPE.md](docs/01-SCOPE.md) | Full module & feature catalog with priorities and edition mapping |
+| [docs/02-ARCHITECTURE.md](docs/02-ARCHITECTURE.md) | Offline vs online vs hybrid analysis, sync design, tech stack, multi-tenancy |
+| [docs/03-FRONTEND.md](docs/03-FRONTEND.md) | App surfaces, counter UX principles, screen inventory, design system |
+| [docs/04-ROADMAP.md](docs/04-ROADMAP.md) | Phased build plan with exit criteria, pilot & migration strategy |
+| [docs/05-CHECKLIST.md](docs/05-CHECKLIST.md) | Master build checklist, grouped by area |
+| [docs/06-DECISIONS.md](docs/06-DECISIONS.md) | Open decisions that need a founder call, with recommendations |
+| [docs/07-DB-AND-CONFIG.md](docs/07-DB-AND-CONFIG.md) | Everything-in-DB design (messages, plans, settings), local DB engine analysis (D12) |
+| [docs/08-MARKET-ARCHITECTURES.md](docs/08-MARKET-ARCHITECTURES.md) | How Tally/Marg/GoFrugal/Vyapar & global players architect; Android POS conclusion |
+| [docs/09-UX-FLOWS-AND-MENUS.md](docs/09-UX-FLOWS-AND-MENUS.md) | Screen-level UX blueprint: POS flows, back-office menu tree, Purchase Inbox, owner app |
+| [docs/10-ISSUES-REGISTER.md](docs/10-ISSUES-REGISTER.md) | Consolidated failure-mode register across 6 lenses, with Top-10 risk ranking |
+| [docs/11-ADMIN-SUPPORT-CONSOLE.md](docs/11-ADMIN-SUPPORT-CONSOLE.md) | Settings hierarchy, tenant Admin review, and the vendor-side HQ/support console |
+| docs/13-SPEC-*.md | Keystroke-level specs: billing polish + purchase entry (P1/P2 backlog) |
+| [docs/14-SPEC-HQ-CONSOLE.md](docs/14-SPEC-HQ-CONSOLE.md) | HQ ops console for the Classic client base (D15, apps/hq) |
+| [docs/15-EDITIONS.md](docs/15-EDITIONS.md) | Lite/Standard/Pro/Enterprise: privileges, limits, costs — all configurable DB rows |
+| [docs/16-PROJECT-RULES.md](docs/16-PROJECT-RULES.md) | The 26 hard rules (Tables Rule, money rules, config-over-code, counter rules) |
+| [docs/17-SCALE-AND-INTEGRATIONS.md](docs/17-SCALE-AND-INTEGRATIONS.md) | The 7 committed scale gaps + full integrations catalog with status |
+
+Start with 00-VISION, then 02-ARCHITECTURE, then 04-ROADMAP.
diff --git a/STATUS.md b/STATUS.md
index bb31e8e..c4de85e 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -1,7 +1,7 @@
-# Build Status — 2026-07-09 (post-slice)
+# Build Status — 2026-07-10 (post-slice)
-**54 screens across 2 surfaces** (46 back office + 8 POS), on 7 tested logic packages
-(54 tests green), and a store-server **with a real data layer** (SQLite behind portable
+**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`.
@@ -21,7 +21,8 @@ both UIs in a real browser: bill `ST1C2/26-000001`, ₹600.00.
| `@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/printing` | ESC/POS byte builder, receipt renderer (42/32 col), drawer kick | ✅ |
+| `@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 | — |
@@ -48,9 +49,94 @@ audio feedback (ok/warn/err beeps), ok-notices auto-clear, stock chips in sugges
(live counts, out-of-stock warn-not-block). Bill ST1C2/26-000003 proves the chain.
Purchase Entry P1: crash-proof localStorage draft with resume banner; glance strip on
cell focus (stock · sale · MRP · live margin · last-3 costs via /purchases/cost-history).
-Still pending: returns flow, supervisor PIN overlay (needs /auth/verify-pin), phonetic
-@sims/search-core, purchase schema P1s (free-qty, disc%, interstate split — one
-migration pass together), history-R repeat, customer display, offline queue.
+Still pending: returns flow, supervisor PIN overlay (needs /auth/verify-pin),
+purchase schema P1s (free-qty, disc%, interstate split — one migration pass together),
+history-R repeat, customer display, offline queue.
+
+**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).
## Purchase Entry — LIVE (the second crown jewel)
@@ -77,9 +163,10 @@ Login (1) + Dashboard (1, custom) + 44 module pages:
| Reports | Sales Reports · Margins · Stock Aging · Day-End Digest | 4 |
| Admin | **Users & Roles** (custom matrix) · Stores & Counters · **Settings** (custom hierarchy) · Print Templates · Integrations · Subscription & Plan · Audit Log · Sync & Devices · Backups · Data Import | 10 |
-**5 pages are now LIVE on real data**: Items (list + search + New Item — billable at the
-counter immediately), Customers, Bills, Stock (derived from movements), Audit Log.
-Login is real (server-verified). 36 pages render from the data-driven registry; 4 are
+**6 pages are now LIVE on real data**: Items (list + search + New Item — billable at the
+counter immediately), Customers, Bills (rows open the printable A4 GST invoice — S2),
+Stock (derived from movements), Audit Log, and Label Printing (barcode/MRP sheets — S2).
+Login is real (server-verified). 35 pages render from the data-driven registry; 4 are
custom wireframes (Dashboard, Purchase Inbox, Settings hierarchy, Users & Roles).
Edition-locked pages render as upsells; everything themed and role/edition-aware.
diff --git a/apps/backoffice/README.md b/apps/backoffice/README.md
new file mode 100644
index 0000000..d0ae6d9
--- /dev/null
+++ b/apps/backoffice/README.md
@@ -0,0 +1,5 @@
+# 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
new file mode 100644
index 0000000..15c0faa
--- /dev/null
+++ b/apps/backoffice/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ SiMS Next — Back Office
+
+
+
+
+
+
diff --git a/apps/backoffice/package.json b/apps/backoffice/package.json
new file mode 100644
index 0000000..b41e1f5
--- /dev/null
+++ b/apps/backoffice/package.json
@@ -0,0 +1,28 @@
+{
+ "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
new file mode 100644
index 0000000..f077201
--- /dev/null
+++ b/apps/backoffice/src/Layout.tsx
@@ -0,0 +1,59 @@
+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
new file mode 100644
index 0000000..95bd252
--- /dev/null
+++ b/apps/backoffice/src/Login.tsx
@@ -0,0 +1,43 @@
+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
new file mode 100644
index 0000000..889c19c
--- /dev/null
+++ b/apps/backoffice/src/api.ts
@@ -0,0 +1,73 @@
+/** Back office ↔ store-server client; session token survives reloads. */
+
+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
+}
+
+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')
+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)
diff --git a/apps/backoffice/src/app.css b/apps/backoffice/src/app.css
new file mode 100644
index 0000000..013beee
--- /dev/null
+++ b/apps/backoffice/src/app.css
@@ -0,0 +1,30 @@
+.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
new file mode 100644
index 0000000..fcbd88e
--- /dev/null
+++ b/apps/backoffice/src/main.tsx
@@ -0,0 +1,59 @@
+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 { clearSession } 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, ItemsPage, StockPage } from './pages/live'
+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,
+ '/inventory/stock': StockPage,
+ '/admin/audit': AuditPage,
+ '/purchases/entry': PurchaseEntryPage,
+ '/purchases/list': PurchasesListPage,
+}
+
+function App() {
+ const [userName, setUserName] = useState()
+ if (userName === undefined) return
+ return (
+
+
+ { clearSession(); 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
new file mode 100644
index 0000000..c29bd45
--- /dev/null
+++ b/apps/backoffice/src/nav.ts
@@ -0,0 +1,118 @@
+/**
+ * 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
new file mode 100644
index 0000000..f625321
--- /dev/null
+++ b/apps/backoffice/src/pages/Dashboard.tsx
@@ -0,0 +1,51 @@
+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
new file mode 100644
index 0000000..19cc43f
--- /dev/null
+++ b/apps/backoffice/src/pages/GenericPage.tsx
@@ -0,0 +1,58 @@
+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
new file mode 100644
index 0000000..3950b25
--- /dev/null
+++ b/apps/backoffice/src/pages/PurchaseInbox.tsx
@@ -0,0 +1,65 @@
+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
new file mode 100644
index 0000000..75880f2
--- /dev/null
+++ b/apps/backoffice/src/pages/SettingsPage.tsx
@@ -0,0 +1,46 @@
+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
new file mode 100644
index 0000000..085382f
--- /dev/null
+++ b/apps/backoffice/src/pages/UsersRoles.tsx
@@ -0,0 +1,63 @@
+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/labels.tsx b/apps/backoffice/src/pages/labels.tsx
new file mode 100644
index 0000000..5ac819d
--- /dev/null
+++ b/apps/backoffice/src/pages/labels.tsx
@@ -0,0 +1,100 @@
+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
new file mode 100644
index 0000000..96c6195
--- /dev/null
+++ b/apps/backoffice/src/pages/live.tsx
@@ -0,0 +1,304 @@
+import { useEffect, useState } from 'react'
+import { formatINR, type BillLine, type BillTotals } from '@sims/domain'
+import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing'
+import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, Toolbar } from '@sims/ui'
+import { createItem, getAudit, getBills, getItems, getParties, getSeller, getStock, type Seller } from '../api'
+
+/** 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'}
+
+ )}
+
+ )
+}
+
+export function StockPage() {
+ const { data, error } = useData(() => getStock())
+ 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'])}`,
+ state: onHand <= 0 ? out : onHand < 15 ? low : ok ,
+ }
+ })}
+ />
+ )}
+
+ )
+}
+
+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'}
+
+ )}
+
+ )
+}
diff --git a/apps/backoffice/src/pages/purchase-entry.tsx b/apps/backoffice/src/pages/purchase-entry.tsx
new file mode 100644
index 0000000..d632eae
--- /dev/null
+++ b/apps/backoffice/src/pages/purchase-entry.tsx
@@ -0,0 +1,469 @@
+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[]
+}
+
+interface PLine {
+ itemId: string; name: string; unitCode: string
+ qty: number; unitCostPaise: number; taxRateBp: number
+ lastCostPaise?: number
+ currentSalePaise: number; currentMrpPaise?: number
+ newSalePricePaise?: number; newMrpPaise?: number
+}
+
+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 [entry, setEntry] = useState('')
+ const [printedTotal, setPrintedTotal] = useState('')
+ const [notice, setNotice] = useState<{ tone: 'ok' | 'warn' | 'err'; text: string } | undefined>()
+ const [busy, setBusy] = useState(false)
+ /** Filled on post so the receiving clerk can print labels for what just came in. */
+ const [labelQueue, setLabelQueue] = useState()
+ /** Which cell owns focus: the scan box, or qty/cost of a line. */
+ const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' } | undefined>()
+
+ 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) cellRef.current?.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 } : {}),
+ }]
+ })
+ 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 } : {}),
+ })),
+ 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 (
+
+ )
+}
+
+/** 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
new file mode 100644
index 0000000..ff0c61e
--- /dev/null
+++ b/apps/backoffice/src/pages/registry.tsx
@@ -0,0 +1,673 @@
+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
new file mode 100644
index 0000000..bf5a36d
--- /dev/null
+++ b/apps/backoffice/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*"]
+}
diff --git a/apps/backoffice/vite.config.ts b/apps/backoffice/vite.config.ts
new file mode 100644
index 0000000..4fc47c6
--- /dev/null
+++ b/apps/backoffice/vite.config.ts
@@ -0,0 +1,21 @@
+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/pos/README.md b/apps/pos/README.md
new file mode 100644
index 0000000..0e62bd6
--- /dev/null
+++ b/apps/pos/README.md
@@ -0,0 +1,7 @@
+# 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
new file mode 100644
index 0000000..a852530
--- /dev/null
+++ b/apps/pos/build-main.mjs
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..fd44967
--- /dev/null
+++ b/apps/pos/dist-electron/main.cjs
@@ -0,0 +1,62 @@
+"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
new file mode 100644
index 0000000..eeff08f
--- /dev/null
+++ b/apps/pos/dist-electron/preload.cjs
@@ -0,0 +1,8 @@
+"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
new file mode 100644
index 0000000..1414f2d
--- /dev/null
+++ b/apps/pos/electron/main.ts
@@ -0,0 +1,46 @@
+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
new file mode 100644
index 0000000..0f45d23
--- /dev/null
+++ b/apps/pos/electron/preload.ts
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..58b0339
--- /dev/null
+++ b/apps/pos/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ SiMS Next POS
+
+
+
+
+
+
diff --git a/apps/pos/package.json b/apps/pos/package.json
new file mode 100644
index 0000000..822ed6b
--- /dev/null
+++ b/apps/pos/package.json
@@ -0,0 +1,32 @@
+{
+ "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/src/App.tsx b/apps/pos/src/App.tsx
new file mode 100644
index 0000000..3a229c8
--- /dev/null
+++ b/apps/pos/src/App.tsx
@@ -0,0 +1,77 @@
+import { useEffect, useState } from 'react'
+import { Button, Notice } from '@sims/ui'
+import { uuidv7, type Item } from '@sims/domain'
+import { fetchBootstrap, fetchItemsCache, type Bootstrap, type PosUser } from './api'
+import { Login } from './Login'
+import { ShiftOpen } from './ShiftOpen'
+import { BillingScreen } from './BillingScreen'
+
+/** POS flow: bootstrap from store-server → login (PIN) → shift open → billing. */
+export function App() {
+ const [boot, setBoot] = useState()
+ const [bootError, setBootError] = useState()
+ const [user, setUser] = useState()
+ const [items, setItems] = useState- ()
+ const [shift, setShift] = useState<{ id: string; floatPaise: number } | undefined>()
+
+ const load = () => {
+ setBootError(undefined)
+ const counter = new URLSearchParams(location.search).get('counter') ?? 'C2'
+ fetchBootstrap(counter).then(setBoot).catch((e: Error) => setBootError(e.message))
+ }
+ useEffect(load, [])
+
+ 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 (
+ {
+ setUser(u)
+ setItems(await fetchItemsCache())
+ }}
+ />
+ )
+ }
+ if (shift === undefined) {
+ return (
+ setShift({ id: uuidv7(), floatPaise })}
+ onBack={() => {
+ setUser(undefined)
+ setItems(undefined)
+ }}
+ />
+ )
+ }
+ return (
+ {
+ setShift(undefined)
+ setUser(undefined)
+ setItems(undefined)
+ }}
+ />
+ )
+}
diff --git a/apps/pos/src/BillingScreen.tsx b/apps/pos/src/BillingScreen.tsx
new file mode 100644
index 0000000..b59594a
--- /dev/null
+++ b/apps/pos/src/BillingScreen.tsx
@@ -0,0 +1,1017 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Badge, Button, Field, Modal, Notice, ThemeSwitcher } from '@sims/ui'
+import { deriveSupply, formatINR, validateGstin, type Item, type PaymentMode, type RoleCode } from '@sims/domain'
+// Subpath import: the @sims/auth barrel re-exports pin.ts (node:crypto), which the
+// browser bundle can't include. permissions.ts is pure (type-only imports), so the
+// role→gate matrix stays the single source of truth without dragging in scrypt.
+import { gateFor } from '@sims/auth/permissions'
+import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
+import { parseEntry, parseScaleBarcode, WedgeDetector, type ScaleBarcodeProfile } from '@sims/scanning'
+import { buildIndex, querySearch } from '@sims/search-core'
+import { renderReceipt, receiptLines, type ReceiptDoc } from '@sims/printing'
+import { createCustomer, createDraftItem, fetchBills, fetchStock, findCustomers, postBill, verifySupervisorPin, type BillOverride, type BootstrapStore, type PosCustomer, type PosUser } from './api'
+import { 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. */
+interface Approval { approvedByUserId: 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'
+
+export function BillingScreen(props: {
+ user: PosUser
+ users: PosUser[]
+ store: BootstrapStore
+ taxClasses: TaxClassRow[]
+ items: Item[]
+ shiftId: string
+ floatPaise: number
+ 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; resolve: (a: Approval | undefined) => void } | 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>()
+
+ 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
+
+ // 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)
+ }, [])
+
+ 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)`)
+ }
+ 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 && qty === undefined) {
+ const i = prev.findIndex((l) => l.itemId === item.id)
+ 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 } : {}),
+ }]
+ })
+ 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): Promise =>
+ new Promise((resolve) => setSupPanel({ desc, resolve }))
+
+ const applyPrice = (i: number, paise: number, approval?: Approval, before?: number) => {
+ setLines((prev) => prev.map((x, j) => (j === i ? { ...x, unitPricePaise: paise } : x)))
+ if (approval !== undefined && before !== undefined) {
+ const itemId = lines[i]?.itemId
+ if (itemId !== undefined) {
+ setOverrides((o) => [...o, { itemId, kind: 'price', before, after: paise, approvedByUserId: approval.approvedByUserId }])
+ }
+ }
+ 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.
+ 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); return } // supervisor+ self-authorises
+ const approval = await supervisorGate(
+ `${line.name}: rate ${formatINR(line.unitPricePaise)} → ${formatINR(paise)}${beyondBand ? ` (beyond ±${POS_LIMITS.PRICE_BAND_PCT}% band)` : ''}`,
+ )
+ if (approval === undefined) { say('warn', 'Price override cancelled — no supervisor approval'); inputRef.current?.focus(); return }
+ applyPrice(i, paise, approval, line.unitPricePaise)
+ 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 apply = (approval?: Approval) => {
+ setLines((prev) => prev.map((x, j) => (j === i ? { ...x, discount } : x)))
+ if (approval !== undefined) {
+ setOverrides((o) => [...o, { itemId: line.itemId, kind: 'discount', before: 0, after: discPaise, approvedByUserId: approval.approvedByUserId }])
+ }
+ inputRef.current?.focus()
+ }
+ if (!overCap || userGate('DISCOUNT_OVER_CAP') === 'allow') { apply(); return }
+ const approval = await supervisorGate(`${line.name}: discount ${formatINR(discPaise)} (> ${POS_LIMITS.DISC_WARN_PCT}% of line)`)
+ if (approval === undefined) { say('warn', 'Discount cancelled — no supervisor approval'); inputRef.current?.focus(); return }
+ apply(approval)
+ say('ok', `Discount approved by ${approval.approvedByName}`)
+ }
+
+ const commit = (mode: PaymentMode, tenderedPaise?: number) => {
+ if (busy) return
+ if (computed === undefined) return say('warn', 'Nothing to bill')
+ setBusy(true)
+ const totals = computed.totals
+ const committedLines = lines
+ postBill({
+ storeId: store.id, counterId: store.counterId, shiftId: props.shiftId,
+ ...(customer !== undefined ? { customerId: customer.id } : {}),
+ businessDate: today, lines,
+ payments: [{ mode, amountPaise: totals.payablePaise }],
+ clientPayablePaise: totals.payablePaise,
+ ...(overrides.length > 0 ? { overrides } : {}), // audited server-side in the commit txn (spec P0-2)
+ })
+ .then((res) => {
+ // The bill is committed server-side; printing is a consequence (09-UX §5.4).
+ setDayLog((d) => [...d, { docNo: res.docNo, payablePaise: totals.payablePaise, mode, at: new Date().toLocaleTimeString() }])
+ setLastBillLines(committedLines) // Ctrl+B repeat (spec P1-1)
+ // decrement local stock view optimistically (spec P1-5)
+ setStockMap((m) => {
+ const next = new Map(m)
+ for (const l of committedLines) {
+ const item = cat.find((i) => i.id === l.itemId)
+ if (item !== undefined && next.has(item.code)) next.set(item.code, next.get(item.code)! - l.qty)
+ }
+ return next
+ })
+ if (tenderedPaise !== undefined && tenderedPaise > totals.payablePaise) {
+ setChangeFlash(tenderedPaise - totals.payablePaise)
+ setTimeout(() => setChangeFlash(undefined), 4000)
+ }
+ setLines([])
+ setPendingQty(undefined)
+ setCustomer(undefined)
+ setOverrides([]) // override records belong to the committed cart only
+ setOverlay('none')
+ const cfg = loadPrinterCfg()
+ const kick = mode === 'CASH'
+ const receiptDoc: ReceiptDoc = {
+ storeName: store.name, gstin: store.gstin, docNo: res.docNo, businessDate: today,
+ counterCode: store.counterCode, cashierName: props.user.name,
+ ...(customer !== undefined ? { buyerName: customer.name } : {}),
+ ...(supply.buyerGstin !== undefined ? { buyerGstin: supply.buyerGstin } : {}),
+ lines: computed.lines, totals, paymentLabel: mode,
+ }
+ // Indic receipts rasterize (09-UX §5.3); ASCII stays the fast text path.
+ // Same content model (receiptLines) so item names match on either path.
+ const send = cfg.script === 'raster'
+ ? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick)
+ : sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick }))
+ return send
+ .then(() => say('ok', `Bill ${res.docNo} · ${formatINR(totals.payablePaise)} — printed`))
+ .catch((err: unknown) =>
+ say('warn', `Bill ${res.docNo} saved — print: ${err instanceof Error ? err.message : String(err)} (E-2101)`))
+ })
+ .catch((err: unknown) => {
+ // Commit failed: the cart stays; nothing is lost.
+ say('err', `${err instanceof Error ? err.message : String(err)} — bill NOT saved, cart kept`)
+ })
+ .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
+ }
+ 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
+ if (overlayRef.current === 'settings' || overlayRef.current === 'customer') 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 '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
+
+
+
+
+ {
+ 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) {
+ 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) }}
+ />
+ ) : 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'}
+ 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 === '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 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
+ 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)
+ .then((r) => props.onApprove({ approvedByUserId: r.userId, 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} }
+
+ )
+}
diff --git a/apps/pos/src/Login.tsx b/apps/pos/src/Login.tsx
new file mode 100644
index 0000000..d29b5ae
--- /dev/null
+++ b/apps/pos/src/Login.tsx
@@ -0,0 +1,84 @@
+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
new file mode 100644
index 0000000..526e3f0
--- /dev/null
+++ b/apps/pos/src/Settings.tsx
@@ -0,0 +1,106 @@
+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
new file mode 100644
index 0000000..03d1f18
--- /dev/null
+++ b/apps/pos/src/ShiftOpen.tsx
@@ -0,0 +1,41 @@
+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
new file mode 100644
index 0000000..788e520
--- /dev/null
+++ b/apps/pos/src/api.ts
@@ -0,0 +1,88 @@
+import type { 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[] }
+
+let 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)')
+ 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 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 }),
+ })
+ token = s.token
+}
+
+/**
+ * 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.
+ */
+export const verifySupervisorPin = (userId: string, pin: string): Promise<{ ok: true; userId: string; displayName: string }> =>
+ call('/auth/verify-pin', { method: 'POST', body: JSON.stringify({ userId, pin }) })
+
+// 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). */
+export interface BillOverride {
+ itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: 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) })
+
+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 } : {}) }),
+ })
diff --git a/apps/pos/src/audio.ts b/apps/pos/src/audio.ts
new file mode 100644
index 0000000..db5a5f7
--- /dev/null
+++ b/apps/pos/src/audio.ts
@@ -0,0 +1,28 @@
+/**
+ * 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
new file mode 100644
index 0000000..8668edb
--- /dev/null
+++ b/apps/pos/src/main.tsx
@@ -0,0 +1,10 @@
+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( )
diff --git a/apps/pos/src/pos.css b/apps/pos/src/pos.css
new file mode 100644
index 0000000..64edf3e
--- /dev/null
+++ b/apps/pos/src/pos.css
@@ -0,0 +1,74 @@
+/* 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
new file mode 100644
index 0000000..5eb588b
--- /dev/null
+++ b/apps/pos/src/print.ts
@@ -0,0 +1,59 @@
+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
new file mode 100644
index 0000000..926b79d
--- /dev/null
+++ b/apps/pos/src/raster.ts
@@ -0,0 +1,63 @@
+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
new file mode 100644
index 0000000..ec3fe53
--- /dev/null
+++ b/apps/pos/src/types.d.ts
@@ -0,0 +1,9 @@
+/** 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/tsconfig.json b/apps/pos/tsconfig.json
new file mode 100644
index 0000000..52e1679
--- /dev/null
+++ b/apps/pos/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*", "electron/**/*"]
+}
diff --git a/apps/pos/vite.config.ts b/apps/pos/vite.config.ts
new file mode 100644
index 0000000..e198c7f
--- /dev/null
+++ b/apps/pos/vite.config.ts
@@ -0,0 +1,26 @@
+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/README.md b/apps/store-server/README.md
new file mode 100644
index 0000000..0116e0e
--- /dev/null
+++ b/apps/store-server/README.md
@@ -0,0 +1,6 @@
+# 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
new file mode 100644
index 0000000..d523980
--- /dev/null
+++ b/apps/store-server/build-server.mjs
@@ -0,0 +1,11 @@
+import { build } from 'esbuild'
+
+await build({
+ entryPoints: ['src/server.ts'],
+ outfile: 'dist/server.cjs',
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ external: ['better-sqlite3'],
+})
+console.log('store-server built')
diff --git a/apps/store-server/package.json b/apps/store-server/package.json
new file mode 100644
index 0000000..250abcb
--- /dev/null
+++ b/apps/store-server/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@sims/store-server",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "node build-server.mjs",
+ "start": "npm run build && node dist/server.cjs",
+ "typecheck": "tsc -p tsconfig.json"
+ },
+ "dependencies": {
+ "@sims/auth": "*",
+ "@sims/billing-engine": "*",
+ "@sims/domain": "*",
+ "better-sqlite3": "^11.7.0",
+ "express": "^4.21.0"
+ },
+ "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
new file mode 100644
index 0000000..a08a77c
--- /dev/null
+++ b/apps/store-server/src/api.ts
@@ -0,0 +1,226 @@
+import { Router, type Request, type Response, type NextFunction } from 'express'
+import { randomUUID } from 'node:crypto'
+import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, type LockoutState } from '@sims/auth'
+import type { LineInput } from '@sims/billing-engine'
+import type { DB } from './db'
+import {
+ commitBill, createCustomer, createItem, listAudit, listBills, listItems,
+ listParties, listTaxClasses, stockView, writeAudit,
+} from './repos'
+import { commitPurchase, costHistory, lastCosts, listPurchases, type CommitPurchaseInput } from './repos-purchase'
+
+interface Session {
+ token: string; userId: string; tenantId: string; displayName: string; roles: string[]
+}
+
+const sessions = new Map()
+const lockouts = new Map()
+
+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
+}
+
+function requireAuth(req: Request, res: Response, next: NextFunction): void {
+ const token = (req.headers.authorization ?? '').replace('Bearer ', '')
+ const session = sessions.get(token)
+ if (session === undefined) {
+ res.status(401).json({ error: 'Not signed in (E-6103)' })
+ return
+ }
+ ;(req as Request & { session: Session }).session = session
+ next()
+}
+
+const sess = (req: Request): Session => (req as Request & { session: Session }).session
+
+export function apiRouter(db: DB): Router {
+ const r = Router()
+
+ const startSession = (u: UserRow): Session => {
+ const s: Session = {
+ token: randomUUID(), userId: u.id, tenantId: u.tenant_id,
+ displayName: u.display_name, roles: JSON.parse(u.roles) as string[],
+ }
+ 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', (req, res) => {
+ const { userId, pin } = req.body as { userId?: string; pin?: string }
+ const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined
+ const out = attempt(u, pin ?? '', u?.pin_salt ?? null, u?.pin_hash ?? null)
+ 'token' in out ? res.json(out) : res.status(out.status).json({ error: out.error })
+ })
+
+ // Back office: username + password
+ r.post('/auth/login', (req, res) => {
+ const { username, password } = req.body as { username?: string; password?: string }
+ const u = db.prepare(`SELECT * FROM app_user WHERE username=?`).get((username ?? '').toLowerCase()) as UserRow | undefined
+ const out = attempt(u, password ?? '', u?.pw_salt ?? null, u?.pw_hash ?? null)
+ 'token' in out ? res.json(out) : res.status(out.status).json({ error: out.error })
+ })
+
+ // In-flow supervisor gate (spec P0-2): verify a PIN WITHOUT creating a session —
+ // the cashier's login must never be disturbed and `sessions` must not grow. Shares
+ // the same lockout + scrypt verification as attempt(); audits PIN_VERIFY on success
+ // and a LOGIN_FAILED-style PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor /
+ // manager / owner roles may approve an override (09-UX §5.5) — a valid-PIN cashier
+ // is rejected loudly, never granted.
+ const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
+ r.post('/auth/verify-pin', (req, res) => {
+ const { userId, pin } = req.body as { userId?: string; pin?: string }
+ 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)' })
+ return
+ }
+ writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY', 'app_user', u.id)
+ res.json({ ok: true, userId: u.id, displayName: u.display_name })
+ })
+
+ // POS bootstrap: store/counter context, login tiles, tax classes
+ r.get('/bootstrap', (req, res) => {
+ 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'], gstin: tenant['gstin'],
+ counterId: counter?.['id'] ?? 'c2', counterCode,
+ },
+ users: users.map((u) => ({ id: u['id'], name: u['display_name'], role: (JSON.parse(u['roles'] as string) as string[])[0] })),
+ taxClasses: listTaxClasses(db),
+ })
+ })
+
+ r.use(requireAuth)
+
+ r.get('/items', (req, res) => {
+ res.json(listItems(db, sess(req).tenantId, {
+ ...(req.query['q'] !== undefined ? { query: String(req.query['q']) } : {}),
+ all: req.query['all'] === '1',
+ ...(req.query['limit'] !== undefined ? { limit: Number(req.query['limit']) } : {}),
+ ...(req.query['offset'] !== undefined ? { offset: Number(req.query['offset']) } : {}),
+ }))
+ })
+ r.post('/items', (req, res) => {
+ const item = createItem(db, sess(req).tenantId, req.body as Parameters[2])
+ writeAudit(db, sess(req).tenantId, sess(req).userId, 'ITEM_CREATE', 'item', item.id, undefined, item)
+ res.json(item)
+ })
+
+ r.get('/parties', (req, res) => {
+ res.json(listParties(db, sess(req).tenantId, req.query['kind'] as string | undefined, req.query['phone'] as string | undefined))
+ })
+ r.post('/parties', (req, res) => {
+ const { name, phone, gstin, stateCode } = req.body as { name: string; phone: string; gstin?: string; stateCode?: string }
+ try {
+ const p = createCustomer(db, sess(req).tenantId, name, phone, {
+ ...(gstin !== undefined ? { gstin } : {}),
+ ...(stateCode !== undefined ? { stateCode } : {}),
+ })
+ writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p)
+ res.json(p)
+ } catch (err) {
+ const status = (err as { status?: number }).status ?? 500
+ res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+
+ r.post('/bills', (req, res) => {
+ const body = req.body as {
+ storeId: string; counterId: string; shiftId: string; customerId?: string
+ businessDate: string; lines: LineInput[]
+ payments: { mode: string; amountPaise: number; reference?: string }[]
+ clientPayablePaise: number
+ overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[]
+ }
+ try {
+ const out = commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId })
+ res.json(out)
+ } catch (err) {
+ const status = (err as { status?: number }).status ?? 500
+ res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.get('/bills', (req, res) => {
+ res.json(listBills(
+ db, sess(req).tenantId,
+ req.query['date'] as string | undefined, req.query['counterId'] as string | undefined,
+ req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined,
+ req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined,
+ ))
+ })
+
+ r.get('/stock', (req, res) => {
+ const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
+ res.json(stockView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
+ })
+
+ r.get('/audit', (req, res) => {
+ res.json(listAudit(
+ db, sess(req).tenantId,
+ req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined,
+ req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined,
+ ))
+ })
+
+ r.get('/purchases', (req, res) => {
+ res.json(listPurchases(db, sess(req).tenantId))
+ })
+ r.get('/purchases/last-costs', (req, res) => {
+ res.json(lastCosts(db, sess(req).tenantId, req.query['supplierId'] as string | undefined))
+ })
+ r.get('/purchases/cost-history', (req, res) => {
+ res.json(costHistory(db, sess(req).tenantId, String(req.query['itemId'] ?? ''), req.query['supplierId'] as string | undefined))
+ })
+ r.post('/purchases', (req, res) => {
+ const body = req.body as Omit
+ try {
+ res.json(commitPurchase(db, { ...body, tenantId: sess(req).tenantId, userId: sess(req).userId }))
+ } catch (err) {
+ const status = (err as { status?: number }).status ?? 500
+ res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+
+ return r
+}
diff --git a/apps/store-server/src/db.ts b/apps/store-server/src/db.ts
new file mode 100644
index 0000000..b18634d
--- /dev/null
+++ b/apps/store-server/src/db.ts
@@ -0,0 +1,141 @@
+import Database from 'better-sqlite3'
+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,
+ 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, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL
+);
+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
+);
+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
+);
+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,
+ 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);
+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`)
+}
+
+export function openDb(): DB {
+ const dir = path.resolve(__dirname, '../data')
+ fs.mkdirSync(dir, { recursive: true })
+ const db = new Database(path.join(dir, 'sims.db'))
+ db.pragma('journal_mode = WAL')
+ db.pragma('foreign_keys = ON')
+ db.exec(SCHEMA)
+ migrate(db)
+ return db
+}
diff --git a/apps/store-server/src/repos-purchase.ts b/apps/store-server/src/repos-purchase.ts
new file mode 100644
index 0000000..aaff0b2
--- /dev/null
+++ b/apps/store-server/src/repos-purchase.ts
@@ -0,0 +1,142 @@
+import { uuidv7 } from '@sims/domain'
+import type { DB } from './db'
+import { 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
+}
+
+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 })
+ }
+
+ 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)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ const move = db.prepare(
+ `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall)
+ VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?)`,
+ )
+ input.lines.forEach((l, i) => {
+ const m = math[i]!
+ insLine.run(id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total, l.newSalePricePaise ?? null, l.newMrpPaise ?? null)
+ move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, input.businessDate, now)
+ if (l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined) {
+ 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}`,
+ })
+ }
+ })
+ writeAudit(db, input.tenantId, input.userId, 'PURCHASE_COMMIT', 'purchase', id, undefined, {
+ invoiceNo: input.invoiceNo, supplier: input.supplierId, total,
+ })
+ })()
+
+ 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.ts b/apps/store-server/src/repos.ts
new file mode 100644
index 0000000..a98b49a
--- /dev/null
+++ b/apps/store-server/src/repos.ts
@@ -0,0 +1,305 @@
+import {
+ deriveSupply, formatDocNo, fyOf, fyShort, seriesPrefix, uuidv7, validateGstin,
+ type Item, type Party,
+} from '@sims/domain'
+import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
+import type { DB } from './db'
+
+/**
+ * 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).
+ */
+ overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[]
+}
+
+const OVERRIDE_ACTION = {
+ price: 'PRICE_OVERRIDE', qty: 'QTY_OVERRIDE', discount: 'DISCOUNT_OVERRIDE',
+} as const
+
+export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: string; payablePaise: number } {
+ const store = db.prepare(`SELECT * FROM store WHERE id=?`).get(input.storeId) as { code: string; state_code: string }
+ const counter = db.prepare(`SELECT * FROM counter WHERE id=?`).get(input.counterId) as { code: string }
+ const rates = listTaxClasses(db)
+
+ // 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,
+ )
+
+ // Same engine everywhere: the server recomputes and must agree to the paisa.
+ const computed = computeBill(input.lines, {
+ businessDate: input.businessDate,
+ supplyStateCode: store.state_code,
+ placeOfSupplyStateCode: supply.placeOfSupplyStateCode,
+ roundToRupee: true,
+ }, rates)
+ if (computed.totals.payablePaise !== input.clientPayablePaise) {
+ throw Object.assign(new Error('Engine mismatch: client and server computed different totals'), { status: 409 })
+ }
+ const paid = input.payments.reduce((a, p) => a + p.amountPaise, 0)
+ if (paid !== computed.totals.payablePaise) {
+ throw Object.assign(new Error('Payments do not sum to payable'), { status: 400 })
+ }
+
+ const id = uuidv7()
+ const now = new Date().toISOString()
+ const fy = fyOf(input.businessDate)
+
+ const txn = db.transaction(() => {
+ // per-counter, per-FY series allocated inside the commit transaction
+ let series = db.prepare(
+ `SELECT * FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`,
+ ).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined
+ if (series === undefined) {
+ series = { prefix: seriesPrefix(store.code, counter.code, fyShort(fy)), next_seq: 1 }
+ 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, 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,
+ payload, now,
+ )
+ const move = db.prepare(
+ `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall)
+ VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?)`,
+ )
+ for (const line of computed.lines) {
+ move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, 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 })
+ // One audit row per approved override, in-transaction (R17). The approver's id
+ // is resolved to a display name so the audit trail is human-readable.
+ for (const o of input.overrides ?? []) {
+ const approvedBy = o.approvedByUserId !== undefined
+ ? (db.prepare(`SELECT display_name FROM app_user WHERE id=?`).get(o.approvedByUserId) as { display_name: string } | undefined)?.display_name
+ ?? o.approvedByUserId
+ : undefined
+ writeAudit(
+ db, input.tenantId, input.cashierId, OVERRIDE_ACTION[o.kind], 'bill', id,
+ { itemId: o.itemId, before: o.before },
+ { after: o.after, approvedBy, approvedByUserId: o.approvedByUserId },
+ )
+ }
+ 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,
+): Record[] {
+ 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=?`
+ 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) }
+ 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,
+ COALESCE(SUM(m.qty_delta), 0) AS on_hand
+ FROM item i LEFT JOIN stock_movement m ON m.item_id=i.id AND m.store_id=?
+ WHERE i.tenant_id=? GROUP BY i.id ORDER BY i.name`,
+ ).all(storeId, tenantId) as Record[]
+}
+
+// ---------- audit ----------
+export function writeAudit(
+ db: DB, tenantId: string, userId: string, action: string,
+ entity: string, entityId: string, before?: unknown, after?: unknown,
+): void {
+ db.prepare(
+ `INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ).run(
+ uuidv7(), tenantId, new Date().toISOString(), userId, action, entity, entityId,
+ before === undefined ? null : JSON.stringify(before),
+ after === undefined ? null : JSON.stringify(after),
+ )
+}
+
+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
new file mode 100644
index 0000000..5ce391f
--- /dev/null
+++ b/apps/store-server/src/seed.ts
@@ -0,0 +1,85 @@
+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')
+
+ 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()
+}
diff --git a/apps/store-server/src/server.ts b/apps/store-server/src/server.ts
new file mode 100644
index 0000000..9be15c4
--- /dev/null
+++ b/apps/store-server/src/server.ts
@@ -0,0 +1,63 @@
+import express from 'express'
+import net from 'node:net'
+import path from 'node:path'
+import { openDb } from './db'
+import { 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 network printer (browsers can't open sockets)
+ * /api/health → monitoring handshake
+ * The Oracle 12c repository layer (D12) and the outbox sync agent (D14) land here next.
+ */
+const app = express()
+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()
+seedIfEmpty(db)
+app.use('/api', apiRouter(db))
+
+app.post('/api/print', (req, res) => {
+ const { host, port, bytes } = req.body as { host?: string; port?: number; bytes?: number[] }
+ if (typeof host !== 'string' || typeof port !== 'number' || !Array.isArray(bytes)) {
+ res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }' })
+ return
+ }
+ // 'close' always follows 'error'/'timeout' on net sockets — respond exactly once.
+ let done = false
+ const finish = (status: number, body: object) => {
+ if (done) return
+ done = true
+ res.status(status).json(body)
+ }
+ const socket = net.createConnection({ host, port, timeout: 3000 })
+ socket.on('connect', () => socket.end(Buffer.from(bytes)))
+ socket.on('close', () => finish(200, { ok: true }))
+ socket.on('timeout', () => {
+ socket.destroy()
+ finish(504, { ok: false, error: `Printer ${host}:${port} timed out` })
+ })
+ socket.on('error', (err) => finish(502, { ok: false, error: err.message }))
+})
+
+// 8080–8085 sit in Windows' Hyper-V excluded port ranges on shop PCs too — avoid them.
+const PORT = Number(process.env['PORT'] ?? 5181)
+app.listen(PORT, () => {
+ console.log(`SiMS store-server listening on http://localhost:${PORT}`)
+ console.log(` Back office: http://localhost:${PORT}/`)
+ console.log(` POS: http://localhost:${PORT}/pos/`)
+})
diff --git a/apps/store-server/tsconfig.json b/apps/store-server/tsconfig.json
new file mode 100644
index 0000000..bf5a36d
--- /dev/null
+++ b/apps/store-server/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*"]
+}
diff --git a/docs/superpowers/plans/2026-07-10-hq2-reminders.md b/docs/superpowers/plans/2026-07-10-hq2-reminders.md
new file mode 100644
index 0000000..a793f3f
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-hq2-reminders.md
@@ -0,0 +1,968 @@
+# HQ-2 — Reminders, Recurring, AMC, Interactions & Dashboard Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task is a full TDD loop: write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → commit. No task depends on code a later task writes.
+
+**Goal:** Ship HQ-2 of [docs/14-SPEC-HQ-CONSOLE.md](../../14-SPEC-HQ-CONSOLE.md) on top of the HQ-1 `apps/hq` + `apps/hq-web` codebase: recurring plans that generate invoices on schedule, a reminder engine (auto-send + manual queue, idempotent across crashes/catch-up, bounce-aware), AMC contracts with derived paid-state, an interaction/visit/call/training log with follow-ups, and a dashboard money-view that becomes the default page after login. All grounded in the real HQ-1 interfaces (`createDraft`/`issueDocument`, `sendDocumentEmail`, `getAccount`/`TokenDeadError`, `priceOn`, `writeAudit`, `formatINR`).
+
+**Architecture:** Extend the store-server-shaped HQ pattern exactly as HQ-1 established it — express + better-sqlite3 behind plain-function repositories (`repos-*.ts`), pure logic reused from `@sims/domain` (`formatINR`, `fromRupees`, `uuidv7`, `fyOf`) and the HQ document engine. **New tables are added to the `SCHEMA` string in `db.ts` via `CREATE TABLE IF NOT EXISTS`** (no ALTER needed — the table simply appears on next `openDb`); **the one new column** (`email_log.bounced`) is added through the existing guarded `migrate(db)` PRAGMA/ALTER path. The scheduler is a **pure function** `runDailyScan(db, deps, today)` with an injected clock (never `Date.now()` inside logic) so tests are deterministic; it is wired into `server.ts` to run on boot and every 6h. Idempotency lives entirely in one UNIQUE constraint — `reminder(rule_kind, subject_id, due_period)` — reached via `INSERT OR IGNORE`, which is what makes catch-up after downtime safe with zero extra bookkeeping.
+
+**Tech Stack:** unchanged from HQ-1 — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (PDF, injected into scheduler/reminder deps so tests never launch Chromium), vitest, React 19 + Vite 6 + react-router 7, `@sims/ui`. No new dependencies (bounce polling is `fetch`-based, same as `gmail.ts`).
+
+## Global Constraints
+
+Everything from the HQ-1 plan still holds; the load-bearing ones for HQ-2, plus the extensions:
+
+- All money is **integer paise** (`Paise` from `@sims/domain`); never floats. Amounts render with `formatINR`.
+- All ids are **UUIDv7** via `uuidv7()` from `@sims/domain` (ids sort by creation time — reused for `ORDER BY id`).
+- **Every mutation writes an `audit_log` row** via `writeAudit(db, userId, action, entity, entityId, before?, after?)`. Scheduler-originated mutations use `userId = 'system'`.
+- SQL stays **portable** (D12 guardrail): standard SQL; SQLite/Postgres-only dialect (`INSERT OR IGNORE`, `ON CONFLICT`) is commented as a portability quirk, matching `series.ts`/`seed.ts`.
+- **New tables → `SCHEMA` const in `db.ts` with `CREATE TABLE IF NOT EXISTS`.** New tables need no migration entry. **New columns → guarded `migrate(db)`** (PRAGMA `table_info` check then `ALTER TABLE … ADD COLUMN`), exactly like the existing `module.quote_content` block.
+- HQ prices are **GST-exclusive** (`priceIncludesTax: false`); recurring/AMC invoices flow through the same `createDraft` → `computeBill` path, so GST (CGST/SGST vs IGST) is computed identically to HQ-1.
+- Issued invoices are **never edited or deleted**. A recurring invoice, once generated and issued, exists permanently; a failed *send* never deletes or regenerates it.
+- **Scheduler determinism:** `runDailyScan(db, deps, today: string)` takes the business date as an ISO `YYYY-MM-DD` param. Wall-clock timestamps (`created_at`, `sent_at`) come from `deps.now?.() ?? new Date().toISOString()` so tests pin them.
+- **Idempotency / at-most-once:** the reminder row is created once per `(rule_kind, subject_id, due_period)` via `INSERT OR IGNORE`. Recurring generation, `next_run` advance, and the reminder insert are one better-sqlite3 **transaction** (atomic); the async email send happens *after* commit and only ever transitions an already-committed reminder from `queued`→`sent`/`failed`. A crash therefore never double-generates and never auto-double-sends.
+- **Failed send never advances the schedule:** for recurring, `next_run` advances on *generation* (the invoice existing is the spec's condition), not on send — a failed send parks the reminder in the manual queue with its `error` visible; the invoice still exists and is re-sent from the queue, never regenerated (the UNIQUE key forbids regeneration).
+- **"sent" ≠ "delivered":** `email_log.status='sent'` means Gmail accepted the message. Delivery failures surface later via `pollBounces` → `email_log.bounced=1` + an `email_bounced` dashboard reminder.
+- Server port **5182**. Secrets: `HQ_SECRET_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (already used by HQ-1). Never log tokens.
+- Tests: vitest, colocated under `apps/hq/test/` (already in the vitest `include`). Puppeteer is injected as `deps.renderPdf`, and `fetch` as `deps.gmail.f`, so every HQ-2 test runs offline and Chromium-free.
+- **Per-app typecheck is part of the root gate:** `npm run typecheck` runs `tsc -p tsconfig.json && npm run typecheck --workspaces --if-present`. Both `apps/hq` and `apps/hq-web` must typecheck clean at every commit.
+- Commit after every task. Each commit message ends with the trailer — use the two-`-m` form:
+ ```bash
+ git commit -m "feat(hq): " -m "Co-Authored-By: Claude Fable 5 "
+ ```
+
+## Scope (locked — do not reopen)
+
+**In:** recurring_plan (generation on `next_run`), reminder engine (auto + manual queue, idempotent, bounce-aware), amc_contract (+ renewal invoice, derived paid), interaction + interaction_type (+ follow-ups), dashboard money-view (default route) with the manual reminder queue, bounce detection (Gmail `readonly` scope + `pollBounces`), and the routes/pages for all of it.
+
+**Out (do not plan):** SMS pack-balance tracking (founder decision pending); AWS cost pull + cross-client chart (HQ-3); receipt PDFs. Per-rule auto/manual toggles for the *dunning* rules (overdue/renewal/amc) are intentionally not built — the only **auto** path is `recurring_plan.policy='auto'`; dunning/renewal/amc/follow-up reminders always land in the **manual queue** (this satisfies the founder's "both modes exist": auto = recurring, manual = everything else, and recurring plans carry their own `auto`/`manual` policy). State this in the recurring task, don't re-litigate it.
+
+## File Structure (HQ-2 additions)
+
+```
+apps/hq/
+ src/db.ts — MODIFY: 5 new CREATE TABLE IF NOT EXISTS + email_log.bounced in migrate()
+ src/seed.ts — MODIFY: seed interaction_type rows, AMC module, reminder.* settings
+ src/repos-recurring.ts — NEW: recurring_plan CRUD
+ src/repos-amc.ts — NEW: amc_contract CRUD + renewal invoice + derived paid
+ src/repos-interactions.ts — NEW: interaction_type + interaction CRUD + follow-ups
+ src/reminder-templates.ts — NEW: subject+body text per rule_kind (formatINR, Indian business English)
+ src/repos-reminders.ts — NEW: reminder upsert/list/dismiss + sendReminder orchestration + settings helpers
+ src/gmail.ts — MODIFY: add sendReminderEmail (attachment-optional, no document coupling)
+ src/scheduler.ts — NEW: runDailyScan (pure), startScheduler (setInterval, unref)
+ src/bounces.ts — NEW: pollBounces + markBounce (fetch-based, injectable Fetcher)
+ src/repos-payments.ts — MODIFY: export outstandingPaise(db, docId)
+ src/repos-dashboard.ts — NEW: dashboardView aggregation
+ src/api.ts — MODIFY: recurring / amc / interactions / reminders / dashboard routes
+ src/server.ts — MODIFY: startServer(port, { scheduler }) wiring
+ scripts/gmail-connect.ts — MODIFY: SCOPE gains gmail.readonly (for bounce polling)
+ test/*.test.ts — NEW: one file per task
+apps/hq-web/
+ src/api.ts — MODIFY: types + typed calls for recurring/amc/interactions/reminders/dashboard
+ src/Layout.tsx — MODIFY: nav gains Dashboard (default) + Clients
+ src/main.tsx — MODIFY: '/' → Dashboard, '/clients' → Clients
+ src/pages/Dashboard.tsx — NEW: money view + manual reminder queue
+ src/pages/ClientDetail.tsx — MODIFY: AMC, Interactions, Recurring sections
+```
+
+---
+
+### Task 1: HQ-2 schema — new tables, `email_log.bounced`, seeds
+
+**Files:**
+- Modify: `apps/hq/src/db.ts` (append 5 tables to `SCHEMA`; add the `bounced` column to `migrate`), `apps/hq/src/seed.ts` (seed lookups + settings + AMC module)
+- Test: `apps/hq/test/hq2-schema.test.ts`
+
+**Interfaces:**
+- Produces (schema only — no new TS exports): tables `recurring_plan`, `amc_contract`, `interaction`, `interaction_type`, `reminder`; column `email_log.bounced INTEGER NOT NULL DEFAULT 0`. `seedIfEmpty` additionally seeds the 7 `interaction_type` rows, one `module` row `code='AMC'`, and settings `reminders.overdue_days='7'`, `reminders.renewal_days='15'`.
+- Note on `reminder.doc_id`: the locked column list is extended with **one** nullable column, `doc_id TEXT`, carrying the document a reminder concerns (the overdue invoice, the generated recurring invoice, an AMC renewal invoice). It is required so the manual-queue **Send** action and the recurring auto-send can attach/reference the right PDF; `subject_id` stays the logical subject (plan/module/contract/interaction/invoice). For `invoice_overdue`, `subject_id == doc_id`.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// apps/hq/test/hq2-schema.test.ts
+import { describe, it, expect } from 'vitest'
+import { openDb } from '../src/db'
+import { seedIfEmpty } from '../src/seed'
+
+describe('hq2 schema', () => {
+ it('creates every HQ-2 table', () => {
+ const db = openDb(':memory:')
+ const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map((r) => r.name)
+ for (const t of ['recurring_plan', 'amc_contract', 'interaction', 'interaction_type', 'reminder'])
+ expect(names, `missing table ${t}`).toContain(t)
+ })
+ it('adds the bounced column to email_log', () => {
+ const db = openDb(':memory:')
+ const cols = (db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]).map((c) => c.name)
+ expect(cols).toContain('bounced')
+ })
+ it('enforces the reminder idempotency key', () => {
+ const db = openDb(':memory:')
+ const ins = db.prepare(
+ `INSERT OR IGNORE INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
+ VALUES (?, 'invoice_overdue', 's1', '2026-07', 'c1', 'queued', 'manual', '2026-07-10T00:00:00Z')`,
+ )
+ expect(ins.run('r1').changes).toBe(1)
+ expect(ins.run('r2').changes).toBe(0) // same (rule_kind, subject_id, due_period) → ignored
+ })
+ it('seeds interaction types, the AMC module and reminder settings', () => {
+ const db = openDb(':memory:'); seedIfEmpty(db)
+ const types = (db.prepare(`SELECT code FROM interaction_type`).all() as { code: string }[]).map((r) => r.code)
+ expect(types).toEqual(expect.arrayContaining(['call', 'site_visit', 'training', 'complaint']))
+ expect(db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get()).toMatchObject({ n: 1 })
+ const s = (db.prepare(`SELECT value FROM setting WHERE key='reminders.overdue_days'`).get() as { value: string } | undefined)
+ expect(s?.value).toBe('7')
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/hq2-schema.test.ts` → FAIL (tables/column/seeds absent).
+
+- [ ] **Step 3: Implement.** Append to the `SCHEMA` template string in `apps/hq/src/db.ts` (before the closing backtick):
+
+```sql
+CREATE TABLE IF NOT EXISTS recurring_plan (
+ id TEXT PRIMARY KEY, client_id TEXT NOT NULL,
+ client_module_id TEXT, -- source of the invoice line's module; required for generation in HQ-2
+ cadence TEXT NOT NULL CHECK (cadence IN ('monthly','yearly')),
+ amount_paise INTEGER, -- NULL = price from module_price_book at generation time
+ next_run TEXT NOT NULL,
+ policy TEXT NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')),
+ active INTEGER NOT NULL DEFAULT 1
+);
+CREATE TABLE IF NOT EXISTS amc_contract (
+ id TEXT PRIMARY KEY, client_id TEXT NOT NULL, coverage TEXT NOT NULL DEFAULT '',
+ period_from TEXT NOT NULL, period_to TEXT NOT NULL, amount_paise INTEGER NOT NULL,
+ renewal_reminder_days INTEGER NOT NULL DEFAULT 30,
+ legacy_paid INTEGER, -- manual flag for imported contracts only; else paid derives from invoice_doc_id
+ invoice_doc_id TEXT, active INTEGER NOT NULL DEFAULT 1
+);
+CREATE TABLE IF NOT EXISTS interaction_type (
+ code TEXT PRIMARY KEY, label TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS interaction (
+ id TEXT PRIMARY KEY, client_id TEXT NOT NULL, type_code TEXT NOT NULL,
+ on_date TEXT NOT NULL, staff_id TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '',
+ outcome TEXT CHECK (outcome IN ('positive','neutral','negative')),
+ follow_up_on TEXT, created_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS reminder (
+ id TEXT PRIMARY KEY,
+ rule_kind TEXT NOT NULL CHECK (rule_kind IN
+ ('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced')),
+ subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
+ doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice); NULL otherwise
+ status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
+ policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
+ error TEXT, created_at TEXT NOT NULL, sent_at TEXT,
+ UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period
+);
+```
+
+In `migrate(db)` add the guarded column (same shape as the existing `quote_content` block):
+
+```ts
+ const emailCols = db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]
+ if (!emailCols.some((c) => c.name === 'bounced')) {
+ db.exec(`ALTER TABLE email_log ADD COLUMN bounced INTEGER NOT NULL DEFAULT 0`)
+ }
+```
+
+In `apps/hq/src/seed.ts` — import `createModule` and extend `seedIfEmpty` (after the existing GST18 block):
+
+```ts
+import { createModule } from './repos-modules'
+// ...
+const INTERACTION_TYPES: [string, string][] = [
+ ['call', 'Call'], ['site_visit', 'Site visit'], ['training', 'Training'],
+ ['courtesy_meeting', 'Courtesy meeting'], ['committee_meeting', 'Committee meeting'],
+ ['demo', 'Demo'], ['complaint', 'Complaint'],
+]
+const REMINDER_SETTINGS: Record = {
+ 'reminders.overdue_days': '7',
+ 'reminders.renewal_days': '15',
+}
+```
+
+and inside `seedIfEmpty`, after the tax-class seed:
+
+```ts
+ const typeInsert = db.prepare(
+ `INSERT INTO interaction_type (code, label) VALUES (?, ?) ON CONFLICT (code) DO NOTHING`,
+ )
+ let seededTypes = 0
+ for (const [code, label] of INTERACTION_TYPES) seededTypes += typeInsert.run(code, label).changes
+ if (seededTypes > 0) writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes })
+
+ let seededReminderSettings = 0
+ for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes
+ if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS)
+
+ const amc = db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get() as { n: number }
+ if (amc.n === 0) {
+ // The AMC/renewal invoice line hangs off a real module so it flows through
+ // createDraft → computeBill unchanged. SAC 998719 (maintenance/repair) — CA to confirm.
+ createModule(db, 'system', {
+ code: 'AMC', name: 'Annual Maintenance Contract', sac: '998719',
+ allowedKinds: ['yearly', 'one_time'],
+ })
+ }
+```
+
+(`insert` is the existing `INSERT … ON CONFLICT (key) DO NOTHING` setting statement already defined in `seedIfEmpty`.)
+
+- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/hq2-schema.test.ts` → PASS. `npm run typecheck` → clean.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/hq/src/db.ts apps/hq/src/seed.ts apps/hq/test/hq2-schema.test.ts
+git commit -m "feat(hq): hq-2 schema — recurring/amc/interaction/reminder tables + bounce column + seeds" -m "Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 2: Recurring plans — repo + owner-gated routes
+
+**Files:**
+- Create: `apps/hq/src/repos-recurring.ts`
+- Modify: `apps/hq/src/api.ts`
+- Test: `apps/hq/test/recurring.test.ts`
+
+**Interfaces:**
+- Produces:
+ - `RecurringPlan = { id: string; clientId: string; clientModuleId: string | null; cadence: 'monthly'|'yearly'; amountPaise: number | null; nextRun: string; policy: 'auto'|'manual'; active: boolean }`
+ - `createRecurringPlan(db, userId, input: { clientId: string; clientModuleId: string; cadence: 'monthly'|'yearly'; amountPaise?: number; nextRun: string; policy?: 'auto'|'manual' }): RecurringPlan` — validates the client and client_module exist and belong together; validates that a price is resolvable (either `amountPaise` given, or the client_module's module has a `priceOn` for the cadence's kind on `nextRun`). **`clientModuleId` is required in HQ-2** (it is the module for the invoice line; the schema keeps the column nullable for future client-level plans).
+ - `getRecurringPlan(db, id): RecurringPlan | null`; `listRecurringPlans(db, clientId?): RecurringPlan[]`.
+ - `updateRecurringPlan(db, userId, id, patch: { cadence?; amountPaise?: number | null; nextRun?; policy?; active? }): RecurringPlan`.
+ - `deactivateRecurringPlan(db, userId, id): RecurringPlan` (sets `active=0`).
+ - `CADENCE_KIND: Record<'monthly'|'yearly', Kind> = { monthly: 'monthly', yearly: 'yearly' }` (exported — reused by the scheduler).
+- Routes (create/update/deactivate are **owner-gated** — money): `GET /api/recurring?clientId=`, `POST /api/clients/:id/recurring` (owner), `PATCH /api/recurring/:id` (owner), `POST /api/recurring/:id/deactivate` (owner).
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// apps/hq/test/recurring.test.ts
+import { describe, it, expect } from 'vitest'
+import { openDb } from '../src/db'
+import { createClient } from '../src/repos-clients'
+import { assignModule, createModule, setPrice } from '../src/repos-modules'
+import {
+ createRecurringPlan, listRecurringPlans, updateRecurringPlan, deactivateRecurringPlan,
+} from '../src/repos-recurring'
+
+function setup() {
+ const db = openDb(':memory:')
+ const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
+ const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud Hosting', allowedKinds: ['monthly', 'yearly'] })
+ setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-04-01' })
+ const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
+ return { db, c, m, cm }
+}
+
+describe('recurring plans', () => {
+ it('creates a plan, lists it, updates and deactivates with audit', () => {
+ const { db, c, cm } = setup()
+ const p = createRecurringPlan(db, 'u1', {
+ clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', policy: 'auto',
+ })
+ expect(p.policy).toBe('auto')
+ expect(p.amountPaise).toBeNull() // price resolves from the module at generation
+ expect(listRecurringPlans(db, c.id)).toHaveLength(1)
+ const up = updateRecurringPlan(db, 'u1', p.id, { amountPaise: 2_500_00 })
+ expect(up.amountPaise).toBe(2_500_00)
+ const off = deactivateRecurringPlan(db, 'u1', p.id)
+ expect(off.active).toBe(false)
+ const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='recurring_plan'`).all()
+ expect(audits.length).toBe(3) // create + update + deactivate
+ })
+ it('rejects a plan whose module has no price and no explicit amount', () => {
+ const db = openDb(':memory:')
+ const c = createClient(db, 'u1', { name: 'X', stateCode: '32' })
+ const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
+ const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
+ expect(() => createRecurringPlan(db, 'u1', {
+ clientId: c.id, clientModuleId: cm.id, cadence: 'yearly', nextRun: '2026-08-01',
+ })).toThrow(/price/i)
+ })
+ it('rejects a client_module that belongs to another client', () => {
+ const { db, cm } = setup()
+ const other = createClient(db, 'u1', { name: 'Other', stateCode: '32' })
+ expect(() => createRecurringPlan(db, 'u1', {
+ clientId: other.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', amountPaise: 100_00,
+ })).toThrow(/client/i)
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/recurring.test.ts` → FAIL (module not found).
+
+- [ ] **Step 3: Implement `apps/hq/src/repos-recurring.ts`**
+
+```ts
+import { uuidv7 } from '@sims/domain'
+import { writeAudit } from './audit'
+import type { DB } from './db'
+import { getClient } from './repos-clients'
+import { getClientModule, priceOn, type Kind } from './repos-modules'
+
+/** Recurring billing plans — plain functions over the handle (D12 portable-repo pattern). */
+
+export type Cadence = 'monthly' | 'yearly'
+export const CADENCE_KIND: Record = { monthly: 'monthly', yearly: 'yearly' }
+
+export interface RecurringPlan {
+ id: string; clientId: string; clientModuleId: string | null; cadence: Cadence
+ amountPaise: number | null; nextRun: string; policy: 'auto' | 'manual'; active: boolean
+}
+
+interface RecurringRow {
+ id: string; client_id: string; client_module_id: string | null; cadence: string
+ amount_paise: number | null; next_run: string; policy: string; active: number
+}
+
+function toPlan(r: RecurringRow): RecurringPlan {
+ return {
+ id: r.id, clientId: r.client_id, clientModuleId: r.client_module_id,
+ cadence: r.cadence as Cadence, amountPaise: r.amount_paise, nextRun: r.next_run,
+ policy: r.policy as 'auto' | 'manual', active: r.active === 1,
+ }
+}
+
+export function getRecurringPlan(db: DB, id: string): RecurringPlan | null {
+ const row = db.prepare(`SELECT * FROM recurring_plan WHERE id=?`).get(id) as RecurringRow | undefined
+ return row === undefined ? null : toPlan(row)
+}
+
+export function listRecurringPlans(db: DB, clientId?: string): RecurringPlan[] {
+ const rows = clientId === undefined
+ ? db.prepare(`SELECT * FROM recurring_plan ORDER BY id DESC`).all() as RecurringRow[]
+ : db.prepare(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`).all(clientId) as RecurringRow[]
+ return rows.map(toPlan)
+}
+
+export interface CreateRecurringInput {
+ clientId: string; clientModuleId: string; cadence: Cadence
+ amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual'
+}
+
+export function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): RecurringPlan {
+ if (getClient(db, input.clientId) === null) throw new Error('Client not found')
+ const cm = getClientModule(db, input.clientModuleId)
+ if (cm === null) throw new Error('Client module not found')
+ if (cm.clientId !== input.clientId) throw new Error('Client module belongs to another client')
+ if (input.amountPaise !== undefined && (!Number.isInteger(input.amountPaise) || input.amountPaise < 0)) {
+ throw new Error('amountPaise must be a non-negative integer (paise)')
+ }
+ // A plan must be priceable at generation time: explicit amount, or a resolvable module price.
+ if (input.amountPaise === undefined) {
+ const kind = CADENCE_KIND[input.cadence]
+ if (priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun) === null) {
+ throw new Error(`No ${kind} price for the module on ${input.nextRun}; set an amount or add a price`)
+ }
+ }
+ const id = uuidv7()
+ db.prepare(
+ `INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
+ ).run(
+ id, input.clientId, input.clientModuleId, input.cadence,
+ input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual',
+ )
+ const plan = getRecurringPlan(db, id)!
+ writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan)
+ return plan
+}
+
+export interface RecurringPatch {
+ cadence?: Cadence; amountPaise?: number | null; nextRun?: string
+ policy?: 'auto' | 'manual'; active?: boolean
+}
+
+export function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): RecurringPlan {
+ const before = getRecurringPlan(db, id)
+ if (before === null) throw new Error('Recurring plan not found')
+ const sets: string[] = []
+ const args: unknown[] = []
+ if (patch.cadence !== undefined) { sets.push('cadence=?'); args.push(patch.cadence) }
+ if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) }
+ if (patch.nextRun !== undefined) { sets.push('next_run=?'); args.push(patch.nextRun) }
+ if (patch.policy !== undefined) { sets.push('policy=?'); args.push(patch.policy) }
+ if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
+ if (sets.length > 0) {
+ args.push(id)
+ db.prepare(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ }
+ const after = getRecurringPlan(db, id)!
+ writeAudit(db, userId, 'update', 'recurring_plan', id, before, after)
+ return after
+}
+
+export function deactivateRecurringPlan(db: DB, userId: string, id: string): RecurringPlan {
+ return updateRecurringPlan(db, userId, id, { active: false })
+}
+```
+
+Wire routes in `apps/hq/src/api.ts` — add the import and the block (owner gate on writes, matching the modules block):
+
+```ts
+import {
+ createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans,
+ updateRecurringPlan, type CreateRecurringInput, type RecurringPatch,
+} from './repos-recurring'
+```
+
+```ts
+ // ---------- recurring plans ----------
+ r.get('/recurring', requireAuth, (req, res) => {
+ const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined
+ res.json({ ok: true, plans: listRecurringPlans(db, clientId) })
+ })
+ r.post('/clients/:id/recurring', requireAuth, requireOwner, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ try {
+ const plan = createRecurringPlan(db, staffId(res), {
+ ...(req.body as Omit), clientId: id,
+ })
+ res.json({ ok: true, plan })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.patch('/recurring/:id', requireAuth, requireOwner, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
+ try {
+ res.json({ ok: true, plan: updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.post('/recurring/:id/deactivate', requireAuth, requireOwner, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
+ res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) })
+ })
+```
+
+- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/recurring.test.ts` → PASS. `npm run typecheck` → clean.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/hq/src/repos-recurring.ts apps/hq/src/api.ts apps/hq/test/recurring.test.ts
+git commit -m "feat(hq): recurring plan repo and owner-gated routes" -m "Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 3: AMC contracts — repo, renewal invoice, derived paid state, routes
+
+**Files:**
+- Create: `apps/hq/src/repos-amc.ts`
+- Modify: `apps/hq/src/api.ts`
+- Test: `apps/hq/test/amc.test.ts`
+
+**Interfaces:**
+- Consumes: `createDraft`, `issueDocument`, `getDocument`; `outstandingPaise` (added in Task 7 — until then AMC paid-state uses the local `outstandingOf` helper defined here that mirrors `repos-payments`; **when Task 7 lands, switch `amcPaidStatus` to import `outstandingPaise`**). To keep Task 3 self-contained, this task defines its own tiny outstanding read; Task 7 does not require editing Task 3.
+- Produces:
+ - `AmcContract = { id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string; amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null; invoiceDocId: string | null; active: boolean }`
+ - `createAmc(db, userId, input: { clientId; coverage?; periodFrom; periodTo; amountPaise; renewalReminderDays?; legacyPaid?: boolean }): AmcContract`
+ - `getAmc(db, id): AmcContract | null`; `listAmc(db, clientId): AmcContract[]`
+ - `updateAmc(db, userId, id, patch): AmcContract`; `deactivateAmc(db, userId, id): AmcContract`
+ - `generateAmcRenewalInvoice(db, userId, amcId): Doc` — builds one INVOICE line via the seeded `AMC` module (`description = coverage`, `qty 1`, `kind 'yearly'`, `unitPricePaise = amc.amountPaise`), `createDraft` → `issueDocument`, then sets `amc_contract.invoice_doc_id`; throws if an invoice is already linked and unpaid.
+ - `amcPaidStatus(db, amc): 'paid' | 'unpaid' | 'unbilled'` — `legacyPaid === true` → `'paid'`; else no `invoiceDocId` → `'unbilled'`; else outstanding on the linked invoice `<= 0` → `'paid'` else `'unpaid'`.
+- Routes: `GET /api/clients/:id/amc`, `POST /api/clients/:id/amc` (owner), `PATCH /api/amc/:id` (owner), `POST /api/amc/:id/deactivate` (owner), `POST /api/amc/:id/renewal-invoice` (owner).
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// apps/hq/test/amc.test.ts
+import { describe, it, expect } from 'vitest'
+import { openDb } from '../src/db'
+import { seedIfEmpty } from '../src/seed'
+import { createClient } from '../src/repos-clients'
+import { recordPayment } from '../src/repos-payments'
+import {
+ createAmc, listAmc, generateAmcRenewalInvoice, amcPaidStatus, getAmc,
+} from '../src/repos-amc'
+
+function setup() {
+ const db = openDb(':memory:'); seedIfEmpty(db) // seeds company.state_code, GST18, AMC module
+ const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
+ return { db, c }
+}
+
+describe('amc contracts', () => {
+ it('creates, generates a renewal invoice, and derives paid from settlement', () => {
+ const { db, c } = setup()
+ const amc = createAmc(db, 'u1', {
+ clientId: c.id, coverage: 'On-site support', periodFrom: '2026-04-01', periodTo: '2027-03-31',
+ amountPaise: 20_000_00,
+ })
+ expect(listAmc(db, c.id)).toHaveLength(1)
+ expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unbilled')
+ const inv = generateAmcRenewalInvoice(db, 'u1', amc.id)
+ expect(inv.docType).toBe('INVOICE')
+ expect(inv.payablePaise).toBe(23_600_00) // 20,000 + 18% GST intra-state
+ expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unpaid')
+ recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 23_600_00 })
+ expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid')
+ })
+ it('honours the legacy_paid flag for imported contracts without an invoice', () => {
+ const { db, c } = setup()
+ const amc = createAmc(db, 'u1', {
+ clientId: c.id, periodFrom: '2025-04-01', periodTo: '2026-03-31', amountPaise: 10_000_00, legacyPaid: true,
+ })
+ expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid')
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/amc.test.ts` → FAIL.
+
+- [ ] **Step 3: Implement `apps/hq/src/repos-amc.ts`**
+
+```ts
+import { uuidv7 } from '@sims/domain'
+import { writeAudit } from './audit'
+import type { DB } from './db'
+import { getClient } from './repos-clients'
+import { getModule } from './repos-modules'
+import { createDraft, getDocument, issueDocument, type Doc } from './repos-documents'
+
+/** AMC contracts — plain functions over the handle (D12 pattern). Paid state is
+ * derived from the linked invoice's settlement; legacy_paid is a manual flag for
+ * imported contracts only, so there is one source of truth and no drift. */
+
+export interface AmcContract {
+ id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string
+ amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null
+ invoiceDocId: string | null; active: boolean
+}
+
+interface AmcRow {
+ id: string; client_id: string; coverage: string; period_from: string; period_to: string
+ amount_paise: number; renewal_reminder_days: number; legacy_paid: number | null
+ invoice_doc_id: string | null; active: number
+}
+
+function toAmc(r: AmcRow): AmcContract {
+ return {
+ id: r.id, clientId: r.client_id, coverage: r.coverage,
+ periodFrom: r.period_from, periodTo: r.period_to, amountPaise: r.amount_paise,
+ renewalReminderDays: r.renewal_reminder_days,
+ legacyPaid: r.legacy_paid === null ? null : r.legacy_paid === 1,
+ invoiceDocId: r.invoice_doc_id, active: r.active === 1,
+ }
+}
+
+export function getAmc(db: DB, id: string): AmcContract | null {
+ const row = db.prepare(`SELECT * FROM amc_contract WHERE id=?`).get(id) as AmcRow | undefined
+ return row === undefined ? null : toAmc(row)
+}
+
+export function listAmc(db: DB, clientId: string): AmcContract[] {
+ const rows = db.prepare(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`).all(clientId) as AmcRow[]
+ return rows.map(toAmc)
+}
+
+export interface CreateAmcInput {
+ clientId: string; coverage?: string; periodFrom: string; periodTo: string
+ amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean
+}
+
+export function createAmc(db: DB, userId: string, input: CreateAmcInput): AmcContract {
+ if (getClient(db, input.clientId) === null) throw new Error('Client not found')
+ if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) {
+ throw new Error('amountPaise must be a non-negative integer (paise)')
+ }
+ const id = uuidv7()
+ db.prepare(
+ `INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise,
+ renewal_reminder_days, legacy_paid, invoice_doc_id, active)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`,
+ ).run(
+ id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise,
+ input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0,
+ )
+ const amc = getAmc(db, id)!
+ writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc)
+ return amc
+}
+
+export interface AmcPatch {
+ coverage?: string; periodFrom?: string; periodTo?: string; amountPaise?: number
+ renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean
+}
+
+export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): AmcContract {
+ const before = getAmc(db, id)
+ if (before === null) throw new Error('AMC contract not found')
+ const sets: string[] = []
+ const args: unknown[] = []
+ if (patch.coverage !== undefined) { sets.push('coverage=?'); args.push(patch.coverage) }
+ if (patch.periodFrom !== undefined) { sets.push('period_from=?'); args.push(patch.periodFrom) }
+ if (patch.periodTo !== undefined) { sets.push('period_to=?'); args.push(patch.periodTo) }
+ if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) }
+ if (patch.renewalReminderDays !== undefined) { sets.push('renewal_reminder_days=?'); args.push(patch.renewalReminderDays) }
+ if (patch.legacyPaid !== undefined) { sets.push('legacy_paid=?'); args.push(patch.legacyPaid === null ? null : patch.legacyPaid ? 1 : 0) }
+ if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
+ if (sets.length > 0) {
+ args.push(id)
+ db.prepare(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ }
+ const after = getAmc(db, id)!
+ writeAudit(db, userId, 'update', 'amc_contract', id, before, after)
+ return after
+}
+
+export function deactivateAmc(db: DB, userId: string, id: string): AmcContract {
+ return updateAmc(db, userId, id, { active: false })
+}
+
+/** Renewal invoice for an AMC: one line on the seeded AMC module, priced at the
+ * contract amount, issued and linked back via invoice_doc_id. */
+export function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Doc {
+ const amc = getAmc(db, amcId)
+ if (amc === null) throw new Error('AMC contract not found')
+ if (amc.invoiceDocId !== null) {
+ const existing = getDocument(db, amc.invoiceDocId)
+ if (existing !== null && existing.status !== 'cancelled' && outstandingOf(db, existing.id) > 0) {
+ throw new Error(`AMC already has an unpaid renewal invoice ${existing.docNo}`)
+ }
+ }
+ const amcModule = db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string } | undefined
+ if (amcModule === undefined) throw new Error('AMC module missing — run the seed')
+ return db.transaction(() => {
+ const draft = createDraft(db, userId, {
+ docType: 'INVOICE', clientId: amc.clientId,
+ lines: [{
+ moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance',
+ qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise,
+ }],
+ })
+ const inv = issueDocument(db, userId, draft.id)
+ db.prepare(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`).run(inv.id, amcId)
+ writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id })
+ return inv
+ })()
+}
+
+/** Local outstanding read (payable − allocations − non-cancelled credit notes) —
+ * mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can
+ * switch to it then, but does not depend on Task 7 to compile. */
+function outstandingOf(db: DB, docId: string): number {
+ const doc = getDocument(db, docId)
+ if (doc === null) return 0
+ const alloc = (db.prepare(
+ `SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`,
+ ).get(docId) as { total: number }).total
+ const credited = (db.prepare(
+ `SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
+ WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`,
+ ).get(docId) as { total: number }).total
+ return doc.payablePaise - alloc - credited
+}
+
+export function amcPaidStatus(db: DB, amc: AmcContract): 'paid' | 'unpaid' | 'unbilled' {
+ if (amc.legacyPaid === true) return 'paid'
+ if (amc.invoiceDocId === null) return 'unbilled'
+ return outstandingOf(db, amc.invoiceDocId) <= 0 ? 'paid' : 'unpaid'
+}
+```
+
+Wire routes in `api.ts` (import + block; writes owner-gated):
+
+```ts
+import {
+ amcPaidStatus, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, listAmc,
+ updateAmc, type AmcPatch, type CreateAmcInput,
+} from './repos-amc'
+```
+
+```ts
+ // ---------- amc ----------
+ r.get('/clients/:id/amc', requireAuth, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ const contracts = listAmc(db, id).map((a) => ({ ...a, paidStatus: amcPaidStatus(db, a) }))
+ res.json({ ok: true, contracts })
+ })
+ r.post('/clients/:id/amc', requireAuth, requireOwner, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ try {
+ const amc = createAmc(db, staffId(res), { ...(req.body as Omit), clientId: id })
+ res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.patch('/amc/:id', requireAuth, requireOwner, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
+ try {
+ const amc = updateAmc(db, staffId(res), id, req.body as AmcPatch)
+ res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.post('/amc/:id/deactivate', requireAuth, requireOwner, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
+ const amc = deactivateAmc(db, staffId(res), id)
+ res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
+ })
+ r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
+ try {
+ res.json({ ok: true, document: generateAmcRenewalInvoice(db, staffId(res), id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+```
+
+- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/amc.test.ts` → PASS (verify the golden `23_600_00` paisa). `npm run typecheck` → clean.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/hq/src/repos-amc.ts apps/hq/src/api.ts apps/hq/test/amc.test.ts
+git commit -m "feat(hq): amc contracts with renewal invoice and derived paid state" -m "Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 4: Interactions — types, log, follow-ups, routes
+
+**Files:**
+- Create: `apps/hq/src/repos-interactions.ts`
+- Modify: `apps/hq/src/api.ts`
+- Test: `apps/hq/test/interactions.test.ts`
+
+**Interfaces:**
+- Produces:
+ - `InteractionType = { code: string; label: string }`; `listInteractionTypes(db): InteractionType[]`; `createInteractionType(db, userId, { code, label }): InteractionType` (owner-only at the route; the lookup is user-extensible).
+ - `Interaction = { id: string; clientId: string; typeCode: string; onDate: string; staffId: string; notes: string; outcome: 'positive'|'neutral'|'negative' | null; followUpOn: string | null; createdAt: string }`
+ - `createInteraction(db, userId, input: { clientId; typeCode; onDate; notes?; outcome?; followUpOn? }): Interaction` — validates the client exists and `typeCode` is a known type; `staffId` is the acting user.
+ - `listInteractions(db, clientId): Interaction[]` (newest first — the timeline).
+ - `updateInteraction(db, userId, id, patch: { notes?; outcome?; followUpOn? }): Interaction`.
+ - `listOpenFollowUps(db, onOrBefore: string): (Interaction & { clientName: string })[]` — interactions whose `follow_up_on <= onOrBefore`, newest due first (feeds the dashboard + the `follow_up` reminder rule).
+- Routes: `GET /api/interaction-types`, `POST /api/interaction-types` (owner), `GET /api/clients/:id/interactions`, `POST /api/clients/:id/interactions`, `PATCH /api/interactions/:id`.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// apps/hq/test/interactions.test.ts
+import { describe, it, expect } from 'vitest'
+import { openDb } from '../src/db'
+import { seedIfEmpty } from '../src/seed'
+import { createClient } from '../src/repos-clients'
+import {
+ createInteraction, listInteractions, listInteractionTypes, listOpenFollowUps, updateInteraction,
+} from '../src/repos-interactions'
+
+describe('interactions', () => {
+ it('logs a typed interaction, updates outcome, and surfaces due follow-ups', () => {
+ const db = openDb(':memory:'); seedIfEmpty(db)
+ expect(listInteractionTypes(db).length).toBeGreaterThanOrEqual(7)
+ const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
+ const i = createInteraction(db, 'u1', {
+ clientId: c.id, typeCode: 'site_visit', onDate: '2026-07-01',
+ notes: 'Installed POS; owner wants training', followUpOn: '2026-07-08',
+ })
+ expect(listInteractions(db, c.id)).toHaveLength(1)
+ const up = updateInteraction(db, 'u1', i.id, { outcome: 'positive' })
+ expect(up.outcome).toBe('positive')
+ expect(listOpenFollowUps(db, '2026-07-10').map((f) => f.id)).toContain(i.id)
+ expect(listOpenFollowUps(db, '2026-07-05')).toHaveLength(0) // not yet due
+ })
+ it('rejects an unknown interaction type', () => {
+ const db = openDb(':memory:'); seedIfEmpty(db)
+ const c = createClient(db, 'u1', { name: 'X', stateCode: '32' })
+ expect(() => createInteraction(db, 'u1', { clientId: c.id, typeCode: 'telepathy', onDate: '2026-07-01' }))
+ .toThrow(/type/i)
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/interactions.test.ts` → FAIL.
+
+- [ ] **Step 3: Implement `apps/hq/src/repos-interactions.ts`**
+
+```ts
+import { uuidv7 } from '@sims/domain'
+import { writeAudit } from './audit'
+import type { DB } from './db'
+import { getClient } from './repos-clients'
+
+/** Interaction log — the "memories into the system" table (D12 plain-repo pattern). */
+
+export interface InteractionType { code: string; label: string }
+
+export function listInteractionTypes(db: DB): InteractionType[] {
+ return db.prepare(`SELECT code, label FROM interaction_type ORDER BY label`).all() as InteractionType[]
+}
+
+export function createInteractionType(db: DB, userId: string, input: { code: string; label: string }): InteractionType {
+ if (input.code.trim() === '' || input.label.trim() === '') throw new Error('code and label are required')
+ db.prepare(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`).run(input.code.trim(), input.label.trim())
+ writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input)
+ return { code: input.code.trim(), label: input.label.trim() }
+}
+
+export type Outcome = 'positive' | 'neutral' | 'negative'
+
+export interface Interaction {
+ id: string; clientId: string; typeCode: string; onDate: string; staffId: string
+ notes: string; outcome: Outcome | null; followUpOn: string | null; createdAt: string
+}
+
+interface InteractionRow {
+ id: string; client_id: string; type_code: string; on_date: string; staff_id: string
+ notes: string; outcome: string | null; follow_up_on: string | null; created_at: string
+}
+
+function toInteraction(r: InteractionRow): Interaction {
+ return {
+ id: r.id, clientId: r.client_id, typeCode: r.type_code, onDate: r.on_date, staffId: r.staff_id,
+ notes: r.notes, outcome: r.outcome as Outcome | null, followUpOn: r.follow_up_on, createdAt: r.created_at,
+ }
+}
+
+export function getInteraction(db: DB, id: string): Interaction | null {
+ const row = db.prepare(`SELECT * FROM interaction WHERE id=?`).get(id) as InteractionRow | undefined
+ return row === undefined ? null : toInteraction(row)
+}
+
+export function listInteractions(db: DB, clientId: string): Interaction[] {
+ const rows = db.prepare(
+ `SELECT * FROM interaction WHERE client_id=? ORDER BY on_date DESC, id DESC`,
+ ).all(clientId) as InteractionRow[]
+ return rows.map(toInteraction)
+}
+
+export interface CreateInteractionInput {
+ clientId: string; typeCode: string; onDate: string
+ notes?: string; outcome?: Outcome; followUpOn?: string | null
+}
+
+const OUTCOMES: Outcome[] = ['positive', 'neutral', 'negative']
+
+export function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Interaction {
+ if (getClient(db, input.clientId) === null) throw new Error('Client not found')
+ const type = db.prepare(`SELECT code FROM interaction_type WHERE code=?`).get(input.typeCode)
+ if (type === undefined) throw new Error(`Unknown interaction type: ${input.typeCode}`)
+ if (input.outcome !== undefined && !OUTCOMES.includes(input.outcome)) throw new Error(`Unknown outcome: ${input.outcome}`)
+ const id = uuidv7()
+ db.prepare(
+ `INSERT INTO interaction (id, client_id, type_code, on_date, staff_id, notes, outcome, follow_up_on, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ).run(
+ id, input.clientId, input.typeCode, input.onDate, userId, input.notes ?? '',
+ input.outcome ?? null, input.followUpOn ?? null, new Date().toISOString(),
+ )
+ const interaction = getInteraction(db, id)!
+ writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction)
+ return interaction
+}
+
+export interface InteractionPatch { notes?: string; outcome?: Outcome | null; followUpOn?: string | null }
+
+export function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Interaction {
+ const before = getInteraction(db, id)
+ if (before === null) throw new Error('Interaction not found')
+ if (patch.outcome !== undefined && patch.outcome !== null && !OUTCOMES.includes(patch.outcome)) {
+ throw new Error(`Unknown outcome: ${patch.outcome}`)
+ }
+ const sets: string[] = []
+ const args: unknown[] = []
+ if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) }
+ if (patch.outcome !== undefined) { sets.push('outcome=?'); args.push(patch.outcome) }
+ if (patch.followUpOn !== undefined) { sets.push('follow_up_on=?'); args.push(patch.followUpOn) }
+ if (sets.length > 0) {
+ args.push(id)
+ db.prepare(`UPDATE interaction SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ }
+ const after = getInteraction(db, id)!
+ writeAudit(db, userId, 'update', 'interaction', id, before, after)
+ return after
+}
+
+/** Interactions with a follow-up date on or before the given date — the follow-ups queue. */
+export function listOpenFollowUps(db: DB, onOrBefore: string): (Interaction & { clientName: string })[] {
+ const rows = db.prepare(
+ `SELECT i.*, c.name AS client_name FROM interaction i JOIN client c ON c.id = i.client_id
+ WHERE i.follow_up_on IS NOT NULL AND i.follow_up_on <= ?
+ ORDER BY i.follow_up_on DESC, i.id DESC`,
+ ).all(onOrBefore) as (InteractionRow & { client_name: string })[]
+ return rows.map((r) => ({ ...toInteraction(r), clientName: r.client_name }))
+}
+```
+
+Wire routes in `api.ts` (import + block; type-create owner-gated, logging is staff-allowed):
+
+```ts
+import {
+ createInteraction, createInteractionType, getInteraction, listInteractions,
+ listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch,
+} from './repos-interactions'
+```
+
+```ts
+ // ---------- interactions ----------
+ r.get('/interaction-types', requireAuth, (_req, res) => {
+ res.json({ ok: true, types: listInteractionTypes(db) })
+ })
+ r.post('/interaction-types', requireAuth, requireOwner, (req, res) => {
+ try {
+ const type = createInteractionType(db, staffId(res), req.body as { code: string; label: string })
+ res.json({ ok: true, type })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.get('/clients/:id/interactions', requireAuth, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ res.json({ ok: true, interactions: listInteractions(db, id) })
+ })
+ r.post('/clients/:id/interactions', requireAuth, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ try {
+ const interaction = createInteraction(db, staffId(res), {
+ ...(req.body as Omit), clientId: id,
+ })
+ res.json({ ok: true, interaction })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.patch('/interactions/:id', requireAuth, (req, res) => {
+ const id = String(req.params['id'] ?? '')
+ if (getInteraction(db, id) === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return }
+ try {
+ res.json({ ok: true, interaction: updateInteraction(db, staffId(res), id, req.body as InteractionPatch) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+```
+
+- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/interactions.test.ts` → PASS. `npm run typecheck` → clean.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/hq/src/repos-interactions.ts apps/hq/src/api.ts apps/hq/test/interactions.test.ts
+git commit -m "feat(hq): interaction log with types, follow-ups and routes" -m "Co-Authored-By: Claude Fable 5 "
+```
+
+---
diff --git a/packages/auth/package.json b/packages/auth/package.json
new file mode 100644
index 0000000..86ddbc8
--- /dev/null
+++ b/packages/auth/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@sims/auth",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "dependencies": {
+ "@sims/domain": "*"
+ }
+}
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
new file mode 100644
index 0000000..28dd204
--- /dev/null
+++ b/packages/auth/src/index.ts
@@ -0,0 +1,4 @@
+export * from './pin'
+export * from './lockout'
+export * from './permissions'
+export * from './session'
diff --git a/packages/auth/src/lockout.ts b/packages/auth/src/lockout.ts
new file mode 100644
index 0000000..495ac4a
--- /dev/null
+++ b/packages/auth/src/lockout.ts
@@ -0,0 +1,38 @@
+/**
+ * Brute-force lockout as a pure reducer so POS and back office share the exact
+ * policy and it is testable without a clock. Attempts are audit-logged by the
+ * caller (I7: every mutation audit-logged in the same transaction).
+ */
+export interface LockoutPolicy {
+ maxFailures: number
+ lockMs: number
+}
+
+export const DEFAULT_LOCKOUT: LockoutPolicy = { maxFailures: 5, lockMs: 60_000 }
+
+export interface LockoutState {
+ failures: number
+ lockedUntilMs?: number
+}
+
+export const FRESH_LOCKOUT: LockoutState = { failures: 0 }
+
+export function isLocked(state: LockoutState, nowMs: number): boolean {
+ return state.lockedUntilMs !== undefined && nowMs < state.lockedUntilMs
+}
+
+export function recordFailure(
+ state: LockoutState,
+ nowMs: number,
+ policy: LockoutPolicy = DEFAULT_LOCKOUT,
+): LockoutState {
+ const failures = state.failures + 1
+ if (failures >= policy.maxFailures) {
+ return { failures, lockedUntilMs: nowMs + policy.lockMs }
+ }
+ return { failures }
+}
+
+export function recordSuccess(): LockoutState {
+ return FRESH_LOCKOUT
+}
diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts
new file mode 100644
index 0000000..ffb0245
--- /dev/null
+++ b/packages/auth/src/permissions.ts
@@ -0,0 +1,74 @@
+import type { RoleCode, User } from '@sims/domain'
+
+/**
+ * Action codes gate everything sensitive. Role→action grants ship as defaults
+ * below but live as DB rows in production (config-driven principle) so a
+ * tenant can tighten or loosen without a release. Gate semantics follow
+ * 09-UX §5.5: role gates hide navigation but in-flow actions raise the
+ * supervisor-PIN overlay instead of disappearing.
+ */
+export type ActionCode =
+ | 'BILL_CREATE'
+ | 'BILL_VOID'
+ | 'PRICE_OVERRIDE'
+ | 'DISCOUNT_OVER_CAP'
+ | 'DRAWER_NOSALE'
+ | 'REPRINT_EXCESS'
+ | 'RETURN_CREATE'
+ | 'DAY_BEGIN'
+ | 'DAY_END'
+ | 'SHIFT_OPEN'
+ | 'SHIFT_CLOSE'
+ | 'PURCHASE_ENTRY'
+ | 'STOCK_ADJUST'
+ | 'MASTER_EDIT'
+ | 'SETTINGS_EDIT'
+ | 'USER_MANAGE'
+ | 'REPORT_VIEW'
+ | 'AUDIT_VIEW'
+
+export type Gate = 'allow' | 'supervisor-pin' | 'deny'
+
+export const DEFAULT_ROLE_GRANTS: Record = {
+ owner: [
+ 'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE',
+ 'REPRINT_EXCESS', 'RETURN_CREATE', 'DAY_BEGIN', 'DAY_END', 'SHIFT_OPEN', 'SHIFT_CLOSE',
+ 'PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'SETTINGS_EDIT', 'USER_MANAGE',
+ 'REPORT_VIEW', 'AUDIT_VIEW',
+ ],
+ manager: [
+ 'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE',
+ 'REPRINT_EXCESS', 'RETURN_CREATE', 'DAY_BEGIN', 'DAY_END', 'SHIFT_OPEN', 'SHIFT_CLOSE',
+ 'PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'SETTINGS_EDIT', 'REPORT_VIEW',
+ ],
+ supervisor: [
+ 'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE',
+ 'REPRINT_EXCESS', 'RETURN_CREATE', 'SHIFT_OPEN', 'SHIFT_CLOSE', 'REPORT_VIEW',
+ ],
+ cashier: ['BILL_CREATE', 'RETURN_CREATE', 'SHIFT_OPEN', 'SHIFT_CLOSE'],
+ purchaser: ['PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'REPORT_VIEW'],
+ accountant: ['REPORT_VIEW', 'AUDIT_VIEW'],
+ auditor: ['REPORT_VIEW', 'AUDIT_VIEW'],
+}
+
+export function can(
+ user: Pick,
+ action: ActionCode,
+ grants: Record = DEFAULT_ROLE_GRANTS,
+): boolean {
+ if (!user.active) return false
+ return user.roles.some((role) => grants[role]?.includes(action))
+}
+
+/** In-flow gate: allowed directly, escalatable via supervisor PIN, or denied. */
+export function gateFor(
+ user: Pick,
+ action: ActionCode,
+ grants: Record = DEFAULT_ROLE_GRANTS,
+): Gate {
+ if (can(user, action, grants)) return 'allow'
+ const supervisorCould = (['supervisor', 'manager', 'owner'] as RoleCode[]).some((r) =>
+ grants[r]?.includes(action),
+ )
+ return supervisorCould ? 'supervisor-pin' : 'deny'
+}
diff --git a/packages/auth/src/pin.ts b/packages/auth/src/pin.ts
new file mode 100644
index 0000000..9de52c7
--- /dev/null
+++ b/packages/auth/src/pin.ts
@@ -0,0 +1,29 @@
+import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
+
+/**
+ * POS login is name-tile + PIN (09-UX §1: PIN-fast shift open); back office is
+ * username + password. Both hash through here — scrypt, per-credential salt.
+ */
+export interface PinHash {
+ salt: string // hex
+ hash: string // hex
+}
+
+export function assertUsablePin(pin: string): void {
+ if (!/^\d{4,6}$/.test(pin)) throw new Error('PIN must be 4–6 digits')
+ if (/^(\d)\1+$/.test(pin)) throw new Error('PIN must not be a single repeated digit')
+ if ('0123456789'.includes(pin) || '9876543210'.includes(pin)) {
+ throw new Error('PIN must not be a simple sequence')
+ }
+}
+
+export function hashPin(pin: string): PinHash {
+ const salt = randomBytes(16)
+ const hash = scryptSync(pin, salt, 32)
+ return { salt: salt.toString('hex'), hash: hash.toString('hex') }
+}
+
+export function verifyPin(pin: string, stored: PinHash): boolean {
+ const candidate = scryptSync(pin, Buffer.from(stored.salt, 'hex'), 32)
+ return timingSafeEqual(candidate, Buffer.from(stored.hash, 'hex'))
+}
diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts
new file mode 100644
index 0000000..7806dd0
--- /dev/null
+++ b/packages/auth/src/session.ts
@@ -0,0 +1,38 @@
+import type { BusinessDay, IsoDate } from '@sims/domain'
+import { isOpen } from '@sims/domain'
+
+/**
+ * A POS session binds user + counter + shift + the Day-Begin business date
+ * (the OG working-day idiom). Billing requires an open day and an open shift.
+ */
+export interface PosSession {
+ tenantId: string
+ storeId: string
+ counterId: string
+ userId: string
+ shiftId: string
+ businessDate: IsoDate
+ startedAtWallClock: string
+}
+
+export function openSession(args: {
+ tenantId: string
+ storeId: string
+ counterId: string
+ userId: string
+ shiftId: string
+ day: BusinessDay
+ nowWallClock: string
+}): PosSession {
+ if (!isOpen(args.day)) throw new Error('Day is closed — run Day Begin before opening a shift')
+ if (args.day.storeId !== args.storeId) throw new Error('Business day belongs to another store')
+ return {
+ tenantId: args.tenantId,
+ storeId: args.storeId,
+ counterId: args.counterId,
+ userId: args.userId,
+ shiftId: args.shiftId,
+ businessDate: args.day.date,
+ startedAtWallClock: args.nowWallClock,
+ }
+}
diff --git a/packages/auth/test/auth.test.ts b/packages/auth/test/auth.test.ts
new file mode 100644
index 0000000..ae870d9
--- /dev/null
+++ b/packages/auth/test/auth.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+import {
+ assertUsablePin, hashPin, verifyPin,
+ FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, DEFAULT_LOCKOUT,
+ can, gateFor,
+ openSession,
+} from '@sims/auth'
+import type { BusinessDay, User } from '@sims/domain'
+
+describe('PIN policy & hashing', () => {
+ it('accepts sane PINs, rejects weak ones', () => {
+ expect(() => assertUsablePin('4728')).not.toThrow()
+ expect(() => assertUsablePin('12')).toThrow(/4–6 digits/)
+ expect(() => assertUsablePin('0000')).toThrow(/repeated/)
+ expect(() => assertUsablePin('1234')).toThrow(/sequence/)
+ expect(() => assertUsablePin('8765')).toThrow(/sequence/)
+ })
+ it('hashes with per-credential salt and verifies', () => {
+ const stored = hashPin('4728')
+ expect(verifyPin('4728', stored)).toBe(true)
+ expect(verifyPin('4729', stored)).toBe(false)
+ expect(hashPin('4728').hash).not.toBe(stored.hash) // fresh salt every time
+ })
+})
+
+describe('lockout reducer', () => {
+ it('locks after max failures and unlocks after the window', () => {
+ let s = FRESH_LOCKOUT
+ for (let i = 0; i < DEFAULT_LOCKOUT.maxFailures; i++) {
+ expect(isLocked(s, 1000)).toBe(false)
+ s = recordFailure(s, 1000)
+ }
+ expect(isLocked(s, 1000)).toBe(true)
+ expect(isLocked(s, 1000 + DEFAULT_LOCKOUT.lockMs)).toBe(false)
+ expect(isLocked(recordSuccess(), 1000)).toBe(false)
+ })
+})
+
+const user = (roles: User['roles'], active = true): Pick => ({ roles, active })
+
+describe('permissions & gates', () => {
+ it('grants by role', () => {
+ expect(can(user(['cashier']), 'BILL_CREATE')).toBe(true)
+ expect(can(user(['cashier']), 'PRICE_OVERRIDE')).toBe(false)
+ expect(can(user(['cashier'], false), 'BILL_CREATE')).toBe(false)
+ })
+ it('in-flow gate escalates to supervisor PIN instead of hiding (09-UX §5.5)', () => {
+ expect(gateFor(user(['cashier']), 'PRICE_OVERRIDE')).toBe('supervisor-pin')
+ expect(gateFor(user(['cashier']), 'DAY_BEGIN')).toBe('supervisor-pin')
+ expect(gateFor(user(['supervisor']), 'PRICE_OVERRIDE')).toBe('allow')
+ })
+})
+
+describe('POS session — Day Begin binding', () => {
+ const day: BusinessDay = {
+ tenantId: 't1', storeId: 's1', date: '2026-07-09',
+ openedAt: '2026-07-09T09:01:00+05:30', openedBy: 'mgr',
+ }
+ const base = {
+ tenantId: 't1', storeId: 's1', counterId: 'c1', userId: 'u1', shiftId: 'sh1',
+ nowWallClock: '2026-07-09T09:05:00+05:30',
+ }
+ it('binds the session to the working date, not the wall clock', () => {
+ expect(openSession({ ...base, day }).businessDate).toBe('2026-07-09')
+ })
+ it('refuses a closed day or another store’s day', () => {
+ expect(() => openSession({ ...base, day: { ...day, closedAt: 'x', closedBy: 'mgr' } })).toThrow(/Day Begin/)
+ expect(() => openSession({ ...base, day: { ...day, storeId: 's2' } })).toThrow(/another store/)
+ })
+})
diff --git a/packages/billing-engine/package.json b/packages/billing-engine/package.json
new file mode 100644
index 0000000..560ed69
--- /dev/null
+++ b/packages/billing-engine/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@sims/billing-engine",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "dependencies": {
+ "@sims/domain": "*"
+ }
+}
diff --git a/packages/billing-engine/src/compute.ts b/packages/billing-engine/src/compute.ts
new file mode 100644
index 0000000..d9b7c2e
--- /dev/null
+++ b/packages/billing-engine/src/compute.ts
@@ -0,0 +1,167 @@
+import type { BillLine, BillTotals, Paise, IsoDate } from '@sims/domain'
+import { resolveTaxClass, type TaxClassRow } from './tax'
+
+export interface LineInput {
+ itemId: string
+ name: string
+ hsn: string
+ qty: number
+ unitCode: string
+ unitPricePaise: Paise
+ priceIncludesTax: boolean
+ taxClassCode: string
+ mrpPaise?: Paise
+ discount?: { kind: 'percentBp'; value: number } | { kind: 'amount'; paise: Paise }
+ batchId?: string
+}
+
+export interface BillContext {
+ businessDate: IsoDate
+ /** Store's GST state code. */
+ supplyStateCode: string
+ /** Customer/delivery state; equal to supply state ⇒ CGST+SGST, else IGST. */
+ placeOfSupplyStateCode: string
+ roundToRupee: boolean
+ billDiscountPaise?: Paise
+}
+
+export interface ComputedBill {
+ lines: BillLine[]
+ totals: BillTotals
+}
+
+/**
+ * Deterministic money rules — the same engine must produce byte-identical
+ * results on POS, store server, and cloud:
+ * - integer paise throughout; each rounding step is explicit
+ * - inclusive price: line total stays the sticker amount; taxable is derived
+ * - intra-state split: cgst = floor(tax/2), sgst = tax − cgst (sum exact)
+ * - bill discount allocates pro-rata by line gross, largest-remainder method
+ */
+export function computeBill(inputs: LineInput[], ctx: BillContext, rates: TaxClassRow[]): ComputedBill {
+ if (inputs.length === 0) throw new Error('Cannot compute an empty bill')
+ for (const line of inputs) {
+ // Returns are separate documents (immutability rule) — never negative lines.
+ if (!(line.qty > 0)) throw new Error(`Quantity must be positive for "${line.name}"`)
+ if (!Number.isInteger(line.unitPricePaise) || line.unitPricePaise < 0) {
+ throw new Error(`Unit price must be non-negative integer paise for "${line.name}"`)
+ }
+ if (line.discount !== undefined) {
+ const v = line.discount.kind === 'percentBp' ? line.discount.value : line.discount.paise
+ if (v < 0) throw new Error(`Discount must be non-negative for "${line.name}"`)
+ if (line.discount.kind === 'percentBp' && line.discount.value > 10_000) {
+ throw new Error(`Discount above 100% for "${line.name}"`)
+ }
+ }
+ }
+ if ((ctx.billDiscountPaise ?? 0) < 0) throw new Error('Bill discount must be non-negative')
+ const interState = ctx.supplyStateCode !== ctx.placeOfSupplyStateCode
+
+ const grossAfterLineDisc = inputs.map((line) => {
+ const gross = Math.round(line.unitPricePaise * line.qty)
+ const disc =
+ line.discount === undefined
+ ? 0
+ : line.discount.kind === 'percentBp'
+ ? Math.round((gross * line.discount.value) / 10_000)
+ : line.discount.paise
+ if (disc > gross) throw new Error(`Discount exceeds line amount for "${line.name}"`)
+ return { gross, disc, net: gross - disc }
+ })
+
+ const billDiscShares = allocateProRata(
+ ctx.billDiscountPaise ?? 0,
+ grossAfterLineDisc.map((g) => g.net),
+ )
+
+ const lines: BillLine[] = inputs.map((line, i) => {
+ const { gross, disc } = grossAfterLineDisc[i]!
+ const discount = disc + billDiscShares[i]!
+ const net = gross - discount
+ const rate = resolveTaxClass(rates, line.taxClassCode, ctx.businessDate)
+ const totalRateBp = rate.ratePctBp + rate.cessPctBp
+
+ let taxable: Paise
+ let taxPlusCess: Paise
+ let lineTotal: Paise
+ if (line.priceIncludesTax) {
+ taxable = Math.round((net * 10_000) / (10_000 + totalRateBp))
+ taxPlusCess = net - taxable
+ lineTotal = net
+ } else {
+ taxable = net
+ taxPlusCess = Math.round((taxable * totalRateBp) / 10_000)
+ lineTotal = taxable + taxPlusCess
+ }
+
+ const cess = totalRateBp === 0 ? 0 : Math.round((taxPlusCess * rate.cessPctBp) / totalRateBp)
+ const tax = taxPlusCess - cess
+ const cgst = interState ? 0 : Math.floor(tax / 2)
+ const sgst = interState ? 0 : tax - cgst
+ const igst = interState ? tax : 0
+
+ return {
+ itemId: line.itemId,
+ name: line.name,
+ hsn: line.hsn,
+ qty: line.qty,
+ unitCode: line.unitCode,
+ unitPricePaise: line.unitPricePaise,
+ ...(line.mrpPaise !== undefined ? { mrpPaise: line.mrpPaise } : {}),
+ grossPaise: gross,
+ discountPaise: discount,
+ taxablePaise: taxable,
+ taxRateBp: rate.ratePctBp,
+ cgstPaise: cgst,
+ sgstPaise: sgst,
+ igstPaise: igst,
+ cessPaise: cess,
+ lineTotalPaise: lineTotal,
+ ...(line.batchId !== undefined ? { batchId: line.batchId } : {}),
+ }
+ })
+
+ const sum = (f: (l: BillLine) => number) => lines.reduce((a, l) => a + f(l), 0)
+ const total = sum((l) => l.lineTotalPaise)
+ const payable = ctx.roundToRupee ? Math.round(total / 100) * 100 : total
+ const savings = lines.reduce((acc, l) => {
+ if (l.mrpPaise === undefined) return acc
+ return acc + Math.max(0, Math.round(l.mrpPaise * l.qty) - l.lineTotalPaise)
+ }, 0)
+
+ return {
+ lines,
+ totals: {
+ grossPaise: sum((l) => l.grossPaise),
+ discountPaise: sum((l) => l.discountPaise),
+ taxablePaise: sum((l) => l.taxablePaise),
+ cgstPaise: sum((l) => l.cgstPaise),
+ sgstPaise: sum((l) => l.sgstPaise),
+ igstPaise: sum((l) => l.igstPaise),
+ cessPaise: sum((l) => l.cessPaise),
+ roundOffPaise: payable - total,
+ payablePaise: payable,
+ savingsVsMrpPaise: savings,
+ },
+ }
+}
+
+/** Largest-remainder allocation; shares always sum exactly to `amount`. */
+export function allocateProRata(amount: Paise, weights: number[]): Paise[] {
+ const totalWeight = weights.reduce((a, w) => a + w, 0)
+ if (amount === 0) return weights.map(() => 0)
+ if (totalWeight === 0) {
+ // Never silently drop money: allocating a nonzero amount needs weight somewhere.
+ throw new Error('Cannot allocate a nonzero amount over zero total weight')
+ }
+ const raw = weights.map((w) => (amount * w) / totalWeight)
+ const shares = raw.map(Math.floor)
+ let remainder = amount - shares.reduce((a, s) => a + s, 0)
+ const order = raw
+ .map((r, i) => ({ frac: r - Math.floor(r), i }))
+ .sort((a, b) => b.frac - a.frac || a.i - b.i)
+ for (let k = 0; remainder > 0; k = (k + 1) % order.length, remainder--) {
+ shares[order[k]!.i]! += 1
+ }
+ return shares
+}
diff --git a/packages/billing-engine/src/index.ts b/packages/billing-engine/src/index.ts
new file mode 100644
index 0000000..0a980ac
--- /dev/null
+++ b/packages/billing-engine/src/index.ts
@@ -0,0 +1,2 @@
+export * from './tax'
+export * from './compute'
diff --git a/packages/billing-engine/src/tax.ts b/packages/billing-engine/src/tax.ts
new file mode 100644
index 0000000..3c6e2bf
--- /dev/null
+++ b/packages/billing-engine/src/tax.ts
@@ -0,0 +1,30 @@
+import type { IsoDate } from '@sims/domain'
+
+/**
+ * Tax rates are dated configuration rows, never constants (07-DB-AND-CONFIG §1;
+ * the Sept-2025 GST 2.0 slab change is the precedent). Rates are basis points:
+ * 18% → 1800. Resolution is by the bill's business date, so returns and credit
+ * notes against old bills resolve the rate that was law on that day.
+ */
+export interface TaxClassRow {
+ classCode: string
+ ratePctBp: number
+ cessPctBp: number
+ effectiveFrom: IsoDate
+ effectiveTo?: IsoDate
+}
+
+export function resolveTaxClass(rows: TaxClassRow[], classCode: string, onDate: IsoDate): TaxClassRow {
+ let best: TaxClassRow | undefined
+ for (const row of rows) {
+ if (row.classCode !== classCode) continue
+ if (row.effectiveFrom > onDate) continue
+ if (row.effectiveTo !== undefined && row.effectiveTo < onDate) continue
+ if (best === undefined || row.effectiveFrom > best.effectiveFrom) best = row
+ }
+ if (best === undefined) {
+ // Silent defaults on tax are how wrong GST reaches the portal — fail loudly.
+ throw new Error(`No tax rate for class "${classCode}" on ${onDate}`)
+ }
+ return best
+}
diff --git a/packages/billing-engine/test/compute.test.ts b/packages/billing-engine/test/compute.test.ts
new file mode 100644
index 0000000..dd3089a
--- /dev/null
+++ b/packages/billing-engine/test/compute.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from 'vitest'
+import {
+ allocateProRata, computeBill, resolveTaxClass,
+ type LineInput, type TaxClassRow,
+} from '@sims/billing-engine'
+
+// Rates as dated config — includes a GST-2.0-style change on 2025-09-22.
+const RATES: TaxClassRow[] = [
+ { classCode: 'STD18', ratePctBp: 1800, cessPctBp: 0, effectiveFrom: '2017-07-01' },
+ { classCode: 'ZERO', ratePctBp: 0, cessPctBp: 0, effectiveFrom: '2017-07-01' },
+ { classCode: 'FMCG', ratePctBp: 1200, cessPctBp: 0, effectiveFrom: '2017-07-01', effectiveTo: '2025-09-21' },
+ { classCode: 'FMCG', ratePctBp: 500, cessPctBp: 0, effectiveFrom: '2025-09-22' },
+ { classCode: 'DEMERIT', ratePctBp: 2800, cessPctBp: 1200, effectiveFrom: '2017-07-01' },
+]
+
+const INTRA = {
+ businessDate: '2026-07-09',
+ supplyStateCode: '32',
+ placeOfSupplyStateCode: '32',
+ roundToRupee: true,
+}
+
+const line = (over: Partial): LineInput => ({
+ itemId: 'i1',
+ name: 'Item',
+ hsn: '1101',
+ qty: 1,
+ unitCode: 'PCS',
+ unitPricePaise: 10_000,
+ priceIncludesTax: false,
+ taxClassCode: 'STD18',
+ ...over,
+})
+
+describe('resolveTaxClass — dated rates', () => {
+ it('resolves the rate in force on the business date', () => {
+ expect(resolveTaxClass(RATES, 'FMCG', '2025-09-21').ratePctBp).toBe(1200)
+ expect(resolveTaxClass(RATES, 'FMCG', '2025-09-22').ratePctBp).toBe(500)
+ })
+ it('fails loudly when no rate covers the date', () => {
+ expect(() => resolveTaxClass(RATES, 'MISSING', '2026-01-01')).toThrow(/No tax rate/)
+ })
+})
+
+describe('computeBill — golden cases (exact paise)', () => {
+ it('A: MRP-inclusive B2C intra-state, 18% — sticker total is preserved', () => {
+ // ₹285 incl. 18%: taxable = round(28500·10000/11800) = 24153, tax = 4347
+ const { lines, totals } = computeBill(
+ [line({ unitPricePaise: 28_500, priceIncludesTax: true, mrpPaise: 28_500 })],
+ INTRA,
+ RATES,
+ )
+ expect(lines[0]).toMatchObject({
+ taxablePaise: 24_153,
+ cgstPaise: 2_173,
+ sgstPaise: 2_174,
+ igstPaise: 0,
+ lineTotalPaise: 28_500,
+ })
+ expect(totals.payablePaise).toBe(28_500)
+ expect(totals.roundOffPaise).toBe(0)
+ expect(totals.savingsVsMrpPaise).toBe(0)
+ })
+
+ it('B: tax-exclusive B2B inter-state — IGST, no split', () => {
+ const ctx = { ...INTRA, placeOfSupplyStateCode: '27', roundToRupee: false }
+ const { lines, totals } = computeBill([line({})], ctx, RATES)
+ expect(lines[0]).toMatchObject({
+ taxablePaise: 10_000,
+ igstPaise: 1_800,
+ cgstPaise: 0,
+ sgstPaise: 0,
+ lineTotalPaise: 11_800,
+ })
+ expect(totals.payablePaise).toBe(11_800)
+ })
+
+ it('C: fractional weighed qty at 0% — 1.25 kg × ₹32/kg', () => {
+ const { lines } = computeBill(
+ [line({ qty: 1.25, unitCode: 'KG', unitPricePaise: 3_200, taxClassCode: 'ZERO', priceIncludesTax: true })],
+ INTRA,
+ RATES,
+ )
+ expect(lines[0]!.lineTotalPaise).toBe(4_000)
+ expect(lines[0]!.taxablePaise).toBe(4_000)
+ })
+
+ it('D: bill discount allocates pro-rata (largest remainder), taxes recompute per line', () => {
+ const { lines, totals } = computeBill(
+ [
+ line({ unitPricePaise: 28_500, priceIncludesTax: true }),
+ line({ itemId: 'i2', unitPricePaise: 4_000, taxClassCode: 'ZERO', priceIncludesTax: true }),
+ ],
+ { ...INTRA, billDiscountPaise: 1_000 },
+ RATES,
+ )
+ // shares of 1000 over weights 28500:4000 → 877 + 123
+ expect(lines[0]!.discountPaise).toBe(877)
+ expect(lines[1]!.discountPaise).toBe(123)
+ // line 1 net 27623 incl 18% → taxable 23409, tax 4214 split 2107/2107
+ expect(lines[0]).toMatchObject({ taxablePaise: 23_409, cgstPaise: 2_107, sgstPaise: 2_107 })
+ expect(totals.payablePaise).toBe(31_500)
+ expect(totals.discountPaise).toBe(1_000)
+ })
+
+ it('E: rupee round-off — ₹99.50 + 18% = ₹117.41 → payable ₹117.00', () => {
+ const { totals } = computeBill([line({ unitPricePaise: 9_950 })], INTRA, RATES)
+ expect(totals.roundOffPaise).toBe(-41)
+ expect(totals.payablePaise).toBe(11_700)
+ })
+
+ it('F: cess rides on top and splits out exactly (28% + 12% cess)', () => {
+ const { lines } = computeBill([line({ taxClassCode: 'DEMERIT' })], INTRA, RATES)
+ expect(lines[0]).toMatchObject({
+ taxablePaise: 10_000,
+ cessPaise: 1_200,
+ cgstPaise: 1_400,
+ sgstPaise: 1_400,
+ lineTotalPaise: 14_000,
+ })
+ })
+
+ it('G: savings vs MRP reported when selling below sticker', () => {
+ const { totals } = computeBill(
+ [line({ unitPricePaise: 27_000, priceIncludesTax: true, mrpPaise: 28_500 })],
+ INTRA,
+ RATES,
+ )
+ expect(totals.savingsVsMrpPaise).toBe(1_500)
+ })
+
+ it('rejects empty bills, non-positive qty, and negative discounts', () => {
+ expect(() => computeBill([], INTRA, RATES)).toThrow(/empty/)
+ expect(() => computeBill([line({ qty: 0 })], INTRA, RATES)).toThrow(/positive/)
+ expect(() =>
+ computeBill([line({ discount: { kind: 'amount', paise: -5 } })], INTRA, RATES),
+ ).toThrow(/non-negative/)
+ })
+})
+
+describe('allocateProRata', () => {
+ it('always sums exactly to the amount', () => {
+ for (const [amount, weights] of [
+ [1000, [28_500, 4_000]],
+ [999, [1, 1, 1]],
+ [7, [100, 200, 300, 400]],
+ ] as const) {
+ const shares = allocateProRata(amount, [...weights])
+ expect(shares.reduce((a, s) => a + s, 0)).toBe(amount)
+ }
+ })
+ it('refuses to drop money on zero total weight', () => {
+ expect(() => allocateProRata(100, [0, 0])).toThrow(/zero total weight/)
+ })
+})
diff --git a/packages/config/package.json b/packages/config/package.json
new file mode 100644
index 0000000..25612f0
--- /dev/null
+++ b/packages/config/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@sims/config",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts"
+}
diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts
new file mode 100644
index 0000000..d38ba2f
--- /dev/null
+++ b/packages/config/src/index.ts
@@ -0,0 +1,2 @@
+export * from './settings'
+export * from './messages'
diff --git a/packages/config/src/messages.ts b/packages/config/src/messages.ts
new file mode 100644
index 0000000..0f2a3d9
--- /dev/null
+++ b/packages/config/src/messages.ts
@@ -0,0 +1,40 @@
+/**
+ * Every user-facing string is a DB row (07-DB-AND-CONFIG §2) — notifications,
+ * errors, labels — per language and channel. The frontend renders codes; the
+ * catalog syncs like any master, so language never depends on the network.
+ */
+export interface MessageRow {
+ code: string
+ channel: 'ui' | 'print' | 'whatsapp' | 'sms'
+ lang: string
+ template: string
+}
+
+export interface LanguagePrefs {
+ userLang?: string
+ storeLang?: string
+}
+
+/** Fallback chain: user language → store default → English → undefined. */
+export function resolveMessage(
+ rows: MessageRow[],
+ code: string,
+ channel: MessageRow['channel'],
+ prefs: LanguagePrefs,
+): { template: string; lang: string } | undefined {
+ const chain = [prefs.userLang, prefs.storeLang, 'en'].filter(
+ (l, i, all): l is string => l !== undefined && all.indexOf(l) === i,
+ )
+ for (const lang of chain) {
+ const row = rows.find((r) => r.code === code && r.channel === channel && r.lang === lang)
+ if (row !== undefined) return { template: row.template, lang }
+ }
+ return undefined
+}
+
+/** Minimal {placeholder} interpolation; missing vars render as the raw key. */
+export function renderTemplate(template: string, vars: Record): string {
+ return template.replace(/\{(\w+)\}/g, (_, name: string) =>
+ name in vars ? String(vars[name]) : `{${name}}`,
+ )
+}
diff --git a/packages/config/src/settings.ts b/packages/config/src/settings.ts
new file mode 100644
index 0000000..df3637b
--- /dev/null
+++ b/packages/config/src/settings.ts
@@ -0,0 +1,58 @@
+/**
+ * The settings hierarchy (11-ADMIN-SUPPORT-CONSOLE §2): nearest scope wins,
+ * user → counter → store → tenant → system. Rows are effective-dated; within a
+ * scope the latest effectiveFrom not after the target date applies.
+ */
+export type SettingScope = 'system' | 'tenant' | 'store' | 'counter' | 'user'
+
+export interface SettingRow {
+ scopeType: SettingScope
+ /** '' for system scope. */
+ scopeId: string
+ key: string
+ value: string
+ effectiveFrom?: string // YYYY-MM-DD; undefined = always
+}
+
+export interface SettingContext {
+ tenantId?: string
+ storeId?: string
+ counterId?: string
+ userId?: string
+}
+
+const PRECEDENCE: SettingScope[] = ['user', 'counter', 'store', 'tenant', 'system']
+
+export function resolveSetting(
+ rows: SettingRow[],
+ key: string,
+ ctx: SettingContext,
+ onDate: string,
+): { value: string; scope: SettingScope } | undefined {
+ for (const scope of PRECEDENCE) {
+ const scopeId =
+ scope === 'system'
+ ? ''
+ : scope === 'tenant'
+ ? ctx.tenantId
+ : scope === 'store'
+ ? ctx.storeId
+ : scope === 'counter'
+ ? ctx.counterId
+ : ctx.userId
+ if (scopeId === undefined) continue
+ let best: SettingRow | undefined
+ for (const row of rows) {
+ if (row.key !== key || row.scopeType !== scope || row.scopeId !== scopeId) continue
+ if (row.effectiveFrom !== undefined && row.effectiveFrom > onDate) continue
+ if (
+ best === undefined ||
+ (row.effectiveFrom ?? '0000-00-00') > (best.effectiveFrom ?? '0000-00-00')
+ ) {
+ best = row
+ }
+ }
+ if (best !== undefined) return { value: best.value, scope }
+ }
+ return undefined
+}
diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts
new file mode 100644
index 0000000..093f287
--- /dev/null
+++ b/packages/config/test/config.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import { resolveMessage, renderTemplate, resolveSetting, type MessageRow, type SettingRow } from '@sims/config'
+
+const CTX = { tenantId: 't1', storeId: 's1', counterId: 'c1', userId: 'u1' }
+
+describe('settings hierarchy — nearest scope wins', () => {
+ const rows: SettingRow[] = [
+ { scopeType: 'system', scopeId: '', key: 'discount.capBp', value: '1000' },
+ { scopeType: 'tenant', scopeId: 't1', key: 'discount.capBp', value: '1500' },
+ { scopeType: 'store', scopeId: 's1', key: 'discount.capBp', value: '500' },
+ ]
+ it('store overrides tenant overrides system', () => {
+ expect(resolveSetting(rows, 'discount.capBp', CTX, '2026-07-09')).toEqual({
+ value: '500',
+ scope: 'store',
+ })
+ })
+ it('falls through when the nearer scope has no row', () => {
+ const noStore = { ...CTX, storeId: 's-other' }
+ expect(resolveSetting(rows, 'discount.capBp', noStore, '2026-07-09')?.value).toBe('1500')
+ })
+ it('system default applies when nothing overrides', () => {
+ expect(resolveSetting(rows, 'discount.capBp', { tenantId: 'tx' }, '2026-07-09')?.value).toBe('1000')
+ })
+ it('returns undefined for unknown keys', () => {
+ expect(resolveSetting(rows, 'nope', CTX, '2026-07-09')).toBeUndefined()
+ })
+})
+
+describe('settings — effective dating', () => {
+ const rows: SettingRow[] = [
+ { scopeType: 'system', scopeId: '', key: 'gst.roundToRupee', value: 'true' },
+ { scopeType: 'system', scopeId: '', key: 'gst.roundToRupee', value: 'false', effectiveFrom: '2026-08-01' },
+ ]
+ it('picks the latest row not after the target date', () => {
+ expect(resolveSetting(rows, 'gst.roundToRupee', {}, '2026-07-31')?.value).toBe('true')
+ expect(resolveSetting(rows, 'gst.roundToRupee', {}, '2026-08-01')?.value).toBe('false')
+ })
+})
+
+describe('message catalog — language fallback chain', () => {
+ const rows: MessageRow[] = [
+ { code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'en', template: 'Hi {name}, your bill of {amount}' },
+ { code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'hi', template: 'नमस्ते {name}, आपका बिल {amount}' },
+ { code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'ml', template: 'നമസ്കാരം {name}, ബിൽ {amount}' },
+ ]
+ it('prefers user language, then store, then English', () => {
+ expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ml' })?.lang).toBe('ml')
+ expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ta', storeLang: 'hi' })?.lang).toBe('hi')
+ expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ta' })?.lang).toBe('en')
+ })
+ it('returns undefined for unknown codes (caller surfaces the raw key in dev)', () => {
+ expect(resolveMessage(rows, 'NOPE', 'whatsapp', {})).toBeUndefined()
+ })
+ it('renders placeholders; missing vars stay visible', () => {
+ expect(renderTemplate('Hi {name}, bill {amount}', { name: 'Lakshmi', amount: '₹1,431.00' })).toBe(
+ 'Hi Lakshmi, bill ₹1,431.00',
+ )
+ expect(renderTemplate('Hi {name}', {})).toBe('Hi {name}')
+ })
+})
diff --git a/packages/domain/package.json b/packages/domain/package.json
new file mode 100644
index 0000000..e98ed78
--- /dev/null
+++ b/packages/domain/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@sims/domain",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts"
+}
diff --git a/packages/domain/src/business-day.ts b/packages/domain/src/business-day.ts
new file mode 100644
index 0000000..53bc0d4
--- /dev/null
+++ b/packages/domain/src/business-day.ts
@@ -0,0 +1,32 @@
+/**
+ * The working-day concept carried over from the OG applications (Day Begin /
+ * Day End in the Manager Menu): every dated record stamps the store's opened
+ * business date, never the machine clock. The wall clock is display metadata.
+ */
+export type IsoDate = string // YYYY-MM-DD
+
+export interface BusinessDay {
+ tenantId: string
+ storeId: string
+ date: IsoDate
+ openedAt: string
+ openedBy: string
+ closedAt?: string
+ closedBy?: string
+}
+
+export function isOpen(day: BusinessDay): boolean {
+ return day.closedAt === undefined
+}
+
+/** Indian financial year label: 2026-04-01 → "2026-27", 2026-03-31 → "2025-26". */
+export function fyOf(date: IsoDate, fyStartMonth = 4): string {
+ const [y, m] = [Number(date.slice(0, 4)), Number(date.slice(5, 7))]
+ const start = m >= fyStartMonth ? y : y - 1
+ return `${start}-${String(start + 1).slice(2)}`
+}
+
+/** Short FY marker for document series prefixes: "2026-27" → "26". */
+export function fyShort(fy: string): string {
+ return fy.slice(2, 4)
+}
diff --git a/packages/domain/src/doc-series.ts b/packages/domain/src/doc-series.ts
new file mode 100644
index 0000000..06c35f5
--- /dev/null
+++ b/packages/domain/src/doc-series.ts
@@ -0,0 +1,44 @@
+/**
+ * Per-counter, per-FY document series (02-ARCHITECTURE §3 rule 3): counters
+ * number documents offline with zero coordination. GST caps a document number
+ * at 16 chars from the set [A-Z a-z 0-9 - /].
+ */
+export interface DocSeries {
+ tenantId: string
+ storeId: string
+ counterId: string
+ docType: string
+ fy: string
+ prefix: string
+ nextSeq: number
+}
+
+const GST_DOC_NO = /^[A-Za-z0-9/-]{1,16}$/
+
+export function assertGstDocNo(docNo: string): void {
+ if (!GST_DOC_NO.test(docNo)) {
+ throw new Error(`Document number "${docNo}" violates the GST 16-char [A-Za-z0-9/-] rule`)
+ }
+}
+
+export function formatDocNo(prefix: string, seq: number, width = 6): string {
+ const docNo = `${prefix}-${String(seq).padStart(width, '0')}`
+ assertGstDocNo(docNo)
+ return docNo
+}
+
+/** e.g. seriesPrefix("ST1", "C2", "26") → "ST1C2/26" */
+export function seriesPrefix(storeCode: string, counterCode: string, fyShortMark: string): string {
+ return `${storeCode}${counterCode}/${fyShortMark}`
+}
+
+export function nextDocNo(series: DocSeries): { docNo: string; series: DocSeries } {
+ const docNo = formatDocNo(series.prefix, series.nextSeq)
+ return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } }
+}
+
+/** FY rollover starts a fresh series; the old one is retained for returns lookups. */
+export function rolloverForFy(series: DocSeries, fy: string, prefix: string): DocSeries {
+ if (fy === series.fy) return series
+ return { ...series, fy, prefix, nextSeq: 1 }
+}
diff --git a/packages/domain/src/documents.ts b/packages/domain/src/documents.ts
new file mode 100644
index 0000000..e0e012f
--- /dev/null
+++ b/packages/domain/src/documents.ts
@@ -0,0 +1,72 @@
+import type { Paise } from './money'
+import type { IsoDate } from './business-day'
+
+/**
+ * Documents are immutable events (02-ARCHITECTURE §3 rule 2). A committed bill
+ * is never edited; corrections are new documents referencing the original.
+ */
+export type DocType = 'SALE' | 'SALE_RETURN' | 'PURCHASE' | 'PURCHASE_RETURN' | 'STOCK_ADJUST'
+
+export interface BillLine {
+ itemId: string
+ name: string
+ hsn: string
+ qty: number
+ unitCode: string
+ unitPricePaise: Paise
+ mrpPaise?: Paise
+ grossPaise: Paise
+ discountPaise: Paise
+ taxablePaise: Paise
+ taxRateBp: number
+ cgstPaise: Paise
+ sgstPaise: Paise
+ igstPaise: Paise
+ cessPaise: Paise
+ lineTotalPaise: Paise
+ batchId?: string
+}
+
+export type PaymentMode = 'CASH' | 'UPI' | 'CARD' | 'KHATA'
+
+export interface Payment {
+ mode: PaymentMode
+ amountPaise: Paise
+ reference?: string
+}
+
+export interface BillTotals {
+ grossPaise: Paise
+ discountPaise: Paise
+ taxablePaise: Paise
+ cgstPaise: Paise
+ sgstPaise: Paise
+ igstPaise: Paise
+ cessPaise: Paise
+ roundOffPaise: Paise
+ payablePaise: Paise
+ savingsVsMrpPaise: Paise
+}
+
+export interface BillDoc {
+ id: string // uuidv7, client-generated
+ tenantId: string
+ storeId: string
+ counterId: string
+ docType: DocType
+ docNo: string
+ /** Working date from Day Begin — never the machine clock. */
+ businessDate: IsoDate
+ cashierId: string
+ shiftId: string
+ customerId?: string
+ refDocId?: string // returns/corrections point at the original
+ lines: BillLine[]
+ payments: Payment[]
+ totals: BillTotals
+ /** Wall clock is display metadata only; ordering uses (lamport, deviceId). */
+ createdAtWallClock: string
+ lamport: number
+ deviceId: string
+ deviceSeq: number
+}
diff --git a/packages/domain/src/gstin.ts b/packages/domain/src/gstin.ts
new file mode 100644
index 0000000..d99bb2c
--- /dev/null
+++ b/packages/domain/src/gstin.ts
@@ -0,0 +1,60 @@
+const GSTIN_SHAPE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/
+const B36 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+
+/** Standard GSTIN check digit: alternating 1/2 factors over base-36 values. */
+export function gstinCheckDigit(first14: string): string {
+ let sum = 0
+ for (let i = 0; i < 14; i++) {
+ const v = B36.indexOf(first14[i]!)
+ const product = v * (i % 2 === 0 ? 1 : 2)
+ sum += Math.trunc(product / 36) + (product % 36)
+ }
+ return B36[(36 - (sum % 36)) % 36]!
+}
+
+export function validateGstin(gstin: string): { ok: boolean; stateCode?: string; reason?: string } {
+ const g = gstin.toUpperCase().trim()
+ if (!GSTIN_SHAPE.test(g)) return { ok: false, reason: 'shape' }
+ if (gstinCheckDigit(g.slice(0, 14)) !== g[14]) return { ok: false, reason: 'checksum' }
+ return { ok: true, stateCode: g.slice(0, 2) }
+}
+
+/** Minimal buyer identity a place-of-supply decision needs. */
+export interface SupplyCustomer {
+ gstin?: string
+ /** 2-digit GST state code; when absent it is read from the GSTIN's first two digits. */
+ stateCode?: string
+}
+
+export interface SupplyDecision {
+ /** True when the buyer carries a GSTIN — a registered B2B sale. */
+ b2b: boolean
+ /** GST state code that is the place of supply (buyer's for B2B, the store's for B2C). */
+ placeOfSupplyStateCode: string
+ /** Place of supply differs from the store ⇒ the IGST path; else CGST+SGST. */
+ interState: boolean
+ /** Buyer GSTIN snapshot (upper-cased) when this is a B2B sale. */
+ buyerGstin?: string
+}
+
+/**
+ * Place-of-supply decision at the counter — the single rule the POS and the
+ * store server must agree on so their engine runs match to the paisa (R5).
+ * A buyer with a GSTIN is B2B: place of supply is the buyer's state (an explicit
+ * stateCode, else the GSTIN's first two digits). Everyone else is a B2C
+ * over-the-counter sale — place of supply is the store. Inter-state ⇒ IGST.
+ */
+export function deriveSupply(customer: SupplyCustomer | undefined, storeStateCode: string): SupplyDecision {
+ const gstin = customer?.gstin?.toUpperCase().trim()
+ const b2b = gstin !== undefined && gstin !== ''
+ const buyerState = customer?.stateCode !== undefined && customer.stateCode !== ''
+ ? customer.stateCode
+ : b2b ? gstin.slice(0, 2) : undefined
+ const placeOfSupplyStateCode = b2b && buyerState !== undefined ? buyerState : storeStateCode
+ return {
+ b2b,
+ placeOfSupplyStateCode,
+ interState: placeOfSupplyStateCode !== storeStateCode,
+ ...(b2b ? { buyerGstin: gstin } : {}),
+ }
+}
diff --git a/packages/domain/src/ids.ts b/packages/domain/src/ids.ts
new file mode 100644
index 0000000..b033e81
--- /dev/null
+++ b/packages/domain/src/ids.ts
@@ -0,0 +1,25 @@
+/**
+ * UUIDv7: 48-bit unix-ms timestamp + random. Time-ordered so ids sort roughly
+ * by creation across devices — the property the sync design (02-ARCHITECTURE §3)
+ * relies on for cheap cursor pagination. Client-generated, never server-assigned.
+ * Web Crypto keeps this package loadable in both Node and the POS renderer.
+ */
+export function uuidv7(now: number = Date.now()): string {
+ const b = new Uint8Array(16)
+ globalThis.crypto.getRandomValues(b)
+ b[0] = (now / 2 ** 40) & 0xff
+ b[1] = (now / 2 ** 32) & 0xff
+ b[2] = (now / 2 ** 24) & 0xff
+ b[3] = (now / 2 ** 16) & 0xff
+ b[4] = (now / 2 ** 8) & 0xff
+ b[5] = now & 0xff
+ b[6] = 0x70 | (b[6]! & 0x0f)
+ b[8] = 0x80 | (b[8]! & 0x3f)
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
+}
+
+export function timestampOfUuidv7(id: string): number {
+ const hex = id.replaceAll('-', '').slice(0, 12)
+ return Number.parseInt(hex, 16)
+}
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
new file mode 100644
index 0000000..748cb46
--- /dev/null
+++ b/packages/domain/src/index.ts
@@ -0,0 +1,9 @@
+export * from './ids'
+export * from './money'
+export * from './tenancy'
+export * from './business-day'
+export * from './doc-series'
+export * from './gstin'
+export * from './masters'
+export * from './documents'
+export * from './outbox'
diff --git a/packages/domain/src/masters.ts b/packages/domain/src/masters.ts
new file mode 100644
index 0000000..7c24ad9
--- /dev/null
+++ b/packages/domain/src/masters.ts
@@ -0,0 +1,40 @@
+import type { Paise } from './money'
+
+export interface Unit {
+ code: string // PCS, KG, BOX…
+ /** Decimal places allowed in a quantity of this unit (KG → 3, PCS → 0). */
+ decimals: number
+}
+
+export interface Item {
+ id: string
+ tenantId: string
+ code: string
+ name: string
+ /** Optional regional-script secondary name, shown under the primary on POS. */
+ nameSecondary?: string
+ hsn: string
+ taxClassCode: string
+ unit: Unit
+ mrpPaise?: Paise
+ salePricePaise: Paise
+ /** Sale price semantics; Indian B2C retail is normally tax-inclusive. */
+ priceIncludesTax: boolean
+ barcodes: string[]
+ batchTracked: boolean
+ /** Draft items come from the POS unknown-barcode quick-add, pending completion. */
+ status: 'active' | 'draft' | 'inactive'
+}
+
+export interface Party {
+ id: string
+ tenantId: string
+ code: string
+ name: string
+ kind: 'customer' | 'supplier'
+ phone?: string
+ gstin?: string
+ /** 2-digit GST state code; drives place-of-supply on B2B documents. */
+ stateCode?: string
+ creditLimitPaise?: Paise
+}
diff --git a/packages/domain/src/money.ts b/packages/domain/src/money.ts
new file mode 100644
index 0000000..7860d4d
--- /dev/null
+++ b/packages/domain/src/money.ts
@@ -0,0 +1,73 @@
+/** All money is integer paise. Fractional-rupee floats never enter the domain. */
+export type Paise = number
+
+export function fromRupees(rupees: number): Paise {
+ return Math.round(rupees * 100)
+}
+
+/** Indian digit grouping: 12,34,567.89 */
+export function formatINR(amount: Paise, opts: { symbol?: boolean } = {}): string {
+ const sign = amount < 0 ? '-' : ''
+ const abs = Math.abs(Math.round(amount))
+ const whole = String(Math.trunc(abs / 100))
+ const frac = String(abs % 100).padStart(2, '0')
+ const grouped =
+ whole.length <= 3
+ ? whole
+ : whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ',') + ',' + whole.slice(-3)
+ return `${sign}${opts.symbol === false ? '' : '₹'}${grouped}.${frac}`
+}
+
+const ONES = [
+ '', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten',
+ 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen',
+]
+const TENS = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety']
+
+function below100(n: number): string {
+ if (n < 20) return ONES[n]!
+ const t = TENS[Math.floor(n / 10)]!
+ const o = n % 10
+ return o === 0 ? t : `${t} ${ONES[o]!}`
+}
+
+function below1000(n: number): string {
+ const h = Math.floor(n / 100)
+ const r = n % 100
+ const parts: string[] = []
+ if (h > 0) parts.push(`${ONES[h]!} Hundred`)
+ if (r > 0) parts.push(below100(r))
+ return parts.join(' ')
+}
+
+/** Whole non-negative rupees to Indian-numbering words (crore/lakh/thousand). */
+function indianWords(rupees: number): string {
+ if (rupees === 0) return 'Zero'
+ let n = rupees
+ const crore = Math.floor(n / 10_000_000); n %= 10_000_000
+ const lakh = Math.floor(n / 100_000); n %= 100_000
+ const thousand = Math.floor(n / 1000); n %= 1000
+ const parts: string[] = []
+ if (crore > 0) parts.push(`${below1000(crore)} Crore`)
+ if (lakh > 0) parts.push(`${below100(lakh)} Lakh`)
+ if (thousand > 0) parts.push(`${below100(thousand)} Thousand`)
+ if (n > 0) parts.push(below1000(n))
+ return parts.join(' ')
+}
+
+/**
+ * Integer paise to an Indian-numbering "amount in words" string for GST invoices
+ * (e.g. 1,00,00,000 paise → "Rupees One Lakh Only"). Rupees and paise are named
+ * separately; "Paisa" is singular. The `formatINR` sibling for the numeric form.
+ */
+export function amountInWordsINR(paise: Paise): string {
+ const sign = paise < 0 ? 'Minus ' : ''
+ const abs = Math.abs(Math.round(paise))
+ const rupees = Math.trunc(abs / 100)
+ const p = abs % 100
+ const paiseWord = p === 1 ? 'Paisa' : 'Paise'
+ if (rupees > 0 && p > 0) return `${sign}Rupees ${indianWords(rupees)} and ${below100(p)} ${paiseWord} Only`
+ if (rupees > 0) return `${sign}Rupees ${indianWords(rupees)} Only`
+ if (p > 0) return `${sign}${below100(p)} ${paiseWord} Only`
+ return 'Rupees Zero Only'
+}
diff --git a/packages/domain/src/outbox.ts b/packages/domain/src/outbox.ts
new file mode 100644
index 0000000..39ebf42
--- /dev/null
+++ b/packages/domain/src/outbox.ts
@@ -0,0 +1,18 @@
+/**
+ * Outbox rows are written in the same local transaction as every business
+ * change from the FIRST local-only build (D14): dormant until the cloud tier
+ * exists, at which point a sync agent drains them. Cloud enablement must be a
+ * switch-on, never a data migration.
+ */
+export interface OutboxRow {
+ opId: string // uuidv7 — the idempotency key
+ tenantId: string
+ deviceId: string
+ deviceSeq: number
+ docType: string
+ docId: string
+ /** Whole-document changeset envelope — row-level sync is forbidden by design. */
+ envelope: unknown
+ createdAtWallClock: string
+ lamport: number
+}
diff --git a/packages/domain/src/tenancy.ts b/packages/domain/src/tenancy.ts
new file mode 100644
index 0000000..538373d
--- /dev/null
+++ b/packages/domain/src/tenancy.ts
@@ -0,0 +1,58 @@
+/**
+ * Tenancy is a data property from the first row, not a cloud feature (D14 guardrail).
+ * Classic has no tenant concept; here every record carries tenantId even though a
+ * local v1 install holds exactly one tenant — minted at provisioning, invisible in
+ * the UI, and the thing that makes the later cloud merge a switch-on instead of a
+ * re-keying migration.
+ */
+export interface Tenant {
+ id: string
+ code: string
+ name: string
+ gstin?: string
+ gstScheme: 'regular' | 'composite'
+ /** Indian FY starts April; kept as data for the day that assumption breaks. */
+ fyStartMonth: number
+ createdAt: string
+}
+
+export interface Store {
+ id: string
+ tenantId: string
+ code: string
+ name: string
+ /** 2-digit GST state code; decides CGST/SGST vs IGST at billing time. */
+ stateCode: string
+ address?: string
+ active: boolean
+}
+
+export interface Counter {
+ id: string
+ tenantId: string
+ storeId: string
+ code: string
+ name: string
+ active: boolean
+}
+
+export type RoleCode =
+ | 'owner'
+ | 'manager'
+ | 'supervisor'
+ | 'cashier'
+ | 'purchaser'
+ | 'accountant'
+ | 'auditor'
+
+export interface User {
+ id: string
+ tenantId: string
+ username: string
+ displayName: string
+ roles: RoleCode[]
+ /** 'all' or explicit store scoping. */
+ storeIds: 'all' | string[]
+ language: string
+ active: boolean
+}
diff --git a/packages/domain/test/domain.test.ts b/packages/domain/test/domain.test.ts
new file mode 100644
index 0000000..e155524
--- /dev/null
+++ b/packages/domain/test/domain.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest'
+import {
+ formatINR, fyOf, fyShort, uuidv7, timestampOfUuidv7,
+ formatDocNo, nextDocNo, rolloverForFy, seriesPrefix, assertGstDocNo,
+ gstinCheckDigit, validateGstin, deriveSupply,
+ type DocSeries,
+} from '@sims/domain'
+
+describe('money — Indian grouping', () => {
+ it('groups lakh/crore style', () => {
+ expect(formatINR(123_456_789)).toBe('₹12,34,567.89')
+ expect(formatINR(100)).toBe('₹1.00')
+ expect(formatINR(-4_50)).toBe('-₹4.50')
+ expect(formatINR(99_999, { symbol: false })).toBe('999.99')
+ })
+})
+
+describe('financial year (Day Begin idiom)', () => {
+ it('splits at 1 April', () => {
+ expect(fyOf('2026-03-31')).toBe('2025-26')
+ expect(fyOf('2026-04-01')).toBe('2026-27')
+ expect(fyShort('2026-27')).toBe('26')
+ })
+})
+
+describe('uuidv7', () => {
+ it('is well-formed and carries its timestamp', () => {
+ const at = 1_750_000_000_000
+ const id = uuidv7(at)
+ expect(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(timestampOfUuidv7(id)).toBe(at)
+ })
+ it('sorts by creation time', () => {
+ expect(uuidv7(1000) < uuidv7(2000)).toBe(true)
+ })
+})
+
+describe('document series — offline per-counter numbering', () => {
+ const series: DocSeries = {
+ tenantId: 't', storeId: 's', counterId: 'c', docType: 'SALE',
+ fy: '2026-27', prefix: seriesPrefix('ST1', 'C2', '26'), nextSeq: 123,
+ }
+ it('formats within the GST 16-char rule', () => {
+ const { docNo, series: bumped } = nextDocNo(series)
+ expect(docNo).toBe('ST1C2/26-000123')
+ expect(docNo.length).toBeLessThanOrEqual(16)
+ expect(bumped.nextSeq).toBe(124)
+ })
+ it('rejects numbers that break the GST rule', () => {
+ expect(() => assertGstDocNo('THIS/PREFIX/IS/TOO/LONG-000001')).toThrow(/16-char/)
+ expect(() => formatDocNo('BAD_CHAR', 1)).toThrow(/16-char/) // underscore not allowed
+ })
+ it('FY rollover restarts the sequence', () => {
+ const rolled = rolloverForFy(series, '2027-28', seriesPrefix('ST1', 'C2', '27'))
+ expect(rolled.nextSeq).toBe(1)
+ expect(rolloverForFy(series, '2026-27', series.prefix)).toBe(series)
+ })
+})
+
+describe('GSTIN validation', () => {
+ const body = '32ABCDE1234F1Z'
+ const valid = body + gstinCheckDigit(body)
+ it('accepts a checksum-correct GSTIN and extracts the state', () => {
+ expect(validateGstin(valid)).toEqual({ ok: true, stateCode: '32' })
+ })
+ it('rejects wrong checksum and wrong shape', () => {
+ const flipped = body + (valid[14] === 'A' ? 'B' : 'A')
+ expect(validateGstin(flipped).ok).toBe(false)
+ expect(validateGstin('not-a-gstin').ok).toBe(false)
+ })
+})
+
+describe('deriveSupply — place of supply at the counter', () => {
+ const store = '32' // MegaMart's state
+ const intraGstin = '32ABCDE1234F1Z' + gstinCheckDigit('32ABCDE1234F1Z')
+ const interGstin = '27ABCDE1234F1Z' + gstinCheckDigit('27ABCDE1234F1Z')
+
+ it('no GSTIN → B2C, place of supply is the store state (intra)', () => {
+ expect(deriveSupply(undefined, store)).toMatchObject({ b2b: false, placeOfSupplyStateCode: '32', interState: false })
+ // A walk-in with only a state on file is still B2C — place of supply stays the store.
+ expect(deriveSupply({ stateCode: '27' }, store)).toMatchObject({ b2b: false, placeOfSupplyStateCode: '32', interState: false })
+ })
+ it('GSTIN in the same state → B2B intra (CGST/SGST)', () => {
+ expect(deriveSupply({ gstin: intraGstin }, store)).toMatchObject({
+ b2b: true, placeOfSupplyStateCode: '32', interState: false, buyerGstin: intraGstin,
+ })
+ })
+ it('GSTIN in another state → B2B inter (IGST)', () => {
+ expect(deriveSupply({ gstin: interGstin }, store)).toMatchObject({
+ b2b: true, placeOfSupplyStateCode: '27', interState: true, buyerGstin: interGstin,
+ })
+ })
+})
diff --git a/packages/printing/package.json b/packages/printing/package.json
new file mode 100644
index 0000000..72b4627
--- /dev/null
+++ b/packages/printing/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@sims/printing",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "dependencies": {
+ "@sims/domain": "*",
+ "@sims/scanning": "*"
+ }
+}
diff --git a/packages/printing/src/escpos.ts b/packages/printing/src/escpos.ts
new file mode 100644
index 0000000..768d269
--- /dev/null
+++ b/packages/printing/src/escpos.ts
@@ -0,0 +1,82 @@
+const ESC = 0x1b
+const GS = 0x1d
+
+/**
+ * ESC/POS byte builder — pure, so receipt layout is testable without a
+ * printer. Spike scope is ASCII text mode; Indic scripts rasterize in
+ * bit-image mode later (09-UX §5.3 print policy).
+ */
+export class EscPos {
+ private bytes: number[] = []
+
+ init(): this {
+ this.bytes.push(ESC, 0x40)
+ return this
+ }
+
+ /** Non-ASCII collapses to '?' in text mode — raster path handles scripts. */
+ text(s: string): this {
+ for (const ch of s) {
+ const code = ch.codePointAt(0)!
+ this.bytes.push(code >= 0x20 && code <= 0x7e ? code : 0x3f)
+ }
+ return this
+ }
+
+ line(s = ''): this {
+ return this.text(s).newline()
+ }
+
+ newline(): this {
+ this.bytes.push(0x0a)
+ return this
+ }
+
+ align(where: 'left' | 'center' | 'right'): this {
+ this.bytes.push(ESC, 0x61, where === 'left' ? 0 : where === 'center' ? 1 : 2)
+ return this
+ }
+
+ bold(on: boolean): this {
+ this.bytes.push(ESC, 0x45, on ? 1 : 0)
+ return this
+ }
+
+ /** Character cell multiplier 1–8 each axis (GS !). */
+ size(w: number, h: number): this {
+ this.bytes.push(GS, 0x21, ((w - 1) << 4) | (h - 1))
+ return this
+ }
+
+ feed(lines: number): this {
+ this.bytes.push(ESC, 0x64, lines)
+ return this
+ }
+
+ /** Partial cut with pre-feed (GS V 66). */
+ cut(): this {
+ this.bytes.push(GS, 0x56, 66, 3)
+ return this
+ }
+
+ /** Cash-drawer kick pulse on pin 2 (ESC p). Fires on payment, not on print. */
+ drawerKick(): this {
+ this.bytes.push(ESC, 0x70, 0, 25, 250)
+ return this
+ }
+
+ build(): Uint8Array {
+ return Uint8Array.from(this.bytes)
+ }
+}
+
+/** Left/right column row padded to exactly `width` chars; left side truncates. */
+export function twoCol(left: string, right: string, width: number): string {
+ const room = width - right.length - 1
+ const l = left.length > room ? left.slice(0, Math.max(0, room)) : left
+ return l + ' '.repeat(Math.max(1, width - l.length - right.length)) + right
+}
+
+export function hr(width: number): string {
+ return '-'.repeat(width)
+}
diff --git a/packages/printing/src/index.ts b/packages/printing/src/index.ts
new file mode 100644
index 0000000..73bb4e0
--- /dev/null
+++ b/packages/printing/src/index.ts
@@ -0,0 +1,5 @@
+export * from './escpos'
+export * from './receipt'
+export * from './invoice-html'
+export * from './labels'
+export * from './raster'
diff --git a/packages/printing/src/invoice-html.ts b/packages/printing/src/invoice-html.ts
new file mode 100644
index 0000000..59fddbd
--- /dev/null
+++ b/packages/printing/src/invoice-html.ts
@@ -0,0 +1,245 @@
+import { amountInWordsINR, formatINR, type BillLine, type BillTotals } from '@sims/domain'
+
+/**
+ * A4 GST tax invoice as a self-contained, printable HTML document (09-UX §5.3:
+ * "bilingual default for A4"). Pure — no DOM, no fetch — so the back office can
+ * open it in a new window and the same string is unit-testable. The thermal
+ * receipt (receipt.ts) stays the B2C fast path; this is the B2B/GST document.
+ */
+
+export interface InvoiceParty {
+ name: string
+ gstin?: string
+ address?: string
+ stateCode?: string
+ phone?: string
+}
+
+export interface InvoiceDoc {
+ seller: InvoiceParty
+ buyer?: InvoiceParty
+ /** GST invoice number (the per-counter series doc no). */
+ invoiceNo: string
+ invoiceDate: string
+ /** State code of supply; drives CGST/SGST vs IGST reading on the print. */
+ placeOfSupplyStateCode?: string
+ lines: BillLine[]
+ totals: BillTotals
+ paymentLabel?: string
+}
+
+export interface InvoiceOptions {
+ /** Document heading; "Tax Invoice" for registered sellers, else "Bill of Supply". */
+ title?: string
+ /** Bilingual footer note; a sensible GST default is used when omitted. */
+ footerNote?: string
+ /** Set false to omit the on-page Print button (e.g. embedding). Default true. */
+ printButton?: boolean
+}
+
+/** GST state codes → names, for a readable "Place of Supply" line. */
+const STATE_NAMES: Record = {
+ '01': 'Jammu & Kashmir', '02': 'Himachal Pradesh', '03': 'Punjab', '04': 'Chandigarh',
+ '05': 'Uttarakhand', '06': 'Haryana', '07': 'Delhi', '08': 'Rajasthan', '09': 'Uttar Pradesh',
+ '10': 'Bihar', '11': 'Sikkim', '12': 'Arunachal Pradesh', '13': 'Nagaland', '14': 'Manipur',
+ '15': 'Mizoram', '16': 'Tripura', '17': 'Meghalaya', '18': 'Assam', '19': 'West Bengal',
+ '20': 'Jharkhand', '21': 'Odisha', '22': 'Chhattisgarh', '23': 'Madhya Pradesh', '24': 'Gujarat',
+ '26': 'Dadra & Nagar Haveli and Daman & Diu', '27': 'Maharashtra', '28': 'Andhra Pradesh (old)',
+ '29': 'Karnataka', '30': 'Goa', '31': 'Lakshadweep', '32': 'Kerala', '33': 'Tamil Nadu',
+ '34': 'Puducherry', '35': 'Andaman & Nicobar Islands', '36': 'Telangana', '37': 'Andhra Pradesh',
+ '38': 'Ladakh', '97': 'Other Territory',
+}
+
+const esc = (s: string): string =>
+ s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
+
+const inr = (p: number): string => formatINR(p, { symbol: false })
+
+function placeOfSupply(code: string | undefined): string {
+ if (code === undefined || code === '') return '—'
+ const name = STATE_NAMES[code]
+ return name !== undefined ? `${code} — ${name}` : code
+}
+
+interface HsnBucket {
+ hsn: string
+ taxRateBp: number
+ taxablePaise: number
+ cgstPaise: number
+ sgstPaise: number
+ igstPaise: number
+ cessPaise: number
+}
+
+/** Collapse lines into an HSN + rate summary — the table the GST return wants. */
+function hsnBuckets(lines: BillLine[]): HsnBucket[] {
+ const map = new Map()
+ for (const l of lines) {
+ const key = `${l.hsn}|${l.taxRateBp}`
+ const b = map.get(key) ?? { hsn: l.hsn, taxRateBp: l.taxRateBp, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, cessPaise: 0 }
+ b.taxablePaise += l.taxablePaise
+ b.cgstPaise += l.cgstPaise
+ b.sgstPaise += l.sgstPaise
+ b.igstPaise += l.igstPaise
+ b.cessPaise += l.cessPaise
+ map.set(key, b)
+ }
+ return [...map.values()]
+}
+
+const STYLE = `
+ :root { color-scheme: light; }
+ * { box-sizing: border-box; }
+ body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; color: #111; margin: 0; background: #f4f4f5; }
+ .sheet { width: 210mm; min-height: 297mm; margin: 8mm auto; padding: 12mm; background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.15); }
+ h1 { font-size: 15pt; margin: 0 0 2mm; letter-spacing: .5px; }
+ .title { text-align: center; font-size: 12pt; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; border: 1px solid #111; padding: 2mm; margin-bottom: 4mm; }
+ .head { display: flex; justify-content: space-between; gap: 6mm; margin-bottom: 4mm; }
+ .head .blk { flex: 1; }
+ .muted { color: #555; font-size: 9pt; }
+ .grid2 { display: flex; gap: 6mm; margin-bottom: 4mm; }
+ .grid2 .box { flex: 1; border: 1px solid #ccc; padding: 3mm; font-size: 9.5pt; }
+ .box h3 { margin: 0 0 1.5mm; font-size: 9pt; text-transform: uppercase; color: #666; letter-spacing: .5px; }
+ table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 4mm; }
+ th, td { border: 1px solid #bbb; padding: 1.6mm 2mm; text-align: left; }
+ th { background: #f0f0f0; font-size: 8.5pt; text-transform: uppercase; letter-spacing: .3px; }
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
+ tfoot td { font-weight: 600; }
+ .totals { width: 46%; margin-left: auto; }
+ .totals td { border: none; padding: 1mm 2mm; }
+ .totals .grand td { border-top: 2px solid #111; font-size: 12pt; font-weight: 700; padding-top: 2mm; }
+ .words { border: 1px solid #ccc; padding: 3mm; font-size: 10pt; margin-bottom: 4mm; }
+ .words b { display: block; font-size: 8.5pt; text-transform: uppercase; color: #666; margin-bottom: 1mm; }
+ .foot { border-top: 1px solid #ccc; padding-top: 3mm; font-size: 9pt; color: #444; display: flex; justify-content: space-between; gap: 6mm; }
+ .sign { text-align: right; }
+ .no-print { position: fixed; top: 10px; right: 14px; }
+ .no-print button { font: inherit; padding: 8px 16px; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
+ @media print { body { background: #fff; } .sheet { margin: 0; box-shadow: none; width: auto; } .no-print { display: none; } }
+ @page { size: A4; margin: 10mm; }
+`
+
+function partyBlock(label: string, p: InvoiceParty | undefined): string {
+ if (p === undefined) return `${esc(label)} Walk-in / Cash sale
`
+ return `
+
${esc(label)}
+
${esc(p.name)}
+ ${p.address !== undefined && p.address !== '' ? `
${esc(p.address)}
` : ''}
+ ${p.gstin !== undefined && p.gstin !== '' ? `
GSTIN: ${esc(p.gstin)}
` : '
Unregistered
'}
+ ${p.stateCode !== undefined && p.stateCode !== '' ? `
State: ${esc(placeOfSupply(p.stateCode))}
` : ''}
+ ${p.phone !== undefined && p.phone !== '' ? `
Ph: ${esc(p.phone)}
` : ''}
+
`
+}
+
+export function renderInvoiceHtml(doc: InvoiceDoc, opts: InvoiceOptions = {}): string {
+ const title = opts.title ?? (doc.seller.gstin !== undefined && doc.seller.gstin !== '' ? 'Tax Invoice' : 'Bill of Supply')
+ const t = doc.totals
+ const interState = t.igstPaise > 0
+ const buckets = hsnBuckets(doc.lines)
+ const footer = opts.footerNote ??
+ 'This is a computer-generated invoice and needs no signature. / यह कंप्यूटर द्वारा जनित बिल है, हस्ताक्षर की आवश्यकता नहीं।'
+
+ const lineRows = doc.lines.map((l, i) => `
+
+ ${i + 1}
+ ${esc(l.name)}
+ ${esc(l.hsn)}
+ ${l.qty} ${esc(l.unitCode)}
+ ${inr(l.unitPricePaise)}
+ ${inr(l.taxablePaise)}
+ ${(l.taxRateBp / 100).toFixed(l.taxRateBp % 100 === 0 ? 0 : 2)}%
+ ${inr(l.lineTotalPaise)}
+ `).join('')
+
+ const taxHead = interState
+ ? 'IGST '
+ : 'CGST SGST '
+ const taxRows = buckets.map((b) => `
+
+ ${esc(b.hsn)}
+ ${(b.taxRateBp / 100).toFixed(b.taxRateBp % 100 === 0 ? 0 : 2)}%
+ ${inr(b.taxablePaise)}
+ ${interState ? `${inr(b.igstPaise)} ` : `${inr(b.cgstPaise)} ${inr(b.sgstPaise)} `}
+ ${inr(b.cessPaise)}
+ `).join('')
+
+ const totalsRows: string[] = [
+ `Taxable value ${inr(t.taxablePaise)} `,
+ ]
+ if (t.discountPaise > 0) totalsRows.push(`Discount -${inr(t.discountPaise)} `)
+ if (interState) {
+ totalsRows.push(`IGST ${inr(t.igstPaise)} `)
+ } else {
+ totalsRows.push(`CGST ${inr(t.cgstPaise)} `)
+ totalsRows.push(`SGST ${inr(t.sgstPaise)} `)
+ }
+ if (t.cessPaise > 0) totalsRows.push(`Cess ${inr(t.cessPaise)} `)
+ if (t.roundOffPaise !== 0) totalsRows.push(`Round off ${inr(t.roundOffPaise)} `)
+
+ return `
+
+
+
+
+${esc(title)} ${esc(doc.invoiceNo)}
+
+
+
+${opts.printButton === false ? '' : 'Print A4
'}
+
+
${esc(title)}
+
+
+
${esc(doc.seller.name)}
+ ${doc.seller.address !== undefined && doc.seller.address !== '' ? `
${esc(doc.seller.address)}
` : ''}
+ ${doc.seller.gstin !== undefined && doc.seller.gstin !== '' ? `
GSTIN: ${esc(doc.seller.gstin)}
` : ''}
+ ${doc.seller.phone !== undefined && doc.seller.phone !== '' ? `
Ph: ${esc(doc.seller.phone)}
` : ''}
+
+
+
Invoice No ${esc(doc.invoiceNo)}
+
Date ${esc(doc.invoiceDate)}
+
Place of Supply ${esc(placeOfSupply(doc.placeOfSupplyStateCode ?? doc.seller.stateCode))}
+
+
+
+
+ ${partyBlock('Billed To', doc.buyer)}
+ ${partyBlock('Supplier', doc.seller)}
+
+
+
+
+
+ # Description HSN Qty
+ Rate Taxable GST% Amount
+
+
+ ${lineRows}
+
+
+
+
+ HSN Rate Taxable ${taxHead}Cess
+
+ ${taxRows}
+
+
+
+
+ ${totalsRows.join('')}
+ Payable${doc.paymentLabel !== undefined ? ` (${esc(doc.paymentLabel)})` : ''} ${formatINR(t.payablePaise)}
+
+
+
+
+ Amount in words
+ ${esc(amountInWordsINR(t.payablePaise))}
+
+
+
+
+
+`
+}
diff --git a/packages/printing/src/labels.ts b/packages/printing/src/labels.ts
new file mode 100644
index 0000000..fb7c055
--- /dev/null
+++ b/packages/printing/src/labels.ts
@@ -0,0 +1,188 @@
+import { formatINR } from '@sims/domain'
+import { ean13CheckDigit } from '@sims/scanning'
+
+/**
+ * Shelf/MRP label printing (S2). `ean13Svg` renders a standards-correct EAN-13
+ * symbol (L/G/R element patterns, first-digit parity) so a scanner reads the
+ * printed label; `renderLabelSheetHtml` lays labels out as a printable grid for
+ * a laser sheet or label roll. Pure — no DOM — so both are unit-testable.
+ */
+
+// Left-hand odd-parity (L) element patterns, digit 0–9. 7 modules each.
+const L: string[] = [
+ '0001101', '0011001', '0010011', '0111101', '0100011',
+ '0110001', '0101111', '0111011', '0110111', '0001011',
+]
+// Right-hand (R) patterns are the ones-complement of L.
+const R: string[] = L.map((s) => [...s].map((b) => (b === '0' ? '1' : '0')).join(''))
+// Left-hand even-parity (G) patterns are R read right-to-left.
+const G: string[] = R.map((s) => [...s].reverse().join(''))
+// First digit selects the parity mix of the six left-hand digits.
+const PARITY: string[] = [
+ 'LLLLLL', 'LLGLGG', 'LLGGLG', 'LLGGGL', 'LGLLGG',
+ 'LGGLLG', 'LGGGLL', 'LGLGLG', 'LGLGGL', 'LGGLGL',
+]
+
+/**
+ * Strict validation: a 12-digit body gets its check digit computed (via the
+ * scanning package's `ean13CheckDigit`); a 13-digit code is verified and throws
+ * on a checksum mismatch. Use for validating catalog input.
+ */
+export function normalizeEan13(code: string): string {
+ const digits = code.replace(/\D/g, '')
+ if (digits.length === 12) return digits + String(ean13CheckDigit(digits))
+ if (digits.length === 13) {
+ const cd = ean13CheckDigit(digits.slice(0, 12))
+ if (cd !== Number(digits[12])) throw new Error(`EAN-13 checksum mismatch: expected ${cd}, got ${digits[12]}`)
+ return digits
+ }
+ throw new Error('EAN-13 needs 12 or 13 digits')
+}
+
+/**
+ * Lenient 13-digit form for rendering: a 12-digit body gains its computed check
+ * digit; a 13-digit code is encoded exactly as stored (so the label round-trips
+ * to the catalog's barcode even when legacy data has an off check digit — the
+ * label must always print, R18). Only the length is enforced.
+ */
+function toEan13(code: string): string {
+ const digits = code.replace(/\D/g, '')
+ if (digits.length === 12) return digits + String(ean13CheckDigit(digits))
+ if (digits.length === 13) return digits
+ throw new Error('EAN-13 needs 12 or 13 digits')
+}
+
+/** The 95-module bar/space string (1 = bar): start · 6 left · centre · 6 right · end. */
+export function ean13Modules(code: string): string {
+ const c = toEan13(code)
+ const first = Number(c[0])
+ const pattern = PARITY[first]!
+ let out = '101' // start guard
+ for (let i = 0; i < 6; i++) {
+ const d = Number(c[1 + i])
+ out += pattern[i] === 'L' ? L[d]! : G[d]!
+ }
+ out += '01010' // centre guard
+ for (let i = 0; i < 6; i++) {
+ out += R[Number(c[7 + i])]!
+ }
+ out += '101' // end guard
+ return out
+}
+
+const GUARD = new Set()
+for (const i of [0, 1, 2, 45, 46, 47, 48, 49, 92, 93, 94]) GUARD.add(i)
+
+/** A self-contained SVG EAN-13 symbol with the human-readable digits beneath. */
+export function ean13Svg(code13: string, opts: { module?: number; height?: number } = {}): string {
+ const c = toEan13(code13)
+ const modules = ean13Modules(c)
+ const m = opts.module ?? 2
+ const barH = opts.height ?? 56
+ const guardH = barH + 7
+ const quietL = 11 * m
+ const quietR = 7 * m
+ const width = quietL + 95 * m + quietR
+ const totalH = guardH + 14
+
+ const bars: string[] = []
+ for (let i = 0; i < modules.length; i++) {
+ if (modules[i] !== '1') continue
+ const h = GUARD.has(i) ? guardH : barH
+ bars.push(` `)
+ }
+
+ // Human-readable: first digit in the left quiet zone, then two groups of six.
+ const ty = guardH + 11
+ const leftMid = quietL + (3 + 21) * m
+ const rightMid = quietL + (50 + 21) * m
+ const text =
+ `${c[0]} ` +
+ `${c.slice(1, 7)} ` +
+ `${c.slice(7)} `
+
+ return `` +
+ ` ` +
+ `${bars.join('')} ` +
+ `${text} ` +
+ ` `
+}
+
+export interface LabelInput {
+ name: string
+ mrpPaise: number
+ /** Barcode to print; omitted labels show text only. */
+ code13?: string
+ /** Selling price when it differs from MRP. */
+ pricePaise?: number
+}
+
+export interface LabelSheetOptions {
+ /** Columns in the grid. */
+ cols: number
+ /** Label cell size in millimetres. */
+ labelWmm: number
+ labelHmm: number
+ title?: string
+ printButton?: boolean
+}
+
+const esc = (s: string): string =>
+ s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
+
+/** A printable A4 sheet of MRP/barcode labels laid out in a grid. */
+export function renderLabelSheetHtml(labels: LabelInput[], opts: LabelSheetOptions): string {
+ const title = opts.title ?? 'Barcode / MRP Labels'
+ const cells = labels.map((lab) => {
+ let bar = ''
+ if (lab.code13 !== undefined && lab.code13 !== '') {
+ try {
+ bar = `${ean13Svg(lab.code13, { module: 2, height: 40 })}
`
+ } catch {
+ bar = `no barcode
`
+ }
+ }
+ const price = lab.pricePaise !== undefined && lab.pricePaise !== lab.mrpPaise
+ ? `Rate ${formatINR(lab.pricePaise)}
`
+ : ''
+ return `
+
${esc(lab.name)}
+ ${bar}
+
MRP ${formatINR(lab.mrpPaise)}
+ ${price}
+
`
+ }).join('')
+
+ return `
+
+
+
+
+${esc(title)}
+
+
+
+${opts.printButton === false ? '' : 'Print labels
'}
+
+
+`
+}
diff --git a/packages/printing/src/raster.ts b/packages/printing/src/raster.ts
new file mode 100644
index 0000000..bcf44dd
--- /dev/null
+++ b/packages/printing/src/raster.ts
@@ -0,0 +1,54 @@
+/**
+ * Raster (bit-image) receipt bytes. ESC/POS text mode can't render Devanagari
+ * or Malayalam (09-UX §5.3), so non-Latin receipts print as a 1-bit raster via
+ * GS v 0. This module is the pure part — thresholding pixels and packing bits;
+ * the canvas that draws the glyphs lives in the app (apps/pos/src/raster.ts),
+ * because canvas is a browser API and byte-packing must stay testable.
+ */
+const GS = 0x1d
+
+/** 1 bit per pixel, row-major; `bits[y*width + x]` is 1 for a printed (black) dot. */
+export interface Bitmap {
+ width: number
+ height: number
+ bits: Uint8Array
+}
+
+/**
+ * Pack one pixel row (0/1 values) into bytes, most-significant bit = leftmost
+ * pixel, padded with zeros to a whole byte. 9 pixels → 2 bytes.
+ */
+export function packRasterRow(row: ArrayLike): number[] {
+ const bytes = new Array(Math.ceil(row.length / 8)).fill(0)
+ for (let i = 0; i < row.length; i++) {
+ if (row[i]) bytes[i >> 3]! |= 0x80 >> (i & 7)
+ }
+ return bytes
+}
+
+/**
+ * Threshold RGBA pixel data (canvas `ImageData.data`) into a 1-bpp bitmap.
+ * A dot prints when it is opaque and darker than `threshold` (Rec.601 luma).
+ */
+export function bitonalFromRGBA(
+ data: ArrayLike, width: number, height: number, threshold = 128,
+): Bitmap {
+ const bits = new Uint8Array(width * height)
+ for (let i = 0; i < width * height; i++) {
+ const r = data[i * 4]!, g = data[i * 4 + 1]!, b = data[i * 4 + 2]!, a = data[i * 4 + 3]!
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b
+ bits[i] = a >= 128 && luma < threshold ? 1 : 0
+ }
+ return { width, height, bits }
+}
+
+/** GS v 0 raster bit-image command for a bitmap (m=0, normal density). */
+export function rasterToEscPos(bmp: Bitmap): Uint8Array {
+ const bytesPerRow = Math.ceil(bmp.width / 8)
+ const out: number[] = [GS, 0x76, 0x30, 0, bytesPerRow & 0xff, (bytesPerRow >> 8) & 0xff, bmp.height & 0xff, (bmp.height >> 8) & 0xff]
+ for (let y = 0; y < bmp.height; y++) {
+ const row = bmp.bits.subarray(y * bmp.width, (y + 1) * bmp.width)
+ out.push(...packRasterRow(row))
+ }
+ return Uint8Array.from(out)
+}
diff --git a/packages/printing/src/receipt.ts b/packages/printing/src/receipt.ts
new file mode 100644
index 0000000..b9a8f4b
--- /dev/null
+++ b/packages/printing/src/receipt.ts
@@ -0,0 +1,112 @@
+import { formatINR, type BillLine, type BillTotals } from '@sims/domain'
+import { EscPos, hr, twoCol } from './escpos'
+
+export interface ReceiptDoc {
+ storeName: string
+ storeAddress?: string
+ gstin?: string
+ docNo: string
+ businessDate: string
+ counterCode: string
+ cashierName: string
+ /** Attached customer (B2B or a named B2C profile), printed under the bill head. */
+ buyerName?: string
+ /** Buyer GSTIN — its presence makes this a B2B receipt (IGST when inter-state). */
+ buyerGstin?: string
+ lines: BillLine[]
+ totals: BillTotals
+ paymentLabel: string
+ footer?: string
+}
+
+export interface ReceiptOptions {
+ /** Characters per line: 32 (2"), 42/48 (3"). */
+ width: number
+ kickDrawer: boolean
+}
+
+const inr = (p: number) => formatINR(p, { symbol: false })
+
+/**
+ * The receipt as plain text lines — the shared content model for both the
+ * ASCII path (renderReceipt) and the Indic raster path (canvas → GS v 0). Item
+ * names print verbatim here so a Devanagari/Malayalam name survives to the
+ * raster renderer; the ASCII builder collapses non-Latin to '?' on its own.
+ */
+export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] {
+ const w = opts.width
+ const out: string[] = []
+ out.push(doc.storeName)
+ if (doc.storeAddress !== undefined) out.push(doc.storeAddress)
+ if (doc.gstin !== undefined) out.push(`GSTIN: ${doc.gstin}`)
+ out.push(hr(w))
+ out.push(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w))
+ out.push(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w))
+ if (doc.buyerName !== undefined) out.push(`Buyer: ${doc.buyerName}`)
+ if (doc.buyerGstin !== undefined) out.push(`Buyer GSTIN: ${doc.buyerGstin}`)
+ out.push(hr(w))
+ for (const l of doc.lines) {
+ out.push(l.name)
+ out.push(twoCol(` ${l.qty} ${l.unitCode} x ${inr(l.unitPricePaise)}`, inr(l.lineTotalPaise), w))
+ }
+ out.push(hr(w))
+ out.push(twoCol('Taxable', inr(doc.totals.taxablePaise), w))
+ if (doc.totals.igstPaise > 0) {
+ out.push(twoCol('IGST', inr(doc.totals.igstPaise), w))
+ } else {
+ out.push(twoCol('CGST', inr(doc.totals.cgstPaise), w))
+ out.push(twoCol('SGST', inr(doc.totals.sgstPaise), w))
+ }
+ if (doc.totals.cessPaise > 0) out.push(twoCol('Cess', inr(doc.totals.cessPaise), w))
+ if (doc.totals.discountPaise > 0) out.push(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w))
+ if (doc.totals.roundOffPaise !== 0) out.push(twoCol('Round off', inr(doc.totals.roundOffPaise), w))
+ out.push(twoCol('PAYABLE', inr(doc.totals.payablePaise), w))
+ out.push(twoCol('Paid by', doc.paymentLabel, w))
+ if (doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`)
+ out.push(doc.footer ?? 'Thank you! Visit again')
+ return out
+}
+
+/** Thermal receipt bytes. Pure — golden-testable without a printer. */
+export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array {
+ const w = opts.width
+ const p = new EscPos().init()
+
+ if (opts.kickDrawer) p.drawerKick()
+
+ p.align('center').bold(true).size(2, 2).line(doc.storeName).size(1, 1).bold(false)
+ if (doc.storeAddress !== undefined) p.line(doc.storeAddress)
+ if (doc.gstin !== undefined) p.line(`GSTIN: ${doc.gstin}`)
+ p.line(hr(w))
+ p.align('left')
+ p.line(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w))
+ p.line(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w))
+ if (doc.buyerName !== undefined) p.line(`Buyer: ${doc.buyerName}`)
+ if (doc.buyerGstin !== undefined) p.line(`Buyer GSTIN: ${doc.buyerGstin}`)
+ p.line(hr(w))
+
+ for (const l of doc.lines) {
+ p.line(l.name)
+ p.line(twoCol(` ${l.qty} ${l.unitCode} x ${inr(l.unitPricePaise)}`, inr(l.lineTotalPaise), w))
+ }
+
+ p.line(hr(w))
+ p.line(twoCol('Taxable', inr(doc.totals.taxablePaise), w))
+ if (doc.totals.igstPaise > 0) {
+ p.line(twoCol('IGST', inr(doc.totals.igstPaise), w))
+ } else {
+ p.line(twoCol('CGST', inr(doc.totals.cgstPaise), w))
+ p.line(twoCol('SGST', inr(doc.totals.sgstPaise), w))
+ }
+ if (doc.totals.cessPaise > 0) p.line(twoCol('Cess', inr(doc.totals.cessPaise), w))
+ if (doc.totals.discountPaise > 0) p.line(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w))
+ if (doc.totals.roundOffPaise !== 0) p.line(twoCol('Round off', inr(doc.totals.roundOffPaise), w))
+ p.bold(true).size(1, 2).line(twoCol('PAYABLE', inr(doc.totals.payablePaise), w)).size(1, 1).bold(false)
+ p.line(twoCol('Paid by', doc.paymentLabel, w))
+ if (doc.totals.savingsVsMrpPaise > 0) {
+ p.align('center').line(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`)
+ }
+ p.align('center').line(doc.footer ?? 'Thank you! Visit again')
+ p.feed(4).cut()
+ return p.build()
+}
diff --git a/packages/printing/test/printing.test.ts b/packages/printing/test/printing.test.ts
new file mode 100644
index 0000000..fd30355
--- /dev/null
+++ b/packages/printing/test/printing.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest'
+import { EscPos, hr, twoCol, renderReceipt } from '@sims/printing'
+import type { BillLine, BillTotals } from '@sims/domain'
+
+describe('twoCol', () => {
+ it('pads to exact width and truncates long names', () => {
+ expect(twoCol('Atta 5kg', '270.00', 32)).toHaveLength(32)
+ const long = twoCol('A very very long item name indeed', '1,431.00', 32)
+ expect(long).toHaveLength(32)
+ expect(long.endsWith('1,431.00')).toBe(true)
+ })
+ it('always keeps one space between columns', () => {
+ expect(twoCol('X'.repeat(40), '99.00', 32)).toContain(' 99.00')
+ })
+})
+
+describe('EscPos builder', () => {
+ it('starts with init and encodes control sequences', () => {
+ const b = new EscPos().init().bold(true).text('HI').cut().build()
+ expect([...b.slice(0, 2)]).toEqual([0x1b, 0x40])
+ expect([...b]).toContain(0x48) // 'H'
+ expect([...b.slice(-4)]).toEqual([0x1d, 0x56, 66, 3])
+ })
+ it('emits the standard drawer-kick pulse', () => {
+ const b = new EscPos().drawerKick().build()
+ expect([...b]).toEqual([0x1b, 0x70, 0, 25, 250])
+ })
+ it('collapses non-ASCII to ? in text mode (raster path comes later)', () => {
+ const b = new EscPos().text('चाय').build()
+ expect([...b]).toEqual([0x3f, 0x3f, 0x3f])
+ })
+})
+
+describe('renderReceipt', () => {
+ const line: BillLine = {
+ itemId: 'i1', name: 'Aashirvaad Atta 5kg', hsn: '1101', qty: 1, unitCode: 'PCS',
+ unitPricePaise: 27_000, mrpPaise: 28_500, grossPaise: 27_000, discountPaise: 0,
+ taxablePaise: 22_881, taxRateBp: 1800, cgstPaise: 2_059, sgstPaise: 2_060,
+ igstPaise: 0, cessPaise: 0, lineTotalPaise: 27_000,
+ }
+ const totals: BillTotals = {
+ grossPaise: 27_000, discountPaise: 0, taxablePaise: 22_881, cgstPaise: 2_059,
+ sgstPaise: 2_060, igstPaise: 0, cessPaise: 0, roundOffPaise: 0,
+ payablePaise: 27_000, savingsVsMrpPaise: 1_500,
+ }
+ const doc = {
+ storeName: 'MegaMart', docNo: 'ST1C2/26-000123', businessDate: '2026-07-09',
+ counterCode: 'C2', cashierName: 'Ramesh', lines: [line], totals, paymentLabel: 'CASH',
+ }
+
+ it('renders a complete receipt with cut at the end', () => {
+ const bytes = renderReceipt(doc, { width: 42, kickDrawer: false })
+ const text = new TextDecoder('ascii').decode(bytes)
+ expect(text).toContain('PAYABLE')
+ expect(text).toContain('ST1C2/26-000123')
+ expect(text).toContain('You saved 15.00 vs MRP')
+ expect([...bytes.slice(-4)]).toEqual([0x1d, 0x56, 66, 3])
+ })
+ it('kicks the drawer before printing only when asked', () => {
+ const withKick = renderReceipt(doc, { width: 42, kickDrawer: true })
+ expect([...withKick.slice(2, 7)]).toEqual([0x1b, 0x70, 0, 25, 250])
+ const noKick = renderReceipt(doc, { width: 42, kickDrawer: false })
+ expect([...noKick.slice(2, 7)]).not.toEqual([0x1b, 0x70, 0, 25, 250])
+ })
+ it(`shows IGST instead of the split for inter-state bills`, () => {
+ const inter = renderReceipt(
+ { ...doc, totals: { ...totals, cgstPaise: 0, sgstPaise: 0, igstPaise: 4_119 } },
+ { width: 42, kickDrawer: false },
+ )
+ const text = new TextDecoder('ascii').decode(inter)
+ expect(text).toContain('IGST')
+ expect(text).not.toContain('CGST')
+ })
+ it('prints the buyer GSTIN line for a B2B receipt', () => {
+ const b2b = renderReceipt(
+ { ...doc, buyerName: 'Anand Traders', buyerGstin: '27ABCDE1234F1Z0' },
+ { width: 42, kickDrawer: false },
+ )
+ const text = new TextDecoder('ascii').decode(b2b)
+ expect(text).toContain('Buyer: Anand Traders')
+ expect(text).toContain('Buyer GSTIN: 27ABCDE1234F1Z0')
+ })
+})
diff --git a/packages/printing/test/s2-print.test.ts b/packages/printing/test/s2-print.test.ts
new file mode 100644
index 0000000..89870d7
--- /dev/null
+++ b/packages/printing/test/s2-print.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from 'vitest'
+import { amountInWordsINR, type BillLine, type BillTotals } from '@sims/domain'
+import {
+ renderInvoiceHtml, type InvoiceDoc,
+ ean13Modules, ean13Svg, normalizeEan13, renderLabelSheetHtml,
+ packRasterRow, bitonalFromRGBA, rasterToEscPos,
+} from '@sims/printing'
+
+describe('amountInWordsINR — Indian numbering', () => {
+ it('zero', () => {
+ expect(amountInWordsINR(0)).toBe('Rupees Zero Only')
+ })
+ it('paisa only', () => {
+ expect(amountInWordsINR(50)).toBe('Fifty Paise Only')
+ })
+ it('a single paisa is singular', () => {
+ expect(amountInWordsINR(1)).toBe('One Paisa Only')
+ })
+ it('one lakh rupees', () => {
+ expect(amountInWordsINR(1_00_000 * 100)).toBe('Rupees One Lakh Only')
+ })
+ it('one crore rupees', () => {
+ expect(amountInWordsINR(1_00_00_000 * 100)).toBe('Rupees One Crore Only')
+ })
+ it('mixed rupees and paise', () => {
+ expect(amountInWordsINR(1431_50)).toBe('Rupees One Thousand Four Hundred Thirty One and Fifty Paise Only')
+ })
+})
+
+const line: BillLine = {
+ itemId: 'i1', name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS',
+ unitPricePaise: 14_500, mrpPaise: 15_000, grossPaise: 14_500, discountPaise: 0,
+ taxablePaise: 12_288, taxRateBp: 1800, cgstPaise: 1_106, sgstPaise: 1_106,
+ igstPaise: 0, cessPaise: 0, lineTotalPaise: 14_500,
+}
+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 invoice: InvoiceDoc = {
+ seller: { name: 'MegaMart T.Nagar', gstin: '32ABCDE1234F1Z5', stateCode: '32', address: 'T.Nagar, Chennai' },
+ buyer: { name: 'Lakshmi', phone: '9840012345' },
+ invoiceNo: 'ST1C2/26-000001', invoiceDate: '2026-07-10',
+ placeOfSupplyStateCode: '32', lines: [line], totals, paymentLabel: 'CASH',
+}
+
+describe('renderInvoiceHtml — A4 GST invoice', () => {
+ it('contains the key GST-invoice fields', () => {
+ const html = renderInvoiceHtml(invoice)
+ expect(html).toContain('Tax Invoice')
+ expect(html).toContain('MegaMart T.Nagar')
+ expect(html).toContain('32ABCDE1234F1Z5')
+ expect(html).toContain('ST1C2/26-000001')
+ expect(html).toContain('Surf Excel 1kg')
+ expect(html).toContain('3402') // HSN
+ expect(html).toContain('CGST')
+ expect(html).toContain('SGST')
+ expect(html).toContain('Place of Supply')
+ expect(html).toContain('Kerala') // state 32 resolved
+ })
+ it('renders the amount in words', () => {
+ const html = renderInvoiceHtml(invoice)
+ expect(html).toContain('Amount in words')
+ expect(html).toContain(amountInWordsINR(totals.payablePaise)) // Rupees One Hundred Forty Five Only
+ })
+ it('shows IGST (not CGST/SGST) for an inter-state bill', () => {
+ const inter = renderInvoiceHtml({
+ ...invoice,
+ totals: { ...totals, cgstPaise: 0, sgstPaise: 0, igstPaise: 2_212 },
+ lines: [{ ...line, cgstPaise: 0, sgstPaise: 0, igstPaise: 2_212 }],
+ placeOfSupplyStateCode: '29',
+ })
+ expect(inter).toContain('IGST')
+ expect(inter).not.toContain('CGST')
+ })
+ it('is a self-contained HTML document', () => {
+ const html = renderInvoiceHtml(invoice)
+ expect(html.startsWith('')).toBe(true)
+ expect(html).toContain('')
+ expect(html).toContain('window.print()')
+ })
+})
+
+describe('ean13 encoding', () => {
+ it('encodes the standard 95-module structure (all-zero symbol)', () => {
+ const m = ean13Modules('000000000000') // 12 digits → check digit appended
+ expect(m).toHaveLength(95)
+ expect(m.startsWith('101')).toBe(true) // start guard
+ expect(m.endsWith('101')).toBe(true) // end guard
+ expect(m.slice(45, 50)).toBe('01010') // centre guard
+ // first digit 0 → all L; L(0) = 0001101, so the first data element follows the start guard
+ expect(m.startsWith('101' + '0001101')).toBe(true)
+ })
+ it('computes the check digit via ean13CheckDigit for a 12-digit body', () => {
+ expect(normalizeEan13('890106301435')).toBe('8901063014350')
+ })
+ it('rejects a 13-digit code with a bad checksum (strict path)', () => {
+ expect(() => normalizeEan13('8901063014357')).toThrow(/checksum/)
+ })
+ it('still renders an SVG for legacy 13-digit data (lenient path, R18)', () => {
+ const svg = ean13Svg('8901063014357')
+ expect(svg.startsWith('')).toBe(true)
+ expect(svg).toContain('8901063014357') // human-readable matches stored code
+ })
+})
+
+describe('renderLabelSheetHtml', () => {
+ it('lays out labels with barcode, name and MRP', () => {
+ const html = renderLabelSheetHtml(
+ [{ name: 'Surf Excel 1kg', mrpPaise: 15_000, code13: '8901030704833', pricePaise: 14_500 }],
+ { cols: 3, labelWmm: 38, labelHmm: 25 },
+ )
+ expect(html).toContain('Surf Excel 1kg')
+ expect(html).toContain('MRP')
+ expect(html).toContain(' {
+ const html = renderLabelSheetHtml([{ name: 'Loose Tomato', mrpPaise: 3_200 }], { cols: 2, labelWmm: 50, labelHmm: 30 })
+ expect(html).toContain('Loose Tomato')
+ expect(html).not.toContain(' {
+ it('packs a byte MSB-first', () => {
+ expect(packRasterRow([1, 0, 1, 0, 0, 0, 0, 0])).toEqual([0xa0])
+ expect(packRasterRow([0, 0, 0, 0, 0, 0, 0, 1])).toEqual([0x01])
+ })
+ it('pads a partial trailing byte with zeros', () => {
+ expect(packRasterRow([1, 1, 1, 1, 1, 1, 1, 1, 1])).toEqual([0xff, 0x80])
+ })
+ it('thresholds RGBA to bitonal (dark = printed dot)', () => {
+ // two pixels: black then white
+ const data = [0, 0, 0, 255, 255, 255, 255, 255]
+ const bmp = bitonalFromRGBA(data, 2, 1)
+ expect([...bmp.bits]).toEqual([1, 0])
+ })
+ it('emits a GS v 0 raster command with the right header', () => {
+ const bytes = rasterToEscPos({ width: 8, height: 1, bits: Uint8Array.from([1, 0, 1, 0, 0, 0, 0, 0]) })
+ expect([...bytes]).toEqual([0x1d, 0x76, 0x30, 0, 1, 0, 1, 0, 0xa0])
+ })
+})
diff --git a/packages/scanning/package.json b/packages/scanning/package.json
new file mode 100644
index 0000000..e3fb73e
--- /dev/null
+++ b/packages/scanning/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@sims/scanning",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts"
+}
diff --git a/packages/scanning/src/barcode.ts b/packages/scanning/src/barcode.ts
new file mode 100644
index 0000000..68cacaf
--- /dev/null
+++ b/packages/scanning/src/barcode.ts
@@ -0,0 +1,13 @@
+/** EAN-13 check digit: weights alternate 1,3 over the first 12 digits. */
+export function ean13CheckDigit(first12: string): number {
+ if (!/^\d{12}$/.test(first12)) throw new Error('EAN-13 body must be 12 digits')
+ let sum = 0
+ for (let i = 0; i < 12; i++) {
+ sum += Number(first12[i]) * (i % 2 === 0 ? 1 : 3)
+ }
+ return (10 - (sum % 10)) % 10
+}
+
+export function isValidEan13(code: string): boolean {
+ return /^\d{13}$/.test(code) && ean13CheckDigit(code.slice(0, 12)) === Number(code[12])
+}
diff --git a/packages/scanning/src/entry.ts b/packages/scanning/src/entry.ts
new file mode 100644
index 0000000..4d204d3
--- /dev/null
+++ b/packages/scanning/src/entry.ts
@@ -0,0 +1,32 @@
+/**
+ * The smart scan box grammar (09-UX §1.2): one input, no modes. Typed and
+ * scanned input both land here; the caller resolves codes against the item
+ * cache and scale profile.
+ */
+export type EntryParse =
+ | { kind: 'empty' }
+ | { kind: 'multiplier'; qty: number; rest: string }
+ | { kind: 'barcode'; code: string }
+ | { kind: 'code'; code: string }
+ | { kind: 'search'; text: string }
+
+const MULTIPLIER = /^(\d{1,4}(?:\.\d{1,3})?)\*(.*)$/
+
+export function parseEntry(raw: string): EntryParse {
+ const input = raw.trim()
+ if (input === '') return { kind: 'empty' }
+
+ const mult = MULTIPLIER.exec(input)
+ if (mult !== null) {
+ const qty = Number(mult[1])
+ if (qty > 0) return { kind: 'multiplier', qty, rest: mult[2]!.trim() }
+ }
+
+ if (/^\d+$/.test(input)) {
+ // 8–14 digits reads as a barcode/item code; shorter digits are still codes
+ // (quick-key PLUs); resolution order is the caller's concern.
+ return input.length >= 8 ? { kind: 'barcode', code: input } : { kind: 'code', code: input }
+ }
+
+ return { kind: 'search', text: input }
+}
diff --git a/packages/scanning/src/index.ts b/packages/scanning/src/index.ts
new file mode 100644
index 0000000..13f6b8a
--- /dev/null
+++ b/packages/scanning/src/index.ts
@@ -0,0 +1,4 @@
+export * from './barcode'
+export * from './scale'
+export * from './entry'
+export * from './wedge'
diff --git a/packages/scanning/src/scale.ts b/packages/scanning/src/scale.ts
new file mode 100644
index 0000000..10de4ba
--- /dev/null
+++ b/packages/scanning/src/scale.ts
@@ -0,0 +1,38 @@
+import { isValidEan13 } from './barcode'
+
+/**
+ * Label-scale barcodes: EAN-13 in the GS1 restricted-circulation range
+ * (prefix 20–29) with the item PLU and a weight or price embedded — the
+ * supermarket produce path where every scan must be zero keystrokes.
+ * Layout: [2-digit prefix][PLU][value][check]; PLU + value = 10 digits.
+ * The layout is per-store scale configuration, hence a profile row.
+ */
+export interface ScaleBarcodeProfile {
+ /** Which 2-digit prefixes this store's scales emit, e.g. ['21']. */
+ prefixes: string[]
+ pluLength: 4 | 5 | 6
+ valueKind: 'weight' | 'price'
+ /** value → quantity (weight: grams with divisor 1000 → kg) or paise. */
+ valueDivisor: number
+}
+
+export type ScaleParse =
+ | { kind: 'scale-weight'; plu: string; qty: number }
+ | { kind: 'scale-price'; plu: string; pricePaise: number }
+ | { kind: 'not-scale' }
+ | { kind: 'invalid'; reason: string }
+
+export function parseScaleBarcode(code: string, profile: ScaleBarcodeProfile): ScaleParse {
+ if (!/^\d{13}$/.test(code)) return { kind: 'not-scale' }
+ if (!profile.prefixes.includes(code.slice(0, 2))) return { kind: 'not-scale' }
+ if (!isValidEan13(code)) return { kind: 'invalid', reason: 'check-digit' }
+ const valueLength = 10 - profile.pluLength
+ const plu = code.slice(2, 2 + profile.pluLength)
+ const value = Number(code.slice(2 + profile.pluLength, 2 + profile.pluLength + valueLength))
+ if (profile.valueKind === 'weight') {
+ const qty = value / profile.valueDivisor
+ if (qty <= 0) return { kind: 'invalid', reason: 'zero-weight' }
+ return { kind: 'scale-weight', plu, qty }
+ }
+ return { kind: 'scale-price', plu, pricePaise: value }
+}
diff --git a/packages/scanning/src/wedge.ts b/packages/scanning/src/wedge.ts
new file mode 100644
index 0000000..93e37c8
--- /dev/null
+++ b/packages/scanning/src/wedge.ts
@@ -0,0 +1,48 @@
+/**
+ * Keyboard-wedge scanners type like superhuman typists: a burst of characters
+ * with tiny inter-key gaps, terminated by Enter. This detector distinguishes a
+ * scan from human typing purely from timing + length, with injected timestamps
+ * so it is testable without a DOM or a clock.
+ */
+export interface WedgeOptions {
+ /** Max gap between scanner keystrokes; humans are slower. */
+ maxInterKeyMs: number
+ /** Bursts shorter than this are treated as typed input. */
+ minLength: number
+}
+
+export const DEFAULT_WEDGE: WedgeOptions = { maxInterKeyMs: 35, minLength: 6 }
+
+export type WedgeResult = { type: 'scan'; code: string } | { type: 'typed'; text: string }
+
+export class WedgeDetector {
+ private chars: string[] = []
+ private lastAtMs: number | undefined
+ private maxGapMs = 0
+
+ constructor(private readonly opts: WedgeOptions = DEFAULT_WEDGE) {}
+
+ feed(char: string, atMs: number): void {
+ if (char.length !== 1) throw new Error('feed() takes single characters')
+ if (this.lastAtMs !== undefined) {
+ this.maxGapMs = Math.max(this.maxGapMs, atMs - this.lastAtMs)
+ }
+ this.lastAtMs = atMs
+ this.chars.push(char)
+ }
+
+ /** Call on Enter. Decides scan vs typed and resets for the next burst. */
+ terminate(): WedgeResult {
+ const text = this.chars.join('')
+ const isScan =
+ this.chars.length >= this.opts.minLength && this.maxGapMs <= this.opts.maxInterKeyMs
+ this.reset()
+ return isScan ? { type: 'scan', code: text } : { type: 'typed', text }
+ }
+
+ reset(): void {
+ this.chars = []
+ this.lastAtMs = undefined
+ this.maxGapMs = 0
+ }
+}
diff --git a/packages/scanning/test/scanning.test.ts b/packages/scanning/test/scanning.test.ts
new file mode 100644
index 0000000..e93a0a4
--- /dev/null
+++ b/packages/scanning/test/scanning.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import {
+ ean13CheckDigit, isValidEan13,
+ parseScaleBarcode, type ScaleBarcodeProfile,
+ parseEntry,
+ WedgeDetector,
+} from '@sims/scanning'
+
+describe('EAN-13', () => {
+ it('computes and validates check digits', () => {
+ expect(ean13CheckDigit('400638133393')).toBe(1)
+ expect(isValidEan13('4006381333931')).toBe(true)
+ expect(isValidEan13('4006381333932')).toBe(false)
+ expect(isValidEan13('123')).toBe(false)
+ })
+})
+
+describe('label-scale barcodes (2x prefix)', () => {
+ const profile: ScaleBarcodeProfile = {
+ prefixes: ['21'],
+ pluLength: 5,
+ valueKind: 'weight',
+ valueDivisor: 1000,
+ }
+ const body = '210004201250' // prefix 21 · PLU 00042 · 1250 g
+ const code = body + String(ean13CheckDigit(body))
+
+ it('extracts PLU and weight as quantity', () => {
+ expect(parseScaleBarcode(code, profile)).toEqual({ kind: 'scale-weight', plu: '00042', qty: 1.25 })
+ })
+ it('rejects a corrupted check digit instead of guessing a weight', () => {
+ const bad = body + String((ean13CheckDigit(body) + 1) % 10)
+ expect(parseScaleBarcode(bad, profile)).toEqual({ kind: 'invalid', reason: 'check-digit' })
+ })
+ it('passes non-scale codes through untouched', () => {
+ expect(parseScaleBarcode('4006381333931', profile)).toEqual({ kind: 'not-scale' })
+ })
+ it('supports price-embedded profiles', () => {
+ const priceProfile: ScaleBarcodeProfile = { ...profile, valueKind: 'price', valueDivisor: 1 }
+ expect(parseScaleBarcode(code, priceProfile)).toEqual({
+ kind: 'scale-price',
+ plu: '00042',
+ pricePaise: 1250,
+ })
+ })
+})
+
+describe('scan-box entry grammar', () => {
+ it('parses the four grammars without modes', () => {
+ expect(parseEntry('')).toEqual({ kind: 'empty' })
+ expect(parseEntry('3*')).toEqual({ kind: 'multiplier', qty: 3, rest: '' })
+ expect(parseEntry('1.5*atta')).toEqual({ kind: 'multiplier', qty: 1.5, rest: 'atta' })
+ expect(parseEntry('4006381333931')).toEqual({ kind: 'barcode', code: '4006381333931' })
+ expect(parseEntry('42')).toEqual({ kind: 'code', code: '42' })
+ expect(parseEntry('chawal')).toEqual({ kind: 'search', text: 'chawal' })
+ })
+})
+
+describe('wedge detector — scanner vs human, by timing', () => {
+ it('classifies a fast burst as a scan', () => {
+ const d = new WedgeDetector()
+ '4006381333931'.split('').forEach((c, i) => d.feed(c, i * 10))
+ expect(d.terminate()).toEqual({ type: 'scan', code: '4006381333931' })
+ })
+ it('classifies human-speed typing as typed input', () => {
+ const d = new WedgeDetector()
+ '400638'.split('').forEach((c, i) => d.feed(c, i * 120))
+ expect(d.terminate()).toEqual({ type: 'typed', text: '400638' })
+ })
+ it('treats short fast bursts as typed (quick-key PLUs)', () => {
+ const d = new WedgeDetector()
+ '42'.split('').forEach((c, i) => d.feed(c, i * 5))
+ expect(d.terminate()).toEqual({ type: 'typed', text: '42' })
+ })
+ it('resets cleanly between bursts', () => {
+ const d = new WedgeDetector()
+ d.feed('9', 0)
+ d.terminate()
+ '4006381333931'.split('').forEach((c, i) => d.feed(c, 1000 + i * 10))
+ expect(d.terminate().type).toBe('scan')
+ })
+})
diff --git a/packages/search-core/package.json b/packages/search-core/package.json
new file mode 100644
index 0000000..26ff5b6
--- /dev/null
+++ b/packages/search-core/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@sims/search-core",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "dependencies": {
+ "@sims/domain": "*"
+ }
+}
diff --git a/packages/search-core/src/index.ts b/packages/search-core/src/index.ts
new file mode 100644
index 0000000..0c8284d
--- /dev/null
+++ b/packages/search-core/src/index.ts
@@ -0,0 +1,100 @@
+import type { Item } from '@sims/domain'
+
+/**
+ * Catalog search (13-SPEC-BILLING-POLISH §P0-1): one pure engine, two profiles —
+ * the POS scan-box dropdown today, the back-office Ctrl+K later. No deps, no
+ * network, no debounce: an in-memory index and a synchronous tiered query so the
+ * counter never waits on a keystroke.
+ */
+
+/** lowercase, punctuation → space, spaces collapsed. Digits kept (e.g. "atta 5kg"). */
+export function normalize(s: string): string {
+ return s
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+/**
+ * The Indic phonetic fold (§5.1): collapses the spellings the same word acquires
+ * across scripts and transliterations so `chawal`/`chaval`, `paneer`/`panir`,
+ * `jeera`/`zeera` share one key. Digraph→letter rules run before the
+ * doubled-letter drop so long vowels fold to the intended short vowel first.
+ */
+export function phoneticKey(s: string): string {
+ let w = normalize(s).replace(/[^a-z]/g, '')
+ w = w.replace(/chh/g, 'ch')
+ w = w.replace(/aa/g, 'a')
+ w = w.replace(/ee/g, 'i')
+ w = w.replace(/oo/g, 'u')
+ w = w.replace(/ph/g, 'f')
+ w = w.replace(/bh/g, 'b')
+ w = w.replace(/sh/g, 's')
+ w = w.replace(/th/g, 't')
+ w = w.replace(/dh/g, 'd')
+ w = w.replace(/w/g, 'v')
+ w = w.replace(/z/g, 'j')
+ w = w.replace(/(.)\1+/g, '$1')
+ return w
+}
+
+interface IndexedItem {
+ item: Item
+ name: string
+ secondary: string
+ tokens: string[]
+ namePhonetic: string
+ tokenPhonetics: string[]
+}
+
+export interface SearchIndex {
+ items: IndexedItem[]
+}
+
+/** Precompute tokens + phonetic keys once; the POS memoizes this on its item cache. */
+export function buildIndex(items: Item[]): SearchIndex {
+ return {
+ items: items.map((item) => {
+ const name = normalize(item.name)
+ const secondary = item.nameSecondary !== undefined ? normalize(item.nameSecondary) : ''
+ const tokens = `${name} ${secondary}`.split(' ').filter((t) => t !== '')
+ return {
+ item, name, secondary, tokens,
+ namePhonetic: phoneticKey(name),
+ tokenPhonetics: tokens.map((t) => phoneticKey(t)),
+ }
+ }),
+ }
+}
+
+// Lower tier wins (1 is best). Whole-prefix → token-prefix → phonetic → substring.
+const TIER_WHOLE_PREFIX = 1
+const TIER_TOKEN_PREFIX = 2
+const TIER_PHONETIC = 3
+const TIER_SUBSTRING = 4
+
+function tierOf(e: IndexedItem, q: string, pq: string): number {
+ if (e.name.startsWith(q)) return TIER_WHOLE_PREFIX
+ if (e.tokens.some((t) => t.startsWith(q))) return TIER_TOKEN_PREFIX
+ if (pq !== '' && (e.namePhonetic.startsWith(pq) || e.tokenPhonetics.some((p) => p.startsWith(pq)))) return TIER_PHONETIC
+ if (e.name.includes(q) || e.secondary.includes(q)) return TIER_SUBSTRING
+ return 0
+}
+
+/**
+ * Tier-ranked matches for `text`. Tie-break: shorter name first, then A→Z
+ * (velocity ranking lands with P1-5). Caller slices to its row budget (≤ 8).
+ */
+export function querySearch(index: SearchIndex, text: string): Item[] {
+ const q = normalize(text)
+ if (q === '') return []
+ const pq = phoneticKey(q)
+ const ranked: { item: Item; tier: number; len: number; name: string }[] = []
+ for (const e of index.items) {
+ const tier = tierOf(e, q, pq)
+ if (tier !== 0) ranked.push({ item: e.item, tier, len: e.item.name.length, name: e.name })
+ }
+ ranked.sort((a, b) => a.tier - b.tier || a.len - b.len || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
+ return ranked.map((r) => r.item)
+}
diff --git a/packages/search-core/test/search-core.test.ts b/packages/search-core/test/search-core.test.ts
new file mode 100644
index 0000000..fcc17ab
--- /dev/null
+++ b/packages/search-core/test/search-core.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest'
+import type { Item } from '@sims/domain'
+import { buildIndex, normalize, phoneticKey, querySearch } from '@sims/search-core'
+
+function item(name: string, extra: Partial- = {}): Item {
+ return {
+ id: name, tenantId: 't1', code: name.replace(/\s+/g, '').toLowerCase(), name,
+ hsn: '', taxClassCode: 'GST18', unit: { code: 'PCS', decimals: 0 },
+ salePricePaise: 1000, priceIncludesTax: true, barcodes: [], batchTracked: false,
+ status: 'active', ...extra,
+ }
+}
+
+const names = (items: Item[]): string[] => items.map((i) => i.name)
+
+describe('normalize', () => {
+ it('lowercases, strips punctuation, collapses spaces, keeps digits', () => {
+ expect(normalize(' Aashirvaad Atta, 5kg! ')).toBe('aashirvaad atta 5kg')
+ expect(normalize('Coca-Cola')).toBe('coca cola')
+ })
+})
+
+describe('phoneticKey — Indic folding', () => {
+ it('folds the mandated collisions to one key', () => {
+ expect(phoneticKey('chawal')).toBe(phoneticKey('chaval')) // w→v (the mandatory case)
+ expect(phoneticKey('paneer')).toBe(phoneticKey('panir')) // ee→i
+ expect(phoneticKey('jeera')).toBe(phoneticKey('zeera')) // z→j + ee→i
+ expect(phoneticKey('phal')).toBe(phoneticKey('fal')) // ph→f
+ expect(phoneticKey('sharbat')).toBe(phoneticKey('sarbat')) // sh→s
+ expect(phoneticKey('dhaniya')).toBe(phoneticKey('daniya')) // dh→d
+ expect(phoneticKey('bhindi')).toBe(phoneticKey('bindi')) // bh→b
+ expect(phoneticKey('thums')).toBe(phoneticKey('tums')) // th→t
+ })
+ it('folds long vowels to short', () => {
+ expect(phoneticKey('aata')).toBe('ata') // aa→a
+ expect(phoneticKey('cheese')).toBe('chise') // ee→i (trailing vowel kept)
+ expect(phoneticKey('noodle')).toBe(phoneticKey('nudle')) // oo→u
+ })
+ it('drops doubled letters', () => {
+ expect(phoneticKey('kotta')).toBe(phoneticKey('kota'))
+ expect(phoneticKey('mummy')).toBe('mumy')
+ })
+ it('is stable for already-folded input', () => {
+ expect(phoneticKey('chaval')).toBe('chaval')
+ })
+})
+
+describe('querySearch — tiered ranking', () => {
+ const cat = [
+ item('Tomato'),
+ item('Tomato Ketchup'),
+ item('Green Tomato'),
+ item('Bottom Shelf Oil'),
+ ]
+ const index = buildIndex(cat)
+
+ it('orders whole-prefix before token-prefix before substring', () => {
+ const out = names(querySearch(index, 'tom'))
+ // tier 1 (name prefix, shorter first), tier 2 (token prefix), tier 4 (substring)
+ expect(out).toEqual(['Tomato', 'Tomato Ketchup', 'Green Tomato', 'Bottom Shelf Oil'])
+ })
+
+ it('breaks ties on the shorter name', () => {
+ const out = names(querySearch(buildIndex([item('Tomato Ketchup'), item('Tomato')]), 'tomato'))
+ expect(out).toEqual(['Tomato', 'Tomato Ketchup'])
+ })
+
+ it('returns nothing for an empty query', () => {
+ expect(querySearch(index, ' ')).toEqual([])
+ })
+})
+
+describe('querySearch — phonetic tier finds transliteration variants', () => {
+ it('finds a Chawal-spelled item when the cashier types "chaval" and vice versa', () => {
+ const index = buildIndex([item('Chawal Basmati'), item('Sugar')])
+ expect(names(querySearch(index, 'chaval'))).toContain('Chawal Basmati')
+ const index2 = buildIndex([item('Chaval Premium'), item('Sugar')])
+ expect(names(querySearch(index2, 'chawal'))).toContain('Chaval Premium')
+ })
+
+ it('ranks an exact prefix above a phonetic-only match', () => {
+ const index = buildIndex([item('Chaval Rice'), item('Chawal Gold')])
+ // typing "chaw": "Chawal Gold" is a whole-prefix (tier 1), "Chaval Rice" only phonetic (tier 3)
+ expect(names(querySearch(index, 'chaw'))).toEqual(['Chawal Gold', 'Chaval Rice'])
+ })
+
+ it('matches against the regional secondary name', () => {
+ const index = buildIndex([item('Coriander', { nameSecondary: 'Dhaniya' })])
+ expect(names(querySearch(index, 'daniya'))).toContain('Coriander')
+ })
+})
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 0000000..96f755a
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@sims/ui",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "dependencies": {
+ "@fontsource-variable/inter": "^5.1.0",
+ "@fontsource-variable/jetbrains-mono": "^5.1.0",
+ "react": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0"
+ }
+}
diff --git a/packages/ui/src/ThemeSwitcher.tsx b/packages/ui/src/ThemeSwitcher.tsx
new file mode 100644
index 0000000..1ca5bba
--- /dev/null
+++ b/packages/ui/src/ThemeSwitcher.tsx
@@ -0,0 +1,46 @@
+import { useState } from 'react'
+import { ACCENTS, getAccent, getThemeMode, setAccent, setThemeMode, type Accent } from './theme'
+
+const SWATCH: Record
= {
+ indigo: '#4f5df0',
+ blue: '#2f7ce8',
+ emerald: '#14957a',
+ violet: '#8250df',
+ rose: '#d4497c',
+ amber: '#b97e10',
+}
+
+/** Compact mode toggle + accent picker; drops into any header. */
+export function ThemeSwitcher() {
+ const [mode, setMode] = useState(getThemeMode())
+ const [accent, setAccentState] = useState(getAccent())
+
+ return (
+
+ {ACCENTS.map((a) => (
+ {
+ setAccent(a)
+ setAccentState(a)
+ }}
+ />
+ ))}
+ {
+ const next = mode === 'light' ? 'dark' : 'light'
+ setThemeMode(next)
+ setMode(next)
+ }}
+ >
+ {mode === 'light' ? '☾ Dark' : '☀ Light'}
+
+
+ )
+}
diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx
new file mode 100644
index 0000000..4d9f133
--- /dev/null
+++ b/packages/ui/src/components.tsx
@@ -0,0 +1,115 @@
+import type { ReactNode } from 'react'
+
+/** Wireframe kit: structure-first components shared by POS and back office. */
+
+export function PageHeader(props: { title: string; desc?: string; badge?: string; actions?: ReactNode }) {
+ return (
+ <>
+
+
{props.title}
+ {props.badge !== undefined &&
{props.badge} }
+
+ {props.actions}
+
+ {props.desc !== undefined && {props.desc}
}
+ >
+ )
+}
+
+export function Toolbar(props: { children: ReactNode }) {
+ return {props.children}
+}
+
+export function Button(props: {
+ children: ReactNode
+ onClick?: () => void
+ tone?: 'default' | 'primary' | 'danger'
+ hotkey?: string
+}) {
+ const cls = `wf${props.tone === 'primary' ? ' primary' : props.tone === 'danger' ? ' danger' : ''}`
+ return (
+
+ {props.children}
+ {props.hotkey !== undefined && {props.hotkey} }
+
+ )
+}
+
+export function Badge(props: { children: ReactNode; tone?: 'ok' | 'warn' | 'err' | 'accent' }) {
+ return {props.children}
+}
+
+export interface Column {
+ key: string
+ label: string
+ numeric?: boolean
+}
+
+export function DataTable(props: {
+ columns: Column[]
+ rows: Record[]
+ onRowClick?: (row: Record, index: number) => void
+}) {
+ return (
+
+
+
+ {props.columns.map((c) => (
+ {c.label}
+ ))}
+
+
+
+ {props.rows.map((row, i) => (
+ props.onRowClick?.(row, i)} style={props.onRowClick !== undefined ? { cursor: 'pointer' } : undefined}>
+ {props.columns.map((c) => (
+ {row[c.key]}
+ ))}
+
+ ))}
+
+
+ )
+}
+
+export function StatCard(props: { label: string; value: ReactNode; hint?: string }) {
+ return (
+
+
{props.label}
+
{props.value}
+ {props.hint !== undefined &&
{props.hint}
}
+
+ )
+}
+
+export function Stats(props: { children: ReactNode }) {
+ return {props.children}
+}
+
+export function EmptyState(props: { children: ReactNode }) {
+ return {props.children}
+}
+
+export function Field(props: { label: string; children: ReactNode }) {
+ return (
+
+ {props.label}
+ {props.children}
+
+ )
+}
+
+export function Modal(props: { title: string; onClose: () => void; children: ReactNode }) {
+ return (
+
+
e.stopPropagation()}>
+
{props.title}
+ {props.children}
+
+
+ )
+}
+
+export function Notice(props: { tone?: 'ok' | 'warn' | 'err'; children: ReactNode }) {
+ return {props.children}
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
new file mode 100644
index 0000000..e0f1319
--- /dev/null
+++ b/packages/ui/src/index.ts
@@ -0,0 +1,3 @@
+export * from './components'
+export * from './theme'
+export * from './ThemeSwitcher'
diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts
new file mode 100644
index 0000000..674a955
--- /dev/null
+++ b/packages/ui/src/theme.ts
@@ -0,0 +1,37 @@
+/**
+ * Theme runtime: mode (light/dark) + accent, applied as data attributes on
+ * and persisted. localStorage for now; these become user-scope settings
+ * rows once the DB layer lands (config-driven principle).
+ */
+export type ThemeMode = 'light' | 'dark'
+
+export const ACCENTS = ['indigo', 'blue', 'emerald', 'violet', 'rose', 'amber'] as const
+export type Accent = (typeof ACCENTS)[number]
+
+const MODE_KEY = 'ui.themeMode'
+const ACCENT_KEY = 'ui.accent'
+
+export function getThemeMode(): ThemeMode {
+ return localStorage.getItem(MODE_KEY) === 'dark' ? 'dark' : 'light'
+}
+
+export function getAccent(): Accent {
+ const stored = localStorage.getItem(ACCENT_KEY)
+ return (ACCENTS as readonly string[]).includes(stored ?? '') ? (stored as Accent) : 'indigo'
+}
+
+export function setThemeMode(mode: ThemeMode): void {
+ localStorage.setItem(MODE_KEY, mode)
+ document.documentElement.dataset['theme'] = mode
+}
+
+export function setAccent(accent: Accent): void {
+ localStorage.setItem(ACCENT_KEY, accent)
+ document.documentElement.dataset['accent'] = accent
+}
+
+/** Call once before first render. */
+export function initTheme(): void {
+ document.documentElement.dataset['theme'] = getThemeMode()
+ document.documentElement.dataset['accent'] = getAccent()
+}
diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css
new file mode 100644
index 0000000..078abd2
--- /dev/null
+++ b/packages/ui/src/tokens.css
@@ -0,0 +1,240 @@
+/* ================================================================
+ SiMS Next design system — premium, themeable, offline-safe.
+ References: Stripe Dashboard (light clarity), Linear (dark, crisp
+ hairlines), Notion (neutral warmth). Fonts are bundled (Inter
+ Variable + JetBrains Mono Variable) — shop fonts are never trusted.
+ Mode + accent apply via (theme.ts).
+ Class names are stable: restyling here restyles every screen.
+ ================================================================ */
+
+:root {
+ --font: 'Inter Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
+ --mono: 'JetBrains Mono Variable', 'Cascadia Mono', Consolas, monospace;
+
+ --radius: 8px;
+ --radius-lg: 14px;
+ --pad: 12px;
+ --speed: 140ms;
+
+ --shadow-sm: 0 1px 2px rgba(16, 20, 28, 0.06);
+ --shadow-md: 0 2px 8px rgba(16, 20, 28, 0.08), 0 1px 2px rgba(16, 20, 28, 0.05);
+ --shadow-lg: 0 16px 48px rgba(16, 20, 28, 0.22), 0 4px 12px rgba(16, 20, 28, 0.1);
+}
+
+/* ---- accents (option to change colour) ---- */
+[data-accent='indigo'] { --accent: #4f5df0; --accent-strong: #3f4cd9; --accent-contrast: #ffffff; }
+[data-accent='blue'] { --accent: #2f7ce8; --accent-strong: #2368ca; --accent-contrast: #ffffff; }
+[data-accent='emerald'] { --accent: #14957a; --accent-strong: #0f7f68; --accent-contrast: #ffffff; }
+[data-accent='violet'] { --accent: #8250df; --accent-strong: #6f42c1; --accent-contrast: #ffffff; }
+[data-accent='rose'] { --accent: #d4497c; --accent-strong: #bb3a6a; --accent-contrast: #ffffff; }
+[data-accent='amber'] { --accent: #b97e10; --accent-strong: #a06a08; --accent-contrast: #ffffff; }
+
+/* ---- light (default): Stripe-like clarity ---- */
+:root, [data-theme='light'] {
+ --bg: #f6f7f9;
+ --bg-raised: #ffffff;
+ --bg-hover: #f0f2f5;
+ --bg-inset: #eef0f3;
+ --border: #e4e7ec;
+ --border-strong: #d4d9e0;
+ --text: #1a1f27;
+ --text-dim: #667084;
+ --ok: #147a52;
+ --warn: #9a6700;
+ --err: #c93a3a;
+ --accent-soft: color-mix(in srgb, var(--accent) 10%, var(--bg-raised));
+ --ok-soft: #e5f5ec;
+ --warn-soft: #fcf3dc;
+ --err-soft: #fdecec;
+ --scrollbar: #c9cfd8;
+ color-scheme: light;
+}
+
+/* ---- dark: Linear-like depth ---- */
+[data-theme='dark'] {
+ --bg: #0e1116;
+ --bg-raised: #161a21;
+ --bg-hover: #1e242e;
+ --bg-inset: #0a0d11;
+ --border: #262c37;
+ --border-strong: #333b49;
+ --text: #e8ecf1;
+ --text-dim: #8b95a6;
+ --ok: #43c288;
+ --warn: #e6b23e;
+ --err: #ef6a63;
+ --accent: color-mix(in srgb, var(--accent-strong) 20%, #7c8cff);
+ --accent-soft: color-mix(in srgb, var(--accent) 16%, var(--bg-raised));
+ --ok-soft: color-mix(in srgb, #43c288 14%, var(--bg-raised));
+ --warn-soft: color-mix(in srgb, #e6b23e 14%, var(--bg-raised));
+ --err-soft: color-mix(in srgb, #ef6a63 14%, var(--bg-raised));
+ --scrollbar: #333b49;
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
+ --shadow-md: 0 2px 10px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6);
+ color-scheme: dark;
+}
+
+/* ================= base ================= */
+* { box-sizing: border-box; }
+html, body, #root { height: 100%; margin: 0; }
+body {
+ background: var(--bg);
+ color: var(--text);
+ font: 400 14px/1.5 var(--font);
+ font-feature-settings: 'cv05', 'cv11';
+ -webkit-font-smoothing: antialiased;
+ transition: background var(--speed) ease, color var(--speed) ease;
+}
+h1, h2, h3 { letter-spacing: -0.015em; font-weight: 600; }
+::selection { background: color-mix(in srgb, var(--accent) 25%, transparent); }
+:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 1px; }
+* { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
+@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
+
+.num {
+ font-family: var(--font);
+ font-variant-numeric: tabular-nums lining-nums;
+ letter-spacing: -0.005em;
+}
+
+/* ================= controls ================= */
+button.wf {
+ background: var(--bg-raised); color: var(--text);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius); padding: 7px 14px;
+ font: 500 13.5px/1.45 var(--font); cursor: pointer;
+ box-shadow: var(--shadow-sm);
+ transition: background var(--speed), border-color var(--speed), box-shadow var(--speed), transform 80ms;
+}
+button.wf:hover { background: var(--bg-hover); border-color: color-mix(in srgb, var(--accent) 35%, var(--border-strong)); }
+button.wf:active { transform: translateY(1px); box-shadow: none; }
+button.wf.primary {
+ background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 92%, #fff), var(--accent-strong));
+ border-color: var(--accent-strong); color: var(--accent-contrast); font-weight: 600;
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--accent-strong) 45%, transparent);
+}
+button.wf.primary:hover { filter: brightness(1.06); }
+button.wf.danger { border-color: color-mix(in srgb, var(--err) 55%, var(--border-strong)); color: var(--err); }
+button.wf.danger:hover { background: var(--err-soft); }
+button.wf kbd {
+ background: color-mix(in srgb, currentColor 14%, transparent);
+ border-radius: 4px; padding: 0 5px; margin-left: 8px;
+ font-family: var(--mono); font-size: 10.5px; font-weight: 500;
+}
+
+input.wf, select.wf {
+ background: var(--bg-raised); color: var(--text);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius); padding: 7px 11px;
+ font: 400 13.5px/1.45 var(--font); width: 100%;
+ box-shadow: inset 0 1px 2px rgba(16, 20, 28, 0.04);
+ transition: border-color var(--speed), box-shadow var(--speed);
+}
+input.wf::placeholder { color: var(--text-dim); }
+input.wf:focus, select.wf:focus {
+ outline: none; border-color: var(--accent);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
+}
+
+/* ================= page scaffolding ================= */
+.wf-page { padding: 24px 28px; max-width: 1400px; }
+.wf-page-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; }
+.wf-page-head h1 { font-size: 21px; margin: 0; }
+.wf-page-desc { color: var(--text-dim); margin: 0 0 18px; max-width: 72ch; }
+.wf-toolbar { display: flex; gap: 8px; align-items: center; margin: 14px 0; flex-wrap: wrap; }
+.wf-toolbar .spacer { flex: 1; }
+
+/* ================= tables ================= */
+table.wf {
+ border-collapse: separate; border-spacing: 0; width: 100%;
+ background: var(--bg-raised); border: 1px solid var(--border);
+ border-radius: 10px; overflow: hidden; box-shadow: var(--shadow-sm);
+}
+table.wf th, table.wf td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); }
+table.wf tr:last-child td { border-bottom: none; }
+table.wf th {
+ background: color-mix(in srgb, var(--bg) 55%, var(--bg-raised));
+ color: var(--text-dim); font-weight: 600; font-size: 11px;
+ text-transform: uppercase; letter-spacing: 0.06em;
+}
+table.wf tbody tr { transition: background var(--speed); }
+table.wf tbody tr:hover td { background: var(--accent-soft); }
+table.wf td.num, table.wf th.num { text-align: right; }
+
+/* ================= badges ================= */
+.wf-badge {
+ display: inline-block; border-radius: 999px; padding: 2px 10px;
+ font-size: 12px; font-weight: 500;
+ background: var(--bg-inset); color: var(--text-dim);
+ border: 1px solid var(--border);
+}
+.wf-badge.ok { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
+.wf-badge.warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
+.wf-badge.err { color: var(--err); background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 25%, transparent); }
+.wf-badge.accent { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
+[data-theme='dark'] .wf-badge.accent { color: var(--accent); }
+
+/* ================= stat cards ================= */
+.wf-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin: 16px 0; }
+.wf-stat {
+ background: var(--bg-raised); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); padding: 16px;
+ box-shadow: var(--shadow-sm);
+ transition: box-shadow var(--speed), transform var(--speed), border-color var(--speed);
+}
+.wf-stat:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); border-color: var(--border-strong); }
+.wf-stat .label { color: var(--text-dim); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
+.wf-stat .value { font-size: 27px; font-weight: 650; letter-spacing: -0.02em; margin-top: 6px; }
+.wf-stat .hint { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; }
+
+/* ================= misc surfaces ================= */
+.wf-empty {
+ border: 1px dashed var(--border-strong); border-radius: var(--radius-lg);
+ padding: 38px; text-align: center; color: var(--text-dim); margin: 14px 0;
+ background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
+}
+
+.wf-field { margin-bottom: 13px; }
+.wf-field > label {
+ display: block; color: var(--text-dim); font-size: 12px; font-weight: 550;
+ margin-bottom: 5px; letter-spacing: 0.01em;
+}
+
+.wf-modal-back {
+ position: fixed; inset: 0; background: rgba(10, 13, 18, 0.45);
+ backdrop-filter: blur(5px);
+ display: flex; align-items: center; justify-content: center; z-index: 50;
+}
+.wf-modal {
+ background: var(--bg-raised); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); padding: 22px;
+ min-width: 430px; max-width: 740px; max-height: 85vh; overflow: auto;
+ box-shadow: var(--shadow-lg);
+}
+.wf-modal h2 { margin: 0 0 14px; font-size: 17px; }
+
+.wf-notice {
+ border-radius: var(--radius); padding: 9px 13px; margin: 10px 0;
+ border: 1px solid var(--border); background: var(--bg-raised);
+ font-size: 13px; box-shadow: var(--shadow-sm);
+}
+.wf-notice.ok { border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-soft); }
+.wf-notice.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-soft); }
+.wf-notice.err { border-color: color-mix(in srgb, var(--err) 35%, transparent); background: var(--err-soft); }
+
+/* ================= theme switcher ================= */
+.wf-themer { display: flex; align-items: center; gap: 6px; }
+.wf-themer .swatch {
+ width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
+ border: 2px solid transparent; padding: 0;
+ transition: transform 80ms, border-color var(--speed);
+}
+.wf-themer .swatch:hover { transform: scale(1.18); }
+.wf-themer .swatch.active { border-color: var(--text); }
+.wf-themer .mode {
+ background: var(--bg-inset); border: 1px solid var(--border);
+ border-radius: 999px; padding: 3px 10px; cursor: pointer;
+ font: 500 12px var(--font); color: var(--text-dim);
+}
+.wf-themer .mode:hover { color: var(--text); border-color: var(--border-strong); }
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000..a6d209b
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "types": ["node"],
+ "baseUrl": ".",
+ "paths": {
+ "@sims/domain": ["packages/domain/src"],
+ "@sims/billing-engine": ["packages/billing-engine/src"],
+ "@sims/config": ["packages/config/src"],
+ "@sims/auth/permissions": ["packages/auth/src/permissions"],
+ "@sims/auth": ["packages/auth/src"],
+ "@sims/scanning": ["packages/scanning/src"],
+ "@sims/search-core": ["packages/search-core/src"],
+ "@sims/printing": ["packages/printing/src"],
+ "@sims/ui": ["packages/ui/src"]
+ }
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..6b011c0
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.base.json",
+ "include": [
+ "packages/*/src/**/*.ts",
+ "packages/*/test/**/*.ts"
+ ]
+}