Settings module
The Settings tab — the admin surface for the operator Hub. Gated behind the settings.* permission family; staff without the right grants don't see the tab at all.
| Folder | src/modules/settings/ |
| Migrations | migrations/008_identity_substrate.sql, migrations/009_staff_fields.sql, migrations/034_staff_aim_fields.sql, migrations/035_shop_config.sql, migrations/042_tab_accents.sql, migrations/043_tooltip_mode.sql, migrations/045_pos_tender_flags.sql, migrations/056_payment_tender_grid.sql, migrations/057_layaway_tile_flag.sql, migrations/060_customer_discount_permissions.sql, migrations/073_rental_deposit_policy.sql, migrations/086_rental_late_fee_policy.sql, migrations/089_rental_rules.sql, migrations/097_rental_waiver_and_holds.sql, migrations/100_rental_deposit_split.sql, migrations/102_rental_addons.sql, migrations/104_service_options.sql, migrations/108_service_bench_settings.sql, migrations/116_loyalty_points.sql, migrations/119_rental_rate_cards.sql, migrations/125_channel_screen_permission.sql, migrations/124_website_screen_permission.sql |
What it does
Users + Staff management. Add a new staff member. Set their role. Reset their PIN. Disable an account. Re-enable one. Every action lands in the audit chain. Migration 034 adds staff.external_aim_id / start_date / username — template columns every bike-shop fork inherits, so an existing AIM (or other legacy POS) roster can be mirrored in at the SAME staff ids the imported transactions already reference (v0.7.105, Swicked: 9 AIM employees id 1–9 loaded from data/staff_roster_swicked.sql; the bootstrap admin is resolved by role, not id=1, so Tom relocates cleanly to id 999 without displacing the AIM operator on id 1).
Add staff (v0.7.399). An Add staff button in the Staff & permissions header opens the same Employee modal in create mode. Captures name · initials · email · phone · role · active · idle override · optional 5-digit sign-in PIN. The PIN is validated + collision-checked at create time on the same path a PIN-set would take. A new hire inherits their role's tab visibility and permission grants — customising a specific tab, permission, or scaffolding-tooltip mode for that staffer is done afterward by reopening them (the existing edit flow, which needs a saved id to hang overrides off). Create is gated on both settings.staff.write (staff-edit) and sys.permissions.edit (role-change); server-side POST /staff. Legacy staff.role is kept coherent with the picked role, no schema change.
Editable Permissions Inspector (BIKE.L2-0155). The role × permission matrix is fully editable for holders of sys.permissions.edit (Owner + sys-admin). Click any cell to grant (●) / revoke (·) — instant save, audited, no Save button. A search box, per-area filter pills, and a "Show only granted" toggle scope the grid; sys_admin is excluded (it holds everything by break-glass).
Custom roles. "+ New role" creates a role (is_system=0). Custom roles rename via header click-to-edit and delete via a text button (blocked while any staffer is assigned by hired role OR override). Built-in roles can never be renamed or deleted.
Per-staff overrides. A Permissions tab in the Employee modal carries an override role (temporarily act as another role; the hired role stays on record — effective role resolves as override_role_id ?? role_id in getActor) plus per-permission grant / revoke / inherit layered on top of the role.
Reset Owner permissions. A sys-admin-only recovery action (gated on sys.dev_tools, which only sys_admin holds) that re-grants Owner the full baseline — every permission except sys.dev_tools.
The substrate ships in migration 016 (staff.override_role_id; Owner granted sys.permissions.edit). New audit events: perms.role.grant/.revoke/.create/.rename/.delete, perms.staff.grant/.revoke/.inherit/.override_role.set/.clear, perms.owner_reset.
Shop config (BIKE.L1-0012, migration 035, v0.7.108). The shop's identity (name, address, timezone, currency), branding, and integration secrets (Stripe key, Twilio creds, etc.) — anything that doesn't have a visible on-screen representation to edit in place. The substrate is one keyed shop_config row per fork; every module reads through the same helper.
Tab-accent picker (migration 042, v0.7.263 → .265). Owner-only shop-wide tab-accent colours — pick a colour per module tab from rainbow / pastel / colour-wheel presets; live preview row updates as the operator hovers a swatch; PUT /settings/tab-accents writes to shop_config.tab_accents_json. Every tab's --accent reads through the same helper, so a picked colour propagates to the module header rule, the Till chrome, the ledger status pills that inherit from the tab colour, and the audit-narrator's tab-slug colouring at once. The v0.7.264 data-tc attribute avoids the collision the router's data-tab was hitting.
Payment types grid — 11 tiles (v0.7.436, migration 056). Owner-only grid that shows or hides each Take Payment tile the Sales till renders. Replaces the earlier 2-toggle Point-of-sale section (mig 045's gift_cards_enabled + account_credit_enabled).
Eleven tiles the owner can flip on/off — one switch per feature:
- Nine own flags (new in mig 056):
pos_tender_mc_enabled·pos_tender_visa_enabled·pos_tender_debit_enabled·pos_tender_cash_enabled·pos_tender_card_manual_enabled·pos_tender_store_credit_enabled·pos_tender_check_enabled·pos_tender_etransfer_enabled·pos_tender_promo_enabled. - Two existing flags kept from mig 045 — Gift card + On account still read
gift_cards_enabled+account_credit_enabled(Tom's "one switch per feature" rule, 2026-07-22 — no duplicated column).
At least one tile must stay on — the grid enforces it so a shop can't shut off every way to take money.
Sales side: tenderEnabled() reads pos_tender_<key>_enabled from shop_config; NULL / absent = on — every tile stays visible for existing shops, so applying the migration alone changes no behaviour; only the owner's grid changes it. Split Payment is not in the grid — it's a compositional mode (pay across several tiles), not a tender kind, and it appears in the Sales grid whenever more than one tile is enabled.
Layaway toggle — standalone, not part of the grid (v0.7.445, migration 057). A separate shop_config.pos_layaway_enabled (DEFAULT 1, NULL/absent=on) gates the Layaway tile on the Sales Take Payment screen. Kept outside the Payment-types grid on purpose — Layaway is a mode (a transaction shape that lets a customer pay down over time), not a tender (a way money moves at the till). Same design rationale as Split Payment's exclusion, opposite mechanic: Split is a compositional consequence of enabled tenders; Layaway is an explicit shop-level policy ("does this shop offer layaway at all?"). Sales' layawayEnabled() reads the flag live; turning it off removes the Charge popup's Layaway outcome path at the till.
Per-staff tooltip mode (migration 043, v0.7.297). Each Employee modal carries a Tooltip mode control (On / Off / Heuristic, default heuristic). Heuristic runs the decaying hint system from core/scaffold.ts; picking Heuristic wipes the staffer's staff_feature_state after a confirm so the count restarts. See Scaffolding tooltips for the full engine.
Audit Trail ledger (BIKE.L2-0135)
Pinned to the bottom of the Settings tab (v0.7.382). A read-only window on the L1 audit spine — the same audit_events table (migration 040) the audit narrator + ticker writes to at mutation time. Every row shows the narrated plain-English summary the narrator stamped; the ledger renders that column verbatim, no re-narration on read.
Columns — When · Who · Kind · Event · Detail · Outcome.
Filters + search + sort + paging.
| Search | Server-side, across the summary + detail columns |
| Filters | Category, severity, outcome pills |
| Date range | Shared kbDatePicker |
| Sort | Click-to-sort on any column via kbSortable |
| Paging | Offset-paged Load older button — filters + sort act on the WHOLE history, not just the visible page, so a category=perms · outcome=denied filter answers "every denied permission action, ever" not "the last 100" |
| Access | sys.permissions.view (Owner + sys-admin); non-holders don't see the section at all |
No schema change — everything downstream of audit_events (mig 040); no new migration ships with this surface. No Core edits — the ledger is entirely inside src/modules/settings/{api,view,client}.ts. Blast radius: Settings tab only.
Rental — deposit % + late-fee + Rental Rules
The Settings > Rental section carries every shop-wide rental knob the till and the core/rental*.ts engines read. Every default reproduces the current hard-coded behaviour — applying the migrations alone changes nothing; only flipping a knob does. The knobs live on the single shop_config row (id=1).
Earlier builds had two adjacent headers, Rental and Rentals, differing only by a letter — the deposit + return rules under one, the waiver + damage-hold amounts under the other. Consolidated into one section here so an operator does not have to guess which header owns which knob.
Default rental deposit % — TWO figures now (mig 073 → mig 100). The old single rental_deposit_pct fallback is superseded by two shop knobs the till pre-fills:
rental_deposit_pct_dayof— for a rental going out today. A shop that wants to collect the full fee at hand-out sets this to 100.rental_deposit_pct_future— for a rental booked for a future date. A commitment device — take enough to make the customer turn up, settle the balance when they arrive.
The old rental_deposit_pct column is left in place and still read as the fallback so an older cached client bundle can't start charging a different percentage because the server moved on ahead of it. Deposit % is of the pre-tax fee, not the tax-inclusive total; the till still supports a per-booking override.
Late-fee policy (mig 086). Two shop-wide knobs read by core/rentallate.ts — the ONE late-fee authority used by both the Rentals /late-quote and the Sales /late-capture, so display and money can never drift:
rental_late_fee_enabled(default 1) — on/off. Off means a rental returned late bills nothing extra.rental_late_grace_minutes(default 30) — periods held past due inside this window bill nothing. Past the window, each period bills the bike's own per-periodrate_cents(daily=per-day, weekly=per-day derived, halfday=flat, hourly=per-hour). Fee is pre-tax; the till adds GST (a late fee is GST-taxable rental revenue).
Rental Rules (mig 089, v0.8.140-ish). "Rental Rules need to be settable for all contingencies" (Tom 2026-08-10). Seven knobs the shop flips to shape the billing contingencies:
- Day counting —
rental_daycount_basis:inclusive(both endpoints count, current) orovernights(hotel convention). - Minimums —
rental_min_days(1–60, default 1) floors the billed period for daily/weekly bases;rental_min_charge_cents(default 0) floors the billed amount. - Early return (read by
core/rentalearlyreturn.ts) —rental_early_return_policy=no_refund(default),prorate(refund unused whole days at the bike's daily rate), orprorate_minus_fee(prorate minus a flat fee);rental_early_return_fee_cents= the flat fee;rental_early_return_cutoff(HH:MM, nullable) = a bike back after this time on the return day still bills that day. - Cancellation (read by
core/rentalcancel.ts) —rental_cancel_policy=full_refund(default),non_refundable,refund_minus_fee, ornon_refundable_within_hours;rental_cancel_fee_cents= the flat fee;rental_cancel_window_hours(0–168) = the window for the last policy.
Waiver text (mig 097). The waiver wording used to be hard-coded in rentals/client.ts — changing a legal document meant a deploy. It lives here now as an editable shop config field. Does not rewrite history: rental_waivers stores waiver_text per signature precisely so what somebody signed keeps reading as it did on the day, regardless of later edits. Editing this changes what the next rider signs and nothing else.
Damage-hold amounts — ladder shop → group → bike (mig 097). Instead of one figure per bike on the Stock Card, holds resolve BIKE override → GROUP → SHOP default so a fleet can run off one number and only the exceptions need saying. The shop-default + group figures are set here. Amounts describe a hold placed on the terminal by hand — the app has no payment gateway; it records what the operator did rather than doing it (mig 098 stamps damage_hold_placed_at; damage_hold_auth_ref is where the terminal's auth code goes).
Rental add-ons — shop-defined list (mig 102). The waiver's helmet / water bottle / panniers used to be three hard-coded columns; adding a fourth item meant a deploy. The list is now editable here as rental_addons. requires_refusal_initials is per item so a shop that adds "Knee pads" can demand initials on a refusal for that too — and one that does not care about helmets can turn it off — without editing code. See Rentals — waivers for how the till captures the checkboxes + initials.
See Rentals module — Rental Rules for the till-side consequences.
Settings shell — accordions + full page width (v0.7.1 series)
The Settings surface itself became each section an accordion (9748b39) at full page width (4ca7afe) — the two-column layout it had was rendering long option lists narrower than they wanted to be. Section action buttons moved out of the accordion headers (c2c1e14) after an earlier removal of the inline pills was reverted (c343914 reverting 6a2c142) — the headers now carry only the label + open/close chevron; the actions sit inside the accordion body where they can't be confused for a header adornment.
Service — the Service ETC + option lists (migs 104 + 108)
Bench-model settings for the Service ETC (mig 108). The three inputs the Service ETC engine needs:
service_bench_count— how many mechanics work in parallel.service_bench_hours_per_day— the time actually spent on tickets (not clock-open hours).service_working_days_per_week.
None of these are ours to guess — hence: shop settings, not defaults. Guessing produces a date the shop stops trusting inside a week.
Service option lists (mig 104). Two pickers the Service Till asks the operator to pick were not the shop's to choose:
bike_locationwas free text — "rack 3", "Rack 3", "rack three", "back wall" were four different places to the database and one place in the workshop. A shop that knows where its racks are should say so once.urgencywas low / normal / rush hard-coded in the client. A shop wanting "Warranty" or "While-you-wait" needed a deploy.
Both are now shop-editable lists here — service_bike_locations + service_urgency_levels.
Loyalty — the ON/OFF switch (mig 116)
shop_config.loyalty_enabled — OFF by default. James asked for the switch in the same breath as the loyalty feature and Swicked has no loyalty programme, so the safe default is off. Turning it on here lights the earn/spend affordances on the Customer card + at the Sales till.
Rental rate cards (mig 119)
Rate cards keyed by rental category — the source of truth for what the Website Reserve funnel quotes and what the Rentals till charges. BIKE override → CATEGORY card → shop default cascade. Editable here so a price change lands in one place instead of one-per-bike.
Screen permissions for the new tabs
screen.website(mig 124) — see the Website tab; shipped dark for a build before this landed because the tab strip is drawn from thescreen.*family.screen.channel(mig 125) — owner-only by design; commercial (brand-level) sell-through belongs to the owner. See Channel module.
Discount builder
The Discount builder is a 3-step authoring flow for customer discounts (migrations 012-014). Each rule has a name, a value (percent or fixed amount), and an applies-to target — Sales, Service, or both. Per-department assignment is validated server-side; an operator can't accidentally hand a customer a Service discount that the Sales till would also honour.
Once authored, a discount is assigned per-customer on the customer card. The Sales / Service tills (when built) read the assigned discount at checkout. This replaces the older A/B/C pricing-tier mechanism — pricing_tier is dormant in the schema; new customers start fresh with no tier mapping.
See Customers module for how the assignment renders on the customer card.
Why the matrix is read-only
The doctrine: mutations of structural things route through specific Edit flows, not through bulk-grid editing. A bulk grid invites the operator to flip a checkbox they don't fully understand, then ship a permissions change that breaks an unrelated workflow. Read-only forces the operator into the focused Edit popup for each role / each staff override, which carries the context (which permission, what does it do, what depends on it).
This is the no-inline-editing section-pill pattern applied to permissions: the matrix is the display, the per-role popup is the editor.
Permissions
The Settings module is gated by:
settings.read— see the tabsettings.staff.write— add / edit / disable staffsettings.permissions.write— change role grantssettings.shop.write— change shop-level config
These are seeded in migrations/007_permissions.sql and resolve through core/perms.ts.
How it composes with the Kernel
Same Module interface as every other module. Self-contained: index.ts registers the module; view.ts carries the markup; client.ts is the browser JS; api.ts handles the routes at /api/settings/*.
See also
- Three strata — Kernel · Meta · Modules
- Customers module — the first business module
- Permission entity — the domain model
- Staff entity — the role grants hang off this
- Audit everything — every staff/permission mutation lands in the chain
- Audit narrator + ticker — the write side of the spine the Audit Trail ledger reads