Skip to main content

Sales module

The POS sell screen. New Sale → attach customer → search-add → tender → receipt. Built on the same sales-3zone chassis as Customers and Products — search + today's-sales ledger on the left, the active Sales Till (cart) on the right. The transactional write path covers sales, refunds, exchanges, estimates, layaway, special-order deposits, park/resume.

Tab labelSales
Tab accent#7c3aed (purple)
Display order5 (first module in the strip, ahead of Customers at 10)
Foldersrc/modules/sales/
Migrations027_sales.sql, 028_line_discount_recording.sql, 029_return_panel.sql
Spec progress21/28 L2 processes complete at v0.8.6 baseline

What it owns

src/modules/sales/
index.ts — registers the module with the Kernel
view.ts — markup + styles for the till + ledger + modals
client.ts — browser JS (cart, tender, estimate / layaway / special-order, park/resume)
api.ts — server-side routes (/api/m/sales/*)

What it does

The till. Today's sales ledger sits on the left under a unified search (product / customer / invoice). The right pane is the active Sales Till — the cart. New Sale starts an empty cart; lines flow in from search hits or from a bulk-items palette; GST/PST preview updates live as items, discounts, and tax overrides change. Attach Customer wires the cart to a customer record (which unlocks customer discount + store-credit balance reads).

Tender. Seven payment methods with cash-change handling. A successful tender posts the sale: writes the transactions header (TRX-YYYY-NNNNNN identifier; posted_at stamped), the transaction_lines (with snapshot product data — the line stays true to what was charged even if the catalog moves), transaction_payments, transaction_taxes, transaction_discounts. Stock decrements through the movement engine — same path Products uses for receive / adjust / write-off, so the chain is consistent. Movement engine has an allowNegative flag for backorder lines so a POS sale of a zero/oversold SKU still posts.

Estimates (/estimate create/void/resume/from-estimate). Quote a job without taking money: cart → save as estimate → resume later → convert to a posted sale. Convert-to-sale (BIKE.L2-0071) wires through from-estimate.

Layaway + special-order deposits (/deposit). Take a deposit against a future sale, add more payments, cancel. Layaway and special-order share the deposit substrate; the differentiation is on the transactions.transaction_type discriminator.

Park / Resume (/parked CRUD). Persistent server-side cart JSON in parked_sales so a multi-tab operator can pause + come back without losing state. Park stays attached to the customer if one's loaded.

Refund + Return + Exchange (/refund). The return panel walks per-line:

  • Quantity to return
  • ConditionNew (restocks), Used (restocks), Damaged (does not restock)
  • Restocking fee (header-level)
  • Header reason

A pure refund nets the fee + per-line condition-driven restock. Exchange opens a fresh sale with the original customer attached and issues store credit for the return value; the pay modal pre-applies the credit (/store-credit-balance) and only collects the difference, or completes at zero if the exchange balances. Per-line condition + restocking columns land in migration 029 (transactions.restocking_fee_cents + return_reason, transaction_lines.return_condition).

Discounts — line and cart, rule engine + manual. Two tiers of discount routing:

  • 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 (the manager-threshold gate is server-authoritative in /create, plus a client-side kbCan so the chip is disabled when the operator lacks the perm). Applied discounts are recorded on transaction_discounts with a transaction_line_id + discount_id link (migration 028) so the audit shows which rule fired on which line for how much.
  • 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.

Per-line tax-category picker (BIKE.L2-0068). Each cart line carries a tax-category picker (e.g. fully_taxable / bike_pst_exempt / gst_only / exempt — see Products → Tax model). The client's taxApplies() reads applies_gst / applies_pst from /tax-categories. Override is recorded; tax_override_reason is logged on the header when present.

Status-card exemption. PST exemption for status-card holders (BC PST Bulletin 219 family of rules) is captured on transactions.tax_exempt_status_card_number and skips the PST calc accordingly.

Void (BIKE.L2-0063). sales.void permission reverses stock through the movement engine + marks the transaction voided (voided_at / voided_by_staff_id / void_reason).

Print receipt (BIKE.L2-0069). Receipt print window opens from the transaction-detail modal in the ledger; composes with kbPrint.

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: discount chips hide, cart Apply hides, line price is read-only. Enforced both 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 /spend-history, family members, account credit balance, and assigned discount rule all read into the Sales Till. The customer Till mirrors a compact spend strip below its header when a Sales cart is active (BIKE.L2-0156).
  • Products — every cart line reads from product_variants for the snapshot data; the movement engine in products/movements.ts records the sale / return; the per-line tax picker reads tax_categories from the Products substrate (migration 018).
  • Settings → Discount builder authors the rules /discount-suggest resolves; manager threshold + applies-to (sales / service / both) is honoured.
  • The audit chain — every posted sale, every refund, every void, every discount application lands in audit_events via recordMutation.

See also