chore: bring the full store-product codebase under version control

POS, back office, store-server, all packages, workspace configs, README/
BUILDING, and the WIP HQ-2 plan — everything except gitignored data
(client import staging, .env, local DBs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent fbf39d7a51
commit 406839659d

2
.gitignore vendored

@ -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

@ -0,0 +1,101 @@
# Building SiMS Next
## Layout
```
Store Software/
├─ docs/ planning docs 0011 (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
`<html data-theme data-accent>`. 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.

@ -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.

@ -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.

@ -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.

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SiMS Next — Back Office</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -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"
}
}

@ -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<HTMLInputElement>(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 (
<div className="bo-shell">
<nav className="bo-side">
<div className="bo-brand">
SiMS Next
<small>Back Office · wireframe</small>
</div>
{NAV.map((mod) => (
<div key={mod.key}>
<div className="bo-module">{mod.icon} {mod.label}</div>
{mod.pages.map((p) => (
<NavLink key={p.path} to={p.path} className={({ isActive }) => (isActive ? 'active' : '')} end={p.path === '/'}>
{p.label}
{isLocked(p) && <span className="lock">🔒 {p.edition}</span>}
</NavLink>
))}
</div>
))}
</nav>
<div className="bo-main">
<header className="bo-top">
<input ref={searchRef} className="wf" placeholder="Search everything… (Ctrl+K)" />
<span style={{ flex: 1 }} />
<select className="wf" style={{ width: 170 }} defaultValue="ST1">
<option value="ST1">ST1 · T.Nagar</option>
<option value="ST2">ST2 · Velachery</option>
<option value="all">All stores</option>
</select>
<ThemeSwitcher />
<Badge tone="accent">Pro</Badge>
<Badge>{props.userName}</Badge>
<button type="button" className="wf" onClick={props.onLogout}>Logout</button>
</header>
<div className="bo-content">
<Outlet />
</div>
</div>
</div>
)
}

@ -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<string | undefined>()
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 (
<div className="login-wrap">
<div className="login-card">
<h2 style={{ marginTop: 0 }}>SiMS Next Back Office</h2>
<Field label="Username">
<input className="wf" value={user} onChange={(e) => setUser(e.target.value)} />
</Field>
<Field label="Password">
<input
className="wf" type="password" value={pw}
onChange={(e) => setPw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && void submit()}
/>
</Field>
<Button tone="primary" onClick={() => void submit()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
</div>
)
}

@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<string> {
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<Record<string, unknown>[]> =>
call(`/items${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`)
export const createItem = (body: Record<string, unknown>): Promise<Record<string, unknown>> =>
call('/items', { method: 'POST', body: JSON.stringify(body) })
export const getParties = (kind?: string): Promise<Record<string, unknown>[]> =>
call(`/parties${kind !== undefined ? `?kind=${kind}` : ''}`)
export const getBills = (date?: string, limit?: number, offset?: number): Promise<Record<string, unknown>[]> => {
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<Record<string, unknown>[]> => call('/stock')
export const getAudit = (limit?: number, offset?: number): Promise<Record<string, unknown>[]> => {
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<Record<string, unknown>[]> => call('/purchases')
export const getLastCosts = (supplierId?: string): Promise<Record<string, { last?: number; supplierLast?: number }>> =>
call(`/purchases/last-costs${supplierId !== undefined ? `?supplierId=${supplierId}` : ''}`)
export const postPurchase = (body: Record<string, unknown>): 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<Seller> =>
call<{ store: Seller }>('/bootstrap?counter=C1').then((b) => b.store)

@ -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; }

@ -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<string, () => 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<string | undefined>()
if (userName === undefined) return <Login onLogin={setUserName} />
return (
<HashRouter>
<Routes>
<Route element={<Layout userName={userName} onLogout={() => { clearSession(); setUserName(undefined) }} />}>
<Route path="/" element={<Dashboard />} />
{Object.entries(CUSTOM).map(([path, Page]) => (
<Route key={path} path={path} element={<Page />} />
))}
{PAGES.filter((p) => !(p.path in CUSTOM)).map((p) => (
<Route key={p.path} path={p.path} element={<GenericPage cfg={p} />} />
))}
<Route path="*" element={<Dashboard />} />
</Route>
</Routes>
</HashRouter>
)
}
initTheme()
createRoot(document.getElementById('root')!).render(<App />)

@ -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)
}

@ -0,0 +1,51 @@
import { Badge, DataTable, PageHeader, StatCard, Stats } from '@sims/ui'
export function Dashboard() {
return (
<div className="wf-page">
<PageHeader
title="Dashboard"
desc="Today across counters and stores — alerts first, numbers second (09-UX §2)."
/>
<Stats>
<StatCard label="Today's sales" value="₹1,84,320" hint="412 bills · +8% vs last Wed" />
<StatCard label="Avg bill value" value="₹447" hint="items/bill 6.2" />
<StatCard label="Cash / UPI split" value="50 / 33%" hint="card 10% · khata 7%" />
<StatCard label="Khata outstanding" value="₹86,420" hint="₹12,340 over 30 days" />
<StatCard label="Low stock items" value="23" hint="6 out of stock" />
<StatCard label="Near expiry (7d)" value="₹4,320" hint="18 batches" />
</Stats>
<h3>Alerts</h3>
<DataTable
columns={[
{ key: 'sev', label: '' },
{ key: 'what', label: 'Alert' },
{ key: 'where', label: 'Where' },
{ key: 'age', label: 'Age' },
]}
rows={[
{ sev: <Badge tone="err">high</Badge>, what: 'Coca-Cola 1.25L out of stock, weekend ahead', where: 'ST1', age: '2 h' },
{ sev: <Badge tone="warn">med</Badge>, what: 'ST2-C1 sync lag 22 min (LAN?)', where: 'ST2', age: '25 min' },
{ sev: <Badge tone="warn">med</Badge>, what: '17 draft items from POS quick-add need completion', where: 'Catalog', age: 'today' },
{ sev: <Badge tone="warn">med</Badge>, what: 'e-Invoice GSP error on ST1C1/26-000355', where: 'GST', age: '3 h' },
{ sev: <Badge>info</Badge>, what: 'Onam scheme starts 20 Aug — stock plan?', where: 'Schemes', age: '—' },
]}
/>
<h3 style={{ marginTop: 20 }}>Counters now</h3>
<DataTable
columns={[
{ key: 'counter', label: 'Counter' },
{ key: 'cashier', label: 'Cashier' },
{ key: 'bills', label: 'Bills', numeric: true },
{ key: 'last', label: 'Last bill' },
{ key: 'state', label: 'State' },
]}
rows={[
{ counter: 'ST1-C1', cashier: 'Divya', bills: '198', last: '18:41', state: <Badge tone="ok">billing</Badge> },
{ counter: 'ST1-C2', cashier: 'Ramesh', bills: '214', last: '18:42', state: <Badge tone="ok">billing</Badge> },
{ counter: 'ST2-C1', cashier: 'Manu', bills: '86', last: '18:20', state: <Badge tone="warn">sync lag</Badge> },
]}
/>
</div>
)
}

@ -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 (
<div className="wf-page">
<PageHeader
title={cfg.title}
desc={cfg.desc}
badge={locked ? `${navPage.edition === 'E' ? 'Enterprise' : 'Higher plan'}` : undefined}
actions={<>{cfg.actions.map((a) => <Button key={a}>{a}</Button>)}</>}
/>
{locked && (
<div className="upsell">
<b>This is a {navPage.edition === 'E' ? 'Enterprise' : 'higher-plan'} feature.</b>
<p style={{ color: 'var(--text-dim)' }}>
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.
</p>
<Button tone="primary">See upgrade options</Button>
</div>
)}
{cfg.filters !== undefined && (
<Toolbar>
{cfg.filters.map((f) => (
<select key={f} className="wf" style={{ width: 'auto', minWidth: 120 }}>
<option>{f}: All</option>
</select>
))}
<span className="spacer" />
<input className="wf" style={{ maxWidth: 240 }} placeholder="Filter rows… ( / )" />
</Toolbar>
)}
{cfg.stats !== undefined && (
<Stats>
{cfg.stats.map((s) => <StatCard key={s.label} label={s.label} value={s.value} hint={s.hint} />)}
</Stats>
)}
{cfg.rows.length === 0 ? (
<EmptyState>No records yet wireframe.</EmptyState>
) : (
<DataTable columns={cfg.columns} rows={cfg.rows} onRowClick={() => undefined} />
)}
<Notice>
Wireframe page every record opens a detail drawer with its audit trail in the real build
(cross-page conventions, 09-UX §2).
</Notice>
</div>
)
}

@ -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 (
<div className="wf-page">
<PageHeader
title="Purchase Inbox"
desc="Suppliers email bills to bills@st1.sims.in (or drag a PDF/photo here). AI drafts the purchase; you confirm."
actions={<><Button>Upload bill</Button><Button>Email-in settings</Button></>}
/>
<Toolbar>
<Badge tone="warn">3 to review</Badge>
<Badge tone="ok">38 posted this month</Badge>
<Badge>item-match memory: 1,204 learned</Badge>
</Toolbar>
<div className="inbox-split">
<div>
<h3>Source HUL_Invoice_HD2651.pdf</h3>
<div className="inbox-doc">{`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`}</div>
</div>
<div>
<h3>Draft purchase review &amp; post</h3>
<DataTable
columns={[
{ key: 'supplier', label: 'Supplier line' },
{ key: 'ours', label: 'Our item' },
{ key: 'qty', label: 'Qty', numeric: true },
{ key: 'cost', label: 'Cost', numeric: true },
{ key: 'conf', label: 'Match' },
]}
rows={[
{ supplier: 'SURF EXCEL MATIC 1KG', ours: 'Surf Excel Matic 1kg (103)', qty: '48', cost: '122.00', conf: <Badge tone="ok">remembered</Badge> },
{ supplier: 'RIN BAR 250G B12', ours: 'Rin Bar 250g (207)', qty: '144', cost: '10.20', conf: <Badge tone="ok">98%</Badge> },
{ supplier: 'CLINIC+ SHAMPOO 340ML', ours: <em>choose item</em>, qty: '24', cost: '168.00', conf: <Badge tone="warn">new mapping</Badge> },
]}
/>
<Notice tone="warn">
Total check: parsed 13,401.02 = draft 13,401.02 · Tax check: CGST+SGST @9% ·
Duplicate check: HD/2651 not seen before
</Notice>
<div style={{ display: 'flex', gap: 8 }}>
<Button tone="primary">Post purchase</Button>
<Button>Save draft</Button>
<Button tone="danger">Reject</Button>
</div>
<Notice>
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.
</Notice>
</div>
</div>
</div>
)
}

@ -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 (
<div className="wf-page">
<PageHeader
title="Settings"
desc="One mechanism, five scopes: system → tenant → store → counter → user. Every override shows what it inherits; every change is effective-dated and audit-logged."
/>
<div className="wf-toolbar">
{SCOPES.map((s) => (
<button key={s} type="button" className={`wf${s === scope ? ' primary' : ''}`} onClick={() => setScope(s)}>
{s}
</button>
))}
<span className="spacer" />
<Button>Show changed-from-default only</Button>
</div>
<DataTable
columns={[
{ key: 'key', label: 'Setting' },
{ key: 'value', label: `Value at ${scope}` },
{ key: 'from', label: 'Inherited from' },
{ key: 'act', label: '' },
]}
rows={[
{ key: 'discount.capBp (cashier max %)', value: '15%', from: <Badge tone="accent">override here</Badge>, act: <Button>Reset to inherited</Button> },
{ key: 'gst.roundToRupee', value: 'true', from: <Badge>System</Badge>, act: <Button>Override</Button> },
{ key: 'negativeStock.policy', value: 'warn (allow with supervisor)', from: <Badge>Tenant</Badge>, act: <Button>Override</Button> },
{ key: 'print.language', value: 'English + Malayalam', from: <Badge tone="accent">override here</Badge>, act: <Button>Reset to inherited</Button> },
{ key: 'khata.defaultLimit', value: '₹5,000', from: <Badge>Tenant</Badge>, act: <Button>Override</Button> },
{ key: 'approval.timeoutSec', value: '45', from: <Badge>System</Badge>, act: <Button>Override</Button> },
]}
/>
<Notice>
Wireframe of `setting(scope_type, scope_id, key, value, effective_from)` resolved by
@sims/config already implemented and tested in packages/config.
</Notice>
</div>
)
}

@ -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 (
<div className="wf-page">
<PageHeader
title="Users & Roles"
desc="Deactivate, never delete. Sensitive in-flow actions stay visible to cashiers but raise the supervisor-PIN overlay."
actions={<><Button>New user</Button><Button>New role</Button></>}
/>
<h3>Users</h3>
<DataTable
columns={[
{ key: 'name', label: 'Name' }, { key: 'roles', label: 'Roles' },
{ key: 'surfaces', label: 'Surfaces' }, { key: 'stores', label: 'Stores' },
{ key: 'lang', label: 'Language' }, { key: 'status', label: 'Status' },
]}
rows={[
{ name: 'Thomas', roles: 'owner', surfaces: 'POS · Back office · Owner app', stores: 'all', lang: 'English', status: <Badge tone="ok">active</Badge> },
{ name: 'Suresh', roles: 'supervisor', surfaces: 'POS', stores: 'ST1', lang: 'Malayalam', status: <Badge tone="ok">active</Badge> },
{ name: 'Ramesh', roles: 'cashier', surfaces: 'POS', stores: 'ST1', lang: 'Malayalam', status: <Badge tone="ok">active</Badge> },
{ name: 'Divya', roles: 'cashier', surfaces: 'POS', stores: 'ST1', lang: 'Tamil', status: <Badge tone="ok">active</Badge> },
{ name: 'Old Cashier', roles: 'cashier', surfaces: '—', stores: '—', lang: '—', status: <Badge>deactivated</Badge> },
]}
/>
<h3 style={{ marginTop: 20 }}>Permission matrix</h3>
<Toolbar>
<Badge tone="warn">PIN = allowed via supervisor PIN overlay</Badge>
<Badge>matrix rows are DB data (config-driven) editable without release</Badge>
</Toolbar>
<div className="matrix">
<DataTable
columns={[
{ key: 'action', label: 'Action' },
{ key: 'cashier', label: 'Cashier' }, { key: 'supervisor', label: 'Supervisor' },
{ key: 'purchaser', label: 'Purchaser' }, { key: 'accountant', label: 'Accountant' },
{ key: 'manager', label: 'Manager' }, { key: 'owner', label: 'Owner' },
]}
rows={[
{ action: 'Create bill', cashier: CHECK, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Price override', cashier: PIN, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Discount above cap', cashier: PIN, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Void bill', cashier: PIN, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Drawer no-sale', cashier: PIN, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Purchase entry', cashier: '—', supervisor: '—', purchaser: CHECK, accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Stock adjustment', cashier: '—', supervisor: '—', purchaser: CHECK, accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Day Begin / Day End', cashier: PIN, supervisor: '—', purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Settings edit', cashier: '—', supervisor: '—', purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
{ action: 'Audit log view', cashier: '—', supervisor: '—', purchaser: '—', accountant: CHECK, manager: '—', owner: CHECK },
]}
/>
</div>
<Notice>
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.
</Notice>
</div>
)
}

@ -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<LabelSheetOptions>): 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<string, unknown>): 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<Record<string, unknown>[] | undefined>()
const [error, setError] = useState<string | undefined>()
const [cols, setCols] = useState(4)
const [size, setSize] = useState<'small' | 'medium' | 'large'>('medium')
const [note, setNote] = useState<string | undefined>()
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 (
<div className="wf-page">
<PageHeader
title="Label Printing" badge="LIVE"
desc="Barcode/MRP labels for the catalog — one label per item. Purchases queue new/changed items here automatically."
actions={<Button tone="primary" onClick={print}>Print labels </Button>}
/>
<Toolbar>
<Field label="Columns">
<select className="wf" style={{ width: 90 }} value={cols} onChange={(e) => setCols(Number(e.target.value))}>
{[2, 3, 4, 5].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</Field>
<Field label="Label size">
<select className="wf" style={{ width: 150 }} value={size} onChange={(e) => setSize(e.target.value as typeof size)}>
<option value="small">Small (38×25 mm)</option>
<option value="medium">Medium (48×30 mm)</option>
<option value="large">Large (63×38 mm)</option>
</select>
</Field>
<Badge>{items?.length ?? '…'} items</Badge>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{note !== undefined && <Notice tone="warn">{note}</Notice>}
{items === undefined ? <EmptyState>Loading</EmptyState> : items.length === 0 ? (
<EmptyState>No items yet add some in Catalog Items.</EmptyState>
) : (
<DataTable
columns={[
{ key: 'name', label: 'Item' }, { key: 'barcode', label: 'Barcode (EAN-13)' },
{ key: 'mrp', label: 'MRP', numeric: true }, { key: 'price', label: 'Price', numeric: true },
]}
rows={items.map((i) => {
const barcodes = (i['barcodes'] as string[] | undefined) ?? []
return {
name: String(i['name']),
barcode: barcodes[0] !== undefined && barcodes[0] !== '' ? barcodes[0] : <Badge>text only</Badge>,
mrp: i['mrpPaise'] !== undefined ? inr(i['mrpPaise']) : '—',
price: inr(i['salePricePaise']),
}
})}
/>
)}
</div>
)
}

@ -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<T>(loader: () => Promise<T>, deps: unknown[] = []): { data?: T; error?: string; reload: () => void } {
const [data, setData] = useState<T | undefined>()
const [error, setError] = useState<string | undefined>()
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<T>(
loader: (limit: number, offset: number) => Promise<T[]>,
deps: unknown[],
): { rows: T[]; error?: string; hasMore: boolean; loading: boolean; loadMore: () => void } {
const [rows, setRows] = useState<T[]>([])
const [error, setError] = useState<string | undefined>()
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 (
<div className="wf-page">
<PageHeader
title="Items" badge="LIVE"
desc="The item master, straight from the store DB. What you add here is billable at the counter immediately."
actions={<Button tone="primary" onClick={() => setCreating(true)}>New item</Button>}
/>
<Toolbar>
<input className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / barcode…" value={q} onChange={(e) => setQ(e.target.value)} />
<Badge>{data?.length ?? '…'} items</Badge>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data !== undefined && (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, { key: 'hsn', label: 'HSN' },
{ key: 'tax', label: 'GST class' }, { key: 'mrp', label: 'MRP', numeric: true },
{ key: 'price', label: 'Price', numeric: true }, { key: 'status', label: 'Status' },
]}
rows={data.map((i) => ({
code: String(i['code']), name: String(i['name']), hsn: String(i['hsn']),
tax: String(i['taxClassCode']),
mrp: i['mrpPaise'] !== undefined ? inr(i['mrpPaise']) : '—',
price: `${inr(i['salePricePaise'])}${(i['unit'] as { code: string }).code === 'KG' ? '/kg' : ''}`,
status: <Badge tone={i['status'] === 'active' ? 'ok' : 'warn'}>{String(i['status'])}</Badge>,
}))}
/>
)}
{creating && <NewItemModal onClose={() => setCreating(false)} onCreated={() => { setCreating(false); reload() }} />}
</div>
)
}
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<string | undefined>()
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 (
<Modal title="New item" onClose={props.onClose}>
<Field label="Code"><input className="wf" value={f.code} onChange={set('code')} /></Field>
<Field label="Name"><input className="wf" value={f.name} onChange={set('name')} /></Field>
<Field label="HSN"><input className="wf" value={f.hsn} onChange={set('hsn')} /></Field>
<Field label="GST class">
<select className="wf" value={f.taxClassCode} onChange={set('taxClassCode')}>
{['ZERO', 'GST5', 'GST12', 'GST18', 'DEMERIT'].map((c) => <option key={c}>{c}</option>)}
</select>
</Field>
<Field label="Unit">
<select className="wf" value={f.unitCode} onChange={set('unitCode')}>
<option>PCS</option><option>KG</option>
</select>
</Field>
<Field label="Sale price (₹, tax-inclusive)"><input className="wf num" value={f.priceRs} onChange={set('priceRs')} /></Field>
<Field label="MRP (₹, optional)"><input className="wf num" value={f.mrpRs} onChange={set('mrpRs')} /></Field>
<Field label="Barcode (optional)"><input className="wf num" value={f.barcode} onChange={set('barcode')} /></Field>
{error !== undefined && <Notice tone="err">{error}</Notice>}
<div style={{ display: 'flex', gap: 8 }}>
<Button tone="primary" onClick={save}>Save item</Button>
<Button onClick={props.onClose}>Cancel</Button>
</div>
</Modal>
)
}
export function CustomersPage() {
const { data, error } = useData(() => getParties('customer'))
return (
<div className="wf-page">
<PageHeader title="Customers" badge="LIVE" desc="Phone-keyed profiles — created here or from the POS customer-attach (F6)." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' },
{ key: 'phone', label: 'Phone' }, { key: 'gstin', label: 'GSTIN' },
{ key: 'limit', label: 'Credit limit', numeric: true },
]}
rows={data.map((p) => ({
code: String(p['code']), name: String(p['name']),
phone: p['phone'] !== undefined ? String(p['phone']) : '—',
gstin: p['gstin'] !== undefined ? <Badge tone="accent">{String(p['gstin'])}</Badge> : '—',
limit: p['creditLimitPaise'] !== undefined ? inr(p['creditLimitPaise']) : '—',
}))}
/>
)}
</div>
)
}
/** Build the A4 GST invoice for a bill row and open it in a print-ready window. */
function openInvoice(bill: Record<string, unknown>, 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<Record<string, unknown>>(
(limit, offset) => getBills(date, limit, offset), [date],
)
const { data: seller } = useData(() => getSeller())
const [printErr, setPrintErr] = useState<string | undefined>()
const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0)
return (
<div className="wf-page">
<PageHeader title="Bills" badge="LIVE" desc="Every committed bill — immutable documents from the counters. Click a row to open its A4 GST invoice." />
<Toolbar>
<input type="date" className="wf" style={{ maxWidth: 170 }} value={date} onChange={(e) => setDate(e.target.value)} />
<Badge>{rows.length}{hasMore ? '+' : ''} bills</Badge>
<Badge tone="accent">{formatINR(total)}</Badge>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{printErr !== undefined && <Notice tone="warn">{printErr}</Notice>}
{rows.length === 0 && loading ? <EmptyState>Loading</EmptyState>
: rows.length === 0 && error === undefined ? <EmptyState>No bills on {date}. Make one at the POS!</EmptyState> : (
<DataTable
columns={[
{ key: 'no', label: 'Bill No' }, { key: 'time', label: 'Time' }, { key: 'cashier', label: 'Cashier' },
{ key: 'customer', label: 'Customer' }, { key: 'gst', label: 'GST', numeric: true },
{ key: 'amount', label: 'Amount', numeric: true }, { key: 'invoice', label: 'Invoice' },
]}
onRowClick={(_row, i) => 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: <Badge tone="accent">Print A4 </Badge>,
}))}
/>
)}
{hasMore && (
<div style={{ marginTop: 10 }}>
<Button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</Button>
</div>
)}
</div>
)
}
export function StockPage() {
const { data, error } = useData(() => getStock())
return (
<div className="wf-page">
<PageHeader title="Stock" badge="LIVE" desc="On-hand derived from movements (invariant I4) — opening stock minus every sale." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Item' },
{ key: 'onhand', label: 'On hand', numeric: true }, { key: 'state', label: '' },
]}
rows={data.map((s) => {
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 ? <Badge tone="err">out</Badge> : onHand < 15 ? <Badge tone="warn">low</Badge> : <Badge tone="ok">ok</Badge>,
}
})}
/>
)}
</div>
)
}
export function AuditPage() {
const { rows, error, hasMore, loading, loadMore } = usePaged<Record<string, unknown>>(
(limit, offset) => getAudit(limit, offset), [],
)
return (
<div className="wf-page">
<PageHeader title="Audit Log" badge="LIVE" desc="Append-only — logins, failed logins, item edits, every bill commit." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{rows.length === 0 && loading ? <EmptyState>Loading</EmptyState>
: rows.length === 0 && error === undefined ? <EmptyState>No audit entries yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'who', label: 'Who' },
{ key: 'action', label: 'Action' }, { key: 'detail', label: 'Detail' },
]}
rows={rows.map((a) => ({
at: new Date(String(a['at_wall'])).toLocaleString(),
who: String(a['user_name'] ?? a['user_id']),
action: <Badge tone="accent">{String(a['action'])}</Badge>,
// 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 && (
<div style={{ marginTop: 10 }}>
<Button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</Button>
</div>
)}
</div>
)
}

@ -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 50200 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<ItemLite[]>([])
const [taxClasses, setTaxClasses] = useState<TaxClassRow[]>([])
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<Record<string, { last?: number; supplierLast?: number }>>({})
const [invoiceNo, setInvoiceNo] = useState('')
const [invoiceDate, setInvoiceDate] = useState(today)
const [lines, setLines] = useState<PLine[]>([])
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<LabelInput[] | undefined>()
/** 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<HTMLInputElement>(null)
const cellRef = useRef<HTMLInputElement>(null)
const [stockMap, setStockMap] = useState<Map<string, number>>(new Map())
const [draft, setDraft] = useState<Draft | undefined>()
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<PLine>) =>
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 (
<div className="wf-page">
<PageHeader
title="Purchase Entry" badge="LIVE"
desc="Scan or type item → qty → cost → back to scan. Last cost prefills; cost changes offer a price revision; F12 posts."
actions={<Button tone="primary" onClick={post} hotkey="F12">{busy ? 'Posting…' : 'Post purchase'}</Button>}
/>
<Toolbar>
<div style={{ position: 'relative', minWidth: 260 }}>
{supplier === undefined ? (
<>
<input
className="wf" placeholder="Supplier… (type to search)"
value={supplierQuery} onChange={(e) => setSupplierQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && supplierMatches[0] !== undefined) {
setSupplier(supplierMatches[0]); setSupplierQuery('')
}
}}
/>
{supplierMatches.length > 0 && (
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 10 }} className="wf-modal">
{supplierMatches.map((s) => (
<div key={s.id} style={{ padding: '6px 8px', cursor: 'pointer' }}
onClick={() => { setSupplier(s); setSupplierQuery('') }}>{s.name}</div>
))}
</div>
)}
</>
) : (
<Badge tone="accent">{supplier.name} </Badge>
)}
{supplier !== undefined && (
<Button onClick={() => setSupplier(undefined)}>Change</Button>
)}
</div>
<input className="wf" style={{ maxWidth: 160 }} placeholder="Invoice no" value={invoiceNo} onChange={(e) => setInvoiceNo(e.target.value)} />
<input type="date" className="wf" style={{ maxWidth: 160 }} value={invoiceDate} onChange={(e) => setInvoiceDate(e.target.value)} />
</Toolbar>
<input
ref={entryRef}
className="wf scanbox" style={{ fontSize: 17, fontFamily: 'var(--mono)', marginBottom: 8 }}
placeholder="Scan barcode · item code · name · 24*surf for quantity"
value={entry}
onChange={(e) => setEntry(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleEntry() } }}
/>
{notice !== undefined && <Notice tone={notice.tone}>{notice.text}</Notice>}
{labelQueue !== undefined && (
<Notice tone="ok">
Print labels for {labelQueue.length} received item(s)?{' '}
<Button tone="primary" onClick={() => { const err = openLabelSheet(labelQueue); if (err !== undefined) say('warn', err) }}>Print labels </Button>{' '}
<Button onClick={() => setLabelQueue(undefined)}>Dismiss</Button>
</Notice>
)}
{draft !== undefined && (
<Notice tone="warn">
Unfinished entry found <b>{draft.invoiceNo !== '' ? draft.invoiceNo : 'no invoice no'}</b> with {draft.lines.length} line(s).{' '}
<Button tone="primary" onClick={() => {
setLines(draft.lines)
setInvoiceNo(draft.invoiceNo)
setInvoiceDate(draft.invoiceDate)
if (draft.supplier !== undefined) setSupplier(draft.supplier)
setDraft(undefined)
}}>Resume</Button>{' '}
<Button onClick={() => { localStorage.removeItem(DRAFT_KEY); setDraft(undefined) }}>Discard</Button>
</Notice>
)}
{focusCell !== undefined && lines[focusCell.line] !== undefined && (() => {
const l = lines[focusCell.line]!
const item = items.find((i) => i.id === l.itemId)
const margin = l.unitCostPaise > 0 ? ((l.currentSalePaise - l.unitCostPaise) / l.unitCostPaise) * 100 : 0
return (
<Notice>
<b>{l.name}</b> · stock {item !== undefined ? (stockMap.get(item.code) ?? '—') : '—'} {l.unitCode}
· sale {inr(l.currentSalePaise)}{l.currentMrpPaise !== undefined ? ` · MRP ${inr(l.currentMrpPaise)}` : ''}
· margin at this cost <b>{margin.toFixed(1)}%</b>
{costHist !== undefined && costHist.length > 0 && (
<> · last costs: {costHist.map((h) => `${inr(Number(h.cost))} (${String(h.date)})`).join(' · ')}</>
)}
</Notice>
)
})()}
{lines.length === 0 ? (
<EmptyState>Pick the supplier, then scan the first line of the invoice.</EmptyState>
) : (
<table className="wf">
<thead>
<tr>
<th>#</th><th>Item</th><th className="num">Qty</th><th className="num">Cost </th>
<th className="num">Last</th><th className="num">GST</th><th className="num">Line total</th><th>Price</th>
</tr>
</thead>
<tbody>
{lines.map((l, i) => {
const lineTaxable = Math.round(l.qty * l.unitCostPaise)
const lineTax = Math.round((lineTaxable * l.taxRateBp) / 10_000)
const costChanged = l.lastCostPaise !== undefined && l.unitCostPaise !== l.lastCostPaise && l.unitCostPaise > 0
const cell = (kind: 'qty' | 'cost') =>
focusCell?.line === i && focusCell.cell === kind ? (
<input
ref={cellRef}
className="wf num" style={{ width: 90 }}
defaultValue={kind === 'qty' ? String(l.qty) : (l.unitCostPaise / 100).toFixed(2)}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== 'Tab') return
e.preventDefault()
const v = (e.target as HTMLInputElement).value
if (kind === 'qty') {
const q = Number(v)
if (q > 0) setLine(i, { qty: q })
setFocusCell({ line: i, cell: 'cost' })
} else {
setLine(i, { unitCostPaise: rs(v) })
setFocusCell(undefined) // Enter on cost returns to the scan box
}
}}
/>
) : (
<span style={{ cursor: 'pointer' }} onClick={() => setFocusCell({ line: i, cell: kind })}>
{kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)}
</span>
)
return (
<tr key={i}>
<td>{i + 1}</td>
<td>{l.name}</td>
<td className="num">{cell('qty')}</td>
<td className="num">{cell('cost')}</td>
<td className="num">{l.lastCostPaise !== undefined ? inr(l.lastCostPaise) : '—'}</td>
<td className="num">{l.taxRateBp / 100}%</td>
<td className="num">{inr(lineTaxable + lineTax)}</td>
<td>
{costChanged ? (
<PriceRevision line={l} onSet={(p) => setLine(i, p)} />
) : l.newSalePricePaise !== undefined ? (
<Badge tone="ok">new {(l.newSalePricePaise / 100).toFixed(2)}</Badge>
) : (
<Badge>keep</Badge>
)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
<Toolbar>
<Badge>Taxable {formatINR(totals.taxable)}</Badge>
<Badge>GST {formatINR(totals.tax)}</Badge>
<Badge tone="accent">Total {formatINR(totals.total)}</Badge>
<span className="spacer" />
<Field label="Supplier's printed total ₹ (cross-check)">
<input className="wf num" style={{ width: 150 }} value={printedTotal} onChange={(e) => setPrintedTotal(e.target.value)} />
</Field>
{crossCheck === 'match' && <Badge tone="ok">totals match </Badge>}
{crossCheck === 'mismatch' && <Badge tone="err"> computed check a line</Badge>}
</Toolbar>
</div>
)
}
/** Inline price-revision prompt when the cost moved — margin at a glance. */
function PriceRevision(props: { line: PLine; onSet: (patch: Partial<PLine>) => 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 (
<span>
<Badge tone="warn">cost changed · margin {(marginBp / 100).toFixed(1)}%</Badge>{' '}
<Button onClick={() => setOpen(true)}>Revise price</Button>
</span>
)
}
return (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
<input className="wf num" style={{ width: 84 }} value={sale} onChange={(e) => setSale(e.target.value)} placeholder="Sale ₹" />
<input className="wf num" style={{ width: 84 }} value={mrp} onChange={(e) => setMrp(e.target.value)} placeholder="MRP ₹" />
<Button tone="primary" onClick={() => {
props.onSet({
newSalePricePaise: rs(sale),
...(mrp !== '' ? { newMrpPaise: rs(mrp) } : {}),
})
setOpen(false)
}}>Set</Button>
</span>
)
}
export function PurchasesListPage() {
const [data, setData] = useState<Record<string, unknown>[] | undefined>()
const [error, setError] = useState<string | undefined>()
useEffect(() => {
getPurchases().then(setData).catch((e: Error) => setError(e.message))
}, [])
return (
<div className="wf-page">
<PageHeader title="Purchase List" badge="LIVE" desc="Every posted supplier invoice — duplicates are rejected at entry." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : data.length === 0 ? (
<EmptyState>No purchases yet enter one in Purchase Entry.</EmptyState>
) : (
<DataTable
columns={[
{ key: 'inv', label: 'Invoice' }, { key: 'supplier', label: 'Supplier' },
{ key: 'date', label: 'Invoice date' }, { key: 'lines', label: 'Lines', numeric: true },
{ key: 'total', label: 'Total', numeric: true },
]}
rows={data.map((p) => ({
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'])),
}))}
/>
)}
</div>
)
}

@ -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<string, ReactNode>[]
}
const ok = (t: string) => <Badge tone="ok">{t}</Badge>
const warn = (t: string) => <Badge tone="warn">{t}</Badge>
const err = (t: string) => <Badge tone="err">{t}</Badge>
const dim = (t: string) => <Badge>{t}</Badge>
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: '030 days', items: '9,240', value: '₹18,42,000' },
{ bucket: '3190 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') },
],
},
]

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}

@ -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 },
})

@ -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.

@ -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')

@ -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());

@ -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)
});

@ -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<string>((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())

@ -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<string> =>
ipcRenderer.invoke('print:raw9100', host, port, bytes),
})

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SiMS Next POS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -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"
}
}

@ -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<Bootstrap | undefined>()
const [bootError, setBootError] = useState<string | undefined>()
const [user, setUser] = useState<PosUser | undefined>()
const [items, setItems] = useState<Item[] | undefined>()
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 (
<div className="login-wrap">
<div className="login-card">
<h2 style={{ marginTop: 0 }}>SiMS Next POS</h2>
<Notice tone="err">{bootError} is the store-server running?</Notice>
<Button tone="primary" onClick={load}>Retry</Button>
</div>
</div>
)
}
if (boot === undefined) return <div className="login-wrap">Connecting to store-server</div>
if (user === undefined || items === undefined) {
return (
<Login
store={boot.store}
users={boot.users}
onLogin={async (u) => {
setUser(u)
setItems(await fetchItemsCache())
}}
/>
)
}
if (shift === undefined) {
return (
<ShiftOpen
userName={user.name}
onOpen={(floatPaise) => setShift({ id: uuidv7(), floatPaise })}
onBack={() => {
setUser(undefined)
setItems(undefined)
}}
/>
)
}
return (
<BillingScreen
user={user}
users={boot.users}
store={boot.store}
taxClasses={boot.taxClasses}
items={items}
shiftId={shift.id}
floatPaise={shift.floatPaise}
onLock={() => {
setShift(undefined)
setUser(undefined)
setItems(undefined)
}}
/>
)
}

File diff suppressed because it is too large Load Diff

@ -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<void> }) {
const [selected, setSelected] = useState<PosUser>(props.users[0]!)
const [pin, setPin] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | undefined>()
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 (
<div
className="login-wrap"
tabIndex={0}
onKeyDown={(e) => {
if (/^\d$/.test(e.key)) press(e.key)
if (e.key === 'Backspace') press('⌫')
if (e.key === 'Enter') void submit()
}}
>
<div className="login-card">
<h2 style={{ marginTop: 0 }}>{props.store.name}</h2>
<div style={{ color: 'var(--text-dim)', fontSize: 13 }}>
Counter {props.store.counterCode} · SiMS Next POS
</div>
<div className="user-tiles">
{props.users.map((u) => (
<button
key={u.id}
type="button"
className={`wf${u.id === selected.id ? ' selected' : ''}`}
onClick={() => {
setSelected(u)
setPin('')
}}
>
{u.name}
<div style={{ color: 'var(--text-dim)', fontSize: 12 }}>{u.role}</div>
</button>
))}
</div>
<div className="pin-dots">
{[0, 1, 2, 3, 4, 5].map((i) => (
<span key={i} className={i < pin.length ? 'filled' : ''} />
))}
</div>
<div className="pin-pad">
{['1', '2', '3', '4', '5', '6', '7', '8', '9', '⌫', '0', '✓'].map((d) => (
<button key={d} type="button" className={`wf${d === '✓' ? ' primary' : ''}`} disabled={busy} onClick={() => press(d)}>
{d}
</button>
))}
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{busy && <Notice>Signing in</Notice>}
</div>
</div>
)
}

@ -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<PrinterCfg>(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 (
<Modal title="Counter settings — printing" onClose={props.onClose}>
<Field label="Thermal printer IP (ESC/POS over network, port 9100)">
<input className="wf" value={cfg.host} onChange={(e) => save({ ...cfg, host: e.target.value })} />
</Field>
<Field label="Port">
<input
className="wf num"
type="number"
value={cfg.port}
onChange={(e) => save({ ...cfg, port: Number(e.target.value) })}
/>
</Field>
<Field label="Paper width">
<select
className="wf"
value={cfg.width}
onChange={(e) => save({ ...cfg, width: Number(e.target.value) as 32 | 42 })}
>
<option value={32}>2 inch (32 chars)</option>
<option value={42}>3 inch (42 chars)</option>
</select>
</Field>
<Field label="Receipt script">
<select
className="wf"
value={cfg.script}
onChange={(e) => save({ ...cfg, script: e.target.value as PrinterCfg['script'] })}
>
<option value="ascii">ASCII (fast, Latin only)</option>
<option value="raster">Raster (Indic Hindi/Malayalam item names)</option>
</select>
</Field>
<Field label="Printing">
<select
className="wf"
value={cfg.enabled ? 'on' : 'off'}
onChange={(e) => save({ ...cfg, enabled: e.target.value === 'on' })}
>
<option value="on">Enabled</option>
<option value="off">Disabled (bills still commit)</option>
</select>
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button tone="primary" onClick={() => void testPrint()}>
Test print
</Button>
<Button onClick={() => void attempt(new EscPos().drawerKick().build(), 'Drawer kick sent')}>
Kick drawer
</Button>
<Button onClick={props.onClose}>Close</Button>
</div>
{status !== undefined && <Notice tone={status.tone}>{status.text}</Notice>}
<Notice>
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).
</Notice>
</Modal>
)
}

@ -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<Record<number, number>>({})
const total = DENOMS.reduce((a, d) => a + d * (counts[d] ?? 0), 0)
return (
<div className="login-wrap">
<div className="login-card" style={{ width: 460 }}>
<h2 style={{ marginTop: 0 }}>Open shift {props.userName}</h2>
<div className="denoms">
{DENOMS.map((d) => (
<Field key={d} label={`${formatINR(d)} ×`}>
<input
className="wf num"
type="number"
min={0}
value={counts[d] ?? ''}
onChange={(e) => setCounts((c) => ({ ...c, [d]: Number(e.target.value) }))}
/>
</Field>
))}
</div>
<div className="payable" style={{ margin: '12px 0' }}>
<span>Opening float</span>
<span className="amount num" style={{ fontSize: 28 }}>{formatINR(total)}</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button tone="primary" onClick={() => props.onOpen(total)}>Open shift</Button>
<Button onClick={() => props.onOpen(0)}>Skip count</Button>
<Button onClick={props.onBack}>Back</Button>
</div>
</div>
</div>
)
}

@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<Bootstrap> =>
call(`/bootstrap?counter=${encodeURIComponent(counter)}`)
export async function pinLogin(userId: string, pin: string): Promise<void> {
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<Item[]> => 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<CommitResult> => call('/bills', { method: 'POST', body: JSON.stringify(body) })
export const fetchBills = (date: string, counterId: string): Promise<Record<string, unknown>[]> =>
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<Item> => 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<PosCustomer[]> =>
call(`/parties?phone=${encodeURIComponent(phone)}`)
export const createCustomer = (name: string, phone: string, gstin?: string): Promise<PosCustomer> =>
call('/parties', {
method: 'POST',
body: JSON.stringify({ name, phone, ...(gstin !== undefined && gstin !== '' ? { gstin } : {}) }),
})

@ -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)
}

@ -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(<App />)

@ -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; }

@ -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<PrinterCfg>) }
} 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<void> {
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<void> {
const { renderRasterReceipt } = await import('./raster')
await sendToPrinter(cfg, renderRasterReceipt(lines, { width: cfg.width, kickDrawer }))
}

@ -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
}

@ -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<string>
}
interface Window {
pos?: PosBridge
}

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*", "electron/**/*"]
}

@ -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' },
})

@ -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.

@ -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')

@ -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"
}
}

@ -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<string, Session>()
const lockouts = new Map<string, LockoutState>()
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<string, unknown>
const counter = db.prepare(`SELECT * FROM counter WHERE store_id=? AND code=?`).get(store['id'], counterCode) as Record<string, unknown> | undefined
const tenant = db.prepare(`SELECT * FROM tenant WHERE id=?`).get(store['tenant_id']) as Record<string, unknown>
const users = db.prepare(`SELECT id, display_name, roles FROM app_user WHERE active=1 AND pin_hash IS NOT NULL`).all() as Record<string, unknown>[]
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<typeof createItem>[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<CommitPurchaseInput, 'tenantId' | 'userId'>
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
}

@ -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
}

@ -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<string, { last?: number; supplierLast?: number }> {
const out: Record<string, { last?: number; supplierLast?: number }> = {}
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<string, unknown>[] {
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<string, unknown>[]
}
export function listPurchases(db: DB, tenantId: string): Record<string, unknown>[] {
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<string, unknown>[]
}

@ -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<string, unknown>[]).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<string, unknown>[]
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<string, unknown>[] {
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<string, unknown>[]
}
// ---------- stock ----------
export function stockView(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] {
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<string, unknown>[]
}
// ---------- 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<string, unknown>[] {
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<string, unknown>[]
}

@ -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()
}

@ -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 }))
})
// 80808085 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/`)
})

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}

