Back to engineering

Engineering

The card step was a page, not a rule

The Bookatu engineering team7 min read

A business can ask for a card to hold a booking. Until release 13 the only thing that asked was the card step on the booking page, so the widget, the API and waitlist claims booked straight past it. Here is how the rule moved to where bookings are written, what counts as proof a card was saved, and what review found when booking terms and questions came after it.

The short version

  • The setting Require a card to hold the booking was asked only by the booking page's card step, in the browser. Every other door booked without a card.
  • The rule now runs where bookings are written: createBooking, createVisit and the Quick swap claim.
  • Proof is a card saved in this booking's own card step and confirmed by Stripe, or a usable card on file on a booking shown to be the cardholder's. If Stripe cannot be asked, the answer is no.
  • The card step sits behind a platform switch that is off by default in the code, and a business has to turn the setting on. Without both, nothing changes.
  • Booking terms and booking questions came next, and review found gaps in both before release.

Our post One rule, every door explains why every visit ends in one function. This rule has a harder edge: its proof lives outside our database. Stripe has to have saved a card.

A rule that lived in a page

The cancellation policy has a setting, Require a card to hold the booking. With it on and a fee above zero, the booking page puts a card step before the review, and will not move on until the card is saved. That check lived in components/booking-flow.tsx:

tsx
// The flag-gated consent step: a card must be saved before continuing. Only
// reachable when consentActive, so this never affects the flag-off flow.
if (consentActive && s === consentStep) return consentVaulted;

createBooking never asked. So every other door booked a card-required service with no card: the website widget, POST /book and /visits/book on the keyless API, create_booking from a business's own agent, Magic Fill and Quick swap claims, and anyone posting straight to the page's own server action. A rule in a page is a suggestion to everything else.

The question, asked like the page asks it

lib/card-requirement.ts asks it in one place:

ts
export function cardRequiredFor(settings: unknown, priceCents: number, currency: string): boolean {
  if (!NOSHOW_CONSENT_CAPTURE_ENABLED) return false;
  return buildConsentDisclosure(settings, priceCents, currency).applies;
}

The platform switch comes first, off by default. Then the disclosure applies only when the business has turned the setting on and its worst-case fee is above zero, exactly when the page shows its card step. createBooking asks it for every client booking, never the team's own.

What counts as a saved card

Since release 12 the card step returns a signed card receipt: this business, this client, this service, this SetupIntent. The catch is timing: it is handed over when the card form starts, before anyone types a number. On its own it proves only that the step was opened.

ts
export async function cardStepSaved(orgId: string, customerId: string, setupIntentId: string): Promise<boolean> {
  if (await stepPaymentMethodOf(orgId, customerId, setupIntentId)) return true;
  try {
    const org = await getOrgById(orgId);
    if (!org?.stripeAccountId) return false;
    const stripe = await forConnectedAccount(org.stripeAccountId);
    const si = await stripe.setupIntents.retrieve(setupIntentId);
    return si?.status === "succeeded";
  } catch (e) {
    log.warn("card_requirement.setup_intent_unreadable", { orgId, error: e instanceof Error ? e.message : String(e) });
    return false;
  }
}

So the receipt has to match the booking, its consent proof has to exist, and the card has to be saved: the webhook recorded its payment method, or Stripe says the SetupIntent succeeded. Stripe unreachable is not saved. A test pins it: a card step whose SetupIntent Stripe reports as requires_payment_method books nothing.

The other way to hold is a card already on file. It counts only on a booking shown to be the cardholder's, for example signed in as them or claimed through their own link. The consent on file must also cover the policy in force. Change the policy, and an old consent no longer holds a new booking.

Doors that cannot show a card form

  • The widget hands the service to the full booking page in a new tab, with the hand-off reason card_required, as it does for a deposit.
  • The keyless API answers 409 CARD_REQUIRED_USE_BOOKING_PAGE with a bookingPageUrl.
  • Magic Fill and Quick swap claims need a usable card on file.
  • Later visits of an online series are held by the card that held the first one.

An online booking with neither a receipt nor a portal session is refused before a client record is looked up or made:

ts
const cardRequired = !isStaffBooked && cardRequiredFor(settings, service.priceCents, settings.currency);
if (
  cardRequired &&
  source === "online" &&
  !keyless &&
  !input.seriesRepeatOf &&
  !input.bookerProof?.cardReceipt &&
  !input.bookerProof?.sessionCustomerId
) {
  return { ok: false, code: CARD_REQUIRED_CODE, error: CARD_REQUIRED_MESSAGE };
}

tests/integration/r13-card-required.test.ts walks the doors on real PGlite and the real booking code, faking only Stripe, mail and the request; a companion file covers Quick swap. Its characterization cases pin what must not change: the team books by phone with no card, a free service needs none, and a business without the setting books as before.

Booking terms took a different road

Booking terms came in the same release. A business sets plain text in Settings, Booking terms, and with none set nothing changes. Its version is the SHA-256 of the text, and a booking keeps the version and the time it was accepted. The public API asks for acceptTerms: true, or answers 422 TERMS_NOT_ACCEPTED with the terms to show.

Acceptance is a tick a person makes, and it is checked at each door that shows the box, not in createBooking. That choice had a cost. Review found that Magic Fill and Quick swap claims booked a client with no terms at all. Both claim pages now show the terms with a required, unticked box, and a claim without the tick, or on terms changed since, changes nothing. tests/integration/r13-claim-terms.test.ts pins both, with characterization cases for a business with no terms.

Booking questions, and what review found

Booking questions in release 14 take the card's road: createBooking and createVisit check every answer, whoever books, and hold every client booking to its required questions. The keyless doors also check early, and answer 422 with the ids of any required question left blank. Review found two problems there.

The early check ran before the service check, for any service id of the business. A phone-only or private service with a required question answered 422 ANSWERS_REQUIRED, naming a question found nowhere else, where an unknown id answers 409 Service unavailable. The difference told the caller the hidden service exists. The early check now asks only the questions of services a client may book online, plus the business-wide ones:

ts
.where(
  and(
    eq(services.orgId, orgId),
    inArray(services.id, ids),
    eq(services.active, true),
    eq(services.bookableOnline, true),
    eq(services.isPrivate, false),
  ),
);

The second was a NUL. A text answer ending in U+0000 passed checkAnswers, then Postgres refused it in jsonb inside the booking's transaction. POST /book answered 500, booked nothing, and logged the failed query with every answer on it. readOne now strips C0 and C1 controls, except a tab and a line break, and lone surrogate halves from a text answer before it reads anything else:

ts
const UNSEEN = /[\ud800-\udbff][\udc00-\udfff]|[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\ud800-\udfff]/g;
const dropUnseen = (s: string): string => s.replace(UNSEEN, (m) => (m.length === 2 ? m : ""));

A whole surrogate pair, an emoji, matches first and is kept. There is no lookbehind, because this module also runs in the booking page's browser. Both fixes came with tests that failed on the code before them.

What we take from it

  • A check in a page protects that page. Put the rule where the write happens. A page can still ask first, to show the right step.
  • Know when your proof is issued. A receipt handed back as a step starts proves the step started.
  • When the proof lives at a payment provider, fail closed if you cannot ask.
  • An error a caller can tell apart from not found is an answer. Refuse hidden things the way you refuse missing ones.
architecturepaymentsstripebooking engineengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs