# Purchase Entry — `/purchases/entry` Implementation Spec Scope: back-office web (React + `@sims/ui` + `@sims/scanning` + store-server API). Everything below is implementable today against the current tree. Key discovery: `apps/store-server/src/repos-purchase.ts` **already exists** (`commitPurchase`, `lastCosts`, `listPurchases`) with `purchase`/`purchase_line` tables in `db.ts`, but **nothing is wired into `api.ts`** and the page at `/purchases/entry` is a registry wireframe. This spec turns that into the real screen. **Files touched (master list)** - NEW `apps/backoffice/src/pages/PurchaseEntry.tsx` — the screen - `apps/backoffice/src/main.tsx` — add `'/purchases/entry': PurchaseEntry` to `CUSTOM` (auto-overrides the registry wireframe) - `apps/backoffice/src/api.ts` — client fns: `getSuppliers`, `createSupplier`, `checkInvoice`, `getLastCosts`, `getTaxClasses`, `postPurchase`, `getPurchases` - `apps/store-server/src/api.ts` — NEW routes (listed in §6) - `apps/store-server/src/repos-purchase.ts` — `findPurchase()`, invoice-no normalization, outbox row, printed-total columns - `apps/store-server/src/repos.ts` — generalize `createCustomer` → `createParty(db, tenantId, {name, kind, phone?, gstin?, stateCode?})` - `apps/store-server/src/db.ts` — 2 new columns on `purchase`, later `label_job` (see §6) - `packages/billing-engine` — NEW exported `computePurchase(lines)` so client and server share one math (same-engine idiom as `computeBill`; `repos-purchase.ts:lineMath` refactors to call it) --- ## 1. Screen layout (zones) ``` ┌──────────────────────────────────────────────────────────────────────────────┐ │ HEADER STRIP (collapses to one line after date; Alt+E reopens) │ │ Supplier ▌amu… → [HUL Distributor ▾] Invoice no [HD/2651] Date [08-07] │ │ ⚠ dup: "HD/2651 already entered 04-Jul" chip: Interstate (IGST) │ ├──────────────────────────────────────────────┬───────────────────────────────┤ │ ┌──────────────────────────────────────────┐ │ ITEM CARD (P1 right rail) │ │ │ SCAN / TYPE ▌ (the sacred line, again) │ │ Amul Butter 500g │ │ └──────────────────────────────────────────┘ │ Stock 9 · MRP 310 │ │ # Item Qty Cost MRP Mgn% Amt │ Last costs (this supplier) │ │ 1 Amul Butter 500g 24 261.90 310 11% 6.6k│ 261.90 04-Jul │ │ 2 Amul Cheese 200g 12 112.50↑ 135 9%⚠ 1.4k│ 259.80 12-Jun │ │ cursor stops: Qty → Cost (→MRP per profile) │ 258.00 28-May │ ├──────────────────────────────────────────────┴───────────────────────────────┤ │ FOOTER Lines 2 · Qty 36 | Taxable 7,912.80 · GST 395.64 · TOTAL ₹8,308.44 │ │ Printed total [________] Δ — [Ctrl+Enter] Check & Post │ └──────────────────────────────────────────────────────────────────────────────┘ ``` **Keymap (page-scoped, one `useEffect` keydown handler exactly like `BillingScreen.tsx:200`)** | Key | Action | |---|---| | Enter | Confirm field / advance cell (item → qty → cost → [MRP] → scan box) | | Esc | Back one level: cell → scan box → (empty) header summary; never deletes a line | | Ctrl+Enter | Post flow: totals-check overlay → price-update overlay → commit | | `n*` prefix + scan/code | Qty multiplier (`24*` + scan = qty 24), via existing `parseEntry` | | ↑ / ↓ | Select grid line (when scan box empty) / navigate type-ahead lists | | F2 | Edit Qty of selected line | | Del, Del | Remove selected line (first Del arms inline "Del again to remove", 2 s timeout) | | Tab / Shift+Tab | Visit every cell including profile-skipped ones (MRP, later Disc/Free) | | Alt+E | Reopen header strip to edit supplier/invoice/date | | F1 | Keymap cheat-sheet overlay | **Focus doctrine:** identical sentinel to POS — the active input's `onBlur` refocuses itself via `setTimeout(0)` unless an overlay is open (copy the pattern at `BillingScreen.tsx:264`). No toast/dialog ever fires system-initiated; all errors render inline in the zone they belong to. Back office may normally toast, but this page adopts the counter creed per 09-UX §3.2. --- ## 2. P0 — the working fast path (ship first) ### P0-1 · Supplier type-ahead with create-new **Behavior.** On mount, fetch suppliers once (`GET /parties?kind=supplier` — exists) and focus the supplier field. Each keystroke filters client-side with the same startsWith-first ranking as `BillingScreen.tsx:search()`; dropdown shows top 8 (name + GSTIN + last-purchase date from `GET /purchases` cache). ↑/↓ + Enter picks and advances focus to Invoice No. Last-used supplier id is remembered in `localStorage['pe.lastSupplier']` and pre-selected on mount — Enter alone accepts it (distributor stacks). No match on Enter → inline panel "Create supplier ‘Amul Agy’ — [Enter] create · [Esc] retype"; second Enter opens a 2-field micro-form (Name prefilled from typed text, GSTIN optional with checksum validation via `@sims/domain/gstin`); Enter saves and advances. **Keystrokes.** Existing supplier: 3 chars + Enter = 4. Repeat supplier: 1. Create: name + Enter×3 (+ GSTIN typing). **Focus.** Mount → supplier. After first line exists the header is read-only (Alt+E to edit; editing supplier after lines exist re-runs the last-cost prefetch and re-flags cost chips). **Edge cases.** GSTIN state (first 2 chars) ≠ `store.state_code` → render "Interstate (IGST)" chip (display-only in P0; split stored in P1-6). Duplicate supplier names allowed, disambiguated by GSTIN in the dropdown. **Touches.** `PurchaseEntry.tsx`; server: generalize `repos.ts:createCustomer` → `createParty` and extend `POST /parties` in `api.ts:112` to accept `kind|gstin|stateCode` — **small NEW server change** (route exists, currently hardcodes `'customer'`). ### P0-2 · Invoice no + date + duplicate-invoice warning **Behavior.** Invoice no: free text, normalized `trim().toUpperCase()`. On Enter → fire NEW `GET /purchases/check?supplierId=&invoiceNo=`; if hit, red inline notice under the field: "Already entered 04-Jul · ₹8,150 · 12 lines — posting will be rejected (E-7301)". The warning never blocks typing lines (the server hard-rejects at post anyway via the existing `UNIQUE(tenant_id, supplier_id, invoice_no)` + 409 at `repos-purchase.ts:58`). Date defaults to today; Enter accepts; accepts `0807`, `08/07`, `08-07-2026`. After date-Enter the header collapses to a one-line summary and focus lands in the scan box. **Keystrokes.** invoice chars + Enter + Enter = typing + 2. **Edge cases.** Future invoice date → amber inline "dated tomorrow?" (accepted on second Enter). Same normalization applied inside `commitPurchase` so `hd/2651` and `HD/2651` cannot double-post — **add `invoiceNo = input.invoiceNo.trim().toUpperCase()` at `repos-purchase.ts:41`**. **Touches.** `PurchaseEntry.tsx`; NEW repo fn `findPurchase(db, tenantId, supplierId, invoiceNo)` in `repos-purchase.ts` + NEW route in `api.ts`. ### P0-3 · Line grid — one scan/type box, POS grammar, Enter-advances-cell **Behavior.** The entry box reuses `parseEntry` + `WedgeDetector` from `@sims/scanning` verbatim (same wiring as `BillingScreen.tsx:112,260`): digits ≥8 → barcode lookup, <8 → item code, letters → fuzzy search (top-8 dropdown, ↑/↓ + Enter; single hit adds immediately), `24*` prefix → qty multiplier. Item cache = `GET /items` loaded at mount (2k rows today; move to server-side `q=` when catalogs grow). On resolve, append line `{item, qty: pending ?? 1, costPaise: supplierLast ?? last ?? undefined, mrpPaise: item.mrpPaise}` from the last-cost dict (P0 prefetch: NEW `GET /purchases/last-costs?supplierId=` wiring the **existing** `lastCosts()` repo fn) and move focus into the line's **Qty** cell. Cell flow: type digits (typing replaces prefill; bare Enter keeps it) → Enter → **Cost** (₹ with paise, prefilled last cost; bare Enter accepts) → Enter → back to scan box. **MRP is Tab-reachable but not an Enter stop** in the default column profile (`purchases.column_profile` setting row; a pharmacy profile later adds Batch/Expiry stops — P2). Tax per line resolves client-side from `item.taxClassCode` via `resolveTaxClass(taxClasses, code, invoiceDate)` (NEW trivial `GET /tax-classes` route wiring existing `listTaxClasses`). Line amount/tax math calls the shared `computePurchase` (§6) so the footer equals the server to the paisa. **Cost-changed highlight.** If entered cost differs from `supplierLast` beyond `purchases.cost_warn_pct` (setting row; default 0 = any change), render an amber chip in the row: `↑ ₹2.10 vs 04-Jun`. Never a dialog — the price decision batches to post time (P0-4). Margin column recomputes live: `landed = cost × (1 + rateBp/10000)`; `margin% = (salePrice − landed) / salePrice`. Below `purchases.margin_floor_pct` → amber cell, never blocks. **Keystrokes/line.** Scan(0) + qty digits + Enter + Enter = qty + 2 when cost unchanged; +cost digits when changed. Meets the ≤5 keys/line budget. **Edge cases.** - *Re-scan of an item already on the grid*: merge (+addQty), flash row, focus its Qty cell. - *Scan burst landing in a Qty/Cost cell* (purchaser scans without refocusing): cells also feed a page-level `WedgeDetector`; a burst that terminates inside a cell commits the cell with its prior value and resolves the code as a new line — the wedge suffix Enter must never write barcode digits into Qty. - *Unknown barcode*: inline panel under the scan box "Unknown code … [Enter] Quick-create · [Esc] skip" → 4-field micro-form (Name, Cost, MRP, GST class defaulting store slab) → existing `POST /items` with `status:'draft'` → line added; lands on the Items "incomplete" worklist. - *Loose/KG item* (`unit.decimals=3`): Qty accepts `12.500`. *Scale label (`2x` EAN)*: not meaningful for purchases — inline hint "scale label — enter by item code". - *Qty ≤ 0 / non-integer cost*: red cell, Enter refused; server re-validates (`repos-purchase.ts:42-47`). *Cost 0*: allowed (free goods) but amber. - ↑/↓ from an empty scan box selects grid rows; F2/Del act on the selection. **Touches.** `PurchaseEntry.tsx`; NEW routes `GET /purchases/last-costs`, `GET /tax-classes` in `api.ts`. ### P0-4 · Consolidated price/MRP update prompt (at post, never mid-entry) **Behavior.** During the post flow, lines where (a) cost changed vs supplierLast, or (b) entered MRP ≠ `item.mrpPaise`, populate one overlay grid: Item · cost old→new · current sale · **suggested sale** · checkbox (default ON). Suggested sale preserves the item's previous margin: `round₹(newLanded / (1 − oldMargin%))`, capped at MRP when MRP exists (Legal Metrology, issue GST-15). Space toggles a row, ↑/↓ moves, typing overrides the suggestion, Enter confirms all → checked rows set `newSalePricePaise`/`newMrpPaise` on the line inputs. The **server side already exists**: `commitPurchase` applies them with `ITEM_PRICE_UPDATE` audit rows (`repos-purchase.ts:89-97`). **Keystrokes.** Usually 1 (Enter accepts all). Esc returns to the grid without posting. **Edge cases.** Item whose sale price would drop below new landed cost → row pre-checked with red margin; suggested never exceeds MRP. **Touches.** `PurchaseEntry.tsx` only. ### P0-5 · Footer totals + "supplier's printed total" cross-check **Behavior.** Footer renders live Taxable / GST / Total from `computePurchase`. `Ctrl+Enter` opens the **totals-check overlay**: one numeric field "Bill total as printed on the paper" (autofocused). Enter compares against computed total: within `purchases.total_tolerance` (setting; default 99 paise) → proceed to P0-4 overlay → commit. Mismatch → red line `Printed 8,320.00 · Computed 8,308.44 · Δ +11.56` with ranked hints (largest line by value, line count) and two keys: `[F2] Add other charges ₹11.56` (fills `otherChargesPaise`, delta recheck passes) · `[Esc] back to lines`. Explicit `[F10] Post anyway` records the mismatch (`printed_total_paise` stored; `PURCHASE_TOTAL_MISMATCH` audit row). This is the manual sibling of the Purchase Inbox verdict math (09-UX §3.4). **Keystrokes.** Ctrl+Enter + printed digits + Enter (+1 Enter in P0-4) — the entire close ceremony is ~digits+3. **Touches.** `PurchaseEntry.tsx`; schema: `ALTER TABLE purchase ADD COLUMN printed_total_paise INTEGER; ADD COLUMN other_charges_paise INTEGER NOT NULL DEFAULT 0` in `db.ts` (dev DB is disposable — recreate or ship the ALTERs); `commitPurchase` total check becomes `taxable + tax + otherCharges === clientTotalPaise` and persists both columns — **NEW server change**. ### P0-6 · Post — single action, one server transaction **Behavior.** After the two overlays, client calls **NEW `POST /purchases`** with `{storeId, supplierId, invoiceNo, invoiceDate, businessDate: today, lines[], otherChargesPaise, printedTotalPaise?, clientTotalPaise}`. On 200: green inline notice "Posted · AA/912 · ₹8,308.44 · 42 units into stock", grid clears, supplier stays selected, focus → Invoice No (next bill from the same distributor stack is 2 fields away). On 409 duplicate: red notice, nothing cleared. On engine-mismatch 409: red notice "totals disagree — refresh tax data (E-7302)"; cart kept. **Server transaction** (single `db.transaction`, `repos-purchase.ts:68` — mostly exists): 1. Insert `purchase` header (+ NEW printed/other-charges columns) — exists 2. Insert `purchase_line` rows — exists 3. `stock_movement` rows, `reason='PURCHASE'`, `qty_delta=+qty` per line — exists (stock IN; derived stock, invariant I4) 4. Item `sale_price_paise`/`mrp_paise` updates + `ITEM_PRICE_UPDATE` audit per changed line — exists 5. `PURCHASE_COMMIT` audit row — exists 6. **NEW: outbox envelope row** — parity with `commitBill` (`repos.ts:174-177`); currently missing from `commitPurchase`, violating the D14 dormant-outbox discipline. Same shape, `doc_type='PURCHASE'`. 7. Label-queue note → P1-3. Supplier ledger credit → P2-2 (no ledger tables exist yet — do not fake it). Duplicate rejection: pre-check + `UNIQUE` — exists. Validation (qty > 0, integer cost, non-empty invoice) — exists. **Touches.** `api.ts` (route + `sess()` for tenant/user, mirroring `r.post('/bills')` at `api.ts:119`), `repos-purchase.ts` (items 6, printed-total), `db.ts`. ### P0-7 · Shared purchase math in the engine **Behavior.** Export `computePurchase(lines: {qty, unitCostPaise, taxRateBp}[]): {perLine: {taxablePaise, taxPaise, totalPaise}[], totals}` from `@sims/billing-engine` with exactly the rounding at `repos-purchase.ts:33-37` (`Math.round(qty*cost)`, `Math.round(taxable*bp/10000)`). `lineMath` refactors to call it; `PurchaseEntry.tsx` imports it for the footer. This is what makes the 409 agreement check honest rather than a paisa-drift trap. **Touches.** `packages/billing-engine/src/` (new file + index export, + golden test), `repos-purchase.ts`. --- ## 3. P1 — makes it pleasant at 200 lines/day - **P1-1 Right-rail item card.** On cell focus, show item photo-less card: stock on hand (existing `stockView`), current sale/MRP, live margin, **last 3 costs from this supplier with dates**. NEW `GET /purchases/cost-history?itemId=&supplierId=` → NEW repo fn (`SELECT unit_cost_paise, p.invoice_date FROM purchase_line pl JOIN purchase p … ORDER BY p.created_at_wall DESC LIMIT 3`). Touches: `repos-purchase.ts`, `api.ts`, `PurchaseEntry.tsx` aside. Zero keystrokes — pure glanceability. - **P1-2 Free-qty column + column profiles.** `ALTER TABLE purchase_line ADD COLUMN free_qty REAL NOT NULL DEFAULT 0`; stock movement becomes `qty + free_qty`; landed-cost math divides by total units. Enter-stop set driven by `purchases.column_profile` setting row (kirana: Qty+Cost; adds Free/Disc/MRP per profile). - **P1-3 Label queue on post.** NEW table `label_job(id, tenant_id, store_id, item_id, qty, mrp_paise, source_doc_id, status, created_at_wall)`. After a successful post, if any line had new MRP or a draft item: inline prompt "Print labels for 5 new/changed items? [Enter] queue · [Esc] skip", quantities defaulting to received qty → NEW `POST /label-jobs` (bulk). `/catalog/labels` page reads them (replaces registry sample rows). Commit of jobs happens *outside* the purchase transaction (prompt is post-post); each job row is trivially idempotent to re-create. - **P1-4 Disc% column.** `ALTER TABLE purchase_line ADD COLUMN disc_bp INTEGER NOT NULL DEFAULT 0`; `computePurchase` extends: `taxable = round(qty*cost*(1−disc_bp/10000))`. Enter stop only in profiles that include it. - **P1-5 Crash-proof draft.** Journal the in-progress entry to `localStorage['pe.draft']` on every line commit; on mount with a draft present, inline banner "Resume AA/912 (14 lines)? [Enter] resume · [Esc] discard". A 180-line invoice must survive a browser crash. No server. - **P1-6 Local/Interstate tax split.** Derive `supply_type` from supplier `state_code` vs store; store `cgst/sgst/igst` split on `purchase` (3 columns) — prerequisite for GSTR-2B recon and ITC reporting. Engine change in `computePurchase` mirrors `computeBill`'s split. - **P1-7 Purchase List goes live.** Wire existing `listPurchases` → NEW `GET /purchases`; replace the `/purchases/list` registry entry with a live page (pattern-copy `BillsPage` in `live.tsx`); row click → read-only detail drawer rendering `payload` lines + audit chip. "New purchase" button → navigate `/purchases/entry`. ## 4. P2 — deliberate deferrals - **P2-1 Batch/expiry cells** (pharmacy profile) — needs batch tables; POS batch-picking doesn't exist yet either. - **P2-2 Supplier ledger credit** in the post transaction + Suppliers ledger tab — blocked on accounting tables. - **P2-3 Internal PI doc number** via `doc_series` (`doc_type='PURCHASE'`) so the list shows `PI-000212` instead of supplier invoice no; reuse the series block in `commitBill` (`repos.ts:139-151`). - **P2-4 RCM flag per line** (freight defaults on) — GST-12 self-invoice machinery. - **P2-5 Pack/unit conversion** (`CASE(24) → PCS`) from a pack master; until then purchasers enter stocking units. - **P2-6 Tax-inclusive-cost toggle** per supplier (some local bills print inclusive costs). - **P2-7 Telemetry**: keys/line and seconds/line emitted per posted purchase (09-UX §3.8 budget: ≤5 keys, ≤8 s) — land with the shared telemetry sink. --- ## 5. NEW server endpoints (all in `apps/store-server/src/api.ts`, behind `requireAuth`) | Route | Backing fn | Status | |---|---|---| | `POST /purchases` | `commitPurchase` | repo EXISTS — wire route (P0) | | `GET /purchases` | `listPurchases` | repo EXISTS — wire route (P0/P1-7) | | `GET /purchases/last-costs?supplierId=` | `lastCosts` | repo EXISTS — wire route (P0) | | `GET /purchases/check?supplierId=&invoiceNo=` | `findPurchase` | NEW repo fn (P0) | | `GET /tax-classes` | `listTaxClasses` | repo EXISTS — wire route (P0) | | `POST /parties` accepts `kind/gstin/stateCode` | `createParty` | generalize existing (P0) | | `GET /purchases/cost-history?itemId=&supplierId=` | NEW repo fn | P1 | | `POST /label-jobs` + `GET /label-jobs` | NEW repo fns | P1 | **Table shapes.** `purchase` and `purchase_line` already exist in `db.ts:95-109`. Deltas: ```sql -- P0 ALTER TABLE purchase ADD COLUMN printed_total_paise INTEGER; -- as typed from the paper, NULL if skipped ALTER TABLE purchase ADD COLUMN other_charges_paise INTEGER NOT NULL DEFAULT 0; -- P1 ALTER TABLE purchase_line ADD COLUMN free_qty REAL NOT NULL DEFAULT 0; ALTER TABLE purchase_line ADD COLUMN disc_bp INTEGER NOT NULL DEFAULT 0; CREATE TABLE label_job ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, item_id TEXT NOT NULL, qty INTEGER NOT NULL, mrp_paise INTEGER, source_doc_id TEXT, status TEXT NOT NULL DEFAULT 'queued', created_at_wall TEXT NOT NULL ); ``` Last-cost lookup needs no new table — it derives from `purchase_line` join `purchase` (idx `idx_pline_item` exists). Note for scale: `lastCosts` currently full-scans all purchase lines per call (`repos-purchase.ts:110-114`); acceptable now, add `ORDER BY … LIMIT` per item via a window query when purchases exceed ~50k lines. --- ## 6. Keystroke count — realistic 3-line invoice (Amul Agency, AA/912, dated today) Line 1: 24 butter, cost unchanged. Line 2: 12 cheese, cost changed to 312.50. Line 3: 6 milk powder, cost unchanged, new MRP 310. Scans = 0 keys by convention. | Step | Keys | Count | |---|---|---| | Supplier `amu` + Enter | `a m u ↵` | 4 | | Invoice no `AA/912` + Enter | 6 + ↵ | 7 | | Date (today) Enter | ↵ | 1 | | L1: scan · `24` ↵ · ↵ (cost prefill ok) | | 4 | | L2: scan · `12` ↵ · `312.5` ↵ | | 9 | | L3: scan · `6` ↵ · ↵ · Tab `310` ↵ | | 8 | | Post: Ctrl+Enter · printed `8964.5` ↵ | | 8 | | Price-update overlay: Enter (accept both suggestions) | ↵ | 1 | | **Total** | **3 scans** | **42 keys** | ~14 keys of once-per-invoice overhead; steady-state 4–9 keys/line — inside the published "~5 keys/line, 12-line bill ≈ 85 keystrokes" budget from 09-UX §3.2, and every one of those keys is Enter, digits, or Tab on the numpad side of the keyboard.