You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/13-SPEC-BILLING-POLISH.md

222 lines
27 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# SiMS Next — POS Billing Screen Polish Spec (v1)
Scope: `apps/pos/src/BillingScreen.tsx` and the packages it consumes. Everything below is implementable against the current architecture (React + `@sims/*` + store-server REST). Items needing server work are marked **NEW SERVER**. Keystroke convention per 09-UX §1: scan = 0 keys, every keypress = 1.
Constants introduced below (`PRICE_BAND_PCT=10`, `QTY_WARN=50`, `DISC_WARN_PCT=10`, `TENDER_MAX_DIGITS=6`) are module consts today, DB `setting` rows later — name them once in a `POS_LIMITS` object at the top of `BillingScreen.tsx` so the migration to config is one import swap.
---
## P0 — ship first
### P0-1 · Live search suggestions dropdown (replaces blind first-match add)
**Why first:** `search()` in `BillingScreen.tsx` (lines 102110) silently adds `hits[0]` — "tomato" can bill tomato ketchup. Every failed scan (515% of items, POS-4) funnels here; this is both the biggest speed and the biggest correctness gap.
**Behavior**
- When the scan box content parses as `kind:'search'` (letters present, per `parseEntry`) **and** length ≥ 2, render a suggestion list directly under the scan box (absolutely positioned, part of `pos-left`, never a `Modal`). Recompute synchronously on every keystroke — **no debounce** (09-UX §5.1: debounce = felt lag). In-memory filter over the items cache (≤ 2000 rows today) is comfortably < 5 ms; budget assert < 150 ms.
- Max 8 rows. Each row: **name** (+ `nameSecondary` small, grey), code, **rate** (`formatINR(salePricePaise)`, `/kg` when `unit.code==='KG'`), MRP struck-through when rate < MRP, and a stock chip (`ok`/`low`/`out` see P1-5; render `—` until stock is loaded). Row 1 pre-highlighted.
- **Match tiers** (short-circuit, per 09-UX §5.1): 1 whole-string prefix on name 2 token prefix on any word of name/`nameSecondary` 3 phonetic-key match 4 fuzzy (edit distance 2, only when tiers 13 yield < 8). Tie-break: shorter name first (velocity ranking is P1-5).
- **Phonetic-friendly:** create the package the blueprint names `packages/search-core` (`@sims/search-core`), pure TS, no deps:
- `normalize(s)`: lowercase, strip punctuation, collapse spaces.
- `phoneticKey(s)`: the Indic folding table from §5.1 (`aaa, eei, oou, wv, phf, bhb, chhch, shs, tht, dhd, zj`, drop doubled letters) so `chawal/chaval`, `dhaniya/dania`, `maggie/magi` collide.
- `buildIndex(items: Item[]): SearchIndex` (precomputes tokens + phonetic keys once; memoize in `BillingScreen` with `useMemo` on `items`).
- `querySearch(index, text): Item[]` returning tier-ranked results.
- Mirror `packages/scanning`'s `package.json`/`tsconfig`; unit-test the folding table (this same package later backs the back-office Ctrl+K one engine, two profiles).
**Exact keystrokes**
- Type letters list appears. `↓`/`↑` move highlight (wraps). `Enter` adds the highlighted item (so `tom` + Enter is unchanged in key count from today, but now visible-before-add). `Esc` (first press) closes the list and clears the input; second Esc is the normal no-op.
- `3*tom` dropdown filters on `tom`; Enter adds qty 3 (thread `parsed.qty` through `handleEnter`'s multiplier branch calls the new `openSuggestions(rest, qty)` instead of `search`).
- Digits-only input **never** opens the dropdown (barcode/code path untouched). Wedge-classified scans short-circuit before dropdown logic first line of `handleEnter` already does this.
**Focus rules**
- DOM focus **never leaves the scan box**. The dropdown is a rendered listbox (`role="listbox"`, `aria-activedescendant`), navigated by intercepting `ArrowUp/ArrowDown/Enter/Escape` in the input's own `onKeyDown` with `e.stopPropagation()` **before** the window-level `onKey` handler otherwise arrows would move the line-grid selection (lines 226227). Rule: **dropdown open ⇒ arrows own the dropdown; dropdown closed ⇒ arrows own the grid.**
**Edge cases**
- 0 hits inline row: `No item matches "xyz" (E-1102) · [Enter] Quick-add · [Esc] Clear` Enter jumps to P0-3's mini-form with Name pre-filled with the typed text.
- Exactly 1 hit still show the list (predictability beats cleverness); Enter adds it.
- A scan arriving mid-typing corrupts the buffer today accepted for P0, fixed properly in P2-1.
- Item list updated by quick-add (P0-3) index memo recomputes (depends on merged items array).
**Touches:** NEW `packages/search-core/src/index.ts` (+ tests); `BillingScreen.tsx` delete `search()`, add `suggest` state `{q, qty?, hits, highlight} | undefined`, extend input `onKeyDown`, render block after the scan box; `pos.css` dropdown styles. **No server change.**
---
### P0-2 · F2 qty edit + F3 price override — inline cell editing with supervisor-gate hook
**Behavior**
- `F2` opens an edit input **inside the Qty cell** of the selected line; `F3` inside the Rate cell. Never a modal (POS-2). The cell input is pre-filled with the current value, text selected.
- Add state `editing?: { line: number; field: 'qty' | 'price'; value: string }`. Render: in the lines table (lines 282290), when `i === editing.line`, replace the cell content with `<input className="wf num" autoFocus …>`.
- **F2 qty:** digits (+ decimal for `unit.decimals > 0`). Commit updates `lines[i].qty`. Qty `0` converts to a Del-with-confirm (P0-5). Non-integer qty on a `decimals===0` unit inline err, stay in edit.
- **F3 price:** entered in rupees (`"270"` or `"270.50"`), stored as `unitPricePaise = Math.round(v*100)`; semantics identical to `salePricePaise` (tax-inclusive per `priceIncludesTax`). Because the server **recomputes** in `commitBill` from the submitted `LineInput`s, an edited `unitPricePaise` flows through and totals agree commit works today with zero server change.
- **Gates (hook now, policy rows later):**
- `newPrice > mrpPaise` **blocked** (GST-15) pending supervisor approval.
- deviation from `item.salePricePaise` beyond ±`PRICE_BAND_PCT` supervisor approval.
- Supervisor gate v1: inline panel replacing the notice area supervisor picker (bootstrap `users` filtered to `role !== 'cashier'`, `↑/↓`+Enter) + 46 digit PIN field. Verify via `POST /api/auth/pin` through a new client fn `verifySupervisorPin(userId, pin)` in `apps/pos/src/api.ts` that **does not assign the module-level `token`** (do not reuse `pinLogin` it would hijack the cashier's session). 3 failures panel closes, edit cancelled. Implement as `async function supervisorGate(desc: string): Promise<{ approvedBy: string } | undefined>` so F4 discounts (P1-3) and future Del/void reuse it.
- **NEW SERVER (recommended, small):** `POST /api/auth/verify-pin` same `attempt()` logic in `api.ts` but no session creation; and audit action `PIN_VERIFY` instead of a login row. Works without it today via `/auth/pin` (discard the token), but that pollutes `sessions` and audit semantics.
- **Override audit (NEW SERVER, required for compliance):** extend `POST /api/bills` body with `overrides?: { itemId: string; kind: 'price'|'qty'|'discount'; before: number; after: number; approvedByUserId?: string }[]`; in `commitBill` (`repos.ts`), inside the existing transaction, `writeAudit(db, …, 'PRICE_OVERRIDE'|'QTY_OVERRIDE', 'bill', id, {before}, {after, approvedBy})` per entry. Client accumulates the array in state and clears it on commit/cart-clear. ~15 lines server-side.
**Exact keystrokes:** `F2` `2` `Enter` = 3 (matches scoreboard). `F3` `2` `6` `5` `Enter` = 5, + PIN out of band. `Esc` in the cell cancels the edit, restores the value.
**Focus rules:** cell input takes focus on open; **on commit or Esc, `inputRef.current?.focus()`** the sacred line always gets focus back. While `editing` is set, the window `onKey` handler must early-return for everything except nothing (guard at top: `if (editingRef.current) return`) so F12 etc. cannot fire mid-edit. Same guard for the supervisor panel.
**Edge cases:** F2/F3 with empty cart quiet no-op. F2 on a scale-weight line allowed, but amber notice "weighed line reweigh is safer". Selected-line index after edits stays; `selected` already tracked. Qty edit beyond `QTY_WARN` amber inline confirm (one extra Enter, POS-7 sanity guard, never a modal).
**Touches:** `BillingScreen.tsx` (onKey cases `F2`/`F3`, table cell render, `supervisorGate`), `apps/pos/src/api.ts` (`verifySupervisorPin`, `overrides` in `postBill` body type), `apps/store-server/src/api.ts` + `repos.ts` (**NEW**: overrides audit; optional verify-pin route).
---
### P0-3 · Unknown-barcode quick-add (inline mini-form → draft item → immediately billable)
**Why:** today `resolveCode()` (line 99) dead-ends with "add it in Back Office" the cashier either abandons the sale or keys a substitute item. Blueprint §1.4: 3 keystrokes + typing.
**Behavior**
- On lookup miss in `resolveCode` (and 0-hit Enter from P0-1): render the inline panel under the scan box (replaces the notice slot): `Unknown code 8901…4357 (E-1102) · [Enter] Quick-add · [Esc] Skip`.
- `Enter` expands it into a 3-field micro-form (still inline, not a `Modal`): **Name** (autofocus; pre-filled with typed text when arriving from search), **Price ₹** (numeric), **GST** (`<select>` of `taxClasses` from props, default = the modal class — compute the mode of `items.map(i => i.taxClassCode)` once via `useMemo`; that is "the store slab" until settings exist).
- Save → `POST /api/items` (endpoint exists; `createItem` in `repos.ts` already accepts `status`): `{ code, name, hsn: '', taxClassCode, unitCode: 'PCS', unitDecimals: 0, salePricePaise: Math.round(price*100), barcode: scannedCode, status: 'draft' }`. Code generation client-side: use the barcode itself as `code` if no `items.find(i => i.code === code)` collision, else `QA-` + `Date.now().toString(36)`. **No server change**`hsn:''` is the deliberate GST-13 `INCOMPLETE` marker; back office `ItemsPage` already renders `draft` status as a warn badge (the completion worklist is back-office scope).
- On success: append to a new `extraItems` local state (merged with `props.items` via `useMemo` — this merged array feeds `resolveCode`, the search index, and quick-keys), call `addItem(newItem, pendingQty)`, notice `ok`: "Draft item added — details to complete in Back Office". If the pending scan carried a `3*` multiplier it applies.
- Add `createItem(...)` to `apps/pos/src/api.ts` (mirror the back-office client fn).
**Exact keystrokes:** `Enter` (open) → type name → `Tab` → type price → `Enter` (save; GST select only touched when non-default: `Tab` `↑/↓`). = 3 keys + typing, matching the scoreboard. `Esc` at any point = skip, 1 key.
**Focus rules:** form fields hold focus while open; `Esc` from any field closes and refocuses the scan box; save success refocuses the scan box. Window `onKey` suspended while the form is open (same `editingRef`-style guard).
**Edge cases**
- **Scans while the form is open must not corrupt the Name field** (§1.4: "further scans queue behind it"): keep a window-level `keydown` capture listener active during the form that runs a second `WedgeDetector`; a detected burst is swallowed (`preventDefault`) into a `queuedScans: string[]` with a chip on the panel ("1 scan waiting"); on form close (save or skip), replay each through `resolveCode`. If the same unknown code is scanned again while its own form is open, ignore it.
- Price empty/zero → block save with inline err (a ₹0 draft item is a margin leak).
- Server failure/offline → form stays populated, inline err with the E-code from `call()`; Esc still exits (sale continues without the line).
- Weighing-scale PLU miss (`Scale PLU … not in catalog`) does **not** offer quick-add (a draft PCS item can't represent a scale PLU correctly) — keep the plain error.
**Touches:** `BillingScreen.tsx` (`resolveCode` miss path, `quickAdd` state + panel render, `extraItems`), `apps/pos/src/api.ts` (add `createItem`). **No server change.**
---
### P0-4 · Cash with change — tendered amount before F12
**Why:** blueprint §1.4's 5-keystroke "cash with change" path doesn't exist; cashiers do mental arithmetic on every non-exact cash bill.
**Behavior**
- In the `F12`/`NumpadAdd` handler (and the Cash button): if the scan box currently holds `/^\d{1,6}$/` (≤ `TENDER_MAX_DIGITS` — a barcode is ≥ 8 digits and can never be read as tender) **and** `value*100 >= totals.payablePaise`, treat it as tendered cash: clear the input, run `commit('CASH')`, and on success flash **CHANGE ₹X** for 4 s — rendered large in the totals panel area (state `changeFlash?: number` + `setTimeout` clear; it is a render, not a toast, and steals nothing).
- If the input is non-empty and does **not** qualify (short digits < payable, or letters): do not guess inline warn "Finish the line first (Enter) or clear (Esc)", no commit. Never silently discard cashier input on the close key.
- Record it: include `tenderedPaise`/`changePaise` in the payment leg (`payments: [{ mode:'CASH', amountPaise: payable, tenderedPaise, changePaise }]`). Server `commitBill` validates `sum(amountPaise) === payable` unchanged; extra fields ride into the stored `payload` JSON automatically. **No server change** (reference field already exists on the type; add the two optional numbers to the client body type only).
**Exact keystrokes:** `2` `0` `0` `0` `F12` = 5. Exact cash stays `F12` = 1.
**Focus rules:** none change scan box focused throughout; change flash is passive.
**Edge cases:** tendered while `pendingQty` set the digits are in the input, not in `pendingQty`; `pendingQty` also cleared on commit. Double-F12 while `busy` ignored silently (guard exists, line 135 change the `say('warn')` to a no-op when `busy` to avoid noise). Change flash also mirrors to the customer display when that surface lands (out of scope now).
**Touches:** `BillingScreen.tsx` (`onKey` F12/NumpadAdd branch, `commit()` signature gains optional `tenderedPaise`, totals panel render). **No server change.**
---
### P0-5 · Keyboard & overlay discipline batch (small fixes, §1 gaps)
All in `BillingScreen.tsx`'s `onKey` effect (lines 200233) and the overlay components. One PR.
1. **Overlay key filtering.** Today F7/F12/etc. still fire while `pay`/`history`/`dayend` overlays are open (only `settings`/`customer` are excluded, line 209). Rule: `overlay !== 'none'` only Esc plus that overlay's own keys are handled; everything else early-returns.
2. **Pay overlay hotkeys** 1.4): while `overlay==='pay'`, keys `1`=CASH `2`=UPI `3`=CARD `4`=KHATA call `commit(mode)`. Add a `useEffect` keydown inside the pay `Modal` render or extend `onKey` with an `overlay==='pay'` branch. UPI/card = 3 keystrokes as budgeted (F11, digit, [confirm comes with payments integration]).
3. **Quick-key bindings**: `Ctrl+1…Ctrl+4` (through `Ctrl+0` as the row grows) `resolveCode(QUICK_KEYS[n])`, `e.preventDefault()` (browser tab-switch). Tiles already render the `kbd` hint the binding is the missing half.
4. **Esc clears `pendingQty`** (currently survives Esc a stale `3*` silently multiplies the next unrelated scan; this is a live defect). Esc order: close suggestion list clear input/notice clear `pendingQty` close overlay.
5. **Del inline confirm** 1.3: "inline confirm"): first `Del` arms an amber strip on the selected line `Remove #3 Aashirvaad Atta 5kg? [Enter] Remove · [Esc] Keep`; Enter removes (2 keys). Removed via a `confirmDel: number | undefined` state; any other key disarms. Never a modal.
6. **F-key `preventDefault`** on all bound function keys (F6 already misses it in some branches audit each case).
7. **History overlay keyboard**: `↑/↓` selects a row in the F10 table, groundwork for P1-1's `R`.
**Focus rules:** unchanged the `onBlur` refocus sentinel (line 264) stays; extend its exclusion list to every state that legitimately holds focus: `overlay !== 'none' || editing || quickAdd || supervisorPanel`.
**Touches:** `BillingScreen.tsx` only. **No server change.**
---
## P1 — next
### P1-1 · Repeat last bill + repeat from history
**Behavior**
- On commit success, snapshot `lastBillLines = lines` (the raw `LineInput[]`, pre-clear) alongside the existing `dayLog` entry.
- **`Ctrl+B`** ("bill again", `preventDefault`): if the cart is empty, re-add `lastBillLines` **re-resolved against the current catalog** for each line, find the item by `itemId` in the merged items array and call `addItem(item, line.qty)` so current price/tax apply (never resurrect old prices; the old bill is immutable, the new cart is fresh). Missing/inactive items are skipped with one notice: "Repeated last bill 2 items no longer available". Non-empty cart warn "Hold or close the current bill first".
- **From history:** in the F10 overlay, `R` on the selected row repeats that bill. `GET /api/bills` already returns the `payload` column (`listBills` selects `b.*`), and `payload.lines` are engine `Line`s which superset `LineInput` map `itemId`+`qty` through the same re-resolve path. **No server change.**
- Regular-customer ergonomics for free: attach customer (F6), `Ctrl+B`, F12 the daily-milk-and-bread bill is 3 keys + phone.
**Keystrokes:** `Ctrl+B` = 1. History repeat: `F10` `↓…` `R` = 3+.
**Focus:** scan box keeps focus; F10 path closes the overlay on `R` and refocuses.
**Edge cases:** last bill was a held-then-resumed cart snapshot is whatever committed; scale-weight lines repeat at the same weight (amber hint "check weighed items"); repeat when `lastBillLines` empty (fresh login) quiet warn.
**Touches:** `BillingScreen.tsx` (`commit` success path, `onKey` Ctrl+B, history overlay `R`).
### P1-2 · Multiplier ergonomics
**Behavior**
- **Instant `*`:** in the scan box `onKeyDown`, when the pressed key is `*` and the current input is `/^\d{1,4}(\.\d{1,3})?$/`, immediately set `pendingQty`, clear the input, `preventDefault`. The scanner-in-left-hand flow becomes `3` `*` scan = 2 keystrokes with zero Enter (matches the scoreboard) and the chip is visible **before** the scan.
- **Chip, not Notice:** move the pending-qty indicator from the `Notice` row (line 268) to a chip rendered inside the scan-box row's right edge (`3 ×` amber) closer to the eye line, doesn't shift layout.
- **Sanity guard (POS-7):** `pendingQty > QTY_WARN` (50) the chip renders amber with "press Enter to confirm ×120"; the next add requires that one confirming Enter. Fat-fingered `10*`→`100*` stops costing a whole void.
- **Decimal × unit check:** decimal `pendingQty` applied to a `decimals===0` item err notice "Whole quantities only for PCS", multiplier kept so the cashier can rescan the right item.
- Esc-clears already added in P0-5.
**Touches:** `BillingScreen.tsx` (input `onKeyDown`, `addItem` validation, chip render). `parseEntry`'s `3*code` inline form keeps working unchanged.
### P1-3 · F4 line discount (engine already supports it)
**Behavior:** `F4` opens the same inline cell editor (P0-2 machinery) on a new Disc column of the selected line. Grammar per §1.3: raw `5` = ₹5 off (`discount: {kind:'amount', paise: 500}`), `5%` = percent (`{kind:'percentBp', value: 500}`). `LineInput.discount` exists in `@sims/billing-engine` (`compute.ts` line 14) and the server recomputes commits work today. `0` or empty clears the discount. Gate: discount > `DISC_WARN_PCT` of line value → `supervisorGate()` from P0-2; audit rides the same **NEW SERVER** `overrides` array (`kind:'discount'`). F5 bill-level discount is **deferred** until the engine grows a bill-level discount input — do not fake it by spreading across lines.
**Keystrokes:** `F4` `5` `Enter` = 3; `F4` `5` `%` `Enter` = 4 (scoreboard: 45). Esc cancels.
**Focus/edge:** identical to P0-2. Show `Disc` column only when any line has one (keeps the grid clean for the 95% case).
**Touches:** `BillingScreen.tsx`; server `overrides` from P0-2.
### P1-4 · Audio + notice refinements (§5.6: sound is the primary feedback channel)
- NEW `apps/pos/src/audio.ts`: WebAudio oscillator beeps, zero assets — `beepOk()` (880 Hz, 60 ms), `beepErr()` (220 Hz, 2 × 120 ms), `beepWarn()` (440 Hz, 100 ms). Called from `addItem` (ok — this is the app-side confirmation beep distinct from the scanner's own, POS-1), `say('err'…)` (err), sanity guards (warn). Counter-scope mute flag in `SettingsModal` later; default on.
- Notice lifecycle in `say()`: `ok` auto-clears after 4 s (timer, cancelled by the next notice); `err`/`warn` persist until Esc or the next successful action. Today an old error lingers until something overwrites it, and successes never leave.
- Every actionable notice carries its key hints as text chips (`[Enter] Quick-add · [Esc] Skip`) — the P0-3 panel sets the pattern; apply to E-1102/E-1103 and commit-failure messages.
- Commit failure message (line 167) already says "cart kept" — add `beepErr` and keep the cart-untouched guarantee prominent.
**Touches:** new `audio.ts`; `BillingScreen.tsx` `say()`/`addItem`. **No server change.**
### P1-5 · Stock (and later velocity) enrichment for the dropdown
- After login, fetch `GET /api/stock` (exists — `stockView`) into a `Map<code, onHand>`; refresh opportunistically (after each commit, decrement billed quantities locally; hard refresh on F10 open). Feeds the P0-1 stock chip and an amber, non-blocking "out of stock" line hint on add (SY-7: warn, never block; the shelf is the arbiter).
- Add `fetchStock()` to `apps/pos/src/api.ts`.
- **NEW SERVER (small, flagged):** 90-day sales velocity for ranking — add `velocity90` to `GET /api/items` (one `LEFT JOIN` aggregate over `stock_movement WHERE reason='SALE'` in `listItems`, `repos.ts`). Until then the P0-1 tie-break (shorter name) stands. When catalogs exceed the current `LIMIT 2000` in `listItems`, the same PR should raise the cache limit or add delta refresh — flagging now so 100k-SKU pilots don't hit a silent truncation.
---
## P2 — when P0/P1 are telemetry-green
### P2-1 · Wedge burst splitting + double-read discard (POS-3)
Upgrade `packages/scanning/src/wedge.ts`: record per-char timestamps; `terminate()` gains a third result `{ type:'mixed', typed: string, code: string }` when a trailing ≥ 6-char burst with gaps ≤ 35 ms follows slower typed chars. `handleEnter` then resolves the code **and restores the typed fragment to the input** (with the suggestion dropdown reopening). Also: identical code within 150 ms of the last resolved scan with no intervening key = hardware double-read → discard + `beepWarn`. Pure-TS change with injected timestamps — extend the existing tests. Touches `wedge.ts`, `BillingScreen.handleEnter`.
### P2-2 · Held-bill tray + persistence
`F8` with > 1 held bill opens an inline tray (not a modal — render in the lines area): token, line count, value, age; `↑/↓`+Enter resumes, `Esc` closes; single held bill keeps today's instant resume (2 keys, scoreboard-compliant). Persist `held` + `tokenSeq` to `localStorage` keyed by counter so held carts survive a refresh (blueprint requires journaling — true server journaling is **NEW SERVER** `POST /api/holds`, deferred; localStorage closes 90% of the power-cut gap on the web build today). Touches `BillingScreen.tsx` (`hold`/`resume`, tray render, `useEffect` persistence).
### P2-3 · Ctrl+P reprint last (§1.3, 1 keystroke)
Snapshot `{ computedLines, totals, docNo, mode }` at commit success; `Ctrl+P` re-renders via `renderReceipt` with a new `duplicate: true` option that prints a `DUPLICATE` line (touches `packages/printing` — add the flag to the receipt options and template) and `kickDrawer: false`. No supervisor gate at copy 1; count copies per bill locally. Touches `BillingScreen.tsx`, `packages/printing`.
### P2-4 · F1 keymap cheat-sheet
Static overlay listing the live bindings with one-line descriptions; user-initiated (F1), Esc closes and refocuses. Content from a const array so the future DB keymap re-uses it. Trivial; high training value (§5.6 — the F-row is the UI for a non-reader).
### P2-5 · Customer modal keyboard completion
`CustomerModal` (line 411): results are click-only. Add `↑/↓`+Enter to attach; when phone ≥ 10 digits and no match, plain **Enter creates phone-only** with the default name (blueprint §1.4: "No match → Enter again creates the customer") — the name field becomes optional polish, keeping attach ≤ 12 keys. Touches `CustomerModal` only.
---
## What NOT to add (guard the one-screen rule)
- **No modals on the billing path.** Everything in this spec is inline or a keystroke-initiated overlay that Esc exits to the scan box. Adopt the POS-1 lint rule now: importing `Modal` into a new billing-path module fails CI (existing F10/F11/day-end overlays are grandfathered user-initiated overlays; do not add more).
- **No "print? Y/N", no printer picker, no mandatory phone/customer field, no close confirmation.** Each would tax 100% of bills (POS-8). Exact-cash close stays exactly 1 key.
- **No network in the scan→line path.** Suggestions, stock chips, quick-add defaults all read local state; only quick-add's save and the commit itself hit the server, and both fail soft with the cart intact.
- **No debounce, no async spinner on search.** Synchronous in-memory query or nothing.
- **No second screen/route, no tabs, no drawer panels.** Returns mode (F9), payment integrations, and the customer display are separate roadmap items — do not stub their UI here.
- **No stock-based sale blocking, no batch/expiry UI yet** — warn-never-block (SY-7) until batch tracking lands.
- **No toast/snackbar library, ever, on this screen.** `say()` + the notice slot + audio is the entire feedback vocabulary.
- **No settings/config UI growth mid-bill** — new constants go in `POS_LIMITS` awaiting DB settings, not new Settings panels.
## NEW SERVER work — consolidated
| Item | Endpoint / function | Size | Needed by |
|---|---|---|---|
| Override audit rows | `POST /api/bills` accepts `overrides[]`; `commitBill` writes audit in-txn (`repos.ts`) | ~15 lines | P0-2, P1-3 (required for compliance; UI works without it) |
| PIN verify without session | `POST /api/auth/verify-pin` (`api.ts`, reuse `attempt`) | ~10 lines | P0-2 (workaround exists via `/auth/pin`) |
| Sales velocity for ranking | `velocity90` in `GET /api/items` (`listItems`) | ~10 lines | P1-5 (optional) |
| Held-cart journal | `POST/GET /api/holds` | new table + routes | P2-2 (localStorage interim) |
Everything else in P0P2 ships against the API exactly as it exists today (`POST /api/items` with `status:'draft'`, `GET /api/stock`, `GET /api/bills` payload column, `POST /api/bills` server-side recompute absorbing qty/price/discount edits).
**Suggested build order:** P0-5 (discipline base) → P0-1 (search-core + dropdown) → P0-3 (quick-add, reuses the dropdown's zero-hit path) → P0-2 (inline edit + gate) → P0-4 (tender) → P1 in listed order. P0 total is roughly 23 days of focused work; nothing blocks on another team except the two small server rows above.