@ -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): <subject>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
## 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<string, string> = {
'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 <noreply@anthropic.com>"
```
---
### 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<Cadence, Kind> = { 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<CreateRecurringInput, 'clientId'>), 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 <noreply@anthropic.com>"
```
---
### 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<CreateAmcInput, 'clientId'>), 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 <noreply@anthropic.com>"
```
---
### 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<CreateInteractionInput, 'clientId'>), 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 <noreply@anthropic.com>"
```
---

@ -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": "*"
}
}

@ -0,0 +1,4 @@
export * from './pin'
export * from './lockout'
export * from './permissions'
export * from './session'

@ -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
}

@ -0,0 +1,74 @@
import type { RoleCode, User } from '@sims/domain'
/**
* Action codes gate everything sensitive. Roleaction 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<RoleCode, ActionCode[]> = {
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<User, 'roles' | 'active'>,
action: ActionCode,
grants: Record<RoleCode, ActionCode[]> = 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<User, 'roles' | 'active'>,
action: ActionCode,
grants: Record<RoleCode, ActionCode[]> = 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'
}

@ -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 46 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'))
}

@ -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,
}
}

@ -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(/46 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<User, 'roles' | 'active'> => ({ 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 stores day', () => {
expect(() => openSession({ ...base, day: { ...day, closedAt: 'x', closedBy: 'mgr' } })).toThrow(/Day Begin/)
expect(() => openSession({ ...base, day: { ...day, storeId: 's2' } })).toThrow(/another store/)
})
})

@ -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": "*"
}
}

@ -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
}

@ -0,0 +1,2 @@
export * from './tax'
export * from './compute'

@ -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
}

@ -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>): 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/)
})
})

@ -0,0 +1,8 @@
{
"name": "@sims/config",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts"
}

@ -0,0 +1,2 @@
export * from './settings'
export * from './messages'

@ -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, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
name in vars ? String(vars[name]) : `{${name}}`,
)
}

@ -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
}

@ -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}')
})
})

@ -0,0 +1,8 @@
{
"name": "@sims/domain",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.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)
}

@ -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 }
}

@ -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
}

@ -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 } : {}),
}
}

@ -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)
}

@ -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'

@ -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
}

@ -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'
}

@ -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
}

@ -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
}

@ -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,
})
})
})

@ -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": "*"
}
}

@ -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 18 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)
}

@ -0,0 +1,5 @@
export * from './escpos'
export * from './receipt'
export * from './invoice-html'
export * from './labels'
export * from './raster'

@ -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<string, string> = {
'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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<string, HsnBucket>()
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 `<div class="box"><h3>${esc(label)}</h3><div>Walk-in / Cash sale</div></div>`
return `<div class="box">
<h3>${esc(label)}</h3>
<div><strong>${esc(p.name)}</strong></div>
${p.address !== undefined && p.address !== '' ? `<div class="muted">${esc(p.address)}</div>` : ''}
${p.gstin !== undefined && p.gstin !== '' ? `<div>GSTIN: ${esc(p.gstin)}</div>` : '<div class="muted">Unregistered</div>'}
${p.stateCode !== undefined && p.stateCode !== '' ? `<div class="muted">State: ${esc(placeOfSupply(p.stateCode))}</div>` : ''}
${p.phone !== undefined && p.phone !== '' ? `<div class="muted">Ph: ${esc(p.phone)}</div>` : ''}
</div>`
}
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) => `
<tr>
<td class="num">${i + 1}</td>
<td>${esc(l.name)}</td>
<td>${esc(l.hsn)}</td>
<td class="num">${l.qty} ${esc(l.unitCode)}</td>
<td class="num">${inr(l.unitPricePaise)}</td>
<td class="num">${inr(l.taxablePaise)}</td>
<td class="num">${(l.taxRateBp / 100).toFixed(l.taxRateBp % 100 === 0 ? 0 : 2)}%</td>
<td class="num">${inr(l.lineTotalPaise)}</td>
</tr>`).join('')
const taxHead = interState
? '<th class="num">IGST</th>'
: '<th class="num">CGST</th><th class="num">SGST</th>'
const taxRows = buckets.map((b) => `
<tr>
<td>${esc(b.hsn)}</td>
<td class="num">${(b.taxRateBp / 100).toFixed(b.taxRateBp % 100 === 0 ? 0 : 2)}%</td>
<td class="num">${inr(b.taxablePaise)}</td>
${interState ? `<td class="num">${inr(b.igstPaise)}</td>` : `<td class="num">${inr(b.cgstPaise)}</td><td class="num">${inr(b.sgstPaise)}</td>`}
<td class="num">${inr(b.cessPaise)}</td>
</tr>`).join('')
const totalsRows: string[] = [
`<tr><td>Taxable value</td><td class="num">${inr(t.taxablePaise)}</td></tr>`,
]
if (t.discountPaise > 0) totalsRows.push(`<tr><td>Discount</td><td class="num">-${inr(t.discountPaise)}</td></tr>`)
if (interState) {
totalsRows.push(`<tr><td>IGST</td><td class="num">${inr(t.igstPaise)}</td></tr>`)
} else {
totalsRows.push(`<tr><td>CGST</td><td class="num">${inr(t.cgstPaise)}</td></tr>`)
totalsRows.push(`<tr><td>SGST</td><td class="num">${inr(t.sgstPaise)}</td></tr>`)
}
if (t.cessPaise > 0) totalsRows.push(`<tr><td>Cess</td><td class="num">${inr(t.cessPaise)}</td></tr>`)
if (t.roundOffPaise !== 0) totalsRows.push(`<tr><td>Round off</td><td class="num">${inr(t.roundOffPaise)}</td></tr>`)
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${esc(title)} ${esc(doc.invoiceNo)}</title>
<style>${STYLE}</style>
</head>
<body>
${opts.printButton === false ? '' : '<div class="no-print"><button type="button" onclick="window.print()">Print A4</button></div>'}
<div class="sheet">
<div class="title">${esc(title)}</div>
<div class="head">
<div class="blk">
<h1>${esc(doc.seller.name)}</h1>
${doc.seller.address !== undefined && doc.seller.address !== '' ? `<div class="muted">${esc(doc.seller.address)}</div>` : ''}
${doc.seller.gstin !== undefined && doc.seller.gstin !== '' ? `<div>GSTIN: <strong>${esc(doc.seller.gstin)}</strong></div>` : ''}
${doc.seller.phone !== undefined && doc.seller.phone !== '' ? `<div class="muted">Ph: ${esc(doc.seller.phone)}</div>` : ''}
</div>
<div class="blk sign">
<div><span class="muted">Invoice No</span><br /><strong>${esc(doc.invoiceNo)}</strong></div>
<div><span class="muted">Date</span><br />${esc(doc.invoiceDate)}</div>
<div><span class="muted">Place of Supply</span><br />${esc(placeOfSupply(doc.placeOfSupplyStateCode ?? doc.seller.stateCode))}</div>
</div>
</div>
<div class="grid2">
${partyBlock('Billed To', doc.buyer)}
${partyBlock('Supplier', doc.seller)}
</div>
<table>
<thead>
<tr>
<th class="num">#</th><th>Description</th><th>HSN</th><th class="num">Qty</th>
<th class="num">Rate</th><th class="num">Taxable</th><th class="num">GST%</th><th class="num">Amount</th>
</tr>
</thead>
<tbody>${lineRows}</tbody>
</table>
<table>
<thead>
<tr><th>HSN</th><th class="num">Rate</th><th class="num">Taxable</th>${taxHead}<th class="num">Cess</th></tr>
</thead>
<tbody>${taxRows}</tbody>
</table>
<table class="totals">
<tbody>
${totalsRows.join('')}
<tr class="grand"><td>Payable${doc.paymentLabel !== undefined ? ` (${esc(doc.paymentLabel)})` : ''}</td><td class="num">${formatINR(t.payablePaise)}</td></tr>
</tbody>
</table>
<div class="words">
<b>Amount in words</b>
${esc(amountInWordsINR(t.payablePaise))}
</div>
<div class="foot">
<div>${esc(footer)}</div>
<div class="sign">For <strong>${esc(doc.seller.name)}</strong><br /><br />Authorised Signatory</div>
</div>
</div>
</body>
</html>`
}

@ -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 09. 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<number>()
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(`<rect x="${(quietL + i * m).toFixed(1)}" y="0" width="${m}" height="${h}" />`)
}
// 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 =
`<text x="${(quietL - 2 * m).toFixed(1)}" y="${ty}" text-anchor="end">${c[0]}</text>` +
`<text x="${leftMid.toFixed(1)}" y="${ty}" text-anchor="middle" letter-spacing="${(1.4 * m).toFixed(1)}">${c.slice(1, 7)}</text>` +
`<text x="${rightMid.toFixed(1)}" y="${ty}" text-anchor="middle" letter-spacing="${(1.4 * m).toFixed(1)}">${c.slice(7)}</text>`
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${totalH}" width="${width}" height="${totalH}" role="img" aria-label="EAN-13 ${c}">` +
`<rect x="0" y="0" width="${width}" height="${totalH}" fill="#fff" />` +
`<g fill="#000">${bars.join('')}</g>` +
`<g fill="#000" font-family="monospace" font-size="${(6 * m).toFixed(1)}">${text}</g>` +
`</svg>`
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
/** 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 = `<div class="bar">${ean13Svg(lab.code13, { module: 2, height: 40 })}</div>`
} catch {
bar = `<div class="bar muted">no barcode</div>`
}
}
const price = lab.pricePaise !== undefined && lab.pricePaise !== lab.mrpPaise
? `<div class="price">Rate ${formatINR(lab.pricePaise)}</div>`
: ''
return `<div class="label">
<div class="name">${esc(lab.name)}</div>
${bar}
<div class="mrp">MRP ${formatINR(lab.mrpPaise)}</div>
${price}
</div>`
}).join('')
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${esc(title)}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; margin: 0; background: #f4f4f5; color: #111; }
.sheet { width: 210mm; margin: 8mm auto; padding: 8mm; background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.15); }
.grid { display: grid; grid-template-columns: repeat(${opts.cols}, ${opts.labelWmm}mm); gap: 2mm; }
.label { width: ${opts.labelWmm}mm; height: ${opts.labelHmm}mm; border: 1px dashed #bbb; padding: 1.5mm; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; overflow: hidden; }
.name { font-size: 8pt; font-weight: 600; line-height: 1.1; max-height: 3.4em; overflow: hidden; }
.bar { margin: 1mm 0; }
.bar svg { max-width: 100%; height: auto; }
.mrp { font-size: 9pt; font-weight: 700; }
.price { font-size: 8pt; color: #333; }
.muted { color: #999; font-size: 7pt; }
.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; } .label { border-color: transparent; } .no-print { display: none; } }
@page { size: A4; margin: 8mm; }
</style>
</head>
<body>
${opts.printButton === false ? '' : '<div class="no-print"><button type="button" onclick="window.print()">Print labels</button></div>'}
<div class="sheet">
<div class="grid">${cells}</div>
</div>
</body>
</html>`
}

@ -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>): number[] {
const bytes = new Array<number>(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<number>, 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)
}

@ -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()
}

@ -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')
})
})

@ -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('<!DOCTYPE html>')).toBe(true)
expect(html).toContain('</html>')
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('<svg')).toBe(true)
expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"')
expect(svg.trimEnd().endsWith('</svg>')).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('<svg')
expect(html).toContain('Rate') // price differs from MRP
expect(html).toContain('grid-template-columns: repeat(3')
})
it('renders text-only labels when no barcode is given', () => {
const html = renderLabelSheetHtml([{ name: 'Loose Tomato', mrpPaise: 3_200 }], { cols: 2, labelWmm: 50, labelHmm: 30 })
expect(html).toContain('Loose Tomato')
expect(html).not.toContain('<svg')
})
})
describe('raster bit packing', () => {
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])
})
})

@ -0,0 +1,8 @@
{
"name": "@sims/scanning",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.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])
}

@ -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)) {
// 814 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 }
}

@ -0,0 +1,4 @@
export * from './barcode'
export * from './scale'
export * from './entry'
export * from './wedge'

@ -0,0 +1,38 @@
import { isValidEan13 } from './barcode'
/**
* Label-scale barcodes: EAN-13 in the GS1 restricted-circulation range
* (prefix 2029) 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 }
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save