Skip to main content

Slice 5 — Transactions & Payments

The till. Builds cash and card sales (Stripe Terminal), refunds, voids, receipt generation, and the per-line BC tax engine application.

Status: Schema + ETL done (17,996 transactions, 18,154 lines, 10,365 payments migrated from AIM). Path B steps 1+2+3 shipped: live search bound to #sales-search (/api/inventory/search) with a clear-search button (× / Esc), the Frequent tab populated by /api/inventory/top-velocity (trailing-90-day units-sold ranking), cart with line items + BC tax display (GST 5% + PST 7%), attach-customer placeholder, clear-cart with audit-logged void, and the cash-tender modal (stub). Charge enables when the cart has lines and total > 0; modal shows total + tendered-amount input + quick-tender buttons + live change calc; Confirm writes an audit row via POST /api/audit/manual summarising the would-be sale. No real transactions row is posted yet — the cash payment is a stub until offline-architecture slice 2 (idempotency-key middleware) lands. Stripe Terminal not yet wired.

Focus steward: #sales-search. This is a hard acceptance criterion when the slice ships — see below.

Focus stewardship — acceptance criterion

The SKU/barcode search input is the steward of focus on the Sales screen. Focus returns to it after every modal close, print dismiss, payment completion, and transaction post. The scanner-input safety net is enabled at the document level. Misrouted scans must not corrupt cart lines, customer fields, or any other input.

See focus stewardship for the full principle, registration API, and the 10-case test plan that must pass before this slice is marked Alpha.

Drafted from planning · v0.1

Scope

  • transactions, transaction_lines, transaction_payments, transaction_refunds
  • Sales screen (formerly Ring-Up)
  • Cash sale flow
  • Card sale via Stripe Terminal
  • Refund flow (partial + full)
  • Void flow
  • Receipt PDF generation
  • BC tax application per line
  • Daily reconciliation cron (compares D1 + Stripe)

Schema

See transaction entity and transaction lifecycle.

Endpoints

  • GET /api/sales/today — today's transactions
  • GET /api/sales/search?q=...&from=...&to=... — search
  • GET /api/sales/:id — detail
  • POST /api/sales — create (cash or card)
  • POST /api/sales/:id/refund — refund (partial or full)
  • POST /api/sales/:id/void — void (only if pending)
  • GET /api/sales/:id/receipt.pdf — receipt PDF
  • POST /api/sales/:id/email-receipt — email to customer
  • POST /api/webhooks/stripe — Stripe webhook handler

UI

Sales screen:

  • Tab bar (Frequent / Service / Bikes / Apparel / Accessories / Parts)
  • Tile grid below (clickable SKU tiles)
  • Cart on the right with running totals
  • "Customer" attach button at the top (search by name/account)
  • Tender buttons (Cash / Card / Split / Gift)
  • Cash modal: enter tendered amount, see change due, confirm
  • Card modal: select reader, sends to terminal, waits for completion
  • Receipt screen on success: print / email / "Done"

Sale detail (post-completion):

  • Header: transaction number, status, totals
  • Lines tab
  • Payments tab (if split)
  • Refund history tab
  • Customer info (if attached)

In edit mode (Sales screen):

  • Tab labels editable, order draggable
  • Tile labels editable, tile order draggable
    • Add tile/+ Add tab
  • Frequent tab is operator-customizable

