Skip to main content

Sales module

The POS sell screen. Walk-in → search → build the cart → Charge. Same sales-3zone chassis (Search + Ledger + Till) as Customers, Inventory, and Service; the Till is what the operator lives in and the ledger on the left is the day's tape. A single Charge popup decides the transaction type from what you enter (paid-in-full → sale; partial → layaway or open order; zero → estimate or "place the order, no payment"). Return / refund / exchange, void, hold, resume, and estimate-to-sale all run through the same Till chassis and post via a common audit narrator.

Tab labelSales
Tab accent#7c3aed (purple)
Strip position1st — the leftmost tab (position set by setTabOrder() in src/modules/index.ts, v0.7.409+)
Foldersrc/modules/sales/
Migrations027_sales.sql, 028_line_discount_recording.sql, 029_return_panel.sql, 036_layaway_backbone.sql, 037_special_order_backbone.sql, 039_variant_bulk_flag.sql, 040_audit_events.sql, 041_payment_reverse_perm.sql, 045_pos_tender_flags.sql, 046_special_line_on_line.sql
HeaderTitle + inline status line (Till open · cashier <name> · N sales today · $X) + color-keyed 2px accent rule
Till header defaultWalk-in (was "Customer" pre-v0.7.189) — the operator sees the intent, not the label
Steward#sales-search — focus stewardship first-mover
ChassisTill, in-Till detail panel, and take-payment modal all share tenderGridHtml + the Charge popup engine — one code path, three surfaces

What it owns​

