Slice 7 — Rentals
The rental business: fleet management, online + in-shop bookings, pickup with signature + ID check, return with damage assessment, deposit handling.
Status: Wave 1 LIVE + operator-runnable end-to-end (v0.6.400, v0.6.401). Fleet flag + per-bike rate ladder + bookings substrate + full in-till edit + Mark Out flow + Fleet-pill stock search. Wave 2 (online booking + signature capture + damage assessment) planned.
The substrate James picked — Option A: fleet-as-variant — landed in migration 102 (v0.6.350). The substrate alone wasn't runnable end-to-end: only initial CREATE accepted customer / plan / deposit / waiver, every detail row was read-only post-booking, and flagging a bike as fleet required navigating to Inventory → Product Card. v0.6.400 and v0.6.401 lifted that ceiling so James can run a rental from booking to Mark Out without leaving the Rentals tab.
Scope
Wave 1 — Shipped:
- Fleet flag (
product_variants.is_rental_fleet) + per-fleet-bike rate ladder rentalsbooking table withreserved → out → returned(withoverdue+cancelledexits)- Rentals tab in the canonical Tab pattern (header / sub-line / ledger)
- Product Card → + Rental Fleet section: checkbox + rate-ladder editor
- Full in-till edit (v0.6.400) — every field on a booking edits in place mid-rental. The UPDATE endpoint accepts customer / plan / deposit / waiver, not just status + notes + money. A booking that started life as walk-in / full-day / no-deposit can be amended into named-customer / weekly / deposit-captured without re-creating the row
- Mark Out flow (v0.6.400) — the canonical "bike has actually left the shop" transition. Wires
reserved → outon the existing status enum, captures the actual checkout timestamp, locks in the rate snapshot - Fleet-pill stock search (v0.6.401) — top-right of the Rentals ledger. Type to find any serialised bike in stock (no need to navigate to Inventory + Product Card), click + Add, fill in the rate ladder inline, the variant is flagged
is_rental_fleet=1
Wave 2 — Planned:
- Public-site booking flow (Stripe Checkout for deposit)
- Counter pickup flow (signature + ID)
- Return flow with damage assessment + late fee
- Calendar view of bookings vs fleet availability
- Cron: daily overdue detection, SMS reminder
Schema — Option A: fleet-as-variant (migration 102, v0.6.350)
James's pick from three substrate options. Each rental bike IS one specific variant with its own pricing — no separate fleet table.
-- ---- product_variants: fleet flag + rate ladder ----
ALTER TABLE product_variants ADD COLUMN is_rental_fleet INTEGER NOT NULL DEFAULT 0;
ALTER TABLE product_variants ADD COLUMN rental_fleet_code TEXT; -- human label: "RNT-FS-01"
ALTER TABLE product_variants ADD COLUMN rental_category TEXT; -- 'ht' | 'fs' | 'e-bike' | 'kids' | 'cruiser' | 'gravel' | 'road'
ALTER TABLE product_variants ADD COLUMN rental_hourly_cents INTEGER NOT NULL DEFAULT 0;
ALTER TABLE product_variants ADD COLUMN rental_half_day_cents INTEGER NOT NULL DEFAULT 0;
ALTER TABLE product_variants ADD COLUMN rental_full_day_cents INTEGER NOT NULL DEFAULT 0;
ALTER TABLE product_variants ADD COLUMN rental_weekly_cents INTEGER NOT NULL DEFAULT 0;
ALTER TABLE product_variants ADD COLUMN rental_overdue_hourly_cents INTEGER NOT NULL DEFAULT 0;
ALTER TABLE product_variants ADD COLUMN rental_deposit_cents INTEGER NOT NULL DEFAULT 0;
ALTER TABLE product_variants ADD COLUMN rental_status TEXT;
-- 'available' | 'out' | 'reserved' | 'maintenance' | 'retired' (NULL = not fleet)
CREATE INDEX idx_pv_rental_fleet ON product_variants(is_rental_fleet) WHERE is_rental_fleet = 1;
-- ---- rentals: the booking record ----
CREATE TABLE rentals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rental_number TEXT UNIQUE, -- "RNT-2026-00042"
product_variant_id INTEGER NOT NULL REFERENCES product_variants(id),
customer_id INTEGER REFERENCES customers(id), -- nullable for walk-in cash-deposit
customer_name_walkin TEXT,
customer_phone_walkin TEXT,
plan_type TEXT NOT NULL, -- 'hourly' | 'half_day' | 'full_day' | 'weekly' | 'multi_day'
rate_cents INTEGER NOT NULL DEFAULT 0, -- snapshot of the plan rate at checkout
reserved_at TEXT,
checkout_at TEXT,
due_at TEXT,
returned_at TEXT,
status TEXT NOT NULL DEFAULT 'reserved' -- 'reserved' | 'out' | 'overdue' | 'returned' | 'cancelled'
-- (rest of the row: deposit columns, return-condition fields, audit fields)
);
Why fleet-as-variant? Three alternatives were considered:
- Option A (chosen):
is_rental_fleetflag onproduct_variants— each fleet bike is one variant; rate ladder lives on the row - Option B: Separate
rental_fleettable joining toproduct_variantsby FK — cleaner separation but doubles the storage for one-to-one data and adds a JOIN to every fleet read - Option C: New
rental_skustable outside the products model entirely — clean conceptually but de-couples rentals from inventory, breaking the "one search box across everything" experience
Option A wins on operator-side simplicity: a rental bike is an inventory item; flagging it as fleet doesn't change that. The Products search returns rental bikes alongside everything else; the Rentals tab filters to WHERE is_rental_fleet=1.
See rental lifecycle.
Endpoints
Wave 1 — live:
GET /api/rentals/fleet— list every variant flaggedis_rental_fleet=1, with rate ladder + current statusGET /api/rentals/today— today's pickups due + returns due + overdue bookingsPOST /api/rentals— create a booking (in-shop counter flow)PUT /api/rentals/:id— update / transition a bookingDELETE /api/rentals/:id— soft-cancel
Wave 2 — planned:
GET /api/rentals/availability?variant_id=&from=&to=— calendar lookupPOST /api/rentals/quote— pre-quote with pricingPOST /api/rentals/book— book + Stripe Checkout for the depositPOST /api/webhooks/stripe— handlescheckout.session.completedfor rental depositPOST /api/rentals/:id/pickup— record pickup (signature + ID)POST /api/rentals/:id/return— record return + damage assessment
UI
Wave 1 — live:
The Rentals tab is in the canonical Tab pattern (same header anatomy + sub-line + ledger shape as Sales / Service / Products / Customers). The header carries a + Fleet action (which lists actual fleet bikes from D1 — not mockup data). The ledger shows today's bookings; status filter dropdown matches the Service ledger's chassis.
The Product Card gains a + Rental Fleet section (v0.6.352): a checkbox to flag this variant as fleet + a rate-ladder editor inline (hourly / half-day / full-day / weekly + overdue + deposit). Once flagged, the variant shows up on the Rentals tab's Fleet pill and is queryable through GET /api/rentals/fleet.
Wave 2 — planned:
- Fleet view: bikes, daily/weekly rates, photo
- Calendar view: bookings overlaid on fleet
- Today: pickups due / returns due
- Booking detail: customer, dates, deposit status, return summary
- Edit-mode: fleet rows draggable for display order
What's not yet built (Wave 2+)
- Public-site online booking flow
- Stripe Checkout for deposit
- Signature capture (touch-screen on Surface tablet expected)
- Damage assessment form
- Calendar view of bookings vs fleet availability
- Cron: daily overdue detection + SMS reminder
Acceptance criteria
- A customer can book online and see "confirmed for tomorrow"
- Sales staff can complete pickup with signature + ID check stored
- Return flow handles late fees + damage assessment
- Deposit captures or refunds correctly per shop config
See also
- Rental lifecycle
- Customer entity
- Data flow diagrams — flow #3 is this slice