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 label | Sales |
| Tab accent | #7c3aed (purple) |
| Display order | 5 (first module in the strip, ahead of Customers at 10) |
| Folder | src/modules/sales/ |
| Migrations | 027_sales.sql, 028_line_discount_recording.sql, 029_return_panel.sql |
| Spec progress | 21/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
- Condition —
New(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, orset_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 bydiscount.override_manager(the manager-threshold gate is server-authoritative in/create, plus a client-sidekbCanso the chip is disabled when the operator lacks the perm). Applied discounts are recorded ontransaction_discountswith atransaction_line_id+discount_idlink (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_discountsrow withtransaction_line_id IS NULL.transactions.total_discount_centsrolls 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
| Permission | What it gates |
|---|---|
screen.sales | The Sales tab is visible at all |
sales.read | Today's sales ledger + transaction detail |
sales.create | Post a sale via /create |
sales.refund | Issue a refund via /refund |
sales.void | Void a posted sale via the void flow |
sales.deposit | Take layaway / special-order deposits |
sales.estimate | Create / resume estimates |
discount.apply | Apply 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_manager | Override 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_variantsfor the snapshot data; the movement engine inproducts/movements.tsrecords the sale / return; the per-line tax picker readstax_categoriesfrom the Products substrate (migration 018). - Settings → Discount builder authors the rules
/discount-suggestresolves; 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_eventsviarecordMutation.
See also
- Three strata — Kernel · Meta · Modules — the architectural shape
- No-inline-editing doctrine — the section-pill pattern (the cart's exception is the line-discount chips, which are explicit per-line affordances, not inline field editing)
- Customers module — customer discount + spend strip integration
- Products module — variant snapshot, movement engine, tax categories
- Settings module — discount builder + permissions inspector
- Keep-mounted SPA navigation — the open cart survives a tab switch to look something up