src/modules/sales/
index.ts — registers the module with the Kernel
view.ts — markup + styles for the till + ledger + Charge popup + detail panel
client.ts — browser JS (Till engine, Charge popup, return / refund / exchange, hold/resume, ledger interactions)
api.ts — server-side routes (/api/m/sales/*)

The Till chassis​

Till, detail panel, take-payment modal — one chassis. The Till is a slot on the right of the Sales screen; opening a transaction from the ledger swaps the same slot into a detail panel (v0.7.130) rather than opening a modal. Return, edit-notes, void, print, and Take Payment run on that panel; the ledger stays visible on the left. The take-payment modal shipped as a 3×3 grid of nine tender tiles (v0.7.206) and reconciled to twelve tiles in a 4×3 grid at v0.7.380 (Tom's canonical set, in his order): M/C · Visa · Debit · Cash · Manual Card · Store Credit · Gift Card · Cheque · eTransfer · Promo · On Account · Split Payment. Two new tenders arrived with the grid — eTransfer and Promo — each wired through TENDERS / tenderLabel / methodLabel on the client and ALLOWED_TENDERS on the server. Split Payment is now its own 12th tile (the "Split across several methods" text link below the grid is gone); its tile opens the same split screen the link used to. SALE_TILE_KEYS on the client is the canonical order. A big DM Serif TOTAL DUE sits above; one shared tenderGridHtml powers every tender surface (new sale, deposit, refund-out, corrected-pay). store_credit and on_account still require a customer; the other ten don't.

Autohold (BIKE.L2-0220). The Till auto-holds a working cart on tab-away or 90-second idle so an operator can pivot to Customers or Inventory without losing state; returning to Sales resumes it. Cancel/void reverts the ledger to Today (v0.7.186/.187); tab-return refreshes the in-Till state (BIKE.L2-0216). (The verb was "Park" through v0.7.336 — v0.7.337 renamed Park → Hold / Held universally per owner directive. Substrate identifiers renamed at the same time via migration 047: parked_sales → held_sales, /parked → /held, data-parked → data-held, notes prefix auto_parked: → auto_held:. User-facing status text stays Pending where the till reads a paused sale's status.)

Till chrome. Idle Till shows a New Sale button (v0.7.276) — no more "click the empty pane to begin". Header reads "Current Sale — <Name>" (v0.7.278) or "Current Sale — (Empty)". Header no longer leads with "Till open" (v0.7.351) — reads "Cashier <Name> · N sales today · $X collected · N refunds". The Till-header reset now reads "Esc" on every tab (Sales / Customers / Inventory / Purchases / Service, v0.7.383, BIKE.L1-0020) — and the physical Esc key is bound to it via core/keys.ts, so the label and the key are the same action. The idle-Esc handler clicks the active tab's visible + enabled .till-clear-dot, scoped to the active module-pane, skipping the dot inside an open modal. Function unchanged from the earlier "C" glyph (v0.7.312 → the KVICK "K" experiment at v0.7.329/.331 → back to "C" at v0.7.346): on Sales it Holds, on the other tabs it clears the till while preserving the data. Layaway / Special Order buttons removed from every status (v0.7.250) — the Charge popup owns those decisions. Paused-sale status text unified to "Pending" (v0.7.294) even though the code / DB verb is Hold (v0.7.337 owner directive). Nav-tab underline retired (v0.7.284). Site-wide browser autofill disabled via core/noautofill.ts (v0.7.283) — a POS field never prefills from unrelated browser history. PIN gate calls itself out honestly — "Enter your PIN to continue" (v0.7.335) — and hardens the field with autocomplete="new-password" so Chrome stops replaying a saved admin code (v0.7.358, audit v2 #11).

Live multi-user poll (BIKE.L1-0017, v0.7.140). A second cashier finalising a sale on another station is reflected in the ledger + status pills without a manual refresh.

Phase-aware search (v0.7.145). The search box's behaviour shifts by Till phase — attaching a customer at first, adding lines once one's on, filtering the ledger once the Till is empty.

GC / Bulk / Misc pill — one pill, five kinds of line​

The GC/Bulk/Misc pill (v0.7.302, next to + Product in the till header). One dropdown that opens five kinds of non-catalog line:

Line kindWhat it doesSubstrate
Misc / open-price itemA one-off item with typed name + price + tax picker (TAX_CATS live, v0.7.300). Also fires from a no-match search (v0.7.298) — the operator types a name, no result, "+ Add as a misc item" appears
+ New inventory itemCross-mounts the Inventory module hidden (via kbEnsureModule), reparents its New-product modal to <body>, opens it. The new SKU is instantly searchable + addable (v0.7.299)
+ Gift CardBearer card at a scanned serial (dupe-serial reject). Line is tax-exempt by construction; the issued card number renders in the post-sale receipt prompt (v0.7.326)
+ Account CreditRetainer / prepaid credit for a picked recipient; posts one row into customer_account_ledger as account_credit. Also available on the customer card's Account modal (v0.7.309)
BulkReal bulk-SKU picker (uses product_variants.is_bulk = 1, migration 039)

The −/+ stepper is repurposed on Gift-Card + Account-Credit lines (v0.7.307): "add another" on +, confirm-remove on − — never a qty change (a $100 gift card isn't 1× $100, it's a specific card). Migration 046 (transaction_lines.account_credit_customer_id + serial_number) links the line to its beneficiary; the estimate → sale convert path (v0.7.308) uses reconstructStoredLine + issueSpecialLines so a card doesn't get re-taxed or re-issued.

Per-shop tender toggles — 11-tile grid (v0.7.436, migration 056; supersedes the mig 045 2-toggle). Owner-only Payment-types grid in Settings shows or hides each Take Payment tile individually — 9 new pos_tender_<key>_enabled columns plus the reused gift_cards_enabled / account_credit_enabled from mig 045. tenderEnabled() reads them at render; NULL/absent = on (existing shops unchanged). At least one tile must stay on. Split Payment is not part of the grid — it's a compositional mode across whatever tiles are enabled.

Pill upgraded to 80% tile-picker (v0.7.700-ish, 85f3cdc + 64886e8). The dropdown was replaced with the same wide 80%-viewport tile picker Rentals' Bikes pill uses — multi-select tiles with an Add to Sale button, and the grid fits the widest configuration (no 1-column stack when few tiles are visible). Same picker chassis as the Service menu picker (af18068 / d259915 — multi-select, Add Service, X, no search) and the Rentals Fleet picker — one tile-picker pattern across every till.

Rentals in the till — read-only Rental line​

A rental deposit or a rental check-out reaches the Sales till as ONE Rental line (bikes listed under it), read-only — the only actions are Cancel and Charge. Rationale: Rentals owns the booking, Sales owns the money — Sales must not let a cashier edit a rental's line count / prices, or the booking and the money drift.

  • Rental deposit — reads "Rental" (not "Layaway") on the till, on the receipt header (RENTAL), and on the customer card (sales+customers: a rental deposit reads "Rental", not "Layaway", sales: rental deposit receipt header reads "RENTAL", not "LAYAWAY").
  • Rental deposit % is of the PRE-TAX fee (b5681c7), not the tax-inclusive total; defaults to the shop % in Settings > Rental (1f22ec1).
  • Rental deposit is decoupled from the rentals migration (db068a5) — non-rental deposits keep working independently (audit finding).
  • Header clear-dot + Esc — a rental in the till shows the header clear-dot (8ac2478) and the Till's Esc clears just the view (0146469). A rental auto-saves as a pending sale the moment it hits the till (7aa2cec) — pressing Esc doesn't lose it.
  • Return → Charge settles the rental in the Sales till (34df2d2) — no separate rentals-side money path.
  • Cancel a rental = refund the deposit + cancel booking + free bikes, one action (f3d54ea).

Money-safety fixes — bug hunts​

  • P0 (v0.8.230-ish, 5c1ac3e) — the till was collecting PST on the GST-only rental fee, producing a drawer overage + customer overcharge. Fixed: rental fees are GST-only (rental_fee_gst_only rule) and the till now respects it end-to-end.
  • 7 confirmed money / stock / security bugs batch-fixed from an alpha-tester run (6c80961). The dispatch-ready prompt + full test catalog live at qa/ in the repo (563c7a7).
  • Beta-report + bug-hunt findings — a no-decision batch of small hardening fixes (b4558da).
  • Client-code hygiene — sensitive / unprofessional comments scrubbed from browser-shipped client.ts files across every module (dcffe4e).

Idempotency — retried sale can't post twice (mig 137)​

Ported from the Monolith (its mig 014, 13 May 2026 — f893530). The modular rebuild had ported the connection pill (the cosmetic half) but left the idempotency layer behind — so a flaky retry on a sale in this Hub had nothing stopping it posting twice: the cashier hits Charge, the network blips, the browser or the operator retries, and the till books two sales and takes the money twice.

  • idempotency_records — server table keying on (idempotency_key, endpoint). A duplicate key returns the same result as the first successful call; nothing books twice.
  • The client mints ONE key per intent — one sale, one refund, one deposit payment. Passed via the Idempotency-Key header through the core/api.ts chokepoint.
  • Extended to refund, exchange, deposits, no-receipt returns (906aa73) — every money-moving endpoint that could double-post.
  • Split-tender deposit payments each carry their own key (16fc766) — a two-leg deposit is two intents, and a retry on the second leg shouldn't collide with the first.

Void puts back what actually left (66c722d + 9e7b69e)​

Two adjacent fixes in the void path:

  • A void must put back what actually left the shop, not what was on the sale. A sale line for a bike that had already been swapped for a different serial (a common Service-to-Sales handoff) used to void by restoring the original SKU/serial — not the one that actually shipped. Now the void reads the movement history and reverses exactly what happened.
  • A void must undo what the sale did — all of it. Every side-effect of the sale (movement + audit + customer-ledger + loyalty + tax detail) unwinds on void. Before this, a subset was reversed and the rest lingered.

Refund tax sign preserved (mig 136)​

137 of 138 imported refunds carried POSITIVE tax detail — the AIM converter wrapped both the taxable base and the tax amount in ABS(), correct for deposits (which AIM books as a negative subtotal the header conversion flips positive) and wrong for everything genuinely negative. Mig 136 restores the minus sign; a refund's tax detail keeps its minus sign end-to-end (a61d0f8 wired the API-side guard alongside).

Omega read-only probes​

GET /price (778d5ba) + GET /gift-card-activity (2f8abb2) — read-only endpoints so the Omega auditor can probe the tax rule with its own carts and reconcile a card's balance without going through the client. Neither writes; both respect the same permission gates as the equivalent client reads.

The Charge popup — one popup, four outcomes​

One Charge popup drives every "commit the cart" decision (v0.7.209–.213). Footer is Cancel · Estimate · Charge; the transaction type auto-derives from what the operator enters:

Entered on ChargeOutcome
Total paid in full, all in stockSale — invoiced immediately
Total paid in full, any OOS lineSpecial Order — invoice status Awaiting order
Editable Total less than balanceLayaway (in-stock) or open Special Order (OOS) — the balance stays owing
Total = $0, in stockEstimate (no charge)
Total = $0, OOS"Place the order — no payment" (BIKE.L2-0212, v0.7.212)
Deposit already paid offCharge / Layaway hidden — nothing left to bill (v0.7.208)

The Total field is obviously editable (v0.7.214) — solid border, radius, subtle fill, and a hint — because "partial payment" is not a special mode, it's just typing a smaller number. Walk-in Layaway / Special Order / Estimate first prompt the attach-customer popup with Keep walk-in hidden (v0.7.192/.203); a pendingAction auto-fires once the customer's picked or created.

Cart line — 2×2 right grid​

Each cart line's right column is a 2×2 grid (v0.7.347/.348/.374/.375/.376). Row 1: + Discount on the left, unit price on the right. Row 2: − Remove on the left, line total on the right (right-aligned under the unit price). The per-line + Discount popup takes a % / $ + reason; the reason lands on transaction_discounts and prints on the receipt AND the estimate (v0.7.347). Per-line discounts stack with a whole-sale cart discount; both fold into the unit-price display so the customer sees one honest number.

Take Payment — cashier context banner​

Take-Payment tells the cashier what kind of transaction they're about to complete (v0.7.301). Before money moves the modal banner names "Special Order" or "Layaway" if any line is out-of-stock or on order (gate: lineNeedsOrder + lineOrderShort, filtered against product_type so services + rentals never trigger). Receipt flags on-order items so the customer's slip is unambiguous about what's leaving today vs coming later.

One Take-Payment popup for every path (v0.7.343). The title carries a type-pill — mint INVOICE / cyan LAYAWAY / peach SPECIAL ORDER (v0.7.342 dynamic suffix) — and the tender tiles use purpose-drawn PAYICON SVGs, not emoji. The standalone Add-Funds modal was purged; deposit installments now ring through the same Charge popup → /deposit-payment (v0.7.343). Split-tender offers brand-specific card legs (v0.7.353, audit-3 #11) — the card leg's option value is card_present:<brand> so the daily tender breakdown can attribute correctly. On-account confirm (v0.7.352, audit-3 #9) shows the customer's before → +sale → new balance on the modal so the cashier can't accidentally push someone past their credit limit.

Store-credit / on-account balance guard (v0.7.385, audit #1 — Sales half). A store-credit or on-account tender is refused when it would drop the customer's ledger balance below −credit_limit_cents. Max the customer can spend on store credit / on account = balance_cents + credit_limit_cents (the customer record's credit_limit_cents — default 0 means no debt allowed). At the default a $0 customer can't overdraft at all; store credit spent against a real positive balance still works. Server-enforced in all four account-ledger paths via a shared accountTenderError() helper — /create, /from-estimate, /deposit-to-sale, /payment-add. Client pre-warns on the tender-confirm screen: a store-credit availability block (credit-on-file + limit = available) renders alongside the tender, and Complete is disabled with a red warning when the tender exceeds available. /store-credit-balance returns credit_limit_cents alongside the balance so loadCustomerCredit reads both. Closes the silent-overdraft money bug where a $0 customer could run up a debt on the store-credit tender.

Cheque + eTransfer reference capture (v0.7.381, audit #3). The Cheque tile and the new eTransfer tile show an optional reference-number input on the tender-confirm screen — labelled "Cheque number" / "Reference number" respectively. Focused on open, Enter completes, persists to the payment row's reference_number. Optional so a cashier without the number to hand isn't blocked. Single-tender path only for now — split-leg reference capture is a follow-up.

Confirm-tender wording on split legs (ISSUE-008, v0.6.996). On the cash tender screen the button reads "Confirm tender" when the cash is one leg of a split payment (rather than "Complete sale", which would misread the click as "this is the final action").

Refund routing popup (ISSUE-010, v0.6.997). A temporary popup fires immediately after a refund lands, showing how much went where — one line per tender destination with the amount. Confirmable, timed dismiss. Answers "did the refund I just posted go to the right places?" at the moment of hand-off, so a mis-route is visible before the customer walks out.

Pending sale → estimate, 15-day validity (ISSUE-014, v0.7.001). A cart the cashier isn't ready to charge can be converted to an estimate in place — the customer walks out with a quote, valid 15 days, resumable back into the till when they return. Uses the existing estimate substrate; the "pending" → "estimate" transition preserves the cart shape.

Change due — small card, upper-right, shown with the receipt prompt (ISSUE-003/004, v0.6.992). Originally shipped as a 34-px DM Serif Display "hero" on the cash tender screen (v0.7.371). Repositioned to a small card in the upper-right shown alongside the post-sale receipt prompt — the change amount is what the operator hands over, so it belongs with the "do you want a receipt?" dialogue at the moment of hand-off, not on the pre-Charge cash-tender screen where it was answering a question the operator hadn't asked yet.

Short-cash guard — Confirm blocked when tendered < total (v0.7.388, audit SALES-04). Under-tendering used to book the full total and leave the drawer short (mis-tap $10 for $20 → "CHANGE DUE $0.00" + Confirm still enabled + full total posts as collected). Now cashScreen flips the change row to red "SHORT BY $X" when tendered < total, disables Confirm, and confirmSale() hard-returns to guard the Enter / programmatic path too. Hard-block by design: a real pay-part-now needs a customer holding the balance, which is what layaway is for (lower the Total Due through the Charge popup) — an accidental under-tender on a walk-in isn't a partial payment, it's a mistake.

Card brand + last-4 capture​

Every card payment carries brand + last-4 (v0.7.292/.293). A single payLabel() helper renders "Visa ····1234" on the detail panel, the deposit page, the receipt, and the audit ticker — brand replaces the generic "card" label; last-4 appends when present. A hand-keyed card capture screen (v0.7.293) takes card type + last-4 + auth number (all optional) and rides the card_brand / card_last_four / reference_number columns across /create, convert-to-sale, and /payment-add. Nothing on the shop side stores a PAN.

Refund tender routing (SALES-21)​

"Refund to" defaults to "Original tender(s)" (v0.7.396). A multi-tender sale refunds each tender its own proportional share, not the whole amount dumped on the largest tender — a $30 cash + $29.93 card sale refunds $30 to cash and $29.93 to card. Partial refunds allocate proportionally per tender; the last tender takes the rounding remainder so the total lands to the penny. Account tenders (on_account / store_credit / gift_card) refund to the customer's store credit rather than back to their originating instrument. A cancelled sale honours the same routing via deposit-cancel (v0.7.322, audit Tier-1 #6). The ledger row shows the tender name so the audit sentence reads "Marcus refunded $412 to Visa ····1234" rather than "$412 refunded".

Tender-mismatch guard — manager override on any non-original-tender refund (v0.7.396 → v0.7.397). On a receipt-based Return, refunding to any tender other than the original(s) requires manager override (discount.override_manager) + a reason stamped in the refund notes. Only "Original tender(s)" is override-free. Same rule applies to store credit, cash-back-on-a-card-sale, cheque-back-on-a-Visa-sale — the reasoning: the receipt tells us what came in, so any other route out needs sign-off. The Refund modal shows the override-reason field the moment the operator picks a non-original destination; posting without a reason returns HTTP 400. Closes both the "card-to-cash refunds silently" theft vector AND the "anything-to-store-credit as a workaround" variant. The no-receipt Return path keeps store credit as its no-override default because there's no original tender to match against — the accountability gate is different by design.

Note: v0.7.396 shipped the money-to-different-money guard and left store credit exempt as a low-risk default; v0.7.397 tightened it to include store credit for consistency once the receipt-based origin was known.

Historical (v0.7.291 → v0.7.396 evolution). The initial pick-destination-from-original-payment behaviour (defaultRefundMethod() — card → card, cash → cash, account-tender → store-credit) came in at v0.7.291 with a red/amber warning on mismatch but no hard-block. v0.7.396 kept the same picking logic, upgraded the warning to a manager-override gate on money-to-different-money, and split the default from one target tender to per-tender split-back so multi-tender sales refund honestly. Substrate: server POST /refund reworked its payment plan (origTenders → split or single + mismatch override).

Tender breakdown of collected money​

Header shows "money COLLECTED today" (v0.7.336/.345, audit-3 #1/#6). Not accrual, not gross — the real money that landed in a real till. today_collected_cents = net real-money tenders: cash · Visa · Mastercard · Debit · Manual Card · Cheque · eTransfer · plus deposits and funded promo (see below). Excluded from the collected number and shown as real=0 memo lines on the reconcile: store_credit · on_account · gift_card redemptions (those move ledger balances, not till dollars) · comp promo (a giveaway / write-off, no money moved). The header value is clickable → opens a tender-breakdown popup that groups by payments.card_brand and lists cash / Visa / Mastercard / Debit / etc. with totals. Refunds show a refund count in the header (v0.7.356, audit-3 #15) — matches the row count on screen.

eTransfer regression fix (v0.7.387, BIKE.AUDIT SALES-03). The v0.7.380 12-tile grid added eTransfer as a tender but never added it to the real-money set — an eTransfer-paid sale invoiced but its amount was omitted from "$X collected" and the /tenders reconcile, silently mis-reconciling the till. Now eTransfer joins cash / debit / card / cheque in both the /summary collected query and the /tenders realMethods set.

Promo tender — Comp vs Funded, classified at tender time (v0.7.389). Promo splits into two economically-different flavours:

FlavourExamplesCounts as collected?Reconcile line
Comp / write-offstaff comp, service recovery, damaged write-off, loyalty freebie❌ No money movedPromo (comp) — real=0 memo
Funded / reimbursedsupplier co-op, manufacturer rebate, marketing giveaway✅ Yes — a real dollar arrives from a third partyPromo (funded) — real=1

The Promo confirm screen carries a reason picker with a live "counts / doesn't count" hint; the picker defaults to the first Comp reason so an unclassified Promo never accidentally inflates the till. No schema change — the choice rides in the existing payment row: card_brand = 'funded' or 'comp' (already stored + grouped by /tenders), and the reason text lands in reference_number. /summary's collected-count only counts a promo when card_brand='funded'; /tenders labels the two lines "Promo (funded)" and "Promo (comp)" separately.

Header refreshes after every event (v0.7.364, alpha-v2 #4). loadSummary fires after a deposit installment, a void, and every mutation that moves the collected number — the header can't lag.

Gift-card model​

Issuing a gift card is not a sale (v0.7.344, audit-3 #2). ETA s.181.2 + BC PST Bulletin 311 + ASPE all treat a gift-card issuance as a deferred revenue liability, not revenue at the moment of sale. /summary drops the variant-less tax-exempt liability line from the sale count + the revenue number. The tender rings, the till reflects the cash, but the day's sales row on the header doesn't inflate.

Gift card is a split-payment tender (v0.7.332, audit Tier-2 #12). The Take-Payment split dropdown carries Gift card as an option; a /gift-card-lookup picker asks for the card number, verifies the balance, and applies the redemption as one leg of the split. The redemption placeholder reads "Scan or type the card number" (v0.7.354, audit-3 #13), not the stale text it started with.

Editable tax on active transactions​

POST /set-transaction-tax (v0.7.267, gated sales.sell). Click the GST or PST line on the active transaction's detail panel to re-price stored lines — server rebuilds transaction_taxes, updates total_tax + total, re-derives the deposit balance if one's owing. Complements the Adjust-tax modal on the live cart with the same 3-mode UI (Status Card / Manual override / Default).

Product-click → Inventory editor, in place​

Clicking a product line in Sales opens the same Inventory Product Card editor the Inventory tab uses (v0.7.260/.261) — not a copy, not a Sales-flavoured shadow. kbEnsureModule('inventory') mounts Inventory hidden if it hasn't been visited yet; Inventory reparents its New-product modal to <body> on mount and exposes window.kbOpenProductEditor(vid). Sales fetches the variant, opens the same editor as an overlay. The v0.7.261 fix ensures the first-click guard runs a proper ensure-mount step instead of falling back to a tab-switch that dropped the operator out of Sales.

Backorder → Bucket flow​

Zero-stock lines ring through (v0.7.190). No blocking popup — the line lands in the cart with a red backorder N annotation; on Charge the shortfall auto-drops into inventory_order_queue (the Purchases Bucket). The customer walks out with a Special Order confirmation; Purchases sees a new "Awaiting order · for [Customer]" row on the next Bucket refresh. See Backorder → Bucket flow for the whole cross-module chain.

Out-of-stock pill = Adjust actual stock (v0.7.196/.201). If the cashier believes there IS stock (miscounted, misplaced), the OOS pill is now an inline Adjust actual stock stepper. Incrementing commits live to Inventory via /count (debounced) with an "Inventory Adjusted" popup; the cart line's red goes away when on-hand rises above what's needed.

Ledger — Status filter, All-Status view, R flag, hover cards​

Priciest line + "+N" more (v0.7.204/.205). Multi-item rows in the ledger show the highest-priced line title with a +N more badge; N counts physical items (SUM qty), not distinct products.

Status filter (v0.7.215/.216). The Status column header is the filter trigger: opens a Status-filter dropdown; Cancelled is hidden by default. Selecting a status enters an All <Status> view (v0.7.218) — spans all dates, /today gains status + before_at/before_id keyset pagination for infinite scroll. Date-range picker works inside a status view (v0.7.277) — filter by status and by date range at once. Default chip reads "Today's Sales", not "All statuses" (v0.7.281). Clearing the Till from a status view keeps the All <Status> heading (v0.7.223). Sticky column header — the thead moves out of the scroll region so it doesn't scroll away (v0.7.220–.222).

Special-order lifecycle broken out (v0.7.268/.270/.273/.274). The Status dropdown breaks the special-order umbrella into its lifecycle stages — Awaiting order → Ordered → Order Arrived → Ready for pickup. Server adds an Order Arrived case; the pill gets its own gold colour (#fde68a, v0.7.274) to distinguish it from Ordered. The umbrella "Special order" filter was restored (v0.7.270) then removed again per Tom (v0.7.273) — the lifecycle stages are what shops filter on. After arrival a special order becomes payment-aware (v0.7.248): still owing → Layaway; paid in full → Ready for pickup.

Cancelled sales inline (v0.7.317/.319, audit Tier-0 #3). Cancelled sales stay in the Today's Sales list (struck through) — not moved to a hidden footer. Customer history mode shows them struck through too. The audit fix that closed the "cancelled sales vanish" Tier-0 money bug.

Today's Held is scoped to today (v0.7.349). Yesterday's held/pending tickets drop out of Today's Sales unless the operator picks yesterday from the date navigator — the today view is genuinely today, filtered by local hold-date.

In-progress PENDING row tracks the live till (v0.7.363, alpha-v2 #3). The autohold draft's row on the ledger updates in place as the operator adds lines — no more stale "1 item, $12" while the cart's at "4 items, $200". The draft PUT branch calls loadLedger on every change.

Till line floors at zero (v0.7.362, alpha-v2 #2/#7). A cart-line quantity can't go negative; zero removes the line. Closes the "negative-cart cash payout" vector where an aggressive − stepper had let an operator ring up a $-50 cart and cash it out. Reverses direction on BIKE.L2-0215 (negative-qty auto-refund is now Service's job on a returned service, not a Sales till behaviour).

On-account label + Receivables (v0.7.324/.325, audit Tier-1 #5). On-account sales now read "On account" — not "Invoiced" — and have their own amber pill. A Status-filter entry points at them; the Customers tab gained a Receivables view (GET /receivables, red owed pill on the header, #recv hash route) that lists who owes on account, mutually superseding the YoY view when active.

Total row NET of refunds (v0.7.289). Today's Sales sub-header sums sales + refunds (negative) so the total reflects money actually in the till; the count still counts sales only.

Local timezone (v0.7.279). The Sales ledger's today and its date ranges are in the operator's local timezone; the picker converts the local range to UTC bounds before hitting /today and /summary.

R flag for returns (v0.7.225/.226). A red R pill-corner badge lands on any transaction that's been returned (partial included). Once fully returned, the Return button drops off; hovering the R shows a return hover card — a stacking summary of every refund against that sale, with staff + per-refund lines + fully_returned from /sale-returns. On the detail panel a clickable "Returned" pill in the status band (v0.7.350) opens the linked refund transaction — the operator can navigate from the invoice to the refund and back without a search.

Hover cards — monochrome + unified + cross-referenced (v0.7.237). The kbHoverCard shell (BIKE.L2-0204) mounts on every row; posted rows fetch /api/m/sales/transaction?id=, held rows render from local till data. Cards are monochrome (no per-status tint), carry the FULL data set, and cross-reference — an invoice card lists its related refunds; a refund card lists the invoice it refunds. Status pills are the 9-pastel palette from v0.7.107 (BIKE.L2-0163). Owing amount shows under Total for open orders (v0.7.236); date navigator preserves the picked date/range through open + close via kbDatePicker.restoreLabel (v0.7.235).

Ledger hover card IS the receipt (v0.6.960). The hover card that pops from a ledger row now renders the exact same layout as the printed receipt — same lines, same tax breakdown, same tender rows, same Sales Summary header — so the operator sees what the customer would see, live, without opening a detail modal. Cashier displays as initials (see below); the hover repositions itself to stay reachable in the viewport (v0.6.961).

Cashier initials — every name word (v0.6.962). Initials on the receipt + hover card now take every word of the cashier's name — Tom Butler Anderson is TBA, not TA. Fixes a real ambiguity on a two-initial shop where two staff shared their initial-pair.

Sales product search matches category + subcategory (ISSUE-006, v0.6.993 / v0.6.994). The Sales till's search bar now matches on category and subcategory names, not just product name, so "lights" returns every SKU whose subcategory is LIGHTS even when "lights" isn't in the product name. v0.6.994 fixed a duplicate-subcategories JOIN that broke the initial cut.

Attach customer — popup with inline create​

BIKE.L2-0002. Live search over name / phone / email / account #. Empty or no-match results turn into a + New customer affordance — a mini-form POSTs /api/m/customers/create and attaches in one step. Enter picks the first match. Attaching flips the ledger into the customer's whole sales history (BIKE.L2-0025). Clicking the customer name on a historic transaction opens Edit Customer, not re-attach (v0.7.234).

Layaway / Special Order without a customer → auto-attach​

Walk-in Layaway / Special Order / Estimate opens the Attach Customer popup (v0.7.377). Before v0.7.377 the till showed a red "customer required" warning and stopped; the operator had to click Attach, pick the customer, then click Layaway again. Now the button opens the Attach popup with Keep walk-in hidden; requireCustomerThen() + pendingAction fire the original action the moment a customer's picked or created. Closes a silent walk-in oversell — the till would let a walk-in "layaway" through when every line was out of stock (no server-side gate), which posted an unclaimable deposit against no customer.

Layaway earmarks its unit (SALES-22)​

Putting an item on layaway now really does hold it (v0.7.398). Before v0.7.398 the layaway deposit posted and the "Goods held here until paid off" note printed on the receipt, but the underlying stock stayed fully sellable — quantity_allocated_layaway stayed at 0, so another cashier at another till could ring the same physical unit while the first customer was walking to their car. The doctrine and the substrate were out of step. quantity_allocated_layaway was in the deposit backbone (migration 036) but only the special-order side ever wired it (at receive time on the PO); the in-stock layaway hold was silently dropped.

The v0.7.398 fix wires quantity_allocated_layaway across the full lifecycle:

EndpointWhat it does
/deposit (kind='layaway')Per line: allocated_layaway += qty, quantity_available -= qty. quantity_on_hand stays put — the unit is physically here, just reserved off the sellable pool.
/deposit-cancelReleases the earmark: allocated_layaway -= qty, quantity_available += qty. Unit returns to sellable.
/deposit-to-sale (pickup)After the sale decrements on_hand, releases the earmark so quantity_available reconciles without a stale reservation.

Special orders are unchanged — they earmark at /receive-po (correctly; you can't reserve stock that isn't yet in). Two paths flagged as follow-ups: /deposit-to-layaway conversion (re-earmark) and /deposit-update-lines (allocation adjust on edit).

Verified live on variant 50567 (on_hand=2): create layaway → allocated_layaway=1, available=1. Cancel → allocated_layaway=0, available=2. Create + pickup → on_hand=1, allocated_layaway=0, available=1 (sold cleanly, no stale earmark).

One number for the whole deposit lifecycle (SALES-25)​

A layaway or special order keeps its TRX number on pickup (v0.7.400). Before v0.7.400 settling a deposit to a completed sale minted a fresh TRX number, so the deposit-slip number the customer walked out with — and every staff-side warranty / return / reconciliation lookup that used it — 404'd the moment the customer came back to pick up. Same physical row, new number.

kvick-bike settles a deposit in place (one row flips transaction_type='deposit_layaway' → 'sale') — this is the deposit-backbone doctrine that diverges from the prior implementation. The renumber was a leftover from the estimate → sale convert (where EST- → TRX- is a legitimate renumber because Estimate uses its own prefix). A deposit is already TRX-numbered, so it keeps that number for its whole life. Fix: /deposit-to-sale reuses deposit.reference_number instead of calling nextTrx(). Verified live on TRX-2026-000077: layaway → pay balance → still TRX-2026-000077, transaction_type='sale', deposit_status='INVOICED'. The deposit slip stays findable.

No-match create-customer from the search bar (v0.7.335, Tier-2 #18). An idle-till search that returns nothing shows a + Create customer affordance pre-filled with the typed query, so a walk-in whose name isn't in the DB is one click to a real record.

Search results race guard (v0.7.365, alpha-v2 #10). Typeahead responses whose query no longer matches the box are dropped — no more stale results from a half-typed name.

Search hint doesn't cover the results (v0.7.367, audit v2 #6 / v3 C). The sales.search_bar scaffolding hint refuses to render on a non-empty input; an already-open hint retires on the first keystroke. Migration 048 rewords the hint copy to be phase-neutral (no "idle till" assertion).

Serial-number capture (SALES-15, BIKE.L2-0249)​

Serialized items can't leave the till without their serial (v0.7.390 → v0.7.392). A serialized product — bike, e-bike battery, e-bike motor (products.is_serialized = 1, populated on the 717 bikes the AIM cutover reclassified from sad_type='S') — carries a hard rule: the serial gets captured at point of sale.

At add-to-cart. Adding a serialized item auto-opens a serial capture popup — scan or type. Serialized lines are one unit per line (unique key, no qty-merge, stepper pinned to 1); adding two bikes is two lines with two serials. Once a serial's entered, a green S/N: <value> pill lands on the cart line; before then, a red Serial required pill sits in its place. Duplicate serials within one sale are rejected — a scanner double-fire can't accidentally sell the same bike twice.

At Charge — hard block. The Charge popup is blocked while any serialized cart line is un-serialled. Hitting Charge with a red pill on the cart re-opens the capture popup for the offending line; on completion the sale resumes.

Manager override. A staffer with discount.override_manager (reused, not a new permission) can sell without a serial after entering a logged reason — illegible sticker, missing tag, whatever the shop policy allows. The reason lands on the sale's notes for the audit trail.

Server defense-in-depth on every sale path. A shared serialGuardError() helper (v0.7.391, audit gap) refuses to post a serialized line with no serial and no override reason on all four sale-creating paths — /create, /deposit, /from-estimate, and /deposit-to-sale's edited-lines branch — even if a client-side gate is bypassed (HTTP 400). The v0.7.390 guard was /create-only; the audit found /deposit (the layaway / special-order path — the most likely way a serialized bike is sold on the shop floor) was accepting blank serials at HTTP 200. serialOverrideNote() logs the override reason onto the sale on every path.

Serials survive convert paths (v0.7.391). Convert paths (from-estimate, deposit-to-sale) DELETE and re-insert lines; a serial captured at deposit time was being dropped at pickup. reconstructStoredLine now carries serial_number through the round-trip, so the serial persists across the whole transaction lifecycle.

Prints on the till receipt (v0.7.392). The serial was previously printing only on the full-page Estimate; the thermal till/sale receipt renderer (receiptHtml) had no serial line, so a customer buying a serialized bike walked out with no artefact tying the unit to them. Now every serialized line in receiptHtml renders a bold S/N: <serial> row directly under the product name, on both first-print (buildSaleReceipt) and every later reprint (buildDetailReceipt) — since both feed receiptHtml.

Serial lands on transaction_lines.serial_number; the override reason lands on the sale notes; both come back in the transaction-detail response and print on the receipt. Non-serialized items (tubes, accessories, labour, service_menu) are unaffected — the gate only fires for lines whose product row carries is_serialized = 1.

Downstream (Inventory + Customer surfaces). The serial-on-transaction is the write side; the read side is the Inventory Product Card (which serials are on which bikes in stock) and the Customer card's Equipment section (which bikes this customer bought, with serial). Those surfaces are coordinated by their own seats and land in their own commit notes.

Special-order + serialized-line workflow at the till (v0.6.964 → v0.6.977)​

A concentrated revision of how the till handles out-of-stock lines and serialized items. The through-line: the till never infers intent from the stock number — it only asks.

  • v0.6.963 — one right rail on every till line; OUT OF STOCK as centered text; Special Order? pill on row 1.
  • v0.6.964 — pill defaults by the count: Ordering when out of stock, Sell -ve Stock as the override.
  • v0.6.965 — the count never decides. Out-of-stock rings as an ordinary sale; the pill only asks (Tom's rework of the v0.6.929 direction).
  • v0.6.966 — never a stepper on a serialized line (one bike, one line).
  • v0.6.967 — a serialized re-add to an open order is its own line; a bad quantity cannot hide.
  • v0.6.968 — fixed-width Special Order pill so OUT OF STOCK lines up on every line.
  • v0.6.969 — right-anchored columns; In Stock mirrors Out of Stock.
  • v0.6.971 — cart-aware stock + serials — a 2nd of a 1-in-stock bike reads Out of Stock.
  • v0.6.972 — serial-button label refinement; no-vendor picker retired; single-tender sale auto-completes without a Confirm click.
  • v0.6.973 — two-line stock split for parts (in-stock and on-order on the one receipt), overage earmarked to the buyer.
  • v0.6.974 — Take Payment pills read the in-progress act (Charge / Layaway / Special Order); layaway deposit carries over; ledger shows on-order sales.
  • v0.6.975 — till only takes the order; mixed-order split modal at Charge; backorders attributed to the sale.
  • v0.6.976 — reversing a special order pulls it from purchasing; committed orders need manager approval to reverse.
  • v0.6.977 — inline Special Order pill returns; count sets the default; a special-order ticket orders in only the pill-on lines (Tom + James).

Net effect: mixed carts (in-stock + on-order + special-order) ring correctly through one Charge, one receipt, one ledger row, with the buyer attribution preserved through purchasing.

Reassign customer on a posted transaction​

Clicking the customer header inside a posted transaction's detail panel opens the Attach popup — but the flow is a reassign, not an attach (v0.7.266). POST /reassign-customer writes the change to the transaction, audits, and is blocked when on-account or store-credit rows tie the original customer's ledger to the sale (moving those without moving the ledger entries would corrupt the balance). Useful for correcting a walk-in that turned out to belong to an existing account.

Return / Refund / Exchange​

The Sales till carries two Return paths that share the same tax engine and audit shape but come in from different starting points.

Return with receipt​

The Return panel (v0.7.225–.232) opens against a posted invoice; the invoice stays up behind the modal (v0.7.228). Per-line:

  • Quantity — standard kbStepper (BIKE.L1-0013)
  • Condition — New (restocks), Used (restocks), Damaged (doesn't restock)
  • Restocking fee — header-level (dollar or percent-of-return)
  • Header reason

Refund estimate now includes tax (v0.7.227). The refund credits the actual tax paid (v0.7.230, effTax = total − subtotal + discount), proportions to the header not the line prices (v0.7.231 — imported sales don't reconcile at the line level), and for imported sales with no tax rows infers GST 5% + PST remainder (v0.7.232/.233). Pick-destination-at-completion (cash / card / store credit / on-account) covers a pure refund; an exchange — where the customer's swapping the returned item for a different one — is now its own netted flow instead of a full refund plus a fresh sale (see below).

A refused refund leaves the sale fully refundable (v0.7.410). Before v0.7.410, /refund consumed the original line's refunded_quantity inside the resolve loop, before the validations that can still 400 (store-credit-needs-a-customer, wrong-tender-needs-manager-override). So a correctly-refused refund ate the quantity with NO refund transaction created and no restock — the line read "already refunded", the goods never came back, and the legitimate retry got "Nothing to refund" forever. The v0.7.396 tender-override guard made this reachable from any wrong tender pick — one mistap poisoned the line. Fix: the resolve loop is now read-only — refunded_quantity writes are queued and only applied in the write phase, after every check has passed. A rejected refund now leaves the sale unchanged; the operator picks the correct tender and the refund goes through.

Negative-quantity lines can no longer post as sales (v0.7.410, server half of BIKE.L2-0244). A qty -1 line on /create made the total net-negative, which paid |total| out on one tender with no original sale, no refund-to-original-tender rule, no manager override, no restock condition and no audit link — bypassing the entire refund control surface. The client-side stepper had been fenced at 0 (per BIKE.L2-0244, "a cart line can never be 0 or negative", v0.7.362) but the server never matched, leaving the shrinkage vector open on the API. Now /create refuses negatives outright — money leaves the till only through Return with receipt, Return without receipt, or Exchange, each of which carries its own controls (link to the original sale, refund-to-original-tender, manager override, restock condition, audit trail). Same defence-in-depth pattern as the server-side serial hard-block.

Refund invariant — a refund can never return more than the customer actually paid (v0.7.411). This is the standing guarantee on every refund and exchange path: refund_amount ≤ paid − already_refunded for that sale. Bounded by a hard server-side ceiling; a legitimate refund is capped rather than blocked. Encodes what the handler's own comment already promised: "never more than was charged".

Mixed-sign imported sales — no automatic amount; manager enters, with a reason (v0.7.414). AIM history records exchanges as ordinary sales — positive lines for the goods taken plus negative lines for the goods handed back on one ticket, netting to a small header (a $210 bike-swap ticket where the two bikes cancelled out is a real example). Imported lines on this shape are untrustworthy at the line level — only the header + payment are truth. Every proportional formula is wrong silently on some row of the ~133 imported tickets with this shape:

DenominatorLive failure mode
Sum of ALL lines (pre-v0.7.411)Collapses to the net on a matched +/− pair → $7,699.99 back on a $210 sale (38×)
Positive lines only (v0.7.411 attempt)Inflated by the cancelled pair → $5.30 back where ~$200 was due (37× under-refund)
Net of the headerRight on some rows, but a $1.70-net sale on $24.76 paid still misprices per-item

Tom's ruling (v0.7.414): on this shape, don't compute. Detect the shape (any negative-quantity line on the original), refuse to auto-calculate, and require a manager to enter the amount deliberately with a reason. The dialog names what the customer actually paid on the ticket so the manager has the number they need. The hard ceiling still caps whatever's entered. Fails safe — a silent money error becomes a human decision a manager can get right in two seconds. Applied to both money paths — /refund (explicit_refund_cents) and /exchange (explicit_credit_cents) — the earlier v0.7.411 fix had only touched /refund; the Codex flagged that /exchange had received none of it and needed the same treatment.

Normal all-positive sales are unaffected — they keep the automatic proportional path (denominator = positive line value only, ceiling still applies) and the calculation is identical to before. Only the ~133 mixed-sign imported tickets fall through to the manager-entry path.

The whole three-commit arc — v0.7.410 fencing negatives forward, v0.7.411 fixing the denominator + adding the ceiling, v0.7.414 replacing formulae with manager entry — was surfaced by AIM Migration's warning that the v0.7.410 fence is forward-only (the converter writes negatives directly, so imported history legitimately contains them). Checking what Sales code assumed "negative lines cannot exist" surfaced two consecutive money bugs the audit hadn't found. Same fix-your-yardstick-first doctrine in reverse: a peer seat's warning became the yardstick that found the code bug.

Return without receipt — "Issue Store Credit" (BIKE.L2-0062, SALES-18)​

Renamed at v0.6.998 (ISSUE-012). The action-button text on the no-receipt flow now reads "Issue Store Credit" — plain-language for the operator, and the store-credit-vs-money choice on completion is preserved (any cashier for store credit, manager override for money). The old "Return without receipt" label read as a "return type" when the operator is actually issuing a credit against no known original.

Lost-receipt + gift returns — a second path with different accountability (v0.7.393). Reached from inside the Return dialog — a "Customer has no receipt? › Start a no-receipt return" link at the top of the receipt-based Return closes it and opens the receipt-less flow (v0.7.401). Handles the case the receipt-based path can't: the customer walks in with an item that isn't in the system, either because they lost the receipt or because it was a gift someone else bought. (A standalone "Return w/o receipt" button shipped briefly on the idle till at v0.7.393 and was removed at v0.7.401 — a no-receipt return is an exception path, not a co-equal alternative to New Sale, so it belongs one step in.)

Flow: attach customer (required — no walk-in path) → search + add the item(s) → set each line's Condition (New / Used restock; Damaged doesn't) + a per-line reason → pick refund destination.

Refund destination is the accountability gate:

DestinationWho can do itWhy
Store credit (on the customer's account)Any cashierLow-risk default — money doesn't leave the shop; if the return turns out to be improper the credit stays on the account and can be clawed back
Refund money — cash / debit / card manual / chequeRequires manager override (discount.override_manager — the same permission that gates line-discount overrides) + a logged reasonNo proof of purchase; the cash walks out the door

A customer is required on both destinations: the store-credit path needs an account to land on, and the money path needs a name-of-record because "who returned it" is the only accountability on a no-proof return. No restocking fee on this path — with no receipt, there's no original transaction to reconcile the fee against. Same tax engine as the receipt-based return.

Every no-receipt return is stamped "No-receipt return" in the transaction notes, plus the manager-override reason on the money path. The stamp is what the audit trail reads to identify the class of return.

Substrate: new server endpoint POST /return-no-receipt; new modal sc-nrr-modal; entry button sc-nrr-open.

Exchange — payment modal + split-tender collect (ISSUE-013)​

v0.6.999 / v0.6.1000 (ISSUE-013). Exchange difference now settles through the standard payment modal on both entry points (receipt-based Return dialog + no-receipt modal), instead of a bespoke "collect the difference" screen. POST /exchange accepts a payments[] array on the collect side — the operator can split the difference across tenders exactly as they would on a plain sale. The cash-collect header no longer renders a literal &mdash; where the difference used to display (a v0.6.1000 fix).

Exchange — netting (SALES-19)​

One linked transaction — return item A, ring item B, net the difference (v0.7.394). Before v0.7.394 an even swap posted a full refund AND a fresh sale, which double-charged the customer's card (refund to card → new sale to card → they see two large transactions on their statement instead of the single-cent-neutral event that actually happened). Now Exchange is its own transaction_type and rings one linked transaction that computes the delta and only moves the delta:

DeltaWhat happensTender path
Replacement B costs more than returned ACollect only the differenceAny tender the Charge popup offers
B costs less than ARefund only the differenceReceipt-based — the dialog gains a "Refund to" selector defaulting to "Original tender(s)"; the difference splits back across the original tenders proportionally (v0.7.397, matches refund tender routing). Forcing a different tender needs manager override + reason. No-receipt — follows the no-receipt rule (store credit any cashier, money manager-override)
Even swap$0 moves; the original card is never touched—

Two entry points — both share the same underlying exchange endpoint:

  1. Receipt-based — open a posted sale → Return → "Exchange for — add replacement" section. Tied to the original sale for provenance + original-tender routing.
  2. No-receipt — the same "add replacement" section inside the Return w/o receipt modal. Net-refund destination follows the no-receipt rule.

Both sides post cleanly. The returned A restocks per its Condition (New / Used yes, Damaged no); the replacement B depletes like a normal sale (oversell allowed — a shortfall auto-buckets for reorder via the backorder → Bucket flow); a serialized B needs its serial captured the same way SALES-15 requires on any sale. Tax nets on the same engine — the transaction's tax total is tax(B) − tax(A).

The ledger row shows Exchange as its own status (mint pill), distinct from a plain Refund or a plain Sale. Server: POST /exchange (both modes). New transaction_type = 'exchange' in TYPE_LABEL + the status palette + businessStatusLabel + the /today predicate.

Every mutation is narrated (v0.7.194–.200). core/audit-narrate.ts holds a deterministic verb + lexicon library keyed by event_type; the shell footer is a ticker tape (v0.7.195) resting 25px below every tab, verbose-hover peek only (click-to-open was retired v0.7.200). Sale, void, refund, deposit-create, add-funds, deposit-cancel all route through recordNarrated in core/audit.ts (mig 040). House-style voice (v0.7.269) — every idiom uses the same verb tense, cents render as $X.XX, arrows and × notation are normalized. See Audit narrator + ticker.

Tender​

Seven payment methods, split-tender (a single sale can pay across several rows), quick-tender presets $5 / $10 / $20 / Exact (BIKE.L2-0201). Cash tender obeys the keyboard conventions (BIKE.L2-0199/0200): auto-select on open, Enter confirms, Escape cancels from any step. A successful tender posts the sale: writes the transactions header (TRX-YYYY-NNNNNN identifier), the transaction_lines (with snapshot product data — the line stays true to what was charged even if the catalog moves later), transaction_payments, transaction_taxes, transaction_discounts. Stock decrements through core/movements.ts — same code path Inventory uses for receive / adjust / write-off, so the chain is consistent. The allowNegative flag on the movement engine lets zero-stock backorder lines post.

Money-input mask​

Every editable money input carries data-mask="money" (v0.7.210, core/formats.ts) — type=text inputmode=decimal, strips commas on focus, shows 5,999.99 on blur. Rolled out on Sales unit-price inputs first; every module reads through the same kbFmt.moneyMask / moneyToCents. See Money mask.

Customer standing discounts (ISSUE-015)​

A customer can hold several standing discounts on their card (v0.7.002 → v0.7.007). Migration 013 gave each customer one sales-discount slot + one service-discount slot; migration 059 (v0.7.002) added customer_discounts — a join table letting a customer carry any number of Discount-Builder rules per department. Migration 060 (v0.7.002) added two Settings permissions: discount.customer.manage (assign / remove standing discounts on the card) and discount.customer.override (remove or replace an auto-applied customer discount at the till). Defaults: Owner + Manager on both.

Auto-apply at the till (v0.7.003–v0.7.007):

  • v0.7.003 — Attaching a customer to the till auto-applies every one of their standing discounts that matches a cart line; cashier sees a chip per applied rule with the rule's name.
  • v0.7.004 — Cost-plus discounts auto-apply at the till too (per-line, on the same path).
  • v0.7.005 — Category match is case-insensitive — "lights" matches "LIGHTS" (fix, subcategory-tax path).
  • v0.7.006 — Line discounts stack off the original price, not the already-discounted price: a customer's standing rule + an inline manager override compound honestly on the original line total.
  • v0.7.007 — Receipt closes with a "You saved $X" rollup — one number under the totals block naming what the customer paid vs what the sticker price would have been.

Old customers.sales_discount_id / service_discount_id columns are left in place (like the dormant pricing_tier), so /discount-suggest and any legacy reader keep working until the till side moved over.

Discounts — line + cart​

  • Line discount (BIKE.L2-0059) — chips per line offer percent, dollar, or set_price. The customer-assigned discount rule (authored in Settings → Discount builder) is resolved by /discount-suggest. Manual overrides above the rule's threshold are gated by discount.override_manager — server-authoritative in /create, plus a client-side kbCan so the chip is disabled when the operator lacks the perm. Recorded on transaction_discounts with a transaction_line_id + discount_id link (migration 028). Per-line + Discount popup carries a reason (v0.7.347); the reason prints on receipt + estimate + shows on a recalled sale (v0.7.357, audit-3 #16).
  • Cart discount (BIKE.L2-0060) — a totals-level chip Apply / remove. Reduces the subtotal AND the GST / PST base proportionally so the tax math stays honest. Threshold-gated the same way. Recorded as a transaction_discounts row with transaction_line_id IS NULL. transactions.total_discount_cents rolls the line + cart discounts up onto the header.
  • Ad-hoc mid-sale discount (v0.7.334, Tier-2 #14) — an on-Till + Discount in the totals gives the operator a manual discount + reason without a customer rule attached. Stored on transaction_discounts with discount_type='manual' and description=<reason>. Mutually exclusive with an applied cart-rule discount.

Discount model — the correctness fix (alpha-v2 #1 BLOCKER)​

The discount model has a gotcha the code paid for and the Bible now names: per-line discounts sit INSIDE the subtotal; cart discounts sit OUTSIDE. transactions.total_discount_cents holds BOTH totals summed. The correctness formula for the cart-only portion is:

cart_discount_cents = total_discount_cents − SUM(transaction_discounts WHERE line_id IS NOT NULL).discount_cents

Reading total_discount_cents where you meant cart_discount_cents double-counts the line discounts, over-refunds by that amount on a return, and shows a phantom balance on a layaway. v0.7.359 fixed this everywhere: the detail panel, the row hover-card, the return dialog, the reprinted receipt, AND the server /refund (which was over-refunding by the cart discount every time). v0.7.360 fixed /deposit — the endpoint was computing total = subtotal + tax with no discount logic at all, so a customer with a $200 layaway on a discounted bike walked out with a phantom balance owing. v0.7.361 fixed /deposit-update-lines — a layaway edit was re-booking at full price; now the cart discount is stored as dollars and capped to the new subtotal on edit.

Rule of thumb: when in doubt, use cart_discount_cents computed as above, not the raw total_discount_cents.

Adjust-tax modal on the whole sale​

Whole-sale tax override (BIKE.L2-0067, v0.7.106). The GST and PST rows in the totals are clickable — opens a 3-mode Adjust Tax modal: Status Card (PST-exempt via card number), Manual override (enter cents on either tax, reason required), or Default (revert to per-line computed). Modes are mutually exclusive; an override badge lands in the totals block while active; Hold / Clear / new-sale wipes it. Sits on top of the per-line tax categories — the override replaces the computed GST / PST total.

Per-line tax-category picker​

Per-line category (BIKE.L2-0068). Each cart line carries a tax-category picker (fully_taxable / bike_pst_exempt / gst_only / exempt). The client's taxApplies() reads applies_gst / applies_pst from /tax-categories. Per-line tax badge (v0.7.351, audit-3 #10) — every cart line shows a small badge naming its resolved tax treatment (GST, PST, GST+PST, EXEMPT) so the cashier can see at a glance which category rang.

BC PST accessory rule (BIKE.L2-0248, v0.7.378). Attachments — locks, cages, bells, water bottles, bottle cages, panniers, pumps — are ALWAYS PST + GST at the counter. The v0.7.373 experiment shipped a per-line "Fit to bike" toggle that would drop the PST when a lock or cage was fitted to a bike-in-invoice; v0.7.378 removed the toggle at the Till per owner directive. The exemption remains legal but is now granted only via the Service Tab (not built yet — a bike that comes in for a bell fit gets the PST-exempt bell rung through Service, not through the retail Till). Migration 050 kept the transaction_lines.fitted_to_bike column but the write path never sets it. Parts + safety gear (lights, helmets, reflectors) remain PST-exempt by their own category (always_exempt_part / always_exempt_safety).

Durable subcategory tax (v0.7.379). Rather than tag every SKU one at a time, the ETL's convert-catalog.ts sets products.default_tax_category_id by subcategory NAME: LIGHTS → always_exempt_safety, FENDERS → always_exempt_part, COMPUTERS / CAR RACKS → fully_taxable, LOCKS / BELLS / BAGS / PANNIER RACKS / WATER BOTTLES / BOTTLE CAGES / PUMPS → context_dependent_accessory. The tax resolver reads COALESCE(inventory.tax_category_id, product.default_tax_category_id, category.default_tax_category_id) so a subcategory rule stands in until a per-SKU override is set. Coexists with the manual tax_reapply step in the ETL finalize phase.

Bundled-accessory PST drop (migration 032) is the mechanism for the context-dependent-accessory decision at cart time by BC PST Bulletin 204; the v0.7.378 direction is that at the retail Till the context defaults to "not fitted" → PST applies. Override is recorded; tax_override_reason logs on the header when present. Status-card exemption for PST holders (BC Bulletin 219) is captured on transactions.tax_exempt_status_card_number and skips PST accordingly.

Restocking fee is GST-inclusive (v0.7.341, audit-3 #4). Per ETA s.182, a restocking fee is a supply that carries GST. The refund flow computes fee × 5 / 105 for the GST-inside-the-fee amount so the shop keeps the correct net.

Void + receipt​

Void (BIKE.L2-0063). sales.void reverses stock through the movement engine and marks the transaction voided (voided_at / voided_by_staff_id / void_reason). Void needs a reason; the ledger-row one-click was retired (v0.7.75) as too easy to hit — void now lives inside the transaction detail panel.

Post-sale receipt prompt (v0.7.252 → .259, BIKE.L2-0069). Every sale / refund / estimate / deposit fires openReceiptPrompt: Print / Email-soon / No receipt. Enter = Print (via Core keyboard layer); localStorage carries an always-auto-print opt-in. One receiptHtml() renderer drives every doc — thermal for sale / refund / layaway / invoice, full-page Letter for Estimate (v0.7.257). Deposits show a balance-owing line (v0.7.296). Gift cards render the issued card number in the prompt header (v0.7.326, audit Tier-1 #8). See Receipt engine.

Payment reversal + stray-click hardening​

sales.payment_reverse (migration 041, seeded to owner + manager). A payment row on a posted transaction can be reversed — creates a mirror-signed transaction_payments row + audit — so the next payment lands with the corrected method or amount. Extended to open deposits (v0.7.333, Tier-2 #13) — a mis-entered installment payment on an open layaway/SO can be reversed; the ledger shows the original + reversal + refund rows and the client reopens the detail for re-collect.

Collect balance on a reopened sale (v0.7.395, SALES-20). Before v0.7.395 the corrected-payment tender grid only opened in the modal that appeared immediately after a reversal — a cashier who reversed a payment, walked away, and reopened the sale saw "Balance (payment reversed) $X" with only Return / Print available and no way to collect the outstanding balance. Now a posted sale with an outstanding balance renders a Collect balance button on the detail panel next to Return / Print; clicking it opens the same openCorrectPay() tender grid, pre-filled with the amount still owed, and posts to /payment-add?id=. Single or split re-collection works exactly like the immediate-post-reversal modal did.

Stray-click hardening (v0.7.315, audit Tier-0 #2): the Take-Payment tender tiles carry a 42-px gap between Split and the next tile, an "or" divider, and a confirm-step on every tile — a mis-tap can no longer accidentally take money.

Permissions​

PermissionWhat it gates
screen.salesThe Sales tab is visible at all
sales.readToday's sales ledger + transaction detail
sales.createPost a sale via /create
sales.refundIssue a refund via /refund
sales.voidVoid a posted sale via the void flow
sales.depositTake layaway / special-order deposits
sales.estimateCreate / resume estimates
discount.applyApply any discount — rule, line manual, or cart. Without it: chips hide, cart Apply hides, line price is read-only. Enforced client-side and server-side (/create returns 403 if a discount lands without the perm)
discount.override_managerOverride a discount rule's manager threshold (line or cart)

The whole Sales permission surface is enforced end-to-end as of v0.7.54.

How it composes with the rest​

  • Customers — the attached customer's family members, account credit balance, and assigned discount rule all read into the Till; attaching flips the ledger into that customer's whole sales history. Signed-money display on the Account balance (v0.7.213) — -$X.XX owing / $X.XX credit.
  • Inventory — every cart line reads from product_variants for the snapshot data; the movement engine records the sale / return; the per-line tax picker reads tax_categories (mig 018 + 032). The OOS pill's Adjust-actual-stock stepper commits live to inventory.quantity_on_hand through /count.
  • Purchases — zero-stock cart lines auto-drop into the Purchases Bucket on Charge; the Bucket row shows "for <Customer>" (blue chip) or "for Stock" (neutral). Special-order deposits still auto-post the same way (migration 033). See Backorder → Bucket flow.
  • Service — pickup on a service ticket rings through /create as a service_invoice transaction; same tender, refund, and audit paths.
  • Settings → Discount builder authors the rules /discount-suggest resolves; manager threshold + applies-to (sales / service / both) honoured.
  • Audit chain — every posted sale, refund, void, deposit action, discount application lands in audit_events via recordNarrated; the ticker tape shows the last few, hover shows the verbose sentence.

See also​