|
|
# Build Status — 2026-07-10 (post-slice)
|
|
|
|
|
|
**54 screens across 2 surfaces** (46 back office + 8 POS), on 8 tested logic packages
|
|
|
(89 tests green), and a store-server **with a real data layer** (SQLite behind portable
|
|
|
repositories; Oracle 12c adapter slots in when the Classic DDL arrives — D12).
|
|
|
Product form: **web app** served by the store-server (D2) — back office at `/`,
|
|
|
POS at `/pos/`, printing via `/api/print`.
|
|
|
|
|
|
**The vertical slice is LIVE and verified end-to-end**: PIN login (server-side scrypt +
|
|
|
lockout) → item cache from DB → bill commit in one transaction (GST series allocation,
|
|
|
bill + lines, stock movements, dormant outbox row, audit entry) with **server-side
|
|
|
engine re-verification** (client and server must agree to the paisa) → bill visible in
|
|
|
Back Office Bills, stock derived correctly, audit trail written. Verified by driving
|
|
|
both UIs in a real browser: bill `ST1C2/26-000001`, ₹600.00.
|
|
|
|
|
|
## P0 security remediation — DONE 2026-07-10 (red-team close-out)
|
|
|
|
|
|
The store-server now enforces the trust boundary the red-team review (docs/18) found
|
|
|
missing: it no longer trusts the client for **identity, price, tax, item existence, or
|
|
|
input shape**. One coherent change, three server-side moves + client wiring:
|
|
|
|
|
|
| Move | What changed | Closes |
|
|
|
|---|---|---|
|
|
|
| **Item-master anchoring** | `commitBill` rebuilds every line from OUR `item` row — tax class, HSN, MRP, unit, inclusive-price flag are read from the master and the request's copies ignored; the unit price is accepted only if it equals the master sale price, else it is a *deviation* needing approval; price > MRP is a hard block (E-1301); fractional qty on a whole unit is rejected (E-1104); non-existent/foreign item rejected (E-1105) | C2, C3, H5, M8 |
|
|
|
| **Per-route authorization** | `requireAnyPerm(...ActionCode)` middleware in `api.ts` maps each mutating/sensitive route to `permissions.ts` grants, `can(session, action)`, deny-by-default 403 (E-6105). Cashier keeps billing + draft quick-add + own reads; loses `/audit`, `/purchases*`, cost endpoints, master edits, purchase posting | C1, C4 |
|
|
|
| **Input validation + generic errors** | hand-rolled `validate.ts` guards (no new deps) on every route: ISO+windowed `businessDate` (H6), clamped `limit`/`offset` (M6), capped `lines` ≤ 500 (M12), payment legs > 0 with an allowlisted mode summing to payable (M8). Express 4-arg error handler returns `{error, code}` with no stack/path (M7); `x-powered-by` disabled (M14) | H6, M6, M7, M8, M12 |
|
|
|
| **Bound approval tokens** | `override_approval` rows carry `item_id/kind/before/after`; `/auth/verify-pin` binds the override context at mint; `commitBill` re-verifies the token against the line's server-computed deviation (H4). Self-authorised (owner/manager/supervisor) deviations are audited from the session identity, so **every** deviation leaves a trail (H7) | H4, H7 |
|
|
|
|
|
|
**Proven on a fresh DB by re-running the red-team PoCs (all now blocked) and a real-browser
|
|
|
E2E** (Ramesh normal bill → commits; Ramesh F3 rate override → Suresh PIN → bound-token
|
|
|
commit + audit; Thomas owner self-price-change → commit + audit). 207 tests green,
|
|
|
typecheck + all three builds clean. Details + evidence table in docs/18-RED-TEAM-REVIEW.md.
|
|
|
P1 (print allowlist, TLS, session TTL, bootstrap/verify-pin hardening) and P2
|
|
|
(encryption-at-rest, hash chain, demo-seed gate) remain **open** by design.
|
|
|
|
|
|
### HIGH follow-up — cashier draft quick-add path CLOSED 2026-07-13
|
|
|
|
|
|
The independent verifier found the C1 remediation left one HIGH hole inside the quick-add
|
|
|
convenience it kept open: a cashier could `POST /items status:'draft'` with an **arbitrary
|
|
|
price AND tax class**, then bill it — reproducing **C2 (arbitrary price)** and **C3 (GST
|
|
|
evasion: a taxable good self-added as `ZERO`, sold at ₹0 GST)** with only an `ITEM_CREATE`
|
|
|
trace. Closed while keeping the convenience (an unknown barcode must not stop the queue):
|
|
|
|
|
|
| Move | What changed | Closes |
|
|
|
|---|---|---|
|
|
|
| **Force the draft tax class** | On a non-`MASTER_EDIT` caller's `POST /items` draft, the server **ignores** the client `taxClassCode` and assigns the store default (`catalog.defaultTaxClass` setting, system→tenant, default `GST18`). A manager/owner still sets it; back office completes the real HSN/class later | C3-via-draft |
|
|
|
| **Cap the draft price** | A non-`MASTER_EDIT` draft over `catalog.draftMaxPricePaise` (default ₹5,000) is denied **E-1108** ("call a supervisor"); a `MASTER_EDIT` role may exceed it | C2-via-draft |
|
|
|
| **Audit every draft sale** | `commitBill` writes a **`DRAFT_SALE`** audit row (entity `bill`, `{itemId,name,pricePaise,docNo}`) in the commit transaction for each line whose master row is still `status:'draft'` — a self-authored-item sale is no longer invisible in a review | review gap |
|
|
|
| **Scope cross-counter reads** | `GET /bills` for a caller without `REPORT_VIEW` is pinned server-side to the session user id — a cashier sees only their **own** bills; a manager keeps the full view | residual LOW/MED |
|
|
|
|
|
|
Two new settings seed at `system` scope (`catalog.defaultTaxClass=GST18`,
|
|
|
`catalog.draftMaxPricePaise=500000`), tenant-overridable per R9. **Proven live on a fresh DB**
|
|
|
(re-fired as cashier u1/4728): "Redteam Whiskey" `ZERO`@₹2000 → server forced **GST18**, billed
|
|
|
**CGST+SGST 30508 paise** (was ₹0); draft @ ₹9,999 → **400 E-1108**; two draft sales wrote **2
|
|
|
`DRAFT_SALE`** rows; Ramesh/Divya each saw only their own `/bills` while the owner saw all; and
|
|
|
the earlier C1/C2/C3 PoCs on existing items still fail closed. Legit quick-add still bills
|
|
|
end-to-end. **220 tests green** (9 new), typecheck + all three builds clean, dev DB reset and
|
|
|
store-server restarted on `:5181`. No git commits.
|
|
|
|
|
|
### M11 (MEDIUM) — offline customer PII on shared counter PCs CLOSED (POS client) 2026-07-13
|
|
|
|
|
|
Red-team M11 (docs/18): the POS offline IndexedDB (`sims-pos-offline`) held, unencrypted on
|
|
|
shared counter PCs, (a) queued offline bills carrying `customerName` + `buyerGstin`, and (b) a
|
|
|
catalog cache including the staff roster — readable by the next cashier or any same-origin script.
|
|
|
**Closed on the POS client (store-server untouched — a separate agent owns that tier):**
|
|
|
|
|
|
| Move | What changed | File |
|
|
|
|---|---|---|
|
|
|
| **No PII in the queued bill** | `QueuedBill` / `buildQueuedBill` no longer model or store `customerName`/`buyerGstin`; the queued record keeps **only `customerId`**. The drain (`POST /api/bills/offline`) already carried id-only, and the server re-resolves the buyer's name/GSTIN/place-of-supply from `customerId` + tenant at commit (`repos.ts:296-298`), identical to an online bill — so correctness is unchanged and no buyer PII is written to the device | `apps/pos/src/offline.ts`, `BillingScreen.tsx` `queueOffline` |
|
|
|
| **Wipe cache/roster on Lock & logout** | new `clearCatalogCache()` clears the whole `cache` store (store, staff roster, item master) from `App.onLock`; **un-drained queued bills are deliberately preserved** (a lock must never lose a sale) and the catalog is re-fetched on next login | `apps/pos/src/offline.ts`, `App.tsx` `onLock` |
|
|
|
|
|
|
**Verified** (server was mid-restart / down on :5181, so no full E2E — did the offline path instead):
|
|
|
typecheck clean + POS builds clean; **6 offline unit tests green** (asserting the PII-free queued shape);
|
|
|
and a **real-browser IndexedDB run** (Vite dev + dynamic import of the actual `offline.ts`) confirmed the
|
|
|
persisted queued record has `customerId` only — injected `customerName`/`buyerGstin` were dropped, nothing
|
|
|
PII in the serialized record — the catalog+roster cache was present then **cleared after lock**, while the
|
|
|
un-drained queued bill **survived** (count 1→1). **Residual:** the queued lines/amounts still sit in a plain
|
|
|
IndexedDB until they drain — true at-rest **encryption** of that remaining queue rolls into H3 (server-tier
|
|
|
SQLCipher), a separate finding. This fix removes the standing PII, not the queue itself. No git commits.
|
|
|
|
|
|
## P1 edge & transport hardening — DONE 2026-07-13 (red-team close-out)
|
|
|
|
|
|
The P1 "lock down the edges + TLS" workstream (docs/18) is **REMEDIATED**: **H1, M1, M2, M4,
|
|
|
M13, M14** closed, **M15** noted. Server changes stayed in `server.ts` + `api.ts` plus three
|
|
|
pure, unit-tested modules; the only new runtime dep is **helmet** (justified). Client edits were
|
|
|
limited to logout wiring + the POS post-login bootstrap re-fetch. (M5 — tenant-scoping
|
|
|
`commitBill`'s store/counter — belongs to the money-logic workstream and is untouched here.)
|
|
|
|
|
|
| Finding | What changed | File(s) |
|
|
|
|---|---|---|
|
|
|
| **H1** print SSRF | `/api/print` now an authenticated `apiRouter` route constrained to a **LAN IP** (127/8, 10/8, 172.16/12, 192.168/16; public/link-local rejected) on a **port allowlist** ({9100,9101,9102}, `SIMS_PRINTER_PORTS`) with a **≤64 KB** cap; the host is resolved and every resolved IP LAN-checked, then we connect to the resolved IP (no DNS-rebind) | `apps/store-server/src/print-guard.ts`, `api.ts` |
|
|
|
| **M1** `/bootstrap` minimize | the UNAUTHENTICATED payload drops each user's **role** and the tenant **GSTIN** (keeps id+name for PIN login); both restored when the SAME endpoint is called WITH a session — the POS re-fetches after login, back office is always authed | `api.ts` `/bootstrap`, `apps/pos/src/App.tsx` `onLogin` |
|
|
|
| **M2** verify-pin behind auth | `/auth/verify-pin` now requires a logged-in **caller** (`requireAuth`) and is rate-limited; the approver PIN still rides in the body — it is no longer an unauthenticated PIN oracle / token mint | `api.ts` |
|
|
|
| **M4** session TTL + logout + rate limit | sessions carry created+last-seen and are evicted past a **12 h absolute / 30 m idle** TTL (401 E-6110); **`POST /api/auth/logout`** deletes the session (POS "Lock" + back-office "Logout" now call it); a **global + per-IP `/auth/*`** limiter (20/min/IP, 200/min global, env-tunable) layers on the per-user lockout | `api.ts`, `rate-limit.ts`, `session-policy.ts`; POS `App.tsx`/`api.ts`, back office `main.tsx`/`api.ts` |
|
|
|
| **M13** TLS | HTTPS available: `SIMS_TLS_CERT`+`SIMS_TLS_KEY` ⇒ `https.createServer`; unset ⇒ HTTP dev (`:5181` unchanged). Dev-cert one-liner + "prod MUST supply certs" in `server.ts`; HSTS + `upgrade-insecure-requests` only under TLS | `server.ts` |
|
|
|
| **M14** security headers | **helmet** with a SPA-compatible CSP (`'self'` scripts + `'unsafe-inline'` styles + `blob:`/`data:` for the invoice/label tabs + `data:` fonts; the tabs' only inline script — `window.print()` — allowed by a single `'unsafe-hashes'` sha256) + explicit **`X-Frame-Options: DENY`** + `frame-ancestors 'none'` + nosniff + `Referrer-Policy: no-referrer` | `server.ts` |
|
|
|
| **M15** SW poisoning | noted — closes once prod is HTTPS (M13) | (doc) |
|
|
|
|
|
|
**Verified**: **245 tests green** (was 220; new `edge-guards.test.ts` pure cases +
|
|
|
`edge.test.ts` HTTP cases), typecheck clean on root + all three apps, all three apps build,
|
|
|
store-server rebuilt + restarted on `:5181` (HTTP, `/api/health` ok, helmet headers present, no
|
|
|
`X-Powered-By`). Live PoCs re-fired: unauth `/bootstrap` carries no role/gstin (authed does);
|
|
|
`/api/print` → public `8.8.8.8:9100` **400 E-2203** (no socket), `8.8.8.8:80` **400 E-2202**,
|
|
|
LAN `127.0.0.1:9100` **502 ECONNREFUSED** (guard passed), unauth **401**; unauth verify-pin
|
|
|
**401**, authed cashier caller **200 + approvalId**; logout then reuse **401**; a 1 s-idle
|
|
|
instance **401 E-6110** after the timeout; a cap-3 instance **429** on the 4th auth attempt.
|
|
|
Real browser: back office + POS login screens render with **zero CSP console violations** (the
|
|
|
POS tiles now show no role subtitle — the expected M1 effect). No git commits.
|
|
|
|
|
|
## P2 data-integrity & crypto — DONE 2026-07-13 (red-team close-out)
|
|
|
|
|
|
The P2 "harden-before-cloud" data-integrity slice (docs/18) is **REMEDIATED**: **H2, M3, M5, M9,
|
|
|
M10** closed; only **H3** (encryption at rest) stays open, owned by a separate agent next. Confined
|
|
|
to `apps/store-server/src/{db.ts,repos.ts,api.ts,server.ts}`, `packages/auth/src/pin.ts`,
|
|
|
`packages/domain/src/doc-series.ts`, + `dev-start.mjs` and `.env.example`. No new runtime deps.
|
|
|
|
|
|
| Finding | What changed | File(s) |
|
|
|
|---|---|---|
|
|
|
| **H2** tamper-evident audit log | `audit_log` gained `prev_hash`+`entry_hash`; `writeAudit` links each row into the tenant's SHA-256 hash chain **inside the existing commit transactions** (genesis `''`, rowid insertion order); `verifyAuditChain` + **`GET /api/audit/verify`** (AUDIT_VIEW) recompute it and name the first broken row. Tamper-EVIDENCE, not prevention (the file is still writable — that is H3) | `db.ts`, `repos.ts`, `api.ts` |
|
|
|
| **M3** PIN pepper | `hashPin`/`verifyPin` HMAC-SHA256 the secret with a server-held pepper (`SIMS_PIN_PEPPER`) **before scrypt**, so a stolen DB can't be brute-forced against the 10k PIN keyspace without the env-only pepper; unset ⇒ raw-secret fallback (dev unchanged) + one-time warning; `.env.example` added | `packages/auth/src/pin.ts`, `.env.example` |
|
|
|
| **M5** tenant-scope commit | `commitBill` selects store AND counter `WHERE id=? AND tenant_id=?` → **400 E-1110** before any money logic (kills the undefined-deref 500 + any cross-tenant reference); `/bills` + `/bills/offline` both covered. (E-1110, not the task's E-1109 — that code is already payments-≠-payable) | `repos.ts` (already present from the H5/anchoring pass; verified + tested) |
|
|
|
| **M9** 4-digit FY series | `seriesPrefix` embeds the 4-digit FY start year (`ST1C22026-000123`); FY 2026-27 vs 2126-27 no longer collide. The counter↔FY `/` is dropped to fit the GST 16-char rule at 6-digit sequences (store+counter must total ≤ 5 chars) | `packages/domain/src/doc-series.ts`, `repos.ts` |
|
|
|
| **M10** demo-seed gate | `seedIfEmpty`/`ensureBatchTrackingSeed` run only under `SIMS_SEED_DEMO=1`; a prod empty DB never auto-creates the known-credential owner (thomas/9174/sims); dev `npm start` → `dev-start.mjs` sets the flag | `server.ts`, `dev-start.mjs`, `package.json` |
|
|
|
|
|
|
**Verified**: **260 tests green** (was 245; +5 audit-chain, +4 tenant-scope, +1 `/audit/verify` HTTP,
|
|
|
+4 PIN-pepper, +1 4-digit-FY collision), typecheck clean on root + all three apps, all three apps build.
|
|
|
Dev DB reset and store-server restarted on `:5181` with `SIMS_SEED_DEMO=1` (`/api/health` ok, seeded).
|
|
|
Live PoCs: a fresh bill's doc no **`ST1C22026-000001`** (16 chars); owner `GET /api/audit/verify` **`ok:true`**,
|
|
|
then a direct `UPDATE` of one `audit_log` row → **`ok:false`** with `brokenAtId` = that row; a bill with a
|
|
|
foreign `storeId` → **400 E-1110** (not 500); an isolated instance with `SIMS_SEED_DEMO` **unset** → **0
|
|
|
tenants**, thomas login **401**. **H3 (encryption at rest) is the only open P2 item.** No git commits.
|
|
|
|
|
|
### H3 (HIGH) — encryption at rest CLOSED 2026-07-13 (red-team close-out)
|
|
|
|
|
|
The **last** open red-team finding is **REMEDIATED**: the store DB is **full-DB encrypted at rest
|
|
|
with SQLCipher**, closing H3, the F5 at-rest concern, and the M11 residual (the offline queue drains
|
|
|
into a now-encrypted server tier). `better-sqlite3` was swapped for the API-compatible drop-in
|
|
|
**`better-sqlite3-multiple-ciphers`** (same API + `pragma('key=…')`; it ships its own
|
|
|
DefinitelyTyped-derived types, so the `DB` type and every call are unchanged — **no shim, no
|
|
|
loosened strictness**). Confined to `apps/store-server/src/db.ts`, `package.json`,
|
|
|
`build-server.mjs`, `.env.example`; no other hardening touched.
|
|
|
|
|
|
| Move | What changed | File(s) |
|
|
|
|---|---|---|
|
|
|
| **Key the DB from the environment** | `openDb()` reads **`SIMS_DB_KEY`**; when set it keys the DB (`cipher='sqlcipher'`, `key=…`) **before any read**, so `sims.db` + its WAL/SHM sidecars are ciphertext. The key is held ONLY in the server env (machine-scoped DPAPI/keychain), never in the repo or DB; quotes in it are escaped | `apps/store-server/src/db.ts` |
|
|
|
| **Dev fallback preserved** | Key UNSET ⇒ open unencrypted exactly as before + a **one-time** warning that prod MUST set it (mirrors the `SIMS_PIN_PEPPER` pattern), so `:5181` keeps working with no key | `apps/store-server/src/db.ts`, `.env.example` |
|
|
|
| **Native module externalled** | esbuild externals `better-sqlite3-multiple-ciphers` (as it did `better-sqlite3`) so the server bundle stays buildable | `apps/store-server/build-server.mjs` |
|
|
|
|
|
|
**Path taken: full-DB encryption** — the native package installed from a **prebuilt binary** (no
|
|
|
node-gyp) on this Windows / Node 22 box, so the app-level field-encryption fallback was not needed.
|
|
|
**Proven live** on the reset, keyed `:5181`: (a) `head -c 16 data/sims.db` → random `28 85 83 94…`,
|
|
|
**not** `SQLite format 3\0`, and grep of db/WAL/SHM for `MegaMart`/`Lakshmi`/the phone/the GSTIN found
|
|
|
**no cleartext**; (b) opening `data/sims.db` **without** the key (and with a **wrong** key) → `file is
|
|
|
not a database`, while the **correct** key decrypts it; (c) the app works end-to-end through the
|
|
|
cipher — cashier login **200**, `GET /items?all=1` **200** (11 items), `POST /bills` → **`ST1C22026-000001`**,
|
|
|
owner `GET /audit/verify` → **`{ok:true}`**. **265 tests green** (was 260; +5 new `encryption.test.ts`),
|
|
|
typecheck clean on root + store-server, all three apps build. **Production still must**: supply
|
|
|
`SIMS_DB_KEY` via a machine-scoped secret (DPAPI/keychain, never a file beside the DB); migrate any
|
|
|
existing plaintext `sims.db` once offline (`sqlcipher_export`) before the first encrypted boot; treat
|
|
|
rotation as a re-key; back up the key with the DB; and tighten data-dir ACLs. No git commits. **All
|
|
|
red-team findings are now closed.**
|
|
|
|
|
|
## Returns / credit notes — LIVE 2026-07-13 (first increment: return against an original bill)
|
|
|
|
|
|
The **RETURNS / credit-note flow** ships end-to-end (POS F9 → server → back office), built
|
|
|
security-first so it rides — never weakens — the money/commit boundary the red-team hardened
|
|
|
(docs/18). A return is a new **immutable `SALE_RETURN` document** referencing the original bill
|
|
|
(R6): everything anchors to OUR stored original bill, the refund is server-computed, over-return
|
|
|
is blocked, and the whole commit (credit-note series, stock-back, refund leg, outbox, hash-chained
|
|
|
audit) lands in ONE transaction (R15/R17). Scope this increment: **return against an original
|
|
|
sale**; exchanges and split-tender refunds are deferred (below).
|
|
|
|
|
|
| Move | What changed | File(s) |
|
|
|
|---|---|---|
|
|
|
| **Shared return engine (R5)** | new pure `computeReturn` — the money half of a credit note, shared BYTE-FOR-BYTE by the POS (refund preview) and the server (authoritative commit), exactly like `computeBill`. It REVERSES the original line PROPORTIONALLY by `returnQty/soldQty` and at the **ORIGINAL captured rate** (it scales the stored taxable/CGST/SGST/IGST/cess, never today's tax master — so a return reverses tax at the rate on the bill even if the master changed since, R5/R10). Integer paise; `taxable + taxes == lineTotal` by construction; a full-line return reproduces the original exactly | `packages/billing-engine/src/returns.ts` |
|
|
|
| **Credit-note series (M9 reuse)** | `creditNotePrefix` — a **`CN`-marked** per-counter, 4-digit-FY series so a credit note can never collide with a sale in `bill`'s `UNIQUE(tenant, doc_no)`; the CN marker costs 2 chars, so the sequence is 4 digits to hold the GST 16-char rule: `CNST1C22026-0001` (16) | `packages/domain/src/doc-series.ts` |
|
|
|
| **Server `commitReturn` (the trust boundary)** | loads the original bill by **id AND tenant_id AND doc_type='SALE'** (cross-tenant / nonexistent / non-sale → **400 E-1320**); reads prices/tax/HSN/splits **from the stored original payload, never the client**; enforces **returnable qty** = returned-so-far (Σ prior `SALE_RETURN` refs) + this qty ≤ sold (over-return → **400 E-1324**); rejects qty ≤ 0 / fractional-on-whole-unit (**E-1323**); computes the refund server-side and **409 E-1325** if a client-declared total disagrees (mirrors `commitBill`). In one txn: the `SALE_RETURN` bill row (refDocId = original, buyer/place-of-supply snapshotted FROM the original), the CN series no, **stock ADDED BACK** (positive `qty_delta`, reason `SALE_RETURN`, carrying the original line's `batch_id`), the refund payment leg, the outbox row, and a **`RETURN_COMMIT`** audit row hash-chained in-txn (H2) | `apps/store-server/src/repos-returns.ts` |
|
|
|
| **API** | `POST /api/returns` (gated **`RETURN_CREATE`** per-route authz — cashiers have it), `GET /api/returns` (credit notes, tenant-scoped, paginated, cashier pinned to own per the M-fix scoping), `GET /api/bills/:id/returnable` (sold/returned/returnable per line + raw lines for the preview), `GET /api/bills/lookup?docNo|phone` (targeted SALE lookup for the return screen). `listBills` now filters to `doc_type='SALE'` so credit notes never pollute the sales list or POS history | `apps/store-server/src/api.ts`, `repos.ts` |
|
|
|
| **POS — F9 returns** | F9 opens a keyboard-first returns overlay: look up the original by **bill no or customer phone**, a line grid with sold/returned/**returnable-qty inputs**, a **reason** dropdown (Damaged / Wrong item / Expired / Customer changed mind / Other), a **live server-matching refund preview** (client `computeReturn`), commit → prints a **CREDIT NOTE** receipt. Esc cancels, focus discipline kept, over-return + zero-qty blocked inline | `apps/pos/src/BillingScreen.tsx`, `api.ts` |
|
|
|
| **Back office — `/sales/returns` LIVE** | the registry wireframe is replaced (CUSTOM map) by a live, tenant-scoped, paginated credit-note list (CN no, against-bill, date, cashier, customer, reason, refund) — pattern-copied from `BillsPage` | `apps/backoffice/src/pages/live.tsx`, `main.tsx`, `api.ts` |
|
|
|
| **Printing** | `receiptLines`/`renderReceipt` gained a `variant:'credit-note'` — a **CREDIT NOTE** banner, the original bill ref, REFUND/Refund-via labels and the reason, on the one shared content model | `packages/printing/src/receipt.ts` |
|
|
|
|
|
|
**Verified (R23 — not just unit tests): 333 tests green** across all suites, 0 failing (+12 for this
|
|
|
flow — 4 in a new engine `returns.test.ts`, 8 in a new server `returns.test.ts` covering over-return,
|
|
|
refund-from-original-not-client 409, tax reversed at the original rate, cross-tenant/non-sale
|
|
|
reject, the `RETURN_CREATE` gate, doc_no ≤16), typecheck clean on root + all 3 apps, all 3 apps
|
|
|
build, store-server rebuilt + restarted on `:5181` (plaintext
|
|
|
dev DB, `SIMS_SEED_DEMO=1`). **Real-HTTP E2E on `:5181`** (17/17 checks): cashier `u1/4728` billed
|
|
|
3× Surf Excel → `ST1C22026-000001` (stock 120→117) → returned 2 → **`CNST1C22026-0001`** (16 chars),
|
|
|
**refund ₹290.00 = 2/3 of the taxed line**, **stock 117→119 (+2)**, **GST reversed CGST+SGST 4424 paise
|
|
|
at the original GST18 rate** (not ₹0), back-office `/returns` lists it against the original; a second
|
|
|
2-unit return (only 1 left) → **400 E-1324**; abuse cases all fail closed — client-inflated refund →
|
|
|
**409 E-1325**, out-of-range line → **400 E-1321**, qty > sold → **400 E-1324**, nonexistent/cross-tenant
|
|
|
original → **400 E-1320**; `GET /audit/verify` stays **`{ok:true}`** with the `RETURN_COMMIT` row chained
|
|
|
in. **Real browser (Chrome, both surfaces):** back office `/sales/returns` renders the credit note LIVE;
|
|
|
POS **F9** → looked up `ST1C22026-000002` → line grid (Sold 3 / Returned 0 / Left 3) → qty 2, reason
|
|
|
"Wrong item" → **live REFUND ₹290.00** → committed **`CNST1C22026-0002`** (gap-free series), Surf Excel
|
|
|
on-hand settled at 118 (sold 6, returned 4). No git commits. **Deferred (follow-ups):** exchanges
|
|
|
(return + re-sell in one document); partial refund split to a **different** tender than the original;
|
|
|
an A4 credit-note document (the thermal CREDIT NOTE receipt ships now); offline-queued returns (the
|
|
|
return path needs the server up — the sale's S4 queue is unchanged); a physical thermal print of the
|
|
|
credit note (unit- + browser-verified, no 80 mm printer here).
|
|
|
|
|
|
## GST RETURNS — LIVE 2026-07-13 (GSTR-1 + GSTR-3B + HSN from real transaction data)
|
|
|
|
|
|
The **GST returns** phase ships: GSTR-1, GSTR-3B (3.1a) and the HSN summary are generated
|
|
|
**live from the counters' immutable SALE + SALE_RETURN documents** and the wireframe GST pages are
|
|
|
now real. It is **READ-ONLY reporting** — no writes, no new money-commit surface — but
|
|
|
**compliance-critical**, so it never recomputes tax: every amount is read straight off the stored
|
|
|
document line **as captured**, so a return reduces liability at the **original bill-date rate** even
|
|
|
after a slab change (GST-1/GST-2/GST-10). Integer paise throughout (R4); the only math is addition
|
|
|
of already-rounded amounts, so books == report == JSON to the paise (GST-3). Reconciliation is a
|
|
|
first-class, tested output.
|
|
|
|
|
|
| Move | What changed | File(s) |
|
|
|
|---|---|---|
|
|
|
| **Shared pure GST-return engine (R5)** | new `computeGstr1` / `computeGstr3b` / `reconcile` over a list of documents. **GSTR-1 sections**: B2B (Table 4A, grouped by buyer GSTIN + rate), B2CL (Table 5, inter-state B2C invoices above the **₹2,50,000** dated threshold, per invoice), B2CS (Table 7, aggregated by place-of-supply + rate, **net of small B2C credit notes**), CDNR (Table 9B, registered credit notes) + CDNUR (unregistered inter-state large), HSN summary (Table 12, net of returns) and documents-issued (Table 13). **GSTR-3B 3.1(a)**: outward taxable value + IGST/CGST/SGST/cess, **net of credit notes** (ITC/inward is an explicit stub). Each line lands in exactly one section, so the reconciliation identity holds by construction | `packages/billing-engine/src/gst-returns.ts` |
|
|
|
| **Reconciliation as a compliance gate** | `reconcile()` proves **Σ GSTR-1 section taxables (returns negative) == Σ source == HSN total == GSTR-3B outward**, and likewise for total tax; any inequality is named and `ok:false`. Surfaced on every page and in the API response — a non-reconciling period says "do not file" loudly rather than papering over | same |
|
|
|
| **Server repo (read-only)** | `gstr1` / `gstr3b` / `hsnSummary` / `gstr1PortalJson` read the period's SALE+SALE_RETURN by `business_date` (each bill carries its own store state so intra/inter is per-document), fold through the engine, and attach the reconciliation. B2CL threshold is a **dated tenant setting** `gst.b2clThresholdPaise` (R9/R10, seeded ₹2,50,000). The `.json` builder emits a **pragmatic GSTN-shaped** export (b2b/b2cl/b2cs/cdnr/cdnur/hsn/doc_issue, rupee amounts) documented in-payload as **NOT a certified filing** | `apps/store-server/src/repos-gst.ts`, `validate.ts` (`assertPeriod`), `seed.ts` |
|
|
|
| **API** | `GET /api/gst/gstr1?period=YYYY-MM`, `/gstr3b`, `/hsn`, `/gstr1.json` — all gated **REPORT_VIEW**, tenant-scoped, period-validated (E-1330) | `apps/store-server/src/api.ts` |
|
|
|
| **Back office LIVE pages** | `/gst/gstr1`, `/gst/gstr3b`, `/gst/hsn` replace the registry wireframes (CUSTOM map): month picker, a green/red **reconciliation banner**, stat cards, every section as a table (tables-rule R1), a **Download JSON** button, and a clear **"not a certified filing — export for your CA / GSP"** note on each | `apps/backoffice/src/pages/gst.tsx`, `main.tsx`, `api.ts` |
|
|
|
|
|
|
**Verified (R23): 351 tests green** (was 333; +12 engine `gst-returns.test.ts` — hand-computed B2B/B2CL/B2CS/CDNR/HSN, threshold behaviour, and the reconciliation identity incl. a deliberate-drift catch; +6 server `gst.test.ts` — sections over a known month, 3B hand-checked, reconciliation, empty period, valid JSON, and the REPORT_VIEW gate). Typecheck clean on root + all 3 apps; all 3 apps build. Dev DB reset + store-server rebuilt and restarted on `:5181` (plaintext, `SIMS_SEED_DEMO=1`, no db key).
|
|
|
|
|
|
**Live proof on `:5181`** — a known single-month set created via the API (period 2026-07): a **B2B** invoice `ST1C22026-000001` (2× Surf Excel to Anand Traders `27ABCDE1234F1Z0`, inter-state IGST), two **B2C** cash bills (`-000002` Surf+Amul, `-000003` 3× Parle), and a **credit note** `CNST1C22026-0001` (full Amul line of bill 2). The reconciliation ties out exactly:
|
|
|
|
|
|
| Figure (paise) | Source docs | GSTR-1 net | HSN summary | GSTR-3B outward |
|
|
|
|---|--:|--:|--:|--:|
|
|
|
| **Taxable** | 62,578 | 62,578 | 62,578 | 62,578 |
|
|
|
| **Total tax** | 7,922 | 7,922 | 7,922 | 7,922 |
|
|
|
|
|
|
3B hand-check: **Σ bill tax ₹108.68 − Σ credit-note tax ₹29.46 = ₹79.22** == 3B total tax. GSTR-1 B2B shows the GSTIN invoice (taxable ₹245.76, IGST ₹44.24); B2CS aggregates the cash bills by rate (5% ₹257.14, 18% ₹122.88) with the returned Amul (GST12) bucket **netting to zero and dropping**; HSN totals ₹625.78; the `.json` export is valid (gstin `32ABCDE1234F1Z5`, fp `072026`). **Real browser (login thomas/sims):** `/gst/gstr1` and `/gst/gstr3b` render the LIVE numbers with the green **Reconciled** banner, all section tables, and the not-certified note (screenshots captured; MCP browser was wedged so driven via Puppeteer, per the S5 precedent). No git commits.
|
|
|
|
|
|
**Deferred (follow-ups, noted for compliance honesty):** ITC / inward-supply reconciliation (GSTR-2A/2B, 3B Table 4 — purchase data exists); GSP e-filing (the JSON is an approximation, not a signed return — doc 17 §S2/D4); dedicated nil-rated / exempt / non-GST split tables (GST-4; 0%-rated supply currently sits as rate-0 buckets in the taxable sections); amendments (B2BA/CDNRA…); the cross-period **filing-freeze / sync-completeness** gate + soft-lock (GST-11); the 30-Nov credit-note guillotine alerting (GST-10); HSN Table-12 dropdown validation (GST-13); composition-scheme (Bill of Supply) returns (GST-6). Multi-store tenants are handled per-document (each bill's own store state drives intra/inter).
|
|
|
|
|
|
## The foundation (packages/ — real, tested code, not wireframe)
|
|
|
|
|
|
| Package | What it does | Tests |
|
|
|
|---|---|---|
|
|
|
| `@sims/domain` | Tenancy (tenant→store→counter→user), integer-paise money + ₹ Indian grouping, UUIDv7 ids, Day-Begin business day + FY, per-counter GST doc series (16-char rule), GSTIN checksum validation, immutable document types, dormant outbox (D14) | ✅ |
|
|
|
| `@sims/billing-engine` | Dated GST rates, inclusive/exclusive prices, CGST/SGST vs IGST, cess, pro-rata bill discounts, rupee round-off — golden-tested to the paisa | ✅ |
|
|
|
| `@sims/auth` | PIN policy + scrypt hashing, lockout reducer, role→action matrix with supervisor-PIN gates, POS session bound to business date | ✅ |
|
|
|
| `@sims/scanning` | EAN-13 validation, weighing-scale (2x) barcodes, scan-box grammar (`3*` qty, code, search), wedge scanner vs human-typing detector | ✅ |
|
|
|
| `@sims/search-core` | Catalog search (pure TS, no deps): Indic phonetic folding (chawal≈chaval, paneer≈panir, jeera≈zeera), in-memory index, tiered ranking (whole-prefix → token-prefix → phonetic → substring) | ✅ |
|
|
|
| `@sims/printing` | ESC/POS byte builder, receipt renderer (42/32 col), drawer kick; **A4 GST invoice HTML + amount-in-words, EAN-13 label sheets, GS v 0 Indic raster (S2)** | ✅ |
|
|
|
| `@sims/config` | Settings hierarchy resolver (system→tenant→store→counter→user, effective-dated), message catalog with language fallback | ✅ |
|
|
|
| `@sims/ui` | Design system: light/dark themes, 6 accents, Inter + JetBrains Mono bundled, wireframe component kit, ThemeSwitcher | — |
|
|
|
|
|
|
## POS — 7 screens (`/pos/`) — FUNCTIONAL
|
|
|
|
|
|
| # | Screen | State |
|
|
|
|---|--------|-------|
|
|
|
| 1 | Login — user tiles + PIN pad | Functional (seed users) |
|
|
|
| 2 | Shift Open — denomination count → opening float | Functional |
|
|
|
| 3 | **Billing screen** — scan box, lines grid, totals, quick keys, status bar | **Functional end-to-end**: real engine, barcode/multiplier/scale-barcode entry, hold/resume, per-counter GST series, one-key cash close (F12/Numpad+) |
|
|
|
| 4 | Payment overlay (F11) — Cash/UPI/Card/Khata | Functional (modes recorded; QR/split later) |
|
|
|
| 5 | Bill history (F10) | Functional (day log) |
|
|
|
| 6 | Day-End summary — totals by payment mode | Functional (variance calc later) |
|
|
|
| 7 | Settings (Ctrl+,) — printer IP/port/width, test print, drawer kick | Functional (prints via store-server) |
|
|
|
|
|
|
Also live: customer attach (F6); server-backed history (F10); commit failures keep the
|
|
|
cart; **live search suggestions** (visible-before-add, ↑/↓+Enter); **F2 qty / F3 price
|
|
|
inline cell edits** (price>MRP blocked); **unknown-code quick-add** (draft item created
|
|
|
server-side, billable immediately). Verified: bill ST1C2/26-000002.
|
|
|
**P1 pass done (verified)**: tendered-cash change flash (type `100` F12 → CHANGE ₹20),
|
|
|
Ctrl+B repeat-last-bill (current prices, never resurrected), instant `*` multiplier with
|
|
|
qty chip, F4 line discount (`4` = ₹4, `5%` = percent; Disc column appears on demand),
|
|
|
audio feedback (ok/warn/err beeps), ok-notices auto-clear, stock chips in suggestions
|
|
|
(live counts, out-of-stock warn-not-block). Bill ST1C2/26-000003 proves the chain.
|
|
|
Purchase Entry P1: crash-proof localStorage draft with resume banner; glance strip on
|
|
|
cell focus (stock · sale · MRP · live margin · last-3 costs via /purchases/cost-history).
|
|
|
Returns/credit notes now LIVE (F9 — see the Returns section above). Still pending:
|
|
|
purchase schema P1s (free-qty, disc%, interstate split — one migration pass together),
|
|
|
history-R repeat, customer display.
|
|
|
|
|
|
**Supervisor PIN gate + override audit (P0-2 / P1-3) — done 2026-07-10.** In-flow price/
|
|
|
discount overrides now raise a supervisor PIN instead of applying silently (R20, 09-UX §5.5).
|
|
|
New `POST /api/auth/verify-pin` reuses the existing `attempt()` scrypt + lockout but creates
|
|
|
**no session** (never pollutes `sessions`): success audits `PIN_VERIFY`, a wrong PIN audits
|
|
|
`PIN_VERIFY_FAILED` and increments the same per-user lockout (5 fails → locked 60 s, E-6104),
|
|
|
and only supervisor/manager/owner roles may approve — a valid-PIN cashier is rejected (E-6105).
|
|
|
`POST /api/bills` gained an `overrides[]` body; `commitBill` writes one audit row per entry
|
|
|
(`PRICE_OVERRIDE`/`QTY_OVERRIDE`/`DISCOUNT_OVERRIDE`, entity `bill`, before/after JSON with the
|
|
|
approver resolved to a display name) **inside the existing commit transaction** (R17). POS: a
|
|
|
`POS_LIMITS` const (`PRICE_BAND_PCT=10`, `DISC_WARN_PCT=10` — module consts today, DB `setting`
|
|
|
rows later per R9) tops `BillingScreen`; a reusable `supervisorGate(desc)` promise renders an
|
|
|
**inline** panel (never a modal) replacing the notice area — supervisor picker (bootstrap
|
|
|
`users`, cashiers excluded), 4–6 digit PIN, 3 wrong PINs closes + cancels the edit;
|
|
|
`verifySupervisorPin` verifies without touching the module token (the cashier stays logged in).
|
|
|
F3 price: >MRP stays hard-blocked; a cashier's change (beyond ±band or any change) raises the
|
|
|
gate, owner/supervisor/manager self-authorise via `gateFor` (@sims/auth). F4 discount > 10% of
|
|
|
line value raises the same gate. Approved edits accumulate in an `overrides` state array (paise
|
|
|
+ approvedByUserId), ride with `postBill`, and clear on commit/cart-clear. Verified 2026-07-10:
|
|
|
**117 tests green**, typecheck clean on root + all three apps, all three apps build, store-server
|
|
|
rebuilt + restarted. Browser E2E: Ramesh (cashier) billed Surf Excel → F3 145.00→120.00 (beyond
|
|
|
10% band) → inline panel appeared (picker showed only Suresh + Thomas) → a wrong PIN showed
|
|
|
"attempt 1 of 3" → Suresh PIN 8265 approved → F4 20% discount (₹24 > 10%) raised the gate again
|
|
|
→ approved → F12 committed **ST1C2/26-000006** (₹96.00, engine recomputed both edits) → back
|
|
|
office (thomas/sims) Audit Log shows **PRICE_OVERRIDE** `before 14500 → after 12000, approvedBy
|
|
|
Suresh` and **DISCOUNT_OVERRIDE** `before 0 → after 2400, approvedBy Suresh`. Also confirmed via
|
|
|
API: a cashier picked as approver is rejected (E-6105) and 5 wrong PINs lock the user (E-6104).
|
|
|
Back-office polish: the Audit detail column now renders `before → after` for override rows.
|
|
|
**Caveats:** override records clear on hold/cart-clear (a resumed held cart re-approves);
|
|
|
`POS_LIMITS` awaits the DB settings surface (R9 follow-up); the web bundle imports the pure
|
|
|
`@sims/auth/permissions` subpath because the barrel re-exports scrypt (node:crypto) — added a
|
|
|
matching vite alias + tsconfig path. No git commits.
|
|
|
|
|
|
**Catalog scale (S1 + S6) — done 2026-07-10.** New `@sims/search-core` (pure TS, no
|
|
|
deps) replaces BillingScreen's linear name filter: `phoneticKey` folds the Indic
|
|
|
spelling variants the spec names (aa→a, ee→i, oo→u, w→v, ph→f, bh→b, chh→ch, sh→s,
|
|
|
th→t, dh→d, z→j, drop doubled letters), and `buildIndex`/`querySearch` rank matches in
|
|
|
tiers (whole-prefix → token-prefix → phonetic → substring, shorter name breaks ties).
|
|
|
The POS memoizes the index over its live item cache (grows with quick-adds); the ≤8-row
|
|
|
dropdown and arrow/Enter behaviour are unchanged. S6/R13: the silent `LIMIT 2000` in
|
|
|
`listItems` is gone — `GET /api/items?all=1` returns the whole non-inactive catalog for
|
|
|
the POS cache (which now fetches with `?all=1`), every other path paginates
|
|
|
(limit/offset, default 5000); `listBills`/`listAudit` and their routes paginate too
|
|
|
(default 100), and the back-office Bills and Audit pages grew a "Load more" button.
|
|
|
Verified 2026-07-10: 66/66 tests green (11 new folding/tier cases), `npm run typecheck`
|
|
|
clean on root + all three apps, all three apps build, and the store-server was rebuilt
|
|
|
and restarted — `/api/health` ok, `/api/items?all=1` returns the full 12-item catalog
|
|
|
with no cap, limit/offset confirmed on items/bills/audit, and a phonetic query
|
|
|
`ashirwad` resolves to `Aashirvaad Atta 5kg`. Integer paise, existing tests, and the
|
|
|
zero-runtime-dep rule are untouched.
|
|
|
|
|
|
**Print coverage (S2) — done 2026-07-10.** Three new pure modules in `@sims/printing`:
|
|
|
`invoice-html.ts` (`renderInvoiceHtml` → a self-contained, printable **A4 GST tax
|
|
|
invoice**: seller name/GSTIN/address header, buyer block, HSN-wise line table, a
|
|
|
CGST/SGST **or** IGST + cess breakup grouped by HSN+rate, round-off, payable, and
|
|
|
**amount-in-words** via a new `amountInWordsINR(paise)` domain util — integer paise,
|
|
|
Indian lakh/crore numbering, singular "Paisa" — plus a bilingual English/Hindi footer);
|
|
|
`labels.ts` (`ean13Svg` — standards-correct EAN-13 with L/G/R element patterns and
|
|
|
first-digit parity, checksum via `@sims/scanning`'s `ean13CheckDigit`; `renderLabelSheetHtml`
|
|
|
— a printable barcode/MRP label grid); and `raster.ts` (pure `packRasterRow` /
|
|
|
`bitonalFromRGBA` / `rasterToEscPos` for **GS v 0** 1-bit bit-image receipts). Back
|
|
|
office: **Bills rows are clickable** → the A4 invoice opens in a print-ready tab (Blob
|
|
|
URL, own "Print A4" button); **Catalog → Label Printing is now LIVE** (prints labels for
|
|
|
current items, columns + label size configurable); Purchase Entry offers "Print labels"
|
|
|
for the received lines after a post. POS: a **"Receipt script: ASCII / Raster (Indic)"**
|
|
|
setting (persisted in the printer cfg) routes the commit-time receipt through a
|
|
|
canvas→GS v 0 raster path (`apps/pos/src/raster.ts`) so Devanagari/Malayalam item names
|
|
|
print, sharing one `receiptLines` content model with the ASCII path. Verified 2026-07-10:
|
|
|
**97 tests green** (20 new S2 cases: amountInWordsINR 0/paisa/1/lakh/crore, invoice fields
|
|
|
+ amount-in-words + IGST switch, EAN-13 structure + checksum, label sheet, raster
|
|
|
bit-packing), typecheck clean on root + all three apps, all three apps build, store-server
|
|
|
rebuilt and restarted. Drove the back office in a real browser (thomas/sims): Bills → click
|
|
|
`ST1C2/26-000001` → A4 invoice rendered with **"Rupees Six Hundred Only"**, CGST/SGST
|
|
|
breakup and place-of-supply Kerala; Catalog → Label Printing → sheet rendered **7 real
|
|
|
EAN-13 SVG symbols**; and the raster canvas pipeline was exercised in-browser (384-px 2"
|
|
|
head, Indic glyphs drew 1,575 ink pixels via the OS font, GS v 0 header bytes correct).
|
|
|
**Honest caveats:** raster receipts are unit- and canvas-verified but the physical thermal
|
|
|
output needs a real 80 mm printer; canvas Indic glyphs fall back to the shop PC's OS fonts
|
|
|
(Windows ships Nirmala UI) — bundled canvas webfonts are a follow-up; the demo seed
|
|
|
barcodes carry placeholder check digits, so `ean13Svg` encodes stored 13-digit codes
|
|
|
as-is (a strict `normalizeEan13` validator is exported for real catalog input).
|
|
|
|
|
|
**B2B at the counter (S3) — done 2026-07-10.** A registered buyer's GSTIN now flows from
|
|
|
attach → engine → document. New pure `deriveSupply(customer, storeState)` in `@sims/domain`
|
|
|
is the single place-of-supply rule both surfaces run: no GSTIN → B2C, place of supply = the
|
|
|
store (CGST/SGST); a GSTIN → B2B, place of supply = the buyer's state (its first two digits,
|
|
|
or an explicit stateCode), inter-state ⇒ IGST. **Parties gained B2B identity**: `createCustomer`
|
|
|
takes an optional GSTIN, **validated server-side** with `validateGstin` (bad checksum/shape
|
|
|
rejected with E-1401/E-1402), the state code derived from the GSTIN when absent; the back
|
|
|
office **Customers page has a GSTIN column** and the POS **F6 attach modal has an optional
|
|
|
"GSTIN (B2B)" field with live checksum/state feedback**. **Billing is now place-of-supply
|
|
|
aware**: the POS `computeBill` ctx uses the attached customer's state (falls back to the
|
|
|
store), and `commitBill` **does not trust the client** — it re-reads the customer row, derives
|
|
|
place of supply itself, recomputes, and the paisa-agreement check (R5) stays. The **immutable
|
|
|
bill snapshots the buyer** (`buyer_gstin`, `place_of_supply` — added to the CREATE TABLE and
|
|
|
back-filled on the running dev DB by a PRAGMA-guarded `ALTER TABLE` migrate step in `db.ts`);
|
|
|
`listBills` returns them. **Documents follow**: the thermal receipt prints a buyer GSTIN line
|
|
|
(IGST rows on the existing inter-state path), the A4 invoice buyer block fills GSTIN +
|
|
|
place-of-supply from the bill (closing the S2 caveat), and the POS totals panel shows an
|
|
|
**"IGST · inter-state"** badge plus a B2B chip on the customer strip. Verified 2026-07-10:
|
|
|
**110 tests pass** (4 new: `deriveSupply` no-GSTIN/intra/inter, B2B receipt buyer line),
|
|
|
typecheck clean on root + all three apps, all three apps build, store-server rebuilt and
|
|
|
restarted (`/api/health` ok, migration added both columns to the 3-bill dev DB). Drove both
|
|
|
UIs in a real browser: POS login (Ramesh/4728, Skip count) → F6 → created **Anand Traders**
|
|
|
(phone 9876543210) with Maharashtra GSTIN **`27ABCDE1234F1Z0`** (body `27ABCDE1234F1Z` +
|
|
|
`gstinCheckDigit` = `0`) → the modal showed "place of supply state 27 — GSTIN valid", the
|
|
|
strip showed the B2B chip, the totals showed **IGST · inter-state** → billed Surf Excel
|
|
|
(GST ₹22.12 as IGST) → committed **ST1C2/26-000004**. Server row confirms `buyer_gstin`,
|
|
|
`place_of_supply='27'`, `igst_paise=2212`, `cgst=sgst=0`, payable ₹145.00 (client/server
|
|
|
agreed to the paisa). Back office → Bills → the row opened its A4 **Tax Invoice** with
|
|
|
**Billed To Anand Traders / GSTIN 27ABCDE1234F1Z0 / State 27 — Maharashtra**, Place of Supply
|
|
|
**"27 — Maharashtra"**, and an **IGST** tax row (no CGST/SGST); Customers shows the GSTIN column.
|
|
|
**Honest caveats:** the buyer GSTIN receipt line is unit-tested only (no physical printer here);
|
|
|
the seed store's state 32 resolves as "Kerala" in the invoice state map though it is named
|
|
|
T.Nagar — a seed-data quirk unrelated to S3; the e-Invoice/IRN (GSP) hook remains P2 (S3 stops
|
|
|
at the document, per doc 17 §S3). Unrelated pre-existing failure: `apps/hq/test/gmail.test.ts`
|
|
|
imports a not-yet-added `../src/gmail` (HQ-console in-flight work, untouched by S3).
|
|
|
|
|
|
**Counter resilience / offline queue (S4) — done 2026-07-10.** A browser POS now
|
|
|
survives the store-server dying mid-shift: billing continues, nothing is lost, the queue
|
|
|
drains on reconnect (02-ARCHITECTURE §3, R18 cart-never-lost). New pure
|
|
|
`apps/pos/src/offline.ts` (**raw IndexedDB, zero new deps**): when `postBill` fails with a
|
|
|
**network** error — the server unreachable, distinguished from a server that *answered*
|
|
|
with a 4xx/5xx (which still just keeps the cart on screen) via a new `NetworkError`
|
|
|
type — the sale is persisted to an IndexedDB `bills` store with a **client UUIDv7** id and
|
|
|
a provisional token **OFF-n**, the cart clears, and billing continues; the receipt prints
|
|
|
**"PROVISIONAL OFF-n (offline)"** instead of a GST doc no, and an amber
|
|
|
**"OFFLINE · n queued"** chip shows in the status bar. A background **reconnect drain**
|
|
|
(every 15 s ping `/api/health`, also on the window `online` event) POSTs queued bills
|
|
|
**oldest-first** to a new **idempotent `POST /api/bills/offline`**: the client bill id is the
|
|
|
idempotency key, stored on a new `bill.client_id` column (PRAGMA-guarded `ALTER` + a
|
|
|
partial `CREATE UNIQUE INDEX … (tenant_id, client_id) WHERE client_id IS NOT NULL`), and
|
|
|
`commitBill` short-circuits on a known client id, so a **re-drained payload returns the
|
|
|
already-assigned doc no — never a second bill or stock movement**. The **real GST series
|
|
|
number is allocated at drain time** and engine re-verification (R5) still runs; the POS
|
|
|
maps each **OFF-n → the real doc no** in a notice and deletes it from IDB. **The drain
|
|
|
endpoint is authenticated** (security review, SEC-1): it sits below `requireAuth` and takes
|
|
|
tenant + cashier from the cashier's **live session**, never from the body — an earlier
|
|
|
session-independent variant that trusted a body `cashierId` was an unauthenticated
|
|
|
bill-commit hole and was removed. If the reconnected server has restarted (its in-memory
|
|
|
sessions gone) the drain simply **401s and the queue waits until the counter re-logs in** —
|
|
|
nothing is lost; a transient blip that leaves the process alive drains seamlessly. **Override
|
|
|
approvals are unforgeable** (security review, SEC-2): `/auth/verify-pin` now mints a
|
|
|
**single-use token** (new `override_approval` table) that the POS sends with each override
|
|
|
(`approvalId`, replacing the old client-supplied `approvedByUserId`); `commitBill` redeems
|
|
|
it **inside the commit transaction** (must be unused, < 5 min old, approver still holds a
|
|
|
supervisor/manager/owner role), marks it used, writes the audit row from the **server-side**
|
|
|
approver identity, and **rejects the whole commit** (rolls back) on any mismatch — the client
|
|
|
can no longer claim an approver it never got. **Item-cache persistence:**
|
|
|
the catalog (items + tax classes + store/users) is saved to IndexedDB at login and the
|
|
|
session token to `sessionStorage`, so a **mid-shift browser refresh with the server down
|
|
|
resumes straight to billing** behind an **"OFFLINE — using cached catalog"** banner (a
|
|
|
fresh login still needs the server — a locally-cached PIN-hash login is deliberately out
|
|
|
of scope). **App shell:** a minimal cache-first service worker (`apps/pos/public/sw.js`,
|
|
|
registered in `main.tsx`, scope `/pos/`) serves the built assets from cache when the
|
|
|
server is down; `/api/*` is **never** cached, so the queue + drain logic stay in control.
|
|
|
Verified 2026-07-10: **164 tests green** (6 offline-serialization cases —
|
|
|
`nextProvisionalToken` / `buildQueuedBill` / `toOfflineBillBody` — plus 4 new server-side
|
|
|
`override_approval` security cases: fabricated / replayed / non-approver-role tokens and a
|
|
|
missing-approval override are each rejected, a genuine token audits the server-side
|
|
|
approver), typecheck clean on root + all three apps, all three apps build, store-server
|
|
|
rebuilt and restarted (migration added `bill.client_id` + the partial unique index + the
|
|
|
`override_approval` table to the running dev DB). **Full browser E2E:** Ramesh/4728 → billed Surf Excel online (**ST1C2/26-000007**)
|
|
|
→ **killed the store-server** → billed Parle-G then Maggi (each showed **"saved OFF-n"**, the
|
|
|
amber **OFFLINE · n queued** chip, cart cleared; both persisted to IndexedDB with valid
|
|
|
uuidv7 ids) → **restarted the server** → the drain mapped **OFF-1 → ST1C2/26-000008** and
|
|
|
**OFF-2 → ST1C2/26-000009**; back-office **Bills** shows both with real doc nos and Parle-G
|
|
|
carries exactly one `SALE -1` movement (**no duplication**). **Idempotency proven** by
|
|
|
re-POSTing a drained payload manually — the server returned the same doc no with
|
|
|
`duplicate:true`, bill count unchanged. **Offline resume proven** by killing the server and
|
|
|
reloading `/pos/` — the app shell loaded from the SW cache and resumed to the billing
|
|
|
screen with the cached catalog + banner (quick keys populated from IndexedDB, no server).
|
|
|
**Security-review fixes verified E2E:** an unauthenticated `POST /api/bills/offline` now
|
|
|
returns **401**; after a full restart the stale-session drain 401s, the queue **holds OFF-n**
|
|
|
(nothing lost) and the POS shows a **"Sign in to sync N offline bills"** prompt, then after
|
|
|
**re-login** the authenticated drain committed the queued bill (e.g. **OFF-4 →
|
|
|
ST1C2/26-000015**) with `cashier_id` taken from the session, not the body. For
|
|
|
overrides, a **fabricated** approvalId and a **replayed** (already-used) approvalId are each
|
|
|
rejected **403** with the whole commit rolled back and **no doc-series gap**, a **cashier**
|
|
|
PIN yields no token (403), while a genuine **Suresh/8265** approval committed and audited
|
|
|
from the server-side identity. **Gate E2E (approvalId path):** F3 price change ₹145.00 →
|
|
|
₹120.00 (beyond the ±10 % band) as Ramesh raised the inline supervisor panel → **Suresh/8265
|
|
|
approved** → committed **ST1C2/26-000012** at ₹120.00, and the **Audit** row records
|
|
|
**PRICE_OVERRIDE** with `approvedBy: Suresh` / `approvedByUserId: u2` **read server-side from
|
|
|
the redeemed token** (by Ramesh), plus the matching PIN_VERIFY. **Honest caveats:** the
|
|
|
reconnect drain needs a live session, so after a full server *restart* the queue waits for
|
|
|
the counter to **re-log in** before it drains (a transient blip that leaves the process alive
|
|
|
drains seamlessly) — and a new online bill likewise needs a fresh login after a restart (the
|
|
|
401 keeps the cart and does **not** queue; only true network failures queue); an override
|
|
|
approval token is valid for **5 minutes**, so in the rare race where a supervisor approves
|
|
|
online and the server dies before commit, that one queued bill's override would expire before
|
|
|
drain (in practice offline bills carry no overrides — the gate itself needs the server up);
|
|
|
the service worker precaches only the navigation root on install, so the full shell caches on
|
|
|
the first *controlled* load (one online reload) and a new deploy is seen only after the SW's
|
|
|
`CACHE` version bumps; provisional receipts can't physically print while the server (the
|
|
|
print relay) is itself down.
|
|
|
|
|
|
**Batch & expiry (S5) — done 2026-07-10.** Perishables now carry lots from purchase to
|
|
|
counter to dashboard, with FEFO picking and an expired-sale guard. New pure
|
|
|
`@sims/domain/batch.ts` (`sortFefo` earliest-expiry-first/undated-last, `isExpired`,
|
|
|
`pickFefoBatch` = earliest **non-expired** lot else earliest) is the single FEFO rule both
|
|
|
surfaces and the tests share (ISO dates compare lexicographically — no Date parsing).
|
|
|
**Schema (R7 preserved):** a new `batch` table (uuidv7 id, tenant/store/item, batch_no,
|
|
|
**nullable** expiry_date, UNIQUE(tenant,store,item,batch_no)); `stock_movement` and
|
|
|
`purchase_line` gained a nullable `batch_id` (CREATE TABLE additions + a PRAGMA-guarded
|
|
|
`migrate()` for the running dev DB — the `idx_move_batch` index lives in `migrate()` only,
|
|
|
after the ALTER, so it never races the SCHEMA pass on an upgraded DB). **Per-batch on-hand
|
|
|
stays derived** — `SUM(movements WHERE batch_id=lot)`, never stored. **Seed/migrate**
|
|
|
marks Amul Butter (107) and Maggi (105) `batch_tracked` and, if no batch exists, splits
|
|
|
each item's opening 120 into lots (one **expired-with-stock**, one **≤7-day**, one **~6-month**
|
|
|
for Amul; ≤7-day + ~6-month for Maggi) via per-lot OPENING movements **offset by one
|
|
|
negative un-batched OPENING adjustment** so total on-hand is unchanged (idempotent; runs
|
|
|
from `seedIfEmpty` for fresh DBs and from `server.ts` for the already-populated dev DB).
|
|
|
**Purchase capture:** for a batch-tracked item the Enter chain extends qty→cost→**batch
|
|
|
no→expiry** (date input; blank Enter skips expiry for non-perishables) →scan; the server
|
|
|
`upsertBatch`es the lot (unique key or new, filling a later expiry) and carries `batch_id`
|
|
|
onto the purchase line and the stock-IN movement. **POS FEFO pick (R18 — never modal):**
|
|
|
`GET /api/batches` (per-batch on-hand>0, item-keyed) is cached at login and refreshed after
|
|
|
each commit; adding a batch-tracked item raises an **inline** picker, FEFO-sorted with the
|
|
|
first sellable lot preselected (↑/↓ move, Enter accepts), **expired lots flagged red and
|
|
|
blocked with E-1310** unless approved — a supervisor+ sells on their own authority, a
|
|
|
cashier raises the existing `supervisorGate`, and the approval rides as a new `kind:'batch'`
|
|
|
override audited **BATCH_OVERRIDE** server-side (single-use token, same SEC path as price/
|
|
|
discount). The committed line carries `batchId`; `commitBill` writes the movement's
|
|
|
`batch_id` and **rejects (rolls back) any batch that doesn't belong to the item+store
|
|
|
(E-1311)**. **Expiry dashboard is LIVE** (replaces the registry wireframe via the CUSTOM
|
|
|
map): `GET /api/expiry` feeds ≤7-day and ≤30-day buckets with per-batch on-hand + value at
|
|
|
sale price, an expired-with-stock section, and a dead-stock note; the Stock page gained a
|
|
|
batch-tracked second table + a "batch" tag. **Verified 2026-07-10: 178 tests green** (6 new
|
|
|
FEFO domain cases + 8 server batch cases: seed totals-preserved/idempotent, purchase upsert
|
|
|
+ reuse, batch-attributed decrement, E-1311 belongs-check, BATCH_OVERRIDE audit), typecheck
|
|
|
clean on root + all three apps, all three apps build, store-server rebuilt and restarted
|
|
|
(migration added the batch table + `batch_id` columns + demo lots to the running dev DB;
|
|
|
totals held — Amul 120, Maggi 119). **Full browser E2E (Puppeteer, real Chromium):** back
|
|
|
office (thomas/sims) → Purchase Entry → Amul Butter, Enter chain captured **batch B-TEST /
|
|
|
expiry 2026-07-17**, F12 **posted**; Expiry Dashboard rendered live — **≤7-day ₹6,350 / 3
|
|
|
batches** (B-105A ₹1,400, B-107A ₹3,300, B-TEST ₹1,650) and **Expired-with-stock ₹2,200 /
|
|
|
1 batch** (B-107X). POS (Ramesh/4728, Skip count) → billed Amul Butter → the **inline FEFO
|
|
|
picker** listed B-107X (EXPIRED, red) · **B-107A (FEFO-preselected, exp 2026-07-15)** ·
|
|
|
B-TEST · B-107B → Enter committed **ST1C2/26-000016** and **B-107A on-hand fell 12→11**
|
|
|
(per-batch, not item-wide); a second bill navigated (↑) to the expired **B-107X** → the
|
|
|
inline gate showed **"batch B-107X EXPIRED 2026-07-07 — approve sale? (E-1310)"** with
|
|
|
Suresh/Thomas as approvers → cancelled, so B-107X stayed at 8 (never sold). **Honest
|
|
|
caveats:** the server validates a batch belongs to the item+store but does **not** itself
|
|
|
re-block expired sales — the E-1310 gate is POS-side with a BATCH_OVERRIDE audit trail
|
|
|
(server-side expiry enforcement is a follow-up); the batch cache is fetched in the billing
|
|
|
screen at mount (effectively login) and after each commit, but is **not** written to the S4
|
|
|
offline IndexedDB cache, so a batch-tracked sale made while the server is down bills
|
|
|
un-batched with a warning; the demo dead-stock note flags by expiry only (velocity-based
|
|
|
slow-mover ranking is a follow-up); the chrome-devtools MCP was unusable this session (its
|
|
|
browser handle wedged into a "browser already running" state that survived killing every
|
|
|
Chrome + the profile lock and needs an MCP-server restart), so the browser E2E was driven
|
|
|
via Puppeteer against the same running store-server instead. No git commits.
|
|
|
|
|
|
**Load verification (S7) — done 2026-07-10.** The 15-EDITIONS scale target is now
|
|
|
verified by load test, not assumed (R25). A scripted run (`apps/store-server/load/loadtest.mjs`,
|
|
|
plain node 22 + better-sqlite3, no new deps) drove **2,500 real bill commits distributed
|
|
|
across 10 counters** at a concurrency of 10 (one in-flight request per counter) against a
|
|
|
**second, isolated store-server instance on :5281 with a fresh data dir** — the dev DB and
|
|
|
the running :5181 server were never touched (new `SIMS_DATA_DIR` env in `db.ts` points the
|
|
|
isolated instance at its own data dir; the seed makes C1/C2, so C3–C10 were added as real
|
|
|
counter rows with their own per-counter GST series). Each bill was a randomized 1–5-line
|
|
|
cart from the seeded catalog (the 2 batch-tracked items skipped); the client payable is
|
|
|
`roundToRupee(Σ qty·price)` — exact for tax-inclusive, no-discount bills — and the server
|
|
|
still re-verified every document to the paisa with the shared engine (R5). A burst phase
|
|
|
then pushed 100 bills at concurrency 50 to find the knee.
|
|
|
|
|
|
**Environment (single dev machine — not a distributed benchmark):**
|
|
|
|
|
|
| Field | Value |
|
|
|
|---|---|
|
|
|
| Node | v22.20.0 |
|
|
|
| CPU | 12× 13th Gen Intel Core i5-1335U |
|
|
|
| RAM | 15.69 GB |
|
|
|
| OS | Windows 11 (10.0.26200) |
|
|
|
| Target | one store-server process, isolated data dir, port 5281 |
|
|
|
|
|
|
**Results:**
|
|
|
|
|
|
| Phase | Bills | Concurrency | Success | Wall | Throughput | p50 | p95 | p99 | max |
|
|
|
|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|
|
|
|
| Steady | 2,500 | 10 (1/counter) | 2,500 (100%) | 8.64 s | **289.4 bills/s** | 28.8 ms | 54.6 ms | 76.0 ms | 397.6 ms |
|
|
|
| Burst | 100 | 50 | 100 (100%) | 1.16 s | 85.9 bills/s | 445.9 ms | 737.4 ms | 746.5 ms | 748.7 ms |
|
|
|
|
|
|
**Integrity (queried directly against the load DB after the run):**
|
|
|
|
|
|
| # | Check | Result |
|
|
|
|---|---|---|
|
|
|
| a | bill rows == successful commits | **PASS** (2,600 == 2,600) |
|
|
|
| b | every counter's SALE series gap-free & duplicate-free | **PASS** (c1–c10 each `1..260`) |
|
|
|
| c | stock derived == opening − billed, per item (R7) | **PASS** (9 items reconciled; 7,800 SALE movements == 7,800 billed lines) |
|
|
|
| d | every bill's payments sum == payable | **PASS** (2,600 bills, 0 mismatch) |
|
|
|
| e | audit `BILL_COMMIT` count == bill count (R17) | **PASS** (2,600 == 2,600) |
|
|
|
| f | outbox rows == bill count (R15) | **PASS** (2,600 == 2,600) |
|
|
|
|
|
|
**Honest interpretation.** Steady-state throughput on this dev laptop is **~289 committed
|
|
|
bills/sec** with a sub-30 ms median. Reading the S7 target at its heaviest (10 counters ×
|
|
|
2,500 = 25,000 bills/day), the entire day's work is **~86 s of server-busy time** spread
|
|
|
across a full trading day — 2–3 orders of magnitude of headroom; the lighter reading (2,500
|
|
|
bills/day for the store) completes in **8.6 s**. `better-sqlite3` serializes writes in-process
|
|
|
(one synchronous commit at a time), so throughput is bounded by single-core commit cost
|
|
|
(~3.5 ms/bill) and concurrency buys latency, not throughput: the burst at concurrency 50
|
|
|
inflated p50 latency ~15× (29 ms → 446 ms) **without** raising throughput (it measured lower),
|
|
|
so the knee is well below 50 — the realistic 10-counter operating point already sits near the
|
|
|
single-write-core ceiling with a wide latency margin. Correctness held perfectly under load:
|
|
|
zero failed commits, and all six integrity invariants (no series gaps/dups across 10
|
|
|
independent counters, exact stock reconciliation, payments == payable, one audit + one outbox
|
|
|
row per bill) passed on 2,600 bills. Incidentally confirmed the 2-digit counter code C10 stays
|
|
|
within the GST 16-char doc-no rule at 6-digit sequences (`ST1C10/26-000260`). These are
|
|
|
single-machine dev-hardware numbers; a store-server on real store hardware and the future
|
|
|
Oracle 12c adapter (D12) will have different constants, but the serialization model and the
|
|
|
integrity guarantees are the same. No git commits; typecheck clean on the one TS file touched
|
|
|
(`db.ts`), `:5281` instance killed after the run.
|
|
|
|
|
|
## Purchase Entry — LIVE (the second crown jewel)
|
|
|
|
|
|
`/purchases/entry`: supplier type-ahead → invoice no/date → scan-grammar line entry
|
|
|
(barcode / code / name / `48*surf`) → qty→cost cell flow (Enter-advances) → last-cost
|
|
|
prefill + cost-changed price-revision prompt → printed-total cross-check → F12 post.
|
|
|
Server: transactional commit (stock IN, price/MRP updates + audit, duplicate-invoice
|
|
|
rejection, totals re-verification). Purchase List page live too. Verified end-to-end:
|
|
|
HD/2651 ₹6,988.83 posted, duplicate rejected, stock 118→166. Full P1/P2 backlog:
|
|
|
docs/13-SPEC-PURCHASE-ENTRY.md.
|
|
|
|
|
|
## Back office — 46 screens (`/`) — WIREFRAME (sample data)
|
|
|
|
|
|
Login (1) + Dashboard (1, custom) + 44 module pages:
|
|
|
|
|
|
| Module | Pages | Count |
|
|
|
|--------|-------|------:|
|
|
|
| Catalog | Items · Categories · Price Lists · Schemes & Offers · Label Printing | 5 |
|
|
|
| Sales | Bills · Returns & Credit Notes · Customers · Khata & Collections · Loyalty | 5 |
|
|
|
| Purchases | Purchase List · Purchase Entry · **Purchase Inbox** (custom split-review) · Suppliers · GSTR-2B Reconciliation | 5 |
|
|
|
| Inventory | Stock · Adjustments · Transfers · Stock Take · Expiry Dashboard | 5 |
|
|
|
| Accounting | Vouchers · Ledgers · Day Book · P&L/Balance Sheet · Tally Export | 5 |
|
|
|
| GST | GSTR-1 · GSTR-3B · e-Invoice Queue · e-Way Bills · HSN Summary | 5 |
|
|
|
| Reports | Sales Reports · Margins · Stock Aging · Day-End Digest | 4 |
|
|
|
| Admin | **Users & Roles** (custom matrix) · Stores & Counters · **Settings** (custom hierarchy) · Print Templates · Integrations · Subscription & Plan · Audit Log · Sync & Devices · Backups · Data Import | 10 |
|
|
|
|
|
|
**11 pages are now LIVE on real data**: Items (list + search + New Item — billable at the
|
|
|
counter immediately), Customers, Bills (rows open the printable A4 GST invoice — S2),
|
|
|
**Returns & Credit Notes** (live credit-note list, against-bill + reason + refund — F9 flow),
|
|
|
Stock (derived from movements, + per-batch breakdown — S5), Audit Log, Label Printing
|
|
|
(barcode/MRP sheets — S2), the **Expiry Dashboard** (live near-expiry/expired buckets — S5), and
|
|
|
the three **GST returns** pages — **GSTR-1**, **GSTR-3B** and **HSN Summary** (folded live from the
|
|
|
counters' immutable documents, with a reconciliation banner and a portal-shaped JSON export).
|
|
|
Login is real (server-verified). 34 pages render from the data-driven registry; 4 are
|
|
|
custom wireframes (Dashboard, Purchase Inbox, Settings hierarchy, Users & Roles).
|
|
|
Edition-locked pages render as upsells; everything themed and role/edition-aware.
|
|
|
|
|
|
## Store-server (`apps/store-server`) — RUNNING SERVICE with DATA LAYER
|
|
|
|
|
|
Serves both web apps + `POST /api/print` + `GET /api/health`, and now the data API:
|
|
|
`/api/auth/pin`, `/api/auth/login` (scrypt + lockout), `/api/bootstrap`, `/api/items`
|
|
|
(GET/POST), `/api/parties` (GET/POST), `/api/bills` (GET/POST with engine
|
|
|
re-verification), `/api/returns`, `/api/gst/{gstr1,gstr3b,hsn,gstr1.json}` (READ-ONLY
|
|
|
returns reporting, REPORT_VIEW), `/api/stock`, `/api/audit`. SQLite (WAL) at `apps/store-server/data/`
|
|
|
behind portable repositories (`src/repos.ts`) — **the Oracle 12c adapter reimplements
|
|
|
those signatures when the Classic DDL arrives; nothing above them changes.**
|
|
|
Port 5181 (8080–85 are Windows-reserved).
|
|
|
|
|
|
## Not built yet (per roadmap)
|
|
|
|
|
|
- Store-server data layer: Oracle repository, real auth, item/bill APIs → needs the
|
|
|
**Classic Oracle DDL** (→ docs/12-DATA-MODEL.md, to be written once the DDL arrives)
|
|
|
- Owner mobile app (Phase 3) · Customer display · HQ console (doc 11): ops slice now spec'd to **start early** as `apps/hq` (docs/14-SPEC-HQ-CONSOLE.md, D15); fleet half still waits for the cloud tier (D14)
|
|
|
- Purchase Inbox backend (AI parsing) · e-Invoice/e-Way GSP · WhatsApp/UPI integrations
|
|
|
- Sync agent + cloud tier (D14: deliberately later) · Classic migration tool
|
|
|
- Onboarding wizard · i18n content (mechanism exists, Hindi/Malayalam strings pending)
|
|
|
|
|
|
## Decisions: resolved vs open
|
|
|
|
|
|
Resolved: D2 web-first · D5 DB-driven plans/prices · D8 Android later · D12 Oracle 12c
|
|
|
for now · D14 local-first-then-cloud · D15 HQ ops console early, in-monorepo.
|
|
|
**Open: D1 product name · D4 GSP vendor · D5 ₹ amounts · D11 builder headcount · D13 helpdesk.**
|
|
|
|
|
|
## How to run
|
|
|
|
|
|
```
|
|
|
cd apps/store-server && npm start # http://localhost:5181 (/ and /pos/)
|
|
|
npm test && npm run typecheck # from repo root
|
|
|
```
|