diff --git a/.gitignore b/.gitignore index f89e6ef..5573059 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ apps/hq/data/ /data/ # Real client data staging for the APEX->HQ import — never committed import-drop/ +# Local ruflo tooling artifact +ruvector.db diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..e4e63cc --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,101 @@ +# Building SiMS Next + +## Layout + +``` +Store Software/ +├─ docs/ planning docs 00–11 (read 00-VISION first) +├─ packages/ pure, tested business logic — no UI, no DB, no hardware +│ ├─ domain/ tenancy (tenant→store→counter→user), money (integer paise, +│ │ ₹ Indian grouping), uuidv7 ids, business day (Day Begin +│ │ idiom from the OG apps), GST doc series, GSTIN validation, +│ │ immutable document types, dormant outbox rows (D14) +│ ├─ billing-engine/ deterministic GST math: dated rate resolution, inclusive/ +│ │ exclusive prices, CGST/SGST vs IGST, cess, pro-rata bill +│ │ discounts, rupee round-off — locked by golden tests +│ ├─ auth/ PIN policy + scrypt hashing, lockout reducer, role→action +│ │ permission matrix with supervisor-PIN gates, POS session +│ │ bound to the Day-Begin business date +│ ├─ scanning/ EAN-13 validation, label-scale (2x-prefix) weight/price +│ │ barcodes, the scan-box grammar (qty*, code, search), +│ │ wedge scanner vs human-typing detector +│ └─ config/ settings hierarchy resolver (system→tenant→store→counter +│ →user, effective-dated) + message catalog with language +│ fallback — the everything-in-DB principle as code +└─ apps/ thin shells over the packages (in build order) + ├─ pos/ Electron + React counter (Phase-0 hardware spike next) + ├─ store-server/ Node service beside the existing Oracle 12c (D12) + └─ backoffice/ React SPA, served locally by store-server in v1 +``` + +## Commands + +``` +npm install # once; workspaces link @sims/* packages +npm test # vitest across all packages +npm run typecheck # tsc strict over every src + test file + +# THE WEB APP (the product, per D2 revision) — one process serves everything: +cd apps/pos && npm run build +cd apps/backoffice && npm run build +cd apps/store-server && npm start +# Back office: http://localhost:5181/ (thomas / sims) +# POS: http://localhost:5181/pos/ (counters browse here; zero installs) +# Printing: POST /api/print → ESC/POS over TCP 9100 (browsers can't open sockets) + +# Optional Electron kiosk wrapper for the POS (same web bundle, direct print bridge): +cd apps/pos && npm start +``` + +## Design system (packages/ui) + +- **Tokens** (`tokens.css`): light + dark themes, six switchable accents (indigo, blue, + emerald, violet, rose, amber), semantic status colors, shadows, radii — applied via + ``. References: Stripe (light clarity), Linear (dark). +- **Fonts bundled, never CDN** (shops are offline): Inter Variable for UI + tabular + money digits, JetBrains Mono Variable for codes/receipts — via fontsource, built into + each app's dist. +- **`ThemeSwitcher`** sits in both app headers; prefs persist in localStorage now and + become user-scope settings rows later (config-driven principle). +- Restyle rule: pages consume tokens/classes only — changing `tokens.css` restyles the + entire product; no page edits. + +## Wireframe status (v0.1) + +- **POS**: login (PIN tiles) → shift open (denomination count) → billing screen — + functional: real billing engine, scanner wedge detection, scale barcodes, hold/resume, + per-counter GST series, payment overlay, day-end summary, ESC/POS printing over + network (Ctrl+, to configure; test print + drawer kick). Runs in a browser too + (`npm run dev`) with printing stubbed. +- **Back office**: full menu tree (09-UX §2), ~40 pages as data-driven wireframes with + realistic sample data; custom builds of the Purchase Inbox (split review), Settings + hierarchy, Users & Roles matrix, Dashboard. Edition-locked pages render as upsells. +- Revamp path: pages are `PageConfig` rows rendered by one `GenericPage` — restyling the + kit restyles the app; promoting a page to "real" means swapping its config for a + component fed by the store-server API. + +## Rules the structure enforces + +1. **Logic lives in packages, apps are shells.** The same billing engine must produce + byte-identical bills on POS, store server, and cloud — that's why it is pure TS with + golden tests, and why the frontend can stay stable (founder principle) while behavior + evolves as data. +2. **Tenancy from row one.** Classic has no tenant concept; here `tenantId` is on every + record even though a v1 install holds one tenant. Retrofitting this later is the + single most expensive migration there is — so it is never retrofitted. +3. **Documents are immutable, ids are client-generated (uuidv7), outbox rows are written + from day one** even though nothing drains them yet (D14): cloud enablement must be a + switch-on, not a migration. +4. **Nothing user-facing is hard-coded.** Rates, settings, messages, plans resolve from + rows (see `@sims/config`); the packages define the resolution semantics and the DB + supplies the rows once the Oracle schema mapping (docs/12-DATA-MODEL.md) exists. + +## Next steps (in order) + +1. **Phase-0 hardware spike** in `apps/pos`: Electron shell, ESC/POS print, drawer kick, + wedge scanner wired to `@sims/scanning`, one weighing scale. +2. **Golden bills from Classic**: 100 real bills re-computed through the engine to the + paisa — extend `packages/billing-engine/test`. +3. **Classic schema mapping** (needs the Classic Oracle DDL export) → docs/12-DATA-MODEL.md, + then the repository layer in store-server. +4. Billing screen prototype against the item cache, timed with real cashiers. diff --git a/README.md b/README.md new file mode 100644 index 0000000..81b9cb1 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# SiMS Next — Store Management Suite (Rebuild) + +A ground-up rebuild of SiMS Store Software: GST-compliant retail management for India — +inventory, POS billing, purchases, and accounting — for everything from a single-counter +kirana shop to multi-outlet supermarket chains. + +**Why the rebuild:** the current product runs on Oracle Forms & Reports (hard to maintain, +buggy, no path to cloud/multi-store), and the market is moving. Same customers, much better +product. + +**North star:** the fastest, easiest counter workflow in the market. The customer in line +never waits because of the software, and purchase entry is never a burden. + +## Planning documents + +| Doc | What it covers | +|-----|----------------| +| [docs/00-VISION.md](docs/00-VISION.md) | Product vision, segments/editions, business model, success metrics | +| [docs/01-SCOPE.md](docs/01-SCOPE.md) | Full module & feature catalog with priorities and edition mapping | +| [docs/02-ARCHITECTURE.md](docs/02-ARCHITECTURE.md) | Offline vs online vs hybrid analysis, sync design, tech stack, multi-tenancy | +| [docs/03-FRONTEND.md](docs/03-FRONTEND.md) | App surfaces, counter UX principles, screen inventory, design system | +| [docs/04-ROADMAP.md](docs/04-ROADMAP.md) | Phased build plan with exit criteria, pilot & migration strategy | +| [docs/05-CHECKLIST.md](docs/05-CHECKLIST.md) | Master build checklist, grouped by area | +| [docs/06-DECISIONS.md](docs/06-DECISIONS.md) | Open decisions that need a founder call, with recommendations | +| [docs/07-DB-AND-CONFIG.md](docs/07-DB-AND-CONFIG.md) | Everything-in-DB design (messages, plans, settings), local DB engine analysis (D12) | +| [docs/08-MARKET-ARCHITECTURES.md](docs/08-MARKET-ARCHITECTURES.md) | How Tally/Marg/GoFrugal/Vyapar & global players architect; Android POS conclusion | +| [docs/09-UX-FLOWS-AND-MENUS.md](docs/09-UX-FLOWS-AND-MENUS.md) | Screen-level UX blueprint: POS flows, back-office menu tree, Purchase Inbox, owner app | +| [docs/10-ISSUES-REGISTER.md](docs/10-ISSUES-REGISTER.md) | Consolidated failure-mode register across 6 lenses, with Top-10 risk ranking | +| [docs/11-ADMIN-SUPPORT-CONSOLE.md](docs/11-ADMIN-SUPPORT-CONSOLE.md) | Settings hierarchy, tenant Admin review, and the vendor-side HQ/support console | +| docs/13-SPEC-*.md | Keystroke-level specs: billing polish + purchase entry (P1/P2 backlog) | +| [docs/14-SPEC-HQ-CONSOLE.md](docs/14-SPEC-HQ-CONSOLE.md) | HQ ops console for the Classic client base (D15, apps/hq) | +| [docs/15-EDITIONS.md](docs/15-EDITIONS.md) | Lite/Standard/Pro/Enterprise: privileges, limits, costs — all configurable DB rows | +| [docs/16-PROJECT-RULES.md](docs/16-PROJECT-RULES.md) | The 26 hard rules (Tables Rule, money rules, config-over-code, counter rules) | +| [docs/17-SCALE-AND-INTEGRATIONS.md](docs/17-SCALE-AND-INTEGRATIONS.md) | The 7 committed scale gaps + full integrations catalog with status | + +Start with 00-VISION, then 02-ARCHITECTURE, then 04-ROADMAP. diff --git a/STATUS.md b/STATUS.md index bb31e8e..c4de85e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,7 +1,7 @@ -# Build Status — 2026-07-09 (post-slice) +# Build Status — 2026-07-10 (post-slice) -**54 screens across 2 surfaces** (46 back office + 8 POS), on 7 tested logic packages -(54 tests green), and a store-server **with a real data layer** (SQLite behind portable +**54 screens across 2 surfaces** (46 back office + 8 POS), on 8 tested logic packages +(89 tests green), and a store-server **with a real data layer** (SQLite behind portable repositories; Oracle 12c adapter slots in when the Classic DDL arrives — D12). Product form: **web app** served by the store-server (D2) — back office at `/`, POS at `/pos/`, printing via `/api/print`. @@ -21,7 +21,8 @@ both UIs in a real browser: bill `ST1C2/26-000001`, ₹600.00. | `@sims/billing-engine` | Dated GST rates, inclusive/exclusive prices, CGST/SGST vs IGST, cess, pro-rata bill discounts, rupee round-off — golden-tested to the paisa | ✅ | | `@sims/auth` | PIN policy + scrypt hashing, lockout reducer, role→action matrix with supervisor-PIN gates, POS session bound to business date | ✅ | | `@sims/scanning` | EAN-13 validation, weighing-scale (2x) barcodes, scan-box grammar (`3*` qty, code, search), wedge scanner vs human-typing detector | ✅ | -| `@sims/printing` | ESC/POS byte builder, receipt renderer (42/32 col), drawer kick | ✅ | +| `@sims/search-core` | Catalog search (pure TS, no deps): Indic phonetic folding (chawal≈chaval, paneer≈panir, jeera≈zeera), in-memory index, tiered ranking (whole-prefix → token-prefix → phonetic → substring) | ✅ | +| `@sims/printing` | ESC/POS byte builder, receipt renderer (42/32 col), drawer kick; **A4 GST invoice HTML + amount-in-words, EAN-13 label sheets, GS v 0 Indic raster (S2)** | ✅ | | `@sims/config` | Settings hierarchy resolver (system→tenant→store→counter→user, effective-dated), message catalog with language fallback | ✅ | | `@sims/ui` | Design system: light/dark themes, 6 accents, Inter + JetBrains Mono bundled, wireframe component kit, ThemeSwitcher | — | @@ -48,9 +49,94 @@ audio feedback (ok/warn/err beeps), ok-notices auto-clear, stock chips in sugges (live counts, out-of-stock warn-not-block). Bill ST1C2/26-000003 proves the chain. Purchase Entry P1: crash-proof localStorage draft with resume banner; glance strip on cell focus (stock · sale · MRP · live margin · last-3 costs via /purchases/cost-history). -Still pending: returns flow, supervisor PIN overlay (needs /auth/verify-pin), phonetic -@sims/search-core, purchase schema P1s (free-qty, disc%, interstate split — one -migration pass together), history-R repeat, customer display, offline queue. +Still pending: returns flow, supervisor PIN overlay (needs /auth/verify-pin), +purchase schema P1s (free-qty, disc%, interstate split — one migration pass together), +history-R repeat, customer display, offline queue. + +**Catalog scale (S1 + S6) — done 2026-07-10.** New `@sims/search-core` (pure TS, no +deps) replaces BillingScreen's linear name filter: `phoneticKey` folds the Indic +spelling variants the spec names (aa→a, ee→i, oo→u, w→v, ph→f, bh→b, chh→ch, sh→s, +th→t, dh→d, z→j, drop doubled letters), and `buildIndex`/`querySearch` rank matches in +tiers (whole-prefix → token-prefix → phonetic → substring, shorter name breaks ties). +The POS memoizes the index over its live item cache (grows with quick-adds); the ≤8-row +dropdown and arrow/Enter behaviour are unchanged. S6/R13: the silent `LIMIT 2000` in +`listItems` is gone — `GET /api/items?all=1` returns the whole non-inactive catalog for +the POS cache (which now fetches with `?all=1`), every other path paginates +(limit/offset, default 5000); `listBills`/`listAudit` and their routes paginate too +(default 100), and the back-office Bills and Audit pages grew a "Load more" button. +Verified 2026-07-10: 66/66 tests green (11 new folding/tier cases), `npm run typecheck` +clean on root + all three apps, all three apps build, and the store-server was rebuilt +and restarted — `/api/health` ok, `/api/items?all=1` returns the full 12-item catalog +with no cap, limit/offset confirmed on items/bills/audit, and a phonetic query +`ashirwad` resolves to `Aashirvaad Atta 5kg`. Integer paise, existing tests, and the +zero-runtime-dep rule are untouched. + +**Print coverage (S2) — done 2026-07-10.** Three new pure modules in `@sims/printing`: +`invoice-html.ts` (`renderInvoiceHtml` → a self-contained, printable **A4 GST tax +invoice**: seller name/GSTIN/address header, buyer block, HSN-wise line table, a +CGST/SGST **or** IGST + cess breakup grouped by HSN+rate, round-off, payable, and +**amount-in-words** via a new `amountInWordsINR(paise)` domain util — integer paise, +Indian lakh/crore numbering, singular "Paisa" — plus a bilingual English/Hindi footer); +`labels.ts` (`ean13Svg` — standards-correct EAN-13 with L/G/R element patterns and +first-digit parity, checksum via `@sims/scanning`'s `ean13CheckDigit`; `renderLabelSheetHtml` +— a printable barcode/MRP label grid); and `raster.ts` (pure `packRasterRow` / +`bitonalFromRGBA` / `rasterToEscPos` for **GS v 0** 1-bit bit-image receipts). Back +office: **Bills rows are clickable** → the A4 invoice opens in a print-ready tab (Blob +URL, own "Print A4" button); **Catalog → Label Printing is now LIVE** (prints labels for +current items, columns + label size configurable); Purchase Entry offers "Print labels" +for the received lines after a post. POS: a **"Receipt script: ASCII / Raster (Indic)"** +setting (persisted in the printer cfg) routes the commit-time receipt through a +canvas→GS v 0 raster path (`apps/pos/src/raster.ts`) so Devanagari/Malayalam item names +print, sharing one `receiptLines` content model with the ASCII path. Verified 2026-07-10: +**97 tests green** (20 new S2 cases: amountInWordsINR 0/paisa/1/lakh/crore, invoice fields ++ amount-in-words + IGST switch, EAN-13 structure + checksum, label sheet, raster +bit-packing), typecheck clean on root + all three apps, all three apps build, store-server +rebuilt and restarted. Drove the back office in a real browser (thomas/sims): Bills → click +`ST1C2/26-000001` → A4 invoice rendered with **"Rupees Six Hundred Only"**, CGST/SGST +breakup and place-of-supply Kerala; Catalog → Label Printing → sheet rendered **7 real +EAN-13 SVG symbols**; and the raster canvas pipeline was exercised in-browser (384-px 2" +head, Indic glyphs drew 1,575 ink pixels via the OS font, GS v 0 header bytes correct). +**Honest caveats:** raster receipts are unit- and canvas-verified but the physical thermal +output needs a real 80 mm printer; canvas Indic glyphs fall back to the shop PC's OS fonts +(Windows ships Nirmala UI) — bundled canvas webfonts are a follow-up; the demo seed +barcodes carry placeholder check digits, so `ean13Svg` encodes stored 13-digit codes +as-is (a strict `normalizeEan13` validator is exported for real catalog input). + +**B2B at the counter (S3) — done 2026-07-10.** A registered buyer's GSTIN now flows from +attach → engine → document. New pure `deriveSupply(customer, storeState)` in `@sims/domain` +is the single place-of-supply rule both surfaces run: no GSTIN → B2C, place of supply = the +store (CGST/SGST); a GSTIN → B2B, place of supply = the buyer's state (its first two digits, +or an explicit stateCode), inter-state ⇒ IGST. **Parties gained B2B identity**: `createCustomer` +takes an optional GSTIN, **validated server-side** with `validateGstin` (bad checksum/shape +rejected with E-1401/E-1402), the state code derived from the GSTIN when absent; the back +office **Customers page has a GSTIN column** and the POS **F6 attach modal has an optional +"GSTIN (B2B)" field with live checksum/state feedback**. **Billing is now place-of-supply +aware**: the POS `computeBill` ctx uses the attached customer's state (falls back to the +store), and `commitBill` **does not trust the client** — it re-reads the customer row, derives +place of supply itself, recomputes, and the paisa-agreement check (R5) stays. The **immutable +bill snapshots the buyer** (`buyer_gstin`, `place_of_supply` — added to the CREATE TABLE and +back-filled on the running dev DB by a PRAGMA-guarded `ALTER TABLE` migrate step in `db.ts`); +`listBills` returns them. **Documents follow**: the thermal receipt prints a buyer GSTIN line +(IGST rows on the existing inter-state path), the A4 invoice buyer block fills GSTIN + +place-of-supply from the bill (closing the S2 caveat), and the POS totals panel shows an +**"IGST · inter-state"** badge plus a B2B chip on the customer strip. Verified 2026-07-10: +**110 tests pass** (4 new: `deriveSupply` no-GSTIN/intra/inter, B2B receipt buyer line), +typecheck clean on root + all three apps, all three apps build, store-server rebuilt and +restarted (`/api/health` ok, migration added both columns to the 3-bill dev DB). Drove both +UIs in a real browser: POS login (Ramesh/4728, Skip count) → F6 → created **Anand Traders** +(phone 9876543210) with Maharashtra GSTIN **`27ABCDE1234F1Z0`** (body `27ABCDE1234F1Z` + +`gstinCheckDigit` = `0`) → the modal showed "place of supply state 27 — GSTIN valid", the +strip showed the B2B chip, the totals showed **IGST · inter-state** → billed Surf Excel +(GST ₹22.12 as IGST) → committed **ST1C2/26-000004**. Server row confirms `buyer_gstin`, +`place_of_supply='27'`, `igst_paise=2212`, `cgst=sgst=0`, payable ₹145.00 (client/server +agreed to the paisa). Back office → Bills → the row opened its A4 **Tax Invoice** with +**Billed To Anand Traders / GSTIN 27ABCDE1234F1Z0 / State 27 — Maharashtra**, Place of Supply +**"27 — Maharashtra"**, and an **IGST** tax row (no CGST/SGST); Customers shows the GSTIN column. +**Honest caveats:** the buyer GSTIN receipt line is unit-tested only (no physical printer here); +the seed store's state 32 resolves as "Kerala" in the invoice state map though it is named +T.Nagar — a seed-data quirk unrelated to S3; the e-Invoice/IRN (GSP) hook remains P2 (S3 stops +at the document, per doc 17 §S3). Unrelated pre-existing failure: `apps/hq/test/gmail.test.ts` +imports a not-yet-added `../src/gmail` (HQ-console in-flight work, untouched by S3). ## Purchase Entry — LIVE (the second crown jewel) @@ -77,9 +163,10 @@ Login (1) + Dashboard (1, custom) + 44 module pages: | Reports | Sales Reports · Margins · Stock Aging · Day-End Digest | 4 | | Admin | **Users & Roles** (custom matrix) · Stores & Counters · **Settings** (custom hierarchy) · Print Templates · Integrations · Subscription & Plan · Audit Log · Sync & Devices · Backups · Data Import | 10 | -**5 pages are now LIVE on real data**: Items (list + search + New Item — billable at the -counter immediately), Customers, Bills, Stock (derived from movements), Audit Log. -Login is real (server-verified). 36 pages render from the data-driven registry; 4 are +**6 pages are now LIVE on real data**: Items (list + search + New Item — billable at the +counter immediately), Customers, Bills (rows open the printable A4 GST invoice — S2), +Stock (derived from movements), Audit Log, and Label Printing (barcode/MRP sheets — S2). +Login is real (server-verified). 35 pages render from the data-driven registry; 4 are custom wireframes (Dashboard, Purchase Inbox, Settings hierarchy, Users & Roles). Edition-locked pages render as upsells; everything themed and role/edition-aware. diff --git a/apps/backoffice/README.md b/apps/backoffice/README.md new file mode 100644 index 0000000..d0ae6d9 --- /dev/null +++ b/apps/backoffice/README.md @@ -0,0 +1,5 @@ +# apps/backoffice — Back Office (React SPA) + +Masters, purchases, inventory, reports, GST, Admin (users/roles, settings hierarchy, +stores/counters). Served by store-server locally in v1; the same build runs from the +cloud tier later. Menu tree and page anatomy: docs/09-UX-FLOWS-AND-MENUS.md §2. diff --git a/apps/backoffice/index.html b/apps/backoffice/index.html new file mode 100644 index 0000000..15c0faa --- /dev/null +++ b/apps/backoffice/index.html @@ -0,0 +1,12 @@ + + + + + + SiMS Next — Back Office + + +
+ + + diff --git a/apps/backoffice/package.json b/apps/backoffice/package.json new file mode 100644 index 0000000..b41e1f5 --- /dev/null +++ b/apps/backoffice/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sims/backoffice", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@sims/billing-engine": "*", + "@sims/domain": "*", + "@sims/printing": "*", + "@sims/scanning": "*", + "@sims/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "vite": "^6.0.0" + } +} diff --git a/apps/backoffice/src/Layout.tsx b/apps/backoffice/src/Layout.tsx new file mode 100644 index 0000000..f077201 --- /dev/null +++ b/apps/backoffice/src/Layout.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react' +import { NavLink, Outlet } from 'react-router-dom' +import { Badge, ThemeSwitcher } from '@sims/ui' +import { NAV, isLocked } from './nav' + +export function Layout(props: { userName: string; onLogout: () => void }) { + const searchRef = useRef(null) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key.toLowerCase() === 'k') { + e.preventDefault() + searchRef.current?.focus() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + return ( +
+ +
+
+ + + + + Pro + {props.userName} + +
+
+ +
+
+
+ ) +} diff --git a/apps/backoffice/src/Login.tsx b/apps/backoffice/src/Login.tsx new file mode 100644 index 0000000..95bd252 --- /dev/null +++ b/apps/backoffice/src/Login.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react' +import { Button, Field, Notice } from '@sims/ui' +import { login } from './api' + +/** Back-office login — server-side scrypt verification + lockout. */ +export function Login(props: { onLogin: (name: string) => void }) { + const [user, setUser] = useState('thomas') + const [pw, setPw] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + + const submit = async () => { + if (busy) return + setBusy(true) + setError(undefined) + try { + props.onLogin(await login(user.trim(), pw)) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setBusy(false) + } + } + + return ( +
+
+

SiMS Next — Back Office

+ + setUser(e.target.value)} /> + + + setPw(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && void submit()} + /> + + + {error !== undefined && {error}} +
+
+ ) +} diff --git a/apps/backoffice/src/api.ts b/apps/backoffice/src/api.ts new file mode 100644 index 0000000..889c19c --- /dev/null +++ b/apps/backoffice/src/api.ts @@ -0,0 +1,73 @@ +/** Back office ↔ store-server client; session token survives reloads. */ + +let token = sessionStorage.getItem('bo.token') ?? '' + +export function hasSession(): boolean { + return token !== '' +} + +export function clearSession(): void { + token = '' + sessionStorage.removeItem('bo.token') +} + +async function call(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api${path}`, { + ...init, + headers: { + 'content-type': 'application/json', + ...(token !== '' ? { authorization: `Bearer ${token}` } : {}), + }, + }).catch(() => undefined) + if (res === undefined) throw new Error('Store-server unreachable (E-3301)') + if (res.status === 401) clearSession() + const body = (await res.json().catch(() => ({}))) as T & { error?: string } + if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`) + return body +} + +export async function login(username: string, password: string): Promise { + const s = await call<{ token: string; displayName: string }>('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }) + token = s.token + sessionStorage.setItem('bo.token', s.token) + return s.displayName +} + +export const getItems = (q?: string): Promise[]> => + call(`/items${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`) +export const createItem = (body: Record): Promise> => + call('/items', { method: 'POST', body: JSON.stringify(body) }) +export const getParties = (kind?: string): Promise[]> => + call(`/parties${kind !== undefined ? `?kind=${kind}` : ''}`) +export const getBills = (date?: string, limit?: number, offset?: number): Promise[]> => { + const qs = new URLSearchParams() + if (date !== undefined) qs.set('date', date) + if (limit !== undefined) qs.set('limit', String(limit)) + if (offset !== undefined) qs.set('offset', String(offset)) + return call(`/bills${qs.toString() !== '' ? `?${qs}` : ''}`) +} +export const getStock = (): Promise[]> => call('/stock') +export const getAudit = (limit?: number, offset?: number): Promise[]> => { + const qs = new URLSearchParams() + if (limit !== undefined) qs.set('limit', String(limit)) + if (offset !== undefined) qs.set('offset', String(offset)) + return call(`/audit${qs.toString() !== '' ? `?${qs}` : ''}`) +} + +export const getPurchases = (): Promise[]> => call('/purchases') +export const getLastCosts = (supplierId?: string): Promise> => + call(`/purchases/last-costs${supplierId !== undefined ? `?supplierId=${supplierId}` : ''}`) +export const postPurchase = (body: Record): Promise<{ id: string; totalPaise: number }> => + call('/purchases', { method: 'POST', body: JSON.stringify(body) }) +export const getCostHistory = (itemId: string, supplierId?: string): Promise<{ cost: number; date: string; supplier: string }[]> => + call(`/purchases/cost-history?itemId=${itemId}${supplierId !== undefined ? `&supplierId=${supplierId}` : ''}`) +export const getBootstrapPublic = (): Promise<{ taxClasses: { classCode: string; ratePctBp: number; cessPctBp: number; effectiveFrom: string; effectiveTo?: string }[] }> => + call('/bootstrap?counter=C1') + +export interface Seller { id: string; name: string; storeCode: string; stateCode: string; gstin?: string } +/** Seller header for GST invoices/labels — store name, GSTIN, state (from bootstrap). */ +export const getSeller = (): Promise => + call<{ store: Seller }>('/bootstrap?counter=C1').then((b) => b.store) diff --git a/apps/backoffice/src/app.css b/apps/backoffice/src/app.css new file mode 100644 index 0000000..013beee --- /dev/null +++ b/apps/backoffice/src/app.css @@ -0,0 +1,30 @@ +.bo-shell { display: grid; grid-template-columns: 230px 1fr; height: 100vh; } +.bo-side { background: var(--bg-raised); border-right: 1px solid var(--border); overflow-y: auto; padding: 12px 0; } +.bo-brand { font-weight: 700; font-size: 16px; padding: 4px 16px 12px; } +.bo-brand small { display: block; color: var(--text-dim); font-weight: 400; } +.bo-module { margin: 10px 0 2px; padding: 0 16px; color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; } +.bo-side a { + display: flex; align-items: center; gap: 8px; padding: 6px 16px; color: var(--text); + text-decoration: none; font-size: 13.5px; +} +.bo-side a:hover { background: var(--bg-hover); } +.bo-side a.active { background: var(--bg-hover); box-shadow: inset 3px 0 0 var(--accent); } +.bo-side a .lock { margin-left: auto; font-size: 11px; color: var(--warn); } +.bo-main { display: flex; flex-direction: column; min-width: 0; } +.bo-top { + display: flex; gap: 12px; align-items: center; padding: 8px 20px; + border-bottom: 1px solid var(--border); background: var(--bg-raised); +} +.bo-top input { max-width: 380px; } +.bo-content { overflow-y: auto; flex: 1; } +.upsell { + border: 1px solid var(--warn); border-radius: var(--radius); padding: 24px; margin: 16px 0; + background: color-mix(in srgb, var(--warn) 6%, var(--bg-raised)); +} +.inbox-split { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 12px; } +.inbox-doc { + background: var(--bg-raised); border: 1px dashed var(--border); border-radius: var(--radius); + padding: 18px; font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; color: var(--text-dim); +} +.matrix td:first-child { white-space: nowrap; } +.matrix .cell { text-align: center; } diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx new file mode 100644 index 0000000..fcbd88e --- /dev/null +++ b/apps/backoffice/src/main.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { createRoot } from 'react-dom/client' +import { HashRouter, Route, Routes } from 'react-router-dom' +import '@fontsource-variable/inter' +import '@fontsource-variable/jetbrains-mono' +import '../../../packages/ui/src/tokens.css' +import './app.css' +import { initTheme } from '@sims/ui' +import { clearSession } from './api' +import { Layout } from './Layout' +import { Login } from './Login' +import { Dashboard } from './pages/Dashboard' +import { GenericPage } from './pages/GenericPage' +import { PurchaseInbox } from './pages/PurchaseInbox' +import { SettingsPage } from './pages/SettingsPage' +import { UsersRoles } from './pages/UsersRoles' +import { AuditPage, BillsPage, CustomersPage, ItemsPage, StockPage } from './pages/live' +import { LabelsPage } from './pages/labels' +import { PurchaseEntryPage, PurchasesListPage } from './pages/purchase-entry' +import { PAGES } from './pages/registry' + +/** Custom/live pages override registry configs; everything else renders generically. */ +const CUSTOM: Record React.JSX.Element> = { + '/purchases/inbox': PurchaseInbox, + '/admin/settings': SettingsPage, + '/admin/users': UsersRoles, + '/catalog/items': ItemsPage, + '/catalog/labels': LabelsPage, + '/sales/customers': CustomersPage, + '/sales/bills': BillsPage, + '/inventory/stock': StockPage, + '/admin/audit': AuditPage, + '/purchases/entry': PurchaseEntryPage, + '/purchases/list': PurchasesListPage, +} + +function App() { + const [userName, setUserName] = useState() + if (userName === undefined) return + return ( + + + { clearSession(); setUserName(undefined) }} />}> + } /> + {Object.entries(CUSTOM).map(([path, Page]) => ( + } /> + ))} + {PAGES.filter((p) => !(p.path in CUSTOM)).map((p) => ( + } /> + ))} + } /> + + + + ) +} + +initTheme() +createRoot(document.getElementById('root')!).render() diff --git a/apps/backoffice/src/nav.ts b/apps/backoffice/src/nav.ts new file mode 100644 index 0000000..c29bd45 --- /dev/null +++ b/apps/backoffice/src/nav.ts @@ -0,0 +1,118 @@ +/** + * The back-office menu tree (09-UX §2). Editions gate visibility: locked pages + * render as discoverable upsells, never hidden (09-UX §5.5). This is data on + * purpose — in production it comes from the DB with role filtering applied. + */ +export type Edition = 'L' | 'S' | 'P' | 'E' + +export interface NavPage { + path: string + label: string + edition: Edition +} + +export interface NavModule { + key: string + label: string + icon: string + pages: NavPage[] +} + +export const NAV: NavModule[] = [ + { + key: 'dashboard', label: 'Dashboard', icon: '◧', + pages: [{ path: '/', label: 'Overview', edition: 'L' }], + }, + { + key: 'catalog', label: 'Catalog', icon: '⌘', + pages: [ + { path: '/catalog/items', label: 'Items', edition: 'L' }, + { path: '/catalog/categories', label: 'Categories', edition: 'L' }, + { path: '/catalog/price-lists', label: 'Price Lists', edition: 'S' }, + { path: '/catalog/schemes', label: 'Schemes & Offers', edition: 'S' }, + { path: '/catalog/labels', label: 'Label Printing', edition: 'S' }, + ], + }, + { + key: 'sales', label: 'Sales', icon: '₹', + pages: [ + { path: '/sales/bills', label: 'Bills', edition: 'L' }, + { path: '/sales/returns', label: 'Returns & Credit Notes', edition: 'L' }, + { path: '/sales/customers', label: 'Customers', edition: 'L' }, + { path: '/sales/khata', label: 'Khata & Collections', edition: 'L' }, + { path: '/sales/loyalty', label: 'Loyalty', edition: 'P' }, + ], + }, + { + key: 'purchases', label: 'Purchases', icon: '⇩', + pages: [ + { path: '/purchases/list', label: 'Purchase List', edition: 'L' }, + { path: '/purchases/entry', label: 'Purchase Entry', edition: 'L' }, + { path: '/purchases/inbox', label: 'Purchase Inbox', edition: 'P' }, + { path: '/purchases/suppliers', label: 'Suppliers', edition: 'L' }, + { path: '/purchases/gstr2b', label: 'GSTR-2B Reconciliation', edition: 'P' }, + ], + }, + { + key: 'inventory', label: 'Inventory', icon: '▤', + pages: [ + { path: '/inventory/stock', label: 'Stock', edition: 'L' }, + { path: '/inventory/adjustments', label: 'Adjustments', edition: 'L' }, + { path: '/inventory/transfers', label: 'Transfers', edition: 'E' }, + { path: '/inventory/stock-take', label: 'Stock Take', edition: 'P' }, + { path: '/inventory/expiry', label: 'Expiry Dashboard', edition: 'P' }, + ], + }, + { + key: 'accounting', label: 'Accounting', icon: '≡', + pages: [ + { path: '/accounting/vouchers', label: 'Vouchers', edition: 'S' }, + { path: '/accounting/ledgers', label: 'Ledgers', edition: 'L' }, + { path: '/accounting/day-book', label: 'Day Book', edition: 'S' }, + { path: '/accounting/pnl', label: 'P&L / Balance Sheet', edition: 'P' }, + { path: '/accounting/tally', label: 'Tally Export', edition: 'S' }, + ], + }, + { + key: 'gst', label: 'GST', icon: '§', + pages: [ + { path: '/gst/gstr1', label: 'GSTR-1', edition: 'S' }, + { path: '/gst/gstr3b', label: 'GSTR-3B', edition: 'S' }, + { path: '/gst/einvoice', label: 'e-Invoice Queue', edition: 'P' }, + { path: '/gst/eway', label: 'e-Way Bills', edition: 'P' }, + { path: '/gst/hsn', label: 'HSN Summary', edition: 'L' }, + ], + }, + { + key: 'reports', label: 'Reports', icon: '◔', + pages: [ + { path: '/reports/sales', label: 'Sales Reports', edition: 'L' }, + { path: '/reports/margins', label: 'Margins', edition: 'S' }, + { path: '/reports/stock-aging', label: 'Stock Aging', edition: 'S' }, + { path: '/reports/day-end', label: 'Day-End Digest', edition: 'L' }, + ], + }, + { + key: 'admin', label: 'Admin', icon: '⚙', + pages: [ + { path: '/admin/users', label: 'Users & Roles', edition: 'S' }, + { path: '/admin/stores', label: 'Stores & Counters', edition: 'L' }, + { path: '/admin/settings', label: 'Settings', edition: 'L' }, + { path: '/admin/templates', label: 'Print Templates', edition: 'L' }, + { path: '/admin/integrations', label: 'Integrations', edition: 'L' }, + { path: '/admin/subscription', label: 'Subscription & Plan', edition: 'L' }, + { path: '/admin/audit', label: 'Audit Log', edition: 'S' }, + { path: '/admin/sync', label: 'Sync & Devices', edition: 'L' }, + { path: '/admin/backups', label: 'Backups', edition: 'L' }, + { path: '/admin/import', label: 'Data Import', edition: 'L' }, + ], + }, +] + +/** The wireframe tenant runs Pro; Enterprise pages render as upsells. */ +export const CURRENT_EDITION: Edition = 'P' +export const EDITION_ORDER: Edition[] = ['L', 'S', 'P', 'E'] + +export function isLocked(page: NavPage): boolean { + return EDITION_ORDER.indexOf(page.edition) > EDITION_ORDER.indexOf(CURRENT_EDITION) +} diff --git a/apps/backoffice/src/pages/Dashboard.tsx b/apps/backoffice/src/pages/Dashboard.tsx new file mode 100644 index 0000000..f625321 --- /dev/null +++ b/apps/backoffice/src/pages/Dashboard.tsx @@ -0,0 +1,51 @@ +import { Badge, DataTable, PageHeader, StatCard, Stats } from '@sims/ui' + +export function Dashboard() { + return ( +
+ + + + + + + + + +

Alerts

+ high, what: 'Coca-Cola 1.25L out of stock, weekend ahead', where: 'ST1', age: '2 h' }, + { sev: med, what: 'ST2-C1 sync lag 22 min (LAN?)', where: 'ST2', age: '25 min' }, + { sev: med, what: '17 draft items from POS quick-add need completion', where: 'Catalog', age: 'today' }, + { sev: med, what: 'e-Invoice GSP error on ST1C1/26-000355', where: 'GST', age: '3 h' }, + { sev: info, what: 'Onam scheme starts 20 Aug — stock plan?', where: 'Schemes', age: '—' }, + ]} + /> +

Counters now

+ billing }, + { counter: 'ST1-C2', cashier: 'Ramesh', bills: '214', last: '18:42', state: billing }, + { counter: 'ST2-C1', cashier: 'Manu', bills: '86', last: '18:20', state: sync lag }, + ]} + /> +
+ ) +} diff --git a/apps/backoffice/src/pages/GenericPage.tsx b/apps/backoffice/src/pages/GenericPage.tsx new file mode 100644 index 0000000..19cc43f --- /dev/null +++ b/apps/backoffice/src/pages/GenericPage.tsx @@ -0,0 +1,58 @@ +import { useLocation } from 'react-router-dom' +import { Button, DataTable, EmptyState, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui' +import { NAV, isLocked } from '../nav' +import type { PageConfig } from './registry' + +/** Renders any list-page config; locked pages render the upsell, never a wall. */ +export function GenericPage(props: { cfg: PageConfig }) { + const { cfg } = props + const location = useLocation() + const navPage = NAV.flatMap((m) => m.pages).find((p) => p.path === location.pathname) + const locked = navPage !== undefined && isLocked(navPage) + + return ( +
+ {cfg.actions.map((a) => )}} + /> + {locked && ( +
+ This is a {navPage.edition === 'E' ? 'Enterprise' : 'higher-plan'} feature. +

+ You can see exactly what it does below — upgrading unlocks it in place, no reinstall + (paywall-as-demo, 09-UX §5.5). The preview uses sample data. +

+ +
+ )} + {cfg.filters !== undefined && ( + + {cfg.filters.map((f) => ( + + ))} + + + + )} + {cfg.stats !== undefined && ( + + {cfg.stats.map((s) => )} + + )} + {cfg.rows.length === 0 ? ( + No records yet — wireframe. + ) : ( + undefined} /> + )} + + Wireframe page — every record opens a detail drawer with its audit trail in the real build + (cross-page conventions, 09-UX §2). + +
+ ) +} diff --git a/apps/backoffice/src/pages/PurchaseInbox.tsx b/apps/backoffice/src/pages/PurchaseInbox.tsx new file mode 100644 index 0000000..3950b25 --- /dev/null +++ b/apps/backoffice/src/pages/PurchaseInbox.tsx @@ -0,0 +1,65 @@ +import { Badge, Button, DataTable, Notice, PageHeader, Toolbar } from '@sims/ui' + +/** The flagship differentiator — side-by-side review of AI-parsed supplier bills (09-UX §3). */ +export function PurchaseInbox() { + return ( +
+ } + /> + + 3 to review + 38 posted this month + item-match memory: 1,204 learned + +
+
+

Source — HUL_Invoice_HD2651.pdf

+
{`HINDUSTAN UNILEVER DISTRIBUTOR +Invoice HD/2651 · 09-07-2026 · GSTIN 32AABCH1234K1Z6 + +SURF EXCEL MATIC 1KG x48 @122.00 5,856.00 +RIN BAR 250G B12 x144 @10.20 1,468.80 +CLINIC+ SHAMPOO 340ML x24 @168.00 4,032.00 + + Taxable 11,356.80 + CGST 9% 1,022.11 + SGST 9% 1,022.11 + TOTAL 13,401.02`}
+
+
+

Draft purchase — review & post

+ remembered }, + { supplier: 'RIN BAR 250G B12', ours: 'Rin Bar 250g (207)', qty: '144', cost: '10.20', conf: 98% }, + { supplier: 'CLINIC+ SHAMPOO 340ML', ours: choose item…, qty: '24', cost: '168.00', conf: new mapping }, + ]} + /> + + Total check: parsed ₹13,401.02 = draft ₹13,401.02 ✓ · Tax check: CGST+SGST @9% ✓ · + Duplicate check: HD/2651 not seen before ✓ + +
+ + + +
+ + Confirming the shampoo mapping teaches the matcher — next month it posts with one click. + MRP changed on Surf (₹150 → ₹152): label reprint will be queued automatically. + +
+
+
+ ) +} diff --git a/apps/backoffice/src/pages/SettingsPage.tsx b/apps/backoffice/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..75880f2 --- /dev/null +++ b/apps/backoffice/src/pages/SettingsPage.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' +import { Badge, Button, DataTable, Notice, PageHeader } from '@sims/ui' + +const SCOPES = ['System default', 'Tenant', 'Store: ST1', 'Counter: ST1-C2', 'User: Ramesh'] as const + +/** The settings hierarchy made visible — nearest scope wins (11-ADMIN §2). */ +export function SettingsPage() { + const [scope, setScope] = useState<(typeof SCOPES)[number]>('Store: ST1') + return ( +
+ +
+ {SCOPES.map((s) => ( + + ))} + + +
+ override here, act: }, + { key: 'gst.roundToRupee', value: 'true', from: System, act: }, + { key: 'negativeStock.policy', value: 'warn (allow with supervisor)', from: Tenant, act: }, + { key: 'print.language', value: 'English + Malayalam', from: override here, act: }, + { key: 'khata.defaultLimit', value: '₹5,000', from: Tenant, act: }, + { key: 'approval.timeoutSec', value: '45', from: System, act: }, + ]} + /> + + Wireframe of `setting(scope_type, scope_id, key, value, effective_from)` resolved by + @sims/config — already implemented and tested in packages/config. + +
+ ) +} diff --git a/apps/backoffice/src/pages/UsersRoles.tsx b/apps/backoffice/src/pages/UsersRoles.tsx new file mode 100644 index 0000000..085382f --- /dev/null +++ b/apps/backoffice/src/pages/UsersRoles.tsx @@ -0,0 +1,63 @@ +import { Badge, Button, DataTable, Notice, PageHeader, Toolbar } from '@sims/ui' + +const CHECK = '✓' +const PIN = 'PIN' + +/** Users + the role→action matrix with supervisor-PIN gates (09-UX §2, @sims/auth). */ +export function UsersRoles() { + return ( +
+ } + /> +

Users

+ active }, + { name: 'Suresh', roles: 'supervisor', surfaces: 'POS', stores: 'ST1', lang: 'Malayalam', status: active }, + { name: 'Ramesh', roles: 'cashier', surfaces: 'POS', stores: 'ST1', lang: 'Malayalam', status: active }, + { name: 'Divya', roles: 'cashier', surfaces: 'POS', stores: 'ST1', lang: 'Tamil', status: active }, + { name: 'Old Cashier', roles: 'cashier', surfaces: '—', stores: '—', lang: '—', status: deactivated }, + ]} + /> +

Permission matrix

+ + PIN = allowed via supervisor PIN overlay + matrix rows are DB data (config-driven) — editable without release + +
+ +
+ + Matrix edits show “affects N users” before saving and land in the audit log. Backed by + @sims/auth `DEFAULT_ROLE_GRANTS` + `gateFor()` — implemented and tested. + +
+ ) +} diff --git a/apps/backoffice/src/pages/labels.tsx b/apps/backoffice/src/pages/labels.tsx new file mode 100644 index 0000000..5ac819d --- /dev/null +++ b/apps/backoffice/src/pages/labels.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { formatINR } from '@sims/domain' +import { renderLabelSheetHtml, type LabelInput, type LabelSheetOptions } from '@sims/printing' +import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui' +import { getItems } from '../api' + +/** + * Opens a printable label sheet in a new window (blob URL — no document.write). + * Shared by the label queue (Catalog → Label Printing) and the purchase-post + * "Print labels" prompt, so both produce the same sheet (09-UX §5.4 routing). + */ +export function openLabelSheet(labels: LabelInput[], opts?: Partial): string | undefined { + if (labels.length === 0) return 'No labels to print.' + const sheet: LabelSheetOptions = { cols: opts?.cols ?? 4, labelWmm: opts?.labelWmm ?? 48, labelHmm: opts?.labelHmm ?? 30, ...(opts?.title !== undefined ? { title: opts.title } : {}) } + const url = URL.createObjectURL(new Blob([renderLabelSheetHtml(labels, sheet)], { type: 'text/html' })) + const win = window.open(url, '_blank') + if (win === null) { URL.revokeObjectURL(url); return 'Allow pop-ups to open the label sheet.' } + setTimeout(() => URL.revokeObjectURL(url), 60_000) + return undefined +} + +/** Item master → label inputs: MRP falls back to sale price; first barcode is the EAN. */ +export function itemToLabel(item: Record): LabelInput { + const barcodes = (item['barcodes'] as string[] | undefined) ?? [] + const mrp = item['mrpPaise'] !== undefined ? Number(item['mrpPaise']) : Number(item['salePricePaise']) + return { + name: String(item['name']), + mrpPaise: mrp, + pricePaise: Number(item['salePricePaise']), + ...(barcodes[0] !== undefined && barcodes[0] !== '' ? { code13: barcodes[0] } : {}), + } +} + +const inr = (p: unknown) => formatINR(Number(p), { symbol: false }) + +export function LabelsPage() { + const [items, setItems] = useState[] | undefined>() + const [error, setError] = useState() + const [cols, setCols] = useState(4) + const [size, setSize] = useState<'small' | 'medium' | 'large'>('medium') + const [note, setNote] = useState() + + if (items === undefined && error === undefined) { + getItems().then(setItems).catch((e: Error) => setError(e.message)) + } + + const dims = size === 'small' ? { labelWmm: 38, labelHmm: 25 } : size === 'large' ? { labelWmm: 63, labelHmm: 38 } : { labelWmm: 48, labelHmm: 30 } + + const print = () => { + if (items === undefined) return + const labels = items.map(itemToLabel) + setNote(openLabelSheet(labels, { cols, ...dims, title: 'Catalog labels' })) + } + + return ( +
+ Print labels ↗} + /> + + + + + + + + {items?.length ?? '…'} items + + {error !== undefined && {error}} + {note !== undefined && {note}} + {items === undefined ? Loading… : items.length === 0 ? ( + No items yet — add some in Catalog → Items. + ) : ( + { + const barcodes = (i['barcodes'] as string[] | undefined) ?? [] + return { + name: String(i['name']), + barcode: barcodes[0] !== undefined && barcodes[0] !== '' ? barcodes[0] : text only, + mrp: i['mrpPaise'] !== undefined ? inr(i['mrpPaise']) : '—', + price: inr(i['salePricePaise']), + } + })} + /> + )} +
+ ) +} diff --git a/apps/backoffice/src/pages/live.tsx b/apps/backoffice/src/pages/live.tsx new file mode 100644 index 0000000..96c6195 --- /dev/null +++ b/apps/backoffice/src/pages/live.tsx @@ -0,0 +1,304 @@ +import { useEffect, useState } from 'react' +import { formatINR, type BillLine, type BillTotals } from '@sims/domain' +import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing' +import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, Toolbar } from '@sims/ui' +import { createItem, getAudit, getBills, getItems, getParties, getSeller, getStock, type Seller } from '../api' + +/** The first live pages — real data from the store-server (the slice). */ + +const inr = (p: unknown) => formatINR(Number(p), { symbol: false }) + +function useData(loader: () => Promise, deps: unknown[] = []): { data?: T; error?: string; reload: () => void } { + const [data, setData] = useState() + const [error, setError] = useState() + const reload = () => { + setError(undefined) + loader().then(setData).catch((e: Error) => setError(e.message)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(reload, deps) + return { data, error, reload } +} + +const PAGE = 100 + +/** Offset pagination with an explicit "Load more" — R13: a cap the operator can lift, never silent. */ +function usePaged( + loader: (limit: number, offset: number) => Promise, + deps: unknown[], +): { rows: T[]; error?: string; hasMore: boolean; loading: boolean; loadMore: () => void } { + const [rows, setRows] = useState([]) + const [error, setError] = useState() + const [hasMore, setHasMore] = useState(false) + const [loading, setLoading] = useState(false) + const load = (offset: number) => { + setLoading(true) + setError(undefined) + loader(PAGE, offset) + .then((page) => { + setRows((prev) => (offset === 0 ? page : [...prev, ...page])) + setHasMore(page.length === PAGE) + }) + .catch((e: Error) => setError(e.message)) + .finally(() => setLoading(false)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { setRows([]); load(0) }, deps) + return { rows, error, hasMore, loading, loadMore: () => load(rows.length) } +} + +export function ItemsPage() { + const [q, setQ] = useState('') + const { data, error, reload } = useData(() => getItems(q), [q]) + const [creating, setCreating] = useState(false) + + return ( +
+ setCreating(true)}>New item} + /> + + setQ(e.target.value)} /> + {data?.length ?? '…'} items + + {error !== undefined && {error}} + {data !== undefined && ( + ({ + 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: {String(i['status'])}, + }))} + /> + )} + {creating && setCreating(false)} onCreated={() => { setCreating(false); reload() }} />} +
+ ) +} + +function NewItemModal(props: { onClose: () => void; onCreated: () => void }) { + const [f, setF] = useState({ code: '', name: '', hsn: '', taxClassCode: 'GST18', unitCode: 'PCS', priceRs: '', mrpRs: '', barcode: '' }) + const [error, setError] = useState() + const set = (k: keyof typeof f) => (e: { target: { value: string } }) => setF((p) => ({ ...p, [k]: e.target.value })) + + const save = () => { + createItem({ + code: f.code, name: f.name, hsn: f.hsn, taxClassCode: f.taxClassCode, + unitCode: f.unitCode, unitDecimals: f.unitCode === 'KG' ? 3 : 0, + salePricePaise: Math.round(Number(f.priceRs) * 100), + ...(f.mrpRs !== '' ? { mrpPaise: Math.round(Number(f.mrpRs) * 100) } : {}), + ...(f.barcode !== '' ? { barcode: f.barcode } : {}), + }).then(props.onCreated).catch((e: Error) => setError(e.message)) + } + + return ( + + + + + + + + + + + + + + {error !== undefined && {error}} +
+ + +
+
+ ) +} + +export function CustomersPage() { + const { data, error } = useData(() => getParties('customer')) + return ( +
+ + {error !== undefined && {error}} + {data === undefined ? Loading… : ( + ({ + code: String(p['code']), name: String(p['name']), + phone: p['phone'] !== undefined ? String(p['phone']) : '—', + gstin: p['gstin'] !== undefined ? {String(p['gstin'])} : '—', + limit: p['creditLimitPaise'] !== undefined ? inr(p['creditLimitPaise']) : '—', + }))} + /> + )} +
+ ) +} + +/** Build the A4 GST invoice for a bill row and open it in a print-ready window. */ +function openInvoice(bill: Record, seller: Seller | undefined): string | undefined { + let payload: { lines: BillLine[]; totals: BillTotals; payments?: { mode: string }[] } + try { + payload = JSON.parse(String(bill['payload'])) as typeof payload + } catch { + return 'This bill has no stored line detail to invoice.' + } + // The B2B buyer + place of supply are snapshotted on the bill at commit (S3), + // so the invoice reads them from the document, not from today's customer row. + const buyerGstin = bill['buyer_gstin'] !== null && bill['buyer_gstin'] !== undefined ? String(bill['buyer_gstin']) : undefined + const placeOfSupply = bill['place_of_supply'] !== null && bill['place_of_supply'] !== undefined ? String(bill['place_of_supply']) : undefined + const posState = placeOfSupply ?? seller?.stateCode + const doc: InvoiceDoc = { + seller: { + name: seller?.name ?? 'Store', + ...(seller?.gstin !== undefined ? { gstin: seller.gstin } : {}), + ...(seller?.stateCode !== undefined ? { stateCode: seller.stateCode } : {}), + }, + ...(bill['customer_name'] !== null && bill['customer_name'] !== undefined + ? { + buyer: { + name: String(bill['customer_name']), + ...(buyerGstin !== undefined ? { gstin: buyerGstin } : {}), + ...(placeOfSupply !== undefined ? { stateCode: placeOfSupply } : {}), + }, + } + : {}), + invoiceNo: String(bill['doc_no']), + invoiceDate: String(bill['business_date']), + ...(posState !== undefined ? { placeOfSupplyStateCode: posState } : {}), + lines: payload.lines, + totals: payload.totals, + ...(payload.payments?.[0]?.mode !== undefined ? { paymentLabel: payload.payments[0].mode } : {}), + } + // Serve the self-contained HTML as a blob URL — no document.write, and the new + // window loads it as a real document (so its own Print A4 button works). + const url = URL.createObjectURL(new Blob([renderInvoiceHtml(doc)], { type: 'text/html' })) + const win = window.open(url, '_blank') + if (win === null) { URL.revokeObjectURL(url); return 'Allow pop-ups to open the invoice (it prints in a new tab).' } + setTimeout(() => URL.revokeObjectURL(url), 60_000) + return undefined +} + +export function BillsPage() { + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)) + const { rows, error, hasMore, loading, loadMore } = usePaged>( + (limit, offset) => getBills(date, limit, offset), [date], + ) + const { data: seller } = useData(() => getSeller()) + const [printErr, setPrintErr] = useState() + const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0) + return ( +
+ + + setDate(e.target.value)} /> + {rows.length}{hasMore ? '+' : ''} bills + {formatINR(total)} + + {error !== undefined && {error}} + {printErr !== undefined && {printErr}} + {rows.length === 0 && loading ? Loading… + : rows.length === 0 && error === undefined ? No bills on {date}. Make one at the POS! : ( + setPrintErr(openInvoice(rows[i]!, seller))} + rows={rows.map((b) => ({ + no: String(b['doc_no']), + time: new Date(String(b['created_at_wall'])).toLocaleTimeString(), + cashier: String(b['cashier_name'] ?? ''), + customer: b['customer_name'] !== null ? String(b['customer_name']) : 'walk-in', + gst: inr(Number(b['cgst_paise']) + Number(b['sgst_paise']) + Number(b['igst_paise']) + Number(b['cess_paise'])), + amount: inr(b['payable_paise']), + invoice: Print A4 ↗, + }))} + /> + )} + {hasMore && ( +
+ +
+ )} +
+ ) +} + +export function StockPage() { + const { data, error } = useData(() => getStock()) + return ( +
+ + {error !== undefined && {error}} + {data === undefined ? Loading… : ( + { + const onHand = Number(s['on_hand']) + return { + code: String(s['code']), name: String(s['name']), + onhand: `${Math.round(onHand * 1000) / 1000} ${String(s['unit_code'])}`, + state: onHand <= 0 ? out : onHand < 15 ? low : ok, + } + })} + /> + )} +
+ ) +} + +export function AuditPage() { + const { rows, error, hasMore, loading, loadMore } = usePaged>( + (limit, offset) => getAudit(limit, offset), [], + ) + return ( +
+ + {error !== undefined && {error}} + {rows.length === 0 && loading ? Loading… + : rows.length === 0 && error === undefined ? No audit entries yet. : ( + ({ + at: new Date(String(a['at_wall'])).toLocaleString(), + who: String(a['user_name'] ?? a['user_id']), + action: {String(a['action'])}, + // Override rows carry a before_json (who approved what change from what) — + // show before → after so PRICE/QTY/DISCOUNT overrides read at a glance. + detail: `${String(a['entity'])} ${a['before_json'] !== null ? `${String(a['before_json']).slice(0, 60)} → ` : ''}${a['after_json'] !== null ? String(a['after_json']).slice(0, 90) : ''}`, + }))} + /> + )} + {hasMore && ( +
+ +
+ )} +
+ ) +} diff --git a/apps/backoffice/src/pages/purchase-entry.tsx b/apps/backoffice/src/pages/purchase-entry.tsx new file mode 100644 index 0000000..d632eae --- /dev/null +++ b/apps/backoffice/src/pages/purchase-entry.tsx @@ -0,0 +1,469 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { formatINR } from '@sims/domain' +import { parseEntry } from '@sims/scanning' +import { resolveTaxClass, type TaxClassRow } from '@sims/billing-engine' +import type { LabelInput } from '@sims/printing' +import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui' +import { getBootstrapPublic, getCostHistory, getItems, getLastCosts, getParties, getPurchases, getStock, postPurchase } from '../api' +import { openLabelSheet } from './labels' + +/** Crash-proof draft (spec P1-5): a 180-line invoice must survive a browser crash. */ +const DRAFT_KEY = 'pe.draft' +interface Draft { + supplier?: { id: string; name: string } + invoiceNo: string + invoiceDate: string + lines: PLine[] +} + +/** + * Purchase Entry — the second crown-jewel screen (09-UX §3): a purchaser types + * a 50–200 line supplier invoice as fast as the paper can be read. + * Flow per line: scan/type item → qty → cost (last-cost prefilled) → Enter + * returns to the scan box. Cost changes prompt a price revision inline. + */ + +interface ItemLite { + id: string; code: string; name: string; taxClassCode: string + salePricePaise: number; mrpPaise?: number + unit: { code: string; decimals: number }; barcodes: string[] +} + +interface PLine { + itemId: string; name: string; unitCode: string + qty: number; unitCostPaise: number; taxRateBp: number + lastCostPaise?: number + currentSalePaise: number; currentMrpPaise?: number + newSalePricePaise?: number; newMrpPaise?: number +} + +const inr = (p: number) => formatINR(p, { symbol: false }) +const rs = (s: string) => Math.round(Number(s) * 100) + +export function PurchaseEntryPage() { + const today = new Date().toISOString().slice(0, 10) + const [items, setItems] = useState([]) + const [taxClasses, setTaxClasses] = useState([]) + const [suppliers, setSuppliers] = useState<{ id: string; name: string }[]>([]) + const [supplier, setSupplier] = useState<{ id: string; name: string } | undefined>() + const [supplierQuery, setSupplierQuery] = useState('') + const [lastCost, setLastCost] = useState>({}) + const [invoiceNo, setInvoiceNo] = useState('') + const [invoiceDate, setInvoiceDate] = useState(today) + const [lines, setLines] = useState([]) + const [entry, setEntry] = useState('') + const [printedTotal, setPrintedTotal] = useState('') + const [notice, setNotice] = useState<{ tone: 'ok' | 'warn' | 'err'; text: string } | undefined>() + const [busy, setBusy] = useState(false) + /** Filled on post so the receiving clerk can print labels for what just came in. */ + const [labelQueue, setLabelQueue] = useState() + /** Which cell owns focus: the scan box, or qty/cost of a line. */ + const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' } | undefined>() + + const entryRef = useRef(null) + const cellRef = useRef(null) + + const [stockMap, setStockMap] = useState>(new Map()) + const [draft, setDraft] = useState() + const [costHist, setCostHist] = useState<{ cost: number; date: string; supplier: string }[] | undefined>() + + useEffect(() => { + Promise.all([getItems(), getParties('supplier'), getBootstrapPublic(), getStock()]) + .then(([its, sups, boot, stock]) => { + setItems(its as unknown as ItemLite[]) + setSuppliers(sups.map((s) => ({ id: String(s['id']), name: String(s['name']) }))) + setTaxClasses(boot.taxClasses) + setStockMap(new Map(stock.map((s) => [String(s['code']), Number(s['on_hand'])]))) + }) + .catch((e: Error) => setNotice({ tone: 'err', text: e.message })) + // resume a crashed entry? + try { + const raw = localStorage.getItem(DRAFT_KEY) + if (raw !== null) { + const d = JSON.parse(raw) as Draft + if (d.lines.length > 0) setDraft(d) + } + } catch { /* corrupted draft — ignore */ } + }, []) + + // journal every change; cleared on post + useEffect(() => { + if (lines.length > 0) { + localStorage.setItem(DRAFT_KEY, JSON.stringify({ supplier, invoiceNo, invoiceDate, lines } satisfies Draft)) + } + }, [lines, supplier, invoiceNo, invoiceDate]) + + // right-rail glanceability (spec P1-1): cost history for the line being edited + useEffect(() => { + const line = focusCell !== undefined ? lines[focusCell.line] : undefined + if (line === undefined) return setCostHist(undefined) + getCostHistory(line.itemId, supplier?.id).then(setCostHist).catch(() => setCostHist(undefined)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusCell?.line]) + + useEffect(() => { + getLastCosts(supplier?.id).then(setLastCost).catch(() => undefined) + }, [supplier?.id]) + + useEffect(() => { + if (focusCell !== undefined) cellRef.current?.select() + else entryRef.current?.focus() + }, [focusCell, lines.length]) + + const say = (tone: 'ok' | 'warn' | 'err', text: string) => setNotice({ tone, text }) + + const rateOf = (item: ItemLite): number => { + try { + const r = resolveTaxClass(taxClasses, item.taxClassCode, invoiceDate) + return r.ratePctBp + r.cessPctBp + } catch { + return 0 + } + } + + const addItem = (item: ItemLite, qty = 1) => { + const memory = lastCost[item.id] + const prefill = memory?.supplierLast ?? memory?.last ?? 0 + setLines((prev) => { + setFocusCell({ line: prev.length, cell: 'qty' }) + return [...prev, { + itemId: item.id, name: item.name, unitCode: item.unit.code, + qty, unitCostPaise: prefill, taxRateBp: rateOf(item), + ...(memory?.supplierLast !== undefined || memory?.last !== undefined + ? { lastCostPaise: memory.supplierLast ?? memory.last } + : {}), + currentSalePaise: item.salePricePaise, + ...(item.mrpPaise !== undefined ? { currentMrpPaise: item.mrpPaise } : {}), + }] + }) + setNotice(undefined) + } + + const handleEntry = () => { + const parsed = parseEntry(entry) + setEntry('') + const find = (code: string) => + items.find((i) => i.barcodes.includes(code)) ?? items.find((i) => i.code === code) + switch (parsed.kind) { + case 'empty': return + case 'multiplier': { + if (parsed.rest === '') return say('warn', 'Type the item after the quantity, e.g. 24*surf') + const item = /^\d+$/.test(parsed.rest) + ? find(parsed.rest) + : items.find((i) => i.name.toLowerCase().includes(parsed.rest.toLowerCase())) + return item !== undefined ? addItem(item, parsed.qty) : say('err', `No item for "${parsed.rest}" (E-1102)`) + } + case 'barcode': + case 'code': { + const item = find(parsed.code) + return item !== undefined ? addItem(item) : say('err', `Unknown code ${parsed.code} (E-1102)`) + } + case 'search': { + const q = parsed.text.toLowerCase() + const item = items.filter((i) => i.name.toLowerCase().includes(q)) + .sort((a, b) => Number(b.name.toLowerCase().startsWith(q)) - Number(a.name.toLowerCase().startsWith(q)))[0] + return item !== undefined ? addItem(item) : say('err', `No item matches "${parsed.text}" (E-1102)`) + } + } + } + + const setLine = (i: number, patch: Partial) => + setLines((prev) => prev.map((l, j) => (j === i ? { ...l, ...patch } : l))) + + const totals = useMemo(() => { + let taxable = 0, tax = 0 + for (const l of lines) { + const t = Math.round(l.qty * l.unitCostPaise) + taxable += t + tax += Math.round((t * l.taxRateBp) / 10_000) + } + return { taxable, tax, total: taxable + tax } + }, [lines]) + + const printedPaise = printedTotal === '' ? undefined : rs(printedTotal) + const crossCheck = printedPaise === undefined ? 'none' : printedPaise === totals.total ? 'match' : 'mismatch' + + const post = () => { + if (busy) return + if (supplier === undefined) return say('warn', 'Pick the supplier first') + if (invoiceNo.trim() === '') return say('warn', 'Enter the supplier invoice number') + if (lines.length === 0) return say('warn', 'No lines yet — scan or type the first item') + if (crossCheck === 'mismatch') { + return say('warn', `Printed total ${formatINR(printedPaise!)} ≠ computed ${formatINR(totals.total)} — fix a line or clear the check field to post anyway`) + } + setBusy(true) + postPurchase({ + storeId: 's1', supplierId: supplier.id, invoiceNo: invoiceNo.trim(), invoiceDate, + businessDate: today, + lines: lines.map((l) => ({ + itemId: l.itemId, name: l.name, qty: l.qty, unitCostPaise: l.unitCostPaise, + taxRateBp: l.taxRateBp, + ...(l.newSalePricePaise !== undefined ? { newSalePricePaise: l.newSalePricePaise } : {}), + ...(l.newMrpPaise !== undefined ? { newMrpPaise: l.newMrpPaise } : {}), + })), + clientTotalPaise: totals.total, + }) + .then((res) => { + say('ok', `Purchase posted — ${lines.length} lines, ${formatINR(res.totalPaise)}. Stock is in; price updates applied.`) + // Label queue (09-UX §3.2): received qty each, MRP/price effective after the post, + // first barcode from the item master. The clerk prints on the spot. + setLabelQueue(lines.map((l): LabelInput => { + const item = items.find((i) => i.id === l.itemId) + const mrp = l.newMrpPaise ?? l.currentMrpPaise ?? l.newSalePricePaise ?? l.currentSalePaise + const barcode = item?.barcodes.find((b) => b !== '') + return { + name: l.name, mrpPaise: mrp, pricePaise: l.newSalePricePaise ?? l.currentSalePaise, + ...(barcode !== undefined ? { code13: barcode } : {}), + } + })) + setLines([]); setInvoiceNo(''); setPrintedTotal('') + localStorage.removeItem(DRAFT_KEY) + getLastCosts(supplier.id).then(setLastCost).catch(() => undefined) + }) + .catch((err: Error) => say('err', `${err.message} — nothing was posted`)) + .finally(() => setBusy(false)) + } + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'F12' || (e.ctrlKey && e.key === 'Enter')) { e.preventDefault(); post() } + if (e.key === 'Escape') { setFocusCell(undefined); setNotice(undefined) } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }) + + const supplierMatches = supplierQuery === '' ? [] : + suppliers.filter((s) => s.name.toLowerCase().includes(supplierQuery.toLowerCase())).slice(0, 6) + + return ( +
+ {busy ? 'Posting…' : 'Post purchase'}} + /> + + +
+ {supplier === undefined ? ( + <> + setSupplierQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && supplierMatches[0] !== undefined) { + setSupplier(supplierMatches[0]); setSupplierQuery('') + } + }} + /> + {supplierMatches.length > 0 && ( +
+ {supplierMatches.map((s) => ( +
{ setSupplier(s); setSupplierQuery('') }}>{s.name}
+ ))} +
+ )} + + ) : ( + {supplier.name} ✕ + )} + {supplier !== undefined && ( + + )} +
+ setInvoiceNo(e.target.value)} /> + setInvoiceDate(e.target.value)} /> +
+ + setEntry(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleEntry() } }} + /> + {notice !== undefined && {notice.text}} + + {labelQueue !== undefined && ( + + Print labels for {labelQueue.length} received item(s)?{' '} + {' '} + + + )} + + {draft !== undefined && ( + + Unfinished entry found — {draft.invoiceNo !== '' ? draft.invoiceNo : 'no invoice no'} with {draft.lines.length} line(s).{' '} + {' '} + + + )} + + {focusCell !== undefined && lines[focusCell.line] !== undefined && (() => { + const l = lines[focusCell.line]! + const item = items.find((i) => i.id === l.itemId) + const margin = l.unitCostPaise > 0 ? ((l.currentSalePaise - l.unitCostPaise) / l.unitCostPaise) * 100 : 0 + return ( + + {l.name} · stock {item !== undefined ? (stockMap.get(item.code) ?? '—') : '—'} {l.unitCode} + · sale {inr(l.currentSalePaise)}{l.currentMrpPaise !== undefined ? ` · MRP ${inr(l.currentMrpPaise)}` : ''} + · margin at this cost {margin.toFixed(1)}% + {costHist !== undefined && costHist.length > 0 && ( + <> · last costs: {costHist.map((h) => `${inr(Number(h.cost))} (${String(h.date)})`).join(' · ')} + )} + + ) + })()} + + {lines.length === 0 ? ( + Pick the supplier, then scan the first line of the invoice. + ) : ( + + + + + + + + + {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 ? ( + { + 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 + } + }} + /> + ) : ( + setFocusCell({ line: i, cell: kind })}> + {kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)} + + ) + return ( + + + + + + + + + + + ) + })} + +
#ItemQtyCost ₹LastGSTLine totalPrice
{i + 1}{l.name}{cell('qty')}{cell('cost')}{l.lastCostPaise !== undefined ? inr(l.lastCostPaise) : '—'}{l.taxRateBp / 100}%{inr(lineTaxable + lineTax)} + {costChanged ? ( + setLine(i, p)} /> + ) : l.newSalePricePaise !== undefined ? ( + new ₹{(l.newSalePricePaise / 100).toFixed(2)} + ) : ( + keep + )} +
+ )} + + + Taxable {formatINR(totals.taxable)} + GST {formatINR(totals.tax)} + Total {formatINR(totals.total)} + + + setPrintedTotal(e.target.value)} /> + + {crossCheck === 'match' && totals match ✓} + {crossCheck === 'mismatch' && ≠ computed — check a line} + +
+ ) +} + +/** Inline price-revision prompt when the cost moved — margin at a glance. */ +function PriceRevision(props: { line: PLine; onSet: (patch: Partial) => void }) { + const { line } = props + const [open, setOpen] = useState(false) + const [sale, setSale] = useState((line.currentSalePaise / 100).toFixed(2)) + const [mrp, setMrp] = useState(line.currentMrpPaise !== undefined ? (line.currentMrpPaise / 100).toFixed(2) : '') + const marginBp = line.unitCostPaise > 0 + ? Math.round(((line.currentSalePaise - line.unitCostPaise) / line.unitCostPaise) * 10_000) + : 0 + + if (!open) { + return ( + + cost changed · margin {(marginBp / 100).toFixed(1)}%{' '} + + + ) + } + return ( + + setSale(e.target.value)} placeholder="Sale ₹" /> + setMrp(e.target.value)} placeholder="MRP ₹" /> + + + ) +} + +export function PurchasesListPage() { + const [data, setData] = useState[] | undefined>() + const [error, setError] = useState() + useEffect(() => { + getPurchases().then(setData).catch((e: Error) => setError(e.message)) + }, []) + return ( +
+ + {error !== undefined && {error}} + {data === undefined ? Loading… : data.length === 0 ? ( + No purchases yet — enter one in Purchase Entry. + ) : ( + ({ + inv: String(p['invoice_no']), supplier: String(p['supplier_name'] ?? ''), + date: String(p['invoice_date']), lines: String(p['line_count']), + total: inr(Number(p['total_paise'])), + }))} + /> + )} +
+ ) +} diff --git a/apps/backoffice/src/pages/registry.tsx b/apps/backoffice/src/pages/registry.tsx new file mode 100644 index 0000000..ff0c61e --- /dev/null +++ b/apps/backoffice/src/pages/registry.tsx @@ -0,0 +1,673 @@ +import type { Column } from '@sims/ui' +import type { ReactNode } from 'react' +import { Badge } from '@sims/ui' + +/** + * Wireframe pages as data: one config per list page, one generic renderer. + * Sample rows are grocery-retail realistic so flows read true in review. + */ +export interface PageConfig { + path: string + title: string + desc: string + actions: string[] + filters?: string[] + stats?: { label: string; value: string; hint?: string }[] + columns: Column[] + rows: Record[] +} + +const ok = (t: string) => {t} +const warn = (t: string) => {t} +const err = (t: string) => {t} +const dim = (t: string) => {t} + +export const PAGES: PageConfig[] = [ + { + path: '/catalog/items', + title: 'Items', + desc: 'The item master — HSN, GST class, MRP, barcodes, units. Bulk edit and Excel import/export.', + actions: ['New item', 'Import Excel', 'Export', 'Bulk edit'], + filters: ['Category', 'GST class', 'Status', 'Batch-tracked'], + stats: [ + { label: 'Active items', value: '12,482' }, + { label: 'Draft (from POS quick-add)', value: '17', hint: 'need completion' }, + { label: 'Without barcode', value: '318' }, + ], + columns: [ + { key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, { key: 'hsn', label: 'HSN' }, + { key: 'gst', label: 'GST' }, { key: 'mrp', label: 'MRP', numeric: true }, + { key: 'price', label: 'Sale Price', numeric: true }, { key: 'stock', label: 'Stock', numeric: true }, + { key: 'status', label: 'Status' }, + ], + rows: [ + { code: '101', name: 'Aashirvaad Atta 5kg', hsn: '1101', gst: '0%', mrp: '285.00', price: '270.00', stock: '142', status: ok('active') }, + { code: '103', name: 'Surf Excel 1kg', hsn: '3402', gst: '18%', mrp: '150.00', price: '145.00', stock: '86', status: ok('active') }, + { code: '42', name: 'Tomato (loose)', hsn: '0702', gst: '0%', mrp: '—', price: '32.00/kg', stock: '48.2 kg', status: ok('active') }, + { code: 'D-2201', name: 'ORS Sachet (POS quick-add)', hsn: '—', gst: '—', mrp: '—', price: '20.00', stock: '—', status: warn('draft') }, + ], + }, + { + path: '/catalog/categories', + title: 'Categories', + desc: 'Category tree with GST-class defaults that new items inherit.', + actions: ['New category'], + columns: [ + { key: 'name', label: 'Category' }, { key: 'items', label: 'Items', numeric: true }, + { key: 'gst', label: 'Default GST' }, { key: 'margin', label: 'Target margin', numeric: true }, + ], + rows: [ + { name: 'Staples › Flour & Grains', items: '214', gst: '0%', margin: '8%' }, + { name: 'Home Care › Detergents', items: '186', gst: '18%', margin: '14%' }, + { name: 'Fresh › Vegetables', items: '64', gst: '0%', margin: '22%' }, + ], + }, + { + path: '/catalog/price-lists', + title: 'Price Lists', + desc: 'MRP, margin and customer-category pricing with effective dates.', + actions: ['New price list', 'Bulk revise'], + columns: [ + { key: 'name', label: 'Price list' }, { key: 'basis', label: 'Basis' }, + { key: 'items', label: 'Items', numeric: true }, { key: 'from', label: 'Effective from' }, + ], + rows: [ + { name: 'Retail (default)', basis: 'MRP − margin', items: '12,482', from: '2026-04-01' }, + { name: 'Wholesale', basis: 'Cost + 4%', items: '2,140', from: '2026-04-01' }, + ], + }, + { + path: '/catalog/schemes', + title: 'Schemes & Offers', + desc: 'Buy-X-get-Y, slab discounts, date-bound offers — evaluated by the billing engine.', + actions: ['New scheme'], + columns: [ + { key: 'name', label: 'Scheme' }, { key: 'kind', label: 'Type' }, + { key: 'window', label: 'Window' }, { key: 'status', label: 'Status' }, + ], + rows: [ + { name: 'Atta+Ghee combo −₹40', kind: 'Basket', window: '01 Jul – 31 Jul', status: ok('live') }, + { name: 'Onam 5% off staples', kind: 'Category %', window: '20 Aug – 05 Sep', status: dim('scheduled') }, + ], + }, + { + path: '/catalog/labels', + title: 'Label Printing', + desc: 'Barcode/MRP label queue — filled automatically from purchases.', + actions: ['Print queue', 'Design labels'], + columns: [ + { key: 'item', label: 'Item' }, { key: 'batch', label: 'Batch' }, + { key: 'qty', label: 'Labels', numeric: true }, { key: 'src', label: 'Source' }, + ], + rows: [ + { item: 'Surf Excel 1kg', batch: 'B-0714', qty: '48', src: 'Purchase PI-000212' }, + { item: 'Amul Butter 500g', batch: 'B-0713', qty: '24', src: 'Purchase PI-000211' }, + ], + }, + { + path: '/sales/bills', + title: 'Bills', + desc: 'Every bill from every counter — immutable documents; corrections are credit notes.', + actions: ['Export', 'Print duplicate'], + filters: ['Store', 'Counter', 'Cashier', 'Payment mode', 'Date'], + stats: [ + { label: "Today's sales", value: '₹1,84,320' }, + { label: 'Bills', value: '412' }, + { label: 'Avg bill value', value: '₹447' }, + ], + columns: [ + { key: 'no', label: 'Bill No' }, { key: 'time', label: 'Time' }, { key: 'counter', label: 'Counter' }, + { key: 'cashier', label: 'Cashier' }, { key: 'mode', label: 'Mode' }, + { key: 'amount', label: 'Amount', numeric: true }, + ], + rows: [ + { no: 'ST1C2/26-000412', time: '18:42', counter: 'C2', cashier: 'Ramesh', mode: dim('UPI'), amount: '1,431.00' }, + { no: 'ST1C1/26-000388', time: '18:41', counter: 'C1', cashier: 'Divya', mode: dim('CASH'), amount: '284.00' }, + { no: 'ST1C2/26-000411', time: '18:39', counter: 'C2', cashier: 'Ramesh', mode: dim('KHATA'), amount: '2,110.00' }, + ], + }, + { + path: '/sales/returns', + title: 'Returns & Credit Notes', + desc: 'Returns reference the original bill; taxes reverse at the original bill-date rates.', + actions: ['New return'], + columns: [ + { key: 'no', label: 'Credit Note' }, { key: 'against', label: 'Against Bill' }, + { key: 'reason', label: 'Reason' }, { key: 'amount', label: 'Amount', numeric: true }, + ], + rows: [ + { no: 'CN/26-000031', against: 'ST1C2/26-000381', reason: 'Damaged pack', amount: '145.00' }, + { no: 'CN/26-000030', against: 'ST1C1/26-000352', reason: 'Wrong size', amount: '499.00' }, + ], + }, + { + path: '/sales/customers', + title: 'Customers', + desc: 'Phone-number keyed profiles with purchase history, khata and loyalty.', + actions: ['New customer', 'Import', 'Export'], + filters: ['Khata due', 'Loyalty tier', 'Inactive 90d'], + columns: [ + { key: 'phone', label: 'Phone' }, { key: 'name', label: 'Name' }, + { key: 'visits', label: 'Visits', numeric: true }, { key: 'khata', label: 'Khata', numeric: true }, + { key: 'points', label: 'Points', numeric: true }, + ], + rows: [ + { phone: '98400 12345', name: 'Lakshmi', visits: '86', khata: '1,240.00 dr', points: '320' }, + { phone: '98470 55210', name: 'Joseph', visits: '41', khata: '—', points: '110' }, + ], + }, + { + path: '/sales/khata', + title: 'Khata & Collections', + desc: 'Credit ledger with ageing and WhatsApp payment reminders + UPI collect links.', + actions: ['Record payment', 'Send reminders'], + stats: [ + { label: 'Outstanding', value: '₹86,420' }, + { label: 'Over 30 days', value: '₹12,340', hint: '9 customers' }, + ], + columns: [ + { key: 'name', label: 'Customer' }, { key: 'due', label: 'Due', numeric: true }, + { key: 'age', label: 'Oldest', numeric: true }, { key: 'last', label: 'Last payment' }, + { key: 'remind', label: 'Reminder' }, + ], + rows: [ + { name: 'Lakshmi', due: '1,240.00', age: '12 d', last: '28 Jun', remind: ok('sent today') }, + { name: 'Anand Stores (B2B)', due: '22,600.00', age: '38 d', last: '01 Jun', remind: warn('due') }, + ], + }, + { + path: '/sales/loyalty', + title: 'Loyalty', + desc: 'Points earn/redeem rules; redemption happens at the counter.', + actions: ['Configure rules'], + columns: [ + { key: 'tier', label: 'Tier' }, { key: 'members', label: 'Members', numeric: true }, + { key: 'earn', label: 'Earn rate' }, { key: 'redeemed', label: 'Redeemed MTD', numeric: true }, + ], + rows: [ + { tier: 'Green', members: '3,204', earn: '1 pt / ₹100', redeemed: '₹8,420' }, + { tier: 'Gold', members: '412', earn: '2 pt / ₹100', redeemed: '₹6,110' }, + ], + }, + { + path: '/purchases/list', + title: 'Purchase List', + desc: 'All purchase invoices — manual entry or Purchase Inbox.', + actions: ['New purchase', 'Export'], + filters: ['Supplier', 'Status', 'Date'], + columns: [ + { key: 'no', label: 'Purchase' }, { key: 'supplier', label: 'Supplier' }, { key: 'date', label: 'Date' }, + { key: 'src', label: 'Source' }, { key: 'amount', label: 'Amount', numeric: true }, { key: 'status', label: 'Status' }, + ], + rows: [ + { no: 'PI-000212', supplier: 'HUL Distributor', date: '08 Jul', src: dim('Inbox'), amount: '48,210.00', status: ok('posted') }, + { no: 'PI-000211', supplier: 'Amul Agency', date: '08 Jul', src: dim('manual'), amount: '22,400.00', status: ok('posted') }, + { no: 'PI-000210', supplier: 'Local Farm Veg', date: '07 Jul', src: dim('manual'), amount: '8,150.00', status: warn('draft') }, + ], + }, + { + path: '/purchases/entry', + title: 'Purchase Entry', + desc: 'Speed-first manual entry: supplier → lines by barcode/search → margin & MRP prompts → label queue.', + actions: ['Start entry (wireframe form)'], + columns: [ + { key: 'step', label: '#' }, { key: 'what', label: 'Flow step (09-UX §3)' }, { key: 'keys', label: 'Keystrokes' }, + ], + rows: [ + { step: '1', what: 'Pick supplier (type-ahead, remembers last)', keys: '~4' }, + { step: '2', what: 'Invoice no + date (defaults to today)', keys: '~8' }, + { step: '3', what: 'Scan/type item → qty → cost; price-change prompt if cost moved', keys: '~6/line' }, + { step: '4', what: 'Post → stock in, labels queued, supplier ledger updated', keys: '1' }, + ], + }, + { + path: '/purchases/suppliers', + title: 'Suppliers', + desc: 'Supplier master with GSTIN, ledgers, price history and pending documents.', + actions: ['New supplier'], + columns: [ + { key: 'name', label: 'Supplier' }, { key: 'gstin', label: 'GSTIN' }, + { key: 'due', label: 'Payable', numeric: true }, { key: 'last', label: 'Last purchase' }, + ], + rows: [ + { name: 'HUL Distributor', gstin: '32AABCH1234K1Z6', due: '96,400.00', last: '08 Jul' }, + { name: 'Amul Agency', gstin: '32AAACA5678L1Z2', due: '22,400.00', last: '08 Jul' }, + ], + }, + { + path: '/purchases/gstr2b', + title: 'GSTR-2B Reconciliation', + desc: 'Portal data vs entered purchases — exact, fuzzy and unmatched tiers.', + actions: ['Pull 2B', 'Auto-match'], + stats: [ + { label: 'Matched', value: '218', hint: 'exact' }, + { label: 'Fuzzy', value: '11', hint: 'review' }, + { label: 'Missing in books', value: '3' }, + ], + columns: [ + { key: 'inv', label: 'Supplier invoice' }, { key: 'supplier', label: 'Supplier' }, + { key: 'portal', label: 'Portal ITC', numeric: true }, { key: 'books', label: 'Books', numeric: true }, + { key: 'state', label: 'Match' }, + ], + rows: [ + { inv: 'HD/2647', supplier: 'HUL Distributor', portal: '7,344.00', books: '7,344.00', state: ok('exact') }, + { inv: 'AA/889', supplier: 'Amul Agency', portal: '2,688.00', books: '2,688.10', state: warn('off by 0.10') }, + { inv: 'XY/102', supplier: 'Unknown GSTIN', portal: '1,120.00', books: '—', state: err('missing') }, + ], + }, + { + path: '/inventory/stock', + title: 'Stock', + desc: 'Live stock by store — derived from movements, never a stored number (invariant I4).', + actions: ['Export', 'Reorder suggestions'], + filters: ['Store', 'Category', 'Below reorder', 'Batch'], + columns: [ + { key: 'item', label: 'Item' }, { key: 'store', label: 'Store' }, + { key: 'qty', label: 'On hand', numeric: true }, { key: 'value', label: 'Value', numeric: true }, + { key: 'reorder', label: 'Reorder' }, + ], + rows: [ + { item: 'Aashirvaad Atta 5kg', store: 'ST1', qty: '142', value: '35,742.00', reorder: ok('ok') }, + { item: 'Tata Salt 1kg', store: 'ST1', qty: '12', value: '288.00', reorder: warn('below min 24') }, + { item: 'Coca-Cola 1.25L', store: 'ST1', qty: '0', value: '0.00', reorder: err('out') }, + ], + }, + { + path: '/inventory/adjustments', + title: 'Adjustments', + desc: 'Stock corrections with reason codes and approval — every one audit-logged.', + actions: ['New adjustment'], + columns: [ + { key: 'no', label: 'Adj No' }, { key: 'item', label: 'Item' }, { key: 'qty', label: 'Qty', numeric: true }, + { key: 'reason', label: 'Reason' }, { key: 'by', label: 'By' }, { key: 'status', label: 'Status' }, + ], + rows: [ + { no: 'ADJ-0087', item: 'Tomato (loose)', qty: '−2.4 kg', reason: 'Wastage', by: 'Suresh', status: ok('approved') }, + { no: 'ADJ-0086', item: 'Parle-G 800g', qty: '−3', reason: 'Damaged', by: 'Ramesh', status: warn('pending') }, + ], + }, + { + path: '/inventory/transfers', + title: 'Transfers', + desc: 'Inter-store transfers with in-transit state and e-Way bills where value demands.', + actions: ['New transfer'], + columns: [ + { key: 'no', label: 'Transfer' }, { key: 'from', label: 'From' }, { key: 'to', label: 'To' }, + { key: 'items', label: 'Items', numeric: true }, { key: 'state', label: 'State' }, + ], + rows: [ + { no: 'TR-0012', from: 'ST1', to: 'ST2', items: '38', state: warn('in transit') }, + { no: 'TR-0011', from: 'ST2', to: 'ST1', items: '12', state: ok('received') }, + ], + }, + { + path: '/inventory/stock-take', + title: 'Stock Take', + desc: 'Full and cycle counts with mobile scanning; variances become adjustments.', + actions: ['Start session'], + columns: [ + { key: 'session', label: 'Session' }, { key: 'scope', label: 'Scope' }, + { key: 'counted', label: 'Counted', numeric: true }, { key: 'variance', label: 'Variance', numeric: true }, + { key: 'status', label: 'Status' }, + ], + rows: [ + { session: 'CT-2026-07A', scope: 'Fresh + Staples', counted: '278 / 278', variance: '−₹1,240', status: ok('closed') }, + { session: 'CT-2026-07B', scope: 'Home Care', counted: '112 / 186', variance: '—', status: warn('counting') }, + ], + }, + { + path: '/inventory/expiry', + title: 'Expiry Dashboard', + desc: 'Near-expiry, dead stock, fast/slow movers — act before it becomes wastage.', + actions: ['Markdown near-expiry'], + stats: [ + { label: 'Expiring ≤ 7 days', value: '₹4,320', hint: '18 batches' }, + { label: 'Dead stock (90d)', value: '₹18,200' }, + ], + columns: [ + { key: 'item', label: 'Item' }, { key: 'batch', label: 'Batch' }, + { key: 'expiry', label: 'Expiry' }, { key: 'qty', label: 'Qty', numeric: true }, + ], + rows: [ + { item: 'Amul Butter 500g', batch: 'B-0698', expiry: '14 Jul', qty: '9' }, + { item: 'Maggi 12-pack', batch: 'B-0641', expiry: '19 Jul', qty: '14' }, + ], + }, + { + path: '/accounting/vouchers', + title: 'Vouchers', + desc: 'Payment, receipt and expense vouchers feeding the ledgers.', + actions: ['New voucher'], + columns: [ + { key: 'no', label: 'Voucher' }, { key: 'kind', label: 'Type' }, { key: 'party', label: 'Party' }, + { key: 'amount', label: 'Amount', numeric: true }, + ], + rows: [ + { no: 'PV-0231', kind: 'Payment', party: 'HUL Distributor', amount: '50,000.00' }, + { no: 'RV-0140', kind: 'Receipt', party: 'Anand Stores', amount: '10,000.00' }, + { no: 'EX-0088', kind: 'Expense', party: 'KSEB (electricity)', amount: '8,420.00' }, + ], + }, + { + path: '/accounting/ledgers', + title: 'Ledgers', + desc: 'Party and account ledgers with ageing.', + actions: ['Export', 'Statement'], + columns: [ + { key: 'ledger', label: 'Ledger' }, { key: 'group', label: 'Group' }, + { key: 'dr', label: 'Dr', numeric: true }, { key: 'cr', label: 'Cr', numeric: true }, + { key: 'bal', label: 'Balance', numeric: true }, + ], + rows: [ + { ledger: 'Cash in hand', group: 'Cash', dr: '2,84,000', cr: '2,10,000', bal: '74,000 dr' }, + { ledger: 'HUL Distributor', group: 'Sundry Creditors', dr: '3,90,000', cr: '4,86,400', bal: '96,400 cr' }, + ], + }, + { + path: '/accounting/day-book', + title: 'Day Book', + desc: 'Everything that happened on a working date — the OG Day concept, reported.', + actions: ['Print', 'Export'], + columns: [ + { key: 'time', label: 'Time' }, { key: 'doc', label: 'Document' }, { key: 'party', label: 'Party' }, + { key: 'dr', label: 'Dr', numeric: true }, { key: 'cr', label: 'Cr', numeric: true }, + ], + rows: [ + { time: '18:42', doc: 'Bill ST1C2/26-000412', party: 'Lakshmi', dr: '—', cr: '1,431.00' }, + { time: '17:10', doc: 'PV-0231', party: 'HUL Distributor', dr: '50,000.00', cr: '—' }, + ], + }, + { + path: '/accounting/pnl', + title: 'P&L / Balance Sheet', + desc: 'Financial statements (Pro). Most tenants export to Tally for their CA first (D6).', + actions: ['This FY', 'Export'], + columns: [ + { key: 'line', label: 'Line' }, { key: 'mtd', label: 'MTD', numeric: true }, { key: 'ytd', label: 'YTD', numeric: true }, + ], + rows: [ + { line: 'Sales', mtd: '₹41,20,000', ytd: '₹1,42,80,000' }, + { line: 'COGS', mtd: '₹35,40,000', ytd: '₹1,22,10,000' }, + { line: 'Gross profit', mtd: '₹5,80,000', ytd: '₹20,70,000' }, + ], + }, + { + path: '/accounting/tally', + title: 'Tally Export', + desc: 'Masters + vouchers as Tally XML — the CA keeps their workflow, we keep the customer.', + actions: ['Export this month', 'Configure mapping'], + columns: [ + { key: 'run', label: 'Export run' }, { key: 'range', label: 'Range' }, + { key: 'vouchers', label: 'Vouchers', numeric: true }, { key: 'status', label: 'Status' }, + ], + rows: [ + { run: 'TX-0007', range: 'Jun 2026', vouchers: '1,842', status: ok('downloaded') }, + { run: 'TX-0006', range: 'May 2026', vouchers: '1,711', status: ok('downloaded') }, + ], + }, + { + path: '/gst/gstr1', + title: 'GSTR-1', + desc: 'Outward supplies return — B2B/B2C summaries and JSON export for the portal.', + actions: ['Prepare Jun 2026', 'Export JSON'], + stats: [ + { label: 'B2B invoices', value: '214' }, + { label: 'B2C (large)', value: '3' }, + { label: 'B2C (others)', value: '₹38,41,200' }, + ], + columns: [ + { key: 'section', label: 'Section' }, { key: 'count', label: 'Docs', numeric: true }, + { key: 'taxable', label: 'Taxable', numeric: true }, { key: 'tax', label: 'Tax', numeric: true }, + ], + rows: [ + { section: 'B2B', count: '214', taxable: '₹6,84,200', tax: '₹88,340' }, + { section: 'B2CS', count: '—', taxable: '₹32,10,400', tax: '₹2,41,080' }, + { section: 'CDNR (credit notes)', count: '31', taxable: '−₹41,200', tax: '−₹5,120' }, + ], + }, + { + path: '/gst/gstr3b', + title: 'GSTR-3B', + desc: 'Summary return with ITC set-off preview.', + actions: ['Prepare Jun 2026'], + columns: [ + { key: 'table', label: 'Table' }, { key: 'igst', label: 'IGST', numeric: true }, + { key: 'cgst', label: 'CGST', numeric: true }, { key: 'sgst', label: 'SGST', numeric: true }, + ], + rows: [ + { table: '3.1 Outward supplies', igst: '12,400', cgst: '1,58,200', sgst: '1,58,200' }, + { table: '4 Eligible ITC', igst: '8,120', cgst: '1,12,340', sgst: '1,12,340' }, + ], + }, + { + path: '/gst/einvoice', + title: 'e-Invoice Queue', + desc: 'IRN registration for B2B bills — offline bills queue and register when online; the escalation ladder guards the reporting window (Issue GST-8).', + actions: ['Retry failed'], + stats: [ + { label: 'Registered today', value: '38' }, + { label: 'Queued (offline)', value: '2', hint: 'oldest 40 min' }, + { label: 'Failed', value: '1', hint: 'needs attention' }, + ], + columns: [ + { key: 'bill', label: 'Bill' }, { key: 'buyer', label: 'Buyer GSTIN' }, + { key: 'state', label: 'IRN' }, { key: 'age', label: 'Age' }, + ], + rows: [ + { bill: 'ST1C1/26-000372', buyer: '32AABCA1111M1Z3', state: ok('registered'), age: '2 min' }, + { bill: 'ST1C2/26-000398', buyer: '29AAACB2222N1Z5', state: warn('queued'), age: '40 min' }, + { bill: 'ST1C1/26-000355', buyer: '32AABCC3333P1Z7', state: err('GSP error 2150'), age: '3 h' }, + ], + }, + { + path: '/gst/eway', + title: 'e-Way Bills', + desc: 'Generated for goods movement above threshold — transfers and B2B deliveries.', + actions: ['New e-Way'], + columns: [ + { key: 'no', label: 'e-Way No' }, { key: 'doc', label: 'Against' }, + { key: 'vehicle', label: 'Vehicle' }, { key: 'valid', label: 'Valid till' }, + ], + rows: [ + { no: 'EWB-3312 4410 0921', doc: 'TR-0012', vehicle: 'KL-07-AB-1234', valid: '10 Jul 23:59' }, + ], + }, + { + path: '/gst/hsn', + title: 'HSN Summary', + desc: 'HSN-wise outward summary as GSTR-1 wants it.', + actions: ['Export'], + columns: [ + { key: 'hsn', label: 'HSN' }, { key: 'desc', label: 'Description' }, + { key: 'qty', label: 'Qty', numeric: true }, { key: 'taxable', label: 'Taxable', numeric: true }, + { key: 'tax', label: 'Tax', numeric: true }, + ], + rows: [ + { hsn: '1101', desc: 'Wheat flour', qty: '2,140', taxable: '₹5,64,200', tax: '₹0' }, + { hsn: '3402', desc: 'Detergents', qty: '860', taxable: '₹1,10,300', tax: '₹19,854' }, + ], + }, + { + path: '/reports/sales', + title: 'Sales Reports', + desc: 'By day, category, counter, cashier, payment mode — export everywhere.', + actions: ['Export', 'Schedule digest'], + filters: ['Range', 'Store', 'Group by'], + columns: [ + { key: 'day', label: 'Day' }, { key: 'bills', label: 'Bills', numeric: true }, + { key: 'sales', label: 'Sales', numeric: true }, { key: 'gst', label: 'GST', numeric: true }, + { key: 'abv', label: 'Avg bill', numeric: true }, + ], + rows: [ + { day: 'Wed 08 Jul', bills: '498', sales: '₹2,21,400', gst: '₹18,320', abv: '₹445' }, + { day: 'Tue 07 Jul', bills: '451', sales: '₹1,98,100', gst: '₹16,110', abv: '₹439' }, + ], + }, + { + path: '/reports/margins', + title: 'Margins', + desc: 'Realized margin by item/category — where the money actually is.', + actions: ['Export'], + columns: [ + { key: 'cat', label: 'Category' }, { key: 'sales', label: 'Sales', numeric: true }, + { key: 'margin', label: 'Margin ₹', numeric: true }, { key: 'pct', label: 'Margin %', numeric: true }, + ], + rows: [ + { cat: 'Fresh › Vegetables', sales: '₹28,400', margin: '₹6,240', pct: '22.0%' }, + { cat: 'Staples', sales: '₹84,100', margin: '₹6,730', pct: '8.0%' }, + ], + }, + { + path: '/reports/stock-aging', + title: 'Stock Aging', + desc: 'How long stock has been sitting, by bucket.', + actions: ['Export'], + columns: [ + { key: 'bucket', label: 'Age bucket' }, { key: 'items', label: 'Items', numeric: true }, + { key: 'value', label: 'Value', numeric: true }, + ], + rows: [ + { bucket: '0–30 days', items: '9,240', value: '₹18,42,000' }, + { bucket: '31–90 days', items: '2,180', value: '₹4,10,000' }, + { bucket: '> 90 days', items: '412', value: '₹98,000' }, + ], + }, + { + path: '/reports/day-end', + title: 'Day-End Digest', + desc: 'The one-page close of day per store — also lands on WhatsApp at Day End.', + actions: ['Print today', 'Send to WhatsApp'], + columns: [ + { key: 'k', label: '' }, { key: 'v', label: 'Value', numeric: true }, + ], + rows: [ + { k: 'Sales (412 bills)', v: '₹1,84,320' }, + { k: 'Cash / UPI / Card / Khata', v: '₹92,140 / 61,180 / 18,600 / 12,400' }, + { k: 'Cash variance (C1, C2)', v: '+₹10 / −₹0' }, + { k: 'Returns', v: '₹644' }, + { k: 'New khata given', v: '₹12,400' }, + ], + }, + { + path: '/admin/stores', + title: 'Stores & Counters', + desc: 'Stores, counters, per-counter GST series and print profiles, Store Hub role.', + actions: ['New store', 'New counter'], + columns: [ + { key: 'store', label: 'Store' }, { key: 'counter', label: 'Counter' }, + { key: 'series', label: 'Series' }, { key: 'printer', label: 'Printer' }, { key: 'status', label: 'Status' }, + ], + rows: [ + { store: 'ST1 T.Nagar', counter: 'C1', series: 'ST1C1/26-000388', printer: '192.168.1.101', status: ok('online') }, + { store: 'ST1 T.Nagar', counter: 'C2', series: 'ST1C2/26-000412', printer: '192.168.1.100', status: ok('online') }, + { store: 'ST2 Velachery', counter: 'C1', series: 'ST2C1/26-000221', printer: '192.168.2.100', status: warn('sync lag 22 min') }, + ], + }, + { + path: '/admin/templates', + title: 'Print Templates', + desc: 'Receipt/invoice/label layouts with preview — per-tenant customization without code.', + actions: ['New template', 'Preview on counter'], + columns: [ + { key: 'name', label: 'Template' }, { key: 'kind', label: 'Kind' }, + { key: 'paper', label: 'Paper' }, { key: 'usedBy', label: 'Used by' }, + ], + rows: [ + { name: 'Receipt (default)', kind: 'receipt', paper: '3" thermal', usedBy: 'All counters' }, + { name: 'B2B Invoice', kind: 'invoice', paper: 'A4', usedBy: 'Back office' }, + { name: 'MRP Label 38×25', kind: 'label', paper: 'Label roll', usedBy: 'Label queue' }, + ], + }, + { + path: '/admin/integrations', + title: 'Integrations', + desc: 'WhatsApp, UPI, GSP (e-Invoice/e-Way), email-in address for the Purchase Inbox.', + actions: ['Connect new'], + columns: [ + { key: 'name', label: 'Integration' }, { key: 'purpose', label: 'Purpose' }, { key: 'status', label: 'Status' }, + ], + rows: [ + { name: 'WhatsApp Business', purpose: 'e-bills, khata reminders, digests', status: warn('cloud tier (D14)') }, + { name: 'UPI (PSP)', purpose: 'dynamic QR + auto-confirm', status: warn('cloud tier (D14)') }, + { name: 'GSP', purpose: 'e-Invoice IRN, e-Way', status: warn('choose vendor — D4') }, + { name: 'Purchase Inbox email', purpose: 'bills@st1.sims.in', status: warn('Phase 3') }, + ], + }, + { + path: '/admin/subscription', + title: 'Subscription & Plan', + desc: 'Plan, counters, renewal — plans and prices are DB rows (D5), upgrades apply in place.', + actions: ['Change plan', 'Add counter'], + stats: [ + { label: 'Plan', value: 'Pro' }, + { label: 'Counters', value: '3' }, + { label: 'Renews', value: '01 Apr 2027' }, + ], + columns: [ + { key: 'feature', label: 'Feature' }, { key: 'plan', label: 'In your plan' }, + ], + rows: [ + { feature: 'Multi-counter billing', plan: ok('included') }, + { feature: 'Purchase Inbox (AI)', plan: ok('included') }, + { feature: 'Head-office console (multi-store)', plan: warn('Enterprise — tap to see demo') }, + ], + }, + { + path: '/admin/audit', + title: 'Audit Log', + desc: 'Append-only, before/after values, every sensitive action — the MCA-grade trail.', + actions: ['Export', 'Filter'], + filters: ['User', 'Action', 'Date'], + columns: [ + { key: 'at', label: 'When' }, { key: 'who', label: 'Who' }, { key: 'what', label: 'Action' }, + { key: 'detail', label: 'Before → After' }, + ], + rows: [ + { at: '18:12', who: 'Suresh', what: 'PRICE_OVERRIDE (approved Ramesh)', detail: 'Surf Excel 145.00 → 140.00 on ST1C2/26-000402' }, + { at: '17:55', who: 'Thomas', what: 'SETTING_CHANGE', detail: 'discount.capBp store ST1: 1000 → 1500' }, + { at: '17:40', who: 'Ramesh', what: 'BILL_REPRINT', detail: 'ST1C2/26-000396 (copy 1, DUPLICATE)' }, + ], + }, + { + path: '/admin/sync', + title: 'Sync & Devices', + desc: 'Counter devices, outbox depth, last sync — dormant in local-only v1 (D14), observable from day one.', + actions: ['Re-pair device'], + columns: [ + { key: 'device', label: 'Device' }, { key: 'counter', label: 'Counter' }, + { key: 'outbox', label: 'Outbox rows', numeric: true }, { key: 'last', label: 'Last drain' }, { key: 'state', label: 'State' }, + ], + rows: [ + { device: 'POS-7F3A', counter: 'ST1-C1', outbox: '1,204', last: '— (cloud tier off)', state: dim('dormant') }, + { device: 'POS-9B21', counter: 'ST1-C2', outbox: '1,412', last: '— (cloud tier off)', state: dim('dormant') }, + ], + }, + { + path: '/admin/backups', + title: 'Backups', + desc: 'Store DB snapshots — local now, shipped to cloud when the tier lands. Restore drills are scheduled, not hoped.', + actions: ['Backup now', 'Restore drill'], + columns: [ + { key: 'at', label: 'When' }, { key: 'what', label: 'Scope' }, { key: 'size', label: 'Size', numeric: true }, + { key: 'verified', label: 'Verified' }, + ], + rows: [ + { at: 'Today 02:00', what: 'Oracle store DB + counter SQLite', size: '412 MB', verified: ok('checksum ok') }, + { at: 'Yesterday 02:00', what: 'Oracle store DB + counter SQLite', size: '410 MB', verified: ok('checksum ok') }, + ], + }, + { + path: '/admin/import', + title: 'Data Import', + desc: 'Excel/CSV for masters + the SiMS Classic migration tool with its verification report.', + actions: ['Import Excel', 'Run Classic migration'], + columns: [ + { key: 'run', label: 'Run' }, { key: 'kind', label: 'Kind' }, { key: 'rows', label: 'Rows', numeric: true }, + { key: 'result', label: 'Result' }, + ], + rows: [ + { run: 'IMP-0004', kind: 'Items (Excel)', rows: '12,482', result: ok('ok — 3 duplicates skipped') }, + { run: 'IMP-0003', kind: 'Classic migration (trial)', rows: '48,211', result: warn('verification: 2 ledger mismatches') }, + ], + }, +] diff --git a/apps/backoffice/tsconfig.json b/apps/backoffice/tsconfig.json new file mode 100644 index 0000000..bf5a36d --- /dev/null +++ b/apps/backoffice/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"] +} diff --git a/apps/backoffice/vite.config.ts b/apps/backoffice/vite.config.ts new file mode 100644 index 0000000..4fc47c6 --- /dev/null +++ b/apps/backoffice/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'node:url' + +const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url)) + +export default defineConfig({ + base: './', + plugins: [react()], + resolve: { + alias: { + '@sims/domain': p('../../packages/domain/src/index.ts'), + '@sims/ui': p('../../packages/ui/src/index.ts'), + '@sims/scanning': p('../../packages/scanning/src/index.ts'), + '@sims/search-core': p('../../packages/search-core/src/index.ts'), + '@sims/billing-engine': p('../../packages/billing-engine/src/index.ts'), + '@sims/printing': p('../../packages/printing/src/index.ts'), + }, + }, + server: { port: 5180 }, +}) diff --git a/apps/pos/README.md b/apps/pos/README.md new file mode 100644 index 0000000..0e62bd6 --- /dev/null +++ b/apps/pos/README.md @@ -0,0 +1,7 @@ +# apps/pos — POS Counter (Electron + React) + +The counter shell. Lands with the Phase-0 hardware spike (ESC/POS printing, drawer kick, +wedge scanner, scale). It is deliberately thin: billing math lives in +`@sims/billing-engine`, scan handling in `@sims/scanning`, login/PIN/gates in +`@sims/auth`, settings/messages in `@sims/config` — all already built and tested. +Screen specs: docs/09-UX-FLOWS-AND-MENUS.md §1. diff --git a/apps/pos/build-main.mjs b/apps/pos/build-main.mjs new file mode 100644 index 0000000..a852530 --- /dev/null +++ b/apps/pos/build-main.mjs @@ -0,0 +1,16 @@ +import { build } from 'esbuild' + +for (const [entry, out] of [ + ['electron/main.ts', 'dist-electron/main.cjs'], + ['electron/preload.ts', 'dist-electron/preload.cjs'], +]) { + await build({ + entryPoints: [entry], + outfile: out, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + }) +} +console.log('electron main + preload built') diff --git a/apps/pos/dist-electron/main.cjs b/apps/pos/dist-electron/main.cjs new file mode 100644 index 0000000..fd44967 --- /dev/null +++ b/apps/pos/dist-electron/main.cjs @@ -0,0 +1,62 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// electron/main.ts +var import_electron = require("electron"); +var import_node_net = __toESM(require("node:net"), 1); +var import_node_path = __toESM(require("node:path"), 1); +function createWindow() { + const win = new import_electron.BrowserWindow({ + width: 1366, + height: 820, + backgroundColor: "#0f1216", + autoHideMenuBar: true, + webPreferences: { + preload: import_node_path.default.join(__dirname, "preload.cjs"), + contextIsolation: true, + nodeIntegration: false + } + }); + void win.loadFile(import_node_path.default.join(__dirname, "../dist/index.html")); +} +import_electron.ipcMain.handle("printers:list", async (event) => { + const printers = await event.sender.getPrintersAsync(); + return printers.map((p) => ({ name: p.name, displayName: p.displayName })); +}); +import_electron.ipcMain.handle("print:raw9100", (_event, host, port, bytes) => { + return new Promise((resolve, reject) => { + const socket = import_node_net.default.createConnection({ host, port, timeout: 3e3 }); + socket.on("connect", () => { + socket.end(Buffer.from(bytes)); + }); + socket.on("close", () => resolve("sent")); + socket.on("timeout", () => { + socket.destroy(); + reject(new Error(`Printer ${host}:${port} timed out`)); + }); + socket.on("error", (err) => reject(err)); + }); +}); +import_electron.app.whenReady().then(createWindow); +import_electron.app.on("window-all-closed", () => import_electron.app.quit()); diff --git a/apps/pos/dist-electron/preload.cjs b/apps/pos/dist-electron/preload.cjs new file mode 100644 index 0000000..eeff08f --- /dev/null +++ b/apps/pos/dist-electron/preload.cjs @@ -0,0 +1,8 @@ +"use strict"; + +// electron/preload.ts +var import_electron = require("electron"); +import_electron.contextBridge.exposeInMainWorld("pos", { + listPrinters: () => import_electron.ipcRenderer.invoke("printers:list"), + printRaw9100: (host, port, bytes) => import_electron.ipcRenderer.invoke("print:raw9100", host, port, bytes) +}); diff --git a/apps/pos/electron/main.ts b/apps/pos/electron/main.ts new file mode 100644 index 0000000..1414f2d --- /dev/null +++ b/apps/pos/electron/main.ts @@ -0,0 +1,46 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import net from 'node:net' +import path from 'node:path' + +/** + * Phase-0 spike main process. Print transport is network ESC/POS (port 9100) + * — the zero-driver path every thermal printer brand supports. USB-shared + * printers come later via the raw-spool decision (see BUILDING.md). + */ +function createWindow(): void { + const win = new BrowserWindow({ + width: 1366, + height: 820, + backgroundColor: '#0f1216', + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false, + }, + }) + void win.loadFile(path.join(__dirname, '../dist/index.html')) +} + +ipcMain.handle('printers:list', async (event) => { + const printers = await event.sender.getPrintersAsync() + return printers.map((p) => ({ name: p.name, displayName: p.displayName })) +}) + +ipcMain.handle('print:raw9100', (_event, host: string, port: number, bytes: Uint8Array) => { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port, timeout: 3000 }) + socket.on('connect', () => { + socket.end(Buffer.from(bytes)) + }) + socket.on('close', () => resolve('sent')) + socket.on('timeout', () => { + socket.destroy() + reject(new Error(`Printer ${host}:${port} timed out`)) + }) + socket.on('error', (err) => reject(err)) + }) +}) + +app.whenReady().then(createWindow) +app.on('window-all-closed', () => app.quit()) diff --git a/apps/pos/electron/preload.ts b/apps/pos/electron/preload.ts new file mode 100644 index 0000000..0f45d23 --- /dev/null +++ b/apps/pos/electron/preload.ts @@ -0,0 +1,8 @@ +import { contextBridge, ipcRenderer } from 'electron' + +contextBridge.exposeInMainWorld('pos', { + listPrinters: (): Promise<{ name: string; displayName: string }[]> => + ipcRenderer.invoke('printers:list'), + printRaw9100: (host: string, port: number, bytes: Uint8Array): Promise => + ipcRenderer.invoke('print:raw9100', host, port, bytes), +}) diff --git a/apps/pos/index.html b/apps/pos/index.html new file mode 100644 index 0000000..58b0339 --- /dev/null +++ b/apps/pos/index.html @@ -0,0 +1,12 @@ + + + + + + SiMS Next POS + + +
+ + + diff --git a/apps/pos/package.json b/apps/pos/package.json new file mode 100644 index 0000000..822ed6b --- /dev/null +++ b/apps/pos/package.json @@ -0,0 +1,32 @@ +{ + "name": "@sims/pos", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist-electron/main.cjs", + "scripts": { + "build": "vite build && node build-main.mjs", + "start": "npm run build && electron .", + "dev": "vite", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@sims/auth": "*", + "@sims/billing-engine": "*", + "@sims/config": "*", + "@sims/domain": "*", + "@sims/printing": "*", + "@sims/scanning": "*", + "@sims/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "electron": "^43.0.0", + "esbuild": "^0.25.0", + "vite": "^6.0.0" + } +} diff --git a/apps/pos/src/App.tsx b/apps/pos/src/App.tsx new file mode 100644 index 0000000..3a229c8 --- /dev/null +++ b/apps/pos/src/App.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' +import { Button, Notice } from '@sims/ui' +import { uuidv7, type Item } from '@sims/domain' +import { fetchBootstrap, fetchItemsCache, type Bootstrap, type PosUser } from './api' +import { Login } from './Login' +import { ShiftOpen } from './ShiftOpen' +import { BillingScreen } from './BillingScreen' + +/** POS flow: bootstrap from store-server → login (PIN) → shift open → billing. */ +export function App() { + const [boot, setBoot] = useState() + const [bootError, setBootError] = useState() + const [user, setUser] = useState() + const [items, setItems] = useState() + const [shift, setShift] = useState<{ id: string; floatPaise: number } | undefined>() + + const load = () => { + setBootError(undefined) + const counter = new URLSearchParams(location.search).get('counter') ?? 'C2' + fetchBootstrap(counter).then(setBoot).catch((e: Error) => setBootError(e.message)) + } + useEffect(load, []) + + if (bootError !== undefined) { + return ( +
+
+

SiMS Next POS

+ {bootError} — is the store-server running? + +
+
+ ) + } + if (boot === undefined) return
Connecting to store-server…
+ + if (user === undefined || items === undefined) { + return ( + { + setUser(u) + setItems(await fetchItemsCache()) + }} + /> + ) + } + if (shift === undefined) { + return ( + setShift({ id: uuidv7(), floatPaise })} + onBack={() => { + setUser(undefined) + setItems(undefined) + }} + /> + ) + } + return ( + { + setShift(undefined) + setUser(undefined) + setItems(undefined) + }} + /> + ) +} diff --git a/apps/pos/src/BillingScreen.tsx b/apps/pos/src/BillingScreen.tsx new file mode 100644 index 0000000..b59594a --- /dev/null +++ b/apps/pos/src/BillingScreen.tsx @@ -0,0 +1,1017 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Badge, Button, Field, Modal, Notice, ThemeSwitcher } from '@sims/ui' +import { deriveSupply, formatINR, validateGstin, type Item, type PaymentMode, type RoleCode } from '@sims/domain' +// Subpath import: the @sims/auth barrel re-exports pin.ts (node:crypto), which the +// browser bundle can't include. permissions.ts is pure (type-only imports), so the +// role→gate matrix stays the single source of truth without dragging in scrypt. +import { gateFor } from '@sims/auth/permissions' +import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine' +import { parseEntry, parseScaleBarcode, WedgeDetector, type ScaleBarcodeProfile } from '@sims/scanning' +import { buildIndex, querySearch } from '@sims/search-core' +import { renderReceipt, receiptLines, type ReceiptDoc } from '@sims/printing' +import { createCustomer, createDraftItem, fetchBills, fetchStock, findCustomers, postBill, verifySupervisorPin, type BillOverride, type BootstrapStore, type PosCustomer, type PosUser } from './api' +import { beepErr, beepOk, beepWarn } from './audio' +import { loadPrinterCfg, sendRasterReceipt, sendToPrinter } from './print' +import { SettingsModal } from './Settings' + +/** + * Counter limits. Module consts today; per 16-PROJECT-RULES R9 these become DB + * `setting` rows (system→tenant→store→counter→user hierarchy) once the settings + * surface lands — the migration is then a single import swap, not a code change. + */ +const POS_LIMITS = { + PRICE_BAND_PCT: 10, // price deviation beyond ±this % of the item's sale price raises the supervisor gate + DISC_WARN_PCT: 10, // a line discount beyond this % of line value raises the supervisor gate +} as const + +/** A supervisor's approval, resolved by the inline gate panel. */ +interface Approval { approvedByUserId: string; approvedByName: string } + +const SCALE_PROFILE: ScaleBarcodeProfile = { + prefixes: ['21'], + pluLength: 5, + valueKind: 'weight', + valueDivisor: 1000, +} + +const QUICK_KEYS = ['42', '43', '44', '108'] // per-counter DB config later + +interface CommittedBill { docNo: string; payablePaise: number; mode: PaymentMode; at: string } +type NoticeMsg = { tone: 'ok' | 'warn' | 'err'; text: string } +type Overlay = 'none' | 'pay' | 'settings' | 'history' | 'dayend' | 'customer' + +export function BillingScreen(props: { + user: PosUser + users: PosUser[] + store: BootstrapStore + taxClasses: TaxClassRow[] + items: Item[] + shiftId: string + floatPaise: number + onLock: () => void +}) { + const { store, taxClasses } = props + const today = new Date().toISOString().slice(0, 10) + /** Local item cache — starts from the server snapshot, grows with quick-adds. */ + const [cat, setCat] = useState(props.items) + const [lines, setLines] = useState([]) + const [sugg, setSugg] = useState<{ list: Item[]; hi: number } | undefined>() + const [editCell, setEditCell] = useState<{ line: number; field: 'qty' | 'price' | 'discount' } | undefined>() + const [quickAdd, setQuickAdd] = useState<{ code: string; name?: string } | undefined>() + // Supervisor-approved overrides accumulate here and ride with postBill; the server + // audits each in the commit transaction (spec P0-2). Cleared on commit / cart-clear. + const [overrides, setOverrides] = useState([]) + const [supPanel, setSupPanel] = useState<{ desc: string; resolve: (a: Approval | undefined) => void } | undefined>() + const [stockMap, setStockMap] = useState>(new Map()) + const [lastBillLines, setLastBillLines] = useState([]) + const [changeFlash, setChangeFlash] = useState() + const [selected, setSelected] = useState(0) + const [input, setInput] = useState('') + const [pendingQty, setPendingQty] = useState() + const [notice, setNotice] = useState() + const [held, setHeld] = useState<{ token: number; lines: LineInput[] }[]>([]) + const [tokenSeq, setTokenSeq] = useState(1) + const [dayLog, setDayLog] = useState([]) + const [customer, setCustomer] = useState() + const [overlay, setOverlay] = useState('none') + const [busy, setBusy] = useState(false) + const [serverBills, setServerBills] = useState[] | undefined>() + + const inputRef = useRef(null) + const wedge = useRef(new WedgeDetector()) + const overlayRef = useRef(overlay) + overlayRef.current = overlay + const suggRef = useRef(false) + suggRef.current = sugg !== undefined + const inlineEditRef = useRef(false) + inlineEditRef.current = editCell !== undefined || quickAdd !== undefined + // The supervisor panel holds focus (its PIN field) and owns every key but Esc. + const supPanelRef = useRef(undefined) + supPanelRef.current = supPanel + + // B2B at the counter (S3): an attached customer with a GSTIN moves the place of + // supply to their state — the server derives this same decision independently + // and both engine runs must agree to the paisa (R5). + const supply = useMemo(() => deriveSupply(customer, store.stateCode), [customer, store.stateCode]) + + const computed = useMemo(() => { + if (lines.length === 0) return undefined + return computeBill(lines, { + businessDate: today, + supplyStateCode: store.stateCode, + placeOfSupplyStateCode: supply.placeOfSupplyStateCode, + roundToRupee: true, + }, taxClasses) + }, [lines, today, store.stateCode, supply.placeOfSupplyStateCode, taxClasses]) + + const sayTimer = useRef | undefined>(undefined) + /** ok auto-clears in 4s; err/warn persist until Esc or the next action (spec P1-4). */ + const say = (tone: NoticeMsg['tone'], text: string) => { + if (sayTimer.current !== undefined) clearTimeout(sayTimer.current) + setNotice({ tone, text }) + if (tone === 'err') beepErr() + else if (tone === 'warn') beepWarn() + if (tone === 'ok') sayTimer.current = setTimeout(() => setNotice(undefined), 4000) + } + + useEffect(() => { + fetchStock() + .then((rows) => setStockMap(new Map(rows.map((r) => [r.code, Number(r.on_hand)])))) + .catch(() => undefined) + }, []) + + const addItem = (item: Item, qty?: number) => { + const addQty = qty ?? pendingQty ?? 1 + // Whole quantities only for unit-less items; keep the multiplier for a rescan. + if (item.unit.decimals === 0 && !Number.isInteger(addQty)) { + return say('err', `Whole quantities only for ${item.unit.code} — rescan (E-1104)`) + } + setPendingQty(undefined) + beepOk() + const onHand = stockMap.get(item.code) + if (onHand !== undefined && onHand <= 0) { + say('warn', `${item.name} shows out of stock — the shelf is the arbiter, billing anyway`) + } + setLines((prev) => { + if (item.unit.decimals === 0 && qty === undefined) { + const i = prev.findIndex((l) => l.itemId === item.id) + if (i >= 0) { + const copy = [...prev] + copy[i] = { ...copy[i]!, qty: copy[i]!.qty + addQty } + setSelected(i) + return copy + } + } + setSelected(prev.length) + return [...prev, { + itemId: item.id, name: item.name, hsn: item.hsn, qty: addQty, + unitCode: item.unit.code, unitPricePaise: item.salePricePaise, + priceIncludesTax: item.priceIncludesTax, taxClassCode: item.taxClassCode, + ...(item.mrpPaise !== undefined ? { mrpPaise: item.mrpPaise } : {}), + }] + }) + setNotice(undefined) + } + + // Tiered phonetic search (spec P0-1): index rebuilt only when the catalog changes + // (quick-adds grow `cat`), queried synchronously per keystroke — no debounce. + const searchIndex = useMemo(() => buildIndex(cat), [cat]) + const findByName = (text: string): Item[] => querySearch(searchIndex, text) + + const resolveCode = (code: string, qty?: number) => { + const scale = parseScaleBarcode(code, SCALE_PROFILE) + if (scale.kind === 'scale-weight') { + const item = cat.find((i) => i.code === String(Number(scale.plu))) + if (item !== undefined) return addItem(item, scale.qty) + return say('err', `Scale PLU ${scale.plu} not in catalog (E-1102)`) + } + if (scale.kind === 'invalid') return say('err', 'Bad scale barcode — reweigh the item (E-1103)') + const item = cat.find((i) => i.barcodes.includes(code)) ?? cat.find((i) => i.code === code) + if (item !== undefined) return addItem(item, qty) + // Errors never trap (09-UX §1): unknown code opens the inline quick-add. + setQuickAdd({ code }) + } + + const search = (text: string, qty?: number) => { + const first = findByName(text)[0] + if (first === undefined) return say('err', `No item matches "${text}" (E-1102)`) + addItem(first, qty) + } + + /** Live suggestions: recomputed synchronously per keystroke (no debounce). */ + const updateSuggestions = (value: string) => { + const parsed = parseEntry(value) + const text = parsed.kind === 'search' ? parsed.text + : parsed.kind === 'multiplier' && !/^\d*$/.test(parsed.rest) ? parsed.rest + : undefined + if (text !== undefined && text.length >= 2) { + setSugg({ list: findByName(text).slice(0, 8), hi: 0 }) + } else { + setSugg(undefined) + } + } + + const pickSuggestion = () => { + if (sugg === undefined) return + const parsed = parseEntry(input) + const qty = parsed.kind === 'multiplier' ? parsed.qty : undefined + const hit = sugg.list[sugg.hi] + setSugg(undefined) + setInput('') + if (hit !== undefined) return addItem(hit, qty) + // 0 hits → quick-add with the typed name prefilled (errors never trap) + const name = parsed.kind === 'search' ? parsed.text : parsed.kind === 'multiplier' ? parsed.rest : input + setQuickAdd({ code: '', name }) + } + + const handleEnter = () => { + const burst = wedge.current.terminate() + if (burst.type === 'scan') { + setInput('') + setSugg(undefined) + return resolveCode(burst.code) + } + if (sugg !== undefined) return pickSuggestion() + const parsed = parseEntry(input) + setInput('') + switch (parsed.kind) { + case 'empty': return + case 'multiplier': + if (parsed.rest === '') { + setPendingQty(parsed.qty) + return say('ok', `Quantity ${parsed.qty} × — scan or type the item`) + } + return /^\d+$/.test(parsed.rest) ? resolveCode(parsed.rest, parsed.qty) : search(parsed.rest, parsed.qty) + case 'barcode': + case 'code': return resolveCode(parsed.code) + case 'search': return search(parsed.text) + } + } + + // The logged-in cashier's authority — owner/manager/supervisor self-authorise + // price/discount overrides ('allow'); a cashier must raise the PIN gate. + const userGate = (action: 'PRICE_OVERRIDE' | 'DISCOUNT_OVER_CAP') => + gateFor({ roles: [props.user.role as RoleCode], active: true }, action) + + /** + * Reusable in-flow supervisor gate (spec P0-2): shows the inline panel (never a + * modal), resolves with the approver on success or undefined on cancel / 3 fails. + * F4 discounts (P1-3) and future Del/void reuse this exact promise. + */ + const supervisorGate = (desc: string): Promise => + new Promise((resolve) => setSupPanel({ desc, resolve })) + + const applyPrice = (i: number, paise: number, approval?: Approval, before?: number) => { + setLines((prev) => prev.map((x, j) => (j === i ? { ...x, unitPricePaise: paise } : x))) + if (approval !== undefined && before !== undefined) { + const itemId = lines[i]?.itemId + if (itemId !== undefined) { + setOverrides((o) => [...o, { itemId, kind: 'price', before, after: paise, approvedByUserId: approval.approvedByUserId }]) + } + } + inputRef.current?.focus() + } + + // F3 price override (spec P0-2). price>MRP is a hard block; a deviation beyond the + // ±band — or ANY change by a cashier — raises the supervisor gate before it applies. + const commitPriceEdit = async (i: number, v: string) => { + setEditCell(undefined) + const line = lines[i] + const paise = Math.round(Number(v) * 100) + if (line === undefined || !Number.isFinite(paise) || paise < 0) { inputRef.current?.focus(); return } + const mrp = line.mrpPaise + if (mrp !== undefined && paise > mrp) { + say('err', `Price above MRP ${formatINR(mrp)} is not allowed (E-1301)`) + inputRef.current?.focus() + return + } + if (paise === line.unitPricePaise) { inputRef.current?.focus(); return } // no-op + const ref = cat.find((c) => c.id === line.itemId)?.salePricePaise ?? line.unitPricePaise + const beyondBand = Math.abs(paise - ref) > ref * POS_LIMITS.PRICE_BAND_PCT / 100 + if (userGate('PRICE_OVERRIDE') === 'allow') { applyPrice(i, paise); return } // supervisor+ self-authorises + const approval = await supervisorGate( + `${line.name}: rate ${formatINR(line.unitPricePaise)} → ${formatINR(paise)}${beyondBand ? ` (beyond ±${POS_LIMITS.PRICE_BAND_PCT}% band)` : ''}`, + ) + if (approval === undefined) { say('warn', 'Price override cancelled — no supervisor approval'); inputRef.current?.focus(); return } + applyPrice(i, paise, approval, line.unitPricePaise) + say('ok', `Rate override approved by ${approval.approvedByName}`) + } + + // F4 line discount (spec P1-3). A discount beyond DISC_WARN_PCT of the line value + // raises the gate for a cashier; owner/manager/supervisor apply it directly. + const commitDiscountEdit = async (i: number, v: string) => { + setEditCell(undefined) + const line = lines[i] + if (line === undefined) { inputRef.current?.focus(); return } + // Grammar per 09-UX §1.3: `5` = ₹5 off, `5%` = 5 percent; 0/empty clears it. + const m = /^(\d+(?:\.\d{1,2})?)(%?)$/.exec(v.trim()) + if (m === null || Number(m[1]) === 0) { + setLines((prev) => prev.map((x, j) => { if (j !== i) return x; const { discount: _drop, ...rest } = x; return rest })) + inputRef.current?.focus() + return + } + const isPct = m[2] === '%' + const discount: LineInput['discount'] = isPct + ? { kind: 'percentBp', value: Math.round(Number(m[1]) * 100) } + : { kind: 'amount', paise: Math.round(Number(m[1]) * 100) } + const lineValue = line.qty * line.unitPricePaise + const discPaise = isPct ? Math.round(lineValue * Number(m[1]) / 100) : Math.round(Number(m[1]) * 100) + const overCap = discPaise > lineValue * POS_LIMITS.DISC_WARN_PCT / 100 + const apply = (approval?: Approval) => { + setLines((prev) => prev.map((x, j) => (j === i ? { ...x, discount } : x))) + if (approval !== undefined) { + setOverrides((o) => [...o, { itemId: line.itemId, kind: 'discount', before: 0, after: discPaise, approvedByUserId: approval.approvedByUserId }]) + } + inputRef.current?.focus() + } + if (!overCap || userGate('DISCOUNT_OVER_CAP') === 'allow') { apply(); return } + const approval = await supervisorGate(`${line.name}: discount ${formatINR(discPaise)} (> ${POS_LIMITS.DISC_WARN_PCT}% of line)`) + if (approval === undefined) { say('warn', 'Discount cancelled — no supervisor approval'); inputRef.current?.focus(); return } + apply(approval) + say('ok', `Discount approved by ${approval.approvedByName}`) + } + + const commit = (mode: PaymentMode, tenderedPaise?: number) => { + if (busy) return + if (computed === undefined) return say('warn', 'Nothing to bill') + setBusy(true) + const totals = computed.totals + const committedLines = lines + postBill({ + storeId: store.id, counterId: store.counterId, shiftId: props.shiftId, + ...(customer !== undefined ? { customerId: customer.id } : {}), + businessDate: today, lines, + payments: [{ mode, amountPaise: totals.payablePaise }], + clientPayablePaise: totals.payablePaise, + ...(overrides.length > 0 ? { overrides } : {}), // audited server-side in the commit txn (spec P0-2) + }) + .then((res) => { + // The bill is committed server-side; printing is a consequence (09-UX §5.4). + setDayLog((d) => [...d, { docNo: res.docNo, payablePaise: totals.payablePaise, mode, at: new Date().toLocaleTimeString() }]) + setLastBillLines(committedLines) // Ctrl+B repeat (spec P1-1) + // decrement local stock view optimistically (spec P1-5) + setStockMap((m) => { + const next = new Map(m) + for (const l of committedLines) { + const item = cat.find((i) => i.id === l.itemId) + if (item !== undefined && next.has(item.code)) next.set(item.code, next.get(item.code)! - l.qty) + } + return next + }) + if (tenderedPaise !== undefined && tenderedPaise > totals.payablePaise) { + setChangeFlash(tenderedPaise - totals.payablePaise) + setTimeout(() => setChangeFlash(undefined), 4000) + } + setLines([]) + setPendingQty(undefined) + setCustomer(undefined) + setOverrides([]) // override records belong to the committed cart only + setOverlay('none') + const cfg = loadPrinterCfg() + const kick = mode === 'CASH' + const receiptDoc: ReceiptDoc = { + storeName: store.name, gstin: store.gstin, docNo: res.docNo, businessDate: today, + counterCode: store.counterCode, cashierName: props.user.name, + ...(customer !== undefined ? { buyerName: customer.name } : {}), + ...(supply.buyerGstin !== undefined ? { buyerGstin: supply.buyerGstin } : {}), + lines: computed.lines, totals, paymentLabel: mode, + } + // Indic receipts rasterize (09-UX §5.3); ASCII stays the fast text path. + // Same content model (receiptLines) so item names match on either path. + const send = cfg.script === 'raster' + ? sendRasterReceipt(cfg, receiptLines(receiptDoc, { width: cfg.width, kickDrawer: kick }), kick) + : sendToPrinter(cfg, renderReceipt(receiptDoc, { width: cfg.width, kickDrawer: kick })) + return send + .then(() => say('ok', `Bill ${res.docNo} · ${formatINR(totals.payablePaise)} — printed`)) + .catch((err: unknown) => + say('warn', `Bill ${res.docNo} saved — print: ${err instanceof Error ? err.message : String(err)} (E-2101)`)) + }) + .catch((err: unknown) => { + // Commit failed: the cart stays; nothing is lost. + say('err', `${err instanceof Error ? err.message : String(err)} — bill NOT saved, cart kept`) + }) + .finally(() => { + setBusy(false) + inputRef.current?.focus() + }) + } + + const hold = () => { + if (lines.length === 0) return + setHeld((h) => [...h, { token: tokenSeq, lines }]) + setTokenSeq((t) => t + 1) + setLines([]) + setOverrides([]) // approvals belong to the cart on screen; a held cart re-approves on resume + say('ok', `Held as token ${tokenSeq}`) + } + + const resume = () => { + if (lines.length > 0) return say('warn', 'Hold the current bill first (F7)') + const last = held[held.length - 1] + if (last === undefined) return say('warn', 'No held bills') + setHeld((h) => h.slice(0, -1)) + setLines(last.lines) + say('ok', `Resumed token ${last.token}`) + } + + const openHistory = () => { + setOverlay('history') + setServerBills(undefined) + fetchBills(today, store.counterId) + .then(setServerBills) + .catch((err: Error) => say('err', err.message)) + } + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (supPanelRef.current !== undefined) { + supPanelRef.current.resolve(undefined) // cancel the pending gate; the edit is dropped + setSupPanel(undefined) + inputRef.current?.focus() + return + } + setOverlay('none') + setNotice(undefined) + setInput('') + setSugg(undefined) + setEditCell(undefined) + setQuickAdd(undefined) + setPendingQty(undefined) // stale 3* must never multiply an unrelated scan + inputRef.current?.focus() + return + } + // The supervisor panel owns every key but Esc — its PIN field has focus and + // F12/commit must never fire mid-approval. + if (supPanelRef.current !== undefined) return + if (overlayRef.current === 'settings' || overlayRef.current === 'customer') return + // Suggestion list owns arrows+Enter; inline editors own everything. + if (suggRef.current && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter')) return + if (inlineEditRef.current && e.key !== 'F12') return + // Cash close, with tender-in-the-box (spec P0-4): ≤6 digits ≥ payable = tendered. + if (e.code === 'NumpadAdd' || e.key === 'F12') { + e.preventDefault() + const raw = inputRef.current?.value ?? '' + if (/^\d{1,6}$/.test(raw)) { + const tendered = Number(raw) * 100 + const payable = computed?.totals.payablePaise ?? 0 + if (tendered >= payable && payable > 0) { + setInput('') + setSugg(undefined) + commit('CASH', tendered) + } else { + say('warn', 'Tendered is less than payable — finish the line (Enter) or clear (Esc)') + } + } else if (raw !== '') { + say('warn', 'Finish the line first (Enter) or clear (Esc)') + } else { + commit('CASH') + } + return + } + // Repeat last bill (spec P1-1): the daily milk-and-bread bill in one key. + if (e.ctrlKey && e.key.toLowerCase() === 'b') { + e.preventDefault() + if (lines.length > 0) return say('warn', 'Hold or close the current bill first') + if (lastBillLines.length === 0) return say('warn', 'No previous bill this shift') + let missing = 0 + for (const l of lastBillLines) { + const item = cat.find((i) => i.id === l.itemId && i.status !== 'inactive') + if (item !== undefined) addItem(item, l.qty) // current price/tax apply — never resurrect old prices + else missing++ + } + say(missing > 0 ? 'warn' : 'ok', `Repeated last bill${missing > 0 ? ` — ${missing} item(s) no longer available` : ''}`) + return + } + switch (e.key) { + case 'F2': + e.preventDefault() + if (lines.length > 0) setEditCell({ line: selected, field: 'qty' }) + break + case 'F3': + e.preventDefault() + if (lines.length > 0) setEditCell({ line: selected, field: 'price' }) + break + case 'F4': + e.preventDefault() + if (lines.length > 0) setEditCell({ line: selected, field: 'discount' }) + break + case 'F6': e.preventDefault(); setOverlay('customer'); break + case 'F7': e.preventDefault(); hold(); break + case 'F8': e.preventDefault(); resume(); break + case 'F10': e.preventDefault(); openHistory(); break + case 'F11': e.preventDefault(); setOverlay('pay'); break + case 'Delete': + setLines((prev) => prev.filter((_, i) => i !== selected)) + setSelected((s) => Math.max(0, s - 1)) + break + case 'ArrowUp': e.preventDefault(); setSelected((s) => Math.max(0, s - 1)); break + case 'ArrowDown': e.preventDefault(); setSelected((s) => Math.min(lines.length - 1, s + 1)); break + case ',': if (e.ctrlKey) { e.preventDefault(); setOverlay('settings') } break + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }) + + const totals = computed?.totals + const cfg = loadPrinterCfg() + + return ( +
+
+ {store.name} + Counter {store.counterCode} + {props.user.name} ({props.user.role}) + Day {today} + + + + +
+ +
+
+ { + setInput(e.target.value) + updateSuggestions(e.target.value) + }} + onKeyDown={(e) => { + if (sugg !== undefined && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + e.preventDefault() + const n = sugg.list.length + if (n > 0) { + setSugg({ ...sugg, hi: (sugg.hi + (e.key === 'ArrowDown' ? 1 : n - 1)) % n }) + } + return + } + // Instant multiplier (spec P1-2): `3` `*` scan = 2 keys, chip visible before the scan. + if (e.key === '*' && /^\d{1,4}(\.\d{1,3})?$/.test(input)) { + e.preventDefault() + setPendingQty(Number(input)) + setInput('') + setSugg(undefined) + return + } + if (e.key === 'Enter') { e.preventDefault(); handleEnter() } + else if (e.key.length === 1) wedge.current.feed(e.key, e.timeStamp) + }} + onBlur={() => { + if (overlayRef.current === 'none' && !inlineEditRef.current && supPanelRef.current === undefined) { + setTimeout(() => inputRef.current?.focus(), 0) + } + }} + /> + {sugg !== undefined && ( +
+ {sugg.list.length === 0 ? ( +
+ No item matches (E-1102) · Enter quick-add · Esc clear +
+ ) : ( + sugg.list.map((it, i) => ( +
{ setSugg({ ...sugg, hi: i }); pickSuggestion() }} + > + {it.name} + {it.code} + {(() => { + const oh = stockMap.get(it.code) + return oh === undefined ? + : oh <= 0 ? out + : oh < 15 ? {Math.round(oh)} left + : {Math.round(oh)} + })()} + + {formatINR(it.salePricePaise, { symbol: false })}{it.unit.code === 'KG' ? '/kg' : ''} + +
+ )) + )} +
+ )} + {quickAdd !== undefined && ( + t.classCode)} + onDone={(item) => { + setCat((c) => [...c, item]) + setQuickAdd(undefined) + addItem(item) + say('ok', `${item.name} added as draft — complete it in Back Office → Items`) + }} + onCancel={() => { setQuickAdd(undefined); inputRef.current?.focus() }} + /> + )} + {pendingQty !== undefined && ( +
+ 50 ? 'warn' : 'accent'}>{pendingQty} × +
+ )} + {supPanel !== undefined ? ( + u.role !== 'cashier')} + onApprove={(a) => { supPanel.resolve(a); setSupPanel(undefined) }} + onCancel={() => { supPanel.resolve(undefined); setSupPanel(undefined) }} + /> + ) : notice !== undefined ? ( + {notice.text} + ) : null} + +
+ {lines.length === 0 ? ( +
Scan the first item to start a bill
+ Try: 8901063014357 · tomato · 3*104 · scale code 2100042012509 +
+ ) : ( + + + + + {(lines.some((l) => l.discount !== undefined) || editCell?.field === 'discount') && } + + + + + {computed?.lines.map((l, i) => ( + setSelected(i)}> + + + + + {(lines.some((x) => x.discount !== undefined) || editCell?.field === 'discount') && ( + + )} + + + ))} + +
#ItemQtyRateDiscAmount
{i + 1}{l.name} + {editCell?.line === i && editCell.field === 'qty' ? ( + { + const q = Number(v) + if (q > 0) setLines((prev) => prev.map((x, j) => (j === i ? { ...x, qty: q } : x))) + else if (q === 0) setLines((prev) => prev.filter((_, j) => j !== i)) + setEditCell(undefined) + inputRef.current?.focus() + }} + /> + ) : ( + <>{l.qty} {l.unitCode} + )} + + {editCell?.line === i && editCell.field === 'price' ? ( + { void commitPriceEdit(i, v) }} + /> + ) : ( + formatINR(l.unitPricePaise, { symbol: false }) + )} + + {editCell?.line === i && editCell.field === 'discount' ? ( + { void commitDiscountEdit(i, v) }} + /> + ) : l.discountPaise > 0 ? ( + '-' + formatINR(l.discountPaise, { symbol: false }) + ) : ( + '—' + )} + {formatINR(l.lineTotalPaise, { symbol: false })}
+ )} +
+ +
+ {QUICK_KEYS.map((code, i) => { + const it = cat.find((c) => c.code === code) + return it === undefined ? null : ( + + ) + })} +
+
+ + +
+ +
+ + {cfg.enabled ? `Printer ${cfg.host}` : 'Printer off'} + {window.pos !== undefined ? ' · kiosk shell' : ' · web'} + Bills today: {dayLog.length} + Last: {dayLog[dayLog.length - 1]?.docNo ?? '—'} + Held: {held.length} + Float: {formatINR(props.floatPaise)} + + DB: store-server (SQLite slice, D12 Oracle adapter next) + v0.2 +
+ + {overlay === 'pay' && ( + setOverlay('none')}> +
+ {(['CASH', 'UPI', 'CARD', 'KHATA'] as PaymentMode[]).map((m) => ( + + ))} +
+ Split payments and UPI dynamic QR come with the payments integration (01-SCOPE §2). +
+ )} + + {overlay === 'customer' && ( + { setOverlay('none'); inputRef.current?.focus() }} + onAttach={(c) => { + setCustomer(c) + setOverlay('none') + say('ok', c.gstin !== undefined ? `B2B customer: ${c.name} · ${c.gstin}` : `Customer: ${c.name}`) + }} + /> + )} + + {overlay === 'settings' && setOverlay('none')} />} + + {overlay === 'history' && ( + setOverlay('none')}> + {serverBills === undefined ? Loading… : serverBills.length === 0 ? ( + No bills yet today on this counter. + ) : ( + + + + {serverBills.map((b) => ( + + + + + + + ))} + +
BillCashierCustomerAmount
{String(b['doc_no'])}{String(b['cashier_name'] ?? '')}{b['customer_name'] !== null ? String(b['customer_name']) : 'walk-in'}{formatINR(Number(b['payable_paise']))}
+ )} +
+ )} + + {overlay === 'dayend' && ( + setOverlay('none')}> + {(['CASH', 'UPI', 'CARD', 'KHATA'] as PaymentMode[]).map((m) => ( +
+
{m} ({dayLog.filter((b) => b.mode === m).length} bills) + {formatINR(dayLog.filter((b) => b.mode === m).reduce((a, b) => a + b.payablePaise, 0))}
+
+ ))} +
+ This shift + + {formatINR(dayLog.reduce((a, b) => a + b.payablePaise, 0))} + +
+ Full day-end (all counters, variance vs float) comes with shift-close on the server. +
+ )} +
+ ) +} + +/** Inline cell editor for F2/F3 — never a modal; Enter commits, Esc handled globally. */ +function EditCell(props: { initial: string; onCommit: (value: string) => void }) { + return ( + e.target.select()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + props.onCommit((e.target as HTMLInputElement).value) + } + }} + /> + ) +} + +/** + * Inline supervisor gate (spec P0-2) — never a modal, replaces the notice area. + * Pick an approver (bootstrap users with cashiers excluded), enter their 4–6 digit + * PIN, verify via /auth/verify-pin (no session created, cashier stays logged in). + * Three wrong PINs closes the panel and cancels the edit. + */ +function SupervisorPanel(props: { + desc: string + approvers: PosUser[] + onApprove: (a: Approval) => void + onCancel: () => void +}) { + const [idx, setIdx] = useState(0) + const [pin, setPin] = useState('') + const [fails, setFails] = useState(0) + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + const pinRef = useRef(null) + + const submit = () => { + const who = props.approvers[idx] + if (who === undefined || pin.length < 4 || busy) return + setBusy(true) + setError(undefined) + verifySupervisorPin(who.id, pin) + .then((r) => props.onApprove({ approvedByUserId: r.userId, approvedByName: r.displayName })) + .catch((e: Error) => { + const n = fails + 1 + setFails(n) + setPin('') + setBusy(false) + if (n >= 3) return props.onCancel() // 3 failures closes + cancels the edit + setError(`${e.message} — attempt ${n} of 3`) + pinRef.current?.focus() + }) + } + + return ( +
+ Supervisor approval: + {props.desc} + {props.approvers.length === 0 ? ( + No supervisor available — cancel (Esc) + ) : ( + <> + + setPin(e.target.value.replace(/\D/g, ''))} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit() } }} + /> + + + )} + + {error !== undefined && {error}} +
+ ) +} + +/** Unknown-code quick-add: 3 fields, item is billable immediately, draft-flagged for back office. */ +function QuickAddForm(props: { + seed: { code: string; name?: string } + taxClasses: string[] + onDone: (item: Item) => void + onCancel: () => void +}) { + const [name, setName] = useState(props.seed.name ?? '') + const [priceRs, setPriceRs] = useState('') + const [taxClass, setTaxClass] = useState('GST18') + const [error, setError] = useState() + const priceRef = useRef(null) + + const save = () => { + const paise = Math.round(Number(priceRs) * 100) + if (name.trim() === '' || !(paise > 0)) return setError('Name and price are required') + createDraftItem({ + code: props.seed.code !== '' ? props.seed.code : `D-${Date.now() % 100000}`, + name: name.trim(), hsn: '', taxClassCode: taxClass, + unitCode: 'PCS', unitDecimals: 0, salePricePaise: paise, + ...(props.seed.code.length >= 8 ? { barcode: props.seed.code } : {}), + status: 'draft', + }).then(props.onDone).catch((e: Error) => setError(e.message)) + } + + return ( +
+ Quick-add{props.seed.code !== '' ? ` ${props.seed.code}` : ''}: + setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); priceRef.current?.focus() } }} + /> + setPriceRs(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); save() } }} + /> + + + + {error !== undefined && {error}} +
+ ) +} + +function CustomerModal(props: { onClose: () => void; onAttach: (c: PosCustomer) => void }) { + const [phone, setPhone] = useState('') + const [name, setName] = useState('') + const [gstin, setGstin] = useState('') + const [results, setResults] = useState([]) + const [error, setError] = useState() + + const lookup = (p: string) => { + setPhone(p) + setError(undefined) + if (p.length >= 4) { + findCustomers(p).then(setResults).catch((e: Error) => setError(e.message)) + } else { + setResults([]) + } + } + + // Optional B2B identity — validated inline so the cashier sees checksum/state + // feedback before the server rejects a typo (the server re-validates anyway). + const gstinTrimmed = gstin.trim().toUpperCase() + const gstinCheck = gstinTrimmed === '' ? undefined : validateGstin(gstinTrimmed) + const gstinBad = gstinCheck !== undefined && !gstinCheck.ok + + const create = () => { + if (gstinBad) return setError('Fix the GSTIN or clear it before creating the customer') + createCustomer(name === '' ? `Customer ${phone.slice(-4)}` : name, phone, gstinTrimmed === '' ? undefined : gstinTrimmed) + .then(props.onAttach) + .catch((e: Error) => setError(e.message)) + } + + return ( + + + lookup(e.target.value.replace(/\D/g, ''))} placeholder="98400…" /> + + {results.length > 0 && ( + + + {results.map((c) => ( + props.onAttach(c)}> + + + + + ))} + +
{c.name}{c.gstin !== undefined ? {c.gstin} : ''}{c.phone}{c.gstin !== undefined ? B2B : attach}
+ )} + {phone.length >= 10 && results.length === 0 && ( + <> + + setName(e.target.value)} /> + + + setGstin(e.target.value.toUpperCase())} + onKeyDown={(e) => { if (e.key === 'Enter' && !gstinBad) { e.preventDefault(); create() } }} + /> + + {gstinCheck !== undefined && ( + gstinCheck.ok + ? B2B · place of supply state {gstinCheck.stateCode} — GSTIN valid + : GSTIN {gstinCheck.reason === 'checksum' ? 'checksum is wrong' : 'format is wrong (15 chars)'} — fix or leave blank for a B2C bill + )} + + + )} + {error !== undefined && {error}} +
+ ) +} diff --git a/apps/pos/src/Login.tsx b/apps/pos/src/Login.tsx new file mode 100644 index 0000000..d29b5ae --- /dev/null +++ b/apps/pos/src/Login.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { Notice } from '@sims/ui' +import { pinLogin, type BootstrapStore, type PosUser } from './api' + +/** + * POS login (09-UX §1): name tiles + PIN pad. Verification is server-side — + * scrypt hash + lockout reducer in the store-server. Submit on ✓ or Enter. + */ +export function Login(props: { store: BootstrapStore; users: PosUser[]; onLogin: (u: PosUser) => Promise }) { + const [selected, setSelected] = useState(props.users[0]!) + const [pin, setPin] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + + const submit = async () => { + if (pin.length < 4 || busy) return + setBusy(true) + setError(undefined) + try { + await pinLogin(selected.id, pin) + await props.onLogin(selected) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setPin('') + setBusy(false) + } + } + + const press = (d: string) => { + setError(undefined) + if (d === '⌫') return setPin((p) => p.slice(0, -1)) + if (d === '✓') return void submit() + if (pin.length < 6) setPin((p) => p + d) + } + + return ( +
{ + if (/^\d$/.test(e.key)) press(e.key) + if (e.key === 'Backspace') press('⌫') + if (e.key === 'Enter') void submit() + }} + > +
+

{props.store.name}

+
+ Counter {props.store.counterCode} · SiMS Next POS +
+
+ {props.users.map((u) => ( + + ))} +
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+
+ {['1', '2', '3', '4', '5', '6', '7', '8', '9', '⌫', '0', '✓'].map((d) => ( + + ))} +
+ {error !== undefined && {error}} + {busy && Signing in…} +
+
+ ) +} diff --git a/apps/pos/src/Settings.tsx b/apps/pos/src/Settings.tsx new file mode 100644 index 0000000..526e3f0 --- /dev/null +++ b/apps/pos/src/Settings.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { Button, Field, Modal, Notice } from '@sims/ui' +import { EscPos } from '@sims/printing' +import { loadPrinterCfg, savePrinterCfg, sendRasterReceipt, sendToPrinter, type PrinterCfg } from './print' + +/** POS settings (Ctrl+,) — printer transport, test print, drawer kick. */ +export function SettingsModal(props: { onClose: () => void }) { + const [cfg, setCfg] = useState(loadPrinterCfg()) + const [status, setStatus] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>() + + const save = (next: PrinterCfg) => { + setCfg(next) + savePrinterCfg(next) + } + + const attempt = async (bytes: Uint8Array, okText: string) => { + try { + await sendToPrinter({ ...cfg, enabled: true }, bytes) + setStatus({ tone: 'ok', text: okText }) + } catch (err) { + setStatus({ tone: 'err', text: err instanceof Error ? err.message : String(err) }) + } + } + + const testPrint = async () => { + try { + if (cfg.script === 'raster') { + await sendRasterReceipt( + { ...cfg, enabled: true }, + ['SiMS NEXT TEST PRINT', 'रास्टर / റാസ്റ്റർ / Raster', new Date().toLocaleString()], + false, + ) + } else { + await sendToPrinter( + { ...cfg, enabled: true }, + new EscPos().init().align('center').bold(true).line('SiMS NEXT TEST PRINT').bold(false) + .line(new Date().toLocaleString()).feed(4).cut().build(), + ) + } + setStatus({ tone: 'ok', text: `Test page sent (${cfg.script})` }) + } catch (err) { + setStatus({ tone: 'err', text: err instanceof Error ? err.message : String(err) }) + } + } + + return ( + + + save({ ...cfg, host: e.target.value })} /> + + + save({ ...cfg, port: Number(e.target.value) })} + /> + + + + + + + + + + +
+ + + +
+ {status !== undefined && {status.text}} + + Printing route: {window.pos !== undefined ? 'Electron kiosk bridge (direct)' : 'store-server /api/print (web app)'}. + USB printers: share via a print server or use the printer's network port — + driver-spooled USB raw printing is a Phase-1 decision (see BUILDING.md). + +
+ ) +} diff --git a/apps/pos/src/ShiftOpen.tsx b/apps/pos/src/ShiftOpen.tsx new file mode 100644 index 0000000..03d1f18 --- /dev/null +++ b/apps/pos/src/ShiftOpen.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import { Button, Field } from '@sims/ui' +import { formatINR } from '@sims/domain' + +const DENOMS = [50000, 20000, 10000, 5000, 2000, 1000, 500, 200, 100] // paise per note/coin + +/** Shift open with denomination count (09-UX §1.5) — the OG Day-Begin ritual's counter-level sibling. */ +export function ShiftOpen(props: { userName: string; onOpen: (floatPaise: number) => void; onBack: () => void }) { + const [counts, setCounts] = useState>({}) + const total = DENOMS.reduce((a, d) => a + d * (counts[d] ?? 0), 0) + + return ( +
+
+

Open shift — {props.userName}

+
+ {DENOMS.map((d) => ( + + setCounts((c) => ({ ...c, [d]: Number(e.target.value) }))} + /> + + ))} +
+
+ Opening float + {formatINR(total)} +
+
+ + + +
+
+
+ ) +} diff --git a/apps/pos/src/api.ts b/apps/pos/src/api.ts new file mode 100644 index 0000000..788e520 --- /dev/null +++ b/apps/pos/src/api.ts @@ -0,0 +1,88 @@ +import type { Item } from '@sims/domain' +import type { LineInput, TaxClassRow } from '@sims/billing-engine' + +/** POS ↔ store-server client. Same origin in production (served under /pos/). */ + +export interface BootstrapStore { + id: string; name: string; storeCode: string; stateCode: string + gstin: string; counterId: string; counterCode: string +} +export interface PosUser { id: string; name: string; role: string } +export interface Bootstrap { store: BootstrapStore; users: PosUser[]; taxClasses: TaxClassRow[] } + +let token = '' + +async function call(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api${path}`, { + ...init, + headers: { + 'content-type': 'application/json', + ...(token !== '' ? { authorization: `Bearer ${token}` } : {}), + }, + }).catch(() => undefined) + if (res === undefined) throw new Error('Store-server unreachable (E-3301)') + const body = (await res.json().catch(() => ({}))) as T & { error?: string } + if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`) + return body +} + +export const fetchBootstrap = (counter: string): Promise => + call(`/bootstrap?counter=${encodeURIComponent(counter)}`) + +export async function pinLogin(userId: string, pin: string): Promise { + const s = await call<{ token: string }>('/auth/pin', { + method: 'POST', + body: JSON.stringify({ userId, pin }), + }) + token = s.token +} + +/** + * In-flow supervisor gate (spec P0-2): verify a supervisor's PIN without touching + * the module-level `token` — the cashier stays logged in and no session is created. + * Distinct from `pinLogin` on purpose; reusing that would hijack the cashier's session. + */ +export const verifySupervisorPin = (userId: string, pin: string): Promise<{ ok: true; userId: string; displayName: string }> => + call('/auth/verify-pin', { method: 'POST', body: JSON.stringify({ userId, pin }) }) + +// The counter caches the whole sellable catalog (no silent cap — R13); ?all=1 +// returns every non-inactive item unpaginated. +export const fetchItemsCache = (): Promise => call('/items?all=1') + +export const fetchStock = (): Promise<{ code: string; on_hand: number }[]> => call('/stock') + +export interface CommitResult { id: string; docNo: string; payablePaise: number } + +/** Supervisor-approved override, audited server-side in the commit transaction (spec P0-2). */ +export interface BillOverride { + itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string +} + +export const postBill = (body: { + storeId: string; counterId: string; shiftId: string; customerId?: string + businessDate: string; lines: LineInput[] + payments: { mode: string; amountPaise: number }[] + clientPayablePaise: number + overrides?: BillOverride[] +}): Promise => call('/bills', { method: 'POST', body: JSON.stringify(body) }) + +export const fetchBills = (date: string, counterId: string): Promise[]> => + call(`/bills?date=${date}&counterId=${encodeURIComponent(counterId)}`) + +export const createDraftItem = (body: { + code: string; name: string; hsn: string; taxClassCode: string + unitCode: string; unitDecimals: number; salePricePaise: number + barcode?: string; status: string +}): Promise => call('/items', { method: 'POST', body: JSON.stringify(body) }) + +/** Attached-customer identity the counter needs: name plus B2B place-of-supply inputs. */ +export interface PosCustomer { id: string; name: string; phone?: string; gstin?: string; stateCode?: string } + +export const findCustomers = (phone: string): Promise => + call(`/parties?phone=${encodeURIComponent(phone)}`) + +export const createCustomer = (name: string, phone: string, gstin?: string): Promise => + call('/parties', { + method: 'POST', + body: JSON.stringify({ name, phone, ...(gstin !== undefined && gstin !== '' ? { gstin } : {}) }), + }) diff --git a/apps/pos/src/audio.ts b/apps/pos/src/audio.ts new file mode 100644 index 0000000..db5a5f7 --- /dev/null +++ b/apps/pos/src/audio.ts @@ -0,0 +1,28 @@ +/** + * Audio feedback (09-UX §5.6): cashiers scan without looking — sound is the + * primary channel. WebAudio oscillators, zero assets. Counter-mute later. + */ +let ctx: AudioContext | undefined + +function tone(freqHz: number, ms: number, delayMs = 0): void { + try { + ctx ??= new AudioContext() + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.frequency.value = freqHz + gain.gain.value = 0.08 + osc.connect(gain).connect(ctx.destination) + const at = ctx.currentTime + delayMs / 1000 + osc.start(at) + osc.stop(at + ms / 1000) + } catch { + /* no audio device — silent is fine */ + } +} + +export const beepOk = (): void => tone(880, 60) +export const beepWarn = (): void => tone(440, 100) +export const beepErr = (): void => { + tone(220, 120) + tone(220, 120, 180) +} diff --git a/apps/pos/src/main.tsx b/apps/pos/src/main.tsx new file mode 100644 index 0000000..8668edb --- /dev/null +++ b/apps/pos/src/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from 'react-dom/client' +import '@fontsource-variable/inter' +import '@fontsource-variable/jetbrains-mono' +import '../../../packages/ui/src/tokens.css' +import './pos.css' +import { initTheme } from '@sims/ui' +import { App } from './App' + +initTheme() +createRoot(document.getElementById('root')!).render() diff --git a/apps/pos/src/pos.css b/apps/pos/src/pos.css new file mode 100644 index 0000000..64edf3e --- /dev/null +++ b/apps/pos/src/pos.css @@ -0,0 +1,74 @@ +/* POS-specific layout on top of @sims/ui tokens — dense, keyboard-first. */ +.pos-shell { display: flex; flex-direction: column; height: 100vh; } +.pos-header { + display: flex; gap: 16px; align-items: center; padding: 6px 14px; + background: var(--bg-raised); border-bottom: 1px solid var(--border); + font-size: 13px; color: var(--text-dim); +} +.pos-header .store { color: var(--text); font-weight: 600; } +.pos-main { flex: 1; display: grid; grid-template-columns: 1fr 380px; min-height: 0; } +.pos-left { display: flex; flex-direction: column; padding: 12px; min-width: 0; } +.pos-right { border-left: 1px solid var(--border); display: flex; flex-direction: column; padding: 12px; gap: 10px; } + +.scanbox { + font-size: 20px; padding: 12px 14px !important; font-family: var(--mono); +} +.sugg-list { + position: absolute; z-index: 30; margin-top: 4px; min-width: 480px; + background: var(--bg-raised); border: 1px solid var(--border-strong); + border-radius: var(--radius); box-shadow: var(--shadow-lg); overflow: hidden; +} +.sugg-row { + display: flex; gap: 16px; justify-content: space-between; align-items: baseline; + padding: 8px 12px; cursor: pointer; font-size: 15px; +} +.sugg-row.hi { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); } +.sugg-row:hover { background: var(--bg-hover); } +.pos-left { position: relative; } + +.lines-wrap { flex: 1; overflow: auto; margin-top: 10px; } +.lines-wrap table.wf { font-size: 15px; } +.lines-wrap tr.selected td { background: var(--bg-hover); box-shadow: inset 3px 0 0 var(--accent); } + +.quick-keys { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-top: 10px; } +.quick-keys button.wf { padding: 10px 6px; font-size: 13px; } +.quick-keys button.wf kbd { display: block; margin: 4px 0 0; } + +.customer-strip { + background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); + padding: 10px 12px; font-size: 13px; color: var(--text-dim); +} +.totals { + background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); + padding: 12px; display: flex; flex-direction: column; gap: 4px; +} +.totals .row { display: flex; justify-content: space-between; color: var(--text-dim); } +.totals .row .num { color: var(--text); } +.payable { + background: var(--bg); border: 2px solid var(--accent); border-radius: var(--radius); + padding: 10px 14px; display: flex; justify-content: space-between; align-items: baseline; +} +.payable .amount { font-size: 44px; font-weight: 700; } +.saved { text-align: center; color: var(--ok); font-size: 13px; } +.pay-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.pay-buttons button.wf { padding: 14px; font-size: 16px; } + +.pos-status { + display: flex; gap: 18px; padding: 5px 14px; font-size: 12px; color: var(--text-dim); + background: var(--bg-raised); border-top: 1px solid var(--border); +} +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; } +.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.err { background: var(--err); } + +/* Login */ +.login-wrap { height: 100vh; display: flex; align-items: center; justify-content: center; gap: 40px; } +.login-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; padding: 28px; width: 380px; } +.user-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0; } +.user-tiles button.wf { padding: 18px 10px; font-size: 15px; } +.user-tiles button.wf.selected { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); } +.pin-dots { display: flex; gap: 10px; justify-content: center; margin: 14px 0; } +.pin-dots span { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--text-dim); } +.pin-dots span.filled { background: var(--accent); border-color: var(--accent); } +.pin-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.pin-pad button.wf { padding: 16px; font-size: 18px; } +.denoms { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 16px; } diff --git a/apps/pos/src/print.ts b/apps/pos/src/print.ts new file mode 100644 index 0000000..5eb588b --- /dev/null +++ b/apps/pos/src/print.ts @@ -0,0 +1,59 @@ +export interface PrinterCfg { + host: string + port: number + width: 32 | 42 + enabled: boolean + /** ASCII text mode (fast, Latin only) vs raster bit-image (Indic scripts). */ + script: 'ascii' | 'raster' +} + +const KEY = 'pos.printer' +const DEFAULTS: PrinterCfg = { host: '192.168.1.100', port: 9100, width: 42, enabled: false, script: 'ascii' } + +export function loadPrinterCfg(): PrinterCfg { + try { + const raw = localStorage.getItem(KEY) + // Merge over defaults so configs saved before `script` existed still load. + if (raw !== null) return { ...DEFAULTS, ...(JSON.parse(raw) as Partial) } + } catch { + /* corrupted config falls back to defaults */ + } + return { ...DEFAULTS } +} + +export function savePrinterCfg(cfg: PrinterCfg): void { + localStorage.setItem(KEY, JSON.stringify(cfg)) +} + +/** + * Sends ESC/POS bytes to the printer. Web-first (D2 revision): the browser + * can't open sockets, so the store-server does it (/api/print). The Electron + * kiosk shell keeps its direct bridge when present. + */ +export async function sendToPrinter(cfg: PrinterCfg, bytes: Uint8Array): Promise { + if (!cfg.enabled) throw new Error('Printer disabled in settings (Ctrl+,)') + if (window.pos !== undefined) { + await window.pos.printRaw9100(cfg.host, cfg.port, bytes) + return + } + const res = await fetch('/api/print', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ host: cfg.host, port: cfg.port, bytes: Array.from(bytes) }), + }).catch(() => undefined) + if (res === undefined) throw new Error('Store-server unreachable — bill is saved, print queued') + if (!res.ok) { + const body = (await res.json().catch(() => undefined)) as { error?: string } | undefined + throw new Error(body?.error ?? `Print failed (HTTP ${res.status})`) + } +} + +/** + * Print a receipt as an Indic raster bit-image (GS v 0). Same transport as + * sendToPrinter; the content is drawn to a canvas first (raster.ts) so + * non-Latin item names survive. Used when the counter's script = 'raster'. + */ +export async function sendRasterReceipt(cfg: PrinterCfg, lines: string[], kickDrawer: boolean): Promise { + const { renderRasterReceipt } = await import('./raster') + await sendToPrinter(cfg, renderRasterReceipt(lines, { width: cfg.width, kickDrawer })) +} diff --git a/apps/pos/src/raster.ts b/apps/pos/src/raster.ts new file mode 100644 index 0000000..926b79d --- /dev/null +++ b/apps/pos/src/raster.ts @@ -0,0 +1,63 @@ +import { EscPos, bitonalFromRGBA, rasterToEscPos } from '@sims/printing' + +/** + * Indic raster receipt (09-UX §5.3): ESC/POS text mode can't render Devanagari + * or Malayalam, so non-Latin receipts are drawn to an offscreen canvas and sent + * as a GS v 0 bit-image. Canvas is a browser API, so this lives in the app; the + * thresholding + bit-packing are the pure @sims/printing package (raster.ts). + * + * v1 caveat (documented): browsers fall back to the OS fonts for canvas text, so + * Devanagari/Malayalam coverage depends on the shop PC having a suitable font + * (Windows ships Nirmala UI). The raster *pipeline* is what S2 delivers; bundled + * canvas webfonts are a follow-up. Latin/ASCII always renders. + */ + +/** Print-head pixel width: 384 dots for 2" (32 col), 576 for 3" (42 col). */ +export function headWidth(cols: 32 | 42): number { + return cols === 32 ? 384 : 576 +} + +export function renderRasterReceipt(lines: string[], opts: { width: 32 | 42; kickDrawer?: boolean }): Uint8Array { + const W = headWidth(opts.width) + const fontPx = opts.width === 32 ? 20 : 22 + const lineH = Math.round(fontPx * 1.35) + const padTop = 8 + const padBottom = 24 + const H = padTop + lines.length * lineH + padBottom + + const canvas = document.createElement('canvas') + canvas.width = W + canvas.height = H + const ctx = canvas.getContext('2d') + if (ctx === null) throw new Error('Canvas 2D unavailable — cannot rasterize receipt') + ctx.fillStyle = '#fff' + ctx.fillRect(0, 0, W, H) + ctx.fillStyle = '#000' + ctx.textBaseline = 'top' + // A font stack the browser can fall back to for Indic scripts on the shop PC. + ctx.font = `${fontPx}px "Noto Sans", "Noto Sans Devanagari", "Nirmala UI", "Segoe UI", monospace` + lines.forEach((text, i) => { + const y = padTop + i * lineH + if (i === 0) { + ctx.textAlign = 'center' + ctx.fillText(text, W / 2, y) + } else { + ctx.textAlign = 'left' + ctx.fillText(text, 4, y) + } + }) + + const img = ctx.getImageData(0, 0, W, H) + const body = rasterToEscPos(bitonalFromRGBA(img.data, W, H)) + + const head = new EscPos().init() + if (opts.kickDrawer === true) head.drawerKick() + const prefix = head.align('left').build() + const tail = new EscPos().feed(4).cut().build() + + const out = new Uint8Array(prefix.length + body.length + tail.length) + out.set(prefix, 0) + out.set(body, prefix.length) + out.set(tail, prefix.length + body.length) + return out +} diff --git a/apps/pos/src/types.d.ts b/apps/pos/src/types.d.ts new file mode 100644 index 0000000..ec3fe53 --- /dev/null +++ b/apps/pos/src/types.d.ts @@ -0,0 +1,9 @@ +/** Bridge exposed by electron/preload.ts; absent when running in a plain browser. */ +interface PosBridge { + listPrinters(): Promise<{ name: string; displayName: string }[]> + printRaw9100(host: string, port: number, bytes: Uint8Array): Promise +} + +interface Window { + pos?: PosBridge +} diff --git a/apps/pos/tsconfig.json b/apps/pos/tsconfig.json new file mode 100644 index 0000000..52e1679 --- /dev/null +++ b/apps/pos/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "electron/**/*"] +} diff --git a/apps/pos/vite.config.ts b/apps/pos/vite.config.ts new file mode 100644 index 0000000..e198c7f --- /dev/null +++ b/apps/pos/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'node:url' + +const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url)) + +export default defineConfig({ + base: './', + plugins: [react()], + resolve: { + alias: { + '@sims/domain': p('../../packages/domain/src/index.ts'), + '@sims/billing-engine': p('../../packages/billing-engine/src/index.ts'), + '@sims/config': p('../../packages/config/src/index.ts'), + // Browser-safe subpath (pure role→gate matrix) — must precede the barrel alias, + // whose index re-exports scrypt (node:crypto) and cannot enter the web bundle. + '@sims/auth/permissions': p('../../packages/auth/src/permissions.ts'), + '@sims/auth': p('../../packages/auth/src/index.ts'), + '@sims/scanning': p('../../packages/scanning/src/index.ts'), + '@sims/search-core': p('../../packages/search-core/src/index.ts'), + '@sims/printing': p('../../packages/printing/src/index.ts'), + '@sims/ui': p('../../packages/ui/src/index.ts'), + }, + }, + build: { outDir: 'dist' }, +}) diff --git a/apps/store-server/README.md b/apps/store-server/README.md new file mode 100644 index 0000000..0116e0e --- /dev/null +++ b/apps/store-server/README.md @@ -0,0 +1,6 @@ +# apps/store-server — Store Service (Node) + +Runs on the store server beside the existing Oracle 12c (D12): owns Oracle access via +node-oracledb thin mode behind the repository layer, receives document envelopes from +counters over LAN, serves the back-office web app locally, and (when the cloud tier +arrives, D14) hosts the sync agent that drains the outbox upward. diff --git a/apps/store-server/build-server.mjs b/apps/store-server/build-server.mjs new file mode 100644 index 0000000..d523980 --- /dev/null +++ b/apps/store-server/build-server.mjs @@ -0,0 +1,11 @@ +import { build } from 'esbuild' + +await build({ + entryPoints: ['src/server.ts'], + outfile: 'dist/server.cjs', + bundle: true, + platform: 'node', + format: 'cjs', + external: ['better-sqlite3'], +}) +console.log('store-server built') diff --git a/apps/store-server/package.json b/apps/store-server/package.json new file mode 100644 index 0000000..250abcb --- /dev/null +++ b/apps/store-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "@sims/store-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node build-server.mjs", + "start": "npm run build && node dist/server.cjs", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@sims/auth": "*", + "@sims/billing-engine": "*", + "@sims/domain": "*", + "better-sqlite3": "^11.7.0", + "express": "^4.21.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/express": "^4.17.21", + "esbuild": "^0.25.0" + } +} diff --git a/apps/store-server/src/api.ts b/apps/store-server/src/api.ts new file mode 100644 index 0000000..a08a77c --- /dev/null +++ b/apps/store-server/src/api.ts @@ -0,0 +1,226 @@ +import { Router, type Request, type Response, type NextFunction } from 'express' +import { randomUUID } from 'node:crypto' +import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, type LockoutState } from '@sims/auth' +import type { LineInput } from '@sims/billing-engine' +import type { DB } from './db' +import { + commitBill, createCustomer, createItem, listAudit, listBills, listItems, + listParties, listTaxClasses, stockView, writeAudit, +} from './repos' +import { commitPurchase, costHistory, lastCosts, listPurchases, type CommitPurchaseInput } from './repos-purchase' + +interface Session { + token: string; userId: string; tenantId: string; displayName: string; roles: string[] +} + +const sessions = new Map() +const lockouts = new Map() + +interface UserRow { + id: string; tenant_id: string; username: string; display_name: string + roles: string; active: number + pin_salt: string | null; pin_hash: string | null + pw_salt: string | null; pw_hash: string | null +} + +function requireAuth(req: Request, res: Response, next: NextFunction): void { + const token = (req.headers.authorization ?? '').replace('Bearer ', '') + const session = sessions.get(token) + if (session === undefined) { + res.status(401).json({ error: 'Not signed in (E-6103)' }) + return + } + ;(req as Request & { session: Session }).session = session + next() +} + +const sess = (req: Request): Session => (req as Request & { session: Session }).session + +export function apiRouter(db: DB): Router { + const r = Router() + + const startSession = (u: UserRow): Session => { + const s: Session = { + token: randomUUID(), userId: u.id, tenantId: u.tenant_id, + displayName: u.display_name, roles: JSON.parse(u.roles) as string[], + } + sessions.set(s.token, s) + return s + } + + const attempt = (u: UserRow | undefined, secret: string, salt: string | null, hash: string | null): Session | { error: string; status: number } => { + const key = u?.id ?? 'unknown' + const now = Date.now() + const lock = lockouts.get(key) ?? FRESH_LOCKOUT + if (isLocked(lock, now)) return { error: 'Too many attempts — locked for a minute (E-6104)', status: 429 } + if (u === undefined || u.active !== 1 || salt === null || hash === null || !verifyPin(secret, { salt, hash })) { + lockouts.set(key, recordFailure(lock, now)) + if (u !== undefined) writeAudit(db, u.tenant_id, u.id, 'LOGIN_FAILED', 'app_user', u.id) + return { error: 'Wrong credentials (E-6101)', status: 401 } + } + lockouts.set(key, recordSuccess()) + return startSession(u) + } + + // POS: name-tile + PIN + r.post('/auth/pin', (req, res) => { + const { userId, pin } = req.body as { userId?: string; pin?: string } + const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined + const out = attempt(u, pin ?? '', u?.pin_salt ?? null, u?.pin_hash ?? null) + 'token' in out ? res.json(out) : res.status(out.status).json({ error: out.error }) + }) + + // Back office: username + password + r.post('/auth/login', (req, res) => { + const { username, password } = req.body as { username?: string; password?: string } + const u = db.prepare(`SELECT * FROM app_user WHERE username=?`).get((username ?? '').toLowerCase()) as UserRow | undefined + const out = attempt(u, password ?? '', u?.pw_salt ?? null, u?.pw_hash ?? null) + 'token' in out ? res.json(out) : res.status(out.status).json({ error: out.error }) + }) + + // In-flow supervisor gate (spec P0-2): verify a PIN WITHOUT creating a session — + // the cashier's login must never be disturbed and `sessions` must not grow. Shares + // the same lockout + scrypt verification as attempt(); audits PIN_VERIFY on success + // and a LOGIN_FAILED-style PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor / + // manager / owner roles may approve an override (09-UX §5.5) — a valid-PIN cashier + // is rejected loudly, never granted. + const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner']) + r.post('/auth/verify-pin', (req, res) => { + const { userId, pin } = req.body as { userId?: string; pin?: string } + const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined + const key = u?.id ?? 'unknown' + const now = Date.now() + const lock = lockouts.get(key) ?? FRESH_LOCKOUT + if (isLocked(lock, now)) { + res.status(429).json({ error: 'Too many attempts — locked for a minute (E-6104)' }) + return + } + if (u === undefined || u.active !== 1 || u.pin_salt === null || u.pin_hash === null + || !verifyPin(pin ?? '', { salt: u.pin_salt, hash: u.pin_hash })) { + lockouts.set(key, recordFailure(lock, now)) + if (u !== undefined) writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY_FAILED', 'app_user', u.id) + res.status(401).json({ error: 'Wrong PIN (E-6101)' }) + return + } + // Identity proven — clear the lockout — but authorisation is a separate gate. + lockouts.set(key, recordSuccess()) + const roles = JSON.parse(u.roles) as string[] + if (!roles.some((role) => APPROVER_ROLES.has(role))) { + res.status(403).json({ error: 'This user cannot approve overrides — pick a supervisor, manager or owner (E-6105)' }) + return + } + writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY', 'app_user', u.id) + res.json({ ok: true, userId: u.id, displayName: u.display_name }) + }) + + // POS bootstrap: store/counter context, login tiles, tax classes + r.get('/bootstrap', (req, res) => { + const counterCode = String(req.query['counter'] ?? 'C2') + const store = db.prepare(`SELECT * FROM store LIMIT 1`).get() as Record + const counter = db.prepare(`SELECT * FROM counter WHERE store_id=? AND code=?`).get(store['id'], counterCode) as Record | undefined + const tenant = db.prepare(`SELECT * FROM tenant WHERE id=?`).get(store['tenant_id']) as Record + const users = db.prepare(`SELECT id, display_name, roles FROM app_user WHERE active=1 AND pin_hash IS NOT NULL`).all() as Record[] + res.json({ + store: { + id: store['id'], name: store['name'], storeCode: store['code'], + stateCode: store['state_code'], gstin: tenant['gstin'], + counterId: counter?.['id'] ?? 'c2', counterCode, + }, + users: users.map((u) => ({ id: u['id'], name: u['display_name'], role: (JSON.parse(u['roles'] as string) as string[])[0] })), + taxClasses: listTaxClasses(db), + }) + }) + + r.use(requireAuth) + + r.get('/items', (req, res) => { + res.json(listItems(db, sess(req).tenantId, { + ...(req.query['q'] !== undefined ? { query: String(req.query['q']) } : {}), + all: req.query['all'] === '1', + ...(req.query['limit'] !== undefined ? { limit: Number(req.query['limit']) } : {}), + ...(req.query['offset'] !== undefined ? { offset: Number(req.query['offset']) } : {}), + })) + }) + r.post('/items', (req, res) => { + const item = createItem(db, sess(req).tenantId, req.body as Parameters[2]) + writeAudit(db, sess(req).tenantId, sess(req).userId, 'ITEM_CREATE', 'item', item.id, undefined, item) + res.json(item) + }) + + r.get('/parties', (req, res) => { + res.json(listParties(db, sess(req).tenantId, req.query['kind'] as string | undefined, req.query['phone'] as string | undefined)) + }) + r.post('/parties', (req, res) => { + const { name, phone, gstin, stateCode } = req.body as { name: string; phone: string; gstin?: string; stateCode?: string } + try { + const p = createCustomer(db, sess(req).tenantId, name, phone, { + ...(gstin !== undefined ? { gstin } : {}), + ...(stateCode !== undefined ? { stateCode } : {}), + }) + writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p) + res.json(p) + } catch (err) { + const status = (err as { status?: number }).status ?? 500 + res.status(status).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + r.post('/bills', (req, res) => { + const body = req.body as { + storeId: string; counterId: string; shiftId: string; customerId?: string + businessDate: string; lines: LineInput[] + payments: { mode: string; amountPaise: number; reference?: string }[] + clientPayablePaise: number + overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[] + } + try { + const out = commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }) + res.json(out) + } catch (err) { + const status = (err as { status?: number }).status ?? 500 + res.status(status).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/bills', (req, res) => { + res.json(listBills( + db, sess(req).tenantId, + req.query['date'] as string | undefined, req.query['counterId'] as string | undefined, + req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined, + req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined, + )) + }) + + r.get('/stock', (req, res) => { + const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string } + res.json(stockView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id)) + }) + + r.get('/audit', (req, res) => { + res.json(listAudit( + db, sess(req).tenantId, + req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined, + req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined, + )) + }) + + r.get('/purchases', (req, res) => { + res.json(listPurchases(db, sess(req).tenantId)) + }) + r.get('/purchases/last-costs', (req, res) => { + res.json(lastCosts(db, sess(req).tenantId, req.query['supplierId'] as string | undefined)) + }) + r.get('/purchases/cost-history', (req, res) => { + res.json(costHistory(db, sess(req).tenantId, String(req.query['itemId'] ?? ''), req.query['supplierId'] as string | undefined)) + }) + r.post('/purchases', (req, res) => { + const body = req.body as Omit + try { + res.json(commitPurchase(db, { ...body, tenantId: sess(req).tenantId, userId: sess(req).userId })) + } catch (err) { + const status = (err as { status?: number }).status ?? 500 + res.status(status).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + return r +} diff --git a/apps/store-server/src/db.ts b/apps/store-server/src/db.ts new file mode 100644 index 0000000..b18634d --- /dev/null +++ b/apps/store-server/src/db.ts @@ -0,0 +1,141 @@ +import Database from 'better-sqlite3' +import fs from 'node:fs' +import path from 'node:path' + +/** + * Store DB — SQLite for the slice, behind portable repositories (repos.ts). + * The Oracle 12c adapter (D12) implements the same functions when the Classic + * DDL arrives; SQL here stays standard where possible, quirks are commented. + */ +export type DB = Database.Database + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS tenant ( + id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL, + gstin TEXT, gst_scheme TEXT NOT NULL DEFAULT 'regular', + fy_start_month INTEGER NOT NULL DEFAULT 4, created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS store ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL, + name TEXT NOT NULL, state_code TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS counter ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, + code TEXT NOT NULL, name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS app_user ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, roles TEXT NOT NULL, store_ids TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'en', active INTEGER NOT NULL DEFAULT 1, + pin_salt TEXT, pin_hash TEXT, pw_salt TEXT, pw_hash TEXT +); +CREATE TABLE IF NOT EXISTS item ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL, + name TEXT NOT NULL, name_secondary TEXT, hsn TEXT NOT NULL DEFAULT '', + tax_class_code TEXT NOT NULL, unit_code TEXT NOT NULL DEFAULT 'PCS', + unit_decimals INTEGER NOT NULL DEFAULT 0, mrp_paise INTEGER, + sale_price_paise INTEGER NOT NULL, price_includes_tax INTEGER NOT NULL DEFAULT 1, + barcodes TEXT NOT NULL DEFAULT '[]', batch_tracked INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + UNIQUE (tenant_id, code) +); +CREATE TABLE IF NOT EXISTS party ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL, + name TEXT NOT NULL, kind TEXT NOT NULL, phone TEXT, gstin TEXT, + state_code TEXT, credit_limit_paise INTEGER +); +CREATE TABLE IF NOT EXISTS tax_class ( + class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL, + cess_pct_bp INTEGER NOT NULL DEFAULT 0, + effective_from TEXT NOT NULL, effective_to TEXT +); +CREATE TABLE IF NOT EXISTS doc_series ( + tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, counter_id TEXT NOT NULL, + doc_type TEXT NOT NULL, fy TEXT NOT NULL, prefix TEXT NOT NULL, + next_seq INTEGER NOT NULL, + PRIMARY KEY (tenant_id, store_id, counter_id, doc_type, fy) +); +CREATE TABLE IF NOT EXISTS bill ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, + counter_id TEXT NOT NULL, doc_type TEXT NOT NULL, doc_no TEXT NOT NULL, + business_date TEXT NOT NULL, cashier_id TEXT NOT NULL, shift_id TEXT NOT NULL, + customer_id TEXT, ref_doc_id TEXT, + gross_paise INTEGER NOT NULL, discount_paise INTEGER NOT NULL, + taxable_paise INTEGER NOT NULL, cgst_paise INTEGER NOT NULL, + sgst_paise INTEGER NOT NULL, igst_paise INTEGER NOT NULL, + cess_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL, + payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL, + buyer_gstin TEXT, place_of_supply TEXT, + payload TEXT NOT NULL, created_at_wall TEXT NOT NULL, + lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '', + device_seq INTEGER NOT NULL DEFAULT 0, + UNIQUE (tenant_id, doc_no) +); +CREATE TABLE IF NOT EXISTS stock_movement ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, + item_id TEXT NOT NULL, qty_delta REAL NOT NULL, reason TEXT NOT NULL, + ref_doc_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS setting ( + scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, key TEXT NOT NULL, + value TEXT NOT NULL, effective_from TEXT +); +CREATE TABLE IF NOT EXISTS message_catalog ( + code TEXT NOT NULL, channel TEXT NOT NULL, lang TEXT NOT NULL, template TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, at_wall TEXT NOT NULL, + user_id TEXT NOT NULL, action TEXT NOT NULL, entity TEXT NOT NULL, + entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT +); +CREATE TABLE IF NOT EXISTS outbox ( + op_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, device_id TEXT NOT NULL, + device_seq INTEGER NOT NULL, doc_type TEXT NOT NULL, doc_id TEXT NOT NULL, + envelope TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS purchase ( + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, + supplier_id TEXT NOT NULL, invoice_no TEXT NOT NULL, invoice_date TEXT NOT NULL, + business_date TEXT NOT NULL, taxable_paise INTEGER NOT NULL, tax_paise INTEGER NOT NULL, + total_paise INTEGER NOT NULL, created_by TEXT NOT NULL, created_at_wall TEXT NOT NULL, + payload TEXT NOT NULL, + UNIQUE (tenant_id, supplier_id, invoice_no) +); +CREATE TABLE IF NOT EXISTS purchase_line ( + purchase_id TEXT NOT NULL, line_no INTEGER NOT NULL, item_id TEXT NOT NULL, + name TEXT NOT NULL, qty REAL NOT NULL, unit_cost_paise INTEGER NOT NULL, + tax_rate_bp INTEGER NOT NULL, tax_paise INTEGER NOT NULL, line_total_paise INTEGER NOT NULL, + new_sale_price_paise INTEGER, new_mrp_paise INTEGER, + PRIMARY KEY (purchase_id, line_no) +); +CREATE INDEX IF NOT EXISTS idx_pline_item ON purchase_line (item_id); +CREATE INDEX IF NOT EXISTS idx_item_name ON item (tenant_id, name); +CREATE INDEX IF NOT EXISTS idx_bill_date ON bill (tenant_id, business_date); +CREATE INDEX IF NOT EXISTS idx_move_item ON stock_movement (tenant_id, store_id, item_id); +CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (tenant_id, at_wall); +` + +/** + * Additive migrations for dev DBs seeded before a column existed. Fresh DBs get + * the columns from CREATE TABLE above; this only fills the gap for the running + * slice. Each step is guarded by a PRAGMA table_info check so it is idempotent. + * The Oracle adapter (D12) manages its own DDL versioning. + */ +function migrate(db: DB): void { + const cols = db.prepare(`PRAGMA table_info(bill)`).all() as { name: string }[] + const has = (c: string): boolean => cols.some((x) => x.name === c) + // S3 B2B: buyer GSTIN + place-of-supply snapshot on the immutable bill. + if (!has('buyer_gstin')) db.exec(`ALTER TABLE bill ADD COLUMN buyer_gstin TEXT`) + if (!has('place_of_supply')) db.exec(`ALTER TABLE bill ADD COLUMN place_of_supply TEXT`) +} + +export function openDb(): DB { + const dir = path.resolve(__dirname, '../data') + fs.mkdirSync(dir, { recursive: true }) + const db = new Database(path.join(dir, 'sims.db')) + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + db.exec(SCHEMA) + migrate(db) + return db +} diff --git a/apps/store-server/src/repos-purchase.ts b/apps/store-server/src/repos-purchase.ts new file mode 100644 index 0000000..aaff0b2 --- /dev/null +++ b/apps/store-server/src/repos-purchase.ts @@ -0,0 +1,142 @@ +import { uuidv7 } from '@sims/domain' +import type { DB } from './db' +import { writeAudit } from './repos' + +/** + * Purchases — stock IN, cost memory, and price updates in one transaction. + * Same portable-repository contract as repos.ts (D12). + */ + +export interface PurchaseLineInput { + itemId: string + name: string + qty: number + unitCostPaise: number + taxRateBp: number + /** Optional price revisions decided at entry time (cost-changed prompt). */ + newSalePricePaise?: number + newMrpPaise?: number +} + +export interface CommitPurchaseInput { + tenantId: string + storeId: string + userId: string + supplierId: string + invoiceNo: string + invoiceDate: string + businessDate: string + lines: PurchaseLineInput[] + clientTotalPaise: number +} + +function lineMath(l: PurchaseLineInput): { taxable: number; tax: number; total: number } { + const taxable = Math.round(l.qty * l.unitCostPaise) + const tax = Math.round((taxable * l.taxRateBp) / 10_000) + return { taxable, tax, total: taxable + tax } +} + +export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string; totalPaise: number } { + if (input.lines.length === 0) throw Object.assign(new Error('Purchase has no lines'), { status: 400 }) + if (input.invoiceNo.trim() === '') throw Object.assign(new Error('Supplier invoice number is required'), { status: 400 }) + for (const l of input.lines) { + if (!(l.qty > 0)) throw Object.assign(new Error(`Quantity must be positive for "${l.name}"`), { status: 400 }) + if (!Number.isInteger(l.unitCostPaise) || l.unitCostPaise < 0) { + throw Object.assign(new Error(`Cost must be non-negative for "${l.name}"`), { status: 400 }) + } + } + + const math = input.lines.map(lineMath) + const taxable = math.reduce((a, m) => a + m.taxable, 0) + const tax = math.reduce((a, m) => a + m.tax, 0) + const total = taxable + tax + // Same-math-everywhere idiom: the entry screen and the server must agree. + if (total !== input.clientTotalPaise) { + throw Object.assign(new Error('Totals mismatch between entry screen and server'), { status: 409 }) + } + + const dup = db.prepare( + `SELECT id FROM purchase WHERE tenant_id=? AND supplier_id=? AND invoice_no=?`, + ).get(input.tenantId, input.supplierId, input.invoiceNo) + if (dup !== undefined) { + throw Object.assign(new Error(`Invoice ${input.invoiceNo} from this supplier is already entered (duplicate)`), { status: 409 }) + } + + const id = uuidv7() + const now = new Date().toISOString() + + db.transaction(() => { + db.prepare( + `INSERT INTO purchase (id, tenant_id, store_id, supplier_id, invoice_no, invoice_date, business_date, + taxable_paise, tax_paise, total_paise, created_by, created_at_wall, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, input.tenantId, input.storeId, input.supplierId, input.invoiceNo, input.invoiceDate, + input.businessDate, taxable, tax, total, input.userId, now, JSON.stringify(input.lines), + ) + const insLine = db.prepare( + `INSERT INTO purchase_line (purchase_id, line_no, item_id, name, qty, unit_cost_paise, tax_rate_bp, tax_paise, line_total_paise, new_sale_price_paise, new_mrp_paise) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + const move = db.prepare( + `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall) + VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?)`, + ) + input.lines.forEach((l, i) => { + const m = math[i]! + insLine.run(id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total, l.newSalePricePaise ?? null, l.newMrpPaise ?? null) + move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, input.businessDate, now) + if (l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined) { + const before = db.prepare(`SELECT sale_price_paise, mrp_paise FROM item WHERE id=?`).get(l.itemId) + db.prepare( + `UPDATE item SET sale_price_paise = COALESCE(?, sale_price_paise), mrp_paise = COALESCE(?, mrp_paise) WHERE id=? AND tenant_id=?`, + ).run(l.newSalePricePaise ?? null, l.newMrpPaise ?? null, l.itemId, input.tenantId) + writeAudit(db, input.tenantId, input.userId, 'ITEM_PRICE_UPDATE', 'item', l.itemId, before, { + salePricePaise: l.newSalePricePaise, mrpPaise: l.newMrpPaise, source: `purchase ${input.invoiceNo}`, + }) + } + }) + writeAudit(db, input.tenantId, input.userId, 'PURCHASE_COMMIT', 'purchase', id, undefined, { + invoiceNo: input.invoiceNo, supplier: input.supplierId, total, + }) + })() + + return { id, totalPaise: total } +} + +/** Last known unit cost per item — overall and for one supplier (prefill + change detection). */ +export function lastCosts(db: DB, tenantId: string, supplierId?: string): Record { + const out: Record = {} + const rows = db.prepare( + `SELECT pl.item_id, pl.unit_cost_paise, p.supplier_id + FROM purchase_line pl JOIN purchase p ON p.id = pl.purchase_id + WHERE p.tenant_id=? ORDER BY p.created_at_wall ASC`, + ).all(tenantId) as { item_id: string; unit_cost_paise: number; supplier_id: string }[] + for (const r of rows) { + const e = (out[r.item_id] ??= {}) + e.last = r.unit_cost_paise + if (supplierId !== undefined && r.supplier_id === supplierId) e.supplierLast = r.unit_cost_paise + } + return out +} + +/** Last 3 costs for one item (right-rail glanceability, spec P1-1). */ +export function costHistory(db: DB, tenantId: string, itemId: string, supplierId?: string): Record[] { + const args: unknown[] = [tenantId, itemId] + let sql = `SELECT pl.unit_cost_paise AS cost, p.invoice_date AS date, pa.name AS supplier + FROM purchase_line pl JOIN purchase p ON p.id = pl.purchase_id + LEFT JOIN party pa ON pa.id = p.supplier_id + WHERE p.tenant_id=? AND pl.item_id=?` + if (supplierId !== undefined) { sql += ` AND p.supplier_id=?`; args.push(supplierId) } + sql += ` ORDER BY p.created_at_wall DESC LIMIT 3` + return db.prepare(sql).all(...args) as Record[] +} + +export function listPurchases(db: DB, tenantId: string): Record[] { + return db.prepare( + `SELECT pu.*, pa.name AS supplier_name, + (SELECT COUNT(*) FROM purchase_line pl WHERE pl.purchase_id = pu.id) AS line_count + FROM purchase pu LEFT JOIN party pa ON pa.id = pu.supplier_id + WHERE pu.tenant_id=? ORDER BY pu.created_at_wall DESC LIMIT 300`, + ).all(tenantId) as Record[] +} diff --git a/apps/store-server/src/repos.ts b/apps/store-server/src/repos.ts new file mode 100644 index 0000000..a98b49a --- /dev/null +++ b/apps/store-server/src/repos.ts @@ -0,0 +1,305 @@ +import { + deriveSupply, formatDocNo, fyOf, fyShort, seriesPrefix, uuidv7, validateGstin, + type Item, type Party, +} from '@sims/domain' +import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine' +import type { DB } from './db' + +/** + * Repositories — the portable data layer (D12 guardrail). Plain functions over + * a handle; the Oracle adapter reimplements these signatures, nothing above + * this file changes. + */ + +// ---------- items ---------- +interface ItemRow { + id: string; tenant_id: string; code: string; name: string; name_secondary: string | null + hsn: string; tax_class_code: string; unit_code: string; unit_decimals: number + mrp_paise: number | null; sale_price_paise: number; price_includes_tax: number + barcodes: string; batch_tracked: number; status: string +} + +function toItem(r: ItemRow): Item { + return { + id: r.id, tenantId: r.tenant_id, code: r.code, name: r.name, + ...(r.name_secondary !== null ? { nameSecondary: r.name_secondary } : {}), + hsn: r.hsn, taxClassCode: r.tax_class_code, + unit: { code: r.unit_code, decimals: r.unit_decimals }, + ...(r.mrp_paise !== null ? { mrpPaise: r.mrp_paise } : {}), + salePricePaise: r.sale_price_paise, priceIncludesTax: r.price_includes_tax === 1, + barcodes: JSON.parse(r.barcodes) as string[], batchTracked: r.batch_tracked === 1, + status: r.status as Item['status'], + } +} + +/** + * The item master. `all` returns every non-inactive item unpaginated — the POS + * cache path, which must never be silently truncated (R13); every other path + * paginates via limit/offset (default 5000, generous but bounded). + */ +export function listItems(db: DB, tenantId: string, opts: { + query?: string; all?: boolean; limit?: number; offset?: number +} = {}): Item[] { + const { query, all = false } = opts + const limit = opts.limit ?? 5000 + const offset = opts.offset ?? 0 + let rows: ItemRow[] + if (all) { + rows = db.prepare( + `SELECT * FROM item WHERE tenant_id=? AND status <> 'inactive' ORDER BY name`, + ).all(tenantId) as ItemRow[] + } else if (query !== undefined && query !== '') { + rows = db.prepare( + `SELECT * FROM item WHERE tenant_id=? AND (name LIKE ? OR code=? OR barcodes LIKE ?) ORDER BY name LIMIT ? OFFSET ?`, + ).all(tenantId, `%${query}%`, query, `%"${query}"%`, limit, offset) as ItemRow[] + } else { + rows = db.prepare(`SELECT * FROM item WHERE tenant_id=? ORDER BY name LIMIT ? OFFSET ?`).all(tenantId, limit, offset) as ItemRow[] + } + return rows.map(toItem) +} + +export function createItem(db: DB, tenantId: string, input: { + code: string; name: string; hsn: string; taxClassCode: string + unitCode: string; unitDecimals: number; mrpPaise?: number + salePricePaise: number; barcode?: string; status?: string +}): Item { + const id = uuidv7() + db.prepare( + `INSERT INTO item (id, tenant_id, code, name, hsn, tax_class_code, unit_code, unit_decimals, mrp_paise, sale_price_paise, price_includes_tax, barcodes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, + ).run( + id, tenantId, input.code, input.name, input.hsn, input.taxClassCode, + input.unitCode, input.unitDecimals, input.mrpPaise ?? null, input.salePricePaise, + JSON.stringify(input.barcode !== undefined && input.barcode !== '' ? [input.barcode] : []), + input.status ?? 'active', + ) + return toItem(db.prepare(`SELECT * FROM item WHERE id=?`).get(id) as ItemRow) +} + +// ---------- parties ---------- +export function listParties(db: DB, tenantId: string, kind?: string, phone?: string): Party[] { + let rows: unknown[] + if (phone !== undefined && phone !== '') { + rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? AND phone LIKE ?`).all(tenantId, `%${phone}%`) + } else if (kind !== undefined) { + rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? AND kind=? ORDER BY name`).all(tenantId, kind) + } else { + rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? ORDER BY name`).all(tenantId) + } + return (rows as Record[]).map((r) => ({ + id: r['id'] as string, tenantId: r['tenant_id'] as string, code: r['code'] as string, + name: r['name'] as string, kind: r['kind'] as Party['kind'], + ...(r['phone'] !== null ? { phone: r['phone'] as string } : {}), + ...(r['gstin'] !== null ? { gstin: r['gstin'] as string } : {}), + ...(r['state_code'] !== null ? { stateCode: r['state_code'] as string } : {}), + ...(r['credit_limit_paise'] !== null ? { creditLimitPaise: r['credit_limit_paise'] as number } : {}), + })) +} + +export function createCustomer( + db: DB, tenantId: string, name: string, phone: string, + opts: { gstin?: string; stateCode?: string } = {}, +): Party { + // B2B identity is validated server-side (never trust the client): a bad GSTIN + // checksum is rejected loudly; the state code is derived from the GSTIN's first + // two digits when the caller didn't pass one. + let gstin: string | null = null + let stateCode: string | null = opts.stateCode !== undefined && opts.stateCode !== '' ? opts.stateCode : null + if (opts.gstin !== undefined && opts.gstin !== '') { + const v = validateGstin(opts.gstin) + if (!v.ok) { + throw Object.assign( + new Error(v.reason === 'checksum' + ? 'GSTIN checksum is invalid — re-check the number (E-1401)' + : 'GSTIN format is invalid — 15 chars, e.g. 27ABCDE1234F1Z5 (E-1402)'), + { status: 400 }, + ) + } + gstin = opts.gstin.toUpperCase().trim() + if (stateCode === null) stateCode = v.stateCode ?? null + } + const id = uuidv7() + const n = (db.prepare(`SELECT COUNT(*) AS n FROM party WHERE tenant_id=?`).get(tenantId) as { n: number }).n + db.prepare( + `INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code) VALUES (?, ?, ?, ?, 'customer', ?, ?, ?)`, + ).run(id, tenantId, `CU${String(n + 1).padStart(3, '0')}`, name, phone, gstin, stateCode) + return listParties(db, tenantId, undefined, phone)[0]! +} + +// ---------- tax ---------- +export function listTaxClasses(db: DB): TaxClassRow[] { + const rows = db.prepare(`SELECT * FROM tax_class`).all() as Record[] + return rows.map((r) => ({ + classCode: r['class_code'] as string, + ratePctBp: r['rate_pct_bp'] as number, + cessPctBp: r['cess_pct_bp'] as number, + effectiveFrom: r['effective_from'] as string, + ...(r['effective_to'] !== null ? { effectiveTo: r['effective_to'] as string } : {}), + })) +} + +// ---------- bills ---------- +export interface CommitBillInput { + tenantId: string; storeId: string; counterId: string + cashierId: string; shiftId: string; customerId?: string + businessDate: string + lines: LineInput[] + payments: { mode: string; amountPaise: number; reference?: string }[] + clientPayablePaise: number + /** + * Supervisor-approved counter overrides (spec P0-2 / P1-3): each price / qty / + * discount deviation that raised the supervisor PIN gate rides along with the + * bill so its audit row is written in the SAME transaction as the commit (R17). + */ + overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[] +} + +const OVERRIDE_ACTION = { + price: 'PRICE_OVERRIDE', qty: 'QTY_OVERRIDE', discount: 'DISCOUNT_OVERRIDE', +} as const + +export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: string; payablePaise: number } { + const store = db.prepare(`SELECT * FROM store WHERE id=?`).get(input.storeId) as { code: string; state_code: string } + const counter = db.prepare(`SELECT * FROM counter WHERE id=?`).get(input.counterId) as { code: string } + const rates = listTaxClasses(db) + + // Place of supply is decided from OUR copy of the customer row, never the + // client's claim: a B2B buyer's GSTIN state drives IGST vs CGST/SGST (R5). + const customer = input.customerId !== undefined + ? db.prepare(`SELECT gstin, state_code FROM party WHERE id=? AND tenant_id=?`).get(input.customerId, input.tenantId) as { gstin: string | null; state_code: string | null } | undefined + : undefined + const supply = deriveSupply( + customer !== undefined + ? { ...(customer.gstin !== null ? { gstin: customer.gstin } : {}), ...(customer.state_code !== null ? { stateCode: customer.state_code } : {}) } + : undefined, + store.state_code, + ) + + // Same engine everywhere: the server recomputes and must agree to the paisa. + const computed = computeBill(input.lines, { + businessDate: input.businessDate, + supplyStateCode: store.state_code, + placeOfSupplyStateCode: supply.placeOfSupplyStateCode, + roundToRupee: true, + }, rates) + if (computed.totals.payablePaise !== input.clientPayablePaise) { + throw Object.assign(new Error('Engine mismatch: client and server computed different totals'), { status: 409 }) + } + const paid = input.payments.reduce((a, p) => a + p.amountPaise, 0) + if (paid !== computed.totals.payablePaise) { + throw Object.assign(new Error('Payments do not sum to payable'), { status: 400 }) + } + + const id = uuidv7() + const now = new Date().toISOString() + const fy = fyOf(input.businessDate) + + const txn = db.transaction(() => { + // per-counter, per-FY series allocated inside the commit transaction + let series = db.prepare( + `SELECT * FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`, + ).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined + if (series === undefined) { + series = { prefix: seriesPrefix(store.code, counter.code, fyShort(fy)), next_seq: 1 } + db.prepare( + `INSERT INTO doc_series (tenant_id, store_id, counter_id, doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 'SALE', ?, ?, 1)`, + ).run(input.tenantId, input.storeId, input.counterId, fy, series.prefix) + } + const docNo = formatDocNo(series.prefix, series.next_seq) + db.prepare( + `UPDATE doc_series SET next_seq = next_seq + 1 WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`, + ).run(input.tenantId, input.storeId, input.counterId, fy) + + const t = computed.totals + const payload = JSON.stringify({ lines: computed.lines, payments: input.payments, totals: t }) + db.prepare( + `INSERT INTO bill (id, tenant_id, store_id, counter_id, doc_type, doc_no, business_date, cashier_id, shift_id, customer_id, ref_doc_id, + gross_paise, discount_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, cess_paise, round_off_paise, payable_paise, savings_paise, + buyer_gstin, place_of_supply, payload, created_at_wall) VALUES (?, ?, ?, ?, 'SALE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate, + input.cashierId, input.shiftId, input.customerId ?? null, + t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise, + t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise, + supply.buyerGstin ?? null, supply.placeOfSupplyStateCode, + payload, now, + ) + const move = db.prepare( + `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall) + VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?)`, + ) + for (const line of computed.lines) { + move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, input.businessDate, now) + } + // dormant outbox row (D14): written in the same transaction, drained by nothing yet + db.prepare( + `INSERT INTO outbox (op_id, tenant_id, device_id, device_seq, doc_type, doc_id, envelope, created_at_wall, lamport) + VALUES (?, ?, 'store-server', 0, 'SALE', ?, ?, ?, 0)`, + ).run(uuidv7(), input.tenantId, id, payload, now) + writeAudit(db, input.tenantId, input.cashierId, 'BILL_COMMIT', 'bill', id, undefined, { docNo, payable: t.payablePaise }) + // One audit row per approved override, in-transaction (R17). The approver's id + // is resolved to a display name so the audit trail is human-readable. + for (const o of input.overrides ?? []) { + const approvedBy = o.approvedByUserId !== undefined + ? (db.prepare(`SELECT display_name FROM app_user WHERE id=?`).get(o.approvedByUserId) as { display_name: string } | undefined)?.display_name + ?? o.approvedByUserId + : undefined + writeAudit( + db, input.tenantId, input.cashierId, OVERRIDE_ACTION[o.kind], 'bill', id, + { itemId: o.itemId, before: o.before }, + { after: o.after, approvedBy, approvedByUserId: o.approvedByUserId }, + ) + } + return docNo + }) + + const docNo = txn() + return { id, docNo, payablePaise: computed.totals.payablePaise } +} + +export function listBills( + db: DB, tenantId: string, date?: string, counterId?: string, limit = 100, offset = 0, +): Record[] { + let sql = `SELECT b.*, u.display_name AS cashier_name, p.name AS customer_name + FROM bill b LEFT JOIN app_user u ON u.id=b.cashier_id LEFT JOIN party p ON p.id=b.customer_id + WHERE b.tenant_id=?` + const args: unknown[] = [tenantId] + if (date !== undefined) { sql += ` AND b.business_date=?`; args.push(date) } + if (counterId !== undefined) { sql += ` AND b.counter_id=?`; args.push(counterId) } + sql += ` ORDER BY b.created_at_wall DESC LIMIT ? OFFSET ?` + args.push(limit, offset) + return db.prepare(sql).all(...args) as Record[] +} + +// ---------- stock ---------- +export function stockView(db: DB, tenantId: string, storeId: string): Record[] { + return db.prepare( + `SELECT i.code, i.name, i.unit_code, i.sale_price_paise, + COALESCE(SUM(m.qty_delta), 0) AS on_hand + FROM item i LEFT JOIN stock_movement m ON m.item_id=i.id AND m.store_id=? + WHERE i.tenant_id=? GROUP BY i.id ORDER BY i.name`, + ).all(storeId, tenantId) as Record[] +} + +// ---------- audit ---------- +export function writeAudit( + db: DB, tenantId: string, userId: string, action: string, + entity: string, entityId: string, before?: unknown, after?: unknown, +): void { + db.prepare( + `INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + uuidv7(), tenantId, new Date().toISOString(), userId, action, entity, entityId, + before === undefined ? null : JSON.stringify(before), + after === undefined ? null : JSON.stringify(after), + ) +} + +export function listAudit(db: DB, tenantId: string, limit = 100, offset = 0): Record[] { + return db.prepare( + `SELECT a.*, u.display_name AS user_name FROM audit_log a + LEFT JOIN app_user u ON u.id=a.user_id + WHERE a.tenant_id=? ORDER BY a.at_wall DESC LIMIT ? OFFSET ?`, + ).all(tenantId, limit, offset) as Record[] +} diff --git a/apps/store-server/src/seed.ts b/apps/store-server/src/seed.ts new file mode 100644 index 0000000..5ce391f --- /dev/null +++ b/apps/store-server/src/seed.ts @@ -0,0 +1,85 @@ +import { hashPin } from '@sims/auth' +import { uuidv7 } from '@sims/domain' +import type { DB } from './db' + +/** Idempotent demo seed — real installs get the onboarding wizard instead. */ +export function seedIfEmpty(db: DB): void { + const has = db.prepare('SELECT COUNT(*) AS n FROM tenant').get() as { n: number } + if (has.n > 0) return + + const now = new Date().toISOString() + db.prepare( + `INSERT INTO tenant (id, code, name, gstin, gst_scheme, fy_start_month, created_at) + VALUES ('t1', 'MEGA', 'MegaMart Retail', '32ABCDE1234F1Z5', 'regular', 4, ?)`, + ).run(now) + db.prepare( + `INSERT INTO store (id, tenant_id, code, name, state_code) VALUES ('s1', 't1', 'ST1', 'MegaMart T.Nagar', '32')`, + ).run() + db.prepare(`INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('c1','t1','s1','C1','Counter 1')`).run() + db.prepare(`INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('c2','t1','s1','C2','Counter 2')`).run() + + const user = db.prepare( + `INSERT INTO app_user (id, tenant_id, username, display_name, roles, store_ids, language, pin_salt, pin_hash, pw_salt, pw_hash) + VALUES (?, 't1', ?, ?, ?, '"all"', ?, ?, ?, ?, ?)`, + ) + const mk = (id: string, uname: string, name: string, roles: string[], lang: string, pin?: string, pw?: string) => { + const p = pin !== undefined ? hashPin(pin) : undefined + const w = pw !== undefined ? hashPin(pw) : undefined + user.run(id, uname, name, JSON.stringify(roles), lang, p?.salt ?? null, p?.hash ?? null, w?.salt ?? null, w?.hash ?? null) + } + mk('u1', 'ramesh', 'Ramesh', ['cashier'], 'ml', '4728') + mk('u2', 'suresh', 'Suresh', ['supervisor'], 'ml', '8265') + mk('u3', 'thomas', 'Thomas', ['owner'], 'en', '9174', 'sims') + mk('u4', 'divya', 'Divya', ['cashier'], 'ta', '3591') + + const tax = db.prepare( + `INSERT INTO tax_class (class_code, rate_pct_bp, cess_pct_bp, effective_from) VALUES (?, ?, ?, '2017-07-01')`, + ) + for (const [c, r, x] of [['ZERO', 0, 0], ['GST5', 500, 0], ['GST12', 1200, 0], ['GST18', 1800, 0], ['DEMERIT', 2800, 1200]] as const) { + tax.run(c, r, x) + } + + const item = db.prepare( + `INSERT INTO item (id, tenant_id, code, name, hsn, tax_class_code, unit_code, unit_decimals, mrp_paise, sale_price_paise, price_includes_tax, barcodes) + VALUES (?, 't1', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`, + ) + const rows: [string, string, string, string, string, number, number | null, number, string[]][] = [ + ['101', 'Aashirvaad Atta 5kg', '1101', 'ZERO', 'PCS', 0, 28_500, 27_000, ['8901063014357']], + ['102', 'Tata Salt 1kg', '2501', 'ZERO', 'PCS', 0, 3_000, 2_800, ['8904063202016']], + ['103', 'Surf Excel 1kg', '3402', 'GST18', 'PCS', 0, 15_000, 14_500, ['8901030704833']], + ['104', 'Parle-G 800g', '1905', 'GST5', 'PCS', 0, 9_500, 9_000, ['8901063092730']], + ['105', 'Maggi Noodles 12-pack', '1902', 'GST12', 'PCS', 0, 14_400, 14_000, ['8901058851298']], + ['106', 'Coca-Cola 1.25L', '2202', 'DEMERIT', 'PCS', 0, 6_500, 6_500, ['8901764012341']], + ['107', 'Amul Butter 500g', '0405', 'GST12', 'PCS', 0, 28_000, 27_500, ['8901262010337']], + ['108', 'Carry Bag', '3923', 'GST18', 'PCS', 0, null, 500, []], + ['42', 'Tomato (loose)', '0702', 'ZERO', 'KG', 3, null, 3_200, []], + ['43', 'Onion (loose)', '0703', 'ZERO', 'KG', 3, null, 4_500, []], + ['44', 'Potato (loose)', '0701', 'ZERO', 'KG', 3, null, 2_600, []], + ] + for (const [code, name, hsn, cls, unit, dec, mrp, price, barcodes] of rows) { + item.run(uuidv7(), code, name, hsn, cls, unit, dec, mrp, price, JSON.stringify(barcodes)) + // opening stock so the Stock page has something honest to derive + db.prepare( + `INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, business_date, created_at_wall) + SELECT ?, 't1', 's1', id, ?, 'OPENING', ?, ? FROM item WHERE tenant_id='t1' AND code=?`, + ).run(uuidv7(), unit === 'KG' ? 50 : 120, now.slice(0, 10), now, code) + } + + const party = db.prepare( + `INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code, credit_limit_paise) + VALUES (?, 't1', ?, ?, ?, ?, ?, ?, ?)`, + ) + party.run(uuidv7(), 'CU001', 'Lakshmi', 'customer', '9840012345', null, '32', 500_000) + party.run(uuidv7(), 'CU002', 'Joseph', 'customer', '9847055210', null, '32', null) + party.run(uuidv7(), 'SU001', 'HUL Distributor', 'supplier', '9847100001', '32AABCH1234K1Z6', '32', null) + party.run(uuidv7(), 'SU002', 'Amul Agency', 'supplier', '9847100002', '32AAACA5678L1Z2', '32', null) + + const setting = db.prepare( + `INSERT INTO setting (scope_type, scope_id, key, value) VALUES (?, ?, ?, ?)`, + ) + setting.run('system', '', 'gst.roundToRupee', 'true') + setting.run('system', '', 'discount.capBp', '1000') + setting.run('tenant', 't1', 'discount.capBp', '1500') + + db.prepare(`INSERT INTO message_catalog (code, channel, lang, template) VALUES ('BILL_ESHARE','whatsapp','en','Hi {name}, your bill of {amount} from {store}. Thank you!')`).run() +} diff --git a/apps/store-server/src/server.ts b/apps/store-server/src/server.ts new file mode 100644 index 0000000..9be15c4 --- /dev/null +++ b/apps/store-server/src/server.ts @@ -0,0 +1,63 @@ +import express from 'express' +import net from 'node:net' +import path from 'node:path' +import { openDb } from './db' +import { seedIfEmpty } from './seed' +import { apiRouter } from './api' + +/** + * The store server (02-ARCHITECTURE topology, web-first per D2 revision): + * one Node process on the store's server machine — + * / → back office web app + * /pos → POS web app (counters browse here; zero installs) + * /api/print → ESC/POS bytes to a network printer (browsers can't open sockets) + * /api/health → monitoring handshake + * The Oracle 12c repository layer (D12) and the outbox sync agent (D14) land here next. + */ +const app = express() +app.use(express.json({ limit: '1mb' })) + +const POS_DIST = path.resolve(__dirname, '../../pos/dist') +const BACKOFFICE_DIST = path.resolve(__dirname, '../../backoffice/dist') + +app.use('/pos', express.static(POS_DIST)) +app.use('/', express.static(BACKOFFICE_DIST)) + +app.get('/api/health', (_req, res) => { + res.json({ ok: true, service: 'sims-store-server', version: '0.2.0' }) +}) + +const db = openDb() +seedIfEmpty(db) +app.use('/api', apiRouter(db)) + +app.post('/api/print', (req, res) => { + const { host, port, bytes } = req.body as { host?: string; port?: number; bytes?: number[] } + if (typeof host !== 'string' || typeof port !== 'number' || !Array.isArray(bytes)) { + res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }' }) + return + } + // 'close' always follows 'error'/'timeout' on net sockets — respond exactly once. + let done = false + const finish = (status: number, body: object) => { + if (done) return + done = true + res.status(status).json(body) + } + const socket = net.createConnection({ host, port, timeout: 3000 }) + socket.on('connect', () => socket.end(Buffer.from(bytes))) + socket.on('close', () => finish(200, { ok: true })) + socket.on('timeout', () => { + socket.destroy() + finish(504, { ok: false, error: `Printer ${host}:${port} timed out` }) + }) + socket.on('error', (err) => finish(502, { ok: false, error: err.message })) +}) + +// 8080–8085 sit in Windows' Hyper-V excluded port ranges on shop PCs too — avoid them. +const PORT = Number(process.env['PORT'] ?? 5181) +app.listen(PORT, () => { + console.log(`SiMS store-server listening on http://localhost:${PORT}`) + console.log(` Back office: http://localhost:${PORT}/`) + console.log(` POS: http://localhost:${PORT}/pos/`) +}) diff --git a/apps/store-server/tsconfig.json b/apps/store-server/tsconfig.json new file mode 100644 index 0000000..bf5a36d --- /dev/null +++ b/apps/store-server/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"] +} diff --git a/docs/superpowers/plans/2026-07-10-hq2-reminders.md b/docs/superpowers/plans/2026-07-10-hq2-reminders.md new file mode 100644 index 0000000..a793f3f --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-hq2-reminders.md @@ -0,0 +1,968 @@ +# HQ-2 — Reminders, Recurring, AMC, Interactions & Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task is a full TDD loop: write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → commit. No task depends on code a later task writes. + +**Goal:** Ship HQ-2 of [docs/14-SPEC-HQ-CONSOLE.md](../../14-SPEC-HQ-CONSOLE.md) on top of the HQ-1 `apps/hq` + `apps/hq-web` codebase: recurring plans that generate invoices on schedule, a reminder engine (auto-send + manual queue, idempotent across crashes/catch-up, bounce-aware), AMC contracts with derived paid-state, an interaction/visit/call/training log with follow-ups, and a dashboard money-view that becomes the default page after login. All grounded in the real HQ-1 interfaces (`createDraft`/`issueDocument`, `sendDocumentEmail`, `getAccount`/`TokenDeadError`, `priceOn`, `writeAudit`, `formatINR`). + +**Architecture:** Extend the store-server-shaped HQ pattern exactly as HQ-1 established it — express + better-sqlite3 behind plain-function repositories (`repos-*.ts`), pure logic reused from `@sims/domain` (`formatINR`, `fromRupees`, `uuidv7`, `fyOf`) and the HQ document engine. **New tables are added to the `SCHEMA` string in `db.ts` via `CREATE TABLE IF NOT EXISTS`** (no ALTER needed — the table simply appears on next `openDb`); **the one new column** (`email_log.bounced`) is added through the existing guarded `migrate(db)` PRAGMA/ALTER path. The scheduler is a **pure function** `runDailyScan(db, deps, today)` with an injected clock (never `Date.now()` inside logic) so tests are deterministic; it is wired into `server.ts` to run on boot and every 6h. Idempotency lives entirely in one UNIQUE constraint — `reminder(rule_kind, subject_id, due_period)` — reached via `INSERT OR IGNORE`, which is what makes catch-up after downtime safe with zero extra bookkeeping. + +**Tech Stack:** unchanged from HQ-1 — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (PDF, injected into scheduler/reminder deps so tests never launch Chromium), vitest, React 19 + Vite 6 + react-router 7, `@sims/ui`. No new dependencies (bounce polling is `fetch`-based, same as `gmail.ts`). + +## Global Constraints + +Everything from the HQ-1 plan still holds; the load-bearing ones for HQ-2, plus the extensions: + +- All money is **integer paise** (`Paise` from `@sims/domain`); never floats. Amounts render with `formatINR`. +- All ids are **UUIDv7** via `uuidv7()` from `@sims/domain` (ids sort by creation time — reused for `ORDER BY id`). +- **Every mutation writes an `audit_log` row** via `writeAudit(db, userId, action, entity, entityId, before?, after?)`. Scheduler-originated mutations use `userId = 'system'`. +- SQL stays **portable** (D12 guardrail): standard SQL; SQLite/Postgres-only dialect (`INSERT OR IGNORE`, `ON CONFLICT`) is commented as a portability quirk, matching `series.ts`/`seed.ts`. +- **New tables → `SCHEMA` const in `db.ts` with `CREATE TABLE IF NOT EXISTS`.** New tables need no migration entry. **New columns → guarded `migrate(db)`** (PRAGMA `table_info` check then `ALTER TABLE … ADD COLUMN`), exactly like the existing `module.quote_content` block. +- HQ prices are **GST-exclusive** (`priceIncludesTax: false`); recurring/AMC invoices flow through the same `createDraft` → `computeBill` path, so GST (CGST/SGST vs IGST) is computed identically to HQ-1. +- Issued invoices are **never edited or deleted**. A recurring invoice, once generated and issued, exists permanently; a failed *send* never deletes or regenerates it. +- **Scheduler determinism:** `runDailyScan(db, deps, today: string)` takes the business date as an ISO `YYYY-MM-DD` param. Wall-clock timestamps (`created_at`, `sent_at`) come from `deps.now?.() ?? new Date().toISOString()` so tests pin them. +- **Idempotency / at-most-once:** the reminder row is created once per `(rule_kind, subject_id, due_period)` via `INSERT OR IGNORE`. Recurring generation, `next_run` advance, and the reminder insert are one better-sqlite3 **transaction** (atomic); the async email send happens *after* commit and only ever transitions an already-committed reminder from `queued`→`sent`/`failed`. A crash therefore never double-generates and never auto-double-sends. +- **Failed send never advances the schedule:** for recurring, `next_run` advances on *generation* (the invoice existing is the spec's condition), not on send — a failed send parks the reminder in the manual queue with its `error` visible; the invoice still exists and is re-sent from the queue, never regenerated (the UNIQUE key forbids regeneration). +- **"sent" ≠ "delivered":** `email_log.status='sent'` means Gmail accepted the message. Delivery failures surface later via `pollBounces` → `email_log.bounced=1` + an `email_bounced` dashboard reminder. +- Server port **5182**. Secrets: `HQ_SECRET_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (already used by HQ-1). Never log tokens. +- Tests: vitest, colocated under `apps/hq/test/` (already in the vitest `include`). Puppeteer is injected as `deps.renderPdf`, and `fetch` as `deps.gmail.f`, so every HQ-2 test runs offline and Chromium-free. +- **Per-app typecheck is part of the root gate:** `npm run typecheck` runs `tsc -p tsconfig.json && npm run typecheck --workspaces --if-present`. Both `apps/hq` and `apps/hq-web` must typecheck clean at every commit. +- Commit after every task. Each commit message ends with the trailer — use the two-`-m` form: + ```bash + git commit -m "feat(hq): " -m "Co-Authored-By: Claude Fable 5 " + ``` + +## Scope (locked — do not reopen) + +**In:** recurring_plan (generation on `next_run`), reminder engine (auto + manual queue, idempotent, bounce-aware), amc_contract (+ renewal invoice, derived paid), interaction + interaction_type (+ follow-ups), dashboard money-view (default route) with the manual reminder queue, bounce detection (Gmail `readonly` scope + `pollBounces`), and the routes/pages for all of it. + +**Out (do not plan):** SMS pack-balance tracking (founder decision pending); AWS cost pull + cross-client chart (HQ-3); receipt PDFs. Per-rule auto/manual toggles for the *dunning* rules (overdue/renewal/amc) are intentionally not built — the only **auto** path is `recurring_plan.policy='auto'`; dunning/renewal/amc/follow-up reminders always land in the **manual queue** (this satisfies the founder's "both modes exist": auto = recurring, manual = everything else, and recurring plans carry their own `auto`/`manual` policy). State this in the recurring task, don't re-litigate it. + +## File Structure (HQ-2 additions) + +``` +apps/hq/ + src/db.ts — MODIFY: 5 new CREATE TABLE IF NOT EXISTS + email_log.bounced in migrate() + src/seed.ts — MODIFY: seed interaction_type rows, AMC module, reminder.* settings + src/repos-recurring.ts — NEW: recurring_plan CRUD + src/repos-amc.ts — NEW: amc_contract CRUD + renewal invoice + derived paid + src/repos-interactions.ts — NEW: interaction_type + interaction CRUD + follow-ups + src/reminder-templates.ts — NEW: subject+body text per rule_kind (formatINR, Indian business English) + src/repos-reminders.ts — NEW: reminder upsert/list/dismiss + sendReminder orchestration + settings helpers + src/gmail.ts — MODIFY: add sendReminderEmail (attachment-optional, no document coupling) + src/scheduler.ts — NEW: runDailyScan (pure), startScheduler (setInterval, unref) + src/bounces.ts — NEW: pollBounces + markBounce (fetch-based, injectable Fetcher) + src/repos-payments.ts — MODIFY: export outstandingPaise(db, docId) + src/repos-dashboard.ts — NEW: dashboardView aggregation + src/api.ts — MODIFY: recurring / amc / interactions / reminders / dashboard routes + src/server.ts — MODIFY: startServer(port, { scheduler }) wiring + scripts/gmail-connect.ts — MODIFY: SCOPE gains gmail.readonly (for bounce polling) + test/*.test.ts — NEW: one file per task +apps/hq-web/ + src/api.ts — MODIFY: types + typed calls for recurring/amc/interactions/reminders/dashboard + src/Layout.tsx — MODIFY: nav gains Dashboard (default) + Clients + src/main.tsx — MODIFY: '/' → Dashboard, '/clients' → Clients + src/pages/Dashboard.tsx — NEW: money view + manual reminder queue + src/pages/ClientDetail.tsx — MODIFY: AMC, Interactions, Recurring sections +``` + +--- + +### Task 1: HQ-2 schema — new tables, `email_log.bounced`, seeds + +**Files:** +- Modify: `apps/hq/src/db.ts` (append 5 tables to `SCHEMA`; add the `bounced` column to `migrate`), `apps/hq/src/seed.ts` (seed lookups + settings + AMC module) +- Test: `apps/hq/test/hq2-schema.test.ts` + +**Interfaces:** +- Produces (schema only — no new TS exports): tables `recurring_plan`, `amc_contract`, `interaction`, `interaction_type`, `reminder`; column `email_log.bounced INTEGER NOT NULL DEFAULT 0`. `seedIfEmpty` additionally seeds the 7 `interaction_type` rows, one `module` row `code='AMC'`, and settings `reminders.overdue_days='7'`, `reminders.renewal_days='15'`. +- Note on `reminder.doc_id`: the locked column list is extended with **one** nullable column, `doc_id TEXT`, carrying the document a reminder concerns (the overdue invoice, the generated recurring invoice, an AMC renewal invoice). It is required so the manual-queue **Send** action and the recurring auto-send can attach/reference the right PDF; `subject_id` stays the logical subject (plan/module/contract/interaction/invoice). For `invoice_overdue`, `subject_id == doc_id`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/hq2-schema.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' + +describe('hq2 schema', () => { + it('creates every HQ-2 table', () => { + const db = openDb(':memory:') + const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map((r) => r.name) + for (const t of ['recurring_plan', 'amc_contract', 'interaction', 'interaction_type', 'reminder']) + expect(names, `missing table ${t}`).toContain(t) + }) + it('adds the bounced column to email_log', () => { + const db = openDb(':memory:') + const cols = (db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]).map((c) => c.name) + expect(cols).toContain('bounced') + }) + it('enforces the reminder idempotency key', () => { + const db = openDb(':memory:') + const ins = db.prepare( + `INSERT OR IGNORE INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) + VALUES (?, 'invoice_overdue', 's1', '2026-07', 'c1', 'queued', 'manual', '2026-07-10T00:00:00Z')`, + ) + expect(ins.run('r1').changes).toBe(1) + expect(ins.run('r2').changes).toBe(0) // same (rule_kind, subject_id, due_period) → ignored + }) + it('seeds interaction types, the AMC module and reminder settings', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const types = (db.prepare(`SELECT code FROM interaction_type`).all() as { code: string }[]).map((r) => r.code) + expect(types).toEqual(expect.arrayContaining(['call', 'site_visit', 'training', 'complaint'])) + expect(db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get()).toMatchObject({ n: 1 }) + const s = (db.prepare(`SELECT value FROM setting WHERE key='reminders.overdue_days'`).get() as { value: string } | undefined) + expect(s?.value).toBe('7') + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/hq2-schema.test.ts` → FAIL (tables/column/seeds absent). + +- [ ] **Step 3: Implement.** Append to the `SCHEMA` template string in `apps/hq/src/db.ts` (before the closing backtick): + +```sql +CREATE TABLE IF NOT EXISTS recurring_plan ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, + client_module_id TEXT, -- source of the invoice line's module; required for generation in HQ-2 + cadence TEXT NOT NULL CHECK (cadence IN ('monthly','yearly')), + amount_paise INTEGER, -- NULL = price from module_price_book at generation time + next_run TEXT NOT NULL, + policy TEXT NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')), + active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS amc_contract ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, coverage TEXT NOT NULL DEFAULT '', + period_from TEXT NOT NULL, period_to TEXT NOT NULL, amount_paise INTEGER NOT NULL, + renewal_reminder_days INTEGER NOT NULL DEFAULT 30, + legacy_paid INTEGER, -- manual flag for imported contracts only; else paid derives from invoice_doc_id + invoice_doc_id TEXT, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS interaction_type ( + code TEXT PRIMARY KEY, label TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS interaction ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, type_code TEXT NOT NULL, + on_date TEXT NOT NULL, staff_id TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', + outcome TEXT CHECK (outcome IN ('positive','neutral','negative')), + follow_up_on TEXT, created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS reminder ( + id TEXT PRIMARY KEY, + rule_kind TEXT NOT NULL CHECK (rule_kind IN + ('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced')), + subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL, + doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice); NULL otherwise + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')), + policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')), + error TEXT, created_at TEXT NOT NULL, sent_at TEXT, + UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period +); +``` + +In `migrate(db)` add the guarded column (same shape as the existing `quote_content` block): + +```ts + const emailCols = db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[] + if (!emailCols.some((c) => c.name === 'bounced')) { + db.exec(`ALTER TABLE email_log ADD COLUMN bounced INTEGER NOT NULL DEFAULT 0`) + } +``` + +In `apps/hq/src/seed.ts` — import `createModule` and extend `seedIfEmpty` (after the existing GST18 block): + +```ts +import { createModule } from './repos-modules' +// ... +const INTERACTION_TYPES: [string, string][] = [ + ['call', 'Call'], ['site_visit', 'Site visit'], ['training', 'Training'], + ['courtesy_meeting', 'Courtesy meeting'], ['committee_meeting', 'Committee meeting'], + ['demo', 'Demo'], ['complaint', 'Complaint'], +] +const REMINDER_SETTINGS: Record = { + 'reminders.overdue_days': '7', + 'reminders.renewal_days': '15', +} +``` + +and inside `seedIfEmpty`, after the tax-class seed: + +```ts + const typeInsert = db.prepare( + `INSERT INTO interaction_type (code, label) VALUES (?, ?) ON CONFLICT (code) DO NOTHING`, + ) + let seededTypes = 0 + for (const [code, label] of INTERACTION_TYPES) seededTypes += typeInsert.run(code, label).changes + if (seededTypes > 0) writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes }) + + let seededReminderSettings = 0 + for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes + if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS) + + const amc = db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get() as { n: number } + if (amc.n === 0) { + // The AMC/renewal invoice line hangs off a real module so it flows through + // createDraft → computeBill unchanged. SAC 998719 (maintenance/repair) — CA to confirm. + createModule(db, 'system', { + code: 'AMC', name: 'Annual Maintenance Contract', sac: '998719', + allowedKinds: ['yearly', 'one_time'], + }) + } +``` + +(`insert` is the existing `INSERT … ON CONFLICT (key) DO NOTHING` setting statement already defined in `seedIfEmpty`.) + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/hq2-schema.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/db.ts apps/hq/src/seed.ts apps/hq/test/hq2-schema.test.ts +git commit -m "feat(hq): hq-2 schema — recurring/amc/interaction/reminder tables + bounce column + seeds" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Recurring plans — repo + owner-gated routes + +**Files:** +- Create: `apps/hq/src/repos-recurring.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/recurring.test.ts` + +**Interfaces:** +- Produces: + - `RecurringPlan = { id: string; clientId: string; clientModuleId: string | null; cadence: 'monthly'|'yearly'; amountPaise: number | null; nextRun: string; policy: 'auto'|'manual'; active: boolean }` + - `createRecurringPlan(db, userId, input: { clientId: string; clientModuleId: string; cadence: 'monthly'|'yearly'; amountPaise?: number; nextRun: string; policy?: 'auto'|'manual' }): RecurringPlan` — validates the client and client_module exist and belong together; validates that a price is resolvable (either `amountPaise` given, or the client_module's module has a `priceOn` for the cadence's kind on `nextRun`). **`clientModuleId` is required in HQ-2** (it is the module for the invoice line; the schema keeps the column nullable for future client-level plans). + - `getRecurringPlan(db, id): RecurringPlan | null`; `listRecurringPlans(db, clientId?): RecurringPlan[]`. + - `updateRecurringPlan(db, userId, id, patch: { cadence?; amountPaise?: number | null; nextRun?; policy?; active? }): RecurringPlan`. + - `deactivateRecurringPlan(db, userId, id): RecurringPlan` (sets `active=0`). + - `CADENCE_KIND: Record<'monthly'|'yearly', Kind> = { monthly: 'monthly', yearly: 'yearly' }` (exported — reused by the scheduler). +- Routes (create/update/deactivate are **owner-gated** — money): `GET /api/recurring?clientId=`, `POST /api/clients/:id/recurring` (owner), `PATCH /api/recurring/:id` (owner), `POST /api/recurring/:id/deactivate` (owner). + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/recurring.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient } from '../src/repos-clients' +import { assignModule, createModule, setPrice } from '../src/repos-modules' +import { + createRecurringPlan, listRecurringPlans, updateRecurringPlan, deactivateRecurringPlan, +} from '../src/repos-recurring' + +function setup() { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud Hosting', allowedKinds: ['monthly', 'yearly'] }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-04-01' }) + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' }) + return { db, c, m, cm } +} + +describe('recurring plans', () => { + it('creates a plan, lists it, updates and deactivates with audit', () => { + const { db, c, cm } = setup() + const p = createRecurringPlan(db, 'u1', { + clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', policy: 'auto', + }) + expect(p.policy).toBe('auto') + expect(p.amountPaise).toBeNull() // price resolves from the module at generation + expect(listRecurringPlans(db, c.id)).toHaveLength(1) + const up = updateRecurringPlan(db, 'u1', p.id, { amountPaise: 2_500_00 }) + expect(up.amountPaise).toBe(2_500_00) + const off = deactivateRecurringPlan(db, 'u1', p.id) + expect(off.active).toBe(false) + const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='recurring_plan'`).all() + expect(audits.length).toBe(3) // create + update + deactivate + }) + it('rejects a plan whose module has no price and no explicit amount', () => { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'X', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] }) + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + expect(() => createRecurringPlan(db, 'u1', { + clientId: c.id, clientModuleId: cm.id, cadence: 'yearly', nextRun: '2026-08-01', + })).toThrow(/price/i) + }) + it('rejects a client_module that belongs to another client', () => { + const { db, cm } = setup() + const other = createClient(db, 'u1', { name: 'Other', stateCode: '32' }) + expect(() => createRecurringPlan(db, 'u1', { + clientId: other.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', amountPaise: 100_00, + })).toThrow(/client/i) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/recurring.test.ts` → FAIL (module not found). + +- [ ] **Step 3: Implement `apps/hq/src/repos-recurring.ts`** + +```ts +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' +import { getClient } from './repos-clients' +import { getClientModule, priceOn, type Kind } from './repos-modules' + +/** Recurring billing plans — plain functions over the handle (D12 portable-repo pattern). */ + +export type Cadence = 'monthly' | 'yearly' +export const CADENCE_KIND: Record = { monthly: 'monthly', yearly: 'yearly' } + +export interface RecurringPlan { + id: string; clientId: string; clientModuleId: string | null; cadence: Cadence + amountPaise: number | null; nextRun: string; policy: 'auto' | 'manual'; active: boolean +} + +interface RecurringRow { + id: string; client_id: string; client_module_id: string | null; cadence: string + amount_paise: number | null; next_run: string; policy: string; active: number +} + +function toPlan(r: RecurringRow): RecurringPlan { + return { + id: r.id, clientId: r.client_id, clientModuleId: r.client_module_id, + cadence: r.cadence as Cadence, amountPaise: r.amount_paise, nextRun: r.next_run, + policy: r.policy as 'auto' | 'manual', active: r.active === 1, + } +} + +export function getRecurringPlan(db: DB, id: string): RecurringPlan | null { + const row = db.prepare(`SELECT * FROM recurring_plan WHERE id=?`).get(id) as RecurringRow | undefined + return row === undefined ? null : toPlan(row) +} + +export function listRecurringPlans(db: DB, clientId?: string): RecurringPlan[] { + const rows = clientId === undefined + ? db.prepare(`SELECT * FROM recurring_plan ORDER BY id DESC`).all() as RecurringRow[] + : db.prepare(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`).all(clientId) as RecurringRow[] + return rows.map(toPlan) +} + +export interface CreateRecurringInput { + clientId: string; clientModuleId: string; cadence: Cadence + amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual' +} + +export function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): RecurringPlan { + if (getClient(db, input.clientId) === null) throw new Error('Client not found') + const cm = getClientModule(db, input.clientModuleId) + if (cm === null) throw new Error('Client module not found') + if (cm.clientId !== input.clientId) throw new Error('Client module belongs to another client') + if (input.amountPaise !== undefined && (!Number.isInteger(input.amountPaise) || input.amountPaise < 0)) { + throw new Error('amountPaise must be a non-negative integer (paise)') + } + // A plan must be priceable at generation time: explicit amount, or a resolvable module price. + if (input.amountPaise === undefined) { + const kind = CADENCE_KIND[input.cadence] + if (priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun) === null) { + throw new Error(`No ${kind} price for the module on ${input.nextRun}; set an amount or add a price`) + } + } + const id = uuidv7() + db.prepare( + `INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)`, + ).run( + id, input.clientId, input.clientModuleId, input.cadence, + input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual', + ) + const plan = getRecurringPlan(db, id)! + writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan) + return plan +} + +export interface RecurringPatch { + cadence?: Cadence; amountPaise?: number | null; nextRun?: string + policy?: 'auto' | 'manual'; active?: boolean +} + +export function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): RecurringPlan { + const before = getRecurringPlan(db, id) + if (before === null) throw new Error('Recurring plan not found') + const sets: string[] = [] + const args: unknown[] = [] + if (patch.cadence !== undefined) { sets.push('cadence=?'); args.push(patch.cadence) } + if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) } + if (patch.nextRun !== undefined) { sets.push('next_run=?'); args.push(patch.nextRun) } + if (patch.policy !== undefined) { sets.push('policy=?'); args.push(patch.policy) } + if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } + if (sets.length > 0) { + args.push(id) + db.prepare(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getRecurringPlan(db, id)! + writeAudit(db, userId, 'update', 'recurring_plan', id, before, after) + return after +} + +export function deactivateRecurringPlan(db: DB, userId: string, id: string): RecurringPlan { + return updateRecurringPlan(db, userId, id, { active: false }) +} +``` + +Wire routes in `apps/hq/src/api.ts` — add the import and the block (owner gate on writes, matching the modules block): + +```ts +import { + createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, + updateRecurringPlan, type CreateRecurringInput, type RecurringPatch, +} from './repos-recurring' +``` + +```ts + // ---------- recurring plans ---------- + r.get('/recurring', requireAuth, (req, res) => { + const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined + res.json({ ok: true, plans: listRecurringPlans(db, clientId) }) + }) + r.post('/clients/:id/recurring', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + try { + const plan = createRecurringPlan(db, staffId(res), { + ...(req.body as Omit), clientId: id, + }) + res.json({ ok: true, plan }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.patch('/recurring/:id', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return } + try { + res.json({ ok: true, plan: updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/recurring/:id/deactivate', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return } + res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) }) + }) +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/recurring.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-recurring.ts apps/hq/src/api.ts apps/hq/test/recurring.test.ts +git commit -m "feat(hq): recurring plan repo and owner-gated routes" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: AMC contracts — repo, renewal invoice, derived paid state, routes + +**Files:** +- Create: `apps/hq/src/repos-amc.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/amc.test.ts` + +**Interfaces:** +- Consumes: `createDraft`, `issueDocument`, `getDocument`; `outstandingPaise` (added in Task 7 — until then AMC paid-state uses the local `outstandingOf` helper defined here that mirrors `repos-payments`; **when Task 7 lands, switch `amcPaidStatus` to import `outstandingPaise`**). To keep Task 3 self-contained, this task defines its own tiny outstanding read; Task 7 does not require editing Task 3. +- Produces: + - `AmcContract = { id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string; amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null; invoiceDocId: string | null; active: boolean }` + - `createAmc(db, userId, input: { clientId; coverage?; periodFrom; periodTo; amountPaise; renewalReminderDays?; legacyPaid?: boolean }): AmcContract` + - `getAmc(db, id): AmcContract | null`; `listAmc(db, clientId): AmcContract[]` + - `updateAmc(db, userId, id, patch): AmcContract`; `deactivateAmc(db, userId, id): AmcContract` + - `generateAmcRenewalInvoice(db, userId, amcId): Doc` — builds one INVOICE line via the seeded `AMC` module (`description = coverage`, `qty 1`, `kind 'yearly'`, `unitPricePaise = amc.amountPaise`), `createDraft` → `issueDocument`, then sets `amc_contract.invoice_doc_id`; throws if an invoice is already linked and unpaid. + - `amcPaidStatus(db, amc): 'paid' | 'unpaid' | 'unbilled'` — `legacyPaid === true` → `'paid'`; else no `invoiceDocId` → `'unbilled'`; else outstanding on the linked invoice `<= 0` → `'paid'` else `'unpaid'`. +- Routes: `GET /api/clients/:id/amc`, `POST /api/clients/:id/amc` (owner), `PATCH /api/amc/:id` (owner), `POST /api/amc/:id/deactivate` (owner), `POST /api/amc/:id/renewal-invoice` (owner). + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/amc.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { recordPayment } from '../src/repos-payments' +import { + createAmc, listAmc, generateAmcRenewalInvoice, amcPaidStatus, getAmc, +} from '../src/repos-amc' + +function setup() { + const db = openDb(':memory:'); seedIfEmpty(db) // seeds company.state_code, GST18, AMC module + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + return { db, c } +} + +describe('amc contracts', () => { + it('creates, generates a renewal invoice, and derives paid from settlement', () => { + const { db, c } = setup() + const amc = createAmc(db, 'u1', { + clientId: c.id, coverage: 'On-site support', periodFrom: '2026-04-01', periodTo: '2027-03-31', + amountPaise: 20_000_00, + }) + expect(listAmc(db, c.id)).toHaveLength(1) + expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unbilled') + const inv = generateAmcRenewalInvoice(db, 'u1', amc.id) + expect(inv.docType).toBe('INVOICE') + expect(inv.payablePaise).toBe(23_600_00) // 20,000 + 18% GST intra-state + expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unpaid') + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 23_600_00 }) + expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid') + }) + it('honours the legacy_paid flag for imported contracts without an invoice', () => { + const { db, c } = setup() + const amc = createAmc(db, 'u1', { + clientId: c.id, periodFrom: '2025-04-01', periodTo: '2026-03-31', amountPaise: 10_000_00, legacyPaid: true, + }) + expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid') + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/amc.test.ts` → FAIL. + +- [ ] **Step 3: Implement `apps/hq/src/repos-amc.ts`** + +```ts +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' +import { getClient } from './repos-clients' +import { getModule } from './repos-modules' +import { createDraft, getDocument, issueDocument, type Doc } from './repos-documents' + +/** AMC contracts — plain functions over the handle (D12 pattern). Paid state is + * derived from the linked invoice's settlement; legacy_paid is a manual flag for + * imported contracts only, so there is one source of truth and no drift. */ + +export interface AmcContract { + id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string + amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null + invoiceDocId: string | null; active: boolean +} + +interface AmcRow { + id: string; client_id: string; coverage: string; period_from: string; period_to: string + amount_paise: number; renewal_reminder_days: number; legacy_paid: number | null + invoice_doc_id: string | null; active: number +} + +function toAmc(r: AmcRow): AmcContract { + return { + id: r.id, clientId: r.client_id, coverage: r.coverage, + periodFrom: r.period_from, periodTo: r.period_to, amountPaise: r.amount_paise, + renewalReminderDays: r.renewal_reminder_days, + legacyPaid: r.legacy_paid === null ? null : r.legacy_paid === 1, + invoiceDocId: r.invoice_doc_id, active: r.active === 1, + } +} + +export function getAmc(db: DB, id: string): AmcContract | null { + const row = db.prepare(`SELECT * FROM amc_contract WHERE id=?`).get(id) as AmcRow | undefined + return row === undefined ? null : toAmc(row) +} + +export function listAmc(db: DB, clientId: string): AmcContract[] { + const rows = db.prepare(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`).all(clientId) as AmcRow[] + return rows.map(toAmc) +} + +export interface CreateAmcInput { + clientId: string; coverage?: string; periodFrom: string; periodTo: string + amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean +} + +export function createAmc(db: DB, userId: string, input: CreateAmcInput): AmcContract { + if (getClient(db, input.clientId) === null) throw new Error('Client not found') + if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) { + throw new Error('amountPaise must be a non-negative integer (paise)') + } + const id = uuidv7() + db.prepare( + `INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise, + renewal_reminder_days, legacy_paid, invoice_doc_id, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`, + ).run( + id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise, + input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0, + ) + const amc = getAmc(db, id)! + writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc) + return amc +} + +export interface AmcPatch { + coverage?: string; periodFrom?: string; periodTo?: string; amountPaise?: number + renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean +} + +export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): AmcContract { + const before = getAmc(db, id) + if (before === null) throw new Error('AMC contract not found') + const sets: string[] = [] + const args: unknown[] = [] + if (patch.coverage !== undefined) { sets.push('coverage=?'); args.push(patch.coverage) } + if (patch.periodFrom !== undefined) { sets.push('period_from=?'); args.push(patch.periodFrom) } + if (patch.periodTo !== undefined) { sets.push('period_to=?'); args.push(patch.periodTo) } + if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) } + if (patch.renewalReminderDays !== undefined) { sets.push('renewal_reminder_days=?'); args.push(patch.renewalReminderDays) } + if (patch.legacyPaid !== undefined) { sets.push('legacy_paid=?'); args.push(patch.legacyPaid === null ? null : patch.legacyPaid ? 1 : 0) } + if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } + if (sets.length > 0) { + args.push(id) + db.prepare(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getAmc(db, id)! + writeAudit(db, userId, 'update', 'amc_contract', id, before, after) + return after +} + +export function deactivateAmc(db: DB, userId: string, id: string): AmcContract { + return updateAmc(db, userId, id, { active: false }) +} + +/** Renewal invoice for an AMC: one line on the seeded AMC module, priced at the + * contract amount, issued and linked back via invoice_doc_id. */ +export function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Doc { + const amc = getAmc(db, amcId) + if (amc === null) throw new Error('AMC contract not found') + if (amc.invoiceDocId !== null) { + const existing = getDocument(db, amc.invoiceDocId) + if (existing !== null && existing.status !== 'cancelled' && outstandingOf(db, existing.id) > 0) { + throw new Error(`AMC already has an unpaid renewal invoice ${existing.docNo}`) + } + } + const amcModule = db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string } | undefined + if (amcModule === undefined) throw new Error('AMC module missing — run the seed') + return db.transaction(() => { + const draft = createDraft(db, userId, { + docType: 'INVOICE', clientId: amc.clientId, + lines: [{ + moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance', + qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise, + }], + }) + const inv = issueDocument(db, userId, draft.id) + db.prepare(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`).run(inv.id, amcId) + writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id }) + return inv + })() +} + +/** Local outstanding read (payable − allocations − non-cancelled credit notes) — + * mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can + * switch to it then, but does not depend on Task 7 to compile. */ +function outstandingOf(db: DB, docId: string): number { + const doc = getDocument(db, docId) + if (doc === null) return 0 + const alloc = (db.prepare( + `SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`, + ).get(docId) as { total: number }).total + const credited = (db.prepare( + `SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document + WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`, + ).get(docId) as { total: number }).total + return doc.payablePaise - alloc - credited +} + +export function amcPaidStatus(db: DB, amc: AmcContract): 'paid' | 'unpaid' | 'unbilled' { + if (amc.legacyPaid === true) return 'paid' + if (amc.invoiceDocId === null) return 'unbilled' + return outstandingOf(db, amc.invoiceDocId) <= 0 ? 'paid' : 'unpaid' +} +``` + +Wire routes in `api.ts` (import + block; writes owner-gated): + +```ts +import { + amcPaidStatus, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, listAmc, + updateAmc, type AmcPatch, type CreateAmcInput, +} from './repos-amc' +``` + +```ts + // ---------- amc ---------- + r.get('/clients/:id/amc', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + const contracts = listAmc(db, id).map((a) => ({ ...a, paidStatus: amcPaidStatus(db, a) })) + res.json({ ok: true, contracts }) + }) + r.post('/clients/:id/amc', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + try { + const amc = createAmc(db, staffId(res), { ...(req.body as Omit), clientId: id }) + res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.patch('/amc/:id', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + try { + const amc = updateAmc(db, staffId(res), id, req.body as AmcPatch) + res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/amc/:id/deactivate', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + const amc = deactivateAmc(db, staffId(res), id) + res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + }) + r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + try { + res.json({ ok: true, document: generateAmcRenewalInvoice(db, staffId(res), id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/amc.test.ts` → PASS (verify the golden `23_600_00` paisa). `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-amc.ts apps/hq/src/api.ts apps/hq/test/amc.test.ts +git commit -m "feat(hq): amc contracts with renewal invoice and derived paid state" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Interactions — types, log, follow-ups, routes + +**Files:** +- Create: `apps/hq/src/repos-interactions.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/interactions.test.ts` + +**Interfaces:** +- Produces: + - `InteractionType = { code: string; label: string }`; `listInteractionTypes(db): InteractionType[]`; `createInteractionType(db, userId, { code, label }): InteractionType` (owner-only at the route; the lookup is user-extensible). + - `Interaction = { id: string; clientId: string; typeCode: string; onDate: string; staffId: string; notes: string; outcome: 'positive'|'neutral'|'negative' | null; followUpOn: string | null; createdAt: string }` + - `createInteraction(db, userId, input: { clientId; typeCode; onDate; notes?; outcome?; followUpOn? }): Interaction` — validates the client exists and `typeCode` is a known type; `staffId` is the acting user. + - `listInteractions(db, clientId): Interaction[]` (newest first — the timeline). + - `updateInteraction(db, userId, id, patch: { notes?; outcome?; followUpOn? }): Interaction`. + - `listOpenFollowUps(db, onOrBefore: string): (Interaction & { clientName: string })[]` — interactions whose `follow_up_on <= onOrBefore`, newest due first (feeds the dashboard + the `follow_up` reminder rule). +- Routes: `GET /api/interaction-types`, `POST /api/interaction-types` (owner), `GET /api/clients/:id/interactions`, `POST /api/clients/:id/interactions`, `PATCH /api/interactions/:id`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/interactions.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { + createInteraction, listInteractions, listInteractionTypes, listOpenFollowUps, updateInteraction, +} from '../src/repos-interactions' + +describe('interactions', () => { + it('logs a typed interaction, updates outcome, and surfaces due follow-ups', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + expect(listInteractionTypes(db).length).toBeGreaterThanOrEqual(7) + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const i = createInteraction(db, 'u1', { + clientId: c.id, typeCode: 'site_visit', onDate: '2026-07-01', + notes: 'Installed POS; owner wants training', followUpOn: '2026-07-08', + }) + expect(listInteractions(db, c.id)).toHaveLength(1) + const up = updateInteraction(db, 'u1', i.id, { outcome: 'positive' }) + expect(up.outcome).toBe('positive') + expect(listOpenFollowUps(db, '2026-07-10').map((f) => f.id)).toContain(i.id) + expect(listOpenFollowUps(db, '2026-07-05')).toHaveLength(0) // not yet due + }) + it('rejects an unknown interaction type', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const c = createClient(db, 'u1', { name: 'X', stateCode: '32' }) + expect(() => createInteraction(db, 'u1', { clientId: c.id, typeCode: 'telepathy', onDate: '2026-07-01' })) + .toThrow(/type/i) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/interactions.test.ts` → FAIL. + +- [ ] **Step 3: Implement `apps/hq/src/repos-interactions.ts`** + +```ts +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' +import { getClient } from './repos-clients' + +/** Interaction log — the "memories into the system" table (D12 plain-repo pattern). */ + +export interface InteractionType { code: string; label: string } + +export function listInteractionTypes(db: DB): InteractionType[] { + return db.prepare(`SELECT code, label FROM interaction_type ORDER BY label`).all() as InteractionType[] +} + +export function createInteractionType(db: DB, userId: string, input: { code: string; label: string }): InteractionType { + if (input.code.trim() === '' || input.label.trim() === '') throw new Error('code and label are required') + db.prepare(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`).run(input.code.trim(), input.label.trim()) + writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input) + return { code: input.code.trim(), label: input.label.trim() } +} + +export type Outcome = 'positive' | 'neutral' | 'negative' + +export interface Interaction { + id: string; clientId: string; typeCode: string; onDate: string; staffId: string + notes: string; outcome: Outcome | null; followUpOn: string | null; createdAt: string +} + +interface InteractionRow { + id: string; client_id: string; type_code: string; on_date: string; staff_id: string + notes: string; outcome: string | null; follow_up_on: string | null; created_at: string +} + +function toInteraction(r: InteractionRow): Interaction { + return { + id: r.id, clientId: r.client_id, typeCode: r.type_code, onDate: r.on_date, staffId: r.staff_id, + notes: r.notes, outcome: r.outcome as Outcome | null, followUpOn: r.follow_up_on, createdAt: r.created_at, + } +} + +export function getInteraction(db: DB, id: string): Interaction | null { + const row = db.prepare(`SELECT * FROM interaction WHERE id=?`).get(id) as InteractionRow | undefined + return row === undefined ? null : toInteraction(row) +} + +export function listInteractions(db: DB, clientId: string): Interaction[] { + const rows = db.prepare( + `SELECT * FROM interaction WHERE client_id=? ORDER BY on_date DESC, id DESC`, + ).all(clientId) as InteractionRow[] + return rows.map(toInteraction) +} + +export interface CreateInteractionInput { + clientId: string; typeCode: string; onDate: string + notes?: string; outcome?: Outcome; followUpOn?: string | null +} + +const OUTCOMES: Outcome[] = ['positive', 'neutral', 'negative'] + +export function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Interaction { + if (getClient(db, input.clientId) === null) throw new Error('Client not found') + const type = db.prepare(`SELECT code FROM interaction_type WHERE code=?`).get(input.typeCode) + if (type === undefined) throw new Error(`Unknown interaction type: ${input.typeCode}`) + if (input.outcome !== undefined && !OUTCOMES.includes(input.outcome)) throw new Error(`Unknown outcome: ${input.outcome}`) + const id = uuidv7() + db.prepare( + `INSERT INTO interaction (id, client_id, type_code, on_date, staff_id, notes, outcome, follow_up_on, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, input.clientId, input.typeCode, input.onDate, userId, input.notes ?? '', + input.outcome ?? null, input.followUpOn ?? null, new Date().toISOString(), + ) + const interaction = getInteraction(db, id)! + writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction) + return interaction +} + +export interface InteractionPatch { notes?: string; outcome?: Outcome | null; followUpOn?: string | null } + +export function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Interaction { + const before = getInteraction(db, id) + if (before === null) throw new Error('Interaction not found') + if (patch.outcome !== undefined && patch.outcome !== null && !OUTCOMES.includes(patch.outcome)) { + throw new Error(`Unknown outcome: ${patch.outcome}`) + } + const sets: string[] = [] + const args: unknown[] = [] + if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) } + if (patch.outcome !== undefined) { sets.push('outcome=?'); args.push(patch.outcome) } + if (patch.followUpOn !== undefined) { sets.push('follow_up_on=?'); args.push(patch.followUpOn) } + if (sets.length > 0) { + args.push(id) + db.prepare(`UPDATE interaction SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getInteraction(db, id)! + writeAudit(db, userId, 'update', 'interaction', id, before, after) + return after +} + +/** Interactions with a follow-up date on or before the given date — the follow-ups queue. */ +export function listOpenFollowUps(db: DB, onOrBefore: string): (Interaction & { clientName: string })[] { + const rows = db.prepare( + `SELECT i.*, c.name AS client_name FROM interaction i JOIN client c ON c.id = i.client_id + WHERE i.follow_up_on IS NOT NULL AND i.follow_up_on <= ? + ORDER BY i.follow_up_on DESC, i.id DESC`, + ).all(onOrBefore) as (InteractionRow & { client_name: string })[] + return rows.map((r) => ({ ...toInteraction(r), clientName: r.client_name })) +} +``` + +Wire routes in `api.ts` (import + block; type-create owner-gated, logging is staff-allowed): + +```ts +import { + createInteraction, createInteractionType, getInteraction, listInteractions, + listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch, +} from './repos-interactions' +``` + +```ts + // ---------- interactions ---------- + r.get('/interaction-types', requireAuth, (_req, res) => { + res.json({ ok: true, types: listInteractionTypes(db) }) + }) + r.post('/interaction-types', requireAuth, requireOwner, (req, res) => { + try { + const type = createInteractionType(db, staffId(res), req.body as { code: string; label: string }) + res.json({ ok: true, type }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/clients/:id/interactions', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + res.json({ ok: true, interactions: listInteractions(db, id) }) + }) + r.post('/clients/:id/interactions', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + try { + const interaction = createInteraction(db, staffId(res), { + ...(req.body as Omit), clientId: id, + }) + res.json({ ok: true, interaction }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.patch('/interactions/:id', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getInteraction(db, id) === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return } + try { + res.json({ ok: true, interaction: updateInteraction(db, staffId(res), id, req.body as InteractionPatch) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/interactions.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-interactions.ts apps/hq/src/api.ts apps/hq/test/interactions.test.ts +git commit -m "feat(hq): interaction log with types, follow-ups and routes" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..86ddbc8 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,11 @@ +{ + "name": "@sims/auth", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@sims/domain": "*" + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000..28dd204 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,4 @@ +export * from './pin' +export * from './lockout' +export * from './permissions' +export * from './session' diff --git a/packages/auth/src/lockout.ts b/packages/auth/src/lockout.ts new file mode 100644 index 0000000..495ac4a --- /dev/null +++ b/packages/auth/src/lockout.ts @@ -0,0 +1,38 @@ +/** + * Brute-force lockout as a pure reducer so POS and back office share the exact + * policy and it is testable without a clock. Attempts are audit-logged by the + * caller (I7: every mutation audit-logged in the same transaction). + */ +export interface LockoutPolicy { + maxFailures: number + lockMs: number +} + +export const DEFAULT_LOCKOUT: LockoutPolicy = { maxFailures: 5, lockMs: 60_000 } + +export interface LockoutState { + failures: number + lockedUntilMs?: number +} + +export const FRESH_LOCKOUT: LockoutState = { failures: 0 } + +export function isLocked(state: LockoutState, nowMs: number): boolean { + return state.lockedUntilMs !== undefined && nowMs < state.lockedUntilMs +} + +export function recordFailure( + state: LockoutState, + nowMs: number, + policy: LockoutPolicy = DEFAULT_LOCKOUT, +): LockoutState { + const failures = state.failures + 1 + if (failures >= policy.maxFailures) { + return { failures, lockedUntilMs: nowMs + policy.lockMs } + } + return { failures } +} + +export function recordSuccess(): LockoutState { + return FRESH_LOCKOUT +} diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts new file mode 100644 index 0000000..ffb0245 --- /dev/null +++ b/packages/auth/src/permissions.ts @@ -0,0 +1,74 @@ +import type { RoleCode, User } from '@sims/domain' + +/** + * Action codes gate everything sensitive. Role→action grants ship as defaults + * below but live as DB rows in production (config-driven principle) so a + * tenant can tighten or loosen without a release. Gate semantics follow + * 09-UX §5.5: role gates hide navigation but in-flow actions raise the + * supervisor-PIN overlay instead of disappearing. + */ +export type ActionCode = + | 'BILL_CREATE' + | 'BILL_VOID' + | 'PRICE_OVERRIDE' + | 'DISCOUNT_OVER_CAP' + | 'DRAWER_NOSALE' + | 'REPRINT_EXCESS' + | 'RETURN_CREATE' + | 'DAY_BEGIN' + | 'DAY_END' + | 'SHIFT_OPEN' + | 'SHIFT_CLOSE' + | 'PURCHASE_ENTRY' + | 'STOCK_ADJUST' + | 'MASTER_EDIT' + | 'SETTINGS_EDIT' + | 'USER_MANAGE' + | 'REPORT_VIEW' + | 'AUDIT_VIEW' + +export type Gate = 'allow' | 'supervisor-pin' | 'deny' + +export const DEFAULT_ROLE_GRANTS: Record = { + owner: [ + 'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE', + 'REPRINT_EXCESS', 'RETURN_CREATE', 'DAY_BEGIN', 'DAY_END', 'SHIFT_OPEN', 'SHIFT_CLOSE', + 'PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'SETTINGS_EDIT', 'USER_MANAGE', + 'REPORT_VIEW', 'AUDIT_VIEW', + ], + manager: [ + 'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE', + 'REPRINT_EXCESS', 'RETURN_CREATE', 'DAY_BEGIN', 'DAY_END', 'SHIFT_OPEN', 'SHIFT_CLOSE', + 'PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'SETTINGS_EDIT', 'REPORT_VIEW', + ], + supervisor: [ + 'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE', + 'REPRINT_EXCESS', 'RETURN_CREATE', 'SHIFT_OPEN', 'SHIFT_CLOSE', 'REPORT_VIEW', + ], + cashier: ['BILL_CREATE', 'RETURN_CREATE', 'SHIFT_OPEN', 'SHIFT_CLOSE'], + purchaser: ['PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'REPORT_VIEW'], + accountant: ['REPORT_VIEW', 'AUDIT_VIEW'], + auditor: ['REPORT_VIEW', 'AUDIT_VIEW'], +} + +export function can( + user: Pick, + action: ActionCode, + grants: Record = DEFAULT_ROLE_GRANTS, +): boolean { + if (!user.active) return false + return user.roles.some((role) => grants[role]?.includes(action)) +} + +/** In-flow gate: allowed directly, escalatable via supervisor PIN, or denied. */ +export function gateFor( + user: Pick, + action: ActionCode, + grants: Record = DEFAULT_ROLE_GRANTS, +): Gate { + if (can(user, action, grants)) return 'allow' + const supervisorCould = (['supervisor', 'manager', 'owner'] as RoleCode[]).some((r) => + grants[r]?.includes(action), + ) + return supervisorCould ? 'supervisor-pin' : 'deny' +} diff --git a/packages/auth/src/pin.ts b/packages/auth/src/pin.ts new file mode 100644 index 0000000..9de52c7 --- /dev/null +++ b/packages/auth/src/pin.ts @@ -0,0 +1,29 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' + +/** + * POS login is name-tile + PIN (09-UX §1: PIN-fast shift open); back office is + * username + password. Both hash through here — scrypt, per-credential salt. + */ +export interface PinHash { + salt: string // hex + hash: string // hex +} + +export function assertUsablePin(pin: string): void { + if (!/^\d{4,6}$/.test(pin)) throw new Error('PIN must be 4–6 digits') + if (/^(\d)\1+$/.test(pin)) throw new Error('PIN must not be a single repeated digit') + if ('0123456789'.includes(pin) || '9876543210'.includes(pin)) { + throw new Error('PIN must not be a simple sequence') + } +} + +export function hashPin(pin: string): PinHash { + const salt = randomBytes(16) + const hash = scryptSync(pin, salt, 32) + return { salt: salt.toString('hex'), hash: hash.toString('hex') } +} + +export function verifyPin(pin: string, stored: PinHash): boolean { + const candidate = scryptSync(pin, Buffer.from(stored.salt, 'hex'), 32) + return timingSafeEqual(candidate, Buffer.from(stored.hash, 'hex')) +} diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts new file mode 100644 index 0000000..7806dd0 --- /dev/null +++ b/packages/auth/src/session.ts @@ -0,0 +1,38 @@ +import type { BusinessDay, IsoDate } from '@sims/domain' +import { isOpen } from '@sims/domain' + +/** + * A POS session binds user + counter + shift + the Day-Begin business date + * (the OG working-day idiom). Billing requires an open day and an open shift. + */ +export interface PosSession { + tenantId: string + storeId: string + counterId: string + userId: string + shiftId: string + businessDate: IsoDate + startedAtWallClock: string +} + +export function openSession(args: { + tenantId: string + storeId: string + counterId: string + userId: string + shiftId: string + day: BusinessDay + nowWallClock: string +}): PosSession { + if (!isOpen(args.day)) throw new Error('Day is closed — run Day Begin before opening a shift') + if (args.day.storeId !== args.storeId) throw new Error('Business day belongs to another store') + return { + tenantId: args.tenantId, + storeId: args.storeId, + counterId: args.counterId, + userId: args.userId, + shiftId: args.shiftId, + businessDate: args.day.date, + startedAtWallClock: args.nowWallClock, + } +} diff --git a/packages/auth/test/auth.test.ts b/packages/auth/test/auth.test.ts new file mode 100644 index 0000000..ae870d9 --- /dev/null +++ b/packages/auth/test/auth.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + assertUsablePin, hashPin, verifyPin, + FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, DEFAULT_LOCKOUT, + can, gateFor, + openSession, +} from '@sims/auth' +import type { BusinessDay, User } from '@sims/domain' + +describe('PIN policy & hashing', () => { + it('accepts sane PINs, rejects weak ones', () => { + expect(() => assertUsablePin('4728')).not.toThrow() + expect(() => assertUsablePin('12')).toThrow(/4–6 digits/) + expect(() => assertUsablePin('0000')).toThrow(/repeated/) + expect(() => assertUsablePin('1234')).toThrow(/sequence/) + expect(() => assertUsablePin('8765')).toThrow(/sequence/) + }) + it('hashes with per-credential salt and verifies', () => { + const stored = hashPin('4728') + expect(verifyPin('4728', stored)).toBe(true) + expect(verifyPin('4729', stored)).toBe(false) + expect(hashPin('4728').hash).not.toBe(stored.hash) // fresh salt every time + }) +}) + +describe('lockout reducer', () => { + it('locks after max failures and unlocks after the window', () => { + let s = FRESH_LOCKOUT + for (let i = 0; i < DEFAULT_LOCKOUT.maxFailures; i++) { + expect(isLocked(s, 1000)).toBe(false) + s = recordFailure(s, 1000) + } + expect(isLocked(s, 1000)).toBe(true) + expect(isLocked(s, 1000 + DEFAULT_LOCKOUT.lockMs)).toBe(false) + expect(isLocked(recordSuccess(), 1000)).toBe(false) + }) +}) + +const user = (roles: User['roles'], active = true): Pick => ({ roles, active }) + +describe('permissions & gates', () => { + it('grants by role', () => { + expect(can(user(['cashier']), 'BILL_CREATE')).toBe(true) + expect(can(user(['cashier']), 'PRICE_OVERRIDE')).toBe(false) + expect(can(user(['cashier'], false), 'BILL_CREATE')).toBe(false) + }) + it('in-flow gate escalates to supervisor PIN instead of hiding (09-UX §5.5)', () => { + expect(gateFor(user(['cashier']), 'PRICE_OVERRIDE')).toBe('supervisor-pin') + expect(gateFor(user(['cashier']), 'DAY_BEGIN')).toBe('supervisor-pin') + expect(gateFor(user(['supervisor']), 'PRICE_OVERRIDE')).toBe('allow') + }) +}) + +describe('POS session — Day Begin binding', () => { + const day: BusinessDay = { + tenantId: 't1', storeId: 's1', date: '2026-07-09', + openedAt: '2026-07-09T09:01:00+05:30', openedBy: 'mgr', + } + const base = { + tenantId: 't1', storeId: 's1', counterId: 'c1', userId: 'u1', shiftId: 'sh1', + nowWallClock: '2026-07-09T09:05:00+05:30', + } + it('binds the session to the working date, not the wall clock', () => { + expect(openSession({ ...base, day }).businessDate).toBe('2026-07-09') + }) + it('refuses a closed day or another store’s day', () => { + expect(() => openSession({ ...base, day: { ...day, closedAt: 'x', closedBy: 'mgr' } })).toThrow(/Day Begin/) + expect(() => openSession({ ...base, day: { ...day, storeId: 's2' } })).toThrow(/another store/) + }) +}) diff --git a/packages/billing-engine/package.json b/packages/billing-engine/package.json new file mode 100644 index 0000000..560ed69 --- /dev/null +++ b/packages/billing-engine/package.json @@ -0,0 +1,11 @@ +{ + "name": "@sims/billing-engine", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@sims/domain": "*" + } +} diff --git a/packages/billing-engine/src/compute.ts b/packages/billing-engine/src/compute.ts new file mode 100644 index 0000000..d9b7c2e --- /dev/null +++ b/packages/billing-engine/src/compute.ts @@ -0,0 +1,167 @@ +import type { BillLine, BillTotals, Paise, IsoDate } from '@sims/domain' +import { resolveTaxClass, type TaxClassRow } from './tax' + +export interface LineInput { + itemId: string + name: string + hsn: string + qty: number + unitCode: string + unitPricePaise: Paise + priceIncludesTax: boolean + taxClassCode: string + mrpPaise?: Paise + discount?: { kind: 'percentBp'; value: number } | { kind: 'amount'; paise: Paise } + batchId?: string +} + +export interface BillContext { + businessDate: IsoDate + /** Store's GST state code. */ + supplyStateCode: string + /** Customer/delivery state; equal to supply state ⇒ CGST+SGST, else IGST. */ + placeOfSupplyStateCode: string + roundToRupee: boolean + billDiscountPaise?: Paise +} + +export interface ComputedBill { + lines: BillLine[] + totals: BillTotals +} + +/** + * Deterministic money rules — the same engine must produce byte-identical + * results on POS, store server, and cloud: + * - integer paise throughout; each rounding step is explicit + * - inclusive price: line total stays the sticker amount; taxable is derived + * - intra-state split: cgst = floor(tax/2), sgst = tax − cgst (sum exact) + * - bill discount allocates pro-rata by line gross, largest-remainder method + */ +export function computeBill(inputs: LineInput[], ctx: BillContext, rates: TaxClassRow[]): ComputedBill { + if (inputs.length === 0) throw new Error('Cannot compute an empty bill') + for (const line of inputs) { + // Returns are separate documents (immutability rule) — never negative lines. + if (!(line.qty > 0)) throw new Error(`Quantity must be positive for "${line.name}"`) + if (!Number.isInteger(line.unitPricePaise) || line.unitPricePaise < 0) { + throw new Error(`Unit price must be non-negative integer paise for "${line.name}"`) + } + if (line.discount !== undefined) { + const v = line.discount.kind === 'percentBp' ? line.discount.value : line.discount.paise + if (v < 0) throw new Error(`Discount must be non-negative for "${line.name}"`) + if (line.discount.kind === 'percentBp' && line.discount.value > 10_000) { + throw new Error(`Discount above 100% for "${line.name}"`) + } + } + } + if ((ctx.billDiscountPaise ?? 0) < 0) throw new Error('Bill discount must be non-negative') + const interState = ctx.supplyStateCode !== ctx.placeOfSupplyStateCode + + const grossAfterLineDisc = inputs.map((line) => { + const gross = Math.round(line.unitPricePaise * line.qty) + const disc = + line.discount === undefined + ? 0 + : line.discount.kind === 'percentBp' + ? Math.round((gross * line.discount.value) / 10_000) + : line.discount.paise + if (disc > gross) throw new Error(`Discount exceeds line amount for "${line.name}"`) + return { gross, disc, net: gross - disc } + }) + + const billDiscShares = allocateProRata( + ctx.billDiscountPaise ?? 0, + grossAfterLineDisc.map((g) => g.net), + ) + + const lines: BillLine[] = inputs.map((line, i) => { + const { gross, disc } = grossAfterLineDisc[i]! + const discount = disc + billDiscShares[i]! + const net = gross - discount + const rate = resolveTaxClass(rates, line.taxClassCode, ctx.businessDate) + const totalRateBp = rate.ratePctBp + rate.cessPctBp + + let taxable: Paise + let taxPlusCess: Paise + let lineTotal: Paise + if (line.priceIncludesTax) { + taxable = Math.round((net * 10_000) / (10_000 + totalRateBp)) + taxPlusCess = net - taxable + lineTotal = net + } else { + taxable = net + taxPlusCess = Math.round((taxable * totalRateBp) / 10_000) + lineTotal = taxable + taxPlusCess + } + + const cess = totalRateBp === 0 ? 0 : Math.round((taxPlusCess * rate.cessPctBp) / totalRateBp) + const tax = taxPlusCess - cess + const cgst = interState ? 0 : Math.floor(tax / 2) + const sgst = interState ? 0 : tax - cgst + const igst = interState ? tax : 0 + + return { + itemId: line.itemId, + name: line.name, + hsn: line.hsn, + qty: line.qty, + unitCode: line.unitCode, + unitPricePaise: line.unitPricePaise, + ...(line.mrpPaise !== undefined ? { mrpPaise: line.mrpPaise } : {}), + grossPaise: gross, + discountPaise: discount, + taxablePaise: taxable, + taxRateBp: rate.ratePctBp, + cgstPaise: cgst, + sgstPaise: sgst, + igstPaise: igst, + cessPaise: cess, + lineTotalPaise: lineTotal, + ...(line.batchId !== undefined ? { batchId: line.batchId } : {}), + } + }) + + const sum = (f: (l: BillLine) => number) => lines.reduce((a, l) => a + f(l), 0) + const total = sum((l) => l.lineTotalPaise) + const payable = ctx.roundToRupee ? Math.round(total / 100) * 100 : total + const savings = lines.reduce((acc, l) => { + if (l.mrpPaise === undefined) return acc + return acc + Math.max(0, Math.round(l.mrpPaise * l.qty) - l.lineTotalPaise) + }, 0) + + return { + lines, + totals: { + grossPaise: sum((l) => l.grossPaise), + discountPaise: sum((l) => l.discountPaise), + taxablePaise: sum((l) => l.taxablePaise), + cgstPaise: sum((l) => l.cgstPaise), + sgstPaise: sum((l) => l.sgstPaise), + igstPaise: sum((l) => l.igstPaise), + cessPaise: sum((l) => l.cessPaise), + roundOffPaise: payable - total, + payablePaise: payable, + savingsVsMrpPaise: savings, + }, + } +} + +/** Largest-remainder allocation; shares always sum exactly to `amount`. */ +export function allocateProRata(amount: Paise, weights: number[]): Paise[] { + const totalWeight = weights.reduce((a, w) => a + w, 0) + if (amount === 0) return weights.map(() => 0) + if (totalWeight === 0) { + // Never silently drop money: allocating a nonzero amount needs weight somewhere. + throw new Error('Cannot allocate a nonzero amount over zero total weight') + } + const raw = weights.map((w) => (amount * w) / totalWeight) + const shares = raw.map(Math.floor) + let remainder = amount - shares.reduce((a, s) => a + s, 0) + const order = raw + .map((r, i) => ({ frac: r - Math.floor(r), i })) + .sort((a, b) => b.frac - a.frac || a.i - b.i) + for (let k = 0; remainder > 0; k = (k + 1) % order.length, remainder--) { + shares[order[k]!.i]! += 1 + } + return shares +} diff --git a/packages/billing-engine/src/index.ts b/packages/billing-engine/src/index.ts new file mode 100644 index 0000000..0a980ac --- /dev/null +++ b/packages/billing-engine/src/index.ts @@ -0,0 +1,2 @@ +export * from './tax' +export * from './compute' diff --git a/packages/billing-engine/src/tax.ts b/packages/billing-engine/src/tax.ts new file mode 100644 index 0000000..3c6e2bf --- /dev/null +++ b/packages/billing-engine/src/tax.ts @@ -0,0 +1,30 @@ +import type { IsoDate } from '@sims/domain' + +/** + * Tax rates are dated configuration rows, never constants (07-DB-AND-CONFIG §1; + * the Sept-2025 GST 2.0 slab change is the precedent). Rates are basis points: + * 18% → 1800. Resolution is by the bill's business date, so returns and credit + * notes against old bills resolve the rate that was law on that day. + */ +export interface TaxClassRow { + classCode: string + ratePctBp: number + cessPctBp: number + effectiveFrom: IsoDate + effectiveTo?: IsoDate +} + +export function resolveTaxClass(rows: TaxClassRow[], classCode: string, onDate: IsoDate): TaxClassRow { + let best: TaxClassRow | undefined + for (const row of rows) { + if (row.classCode !== classCode) continue + if (row.effectiveFrom > onDate) continue + if (row.effectiveTo !== undefined && row.effectiveTo < onDate) continue + if (best === undefined || row.effectiveFrom > best.effectiveFrom) best = row + } + if (best === undefined) { + // Silent defaults on tax are how wrong GST reaches the portal — fail loudly. + throw new Error(`No tax rate for class "${classCode}" on ${onDate}`) + } + return best +} diff --git a/packages/billing-engine/test/compute.test.ts b/packages/billing-engine/test/compute.test.ts new file mode 100644 index 0000000..dd3089a --- /dev/null +++ b/packages/billing-engine/test/compute.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { + allocateProRata, computeBill, resolveTaxClass, + type LineInput, type TaxClassRow, +} from '@sims/billing-engine' + +// Rates as dated config — includes a GST-2.0-style change on 2025-09-22. +const RATES: TaxClassRow[] = [ + { classCode: 'STD18', ratePctBp: 1800, cessPctBp: 0, effectiveFrom: '2017-07-01' }, + { classCode: 'ZERO', ratePctBp: 0, cessPctBp: 0, effectiveFrom: '2017-07-01' }, + { classCode: 'FMCG', ratePctBp: 1200, cessPctBp: 0, effectiveFrom: '2017-07-01', effectiveTo: '2025-09-21' }, + { classCode: 'FMCG', ratePctBp: 500, cessPctBp: 0, effectiveFrom: '2025-09-22' }, + { classCode: 'DEMERIT', ratePctBp: 2800, cessPctBp: 1200, effectiveFrom: '2017-07-01' }, +] + +const INTRA = { + businessDate: '2026-07-09', + supplyStateCode: '32', + placeOfSupplyStateCode: '32', + roundToRupee: true, +} + +const line = (over: Partial): LineInput => ({ + itemId: 'i1', + name: 'Item', + hsn: '1101', + qty: 1, + unitCode: 'PCS', + unitPricePaise: 10_000, + priceIncludesTax: false, + taxClassCode: 'STD18', + ...over, +}) + +describe('resolveTaxClass — dated rates', () => { + it('resolves the rate in force on the business date', () => { + expect(resolveTaxClass(RATES, 'FMCG', '2025-09-21').ratePctBp).toBe(1200) + expect(resolveTaxClass(RATES, 'FMCG', '2025-09-22').ratePctBp).toBe(500) + }) + it('fails loudly when no rate covers the date', () => { + expect(() => resolveTaxClass(RATES, 'MISSING', '2026-01-01')).toThrow(/No tax rate/) + }) +}) + +describe('computeBill — golden cases (exact paise)', () => { + it('A: MRP-inclusive B2C intra-state, 18% — sticker total is preserved', () => { + // ₹285 incl. 18%: taxable = round(28500·10000/11800) = 24153, tax = 4347 + const { lines, totals } = computeBill( + [line({ unitPricePaise: 28_500, priceIncludesTax: true, mrpPaise: 28_500 })], + INTRA, + RATES, + ) + expect(lines[0]).toMatchObject({ + taxablePaise: 24_153, + cgstPaise: 2_173, + sgstPaise: 2_174, + igstPaise: 0, + lineTotalPaise: 28_500, + }) + expect(totals.payablePaise).toBe(28_500) + expect(totals.roundOffPaise).toBe(0) + expect(totals.savingsVsMrpPaise).toBe(0) + }) + + it('B: tax-exclusive B2B inter-state — IGST, no split', () => { + const ctx = { ...INTRA, placeOfSupplyStateCode: '27', roundToRupee: false } + const { lines, totals } = computeBill([line({})], ctx, RATES) + expect(lines[0]).toMatchObject({ + taxablePaise: 10_000, + igstPaise: 1_800, + cgstPaise: 0, + sgstPaise: 0, + lineTotalPaise: 11_800, + }) + expect(totals.payablePaise).toBe(11_800) + }) + + it('C: fractional weighed qty at 0% — 1.25 kg × ₹32/kg', () => { + const { lines } = computeBill( + [line({ qty: 1.25, unitCode: 'KG', unitPricePaise: 3_200, taxClassCode: 'ZERO', priceIncludesTax: true })], + INTRA, + RATES, + ) + expect(lines[0]!.lineTotalPaise).toBe(4_000) + expect(lines[0]!.taxablePaise).toBe(4_000) + }) + + it('D: bill discount allocates pro-rata (largest remainder), taxes recompute per line', () => { + const { lines, totals } = computeBill( + [ + line({ unitPricePaise: 28_500, priceIncludesTax: true }), + line({ itemId: 'i2', unitPricePaise: 4_000, taxClassCode: 'ZERO', priceIncludesTax: true }), + ], + { ...INTRA, billDiscountPaise: 1_000 }, + RATES, + ) + // shares of 1000 over weights 28500:4000 → 877 + 123 + expect(lines[0]!.discountPaise).toBe(877) + expect(lines[1]!.discountPaise).toBe(123) + // line 1 net 27623 incl 18% → taxable 23409, tax 4214 split 2107/2107 + expect(lines[0]).toMatchObject({ taxablePaise: 23_409, cgstPaise: 2_107, sgstPaise: 2_107 }) + expect(totals.payablePaise).toBe(31_500) + expect(totals.discountPaise).toBe(1_000) + }) + + it('E: rupee round-off — ₹99.50 + 18% = ₹117.41 → payable ₹117.00', () => { + const { totals } = computeBill([line({ unitPricePaise: 9_950 })], INTRA, RATES) + expect(totals.roundOffPaise).toBe(-41) + expect(totals.payablePaise).toBe(11_700) + }) + + it('F: cess rides on top and splits out exactly (28% + 12% cess)', () => { + const { lines } = computeBill([line({ taxClassCode: 'DEMERIT' })], INTRA, RATES) + expect(lines[0]).toMatchObject({ + taxablePaise: 10_000, + cessPaise: 1_200, + cgstPaise: 1_400, + sgstPaise: 1_400, + lineTotalPaise: 14_000, + }) + }) + + it('G: savings vs MRP reported when selling below sticker', () => { + const { totals } = computeBill( + [line({ unitPricePaise: 27_000, priceIncludesTax: true, mrpPaise: 28_500 })], + INTRA, + RATES, + ) + expect(totals.savingsVsMrpPaise).toBe(1_500) + }) + + it('rejects empty bills, non-positive qty, and negative discounts', () => { + expect(() => computeBill([], INTRA, RATES)).toThrow(/empty/) + expect(() => computeBill([line({ qty: 0 })], INTRA, RATES)).toThrow(/positive/) + expect(() => + computeBill([line({ discount: { kind: 'amount', paise: -5 } })], INTRA, RATES), + ).toThrow(/non-negative/) + }) +}) + +describe('allocateProRata', () => { + it('always sums exactly to the amount', () => { + for (const [amount, weights] of [ + [1000, [28_500, 4_000]], + [999, [1, 1, 1]], + [7, [100, 200, 300, 400]], + ] as const) { + const shares = allocateProRata(amount, [...weights]) + expect(shares.reduce((a, s) => a + s, 0)).toBe(amount) + } + }) + it('refuses to drop money on zero total weight', () => { + expect(() => allocateProRata(100, [0, 0])).toThrow(/zero total weight/) + }) +}) diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..25612f0 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,8 @@ +{ + "name": "@sims/config", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 0000000..d38ba2f --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,2 @@ +export * from './settings' +export * from './messages' diff --git a/packages/config/src/messages.ts b/packages/config/src/messages.ts new file mode 100644 index 0000000..0f2a3d9 --- /dev/null +++ b/packages/config/src/messages.ts @@ -0,0 +1,40 @@ +/** + * Every user-facing string is a DB row (07-DB-AND-CONFIG §2) — notifications, + * errors, labels — per language and channel. The frontend renders codes; the + * catalog syncs like any master, so language never depends on the network. + */ +export interface MessageRow { + code: string + channel: 'ui' | 'print' | 'whatsapp' | 'sms' + lang: string + template: string +} + +export interface LanguagePrefs { + userLang?: string + storeLang?: string +} + +/** Fallback chain: user language → store default → English → undefined. */ +export function resolveMessage( + rows: MessageRow[], + code: string, + channel: MessageRow['channel'], + prefs: LanguagePrefs, +): { template: string; lang: string } | undefined { + const chain = [prefs.userLang, prefs.storeLang, 'en'].filter( + (l, i, all): l is string => l !== undefined && all.indexOf(l) === i, + ) + for (const lang of chain) { + const row = rows.find((r) => r.code === code && r.channel === channel && r.lang === lang) + if (row !== undefined) return { template: row.template, lang } + } + return undefined +} + +/** Minimal {placeholder} interpolation; missing vars render as the raw key. */ +export function renderTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (_, name: string) => + name in vars ? String(vars[name]) : `{${name}}`, + ) +} diff --git a/packages/config/src/settings.ts b/packages/config/src/settings.ts new file mode 100644 index 0000000..df3637b --- /dev/null +++ b/packages/config/src/settings.ts @@ -0,0 +1,58 @@ +/** + * The settings hierarchy (11-ADMIN-SUPPORT-CONSOLE §2): nearest scope wins, + * user → counter → store → tenant → system. Rows are effective-dated; within a + * scope the latest effectiveFrom not after the target date applies. + */ +export type SettingScope = 'system' | 'tenant' | 'store' | 'counter' | 'user' + +export interface SettingRow { + scopeType: SettingScope + /** '' for system scope. */ + scopeId: string + key: string + value: string + effectiveFrom?: string // YYYY-MM-DD; undefined = always +} + +export interface SettingContext { + tenantId?: string + storeId?: string + counterId?: string + userId?: string +} + +const PRECEDENCE: SettingScope[] = ['user', 'counter', 'store', 'tenant', 'system'] + +export function resolveSetting( + rows: SettingRow[], + key: string, + ctx: SettingContext, + onDate: string, +): { value: string; scope: SettingScope } | undefined { + for (const scope of PRECEDENCE) { + const scopeId = + scope === 'system' + ? '' + : scope === 'tenant' + ? ctx.tenantId + : scope === 'store' + ? ctx.storeId + : scope === 'counter' + ? ctx.counterId + : ctx.userId + if (scopeId === undefined) continue + let best: SettingRow | undefined + for (const row of rows) { + if (row.key !== key || row.scopeType !== scope || row.scopeId !== scopeId) continue + if (row.effectiveFrom !== undefined && row.effectiveFrom > onDate) continue + if ( + best === undefined || + (row.effectiveFrom ?? '0000-00-00') > (best.effectiveFrom ?? '0000-00-00') + ) { + best = row + } + } + if (best !== undefined) return { value: best.value, scope } + } + return undefined +} diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts new file mode 100644 index 0000000..093f287 --- /dev/null +++ b/packages/config/test/config.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { resolveMessage, renderTemplate, resolveSetting, type MessageRow, type SettingRow } from '@sims/config' + +const CTX = { tenantId: 't1', storeId: 's1', counterId: 'c1', userId: 'u1' } + +describe('settings hierarchy — nearest scope wins', () => { + const rows: SettingRow[] = [ + { scopeType: 'system', scopeId: '', key: 'discount.capBp', value: '1000' }, + { scopeType: 'tenant', scopeId: 't1', key: 'discount.capBp', value: '1500' }, + { scopeType: 'store', scopeId: 's1', key: 'discount.capBp', value: '500' }, + ] + it('store overrides tenant overrides system', () => { + expect(resolveSetting(rows, 'discount.capBp', CTX, '2026-07-09')).toEqual({ + value: '500', + scope: 'store', + }) + }) + it('falls through when the nearer scope has no row', () => { + const noStore = { ...CTX, storeId: 's-other' } + expect(resolveSetting(rows, 'discount.capBp', noStore, '2026-07-09')?.value).toBe('1500') + }) + it('system default applies when nothing overrides', () => { + expect(resolveSetting(rows, 'discount.capBp', { tenantId: 'tx' }, '2026-07-09')?.value).toBe('1000') + }) + it('returns undefined for unknown keys', () => { + expect(resolveSetting(rows, 'nope', CTX, '2026-07-09')).toBeUndefined() + }) +}) + +describe('settings — effective dating', () => { + const rows: SettingRow[] = [ + { scopeType: 'system', scopeId: '', key: 'gst.roundToRupee', value: 'true' }, + { scopeType: 'system', scopeId: '', key: 'gst.roundToRupee', value: 'false', effectiveFrom: '2026-08-01' }, + ] + it('picks the latest row not after the target date', () => { + expect(resolveSetting(rows, 'gst.roundToRupee', {}, '2026-07-31')?.value).toBe('true') + expect(resolveSetting(rows, 'gst.roundToRupee', {}, '2026-08-01')?.value).toBe('false') + }) +}) + +describe('message catalog — language fallback chain', () => { + const rows: MessageRow[] = [ + { code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'en', template: 'Hi {name}, your bill of {amount}' }, + { code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'hi', template: 'नमस्ते {name}, आपका बिल {amount}' }, + { code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'ml', template: 'നമസ്കാരം {name}, ബിൽ {amount}' }, + ] + it('prefers user language, then store, then English', () => { + expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ml' })?.lang).toBe('ml') + expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ta', storeLang: 'hi' })?.lang).toBe('hi') + expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ta' })?.lang).toBe('en') + }) + it('returns undefined for unknown codes (caller surfaces the raw key in dev)', () => { + expect(resolveMessage(rows, 'NOPE', 'whatsapp', {})).toBeUndefined() + }) + it('renders placeholders; missing vars stay visible', () => { + expect(renderTemplate('Hi {name}, bill {amount}', { name: 'Lakshmi', amount: '₹1,431.00' })).toBe( + 'Hi Lakshmi, bill ₹1,431.00', + ) + expect(renderTemplate('Hi {name}', {})).toBe('Hi {name}') + }) +}) diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 0000000..e98ed78 --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,8 @@ +{ + "name": "@sims/domain", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/domain/src/business-day.ts b/packages/domain/src/business-day.ts new file mode 100644 index 0000000..53bc0d4 --- /dev/null +++ b/packages/domain/src/business-day.ts @@ -0,0 +1,32 @@ +/** + * The working-day concept carried over from the OG applications (Day Begin / + * Day End in the Manager Menu): every dated record stamps the store's opened + * business date, never the machine clock. The wall clock is display metadata. + */ +export type IsoDate = string // YYYY-MM-DD + +export interface BusinessDay { + tenantId: string + storeId: string + date: IsoDate + openedAt: string + openedBy: string + closedAt?: string + closedBy?: string +} + +export function isOpen(day: BusinessDay): boolean { + return day.closedAt === undefined +} + +/** Indian financial year label: 2026-04-01 → "2026-27", 2026-03-31 → "2025-26". */ +export function fyOf(date: IsoDate, fyStartMonth = 4): string { + const [y, m] = [Number(date.slice(0, 4)), Number(date.slice(5, 7))] + const start = m >= fyStartMonth ? y : y - 1 + return `${start}-${String(start + 1).slice(2)}` +} + +/** Short FY marker for document series prefixes: "2026-27" → "26". */ +export function fyShort(fy: string): string { + return fy.slice(2, 4) +} diff --git a/packages/domain/src/doc-series.ts b/packages/domain/src/doc-series.ts new file mode 100644 index 0000000..06c35f5 --- /dev/null +++ b/packages/domain/src/doc-series.ts @@ -0,0 +1,44 @@ +/** + * Per-counter, per-FY document series (02-ARCHITECTURE §3 rule 3): counters + * number documents offline with zero coordination. GST caps a document number + * at 16 chars from the set [A-Z a-z 0-9 - /]. + */ +export interface DocSeries { + tenantId: string + storeId: string + counterId: string + docType: string + fy: string + prefix: string + nextSeq: number +} + +const GST_DOC_NO = /^[A-Za-z0-9/-]{1,16}$/ + +export function assertGstDocNo(docNo: string): void { + if (!GST_DOC_NO.test(docNo)) { + throw new Error(`Document number "${docNo}" violates the GST 16-char [A-Za-z0-9/-] rule`) + } +} + +export function formatDocNo(prefix: string, seq: number, width = 6): string { + const docNo = `${prefix}-${String(seq).padStart(width, '0')}` + assertGstDocNo(docNo) + return docNo +} + +/** e.g. seriesPrefix("ST1", "C2", "26") → "ST1C2/26" */ +export function seriesPrefix(storeCode: string, counterCode: string, fyShortMark: string): string { + return `${storeCode}${counterCode}/${fyShortMark}` +} + +export function nextDocNo(series: DocSeries): { docNo: string; series: DocSeries } { + const docNo = formatDocNo(series.prefix, series.nextSeq) + return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } } +} + +/** FY rollover starts a fresh series; the old one is retained for returns lookups. */ +export function rolloverForFy(series: DocSeries, fy: string, prefix: string): DocSeries { + if (fy === series.fy) return series + return { ...series, fy, prefix, nextSeq: 1 } +} diff --git a/packages/domain/src/documents.ts b/packages/domain/src/documents.ts new file mode 100644 index 0000000..e0e012f --- /dev/null +++ b/packages/domain/src/documents.ts @@ -0,0 +1,72 @@ +import type { Paise } from './money' +import type { IsoDate } from './business-day' + +/** + * Documents are immutable events (02-ARCHITECTURE §3 rule 2). A committed bill + * is never edited; corrections are new documents referencing the original. + */ +export type DocType = 'SALE' | 'SALE_RETURN' | 'PURCHASE' | 'PURCHASE_RETURN' | 'STOCK_ADJUST' + +export interface BillLine { + itemId: string + name: string + hsn: string + qty: number + unitCode: string + unitPricePaise: Paise + mrpPaise?: Paise + grossPaise: Paise + discountPaise: Paise + taxablePaise: Paise + taxRateBp: number + cgstPaise: Paise + sgstPaise: Paise + igstPaise: Paise + cessPaise: Paise + lineTotalPaise: Paise + batchId?: string +} + +export type PaymentMode = 'CASH' | 'UPI' | 'CARD' | 'KHATA' + +export interface Payment { + mode: PaymentMode + amountPaise: Paise + reference?: string +} + +export interface BillTotals { + grossPaise: Paise + discountPaise: Paise + taxablePaise: Paise + cgstPaise: Paise + sgstPaise: Paise + igstPaise: Paise + cessPaise: Paise + roundOffPaise: Paise + payablePaise: Paise + savingsVsMrpPaise: Paise +} + +export interface BillDoc { + id: string // uuidv7, client-generated + tenantId: string + storeId: string + counterId: string + docType: DocType + docNo: string + /** Working date from Day Begin — never the machine clock. */ + businessDate: IsoDate + cashierId: string + shiftId: string + customerId?: string + refDocId?: string // returns/corrections point at the original + lines: BillLine[] + payments: Payment[] + totals: BillTotals + /** Wall clock is display metadata only; ordering uses (lamport, deviceId). */ + createdAtWallClock: string + lamport: number + deviceId: string + deviceSeq: number +} diff --git a/packages/domain/src/gstin.ts b/packages/domain/src/gstin.ts new file mode 100644 index 0000000..d99bb2c --- /dev/null +++ b/packages/domain/src/gstin.ts @@ -0,0 +1,60 @@ +const GSTIN_SHAPE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/ +const B36 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' + +/** Standard GSTIN check digit: alternating 1/2 factors over base-36 values. */ +export function gstinCheckDigit(first14: string): string { + let sum = 0 + for (let i = 0; i < 14; i++) { + const v = B36.indexOf(first14[i]!) + const product = v * (i % 2 === 0 ? 1 : 2) + sum += Math.trunc(product / 36) + (product % 36) + } + return B36[(36 - (sum % 36)) % 36]! +} + +export function validateGstin(gstin: string): { ok: boolean; stateCode?: string; reason?: string } { + const g = gstin.toUpperCase().trim() + if (!GSTIN_SHAPE.test(g)) return { ok: false, reason: 'shape' } + if (gstinCheckDigit(g.slice(0, 14)) !== g[14]) return { ok: false, reason: 'checksum' } + return { ok: true, stateCode: g.slice(0, 2) } +} + +/** Minimal buyer identity a place-of-supply decision needs. */ +export interface SupplyCustomer { + gstin?: string + /** 2-digit GST state code; when absent it is read from the GSTIN's first two digits. */ + stateCode?: string +} + +export interface SupplyDecision { + /** True when the buyer carries a GSTIN — a registered B2B sale. */ + b2b: boolean + /** GST state code that is the place of supply (buyer's for B2B, the store's for B2C). */ + placeOfSupplyStateCode: string + /** Place of supply differs from the store ⇒ the IGST path; else CGST+SGST. */ + interState: boolean + /** Buyer GSTIN snapshot (upper-cased) when this is a B2B sale. */ + buyerGstin?: string +} + +/** + * Place-of-supply decision at the counter — the single rule the POS and the + * store server must agree on so their engine runs match to the paisa (R5). + * A buyer with a GSTIN is B2B: place of supply is the buyer's state (an explicit + * stateCode, else the GSTIN's first two digits). Everyone else is a B2C + * over-the-counter sale — place of supply is the store. Inter-state ⇒ IGST. + */ +export function deriveSupply(customer: SupplyCustomer | undefined, storeStateCode: string): SupplyDecision { + const gstin = customer?.gstin?.toUpperCase().trim() + const b2b = gstin !== undefined && gstin !== '' + const buyerState = customer?.stateCode !== undefined && customer.stateCode !== '' + ? customer.stateCode + : b2b ? gstin.slice(0, 2) : undefined + const placeOfSupplyStateCode = b2b && buyerState !== undefined ? buyerState : storeStateCode + return { + b2b, + placeOfSupplyStateCode, + interState: placeOfSupplyStateCode !== storeStateCode, + ...(b2b ? { buyerGstin: gstin } : {}), + } +} diff --git a/packages/domain/src/ids.ts b/packages/domain/src/ids.ts new file mode 100644 index 0000000..b033e81 --- /dev/null +++ b/packages/domain/src/ids.ts @@ -0,0 +1,25 @@ +/** + * UUIDv7: 48-bit unix-ms timestamp + random. Time-ordered so ids sort roughly + * by creation across devices — the property the sync design (02-ARCHITECTURE §3) + * relies on for cheap cursor pagination. Client-generated, never server-assigned. + * Web Crypto keeps this package loadable in both Node and the POS renderer. + */ +export function uuidv7(now: number = Date.now()): string { + const b = new Uint8Array(16) + globalThis.crypto.getRandomValues(b) + b[0] = (now / 2 ** 40) & 0xff + b[1] = (now / 2 ** 32) & 0xff + b[2] = (now / 2 ** 24) & 0xff + b[3] = (now / 2 ** 16) & 0xff + b[4] = (now / 2 ** 8) & 0xff + b[5] = now & 0xff + b[6] = 0x70 | (b[6]! & 0x0f) + b[8] = 0x80 | (b[8]! & 0x3f) + const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('') + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}` +} + +export function timestampOfUuidv7(id: string): number { + const hex = id.replaceAll('-', '').slice(0, 12) + return Number.parseInt(hex, 16) +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100644 index 0000000..748cb46 --- /dev/null +++ b/packages/domain/src/index.ts @@ -0,0 +1,9 @@ +export * from './ids' +export * from './money' +export * from './tenancy' +export * from './business-day' +export * from './doc-series' +export * from './gstin' +export * from './masters' +export * from './documents' +export * from './outbox' diff --git a/packages/domain/src/masters.ts b/packages/domain/src/masters.ts new file mode 100644 index 0000000..7c24ad9 --- /dev/null +++ b/packages/domain/src/masters.ts @@ -0,0 +1,40 @@ +import type { Paise } from './money' + +export interface Unit { + code: string // PCS, KG, BOX… + /** Decimal places allowed in a quantity of this unit (KG → 3, PCS → 0). */ + decimals: number +} + +export interface Item { + id: string + tenantId: string + code: string + name: string + /** Optional regional-script secondary name, shown under the primary on POS. */ + nameSecondary?: string + hsn: string + taxClassCode: string + unit: Unit + mrpPaise?: Paise + salePricePaise: Paise + /** Sale price semantics; Indian B2C retail is normally tax-inclusive. */ + priceIncludesTax: boolean + barcodes: string[] + batchTracked: boolean + /** Draft items come from the POS unknown-barcode quick-add, pending completion. */ + status: 'active' | 'draft' | 'inactive' +} + +export interface Party { + id: string + tenantId: string + code: string + name: string + kind: 'customer' | 'supplier' + phone?: string + gstin?: string + /** 2-digit GST state code; drives place-of-supply on B2B documents. */ + stateCode?: string + creditLimitPaise?: Paise +} diff --git a/packages/domain/src/money.ts b/packages/domain/src/money.ts new file mode 100644 index 0000000..7860d4d --- /dev/null +++ b/packages/domain/src/money.ts @@ -0,0 +1,73 @@ +/** All money is integer paise. Fractional-rupee floats never enter the domain. */ +export type Paise = number + +export function fromRupees(rupees: number): Paise { + return Math.round(rupees * 100) +} + +/** Indian digit grouping: 12,34,567.89 */ +export function formatINR(amount: Paise, opts: { symbol?: boolean } = {}): string { + const sign = amount < 0 ? '-' : '' + const abs = Math.abs(Math.round(amount)) + const whole = String(Math.trunc(abs / 100)) + const frac = String(abs % 100).padStart(2, '0') + const grouped = + whole.length <= 3 + ? whole + : whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ',') + ',' + whole.slice(-3) + return `${sign}${opts.symbol === false ? '' : '₹'}${grouped}.${frac}` +} + +const ONES = [ + '', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', + 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen', +] +const TENS = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety'] + +function below100(n: number): string { + if (n < 20) return ONES[n]! + const t = TENS[Math.floor(n / 10)]! + const o = n % 10 + return o === 0 ? t : `${t} ${ONES[o]!}` +} + +function below1000(n: number): string { + const h = Math.floor(n / 100) + const r = n % 100 + const parts: string[] = [] + if (h > 0) parts.push(`${ONES[h]!} Hundred`) + if (r > 0) parts.push(below100(r)) + return parts.join(' ') +} + +/** Whole non-negative rupees to Indian-numbering words (crore/lakh/thousand). */ +function indianWords(rupees: number): string { + if (rupees === 0) return 'Zero' + let n = rupees + const crore = Math.floor(n / 10_000_000); n %= 10_000_000 + const lakh = Math.floor(n / 100_000); n %= 100_000 + const thousand = Math.floor(n / 1000); n %= 1000 + const parts: string[] = [] + if (crore > 0) parts.push(`${below1000(crore)} Crore`) + if (lakh > 0) parts.push(`${below100(lakh)} Lakh`) + if (thousand > 0) parts.push(`${below100(thousand)} Thousand`) + if (n > 0) parts.push(below1000(n)) + return parts.join(' ') +} + +/** + * Integer paise to an Indian-numbering "amount in words" string for GST invoices + * (e.g. 1,00,00,000 paise → "Rupees One Lakh Only"). Rupees and paise are named + * separately; "Paisa" is singular. The `formatINR` sibling for the numeric form. + */ +export function amountInWordsINR(paise: Paise): string { + const sign = paise < 0 ? 'Minus ' : '' + const abs = Math.abs(Math.round(paise)) + const rupees = Math.trunc(abs / 100) + const p = abs % 100 + const paiseWord = p === 1 ? 'Paisa' : 'Paise' + if (rupees > 0 && p > 0) return `${sign}Rupees ${indianWords(rupees)} and ${below100(p)} ${paiseWord} Only` + if (rupees > 0) return `${sign}Rupees ${indianWords(rupees)} Only` + if (p > 0) return `${sign}${below100(p)} ${paiseWord} Only` + return 'Rupees Zero Only' +} diff --git a/packages/domain/src/outbox.ts b/packages/domain/src/outbox.ts new file mode 100644 index 0000000..39ebf42 --- /dev/null +++ b/packages/domain/src/outbox.ts @@ -0,0 +1,18 @@ +/** + * Outbox rows are written in the same local transaction as every business + * change from the FIRST local-only build (D14): dormant until the cloud tier + * exists, at which point a sync agent drains them. Cloud enablement must be a + * switch-on, never a data migration. + */ +export interface OutboxRow { + opId: string // uuidv7 — the idempotency key + tenantId: string + deviceId: string + deviceSeq: number + docType: string + docId: string + /** Whole-document changeset envelope — row-level sync is forbidden by design. */ + envelope: unknown + createdAtWallClock: string + lamport: number +} diff --git a/packages/domain/src/tenancy.ts b/packages/domain/src/tenancy.ts new file mode 100644 index 0000000..538373d --- /dev/null +++ b/packages/domain/src/tenancy.ts @@ -0,0 +1,58 @@ +/** + * Tenancy is a data property from the first row, not a cloud feature (D14 guardrail). + * Classic has no tenant concept; here every record carries tenantId even though a + * local v1 install holds exactly one tenant — minted at provisioning, invisible in + * the UI, and the thing that makes the later cloud merge a switch-on instead of a + * re-keying migration. + */ +export interface Tenant { + id: string + code: string + name: string + gstin?: string + gstScheme: 'regular' | 'composite' + /** Indian FY starts April; kept as data for the day that assumption breaks. */ + fyStartMonth: number + createdAt: string +} + +export interface Store { + id: string + tenantId: string + code: string + name: string + /** 2-digit GST state code; decides CGST/SGST vs IGST at billing time. */ + stateCode: string + address?: string + active: boolean +} + +export interface Counter { + id: string + tenantId: string + storeId: string + code: string + name: string + active: boolean +} + +export type RoleCode = + | 'owner' + | 'manager' + | 'supervisor' + | 'cashier' + | 'purchaser' + | 'accountant' + | 'auditor' + +export interface User { + id: string + tenantId: string + username: string + displayName: string + roles: RoleCode[] + /** 'all' or explicit store scoping. */ + storeIds: 'all' | string[] + language: string + active: boolean +} diff --git a/packages/domain/test/domain.test.ts b/packages/domain/test/domain.test.ts new file mode 100644 index 0000000..e155524 --- /dev/null +++ b/packages/domain/test/domain.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { + formatINR, fyOf, fyShort, uuidv7, timestampOfUuidv7, + formatDocNo, nextDocNo, rolloverForFy, seriesPrefix, assertGstDocNo, + gstinCheckDigit, validateGstin, deriveSupply, + type DocSeries, +} from '@sims/domain' + +describe('money — Indian grouping', () => { + it('groups lakh/crore style', () => { + expect(formatINR(123_456_789)).toBe('₹12,34,567.89') + expect(formatINR(100)).toBe('₹1.00') + expect(formatINR(-4_50)).toBe('-₹4.50') + expect(formatINR(99_999, { symbol: false })).toBe('999.99') + }) +}) + +describe('financial year (Day Begin idiom)', () => { + it('splits at 1 April', () => { + expect(fyOf('2026-03-31')).toBe('2025-26') + expect(fyOf('2026-04-01')).toBe('2026-27') + expect(fyShort('2026-27')).toBe('26') + }) +}) + +describe('uuidv7', () => { + it('is well-formed and carries its timestamp', () => { + const at = 1_750_000_000_000 + const id = uuidv7(at) + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + expect(timestampOfUuidv7(id)).toBe(at) + }) + it('sorts by creation time', () => { + expect(uuidv7(1000) < uuidv7(2000)).toBe(true) + }) +}) + +describe('document series — offline per-counter numbering', () => { + const series: DocSeries = { + tenantId: 't', storeId: 's', counterId: 'c', docType: 'SALE', + fy: '2026-27', prefix: seriesPrefix('ST1', 'C2', '26'), nextSeq: 123, + } + it('formats within the GST 16-char rule', () => { + const { docNo, series: bumped } = nextDocNo(series) + expect(docNo).toBe('ST1C2/26-000123') + expect(docNo.length).toBeLessThanOrEqual(16) + expect(bumped.nextSeq).toBe(124) + }) + it('rejects numbers that break the GST rule', () => { + expect(() => assertGstDocNo('THIS/PREFIX/IS/TOO/LONG-000001')).toThrow(/16-char/) + expect(() => formatDocNo('BAD_CHAR', 1)).toThrow(/16-char/) // underscore not allowed + }) + it('FY rollover restarts the sequence', () => { + const rolled = rolloverForFy(series, '2027-28', seriesPrefix('ST1', 'C2', '27')) + expect(rolled.nextSeq).toBe(1) + expect(rolloverForFy(series, '2026-27', series.prefix)).toBe(series) + }) +}) + +describe('GSTIN validation', () => { + const body = '32ABCDE1234F1Z' + const valid = body + gstinCheckDigit(body) + it('accepts a checksum-correct GSTIN and extracts the state', () => { + expect(validateGstin(valid)).toEqual({ ok: true, stateCode: '32' }) + }) + it('rejects wrong checksum and wrong shape', () => { + const flipped = body + (valid[14] === 'A' ? 'B' : 'A') + expect(validateGstin(flipped).ok).toBe(false) + expect(validateGstin('not-a-gstin').ok).toBe(false) + }) +}) + +describe('deriveSupply — place of supply at the counter', () => { + const store = '32' // MegaMart's state + const intraGstin = '32ABCDE1234F1Z' + gstinCheckDigit('32ABCDE1234F1Z') + const interGstin = '27ABCDE1234F1Z' + gstinCheckDigit('27ABCDE1234F1Z') + + it('no GSTIN → B2C, place of supply is the store state (intra)', () => { + expect(deriveSupply(undefined, store)).toMatchObject({ b2b: false, placeOfSupplyStateCode: '32', interState: false }) + // A walk-in with only a state on file is still B2C — place of supply stays the store. + expect(deriveSupply({ stateCode: '27' }, store)).toMatchObject({ b2b: false, placeOfSupplyStateCode: '32', interState: false }) + }) + it('GSTIN in the same state → B2B intra (CGST/SGST)', () => { + expect(deriveSupply({ gstin: intraGstin }, store)).toMatchObject({ + b2b: true, placeOfSupplyStateCode: '32', interState: false, buyerGstin: intraGstin, + }) + }) + it('GSTIN in another state → B2B inter (IGST)', () => { + expect(deriveSupply({ gstin: interGstin }, store)).toMatchObject({ + b2b: true, placeOfSupplyStateCode: '27', interState: true, buyerGstin: interGstin, + }) + }) +}) diff --git a/packages/printing/package.json b/packages/printing/package.json new file mode 100644 index 0000000..72b4627 --- /dev/null +++ b/packages/printing/package.json @@ -0,0 +1,12 @@ +{ + "name": "@sims/printing", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@sims/domain": "*", + "@sims/scanning": "*" + } +} diff --git a/packages/printing/src/escpos.ts b/packages/printing/src/escpos.ts new file mode 100644 index 0000000..768d269 --- /dev/null +++ b/packages/printing/src/escpos.ts @@ -0,0 +1,82 @@ +const ESC = 0x1b +const GS = 0x1d + +/** + * ESC/POS byte builder — pure, so receipt layout is testable without a + * printer. Spike scope is ASCII text mode; Indic scripts rasterize in + * bit-image mode later (09-UX §5.3 print policy). + */ +export class EscPos { + private bytes: number[] = [] + + init(): this { + this.bytes.push(ESC, 0x40) + return this + } + + /** Non-ASCII collapses to '?' in text mode — raster path handles scripts. */ + text(s: string): this { + for (const ch of s) { + const code = ch.codePointAt(0)! + this.bytes.push(code >= 0x20 && code <= 0x7e ? code : 0x3f) + } + return this + } + + line(s = ''): this { + return this.text(s).newline() + } + + newline(): this { + this.bytes.push(0x0a) + return this + } + + align(where: 'left' | 'center' | 'right'): this { + this.bytes.push(ESC, 0x61, where === 'left' ? 0 : where === 'center' ? 1 : 2) + return this + } + + bold(on: boolean): this { + this.bytes.push(ESC, 0x45, on ? 1 : 0) + return this + } + + /** Character cell multiplier 1–8 each axis (GS !). */ + size(w: number, h: number): this { + this.bytes.push(GS, 0x21, ((w - 1) << 4) | (h - 1)) + return this + } + + feed(lines: number): this { + this.bytes.push(ESC, 0x64, lines) + return this + } + + /** Partial cut with pre-feed (GS V 66). */ + cut(): this { + this.bytes.push(GS, 0x56, 66, 3) + return this + } + + /** Cash-drawer kick pulse on pin 2 (ESC p). Fires on payment, not on print. */ + drawerKick(): this { + this.bytes.push(ESC, 0x70, 0, 25, 250) + return this + } + + build(): Uint8Array { + return Uint8Array.from(this.bytes) + } +} + +/** Left/right column row padded to exactly `width` chars; left side truncates. */ +export function twoCol(left: string, right: string, width: number): string { + const room = width - right.length - 1 + const l = left.length > room ? left.slice(0, Math.max(0, room)) : left + return l + ' '.repeat(Math.max(1, width - l.length - right.length)) + right +} + +export function hr(width: number): string { + return '-'.repeat(width) +} diff --git a/packages/printing/src/index.ts b/packages/printing/src/index.ts new file mode 100644 index 0000000..73bb4e0 --- /dev/null +++ b/packages/printing/src/index.ts @@ -0,0 +1,5 @@ +export * from './escpos' +export * from './receipt' +export * from './invoice-html' +export * from './labels' +export * from './raster' diff --git a/packages/printing/src/invoice-html.ts b/packages/printing/src/invoice-html.ts new file mode 100644 index 0000000..59fddbd --- /dev/null +++ b/packages/printing/src/invoice-html.ts @@ -0,0 +1,245 @@ +import { amountInWordsINR, formatINR, type BillLine, type BillTotals } from '@sims/domain' + +/** + * A4 GST tax invoice as a self-contained, printable HTML document (09-UX §5.3: + * "bilingual default for A4"). Pure — no DOM, no fetch — so the back office can + * open it in a new window and the same string is unit-testable. The thermal + * receipt (receipt.ts) stays the B2C fast path; this is the B2B/GST document. + */ + +export interface InvoiceParty { + name: string + gstin?: string + address?: string + stateCode?: string + phone?: string +} + +export interface InvoiceDoc { + seller: InvoiceParty + buyer?: InvoiceParty + /** GST invoice number (the per-counter series doc no). */ + invoiceNo: string + invoiceDate: string + /** State code of supply; drives CGST/SGST vs IGST reading on the print. */ + placeOfSupplyStateCode?: string + lines: BillLine[] + totals: BillTotals + paymentLabel?: string +} + +export interface InvoiceOptions { + /** Document heading; "Tax Invoice" for registered sellers, else "Bill of Supply". */ + title?: string + /** Bilingual footer note; a sensible GST default is used when omitted. */ + footerNote?: string + /** Set false to omit the on-page Print button (e.g. embedding). Default true. */ + printButton?: boolean +} + +/** GST state codes → names, for a readable "Place of Supply" line. */ +const STATE_NAMES: Record = { + '01': 'Jammu & Kashmir', '02': 'Himachal Pradesh', '03': 'Punjab', '04': 'Chandigarh', + '05': 'Uttarakhand', '06': 'Haryana', '07': 'Delhi', '08': 'Rajasthan', '09': 'Uttar Pradesh', + '10': 'Bihar', '11': 'Sikkim', '12': 'Arunachal Pradesh', '13': 'Nagaland', '14': 'Manipur', + '15': 'Mizoram', '16': 'Tripura', '17': 'Meghalaya', '18': 'Assam', '19': 'West Bengal', + '20': 'Jharkhand', '21': 'Odisha', '22': 'Chhattisgarh', '23': 'Madhya Pradesh', '24': 'Gujarat', + '26': 'Dadra & Nagar Haveli and Daman & Diu', '27': 'Maharashtra', '28': 'Andhra Pradesh (old)', + '29': 'Karnataka', '30': 'Goa', '31': 'Lakshadweep', '32': 'Kerala', '33': 'Tamil Nadu', + '34': 'Puducherry', '35': 'Andaman & Nicobar Islands', '36': 'Telangana', '37': 'Andhra Pradesh', + '38': 'Ladakh', '97': 'Other Territory', +} + +const esc = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +const inr = (p: number): string => formatINR(p, { symbol: false }) + +function placeOfSupply(code: string | undefined): string { + if (code === undefined || code === '') return '—' + const name = STATE_NAMES[code] + return name !== undefined ? `${code} — ${name}` : code +} + +interface HsnBucket { + hsn: string + taxRateBp: number + taxablePaise: number + cgstPaise: number + sgstPaise: number + igstPaise: number + cessPaise: number +} + +/** Collapse lines into an HSN + rate summary — the table the GST return wants. */ +function hsnBuckets(lines: BillLine[]): HsnBucket[] { + const map = new Map() + for (const l of lines) { + const key = `${l.hsn}|${l.taxRateBp}` + const b = map.get(key) ?? { hsn: l.hsn, taxRateBp: l.taxRateBp, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, cessPaise: 0 } + b.taxablePaise += l.taxablePaise + b.cgstPaise += l.cgstPaise + b.sgstPaise += l.sgstPaise + b.igstPaise += l.igstPaise + b.cessPaise += l.cessPaise + map.set(key, b) + } + return [...map.values()] +} + +const STYLE = ` + :root { color-scheme: light; } + * { box-sizing: border-box; } + body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; color: #111; margin: 0; background: #f4f4f5; } + .sheet { width: 210mm; min-height: 297mm; margin: 8mm auto; padding: 12mm; background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.15); } + h1 { font-size: 15pt; margin: 0 0 2mm; letter-spacing: .5px; } + .title { text-align: center; font-size: 12pt; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; border: 1px solid #111; padding: 2mm; margin-bottom: 4mm; } + .head { display: flex; justify-content: space-between; gap: 6mm; margin-bottom: 4mm; } + .head .blk { flex: 1; } + .muted { color: #555; font-size: 9pt; } + .grid2 { display: flex; gap: 6mm; margin-bottom: 4mm; } + .grid2 .box { flex: 1; border: 1px solid #ccc; padding: 3mm; font-size: 9.5pt; } + .box h3 { margin: 0 0 1.5mm; font-size: 9pt; text-transform: uppercase; color: #666; letter-spacing: .5px; } + table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 4mm; } + th, td { border: 1px solid #bbb; padding: 1.6mm 2mm; text-align: left; } + th { background: #f0f0f0; font-size: 8.5pt; text-transform: uppercase; letter-spacing: .3px; } + td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } + tfoot td { font-weight: 600; } + .totals { width: 46%; margin-left: auto; } + .totals td { border: none; padding: 1mm 2mm; } + .totals .grand td { border-top: 2px solid #111; font-size: 12pt; font-weight: 700; padding-top: 2mm; } + .words { border: 1px solid #ccc; padding: 3mm; font-size: 10pt; margin-bottom: 4mm; } + .words b { display: block; font-size: 8.5pt; text-transform: uppercase; color: #666; margin-bottom: 1mm; } + .foot { border-top: 1px solid #ccc; padding-top: 3mm; font-size: 9pt; color: #444; display: flex; justify-content: space-between; gap: 6mm; } + .sign { text-align: right; } + .no-print { position: fixed; top: 10px; right: 14px; } + .no-print button { font: inherit; padding: 8px 16px; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; } + @media print { body { background: #fff; } .sheet { margin: 0; box-shadow: none; width: auto; } .no-print { display: none; } } + @page { size: A4; margin: 10mm; } +` + +function partyBlock(label: string, p: InvoiceParty | undefined): string { + if (p === undefined) return `

${esc(label)}

Walk-in / Cash sale
` + return `
+

${esc(label)}

+
${esc(p.name)}
+ ${p.address !== undefined && p.address !== '' ? `
${esc(p.address)}
` : ''} + ${p.gstin !== undefined && p.gstin !== '' ? `
GSTIN: ${esc(p.gstin)}
` : '
Unregistered
'} + ${p.stateCode !== undefined && p.stateCode !== '' ? `
State: ${esc(placeOfSupply(p.stateCode))}
` : ''} + ${p.phone !== undefined && p.phone !== '' ? `
Ph: ${esc(p.phone)}
` : ''} +
` +} + +export function renderInvoiceHtml(doc: InvoiceDoc, opts: InvoiceOptions = {}): string { + const title = opts.title ?? (doc.seller.gstin !== undefined && doc.seller.gstin !== '' ? 'Tax Invoice' : 'Bill of Supply') + const t = doc.totals + const interState = t.igstPaise > 0 + const buckets = hsnBuckets(doc.lines) + const footer = opts.footerNote ?? + 'This is a computer-generated invoice and needs no signature. / यह कंप्यूटर द्वारा जनित बिल है, हस्ताक्षर की आवश्यकता नहीं।' + + const lineRows = doc.lines.map((l, i) => ` + + ${i + 1} + ${esc(l.name)} + ${esc(l.hsn)} + ${l.qty} ${esc(l.unitCode)} + ${inr(l.unitPricePaise)} + ${inr(l.taxablePaise)} + ${(l.taxRateBp / 100).toFixed(l.taxRateBp % 100 === 0 ? 0 : 2)}% + ${inr(l.lineTotalPaise)} + `).join('') + + const taxHead = interState + ? 'IGST' + : 'CGSTSGST' + const taxRows = buckets.map((b) => ` + + ${esc(b.hsn)} + ${(b.taxRateBp / 100).toFixed(b.taxRateBp % 100 === 0 ? 0 : 2)}% + ${inr(b.taxablePaise)} + ${interState ? `${inr(b.igstPaise)}` : `${inr(b.cgstPaise)}${inr(b.sgstPaise)}`} + ${inr(b.cessPaise)} + `).join('') + + const totalsRows: string[] = [ + `Taxable value${inr(t.taxablePaise)}`, + ] + if (t.discountPaise > 0) totalsRows.push(`Discount-${inr(t.discountPaise)}`) + if (interState) { + totalsRows.push(`IGST${inr(t.igstPaise)}`) + } else { + totalsRows.push(`CGST${inr(t.cgstPaise)}`) + totalsRows.push(`SGST${inr(t.sgstPaise)}`) + } + if (t.cessPaise > 0) totalsRows.push(`Cess${inr(t.cessPaise)}`) + if (t.roundOffPaise !== 0) totalsRows.push(`Round off${inr(t.roundOffPaise)}`) + + return ` + + + + +${esc(title)} ${esc(doc.invoiceNo)} + + + +${opts.printButton === false ? '' : '
'} +
+
${esc(title)}
+
+
+

${esc(doc.seller.name)}

+ ${doc.seller.address !== undefined && doc.seller.address !== '' ? `
${esc(doc.seller.address)}
` : ''} + ${doc.seller.gstin !== undefined && doc.seller.gstin !== '' ? `
GSTIN: ${esc(doc.seller.gstin)}
` : ''} + ${doc.seller.phone !== undefined && doc.seller.phone !== '' ? `
Ph: ${esc(doc.seller.phone)}
` : ''} +
+
+
Invoice No
${esc(doc.invoiceNo)}
+
Date
${esc(doc.invoiceDate)}
+
Place of Supply
${esc(placeOfSupply(doc.placeOfSupplyStateCode ?? doc.seller.stateCode))}
+
+
+ +
+ ${partyBlock('Billed To', doc.buyer)} + ${partyBlock('Supplier', doc.seller)} +
+ + + + + + + + + ${lineRows} +
#DescriptionHSNQtyRateTaxableGST%Amount
+ + + + ${taxHead} + + ${taxRows} +
HSNRateTaxableCess
+ + + + ${totalsRows.join('')} + + +
Payable${doc.paymentLabel !== undefined ? ` (${esc(doc.paymentLabel)})` : ''}${formatINR(t.payablePaise)}
+ +
+ Amount in words + ${esc(amountInWordsINR(t.payablePaise))} +
+ +
+
${esc(footer)}
+
For ${esc(doc.seller.name)}

Authorised Signatory
+
+
+ +` +} diff --git a/packages/printing/src/labels.ts b/packages/printing/src/labels.ts new file mode 100644 index 0000000..fb7c055 --- /dev/null +++ b/packages/printing/src/labels.ts @@ -0,0 +1,188 @@ +import { formatINR } from '@sims/domain' +import { ean13CheckDigit } from '@sims/scanning' + +/** + * Shelf/MRP label printing (S2). `ean13Svg` renders a standards-correct EAN-13 + * symbol (L/G/R element patterns, first-digit parity) so a scanner reads the + * printed label; `renderLabelSheetHtml` lays labels out as a printable grid for + * a laser sheet or label roll. Pure — no DOM — so both are unit-testable. + */ + +// Left-hand odd-parity (L) element patterns, digit 0–9. 7 modules each. +const L: string[] = [ + '0001101', '0011001', '0010011', '0111101', '0100011', + '0110001', '0101111', '0111011', '0110111', '0001011', +] +// Right-hand (R) patterns are the ones-complement of L. +const R: string[] = L.map((s) => [...s].map((b) => (b === '0' ? '1' : '0')).join('')) +// Left-hand even-parity (G) patterns are R read right-to-left. +const G: string[] = R.map((s) => [...s].reverse().join('')) +// First digit selects the parity mix of the six left-hand digits. +const PARITY: string[] = [ + 'LLLLLL', 'LLGLGG', 'LLGGLG', 'LLGGGL', 'LGLLGG', + 'LGGLLG', 'LGGGLL', 'LGLGLG', 'LGLGGL', 'LGGLGL', +] + +/** + * Strict validation: a 12-digit body gets its check digit computed (via the + * scanning package's `ean13CheckDigit`); a 13-digit code is verified and throws + * on a checksum mismatch. Use for validating catalog input. + */ +export function normalizeEan13(code: string): string { + const digits = code.replace(/\D/g, '') + if (digits.length === 12) return digits + String(ean13CheckDigit(digits)) + if (digits.length === 13) { + const cd = ean13CheckDigit(digits.slice(0, 12)) + if (cd !== Number(digits[12])) throw new Error(`EAN-13 checksum mismatch: expected ${cd}, got ${digits[12]}`) + return digits + } + throw new Error('EAN-13 needs 12 or 13 digits') +} + +/** + * Lenient 13-digit form for rendering: a 12-digit body gains its computed check + * digit; a 13-digit code is encoded exactly as stored (so the label round-trips + * to the catalog's barcode even when legacy data has an off check digit — the + * label must always print, R18). Only the length is enforced. + */ +function toEan13(code: string): string { + const digits = code.replace(/\D/g, '') + if (digits.length === 12) return digits + String(ean13CheckDigit(digits)) + if (digits.length === 13) return digits + throw new Error('EAN-13 needs 12 or 13 digits') +} + +/** The 95-module bar/space string (1 = bar): start · 6 left · centre · 6 right · end. */ +export function ean13Modules(code: string): string { + const c = toEan13(code) + const first = Number(c[0]) + const pattern = PARITY[first]! + let out = '101' // start guard + for (let i = 0; i < 6; i++) { + const d = Number(c[1 + i]) + out += pattern[i] === 'L' ? L[d]! : G[d]! + } + out += '01010' // centre guard + for (let i = 0; i < 6; i++) { + out += R[Number(c[7 + i])]! + } + out += '101' // end guard + return out +} + +const GUARD = new Set() +for (const i of [0, 1, 2, 45, 46, 47, 48, 49, 92, 93, 94]) GUARD.add(i) + +/** A self-contained SVG EAN-13 symbol with the human-readable digits beneath. */ +export function ean13Svg(code13: string, opts: { module?: number; height?: number } = {}): string { + const c = toEan13(code13) + const modules = ean13Modules(c) + const m = opts.module ?? 2 + const barH = opts.height ?? 56 + const guardH = barH + 7 + const quietL = 11 * m + const quietR = 7 * m + const width = quietL + 95 * m + quietR + const totalH = guardH + 14 + + const bars: string[] = [] + for (let i = 0; i < modules.length; i++) { + if (modules[i] !== '1') continue + const h = GUARD.has(i) ? guardH : barH + bars.push(``) + } + + // Human-readable: first digit in the left quiet zone, then two groups of six. + const ty = guardH + 11 + const leftMid = quietL + (3 + 21) * m + const rightMid = quietL + (50 + 21) * m + const text = + `${c[0]}` + + `${c.slice(1, 7)}` + + `${c.slice(7)}` + + return `` + + `` + + `${bars.join('')}` + + `${text}` + + `` +} + +export interface LabelInput { + name: string + mrpPaise: number + /** Barcode to print; omitted labels show text only. */ + code13?: string + /** Selling price when it differs from MRP. */ + pricePaise?: number +} + +export interface LabelSheetOptions { + /** Columns in the grid. */ + cols: number + /** Label cell size in millimetres. */ + labelWmm: number + labelHmm: number + title?: string + printButton?: boolean +} + +const esc = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +/** A printable A4 sheet of MRP/barcode labels laid out in a grid. */ +export function renderLabelSheetHtml(labels: LabelInput[], opts: LabelSheetOptions): string { + const title = opts.title ?? 'Barcode / MRP Labels' + const cells = labels.map((lab) => { + let bar = '' + if (lab.code13 !== undefined && lab.code13 !== '') { + try { + bar = `
${ean13Svg(lab.code13, { module: 2, height: 40 })}
` + } catch { + bar = `
no barcode
` + } + } + const price = lab.pricePaise !== undefined && lab.pricePaise !== lab.mrpPaise + ? `
Rate ${formatINR(lab.pricePaise)}
` + : '' + return `
+
${esc(lab.name)}
+ ${bar} +
MRP ${formatINR(lab.mrpPaise)}
+ ${price} +
` + }).join('') + + return ` + + + + +${esc(title)} + + + +${opts.printButton === false ? '' : '
'} +
+
${cells}
+
+ +` +} diff --git a/packages/printing/src/raster.ts b/packages/printing/src/raster.ts new file mode 100644 index 0000000..bcf44dd --- /dev/null +++ b/packages/printing/src/raster.ts @@ -0,0 +1,54 @@ +/** + * Raster (bit-image) receipt bytes. ESC/POS text mode can't render Devanagari + * or Malayalam (09-UX §5.3), so non-Latin receipts print as a 1-bit raster via + * GS v 0. This module is the pure part — thresholding pixels and packing bits; + * the canvas that draws the glyphs lives in the app (apps/pos/src/raster.ts), + * because canvas is a browser API and byte-packing must stay testable. + */ +const GS = 0x1d + +/** 1 bit per pixel, row-major; `bits[y*width + x]` is 1 for a printed (black) dot. */ +export interface Bitmap { + width: number + height: number + bits: Uint8Array +} + +/** + * Pack one pixel row (0/1 values) into bytes, most-significant bit = leftmost + * pixel, padded with zeros to a whole byte. 9 pixels → 2 bytes. + */ +export function packRasterRow(row: ArrayLike): number[] { + const bytes = new Array(Math.ceil(row.length / 8)).fill(0) + for (let i = 0; i < row.length; i++) { + if (row[i]) bytes[i >> 3]! |= 0x80 >> (i & 7) + } + return bytes +} + +/** + * Threshold RGBA pixel data (canvas `ImageData.data`) into a 1-bpp bitmap. + * A dot prints when it is opaque and darker than `threshold` (Rec.601 luma). + */ +export function bitonalFromRGBA( + data: ArrayLike, width: number, height: number, threshold = 128, +): Bitmap { + const bits = new Uint8Array(width * height) + for (let i = 0; i < width * height; i++) { + const r = data[i * 4]!, g = data[i * 4 + 1]!, b = data[i * 4 + 2]!, a = data[i * 4 + 3]! + const luma = 0.299 * r + 0.587 * g + 0.114 * b + bits[i] = a >= 128 && luma < threshold ? 1 : 0 + } + return { width, height, bits } +} + +/** GS v 0 raster bit-image command for a bitmap (m=0, normal density). */ +export function rasterToEscPos(bmp: Bitmap): Uint8Array { + const bytesPerRow = Math.ceil(bmp.width / 8) + const out: number[] = [GS, 0x76, 0x30, 0, bytesPerRow & 0xff, (bytesPerRow >> 8) & 0xff, bmp.height & 0xff, (bmp.height >> 8) & 0xff] + for (let y = 0; y < bmp.height; y++) { + const row = bmp.bits.subarray(y * bmp.width, (y + 1) * bmp.width) + out.push(...packRasterRow(row)) + } + return Uint8Array.from(out) +} diff --git a/packages/printing/src/receipt.ts b/packages/printing/src/receipt.ts new file mode 100644 index 0000000..b9a8f4b --- /dev/null +++ b/packages/printing/src/receipt.ts @@ -0,0 +1,112 @@ +import { formatINR, type BillLine, type BillTotals } from '@sims/domain' +import { EscPos, hr, twoCol } from './escpos' + +export interface ReceiptDoc { + storeName: string + storeAddress?: string + gstin?: string + docNo: string + businessDate: string + counterCode: string + cashierName: string + /** Attached customer (B2B or a named B2C profile), printed under the bill head. */ + buyerName?: string + /** Buyer GSTIN — its presence makes this a B2B receipt (IGST when inter-state). */ + buyerGstin?: string + lines: BillLine[] + totals: BillTotals + paymentLabel: string + footer?: string +} + +export interface ReceiptOptions { + /** Characters per line: 32 (2"), 42/48 (3"). */ + width: number + kickDrawer: boolean +} + +const inr = (p: number) => formatINR(p, { symbol: false }) + +/** + * The receipt as plain text lines — the shared content model for both the + * ASCII path (renderReceipt) and the Indic raster path (canvas → GS v 0). Item + * names print verbatim here so a Devanagari/Malayalam name survives to the + * raster renderer; the ASCII builder collapses non-Latin to '?' on its own. + */ +export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] { + const w = opts.width + const out: string[] = [] + out.push(doc.storeName) + if (doc.storeAddress !== undefined) out.push(doc.storeAddress) + if (doc.gstin !== undefined) out.push(`GSTIN: ${doc.gstin}`) + out.push(hr(w)) + out.push(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w)) + out.push(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w)) + if (doc.buyerName !== undefined) out.push(`Buyer: ${doc.buyerName}`) + if (doc.buyerGstin !== undefined) out.push(`Buyer GSTIN: ${doc.buyerGstin}`) + out.push(hr(w)) + for (const l of doc.lines) { + out.push(l.name) + out.push(twoCol(` ${l.qty} ${l.unitCode} x ${inr(l.unitPricePaise)}`, inr(l.lineTotalPaise), w)) + } + out.push(hr(w)) + out.push(twoCol('Taxable', inr(doc.totals.taxablePaise), w)) + if (doc.totals.igstPaise > 0) { + out.push(twoCol('IGST', inr(doc.totals.igstPaise), w)) + } else { + out.push(twoCol('CGST', inr(doc.totals.cgstPaise), w)) + out.push(twoCol('SGST', inr(doc.totals.sgstPaise), w)) + } + if (doc.totals.cessPaise > 0) out.push(twoCol('Cess', inr(doc.totals.cessPaise), w)) + if (doc.totals.discountPaise > 0) out.push(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w)) + if (doc.totals.roundOffPaise !== 0) out.push(twoCol('Round off', inr(doc.totals.roundOffPaise), w)) + out.push(twoCol('PAYABLE', inr(doc.totals.payablePaise), w)) + out.push(twoCol('Paid by', doc.paymentLabel, w)) + if (doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`) + out.push(doc.footer ?? 'Thank you! Visit again') + return out +} + +/** Thermal receipt bytes. Pure — golden-testable without a printer. */ +export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array { + const w = opts.width + const p = new EscPos().init() + + if (opts.kickDrawer) p.drawerKick() + + p.align('center').bold(true).size(2, 2).line(doc.storeName).size(1, 1).bold(false) + if (doc.storeAddress !== undefined) p.line(doc.storeAddress) + if (doc.gstin !== undefined) p.line(`GSTIN: ${doc.gstin}`) + p.line(hr(w)) + p.align('left') + p.line(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w)) + p.line(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w)) + if (doc.buyerName !== undefined) p.line(`Buyer: ${doc.buyerName}`) + if (doc.buyerGstin !== undefined) p.line(`Buyer GSTIN: ${doc.buyerGstin}`) + p.line(hr(w)) + + for (const l of doc.lines) { + p.line(l.name) + p.line(twoCol(` ${l.qty} ${l.unitCode} x ${inr(l.unitPricePaise)}`, inr(l.lineTotalPaise), w)) + } + + p.line(hr(w)) + p.line(twoCol('Taxable', inr(doc.totals.taxablePaise), w)) + if (doc.totals.igstPaise > 0) { + p.line(twoCol('IGST', inr(doc.totals.igstPaise), w)) + } else { + p.line(twoCol('CGST', inr(doc.totals.cgstPaise), w)) + p.line(twoCol('SGST', inr(doc.totals.sgstPaise), w)) + } + if (doc.totals.cessPaise > 0) p.line(twoCol('Cess', inr(doc.totals.cessPaise), w)) + if (doc.totals.discountPaise > 0) p.line(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w)) + if (doc.totals.roundOffPaise !== 0) p.line(twoCol('Round off', inr(doc.totals.roundOffPaise), w)) + p.bold(true).size(1, 2).line(twoCol('PAYABLE', inr(doc.totals.payablePaise), w)).size(1, 1).bold(false) + p.line(twoCol('Paid by', doc.paymentLabel, w)) + if (doc.totals.savingsVsMrpPaise > 0) { + p.align('center').line(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`) + } + p.align('center').line(doc.footer ?? 'Thank you! Visit again') + p.feed(4).cut() + return p.build() +} diff --git a/packages/printing/test/printing.test.ts b/packages/printing/test/printing.test.ts new file mode 100644 index 0000000..fd30355 --- /dev/null +++ b/packages/printing/test/printing.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { EscPos, hr, twoCol, renderReceipt } from '@sims/printing' +import type { BillLine, BillTotals } from '@sims/domain' + +describe('twoCol', () => { + it('pads to exact width and truncates long names', () => { + expect(twoCol('Atta 5kg', '270.00', 32)).toHaveLength(32) + const long = twoCol('A very very long item name indeed', '1,431.00', 32) + expect(long).toHaveLength(32) + expect(long.endsWith('1,431.00')).toBe(true) + }) + it('always keeps one space between columns', () => { + expect(twoCol('X'.repeat(40), '99.00', 32)).toContain(' 99.00') + }) +}) + +describe('EscPos builder', () => { + it('starts with init and encodes control sequences', () => { + const b = new EscPos().init().bold(true).text('HI').cut().build() + expect([...b.slice(0, 2)]).toEqual([0x1b, 0x40]) + expect([...b]).toContain(0x48) // 'H' + expect([...b.slice(-4)]).toEqual([0x1d, 0x56, 66, 3]) + }) + it('emits the standard drawer-kick pulse', () => { + const b = new EscPos().drawerKick().build() + expect([...b]).toEqual([0x1b, 0x70, 0, 25, 250]) + }) + it('collapses non-ASCII to ? in text mode (raster path comes later)', () => { + const b = new EscPos().text('चाय').build() + expect([...b]).toEqual([0x3f, 0x3f, 0x3f]) + }) +}) + +describe('renderReceipt', () => { + const line: BillLine = { + itemId: 'i1', name: 'Aashirvaad Atta 5kg', hsn: '1101', qty: 1, unitCode: 'PCS', + unitPricePaise: 27_000, mrpPaise: 28_500, grossPaise: 27_000, discountPaise: 0, + taxablePaise: 22_881, taxRateBp: 1800, cgstPaise: 2_059, sgstPaise: 2_060, + igstPaise: 0, cessPaise: 0, lineTotalPaise: 27_000, + } + const totals: BillTotals = { + grossPaise: 27_000, discountPaise: 0, taxablePaise: 22_881, cgstPaise: 2_059, + sgstPaise: 2_060, igstPaise: 0, cessPaise: 0, roundOffPaise: 0, + payablePaise: 27_000, savingsVsMrpPaise: 1_500, + } + const doc = { + storeName: 'MegaMart', docNo: 'ST1C2/26-000123', businessDate: '2026-07-09', + counterCode: 'C2', cashierName: 'Ramesh', lines: [line], totals, paymentLabel: 'CASH', + } + + it('renders a complete receipt with cut at the end', () => { + const bytes = renderReceipt(doc, { width: 42, kickDrawer: false }) + const text = new TextDecoder('ascii').decode(bytes) + expect(text).toContain('PAYABLE') + expect(text).toContain('ST1C2/26-000123') + expect(text).toContain('You saved 15.00 vs MRP') + expect([...bytes.slice(-4)]).toEqual([0x1d, 0x56, 66, 3]) + }) + it('kicks the drawer before printing only when asked', () => { + const withKick = renderReceipt(doc, { width: 42, kickDrawer: true }) + expect([...withKick.slice(2, 7)]).toEqual([0x1b, 0x70, 0, 25, 250]) + const noKick = renderReceipt(doc, { width: 42, kickDrawer: false }) + expect([...noKick.slice(2, 7)]).not.toEqual([0x1b, 0x70, 0, 25, 250]) + }) + it(`shows IGST instead of the split for inter-state bills`, () => { + const inter = renderReceipt( + { ...doc, totals: { ...totals, cgstPaise: 0, sgstPaise: 0, igstPaise: 4_119 } }, + { width: 42, kickDrawer: false }, + ) + const text = new TextDecoder('ascii').decode(inter) + expect(text).toContain('IGST') + expect(text).not.toContain('CGST') + }) + it('prints the buyer GSTIN line for a B2B receipt', () => { + const b2b = renderReceipt( + { ...doc, buyerName: 'Anand Traders', buyerGstin: '27ABCDE1234F1Z0' }, + { width: 42, kickDrawer: false }, + ) + const text = new TextDecoder('ascii').decode(b2b) + expect(text).toContain('Buyer: Anand Traders') + expect(text).toContain('Buyer GSTIN: 27ABCDE1234F1Z0') + }) +}) diff --git a/packages/printing/test/s2-print.test.ts b/packages/printing/test/s2-print.test.ts new file mode 100644 index 0000000..89870d7 --- /dev/null +++ b/packages/printing/test/s2-print.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { amountInWordsINR, type BillLine, type BillTotals } from '@sims/domain' +import { + renderInvoiceHtml, type InvoiceDoc, + ean13Modules, ean13Svg, normalizeEan13, renderLabelSheetHtml, + packRasterRow, bitonalFromRGBA, rasterToEscPos, +} from '@sims/printing' + +describe('amountInWordsINR — Indian numbering', () => { + it('zero', () => { + expect(amountInWordsINR(0)).toBe('Rupees Zero Only') + }) + it('paisa only', () => { + expect(amountInWordsINR(50)).toBe('Fifty Paise Only') + }) + it('a single paisa is singular', () => { + expect(amountInWordsINR(1)).toBe('One Paisa Only') + }) + it('one lakh rupees', () => { + expect(amountInWordsINR(1_00_000 * 100)).toBe('Rupees One Lakh Only') + }) + it('one crore rupees', () => { + expect(amountInWordsINR(1_00_00_000 * 100)).toBe('Rupees One Crore Only') + }) + it('mixed rupees and paise', () => { + expect(amountInWordsINR(1431_50)).toBe('Rupees One Thousand Four Hundred Thirty One and Fifty Paise Only') + }) +}) + +const line: BillLine = { + itemId: 'i1', name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS', + unitPricePaise: 14_500, mrpPaise: 15_000, grossPaise: 14_500, discountPaise: 0, + taxablePaise: 12_288, taxRateBp: 1800, cgstPaise: 1_106, sgstPaise: 1_106, + igstPaise: 0, cessPaise: 0, lineTotalPaise: 14_500, +} +const totals: BillTotals = { + grossPaise: 14_500, discountPaise: 0, taxablePaise: 12_288, cgstPaise: 1_106, + sgstPaise: 1_106, igstPaise: 0, cessPaise: 0, roundOffPaise: 0, + payablePaise: 14_500, savingsVsMrpPaise: 500, +} +const invoice: InvoiceDoc = { + seller: { name: 'MegaMart T.Nagar', gstin: '32ABCDE1234F1Z5', stateCode: '32', address: 'T.Nagar, Chennai' }, + buyer: { name: 'Lakshmi', phone: '9840012345' }, + invoiceNo: 'ST1C2/26-000001', invoiceDate: '2026-07-10', + placeOfSupplyStateCode: '32', lines: [line], totals, paymentLabel: 'CASH', +} + +describe('renderInvoiceHtml — A4 GST invoice', () => { + it('contains the key GST-invoice fields', () => { + const html = renderInvoiceHtml(invoice) + expect(html).toContain('Tax Invoice') + expect(html).toContain('MegaMart T.Nagar') + expect(html).toContain('32ABCDE1234F1Z5') + expect(html).toContain('ST1C2/26-000001') + expect(html).toContain('Surf Excel 1kg') + expect(html).toContain('3402') // HSN + expect(html).toContain('CGST') + expect(html).toContain('SGST') + expect(html).toContain('Place of Supply') + expect(html).toContain('Kerala') // state 32 resolved + }) + it('renders the amount in words', () => { + const html = renderInvoiceHtml(invoice) + expect(html).toContain('Amount in words') + expect(html).toContain(amountInWordsINR(totals.payablePaise)) // Rupees One Hundred Forty Five Only + }) + it('shows IGST (not CGST/SGST) for an inter-state bill', () => { + const inter = renderInvoiceHtml({ + ...invoice, + totals: { ...totals, cgstPaise: 0, sgstPaise: 0, igstPaise: 2_212 }, + lines: [{ ...line, cgstPaise: 0, sgstPaise: 0, igstPaise: 2_212 }], + placeOfSupplyStateCode: '29', + }) + expect(inter).toContain('IGST') + expect(inter).not.toContain('CGST') + }) + it('is a self-contained HTML document', () => { + const html = renderInvoiceHtml(invoice) + expect(html.startsWith('')).toBe(true) + expect(html).toContain('') + expect(html).toContain('window.print()') + }) +}) + +describe('ean13 encoding', () => { + it('encodes the standard 95-module structure (all-zero symbol)', () => { + const m = ean13Modules('000000000000') // 12 digits → check digit appended + expect(m).toHaveLength(95) + expect(m.startsWith('101')).toBe(true) // start guard + expect(m.endsWith('101')).toBe(true) // end guard + expect(m.slice(45, 50)).toBe('01010') // centre guard + // first digit 0 → all L; L(0) = 0001101, so the first data element follows the start guard + expect(m.startsWith('101' + '0001101')).toBe(true) + }) + it('computes the check digit via ean13CheckDigit for a 12-digit body', () => { + expect(normalizeEan13('890106301435')).toBe('8901063014350') + }) + it('rejects a 13-digit code with a bad checksum (strict path)', () => { + expect(() => normalizeEan13('8901063014357')).toThrow(/checksum/) + }) + it('still renders an SVG for legacy 13-digit data (lenient path, R18)', () => { + const svg = ean13Svg('8901063014357') + expect(svg.startsWith('')).toBe(true) + expect(svg).toContain('8901063014357') // human-readable matches stored code + }) +}) + +describe('renderLabelSheetHtml', () => { + it('lays out labels with barcode, name and MRP', () => { + const html = renderLabelSheetHtml( + [{ name: 'Surf Excel 1kg', mrpPaise: 15_000, code13: '8901030704833', pricePaise: 14_500 }], + { cols: 3, labelWmm: 38, labelHmm: 25 }, + ) + expect(html).toContain('Surf Excel 1kg') + expect(html).toContain('MRP') + expect(html).toContain(' { + const html = renderLabelSheetHtml([{ name: 'Loose Tomato', mrpPaise: 3_200 }], { cols: 2, labelWmm: 50, labelHmm: 30 }) + expect(html).toContain('Loose Tomato') + expect(html).not.toContain(' { + it('packs a byte MSB-first', () => { + expect(packRasterRow([1, 0, 1, 0, 0, 0, 0, 0])).toEqual([0xa0]) + expect(packRasterRow([0, 0, 0, 0, 0, 0, 0, 1])).toEqual([0x01]) + }) + it('pads a partial trailing byte with zeros', () => { + expect(packRasterRow([1, 1, 1, 1, 1, 1, 1, 1, 1])).toEqual([0xff, 0x80]) + }) + it('thresholds RGBA to bitonal (dark = printed dot)', () => { + // two pixels: black then white + const data = [0, 0, 0, 255, 255, 255, 255, 255] + const bmp = bitonalFromRGBA(data, 2, 1) + expect([...bmp.bits]).toEqual([1, 0]) + }) + it('emits a GS v 0 raster command with the right header', () => { + const bytes = rasterToEscPos({ width: 8, height: 1, bits: Uint8Array.from([1, 0, 1, 0, 0, 0, 0, 0]) }) + expect([...bytes]).toEqual([0x1d, 0x76, 0x30, 0, 1, 0, 1, 0, 0xa0]) + }) +}) diff --git a/packages/scanning/package.json b/packages/scanning/package.json new file mode 100644 index 0000000..e3fb73e --- /dev/null +++ b/packages/scanning/package.json @@ -0,0 +1,8 @@ +{ + "name": "@sims/scanning", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/scanning/src/barcode.ts b/packages/scanning/src/barcode.ts new file mode 100644 index 0000000..68cacaf --- /dev/null +++ b/packages/scanning/src/barcode.ts @@ -0,0 +1,13 @@ +/** EAN-13 check digit: weights alternate 1,3 over the first 12 digits. */ +export function ean13CheckDigit(first12: string): number { + if (!/^\d{12}$/.test(first12)) throw new Error('EAN-13 body must be 12 digits') + let sum = 0 + for (let i = 0; i < 12; i++) { + sum += Number(first12[i]) * (i % 2 === 0 ? 1 : 3) + } + return (10 - (sum % 10)) % 10 +} + +export function isValidEan13(code: string): boolean { + return /^\d{13}$/.test(code) && ean13CheckDigit(code.slice(0, 12)) === Number(code[12]) +} diff --git a/packages/scanning/src/entry.ts b/packages/scanning/src/entry.ts new file mode 100644 index 0000000..4d204d3 --- /dev/null +++ b/packages/scanning/src/entry.ts @@ -0,0 +1,32 @@ +/** + * The smart scan box grammar (09-UX §1.2): one input, no modes. Typed and + * scanned input both land here; the caller resolves codes against the item + * cache and scale profile. + */ +export type EntryParse = + | { kind: 'empty' } + | { kind: 'multiplier'; qty: number; rest: string } + | { kind: 'barcode'; code: string } + | { kind: 'code'; code: string } + | { kind: 'search'; text: string } + +const MULTIPLIER = /^(\d{1,4}(?:\.\d{1,3})?)\*(.*)$/ + +export function parseEntry(raw: string): EntryParse { + const input = raw.trim() + if (input === '') return { kind: 'empty' } + + const mult = MULTIPLIER.exec(input) + if (mult !== null) { + const qty = Number(mult[1]) + if (qty > 0) return { kind: 'multiplier', qty, rest: mult[2]!.trim() } + } + + if (/^\d+$/.test(input)) { + // 8–14 digits reads as a barcode/item code; shorter digits are still codes + // (quick-key PLUs); resolution order is the caller's concern. + return input.length >= 8 ? { kind: 'barcode', code: input } : { kind: 'code', code: input } + } + + return { kind: 'search', text: input } +} diff --git a/packages/scanning/src/index.ts b/packages/scanning/src/index.ts new file mode 100644 index 0000000..13f6b8a --- /dev/null +++ b/packages/scanning/src/index.ts @@ -0,0 +1,4 @@ +export * from './barcode' +export * from './scale' +export * from './entry' +export * from './wedge' diff --git a/packages/scanning/src/scale.ts b/packages/scanning/src/scale.ts new file mode 100644 index 0000000..10de4ba --- /dev/null +++ b/packages/scanning/src/scale.ts @@ -0,0 +1,38 @@ +import { isValidEan13 } from './barcode' + +/** + * Label-scale barcodes: EAN-13 in the GS1 restricted-circulation range + * (prefix 20–29) with the item PLU and a weight or price embedded — the + * supermarket produce path where every scan must be zero keystrokes. + * Layout: [2-digit prefix][PLU][value][check]; PLU + value = 10 digits. + * The layout is per-store scale configuration, hence a profile row. + */ +export interface ScaleBarcodeProfile { + /** Which 2-digit prefixes this store's scales emit, e.g. ['21']. */ + prefixes: string[] + pluLength: 4 | 5 | 6 + valueKind: 'weight' | 'price' + /** value → quantity (weight: grams with divisor 1000 → kg) or paise. */ + valueDivisor: number +} + +export type ScaleParse = + | { kind: 'scale-weight'; plu: string; qty: number } + | { kind: 'scale-price'; plu: string; pricePaise: number } + | { kind: 'not-scale' } + | { kind: 'invalid'; reason: string } + +export function parseScaleBarcode(code: string, profile: ScaleBarcodeProfile): ScaleParse { + if (!/^\d{13}$/.test(code)) return { kind: 'not-scale' } + if (!profile.prefixes.includes(code.slice(0, 2))) return { kind: 'not-scale' } + if (!isValidEan13(code)) return { kind: 'invalid', reason: 'check-digit' } + const valueLength = 10 - profile.pluLength + const plu = code.slice(2, 2 + profile.pluLength) + const value = Number(code.slice(2 + profile.pluLength, 2 + profile.pluLength + valueLength)) + if (profile.valueKind === 'weight') { + const qty = value / profile.valueDivisor + if (qty <= 0) return { kind: 'invalid', reason: 'zero-weight' } + return { kind: 'scale-weight', plu, qty } + } + return { kind: 'scale-price', plu, pricePaise: value } +} diff --git a/packages/scanning/src/wedge.ts b/packages/scanning/src/wedge.ts new file mode 100644 index 0000000..93e37c8 --- /dev/null +++ b/packages/scanning/src/wedge.ts @@ -0,0 +1,48 @@ +/** + * Keyboard-wedge scanners type like superhuman typists: a burst of characters + * with tiny inter-key gaps, terminated by Enter. This detector distinguishes a + * scan from human typing purely from timing + length, with injected timestamps + * so it is testable without a DOM or a clock. + */ +export interface WedgeOptions { + /** Max gap between scanner keystrokes; humans are slower. */ + maxInterKeyMs: number + /** Bursts shorter than this are treated as typed input. */ + minLength: number +} + +export const DEFAULT_WEDGE: WedgeOptions = { maxInterKeyMs: 35, minLength: 6 } + +export type WedgeResult = { type: 'scan'; code: string } | { type: 'typed'; text: string } + +export class WedgeDetector { + private chars: string[] = [] + private lastAtMs: number | undefined + private maxGapMs = 0 + + constructor(private readonly opts: WedgeOptions = DEFAULT_WEDGE) {} + + feed(char: string, atMs: number): void { + if (char.length !== 1) throw new Error('feed() takes single characters') + if (this.lastAtMs !== undefined) { + this.maxGapMs = Math.max(this.maxGapMs, atMs - this.lastAtMs) + } + this.lastAtMs = atMs + this.chars.push(char) + } + + /** Call on Enter. Decides scan vs typed and resets for the next burst. */ + terminate(): WedgeResult { + const text = this.chars.join('') + const isScan = + this.chars.length >= this.opts.minLength && this.maxGapMs <= this.opts.maxInterKeyMs + this.reset() + return isScan ? { type: 'scan', code: text } : { type: 'typed', text } + } + + reset(): void { + this.chars = [] + this.lastAtMs = undefined + this.maxGapMs = 0 + } +} diff --git a/packages/scanning/test/scanning.test.ts b/packages/scanning/test/scanning.test.ts new file mode 100644 index 0000000..e93a0a4 --- /dev/null +++ b/packages/scanning/test/scanning.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + ean13CheckDigit, isValidEan13, + parseScaleBarcode, type ScaleBarcodeProfile, + parseEntry, + WedgeDetector, +} from '@sims/scanning' + +describe('EAN-13', () => { + it('computes and validates check digits', () => { + expect(ean13CheckDigit('400638133393')).toBe(1) + expect(isValidEan13('4006381333931')).toBe(true) + expect(isValidEan13('4006381333932')).toBe(false) + expect(isValidEan13('123')).toBe(false) + }) +}) + +describe('label-scale barcodes (2x prefix)', () => { + const profile: ScaleBarcodeProfile = { + prefixes: ['21'], + pluLength: 5, + valueKind: 'weight', + valueDivisor: 1000, + } + const body = '210004201250' // prefix 21 · PLU 00042 · 1250 g + const code = body + String(ean13CheckDigit(body)) + + it('extracts PLU and weight as quantity', () => { + expect(parseScaleBarcode(code, profile)).toEqual({ kind: 'scale-weight', plu: '00042', qty: 1.25 }) + }) + it('rejects a corrupted check digit instead of guessing a weight', () => { + const bad = body + String((ean13CheckDigit(body) + 1) % 10) + expect(parseScaleBarcode(bad, profile)).toEqual({ kind: 'invalid', reason: 'check-digit' }) + }) + it('passes non-scale codes through untouched', () => { + expect(parseScaleBarcode('4006381333931', profile)).toEqual({ kind: 'not-scale' }) + }) + it('supports price-embedded profiles', () => { + const priceProfile: ScaleBarcodeProfile = { ...profile, valueKind: 'price', valueDivisor: 1 } + expect(parseScaleBarcode(code, priceProfile)).toEqual({ + kind: 'scale-price', + plu: '00042', + pricePaise: 1250, + }) + }) +}) + +describe('scan-box entry grammar', () => { + it('parses the four grammars without modes', () => { + expect(parseEntry('')).toEqual({ kind: 'empty' }) + expect(parseEntry('3*')).toEqual({ kind: 'multiplier', qty: 3, rest: '' }) + expect(parseEntry('1.5*atta')).toEqual({ kind: 'multiplier', qty: 1.5, rest: 'atta' }) + expect(parseEntry('4006381333931')).toEqual({ kind: 'barcode', code: '4006381333931' }) + expect(parseEntry('42')).toEqual({ kind: 'code', code: '42' }) + expect(parseEntry('chawal')).toEqual({ kind: 'search', text: 'chawal' }) + }) +}) + +describe('wedge detector — scanner vs human, by timing', () => { + it('classifies a fast burst as a scan', () => { + const d = new WedgeDetector() + '4006381333931'.split('').forEach((c, i) => d.feed(c, i * 10)) + expect(d.terminate()).toEqual({ type: 'scan', code: '4006381333931' }) + }) + it('classifies human-speed typing as typed input', () => { + const d = new WedgeDetector() + '400638'.split('').forEach((c, i) => d.feed(c, i * 120)) + expect(d.terminate()).toEqual({ type: 'typed', text: '400638' }) + }) + it('treats short fast bursts as typed (quick-key PLUs)', () => { + const d = new WedgeDetector() + '42'.split('').forEach((c, i) => d.feed(c, i * 5)) + expect(d.terminate()).toEqual({ type: 'typed', text: '42' }) + }) + it('resets cleanly between bursts', () => { + const d = new WedgeDetector() + d.feed('9', 0) + d.terminate() + '4006381333931'.split('').forEach((c, i) => d.feed(c, 1000 + i * 10)) + expect(d.terminate().type).toBe('scan') + }) +}) diff --git a/packages/search-core/package.json b/packages/search-core/package.json new file mode 100644 index 0000000..26ff5b6 --- /dev/null +++ b/packages/search-core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@sims/search-core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@sims/domain": "*" + } +} diff --git a/packages/search-core/src/index.ts b/packages/search-core/src/index.ts new file mode 100644 index 0000000..0c8284d --- /dev/null +++ b/packages/search-core/src/index.ts @@ -0,0 +1,100 @@ +import type { Item } from '@sims/domain' + +/** + * Catalog search (13-SPEC-BILLING-POLISH §P0-1): one pure engine, two profiles — + * the POS scan-box dropdown today, the back-office Ctrl+K later. No deps, no + * network, no debounce: an in-memory index and a synchronous tiered query so the + * counter never waits on a keystroke. + */ + +/** lowercase, punctuation → space, spaces collapsed. Digits kept (e.g. "atta 5kg"). */ +export function normalize(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * The Indic phonetic fold (§5.1): collapses the spellings the same word acquires + * across scripts and transliterations so `chawal`/`chaval`, `paneer`/`panir`, + * `jeera`/`zeera` share one key. Digraph→letter rules run before the + * doubled-letter drop so long vowels fold to the intended short vowel first. + */ +export function phoneticKey(s: string): string { + let w = normalize(s).replace(/[^a-z]/g, '') + w = w.replace(/chh/g, 'ch') + w = w.replace(/aa/g, 'a') + w = w.replace(/ee/g, 'i') + w = w.replace(/oo/g, 'u') + w = w.replace(/ph/g, 'f') + w = w.replace(/bh/g, 'b') + w = w.replace(/sh/g, 's') + w = w.replace(/th/g, 't') + w = w.replace(/dh/g, 'd') + w = w.replace(/w/g, 'v') + w = w.replace(/z/g, 'j') + w = w.replace(/(.)\1+/g, '$1') + return w +} + +interface IndexedItem { + item: Item + name: string + secondary: string + tokens: string[] + namePhonetic: string + tokenPhonetics: string[] +} + +export interface SearchIndex { + items: IndexedItem[] +} + +/** Precompute tokens + phonetic keys once; the POS memoizes this on its item cache. */ +export function buildIndex(items: Item[]): SearchIndex { + return { + items: items.map((item) => { + const name = normalize(item.name) + const secondary = item.nameSecondary !== undefined ? normalize(item.nameSecondary) : '' + const tokens = `${name} ${secondary}`.split(' ').filter((t) => t !== '') + return { + item, name, secondary, tokens, + namePhonetic: phoneticKey(name), + tokenPhonetics: tokens.map((t) => phoneticKey(t)), + } + }), + } +} + +// Lower tier wins (1 is best). Whole-prefix → token-prefix → phonetic → substring. +const TIER_WHOLE_PREFIX = 1 +const TIER_TOKEN_PREFIX = 2 +const TIER_PHONETIC = 3 +const TIER_SUBSTRING = 4 + +function tierOf(e: IndexedItem, q: string, pq: string): number { + if (e.name.startsWith(q)) return TIER_WHOLE_PREFIX + if (e.tokens.some((t) => t.startsWith(q))) return TIER_TOKEN_PREFIX + if (pq !== '' && (e.namePhonetic.startsWith(pq) || e.tokenPhonetics.some((p) => p.startsWith(pq)))) return TIER_PHONETIC + if (e.name.includes(q) || e.secondary.includes(q)) return TIER_SUBSTRING + return 0 +} + +/** + * Tier-ranked matches for `text`. Tie-break: shorter name first, then A→Z + * (velocity ranking lands with P1-5). Caller slices to its row budget (≤ 8). + */ +export function querySearch(index: SearchIndex, text: string): Item[] { + const q = normalize(text) + if (q === '') return [] + const pq = phoneticKey(q) + const ranked: { item: Item; tier: number; len: number; name: string }[] = [] + for (const e of index.items) { + const tier = tierOf(e, q, pq) + if (tier !== 0) ranked.push({ item: e.item, tier, len: e.item.name.length, name: e.name }) + } + ranked.sort((a, b) => a.tier - b.tier || a.len - b.len || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + return ranked.map((r) => r.item) +} diff --git a/packages/search-core/test/search-core.test.ts b/packages/search-core/test/search-core.test.ts new file mode 100644 index 0000000..fcc17ab --- /dev/null +++ b/packages/search-core/test/search-core.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import type { Item } from '@sims/domain' +import { buildIndex, normalize, phoneticKey, querySearch } from '@sims/search-core' + +function item(name: string, extra: Partial = {}): Item { + return { + id: name, tenantId: 't1', code: name.replace(/\s+/g, '').toLowerCase(), name, + hsn: '', taxClassCode: 'GST18', unit: { code: 'PCS', decimals: 0 }, + salePricePaise: 1000, priceIncludesTax: true, barcodes: [], batchTracked: false, + status: 'active', ...extra, + } +} + +const names = (items: Item[]): string[] => items.map((i) => i.name) + +describe('normalize', () => { + it('lowercases, strips punctuation, collapses spaces, keeps digits', () => { + expect(normalize(' Aashirvaad Atta, 5kg! ')).toBe('aashirvaad atta 5kg') + expect(normalize('Coca-Cola')).toBe('coca cola') + }) +}) + +describe('phoneticKey — Indic folding', () => { + it('folds the mandated collisions to one key', () => { + expect(phoneticKey('chawal')).toBe(phoneticKey('chaval')) // w→v (the mandatory case) + expect(phoneticKey('paneer')).toBe(phoneticKey('panir')) // ee→i + expect(phoneticKey('jeera')).toBe(phoneticKey('zeera')) // z→j + ee→i + expect(phoneticKey('phal')).toBe(phoneticKey('fal')) // ph→f + expect(phoneticKey('sharbat')).toBe(phoneticKey('sarbat')) // sh→s + expect(phoneticKey('dhaniya')).toBe(phoneticKey('daniya')) // dh→d + expect(phoneticKey('bhindi')).toBe(phoneticKey('bindi')) // bh→b + expect(phoneticKey('thums')).toBe(phoneticKey('tums')) // th→t + }) + it('folds long vowels to short', () => { + expect(phoneticKey('aata')).toBe('ata') // aa→a + expect(phoneticKey('cheese')).toBe('chise') // ee→i (trailing vowel kept) + expect(phoneticKey('noodle')).toBe(phoneticKey('nudle')) // oo→u + }) + it('drops doubled letters', () => { + expect(phoneticKey('kotta')).toBe(phoneticKey('kota')) + expect(phoneticKey('mummy')).toBe('mumy') + }) + it('is stable for already-folded input', () => { + expect(phoneticKey('chaval')).toBe('chaval') + }) +}) + +describe('querySearch — tiered ranking', () => { + const cat = [ + item('Tomato'), + item('Tomato Ketchup'), + item('Green Tomato'), + item('Bottom Shelf Oil'), + ] + const index = buildIndex(cat) + + it('orders whole-prefix before token-prefix before substring', () => { + const out = names(querySearch(index, 'tom')) + // tier 1 (name prefix, shorter first), tier 2 (token prefix), tier 4 (substring) + expect(out).toEqual(['Tomato', 'Tomato Ketchup', 'Green Tomato', 'Bottom Shelf Oil']) + }) + + it('breaks ties on the shorter name', () => { + const out = names(querySearch(buildIndex([item('Tomato Ketchup'), item('Tomato')]), 'tomato')) + expect(out).toEqual(['Tomato', 'Tomato Ketchup']) + }) + + it('returns nothing for an empty query', () => { + expect(querySearch(index, ' ')).toEqual([]) + }) +}) + +describe('querySearch — phonetic tier finds transliteration variants', () => { + it('finds a Chawal-spelled item when the cashier types "chaval" and vice versa', () => { + const index = buildIndex([item('Chawal Basmati'), item('Sugar')]) + expect(names(querySearch(index, 'chaval'))).toContain('Chawal Basmati') + const index2 = buildIndex([item('Chaval Premium'), item('Sugar')]) + expect(names(querySearch(index2, 'chawal'))).toContain('Chaval Premium') + }) + + it('ranks an exact prefix above a phonetic-only match', () => { + const index = buildIndex([item('Chaval Rice'), item('Chawal Gold')]) + // typing "chaw": "Chawal Gold" is a whole-prefix (tier 1), "Chaval Rice" only phonetic (tier 3) + expect(names(querySearch(index, 'chaw'))).toEqual(['Chawal Gold', 'Chaval Rice']) + }) + + it('matches against the regional secondary name', () => { + const index = buildIndex([item('Coriander', { nameSecondary: 'Dhaniya' })]) + expect(names(querySearch(index, 'daniya'))).toContain('Coriander') + }) +}) diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..96f755a --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,16 @@ +{ + "name": "@sims/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@fontsource-variable/inter": "^5.1.0", + "@fontsource-variable/jetbrains-mono": "^5.1.0", + "react": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0" + } +} diff --git a/packages/ui/src/ThemeSwitcher.tsx b/packages/ui/src/ThemeSwitcher.tsx new file mode 100644 index 0000000..1ca5bba --- /dev/null +++ b/packages/ui/src/ThemeSwitcher.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' +import { ACCENTS, getAccent, getThemeMode, setAccent, setThemeMode, type Accent } from './theme' + +const SWATCH: Record = { + indigo: '#4f5df0', + blue: '#2f7ce8', + emerald: '#14957a', + violet: '#8250df', + rose: '#d4497c', + amber: '#b97e10', +} + +/** Compact mode toggle + accent picker; drops into any header. */ +export function ThemeSwitcher() { + const [mode, setMode] = useState(getThemeMode()) + const [accent, setAccentState] = useState(getAccent()) + + return ( +
+ {ACCENTS.map((a) => ( + +
+ ) +} diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx new file mode 100644 index 0000000..4d9f133 --- /dev/null +++ b/packages/ui/src/components.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from 'react' + +/** Wireframe kit: structure-first components shared by POS and back office. */ + +export function PageHeader(props: { title: string; desc?: string; badge?: string; actions?: ReactNode }) { + return ( + <> +
+

{props.title}

+ {props.badge !== undefined && {props.badge}} +
+ {props.actions} +
+ {props.desc !== undefined &&

{props.desc}

} + + ) +} + +export function Toolbar(props: { children: ReactNode }) { + return
{props.children}
+} + +export function Button(props: { + children: ReactNode + onClick?: () => void + tone?: 'default' | 'primary' | 'danger' + hotkey?: string +}) { + const cls = `wf${props.tone === 'primary' ? ' primary' : props.tone === 'danger' ? ' danger' : ''}` + return ( + + ) +} + +export function Badge(props: { children: ReactNode; tone?: 'ok' | 'warn' | 'err' | 'accent' }) { + return {props.children} +} + +export interface Column { + key: string + label: string + numeric?: boolean +} + +export function DataTable(props: { + columns: Column[] + rows: Record[] + onRowClick?: (row: Record, index: number) => void +}) { + return ( + + + + {props.columns.map((c) => ( + + ))} + + + + {props.rows.map((row, i) => ( + props.onRowClick?.(row, i)} style={props.onRowClick !== undefined ? { cursor: 'pointer' } : undefined}> + {props.columns.map((c) => ( + + ))} + + ))} + +
{c.label}
{row[c.key]}
+ ) +} + +export function StatCard(props: { label: string; value: ReactNode; hint?: string }) { + return ( +
+
{props.label}
+
{props.value}
+ {props.hint !== undefined &&
{props.hint}
} +
+ ) +} + +export function Stats(props: { children: ReactNode }) { + return
{props.children}
+} + +export function EmptyState(props: { children: ReactNode }) { + return
{props.children}
+} + +export function Field(props: { label: string; children: ReactNode }) { + return ( +
+ + {props.children} +
+ ) +} + +export function Modal(props: { title: string; onClose: () => void; children: ReactNode }) { + return ( +
+
e.stopPropagation()}> +

{props.title}

+ {props.children} +
+
+ ) +} + +export function Notice(props: { tone?: 'ok' | 'warn' | 'err'; children: ReactNode }) { + return
{props.children}
+} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..e0f1319 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,3 @@ +export * from './components' +export * from './theme' +export * from './ThemeSwitcher' diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts new file mode 100644 index 0000000..674a955 --- /dev/null +++ b/packages/ui/src/theme.ts @@ -0,0 +1,37 @@ +/** + * Theme runtime: mode (light/dark) + accent, applied as data attributes on + * and persisted. localStorage for now; these become user-scope settings + * rows once the DB layer lands (config-driven principle). + */ +export type ThemeMode = 'light' | 'dark' + +export const ACCENTS = ['indigo', 'blue', 'emerald', 'violet', 'rose', 'amber'] as const +export type Accent = (typeof ACCENTS)[number] + +const MODE_KEY = 'ui.themeMode' +const ACCENT_KEY = 'ui.accent' + +export function getThemeMode(): ThemeMode { + return localStorage.getItem(MODE_KEY) === 'dark' ? 'dark' : 'light' +} + +export function getAccent(): Accent { + const stored = localStorage.getItem(ACCENT_KEY) + return (ACCENTS as readonly string[]).includes(stored ?? '') ? (stored as Accent) : 'indigo' +} + +export function setThemeMode(mode: ThemeMode): void { + localStorage.setItem(MODE_KEY, mode) + document.documentElement.dataset['theme'] = mode +} + +export function setAccent(accent: Accent): void { + localStorage.setItem(ACCENT_KEY, accent) + document.documentElement.dataset['accent'] = accent +} + +/** Call once before first render. */ +export function initTheme(): void { + document.documentElement.dataset['theme'] = getThemeMode() + document.documentElement.dataset['accent'] = getAccent() +} diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css new file mode 100644 index 0000000..078abd2 --- /dev/null +++ b/packages/ui/src/tokens.css @@ -0,0 +1,240 @@ +/* ================================================================ + SiMS Next design system — premium, themeable, offline-safe. + References: Stripe Dashboard (light clarity), Linear (dark, crisp + hairlines), Notion (neutral warmth). Fonts are bundled (Inter + Variable + JetBrains Mono Variable) — shop fonts are never trusted. + Mode + accent apply via (theme.ts). + Class names are stable: restyling here restyles every screen. + ================================================================ */ + +:root { + --font: 'Inter Variable', 'Segoe UI', system-ui, -apple-system, sans-serif; + --mono: 'JetBrains Mono Variable', 'Cascadia Mono', Consolas, monospace; + + --radius: 8px; + --radius-lg: 14px; + --pad: 12px; + --speed: 140ms; + + --shadow-sm: 0 1px 2px rgba(16, 20, 28, 0.06); + --shadow-md: 0 2px 8px rgba(16, 20, 28, 0.08), 0 1px 2px rgba(16, 20, 28, 0.05); + --shadow-lg: 0 16px 48px rgba(16, 20, 28, 0.22), 0 4px 12px rgba(16, 20, 28, 0.1); +} + +/* ---- accents (option to change colour) ---- */ +[data-accent='indigo'] { --accent: #4f5df0; --accent-strong: #3f4cd9; --accent-contrast: #ffffff; } +[data-accent='blue'] { --accent: #2f7ce8; --accent-strong: #2368ca; --accent-contrast: #ffffff; } +[data-accent='emerald'] { --accent: #14957a; --accent-strong: #0f7f68; --accent-contrast: #ffffff; } +[data-accent='violet'] { --accent: #8250df; --accent-strong: #6f42c1; --accent-contrast: #ffffff; } +[data-accent='rose'] { --accent: #d4497c; --accent-strong: #bb3a6a; --accent-contrast: #ffffff; } +[data-accent='amber'] { --accent: #b97e10; --accent-strong: #a06a08; --accent-contrast: #ffffff; } + +/* ---- light (default): Stripe-like clarity ---- */ +:root, [data-theme='light'] { + --bg: #f6f7f9; + --bg-raised: #ffffff; + --bg-hover: #f0f2f5; + --bg-inset: #eef0f3; + --border: #e4e7ec; + --border-strong: #d4d9e0; + --text: #1a1f27; + --text-dim: #667084; + --ok: #147a52; + --warn: #9a6700; + --err: #c93a3a; + --accent-soft: color-mix(in srgb, var(--accent) 10%, var(--bg-raised)); + --ok-soft: #e5f5ec; + --warn-soft: #fcf3dc; + --err-soft: #fdecec; + --scrollbar: #c9cfd8; + color-scheme: light; +} + +/* ---- dark: Linear-like depth ---- */ +[data-theme='dark'] { + --bg: #0e1116; + --bg-raised: #161a21; + --bg-hover: #1e242e; + --bg-inset: #0a0d11; + --border: #262c37; + --border-strong: #333b49; + --text: #e8ecf1; + --text-dim: #8b95a6; + --ok: #43c288; + --warn: #e6b23e; + --err: #ef6a63; + --accent: color-mix(in srgb, var(--accent-strong) 20%, #7c8cff); + --accent-soft: color-mix(in srgb, var(--accent) 16%, var(--bg-raised)); + --ok-soft: color-mix(in srgb, #43c288 14%, var(--bg-raised)); + --warn-soft: color-mix(in srgb, #e6b23e 14%, var(--bg-raised)); + --err-soft: color-mix(in srgb, #ef6a63 14%, var(--bg-raised)); + --scrollbar: #333b49; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 2px 10px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6); + color-scheme: dark; +} + +/* ================= base ================= */ +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; } +body { + background: var(--bg); + color: var(--text); + font: 400 14px/1.5 var(--font); + font-feature-settings: 'cv05', 'cv11'; + -webkit-font-smoothing: antialiased; + transition: background var(--speed) ease, color var(--speed) ease; +} +h1, h2, h3 { letter-spacing: -0.015em; font-weight: 600; } +::selection { background: color-mix(in srgb, var(--accent) 25%, transparent); } +:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 1px; } +* { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; } +@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + +.num { + font-family: var(--font); + font-variant-numeric: tabular-nums lining-nums; + letter-spacing: -0.005em; +} + +/* ================= controls ================= */ +button.wf { + background: var(--bg-raised); color: var(--text); + border: 1px solid var(--border-strong); + border-radius: var(--radius); padding: 7px 14px; + font: 500 13.5px/1.45 var(--font); cursor: pointer; + box-shadow: var(--shadow-sm); + transition: background var(--speed), border-color var(--speed), box-shadow var(--speed), transform 80ms; +} +button.wf:hover { background: var(--bg-hover); border-color: color-mix(in srgb, var(--accent) 35%, var(--border-strong)); } +button.wf:active { transform: translateY(1px); box-shadow: none; } +button.wf.primary { + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 92%, #fff), var(--accent-strong)); + border-color: var(--accent-strong); color: var(--accent-contrast); font-weight: 600; + box-shadow: 0 1px 2px color-mix(in srgb, var(--accent-strong) 45%, transparent); +} +button.wf.primary:hover { filter: brightness(1.06); } +button.wf.danger { border-color: color-mix(in srgb, var(--err) 55%, var(--border-strong)); color: var(--err); } +button.wf.danger:hover { background: var(--err-soft); } +button.wf kbd { + background: color-mix(in srgb, currentColor 14%, transparent); + border-radius: 4px; padding: 0 5px; margin-left: 8px; + font-family: var(--mono); font-size: 10.5px; font-weight: 500; +} + +input.wf, select.wf { + background: var(--bg-raised); color: var(--text); + border: 1px solid var(--border-strong); + border-radius: var(--radius); padding: 7px 11px; + font: 400 13.5px/1.45 var(--font); width: 100%; + box-shadow: inset 0 1px 2px rgba(16, 20, 28, 0.04); + transition: border-color var(--speed), box-shadow var(--speed); +} +input.wf::placeholder { color: var(--text-dim); } +input.wf:focus, select.wf:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} + +/* ================= page scaffolding ================= */ +.wf-page { padding: 24px 28px; max-width: 1400px; } +.wf-page-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; } +.wf-page-head h1 { font-size: 21px; margin: 0; } +.wf-page-desc { color: var(--text-dim); margin: 0 0 18px; max-width: 72ch; } +.wf-toolbar { display: flex; gap: 8px; align-items: center; margin: 14px 0; flex-wrap: wrap; } +.wf-toolbar .spacer { flex: 1; } + +/* ================= tables ================= */ +table.wf { + border-collapse: separate; border-spacing: 0; width: 100%; + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: 10px; overflow: hidden; box-shadow: var(--shadow-sm); +} +table.wf th, table.wf td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); } +table.wf tr:last-child td { border-bottom: none; } +table.wf th { + background: color-mix(in srgb, var(--bg) 55%, var(--bg-raised)); + color: var(--text-dim); font-weight: 600; font-size: 11px; + text-transform: uppercase; letter-spacing: 0.06em; +} +table.wf tbody tr { transition: background var(--speed); } +table.wf tbody tr:hover td { background: var(--accent-soft); } +table.wf td.num, table.wf th.num { text-align: right; } + +/* ================= badges ================= */ +.wf-badge { + display: inline-block; border-radius: 999px; padding: 2px 10px; + font-size: 12px; font-weight: 500; + background: var(--bg-inset); color: var(--text-dim); + border: 1px solid var(--border); +} +.wf-badge.ok { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); } +.wf-badge.warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); } +.wf-badge.err { color: var(--err); background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 25%, transparent); } +.wf-badge.accent { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); } +[data-theme='dark'] .wf-badge.accent { color: var(--accent); } + +/* ================= stat cards ================= */ +.wf-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin: 16px 0; } +.wf-stat { + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 16px; + box-shadow: var(--shadow-sm); + transition: box-shadow var(--speed), transform var(--speed), border-color var(--speed); +} +.wf-stat:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); border-color: var(--border-strong); } +.wf-stat .label { color: var(--text-dim); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } +.wf-stat .value { font-size: 27px; font-weight: 650; letter-spacing: -0.02em; margin-top: 6px; } +.wf-stat .hint { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; } + +/* ================= misc surfaces ================= */ +.wf-empty { + border: 1px dashed var(--border-strong); border-radius: var(--radius-lg); + padding: 38px; text-align: center; color: var(--text-dim); margin: 14px 0; + background: color-mix(in srgb, var(--bg-raised) 55%, transparent); +} + +.wf-field { margin-bottom: 13px; } +.wf-field > label { + display: block; color: var(--text-dim); font-size: 12px; font-weight: 550; + margin-bottom: 5px; letter-spacing: 0.01em; +} + +.wf-modal-back { + position: fixed; inset: 0; background: rgba(10, 13, 18, 0.45); + backdrop-filter: blur(5px); + display: flex; align-items: center; justify-content: center; z-index: 50; +} +.wf-modal { + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 22px; + min-width: 430px; max-width: 740px; max-height: 85vh; overflow: auto; + box-shadow: var(--shadow-lg); +} +.wf-modal h2 { margin: 0 0 14px; font-size: 17px; } + +.wf-notice { + border-radius: var(--radius); padding: 9px 13px; margin: 10px 0; + border: 1px solid var(--border); background: var(--bg-raised); + font-size: 13px; box-shadow: var(--shadow-sm); +} +.wf-notice.ok { border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-soft); } +.wf-notice.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-soft); } +.wf-notice.err { border-color: color-mix(in srgb, var(--err) 35%, transparent); background: var(--err-soft); } + +/* ================= theme switcher ================= */ +.wf-themer { display: flex; align-items: center; gap: 6px; } +.wf-themer .swatch { + width: 16px; height: 16px; border-radius: 50%; cursor: pointer; + border: 2px solid transparent; padding: 0; + transition: transform 80ms, border-color var(--speed); +} +.wf-themer .swatch:hover { transform: scale(1.18); } +.wf-themer .swatch.active { border-color: var(--text); } +.wf-themer .mode { + background: var(--bg-inset); border: 1px solid var(--border); + border-radius: 999px; padding: 3px 10px; cursor: pointer; + font: 500 12px var(--font); color: var(--text-dim); +} +.wf-themer .mode:hover { color: var(--text); border-color: var(--border-strong); } diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..a6d209b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@sims/domain": ["packages/domain/src"], + "@sims/billing-engine": ["packages/billing-engine/src"], + "@sims/config": ["packages/config/src"], + "@sims/auth/permissions": ["packages/auth/src/permissions"], + "@sims/auth": ["packages/auth/src"], + "@sims/scanning": ["packages/scanning/src"], + "@sims/search-core": ["packages/search-core/src"], + "@sims/printing": ["packages/printing/src"], + "@sims/ui": ["packages/ui/src"] + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6b011c0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "include": [ + "packages/*/src/**/*.ts", + "packages/*/test/**/*.ts" + ] +}