Customers module
The Customers tab — the first module shipped on the clean-room chassis. Self-contained: every line of code that belongs to Customers lives under src/modules/customers/; nothing leaks into the Kernel or other modules.
| Tab label | Customers |
| Tab accent | #f43f5e (rose) |
| Strip position | 3rd — between Service and Purchases (position set by setTabOrder() in src/modules/index.ts, v0.7.409+) |
| Folder | src/modules/customers/ |
| Vertical canon | Slice 2 — Customers |
What it owns
src/modules/customers/
index.ts — registers the module with the Kernel
view.ts — markup + styles for the Customers screen
client.ts — browser JS (popups, ledger, filters)
api.ts — server-side routes (mutations, history)
search-domain.ts — registers Customers as a search domain with core/search
yoy.ts — year-over-year precompute reader (header pulse)
Plus its own migrations: 002_customers.sql, 003_customer_full.sql, 004_transactions.sql, 005_loyalty.sql, 006_txn_indexes.sql, 010_search_customers_fts.sql, 011_customer_family.sql, 013_customer_discounts.sql, 015_linked_accounts_and_family_members.sql, 017_yoy_summary.sql, 113_customer_search_bikes.sql (search reaches the bikes + postal code), 114_customer_bike_asset.sql (a bike is an asset, not a row hanging off a person), 115_customer_person_fields.sql (things a shop knows about a PERSON), 116_loyalty_points.sql (points that actually move — with a shop-level ON/OFF), 117_bike_transfer_reindex.sql (a transferred bike leaves the old owner's search index), 118_consignment_payouts.sql (what the shop owes a consignor once their bike sells).
Account-balance display is signed (v0.7.213). The customer card's Account row shows -$12,409.95 for money owing (negative — customer owes the shop) and $13,550.88 for credit (positive — shop owes the customer). No separate "owing" vs "credit" columns; one signed number.
Credit limit
credit_limit_cents — the most a customer may owe (customers table, present since migration 002; zero-default semantic enforced at v0.7.384, audit #1 — Customer half). Rendered on the customer card's Account section under the balance as "Credit limit". The field means exactly what it says: the maximum negative balance the customer is allowed to run. Default is $0, which means "no debt allowed" — the customer can spend on store credit against a positive balance but can never go negative. Non-zero values open a debt allowance.
The Sales store-credit / on-account tender reads this field at tender time and refuses any transaction that would drop the ledger balance below −credit_limit_cents (v0.7.385, Sales half). Server-enforced in every account-ledger write path; the client pre-warns the cashier on the tender-confirm screen so the refusal doesn't come out of nowhere.
Account management folded into the Edit-customer modal (v0.7.311). The separate Account tab / modal is retired; credit-debit + add-credit + view-ledger all live inside the Edit-customer modal now, so an operator opens one surface to change everything about a customer.
Add account credit (paid) (v0.7.309). A shop taking a customer retainer or prepaid credit adds it from the customer's Account modal: enter the amount, pick the tender, posts a customer_account_ledger row as account_credit. The customer's balance flips positive by that amount and the Sales till can spend from it at pay-time. Available on the Sales GC/Bulk/Misc pill too — see Sales module.
Receivables view (v0.7.325, audit Tier-1 #5 part 2). A #recv hash route on the Customers tab opens a Receivables view — who owes on account, sorted by amount owing. GET /receivables returns the list; a red owed pill on the tab header signals a non-zero receivable total. Selecting Receivables mutually supersedes the YoY filter (they share the header slot) — click YoY, Receivables closes; click Receivables, YoY closes.
Customer ledger mirrors the Sales ledger layout (v0.7.285/.286/.288). The customer's transaction-history ledger now uses the same row-builder as Sales — Summary (top item) column via a subquery (v0.7.285), Status column with the STATUS_PILL_PALETTE 9-pastel palette (v0.7.286), shared rowHtml (v0.7.288). Single-customer view drops the Customer column and adds a Cashier column (v0.7.262); sc-ldg-more / sc-ret-flag / sc-owing CSS ports verbatim so the hover-card + R-flag + owing-line behaviour lands identically on Customers.
What it does
The screen is a 3-pane work area: search input on the left, a swappable ledger in the middle (Recent visits when empty / the selected customer's interaction history when one's loaded), a read-only Till on the right.
Real dataset. The clean build imported the production customer dataset at launch: 3,099 customers · 2,541 bikes · 20,360 transactions. Not a stub; not a fixture; the operator has been moving real money on this build since day one.
Year-over-year header (BIKE.L2-0158). The tab leads with a churn-vs-growth pulse beside the title: total active · ▲ spending more · ▼ spending less (trailing-12-month spend vs prior-12-month). Clicking ▲ / ▼ filters the customer list to that bucket sorted by largest Δ, with a signed Δ YoY column and a Clear-filter pill; the filter sets a URL hash (#yoy=up / #yoy=down) so it's bookmarkable. A customers_yoy_summary precompute table (migration 017) is refreshed by a nightly cron (03:17 Vancouver / 17 11 * * * UTC); each refresh writes a yoy_summary.refreshed audit event. Verified live at launch: 3,099 active · ▲ 177 · ▼ 977.
Till header — name-first. The customer Till header leads with the customer's name at prominent weight, phone + email beneath it (PII-gated; hidden without customers.pii.view). Account number drops out of the header into the Identity section. Empty state reads "No customer attached".
Search — FTS5 with BM25 ranking + synonyms + family-member surfacing. Free-text across name, phone, email, with per-token prefix matching (typing je matches jeremy) and a synonym layer (mike → michael, bob → robert, etc.) registered via search-domain.ts against core/search. As of v0.7.80 (migration 031, BIKE.L2-0021), the FTS row also carries the account's linked family-member names — typing Aaron surfaces the Racher account he's a member of, not just customers named Aaron. Triggers on both sides (customers row changes and customer_family_members row changes) keep the index live. The legacy LIKE search was replaced in the v0.7.x line to scale past the dataset growth that broke the prior implementation.
The Till — read-only by design. The Till shows the customer's whole picture at a glance: contact, linked accounts, family members, marketing preferences, notes, equipment (bikes on record), loyalty, identity, account balance. No inline editing anywhere on the Till — every editable section has its own Edit pill in its header; click the pill, get a focused popup, save once.
This is the canonical no-inline-editing section-pill pattern — the Customers module is the first worked example; every future module inherits it.
Sections in display order (v1.1.0):
| Section | Edit pill opens |
|---|---|
| Contact | Name, phone, email, address |
| Linked | Account-to-account relationships (personal ↔ business; partners with separate accounts) |
| Family | In-card family members (spouse / children) — name, phone, minor flag. Rows are click-to-edit; Save updates via PUT /family-members. Relationship dropped in v1.4.1 per the shop's request (column kept, written NULL). |
| Marketing | SMS / email opt-ins and topic preferences |
| Notes | Free-form operator notes; popup-note banner surfaces on every load |
| Equipment | Bikes on record (make / model / year / serial / colour / size) |
| Loyalty | Loyalty programme enrol; points balance |
| Spend | Spend-over-time bar chart (Monthly / Weekly / Annual toggle) with fiscal-YTD-bordered bars and per-bar tooltip; /spend-history?customer_id=&bucket=… returns total posted-sale spend per period + fiscal-YTD compare. Sales-unblocked in v0.7.58. Category stacking deferred until imported sale lines map to variants. |
| Identity | DOB, ID type, ID number (gated by stronger permissions) |
The Ledger — keyset-paginated. Every interaction the shop has ever had with this customer: sales, service tickets, deposits, refunds, loyalty redemptions. Filterable by transaction type. Paginated by keyset (via kbPager) — no silent 500-row caps; a customer with 4,000 interactions can be fully scrolled.
Account credit + debit. Adjust the customer's account balance with audit-chain entries on every transition.
Merge. Combine two customer records — typical when an operator catches a duplicate after the fact. All sub-records (bikes, transactions, notes, family members, linked accounts) reattach.
Print — Print the customer's profile via kbPrint, the shared Core control extracted from this module in v1.0.0.
Lifecycle — archive / reactivate (soft-delete); the audit chain captures every transition.
Loyalty enrol — opt-in to the shop's loyalty programme; permission-gated (customers.loyalty).
PIPA export + erasure — BC's Personal Information Protection Act gives the customer the right to a full export of every record about them, and the right to ask for it to be erased. Both are first-class flows. See Handle customer erasure request and Handle data export request.
Discounts (read). Each customer can have an assigned discount that applies to Sales and/or Service. Discounts are authored in Settings → Discount builder and assigned per-customer here on the card. The Sales / Service tills (when built) read the customer's assigned discount at checkout.
Permissions
Every CTA + sensitive display in the module is gated, both client-side (to hide / disable affordances) and server-side (to refuse the mutation). The keyed permissions:
customers.read— see the tab + ledgercustomers.edit— open any per-section popup; the Edit pills don't render without itcustomers.delete— archive / soft-deletecustomers.loyalty— the Enrol button hides without this permissioncustomers.export— trigger a PIPA exportcustomers.erase— trigger a PIPA erasure
PII + A/R read-gate on screen.customers (v0.8.180-ish). Customer PII (the full ledger row, mailing address, phone, email) and account-receivable reads are additionally gated on screen.customers — a staffer without the Customers tab in their tab-visibility grant cannot read a customer's PII by hitting the API directly, even if their role holds customers.read. The shared customer picker (GET /customers/search) is deliberately exempted so the till screens (Sales / Rentals / Service) that use it to attach a customer still work for a staffer who has those tabs but not Customers; mutations keep their existing finer ROUTE_PERMS checks. Search failures now surface visibly in the picker (was silently "no match").
Permission keys + role grants are seeded in migrations/007_permissions.sql; the resolver lives in core/perms.ts; the client cache is core/perms-client.ts. See the Permissions module of Settings for how an admin actually manages role grants.
James's 47 scenarios — the Customers reboot (v0.7.1 series)
Six of James's customer scenarios failed for one reason, repeated six times: a bike was a name and a serial belonging to exactly one customer, with no story of its own. Fixing that meant the reboot below — bikes become assets, the person half gets the fields a shop actually asks about, loyalty starts moving, search reaches things it never did.
Bikes become ASSETS (mig 114 — James scenarios 12 · 13 · 14 · 17 · 22)
A bike is now a first-class asset with its own history. What that unlocks:
- Selling a bike puts it on the buyer's record with the date and when the warranty starts (scenario 12).
- A bike we did not sell goes on the same way, marked as an outside bike (scenario 13).
- A bike that changes hands moves to the new owner WITH its history, and we can still see who had it before (scenario 14).
- The record shows each bike's last service and whether it is due (scenario 17).
- A bike we sold that turns up not-ours (theft, private resale) reads cleanly against the old owner's record (scenario 22).
A transferred bike must leave the OLD owner's search index (mig 117). Discovered by walking a real case — the UPDATE trigger reindexed NEW.customer_id, which is right for every edit except the one that changes who owns the bike. Mig 117 splits the trigger so an ownership change moves the FTS row across cleanly.
Consignment payouts (mig 118 — scenario 27). "Someone selling a bike through us on consignment — their split, how they get paid, and when their item expires all sit on their record, along with what they have been paid so far." The intake half already existed on inventory_units; mig 118 adds the payout half so the consignor's record shows what they've been paid so far — and the payout is triggered "when the bike sells" (c6aec33). "A refused sale must not stay posted" (7812b0d) — the consignor's ledger doesn't accrue on a returned/voided sale.
Person fields (mig 115 — scenarios 2 · 10 · 19 · 20 · 30 · 39 · 46 · 47)
Things a shop actually asks and needs to record:
- Contact permission (scenario 2) — "we ask and write down right there whether they're happy to be emailed or texted" + how they heard about the shop.
- Preferred channel + preferred name (scenario 10) — "text me, never call" and "the name they actually go by".
- A kid's age against their bike (scenario 19) — so we know when they've probably outgrown it.
- Alternate pickup contact (scenario 20) — "husband picking up Thursday" — someone other than the owner is collecting.
- Deceased (scenario 30) — "someone who has passed away: marked once, respectfully, every message stops, the record stays."
- Birthdays + milestones (scenario 39).
- Scenarios 46 + 47 round out the person shape.
The greeting split a name on the letter s (46d6xxx) — a display-name bug that snuck through the person-fields work, caught + fixed.
Loyalty points that actually move (mig 116 — scenario 36)
customers.loyalty_points had existed all along and was written in exactly one place — set to 0 at enrolment. So no point had ever been earned or spent. A balance that never changes is worse than no balance: it is a number on the card that looks like it means something.
Mig 116 adds:
loyalty_ledger— earn / spend / adjust rows the same shape as the money ledger.loyalty_enabledonshop_config— OFF BY DEFAULT. James asked for the switch in the same breath as the feature and Swicked has no loyalty programme, so the safe default is off. Turning it on is done from Settings — the tab visibility of the Loyalty affordances follows the flag.
Search reaches the postal code + the BIKES (mig 113 — scenarios 3 · 4 · 16)
- One search box (scenario 3) — "part of a name, a few digits of a phone, part of an email, a POSTAL CODE".
- "The guy with the orange Santa Cruz" (scenario 4) — find the owner by the bike's make, model, colour or serial.
- A serial-number search as a theft check (scenario 16) — "has this bike been through our shop, and whose is it".
Mig 113 gives customer_bikes its own FTS triggers so a bike is searchable the moment it's added. The postal code is folded into the customer FTS row. Search failures no longer read as "no match" (be66b53) — the picker surfaces the failure visibly rather than dropping the query into silence.
Screens for the worklists (parts 2 + 3 of the scenario build)
- The lists a shop acts from (b942899) — part 2 built the screens for the worklists (service-due, warranty-approaching, consignment-expiring) and the bike-as-asset view.
- The record finally reaches the counter (9181a98) — part 3 wires everything the reboot built into the Customer Till so an operator working the counter sees the whole customer, not just the ledger.
- The last five scenarios (631f128) close the set.
- Three things the walk caught that no test would have (f802804) — small edge cases only a hand-walk of the 47 surfaces.
Guardrails discovered during the reboot
- The customer card had its own stale copy of the bike query (530d736). Two paths were reading
customer_bikeswith driftedWHEREclauses; unified. - The till must show the exemption before the charge, not after (ebab9ab). A PST exemption was being rendered on the receipt; now shows during the tender step so the customer sees it before they pay.
Owing — open rentals + open layaways
The customer card carries an Owing field: the running balance the customer owes across open rentals + open layaways — a booking that's out but hasn't paid the balance at hand-out yet, or a layaway with a remaining deposit ladder. Reads live from sales (open layaways) + rental_bookings (out or booked, balance still pending); zero for a customer with no open money-owed obligations. Surfaced on the card so a walk-in with an outstanding rental balance is obvious before ringing the next sale.
How it composes with the Kernel
The contract is the Module interface:
// src/modules/customers/index.ts
import { register } from "../../core/registry.js";
import { customersScreen, customersStyles } from "./view.js";
import { customersClient } from "./client.js";
import { customersApi } from "./api.js";
register({
tab: { name: "customers", label: "Customers", accent: "#f43f5e", order: 10 },
screen: () => customersStyles + customersScreen + `<script>${customersClient}</script>`,
api: customersApi,
});
The Kernel doesn't know what a customer is. It calls screen() when the operator clicks the tab; it routes API requests at /api/customers/* to customersApi. That's the entire interface.
See also
- Three strata — Kernel · Meta · Modules — the architectural shape
- Slice 2 — Customers — the vertical canon (what every bike-shop Customers tab does)
- Customer entity — the domain object
- no-inline-editing doctrine — the inline-edit chassis the Till uses
- Audit everything — every mutation lands in the chain