Engineering
One pricing function, and the inputs that disagreed
Public holiday and busy-time surcharges are priced by one pure function, shared by every door that charges them. Review still found doors that showed one price and charged another, because each handed that function different inputs: another branch, another start, another person. Here is the function, the refusal that caught some of it, and the door it could not protect.
The short version
- Surcharges add a percent or an amount on chosen dates, public holidays or busy weekly times. With no rule set up, nothing changes.
- One pure function, priceSlot, prices them for every door that charges one, from the booking page to the API.
- Review still found doors that told one price and charged another. Most handed the function a different branch, start or person from the one they showed.
- An online booking that would now cost more than it was shown is refused, not charged. The API has no such guard.
- Each mismatch below was found in review before release 14 shipped, and each has a test that failed first.
An owner sets a surcharge up in Settings > Surcharges: a name the client sees, a percent or an amount, and the dates, weekly times or national public holidays of New Zealand or Australia it covers. Our post One rule, every door showed every visit ending in one function. Surcharges taught us that one function is not enough.
Eight rules, written down
The top of lib/surcharges.ts writes the money rules out. In short:
- The wall clock at the start decides. 18:00 to 21:00 takes 18:00 and 20:45, not 21:00.
- Overlapping rules never add up. The highest surcharge wins, and a tie goes to the oldest rule.
- A percent is of the price before any discount code, rounded half up, in whole units where a currency has no cents.
- A fixed amount is added once per booking or visit.
- Nothing is added to a booking priced at zero.
- Changing a rule never reprices a booking.
- With no active rule, priceSlot returns the base price and no lines.
- A holiday rule names the holiday: "Public holiday surcharge (Christmas Day)".
Our post Money math in integer cents, and the discount that vanished covers the rounding. Here is the heart of the function:
for (const rule of input.rules) {
const covered = input.lines.map(
(l, i) => clocks[i] !== null && ruleInScope(rule, l.serviceId, input.locationId) && ruleCoversMoment(rule, clocks[i]!) && l.baseCents > 0,
);
if (!covered.some(Boolean)) continue;
let perLine: number[];
if (rule.amountType === "percent") {
perLine = input.lines.map((l, i) => (covered[i] ? percentOf(l.baseCents, Math.min(MAX_SURCHARGE_PERCENT, rule.amount), input.currency) : 0));
} else {
const first = covered.indexOf(true);
const amount = fixedAmount(Math.min(MAX_FIXED_SURCHARGE, rule.amount), input.currency);
perLine = input.lines.map((_, i) => (i === first ? amount : 0));
}
const cents = perLine.reduce((a, b) => a + b, 0);
if (cents <= 0) continue;
// Strictly more wins, so on a tie the rule met first (made first) stays.
if (!best || cents > best.cents) best = { rule, perLine, cents, first: perLine.findIndex((c) => c > 0) };
}Its inputs are the rules, the lines (a service, a price and a start each), a zone, a branch and a currency. The function is pure, so it is only as right as those. Most bugs below were one input coming from somewhere other than what the client saw.
The refusal that caught some of it
Telling a surcharge before the client commits is a product rule. The booking page and the widget send back the surcharge they showed, and createBooking compares:
if (
source === "online" &&
typeof input.shownSurchargeCents === "number" &&
Number.isFinite(input.shownSurchargeCents) &&
surcharge.cents > Math.max(0, Math.round(input.shownSurchargeCents))
)
return {
ok: false,
code: "surcharge_changed",
error: "The price for this time has changed since it was shown. Please check the time again.",
};It was meant for a rule added while a page sat open. It also turned a disagreement into a refusal, not a bigger bill. That is the better failure, but a client refused on every try still cannot book. And the API sends no such figure, so there a disagreement was simply charged.
Another branch
An online booking was priced at the branch of the person it landed on. A door that names no branch shows its times with no branch's own rule. That is the API, an agent, and every door while the MULTI_LOCATION_ENABLED flag is off, its default. So once a person was kept to a branch with its own rule, the API charged it without saying so, and the booking page refused every try. Now a client's booking is priced at the branch its door named:
locationId: isStaffBooked ? assignedLocation : (input.locationId ?? null),A client's repeats had the same mistake, priced at the branch the engine filed the first visit at. bookRepeats now keeps the door's own branch, which may be none.
The New booking form had it the other way round. With Any available, the quote priced at no branch, while createBooking prices the team's booking at the branch of whoever the engine picks. A surcharge was saved with none shown and no waiver offered. The quote now asks the engine the same question:
const pricedFor = staffId ?? (named ? null : await staffBookingAssignee(org.id, { serviceId, startAt, addOnIds, partySize }));
const locationId = await staffBookingBranch(org.id, named, pricedFor);That lookup first landed inline in app/, and our tenant guard, a test that fails on new inline org-scoped queries there, went red. It lives in lib/surcharges-db.ts now.
Another start
createVisit prices each service of a visit at its own start. The booking page marked a visit's times by the visit's start only. In the test, a colour starts at 16:00 and a cut at 17:00, on a Friday with 10% from 17:00 to 21:00. The page showed nothing, createVisit surcharged the cut, and the guard refused every try. A rule kept to the cut was also shown as a percent of the whole visit. Each start now says which services each rule covers, from visitRulesAt, and the page works the figure out on those.
The visit's emails had the same blind spot: they told the first service's figures as the Total. They now tell the whole visit.
Another person
Prices per team member sit behind STAFF_PRICING_LIVE, a flag also off by default. With it on, the API's visit times priced a start at the person they placed it with, while POST /visits/book books anyone at each service's "from" price. In the test, Sam's colour is $150, Anna's is $120, and the cut is $80. The times said $253.00 and the booking was $220.00. The times now price the person asked for.
Our mobile API surcharged team bookings but answered with no money, so a receptionist could quote the plain price. The answer now carries the total and the surcharge. Waiving one now takes the money permission there and on the web form.
Another label, after a move
A booking kept its surcharge through every move, and its label names the holiday. So a Christmas Day booking moved to 23 December still said "Public holiday surcharge (Christmas Day)" in the reminder, the API and at the point of sale. A move now prices it again at the new start and keeps the smaller:
const cents = Math.max(0, Math.min(l.surchargeCents, priced.perLine[k] ?? 0));
const label = cents > 0 ? priced.total.label : null;Off a holiday, it goes. Between holidays, the new one is named and the amount is no more. Onto a holiday, nothing is added.
Two small things
The holiday is added to a rule's name in one pass, so a $ or {holiday} in the name is not read as a pattern there. A unit test pins it. A business with no rule pays one query on an indexed column, and a characterization suite checks its booking page, API, MCP tool, point of sale, email and widget are unchanged.
What we take from it
- One function is necessary, not enough. Each input has to come from what that door showed.
- Write the money rules down, and fold each fix back into them.
- A refusal beats an overcharge. Where a door cannot say what it showed, as with our API, the inputs simply have to be right.
- Test the door. Most of these tests ask a real route or action what it shows, book through it, and compare.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs