Rentals module
The Rentals tab — the working surface a shop uses to book, check out, and return rental bikes. Same Sales-3-zone chassis as every till: left = Search + Ledger/Calendar, right = the booking card, money = one sanctioned handoff to the Sales till (never a second money engine).
| Tab label | Rentals |
| Folder | src/modules/rentals/ |
| Tab order | 24 (after Trades at 22, eComm at 23) |
| Tab accent | #65a30d (the one unclaimed hue in the strip) |
| Migrations | 063, 065, 066 (retired), 067 (per-unit rental config), 069 (screen.rentals), 070 (bookings + rental_booking_bikes + rental_booking_riders), 071 (checkout state — out_at / returned_at), 072 (money links), 073 (deposit policy), 074 (rate tiers), 075 (rate basis), 076 (sub-day — halfday + hourly), 077 (per-bike CC damage-hold), 078 (hold state), 079 (booking lifecycle), 082 (booking bikes + lines), 085 (internal rental repair on service tickets), 086 (late-fee policy), 087 + 088 (swap unique-active-line indices), 089 (Rental Rules — day count, minimums, early return, cancellation), 091 (inventory images), 092 (per-unit special price), 093 (rental waivers — one row per signer), 094 (waiver public token), 095 (waiver ID check), 096 (waiver ID image), 097 (waiver text in config + hold resolution shop/group/bike), 098 (damage hold placed-at), 099 (rental drafts — autosave, does not claim bikes), 100 (two deposit % — day-of + booked-ahead), 101 (waiver items — helmet/water bottle/panniers + helmet-refusal initials), 102 (rental add-ons — shop's own list) |
| Cores | core/rentallate.ts, core/rentalcancel.ts, core/rentalconvert.ts, core/rentalearlyreturn.ts, core/rentalextend.ts, plus core/live.ts heartbeat |
Doctrine — three origins, one bookings model
"The three rental origins are unified in one bookings model — walk-in (starts today), reservation (booked ahead), online (eComm feeds the same table; deferred). A rental bike is a shared inventory_unit (mig 067), the renter is a customer, and the charge rings through the Sales till — Rentals is connective tissue, not an island." (Tom, 2026-07-29.)
The consequences:
- No separate rentals customer / bike / money tables. A rental customer is a
customersrow; a rental bike is aninventory_unitsrow withis_rental=1; a rental charge is asalesrow. - A rental bike is a physical bike, not a SKU. Mig 067 moved
is_rental/rental_category/rental_rate_centsfromproducts→inventory_units("each bike is an individual, unique entity — you can't have two of the same bike because they are serialized"). The product-level fields (mig 063/066) are retired from the UI;products.is_rentalis DERIVED (set when any of a product's bikes is a rental unit) so the Sales provenance tag ("Rental") keeps working. - The Sales till is the ONE money engine. Reserve deposit, hand-out balance, cancel refund, damage charge, late-fee capture, rental→sale — every money movement is a handoff to the Sales till, never a rentals-side ledger.
Booking lifecycle
booked ─── check-out ──→ out ─── return ──→ returned
│ │ ▲
│ └── swap ────────────┤ (money-neutral, same booking)
│ └── extend ──────────┤ (+GST charge for extra time)
│ └── damage ──────────┤ (exempt charge vs. hold + optional send-to-repair)
│ └── rental → sale ───┴ (line goes to Sales till at full price, retired from booking)
└── cancel ──→ cancelled (deposit refund / keep / prorate per policy)
booked— reservation exists, bikes claimed for the date range, deposit possibly taken (mig 073 policy).out— HAND-OVER stampsout_aton the booking and on eachrental_booking_bikesrow; flips each unit'sinventory_units.status → on_rental(mig 071) so the Fleet picker + Sales sell-guard both see it as out.returned— RETURN stampsreturned_at, flips units back toin_stock.cancelled— reservation released; deposit disposition follows the Cancellation policy (see below).
Every transition is audited and the booking + bike-line row-versions both carry it.
The tab surface
LEFT (till-left) — Search + a [ Ledger | Calendar ] toggle:
- Search — customer-first search, then rental bikes. Also finds existing bookings by customer or bike (so an operator can locate today's / this-week's rentals without knowing the customer's name).
- Ledger — the picked day's fleet: Free / Booked / Out, with washed colour chips. Calendar-day whitespace on the calendar view opens the Ledger for that day.
- Calendar — a full-height month grid of the fleet; a booking bar clicks straight into the till.
- Bikes pill — opens a WIDE tile panel of every rental bike (same 80% tile-picker pattern as Sales' GC/Bulk and Service's Services picker); date-driven — a bike already on the current booking is greyed out, and availability is range-based (not single-day), so an operator picking Aug 1–4 doesn't see a bike that's booked Aug 2.
RIGHT (till-right) — the Rental Card (booking surface):
- Main contact + optional Riders (rider names, no separate customer rows).
- Rate basis picker first, then the right date input (mig 075 + 076): Daily (whole days), Weekly (whole weeks derived from day rate), Half-day (AM / PM flat, mig 076), Hourly (whole hours, mig 076). Rate basis is operator discretion at the till — each bike carries its own tier ladder in Inventory (mig 074).
- Bikes assigned — each line shows the bike + its per-period
rate_centssnapshot (so historical bookings stay stable if the shop later re-tiers). Rate snapshot lives onrental_booking_bikes(mig 070). - Running total + New Rental.
- Reserve — one button, Book & Deposit (deposit goes through the Sales till).
- Rental deposit default = shop % (Take Deposit, mig 073); deposit % is of the PRE-TAX fee, not the tax-inclusive total.
- Sales-till read-only view — when a rental deposit lands in the Sales till, the till loads ONE
Rentalline, bikes listed under it, read-only, with onlyCancelandChargeactions. The receipt header readsRENTAL(notLAYAWAY); the customer card labels it "Rental" not "Layaway".
Money handoff to the Sales till
Every money movement Rentals initiates is a Sales-till operation. The seven arcs:
| Trigger | Sales-till operation | Migration / core |
|---|---|---|
| Reserve | Book & Deposit — rental deposit as its own tender kind (non-layaway) | mig 070 + 073 (deposit % from shop_config) |
| Check-out | Same-day check-out collects the full fee at hand-out; a reserved booking collects the balance (fee − deposit taken). GST-only tax (rental_fee_gst_only, PST-exempt — that's the P0 that got fixed 2026-08-04) | mig 071 + 072 |
| Return | Releases the CC damage-hold | mig 077 + 078 |
| Cancel | Refund deposit / keep as exempt liquidated damages / prorate per policy | core/rentalcancel.ts + mig 089 |
| Damage | Exempt sale charged against the CC hold; offers "send to repair" | mig 077 + 085 (internal service ticket) |
| Late return | Auto-extend at bike's own per-period rate, past a grace window; GST-taxable | core/rentallate.ts + mig 086 |
| Rental → sale | Hand the rented bike to the Sales till as a full-price sale; retire the line from the live booking, exclude sold lines everywhere swapped_out is excluded | core/rentalconvert.ts + mig 088 |
| Extend | Live-quote modal on a live rental; hard-stop on booking conflict; GST charge for extra time; late fee advances the due date (fixes late-then-extend double-charge) | core/rentalextend.ts |
Damage-hold — the CC authorization arc
inventory_units.rental_damage_hold_cents (mig 077, NULL = no hold) is the per-bike CC pre-authorization amount placed at HAND-OUT (before the bikes leave). A booking's hold = SUM of its bikes' holds. Life:
- Hand-out — hold recorded on the booking; shown in the till.
- Return, clean + on time — hold released.
- Return, damage — offer clean-release or charge-against-hold; the charge is an exempt-tax sale that captures part/full of the hold. Immediately after, offer "send to repair" — pulls the bike into an internal service ticket (
service_tickets.is_internal=1,inventory_unit_id+rental_booking_idset, mig 085). While the ticket is OPEN the rentals fleet query excludes the unit (a flag resolved by ticket completion); completing the ticket auto-returns the bike to the fleet. - Late return — the extra rental billing goes through the Sales till (see Late-fee), not the hold.
The recorded-only mode is deliberate for now — a real processor pre-auth is wired later.
Late-fee — one authority, core/rentallate.ts
"The late fee needs to be in the Rental settings." (Tom, 2026-08-01.) Model = auto-extend: a rental returned late keeps billing the bike's own per-period rate for each period held past due, past a grace window.
- Two shop-wide knobs on
shop_config(mig 086) —rental_late_fee_enabled(default 1),rental_late_grace_minutes(default 30). - Both the Rentals
GET /late-quote(display) and the SalesPOST /late-capture(money charge) call the same core (core/rentallate.ts) — one authority, never two drifting copies. - Fee is pre-tax; the caller adds GST (a late fee is GST-taxable rental revenue, per the rental_fee_gst_only rule).
- Per-period rate is
rental_booking_bikes.rate_cents— the effective rate for the booking's chosen basis (daily=per-day, weekly=per-day derived from the week rate, halfday=flat, hourly=per-hour). Late fee =SUM(rate_cents) × periods_late. - Late fee advances the due date (fixes late-then-extend double-charge).
Rental Rules — settable contingencies (mig 089)
Settings > Rental carries the shop-wide rental-billing knobs. Every default reproduces the current hard-coded behaviour — nothing changes until a shop flips a knob.
Day counting:
rental_daycount_basis—inclusive(Aug 1 → Aug 4 = 4 days, current) orovernights(hotel convention, Aug 1 → Aug 4 = 3 nights).
Minimums:
rental_min_days— floor on the billed period (daily/weekly bases), 1–60, default 1.rental_min_charge_cents— floor on the billed amount, default 0.
Early return (core/rentalearlyreturn.ts):
rental_early_return_policy—no_refund(default),prorate(refund unused whole days at bike's day rate), orprorate_minus_fee(prorate minus a flat early-return fee).rental_early_return_fee_cents— the flat fee forprorate_minus_fee.rental_early_return_cutoff(HH:MM, nullable) — a bike back after this time on the return day still bills that day. NULL = no cutoff.
Cancellation (core/rentalcancel.ts):
rental_cancel_policy—full_refund(default),non_refundable,refund_minus_fee,non_refundable_within_hours.rental_cancel_fee_cents— flat fee forrefund_minus_fee.rental_cancel_window_hours— 0–168 hours fornon_refundable_within_hours.
Swap a bike mid-rental
Money-neutral. The Swap bike button reuses the Fleet picker in single-select replace mode (the picker keeps its replace <bike> title). The old line's line_status flips to swapped_out; the new line takes its place. Backstopped by a partial unique index on rental_booking_bikes(booking_id, unit_id) WHERE line_status <> 'swapped_out' (mig 087; mig 088 widens the exclusion to also cover sold once rental→sale lands), so a concurrent duplicate /swap can't insert a second active line for the same bike (which would double-bill it in every SUM(rate_cents) money query). Swapped-out bikes stop reading as reserved (a bug fixed alongside).
No-show forfeit
A booking that never checks out: keep the reservation deposit as exempt liquidated damages — transaction_taxes is cleared so the exempt row carries no GST (mig 072 money link + 33814a7 fix).
Live heartbeat + cross-tab freshness
Rentals rides the shared kbLive heartbeat (core/live.ts, BIKE.L1-0021) — calendar / ledger / fleet update live across every open browser, without polling collisions. Same cadence My Hub uses; a hand-out at one terminal appears in the fleet query at another within one beat.
Cross-module hooks
- Sales till — reads a rental as ONE
Rentalline (bikes listed under it), read-only,CancelandChargeactions only. A rental in the till shows the header clear-dot and the till Esc clears the view. The receipt / customer card reads "Rental" not "Layaway". - Customers card — new Owing field: balance owed on open rentals + open layaways. Search failures now surface visibly in the picker (was silently "no match").
- Inventory card — rental designation + rate tiers (hourly / half-day / weekly, mig 074) + per-bike damage-hold amount (mig 077) all live on the card. The Fleet picker reads
inventory_unitsdirectly. - Service — an internal ticket owns the damaged bike while it's out of the fleet (mig 085); completing the ticket auto-returns it.
Permissions
screen.rentals(mig 069) — see the Rentals tab. Also honoured by the shared customer picker (GET /customers/search) so a rentals-only staffer can attach a customer without holdingscreen.customers.
v0.7.1 — the Fleet / List / Calendar three-lens surface
The Rentals tab opens on the Calendar (RENT-005, 7975f5d). The old [ Ledger | Calendar ] two-way toggle + a modal-triggering Fleet N button became a real three-way segmented view — Fleet | List | Calendar — with the fleet modal deleted and its bikes rendered in the same panel the other two lenses use (8b50f62). The Fleet tab count agrees with the tiles under it (8c1bf34); a bike removed from the till or a cleared till both refresh the fleet count live (e8794a8, 8b77b15). The Fleet reads as a ledger — rows with small inline photos rather than the earlier tile grid (ce15494); tap a bike to start the rental (bd0111b) — no separate + Bike button.
Weekly-basis UX polish: a Weekly rental shows the WEEK charge, not week ÷ 7 per day (88da027, RENT-001); picking Weekly fills the return date automatically and the basis note stops describing the old maths (81a3997, RENT-002).
Calendar — utilisation, booking state, weather
The Calendar cell for a day carries the utilisation (bikes out / fleet size), the booking state (booked / out / mixed), the bike count, and a hover card that opens the day's bookings without a click (beb7e31). Pending rentals — bookings without a paid deposit — appear on the calendar too (not only the paid ones); every day carries its weather (2057b05). A rental nobody has paid for no longer hovers as settled — the hover card distinguishes reserved-but-unpaid from booked-and-paid (1d8298d).
Rental drafts — autosave without claiming bikes (mig 099)
Autosave once you start building it, but no commit bikes until booked (Tom, 2026-08-16). A rental in progress used to live only in browser memory; a refresh, closed tab, or sleeping machine lost the customer, riders, bikes, dates AND any signatures already collected.
- A draft is NOT a booking, and deliberately not a status on
rental_bookings— every query in the app that readsrental_bookingsassumes a real booking (fleet availability, calendar, day ledger, holds list), so adraftstatus would let a half-built rental hold bikes.rental_draftsis its own table and nothing here touches availability. Bikes are claimed at Deposit, exactly as stock moves at Charge in Sales; a double-book is caught there, where the booking is actually written. - One draft per operator — the till is per-person, so a second draft would just be a lost first draft.
payloadis the till state as JSON. Licence images are excluded from the payload by the client (at up to 640 KB they have no business in a row rewritten every couple of seconds).
Two deposit percentages — day-of vs. booked-ahead (mig 100)
"James wants the option of taking the whole rental fee before the bikes leave. One percentage could not express that, because the two cases are not the same question." (Tom, via James, 2026-08-17.)
shop_config.rental_deposit_pct_dayof— a rental going out today is money you can collect in full while the customer is standing there; the shop may want 100%.shop_config.rental_deposit_pct_future— a rental booked for a future date is a commitment device; take enough to make the customer turn up, settle the balance when they arrive.- The old
rental_deposit_pctcolumn is LEFT IN PLACE and still read as the fallback — an older cached client bundle must not start charging a different percentage because the server moved on ahead of it. Both new columns seed from the old figure, so nothing changes on the day the migration lands. - A full-price rental no longer flags itself as taking a deposit (ff8a01f) — a 100%-collected rental is a paid rental, not a deposit followed by a phantom balance.
Deposit split — records WHY (mig 100 + 16c1339)
Keeping part of a deposit at cancellation now records the reason and states the split plainly on the receipt + customer card — no more silent "kept some". The Cancellation policy engine (core/rentalcancel.ts, mig 089) already returned the verdict; this closes the loop on the human-readable record of it.
Overdue — pinned + refused
- Overdue rentals pin to the current day on the Ledger; the ledger heading renames to Current Rentals to reflect what's actually up (87b3add).
- An overdue bike is no longer offered for rent — the fleet picker excludes it (6b1fbbf).
- The extend-conflict check treats a still-out rental as occupying its bike (63aa6b6) — a bike that's overdue can't be booked out from under the current renter via an extend.
- Refuse selling a bike still OUT on a rental (91e9152, RENT-#17) — the Inventory sell-guard extends to rental-out state.
- Refuse retiring a rental bike still OUT (160f78f, RENT-#20) — the Product Card retire action blocks while the bike is on a live rental.
- A rental that never comes back can be written off without pretending it returned (c1e603a) — a new terminal status distinct from
returned.
Waivers — one row per person, signable on tablet or mouse (migs 093-097, 101)
"Produce a waiver for each person renting … signable on a tablet or with a mouse." (Tom, 2026-08-13.)
- ONE ROW PER PERSON PER BOOKING (
rental_waivers). The main contact and every rider each sign for themselves — a waiver signed by the billing customer does not cover the friend riding the second bike. - Signature = base64 PNG on the row, stored the same proven way as inventory / announcement images.
waiver_textis stored PER SIGNATURE, not referenced — what someone signed must stay readable exactly as it was on the day, even after the shop edits its wording. A waiver that silently changes after signing is worth nothing. Editing the shop's waiver text (mig 097) changes what the next rider signs and nothing else.- ID check + ID image (mig 095 + 096) —
id_number,id_dob, and an optional ID-image PNG per signer. - Public signing token (mig 094) — a per-signer token lets a tablet or a link handed to a rider open just their waiver, without leaking the booking or another rider's row.
- Minor (
is_minor=1) requires a guardian signature the same way a helmet refusal requires the rider's own initials (see below).
Items received on the waiver (mig 101 → 102)
"A checkbox per signer for helmet, water bottle and panniers, in the ID check box on the waiver — not the legal text above it." (Tom, 2026-08-17.)
- Each signer's row records what gear went out with them — a family of four on one booking can each make a different call on the helmet.
item_helmetDEFAULTS TO 1 (given) — the waiver text itself says "I will wear a helmet", so the exception that needs proof is declining one, not accepting it. Water bottle and panniers default to 0 (optional extras the operator ticks on when actually handed over).- A helmet refusal needs the rider's own initials (
helmet_refused_initials) — declining is a liability fact, not a preference. The initials field appears only then; bothcaptureStep()in the client and the server refuse to move on without it (the same way a minor can't sign without a guardian). - Add-ons became the shop's own list (mig 102,
rental_addons). The three hard-coded columns became a shop-editable list, editable in Settings > Rentals.requires_refusal_initialsis per item — declining a helmet still needs initials; declining a water bottle does not. A shop that adds "Knee pads" can demand initials for that too, without anybody editing code.
Damage-hold — resolution ladder shop → group → bike (mig 097)
The per-bike figure previously sat on the Stock Card (mig 077). It moves to a resolution ladder — BIKE → GROUP → SHOP default — so a fleet can be run off one number and only the exceptions need saying. Rationale: "a number typed onto a bike months earlier is not a policy, and it was invisible once removed." The shop-default + group amounts live in Settings > Rentals; a bike-level override still lives on the Stock Card.
rental_bookings.damage_hold_placed_at (mig 098) stamps when the operator placed the hold on the terminal. rental_bookings.damage_hold_auth_ref is where the terminal's authorization code goes — the app has no payment gateway; it records what the operator did rather than doing it. Card captures on a rental now record a real payment method (f06ea34), so the hold reconciles against a concrete tender kind at return.
Per-bike Group + per-bike special price
- Per-bike special price (mig 092,
inventory_units.special_price_cents) — "a fleet bike is not worth the SKU's retail — it has been ridden — so THIS serial carries its own price" (Tom, 2026-08-13). NULL = no special price, use the SKU's. Set in the "This unit only" box and shown on the Stock Card unit row. Cents like every money column. - Per-bike rental earnings + history (afb5d94) — see Inventory — Rental earnings & history accordion.
Group-rental — till lines each named ALL the bikes (RENT-#18)
A booking with several bikes used to display each till line as Rental with the same generic label — the operator could not tell one line from another. Each till line now names ALL the bikes on that rental (695f6a9), so a Cancel or Charge on a specific line is unambiguous.
Cart-line lock fix — was freezing every line (v0.0.872, 35d834c)
The Sales till used to freeze every line in a cart that contained a rental because the read-only rule tested "cart has a rental" instead of "this line IS the rental line". Add a helmet or a lock alongside a rental and it could not be removed, reduced, or re-taxed — the only way out was Cancel. Fixed: the lock is scoped to the rental line itself; the helmet + lock stay editable.
Customer picker no longer follows to another tab (a4b5706)
A rentals-side customer picker opened, then switching to another tab left the picker rendered over the new tab's chrome. The picker now unmounts on tab-change.
Rate cards — priced per CATEGORY, overridden per bike (mig 119 → 120 → 121)
"Rental and demo pricing belongs to a CATEGORY, not to a bike" (Tom, 2026-08-28). Under the old per-unit model, five bikes already carried three different numbers for the same thing — two City e-bikes at $100 and a third at $99, with nothing making them agree. With a hundred bikes it would be unmanageable.
Mig 119 introduces rental_rate_cards keyed by category; mig 120 reconciles the vocabulary to what the app already speaks (ht_mtn, fs_mtb, ebike_city, ebike_mtn — the app's rental_category set) after mig 119's first pass invented mtb_hardtail / mtb_full_sus from the website's headings. Mig 121 flagged the hardtails + full-suspension bikes into the actual fleet — before this only the e-bikes carried is_rental=1 despite the site advertising three categories.
Resolution: BIKE override → CATEGORY card → shop default. A one-off exception still lives on the bike; policy lives on the card. The website quotes the same cards — one number, one source, no drift between what the site advertises and what the till charges (see Website — rate cards).
Fleet fix (dd39795): the rate-card join was landing twice in one query and not at all in another, so the Fleet view was reading two different sets of numbers depending on which pane rendered it. Both paths now go through the same join.
Unit description + fit on the Stock Card (mig 123)
"Tell about this bike, and say what size it fits; the data belongs in the Rental section of the Inventory item." (Tom.) The site listed a rental bike as "C3 - SZ2 - Brown - 2026" — the shop's shorthand, meaningless to a customer. Mig 123 adds a description + fit blurb per rental unit, authored on the Inventory Stock Card, rendered on both the Rentals till (so an operator can read the customer what the bike is) and the Website tile.
Website bookings — marked, with nothing paid (c882ad6)
A booking created from the Website Reserve funnel lands in the Rentals tab marked as a website booking with nothing paid — the shop takes payment when the rider shows up. The calendar + ledger show the pending row alongside real deposits; nothing about it hits the fleet-availability query until the operator turns it into a paid booking at hand-out.
Money formatting — the comma (abe7842)
Rentals money surfaces (Ledger totals, Fleet + Calendar hover cards, deposit slips) now go through the shared kbFmt.money — a four-figure amount reads $1,234.00 instead of $1234.00. Money without a comma is the classic silent-failure class: it does not raise an error; it just reads wrong.
The ZZ test catalog is gone (mig 107)
The ZZ- SKUs + ZZSER- units were the fixture bikes built to walk the Rentals scenarios (12 products, 12 variants, 18 units). They were pulled from the fleet earlier (is_rental=0) but still sat in the catalog — findable in Inventory + Sales searches, indistinguishable from real stock. Mig 107 retires them from every operator surface.
See also
- Sales module — the money engine every rental transacts through
- Inventory module — the bike card is where a bike becomes rentable (
is_rental, tiers, damage hold) - Settings module — the Rental Rules + late-fee + deposit-policy knobs
- Service module — internal repair tickets for damaged rental bikes
- Live-refresh heartbeat (
core/live.ts) — the shared cadence