What's built

  • Schema migrations applied ✓
  • Tax engine (src/lib/tax-bc.js) used in service tickets ✓
  • Service-ticket cashout path (creates a transaction row) ✓ partial
  • AIM migration: 17,996 transactions ✓ (with tax fields recomputed)
  • Sales screen live search#sales-search bound to /api/inventory/search with debounce + dropdown results; click or Enter adds to cart ✓
  • Frequent tab — populated by /api/inventory/top-velocity?limit=30, ranked by 90-day units sold (anchor = MAX(transactions.occurred_at) WHERE status='posted', not current_timestamp, because the migrated transaction set ends Feb 2026 and there's no live POS yet — switch to current_timestamp once live sales flow). Requires quantity_on_hand > 0. ✓
  • Cart — line items, quantity edit, line remove, subtotal, GST 5%, PST 7%, total. Tooltips explain each tax row in operator-friendly language. ✓
  • Clear cart — confirmation + audit-logged void of the partial cart ✓
  • Attach customer — button + placeholder slot (search modal lands in the next step) ✓
  • Search clear button — × inside #sales-search + Esc keybinding ✓
  • Cash-tender modal (stub) — live change calc, quick-tender buttons, Confirm writes an audit row via POST /api/audit/manual with event_type='sales.cash_stub'. The /api/audit/manual endpoint is now wrapped in withIdempotency (offline-arch Slice 2), so retries during a network blip replay safely. The real /api/sales POST is next. ✓
  • Real POST /api/sales with dispatched stock decrement ✓ — handler reads products.stock_behavior for each cart line and dispatches per-line:
    • tracked → writes an inventory_movements sale row + decrements inventory.quantity_on_hand (single transactional batch)
    • untracked → no inventory side effect; line lands in transaction_lines, revenue recorded
    • rental → REJECTS with "use the rental flow" (Slice 7), so we don't silently mis-decrement fleet bikes See slice-3 → stock behavior + movements.
  • Estimate flow (live 2026-05-15) ✓ — POST /api/sales/estimate creates a transactions row with transaction_type='estimate', status='pending', full lines + tax breakdown. Returns E-#### reference. Frontend opens a printable PDF window (shop logo from localStorage + shop_config + line items + 14-day-valid footer; window.print() auto-triggers). Estimates appear in the Today's sales ledger with a purple ESTIMATE badge. Click → transaction detail popup with 🖨 Print estimate button. Estimates show up in the FTS5 search engine's search_estimates_fts index (search engine). 2026-05-19: the Estimate button now opens a small delivery-method prompt (Print / Email / Both) before generating, so the operator picks the channel up front instead of always landing on the print preview.
  • Today's sales ledger ✓ — section heading data-section-anchor="sales.todays_sales", sortable columns (Time / Customer / Summary / Status / Total) via window.helmSortableTable, refreshes every 30s while Sales is active, scoped to shop-local "today" (shopTodayUtcRange('America/Vancouver') math in Worker JS). Replaces the velocity tile grid as the cart-page-actions row's primary surface.
  • Park sale ✓ — per-device parked sales stored in localStorage.helm_parked_sales_v1, merged into the Today's sales ledger as synthetic Parked rows. Click a Parked row → resumes into the cart (window.helmResumeParkedSale). The previous "Resume parked" button was removed in favor of the ledger surface. Bug fix 2026-05-19: resuming a parked sale was wiping today's-ledger state on the client; the resume flow now preserves ledger items.
  • Page-actions row ✓ — top of Sales: + Customer · Park sale · End of day (End of Day is Owner/Sys-Admin-only via the helm:auth-changed gate). Page-sub line is dynamic + clickable: Till Open · cashier <name> · register REG-1 — clicking the cashier name opens a popover with Edit my staff card (Owner/Sys Admin only) + Switch cashier (logs out + PIN screen).
  • Tax overrides (migration 033, live 2026-05-16) ✓ — two override paths recorded per-sale, both auditable:
    • Status Card exemption — captures the Indian Status Card number in transactions.tax_exempt_status_card_number; both GST and PST are zeroed on the sale.
    • Manual override — captures staff-entered GST/PST cents in the existing transaction_taxes rows, alongside a required tax_override_reason (free text), plus tax_overridden_by_staff_id and tax_overridden_at for forensic recall.
    • Both write through recordMutation so the override + reason + staff are immutably in the audit chain. The apiSalesCreate handler rejects a manual override with no reason (400) so the "why" is never empty.
  • Editable GST + PST rates (migration 041, live 2026-05-19) ✓ — until now the sale-creation + service-ticket-recalc paths hardcoded GST=5% / PST=7% (the BC defaults) and the Settings → Taxes input fields were disabled. The migration adds shop_config.gst_rate_basis_points + shop_config.pst_rate_basis_points (basis points, integer-only math: tax_cents = subtotal_cents * rate_bp / 10000) with CHECK 0..10000. Cart tax labels read live from the rate; tax recalc uses the per-shop value end-to-end. A shop in AB can set PST=0; a future BC PST rate change is one Settings edit.
  • Customer account ledger (migration 043, live 2026-05-19) ✓ — per-customer money + AR. One row per money movement; current balance = SUM(amount_cents). Positive balance = shop holds credit (gift card, refund-to-credit, trade-in payout, prepaid deposit). Negative balance = customer owes the shop (classic AR). LEDGER not a balance column → auditability (every movement is a SELECT-able row) + concurrency (INSERT-only, no UPDATE contention). entry_type distinguishes cash_deposit, trade_in_to_credit, refund_to_credit, gift_card_load, manual_credit, sale_charge, manual_debit, etc. In-app Customer Account modal shows credit / debit / balance with distinct badge colors (Layaway / Special / Deposit) per entry kind.
  • Deposit button on cart (live 2026-05-19) ✓ — new cart action between Estimate and Charge. Two paths:
    • Stock layaway — customer pays toward an item currently in stock; the cart commits as a sale_charge debit on the customer's ledger, the item is held for them.
    • Special order — customer pays for an item not yet in stock; the sale commits and an inventory_order_queue row is added with customer_id set (migration 045 earmark), so when the item arrives the receiver knows to hold the unit for the named customer instead of putting it on the floor.
  • End of Day report (live 2026-05-19) ✓ — combined Daily Activity + Sales Journal report. End-of-Day button (Owner / Sys-Admin only) styled as a pill in the page-actions row. Drops out a printable summary suitable for the cash-out routine + the bookkeeper's daily reconciliation.
  • Sales-report modal with YoY (live 2026-05-19) ✓ — GET /api/sales/report?period=mtd|ytd|month|year returns the period summary; the modal renders Revenue and Transactions with YoY arrows comparing to the same period a year ago. Anchored to the shop's fiscal_year_end_mmdd (migration 035) — a June-30 fiscal-year shop sees July-1-to-today, not Jan-1-to-today.
  • Today → Owner tab → Today's Till tile (live 2026-05-19) ✓ — Owner-only tile on the Today dashboard showing today's revenue + YoY trend + clickable to drill into MTD / YTD views. Hides MTD/YTD pills from the Sales header (they live on the Today tile now). Fixed a timezone bug where the tile reported $0 because it was computing "today" in UTC instead of the shop's local zone.
  • Cart UI polish (live 2026-05-19) ✓ — cart customer line shows + Customer when empty and Walk-in when ringing without one (clarifies the no-customer state). Park button moved into cart-actions between Estimate and Charge (single page-actions row). window.helmConfirm everywhere replaces native window.confirm. Tax Apply has visible feedback on save. Bulk-Add chains to the full editor with live Order Queue + Deconstruct.
  • Sales ledger date-range picker (live 2026-05-19, v0.4.189) ✓ — the Today's Sales ledger now accepts an explicit start + end date instead of being scoped to "today" only. The picker uses a deliberate color cue — red for the start date, green for the end date — so the operator can't confuse which field they're filling. Defaults remain today-only; widening the range opens the same sortable ledger with whatever window the user picked.
  • "Till" vocabulary (live 2026-05-20, v0.4.190) ✓ — the Sales screen UI now consistently calls itself Till (Sales Till, Till Open, "the till"). The word Cart is now reserved for the eventual eCommerce / public-site shopping cart — using two different words for two different things up-front saves a rename when the eCommerce slice ships. The backend transactions table and the API endpoints (POST /api/sales, GET /api/sales/today, etc.) keep their existing names; the vocabulary shift is operator-facing only.
  • Hub-native helmAlert + helmPrompt (live 2026-05-19, v0.4.184) ✓ — alongside the existing window.helmConfirm (which replaced native confirm() earlier), the platform now ships window.helmAlert(message) and window.helmPrompt(message, defaultValue). All three are Hub-styled, Esc-cancellable, Enter-confirms, focus-stewarded, and consistent with the operator app's design language. The Sales Till, Beta Comments admin, Settings flows, and the bucket workflows all use them in place of the native Chrome dialogs.
  • Refunds & Returns flow (live 2026-05-21, v0.4.228, migration 050) ✓ — POST /api/sales/refund does partial-or-full refunds against a posted sale. Selects which lines come back, computes restocking fee per-line (default from shop_config.default_restocking_fee_bp, operator-overridable), recomputes tax on the refunded subtotal, writes the inverse inventory_movements row per tracked line, links the new refund transactions row to the parent via parent_transaction_id, and bumps transaction_lines.refunded_quantity on the original lines so the next refund attempt knows how much is still refundable. Manager-PIN required at or above shop_config.refund_manager_pin_threshold_cents (default $100). All wrapped in withIdempotency; audit-chained on both the refund and the parent. Settings → Refunds exposes the two thresholds + a per-line restocking-fee override.
  • Exchange flow (live 2026-05-21, v0.4.229) ✓ — Settings → Refunds includes the Exchange UI. Built on top of the refund flow: the operator refunds the original line(s), then rings the replacement item(s) in the same modal. Net difference posts as a single payment delta — positive (customer owes) or negative (refund due). Same audit + idempotency wrap as a standalone refund.
  • Charge to customer account (on_account tender, live 2026-05-21, v0.4.231/.232) ✓ — adds on_account to the tender method enum. Every on_account cent posts a sale_charge debit row on the customer_account_ledger (migration 043). Validation: customer must be attached to the sale; if the customer has a credit limit set (customers.account_credit_limit_cents), the new balance after the charge must not exceed it (returns 400 with a clickable link to the customer's edit modal to raise the limit). If no credit limit is set, the charge is allowed — the operator-decision default is "trust the operator who knows this customer." Pure on-account sales label as sales.on_account.complete in the audit chain; mixed-tender sales label as sales.complete with the on-account breakdown in detail_json.
  • All tender methods stub-wired to post (live 2026-05-21, v0.4.233) ✓ — previously only cash posted real transactions rows; the others (card, debit, store_credit, gift_card, check, on_account) bounced. Now every tender method posts the sale through apiSalesCreate. The Stripe Terminal integration for the card path is the only real-payment piece still unwired; the rest record the tender + complete the sale immediately.
  • Multi-source till search (live 2026-05-21, v0.4.234) ✓ — the Sales-till search input now queries customers alongside products in one round trip. Result dropdown groups by source (Products | Customers). Clicking a customer attaches them to the Till; clicking a product adds to the cart. The customers source uses the same FTS5 index from search-engine.
  • Optimistic locking on transactions-adjacent substrate (live 2026-05-21, migration 051)version column added to 11 substrate tables. transactions and transaction_lines are deliberately excluded (append-only post-post; the audit chain is the safety net for those). See data-consistency layers.
  • Cart holds — reserve-on-read inventory (live 2026-05-22, migration 052, v0.4.239) ✓ — adding a tracked variant to the Till instantly drops quantity_available on every other terminal sub-second. Holds are keyed by a per-tab till_session_id; preserved on Park / Estimate / Deposit, cleared on Charge or Clear, swept after 4 hours of idleness. See data-consistency Layer 3.
  • Parked-sale resume improvements (live 2026-05-24, v0.5.4 + v0.5.12) ✓ — parked rows filter by the ledger's date-mode (single day / range / today) so reviewing yesterday's books doesn't surface today's open parked tills (v0.5.4). Resume action clears the original parked entry the moment the cart loads (no more stale "this sale is also parked" ghost row) and stamps the resumed cart with an origin footnote pointing at the parked-row timestamp + cashier (v0.5.12). Net: parked → resume → ring → ledger shows one row, not two.
  • Customer-history ledger latency fix (live 2026-05-23, v0.5.1 + migration 054) ✓ — attaching a customer to the Sales-Till flips the ledger into customer-history mode (/api/sales/today?customer_id=X). Against the production dataset (~50K migrated transactions) this used to take 10-20s because the existing single-column indexes forced a planner choice between sorting then filtering vs. filtering then sorting. Migration 054 added a compound (customer_id, occurred_at DESC) index on transactions; the same query now returns in ~100ms. Compound indices on three more endpoints landed pre-emptively in migration 055 — see slice-2 customer-history improvements.
  • Ledger heading: date-header treatment (live 2026-05-24, v0.5.13) ✓ — the Sales ledger heading (and now the Service ledger heading) renders the active filter inline ("Today's sales", "May 15 – May 22 sales", "Jane Doe's history") instead of a separate filter pill. Single picker, single source of truth.
  • Charge button removed from bottom of Till (v0.6.4 — rolled back v0.6.16) — the v0.6.4-.6.10 series moved the Charge action onto a header icon as part of the icon-driven dual-action doctrine. That doctrine was rolled back in v0.6.11; see work-area headers for the full story.
  • Till footer: four explicit action buttons (live 2026-05-24, v0.6.16) ✓ — the footer row carries Layaway · Special Order · Estimate · Charge $X.XX. Four named buttons; no intermediate pickers. Layaway and Special Order land directly in the deposit-amount flow (skipping the older deposit-kind picker). Estimate goes straight into the delivery-method prompt. Charge opens the tender modal. Enable gates: Layaway / Special Order / Charge require lines AND a positive total; Estimate needs only lines ($0 estimates are valid quotes for in-development service packages).
  • Special Order requires a customer (live 2026-05-24, v0.6.24) ✓ — clicking Special Order with no customer attached prompts the operator with a short "Special Orders need a named customer — there's nobody to call when the parts arrive otherwise" explanation, then opens the customer picker in no-walk-in mode (the Keep walk-in option is hidden because walk-ins are exactly what's being disallowed). Once a customer is picked the deposit-amount step runs immediately; no second click required.
  • Unified work-area header on the Till (live 2026-05-24, v0.6.12) ✓ — the Till header is now the same shape as the Bucket and Slot headers: line count + + Customer pill on the left (flips to attached-customer name with detach × when filled), Clear × on the right (only visible when populated; runs the existing clearTill confirm flow). See work-area headers for the cross-surface pattern.
  • Customer lookup off the Till search bar (live 2026-05-24, v0.6.15) ✓ — the v0.4.234 multi-source pass that surfaced customer rows inside the #sales-search dropdown is reverted. The + Customer pill in the Till header is now the only inline customer-attach affordance; the search bar queries inventory only. The cross-source Cmd-K palette and the standalone Customers screen are unchanged. Removed because the in-search-dropdown customers section was confusing operators — typing a name returned a Products section AND a Customers section, and the pill already owned the same job.
  • Inline price edit on Till lines (live 2026-05-25, v0.6.33) ✓ — clicking any line's price on the Till makes it an editable text input (Excel-style cell). Enter / blur commits, Esc cancels. Shared helper window.helmStartInlinePriceEdit(cellEl, currentCents, onSave) lives near the top of the body. On the Till the cell swaps its text for a $-prefixed input pre-filled with the current dollar value; on commit the operator's value parses to cents and onSave back-calculates the unit price from the new extended total (newExtended / qty, rounded). Local-state mutation only; no server round-trip until the till is parked or charged. Editable .price cells get a dotted-underline-on-hover for discoverability; active edit shows a solid blue underline with a matching blue input border. Service-Slot lines got the same treatment in the same commit (see slice 4). Bucket lines were considered but skipped — Bucket cost is the inventory weighted-average from a joined table, not a per-line override, so editing it would ripple to every queue row + every future receive.
  • Sales page-header collapsed to one row (live 2026-05-25, v0.6.30) ✓ — the Sales tab's page header used to spend two rows of chrome: title on row 1, page-sub ("Till Open · cashier <name> · register REG-1") on row 2 — about 28 px of vertical chrome that no other tab spent. Title + page-sub now flow inline on the same row (page-sub uses its existing muted typography with margin: 0). Every tab's search bar + work-area now starts at the same Y position so cross-tab screenshots can be lined up.

Multi-terminal realtime, auto-park discipline, resume-source banner, layaway/special-order ledger (v0.6.95 → v0.6.208)

A ~70-commit run that elevated the Sales Till from a single-terminal app to a real multi-user cashier surface — terminal A's parked-sale appears on terminal B in sub-second time — and tightened the till's save/park/clear discipline so unsaved work never silently dies. The whole arc is anchored on three architectural moves:

  1. Server-side parked sales (v0.6.112, migration 062) — parked_sales table replaces per-device localStorage as the source of truth
  2. Auto-build the Sales-Till entry when a Service ticket hits Completed (v0.6.95, v0.6.174 → v0.6.177) — the cashier always has a row to resume when the customer comes to pay
  3. Clear-blocks-on-unsaved-work + auto-park 90s (v0.6.158 → v0.6.163) — Clear refuses outright on unsaved tills; auto-park kicks in after 90 s of idle so the cashier doesn't lose work to a walk-away

Server-side parked sales + multi-user broadcast (v0.6.112, migration 062)

The Till's parked sales used to live in localStorage.helm_parked_sales_v1 (per-device). On a multi-terminal shop, terminal A parking a sale was invisible to terminal B until terminal B refreshed. Migration 062 introduces the parked_sales server-side table with a soft-delete flag and a till_json blob; new endpoints GET / POST / PUT / DELETE /api/parked-sales + POST /api/parked-sales/upsert-by-ticket for the service-ticket dedupe path. Every mutation fires broadcastTopicInvalidation(env, 'parked_sales'). The Layer 2 WebSocket already in place since v0.4.238 carries the invalidation to every connected terminal; a new forward bridge in helm-query.js maps the topic invalidation to the legacy custom-event names the existing Sales-ledger / Service-ledger / Orders-bucket renderers listen for (helm:parked-sales-changed, helm:service-ledger-refresh, helm:bucket-changed). Client-side parkCurrentSale / helmCreateParkedSale / helmUpsertServiceParkedSale / helmDeleteParkedSale / consumeTillSource POST/DELETE the server first and then mirror the cache; sync-on-boot + sync-on-every-broadcast keeps every terminal coherent. See multi-user realtime for the full architecture page.

End-to-end: Service mechanic on terminal A marks ticket Completed → server upserts the parked-sale row + broadcasts → terminal B's WebSocket invalidates parked_sales → forward bridge fires helm:parked-sales-changed → Sales ledger re-renders → cashier on terminal B sees the yellow Service badge appear in real time and clicks it to charge.

Sales-Till auto-build when a ticket hits Completed (v0.6.95, v0.6.174 → v0.6.177)

The Completed-notify popup (Send-SMS-and-Notified, or Just-Mark-Complete) now upserts a Service parked sale into the Sales ledger, deduplicated by ticket_id. Yellow "Service" badge distinguishes ticket-closing rows from abandoned Sales carts. Auto-park trigger evolved: originally fired at status Completed (v0.6.174), then expanded to include Notified (v0.6.176), eventually moved to fire on the new invoice status (v0.6.193) so the auto-park fires at the moment the cashier is about to take payment — not earlier, not later. Re-marking Completed updates the existing parked-sale row in place (preserving id + parked_at) rather than creating duplicates.

  • Don't bail when ticket has no lines (v0.6.175) ✓ — early version of the auto-park failed silently on tickets with zero committed lines (free-warranty pickups, e.g.). Now creates a $0 parked sale so the cashier still has a row to click → finalize. Cashier closes it as "no charge" without any extra ticket admin.
  • Re-marking is safe (v0.6.177) ✓ — the auto-park trigger moved from Picked Up → Invoice (v0.6.193 superseded both); see slice 4 for the lifecycle move.

Clear / Park / Save discipline (v0.6.157 → v0.6.163)

  • Switch-till prompt: only when work would actually be lost (v0.6.157) ✓ — the v0.6.153 switch-prompt + Sales-tab parity gets a predicate refinement: the prompt only fires when there's genuinely unsaved work (staged lines OR draft customer OR loaded source that's been mutated). Resuming a parked sale → resuming another parked sale with no in-between edits no longer prompts.
  • Clear blocks on unsaved work; offers Save/Close as the path (v0.6.158) ✓ — the Clear × on a populated till used to wipe immediately after a confirm. Now if the till has unsaved work (lines, customer attachment, tax overrides, applied-deposit credit, cart holds), Clear refuses and surfaces a three-button prompt: Save/Close · Discard & Clear · Stay on current till. Matches the switch-till prompt shape.
  • Sales till qty stepper targets correct line (v0.6.159) ✓ — minor bug fix: +/− was incrementing the wrong line in certain re-render conditions. Now correctly targets the focused line.
  • Clear refuses outright on unsaved tills/slots (v0.6.160) ✓ — refinement of v0.6.158: rather than offering Discard & Clear as an option, Clear flat-out refuses on unsaved work. Operator MUST Save/Close (parks the sale) or undo their edits manually. Removes "I meant to undo but clicked Clear and lost everything" failure mode.
  • Sales till auto-park after 90 s idle + AUTO badge (v0.6.161) ✓ — keydown / mousedown listeners bump a 90-second timer; activity in modals (Charge, Customer picker) holds the till open until the modal closes. Resume-source tills (parked / estimate / deposit already in progress) are exempt — they only show the operator-triggered park notice. Parked-row server notes get auto_parked:idle_90s; the Sales ledger renders an inline AUTO suffix on the Parked status pill so the cashier can tell an idle auto-park from an operator-triggered park.
  • Sales till Clear button → Park (v0.6.162) ✓ — final refinement: the Clear × button is renamed/repurposed as Park. Click on a till with content auto-parks (silently, no confirm) and resets the till. Removes the "Clear vs Park" cognitive load — every operator action that resets the till preserves the work as a parked sale.
  • Fix Park failing with D1 'undefined' bind error (v0.6.163) ✓ — bug: the new server-side park path was binding undefined to a SQLite parameter slot under certain customer-detach states. Cause: client was sending undefined for customer_id instead of null. Fix: explicit null coercion before the fetch.

Resume-source meta banner (v0.6.137 → v0.6.148)

When the till loads via a Park / Estimate / Deposit resume, an inline banner at the top of the till tells the cashier where this work came from. Iterations:

  • Sales till resume-source meta banner (v0.6.137) ✓ — first cut: banner reads "Resumed from Parked sale #N · parked at HH:MM by Cashier X" / "Resumed from Estimate E-#### · saved at HH:MM" / "Resumed from Deposit #N · paid at HH:MM". Click → opens the source record.
  • Ledger-pill colour + high-contrast white text (v0.6.138) ✓ — banner background uses the same pill colour the source row has on the ledger (matching mental model); foreground white for contrast.
  • Unified pastel status palette + matching resume banner (v0.6.139) ✓ — chassis-wide pastel palette pass (no per-screen variants); resume banner uses the same tokens.
  • Deposit credit applies to till total + Charge (v0.6.140) ✓ — resuming a deposit now properly deducts the deposit amount from the running till total, so Charge shows balance owing not the original total.
  • Layaway banner: + Add Funds pill + editable notes (v0.6.141) ✓ — Layaway-resume banner gets a + Add Funds button (takes another partial payment against the same layaway) and inline-editable customer-facing notes.
  • Special-order auto-pushes lines to Products bucket (v0.6.142) ✓ — Special-Order deposit auto-creates the matching reorder rows in the Products bucket (with customer_id earmark per the existing v0.4.230 pattern). One operator action posts the deposit AND queues the reorder.
  • Cancel layaway / special-order deposits + refund (v0.6.143) ✓ — banner gets a Cancel action: refunds the deposited amount back to the customer (cash / store-credit / original-tender) and closes the layaway / special-order. Used when the customer changes their mind or the special-order can't be filled.
  • Deposit notes are inline-edit (v0.6.144) ✓ — matches the ticket-details editing doctrine (.helm-editable click-to-edit).
  • Fix TDZ on isDeposit in resume-banner render (v0.6.145) ✓ — bug fix: temporal-dead-zone error on a hoisted const.
  • Rewrite till resume-source banner from scratch (v0.6.146) ✓ — refactor after the patches accumulated; banner is now a single render function reading from a normalised tillSource shape.
  • Stop misreading deposits as 'paid in full' (v0.6.147) ✓ — bug: deposit-resume was showing the deposit amount as "paid" and zeroing the running total. Now correctly shows the deposit as a credit against the running total.
  • Drop the resume-banner Dismiss button (v0.6.148) ✓ — the operator should never want to dismiss the banner (it's the only visible cue that the till is resumed from a specific source); removed.

Layaway / Special Order Charge flow (Wave 2: v0.6.132, v0.6.149, v0.6.150)

  • Wave 2: Layaway / Special-Order Charge flow + resume (v0.6.132) ✓ — the Layaway / Special Order flow now charges THROUGH the standard Charge button (vs. its own dedicated payment popup). The till is set up with the layaway/special-order context (resume banner painted), the cashier hits Charge, the tender modal works exactly as a normal sale. Unifies the cashier's mental model.
  • Deposit notes = text input; Machine fast-charge button (v0.6.149) ✓ — deposit-amount form gets a notes input (free text) + a "Machine" fast-charge button that skips the tender step (used for card processors that take cash separately).
  • Update layaway / special-order without finalizing the sale (v0.6.150) ✓ — the cashier can take additional partial payments against a layaway without closing the sale (+ Add Funds button on the banner). Each addition writes a new deposit row + bumps the layaway's running paid total. The sale only finalizes via the Charge button when the customer is ready to take the goods.

Inline price edit polish (v0.6.164, v0.6.165)

  • Inline price edit shows discount in green + on receipt (v0.6.164) ✓ — when the cashier edits a line price BELOW the system default, the cell's price text shows in green to visually flag the discount. The receipt also prints the discount amount as a line item ("Discount applied: $-15.00") so the customer can see what changed.
  • Refund modal uses the payment-options screen (v0.6.165) ✓ — refund flow now opens the same Tender modal as the Charge flow (with payment-options pre-selected to the original tender), instead of its own dedicated refund-tender UI. Less code to maintain, consistent cashier UX.

Misc Sales / Customers polish

  • End of Day moved to My Hub (v0.6.122) ✓ — EOD cash-out left the Sales page-actions row. See slice 4 for the My Hub rename context.
  • Sales attach defensive (v0.6.205) ✓ — Sales-side customer-attach is defensive against the rare race where a ticket is invoiced + the parked-sale ledger row is clicked before the broadcast bridge has synced.
  • Resume service deposit pulls ticket lines (v0.6.207) ✓ — when the cashier resumes a Service parked sale where a deposit was taken at drop-off, the Sales-Till pulls the ticket's committed lines so the till shows the correct balance owing (line total − deposit), not the full line total.

Multi-user discipline cheat sheet

  • Every parked-sale write hits the server first; localStorage cache is a downstream mirror, not a source of truth
  • WebSocket broadcasts invalidate by topic, never by row; clients re-fetch on invalidation
  • Forward bridge maps topic invalidations to legacy custom events so the existing renderers (which predate helmQuery) keep working without rewrites
  • The same forward bridge has a re-entry guard to prevent infinite loops with the legacy bridge (legacy event → topic invalidation)
  • localStorage.helm_parked_sales_v1 is still read synchronously by render loops (the ledger render path can't be async); it's just kept in sync with server state via the sync helpers
  • Estimate-to-sale conversion (live 2026-05-18) ✓ — POST /api/sales/from-estimate/:id converts an existing transaction_type='estimate' row into a real sale: validates the estimate is still pending, copies lines + tax breakdown into a new transactions row with transaction_type='sale', marks the source estimate status='converted' with a back-pointer, runs the dispatched stock decrement (per stock_behavior), and writes audit on both rows. Wrapped in withIdempotency.
  • Sales report endpoint (live 2026-05-18) ✓ — GET /api/sales/report?period=mtd|ytd|month|year returns aggregated totals (gross, net, GST, PST, refunds, count) for the requested window. Owner / Sys Admin only. The YTD period anchors at the shop's fiscal-year-start derived from shop_config.fiscal_year_end_mmdd (migration 035; see the schema list in slice 1) — so a shop on a June-30 fiscal year sees July-1-to-today, not Jan-1-to-today. The Sales screen surfaces a YTD pill in the page-sub line driven by this endpoint.

What's not yet built

  • Stripe Terminal card processing — every tender method stub-posts a real transactions row now (v0.4.233); the Stripe-Terminal-specific Reader registration + PaymentIntent handling is the remaining piece for the card path. transactions.payment_method already accepts 'card'.
  • Receipt PDF generation — logic for receipt content is straightforward; PDF rendering needs decision (vanilla pdf-lib or Puppeteer-on-Workers)
  • Daily reconciliation cron — compares D1 transactions to Stripe charges
  • Frequent tab editability — operator-customizable Frequent tiles (via the helm-editable in-situ pattern) is its own step; today the tab is purely velocity-driven, no manual pinning

Migration from AIM

17,996 transactions from scsahd (header) + scsasld (lines):

  • scsahd.sah_pktransactions.transaction_number
  • Customer linkage via account number
  • Line items 1:1
  • Tax fields recomputed via Helm's tax-bc.js and verified against AIM totals — discrepancies flagged for owner review during dry-run

Acceptance criteria for "slice 5 done"

  • The Sales screen replaces the mockup
  • Cash sales work end-to-end with proper change calculation
  • Card sales work end-to-end with Stripe Terminal
  • Refunds (partial + full) work
  • Receipts generate as PDFs and can be printed or emailed
  • Daily reconciliation cron runs and reports any D1↔Stripe drift
  • The Frequent tab is operator-customizable in-situ

See also