Pricing
Where prices come from
An admin configures pricing on the service (or a service variant), as
pricingGroups: one group per room type or client group, each holding
rows: { duration, price }[]. The pricing editor enters each row as an explicit
rate per hour (the Rate per hour field) and stores it as duration: 60, price: <rate> — so what the admin types is exactly what the guest is charged per
hour. (duration is retained in the shape only for backward compatibility with
older, non‑1h rows.)
At booking time the guest picks a category (client group / room type), which selects that group’s rows.
Rate × hours (current model)
Each configured row is read as an hourly rate, and the guest then chooses how
many hours. In useBookingWizard.ts:
ratePerHour = round(row.price / (row.duration / 60)) // duration is 60 → ratePerHour = priceThen:
- N hours →
duration = N × 60,price = ratePerHour × N. - Whole day →
duration = close − open,price = round(ratePerHour × dayHours).
The hours picker offers 1h … maxHours (how many whole hours fit before closing)
plus Whole day. The plan section shows a live total.
activePlan = { duration, price } is the single derived value the rest of the
flow uses (time slots, review, submit).
Custom / ungrouped
For a client with no group (“Custom”), or a custom booking type, price and
duration are entered manually (customDuration, customPrice) — no rate math.
Free services
If a row’s price (hence the total) is 0, the booking is treated as free: the
payment step shows “Free — no payment needed” and paid is forced false.
Deposits / partial payment
The review step’s payment selector offers Unpaid / Deposit / Paid. A deposit
captures an amountPaid between 0 and the total; the booking is then paid: false
with a remaining balance. Later, PayConfirmModal (calendar) or the booking
drawer can collect more, and paid flips true once amountPaid ≥ totalPrice.
Revenue “collected” totals sum amountPaid, so deposits show up as real money in
hand. See dashboard-and-calendar.md.
Form ↔ booking alignment
The service pricing editor and the booking math now agree: the editor collects a
rate per hour and stores it as a duration: 60 row, and the booking flow
reads that row’s price directly as ratePerHour. There is no longer a
duration/price interpretation gap. Legacy rows whose duration ≠ 60 are still
handled correctly by the price / (duration/60) derivation.
Not yet implemented: an explicit whole‑day price override and min/max
hours per group. Whole‑day currently derives from rate × dayHours, and the
hours picker offers 1h … maxHours where maxHours is how many whole hours fit
before closing.