Back to engineering

Architecture

Pricing by active clients: computing who actually counts

The Bookatu engineering team6 min read

How Bookatu bills by active clients instead of seats: a 90-day window, a deliberately plain in-memory de-dupe across two activity sources, and a clean split between lifecycle and counting.

Most booking software charges per seat. You add a stylist, a trainer, a second front-desk login, and the bill goes up. It's easy to meter and easy to reason about, but it punishes exactly the wrong thing: hiring. A small studio that takes on a part-time helper shouldn't pay more for the privilege.

Bookatu charges by active clients instead. Staff, services and calendars are unlimited on every plan, and we take 0% booking commission. The only number that moves your bill is how many real people you served recently. That framing is friendlier, but it pushes a hard question onto the engineering side: who, precisely, counts? A name in a CRM isn't a customer you're serving. A client who cancelled isn't either. Someone who both books a haircut and logs a workout is one person, not two. This post is about turning that fuzzy intent into a number we can defend on an invoice.

The gist

  • Bill by active clients (booked or trained in the last 90 days), never per seat.
  • An active client is a distinct customer id, de-duped across two activity sources in memory.
  • Cancelled appointments are excluded; quiet clients on your list never count.
  • Lifecycle (trial/grace/paused) is computed without the count, so hot paths stay DB-free.

Defining "active" in one sentence, then in code

The product definition lives as a string next to the pricing logic, because the marketing page, the FAQ and the billing math all have to agree on it:

ts
export const ACTIVE_CLIENT_EXPLAINER =
  "An active client is someone who booked or trained with you in the last 90 days. Quiet clients on your list never count, so you only pay for the people you are actually serving.";

Two knobs back it. The window is a single 90-day constant, which happens to match the 90-day free trial (kept as a separate constant, so the two can drift if we ever want them to). "Booked or trained" maps to two concrete activity sources: a non-cancelled appointment, or a workout logged against a client program. That second source matters because Bookatu isn't only salons. Trainers and clinics drive value through programs, not just the calendar.

Counting distinct people, not rows

The naive version is a COUNT over appointments in the window. That's wrong twice over. It counts a regular who came in five times as five, and it ignores anyone whose only recent activity was a workout log. The value metric is people, so the unit has to be a distinct customer id pulled from both sources and merged.

ts
const since = new Date(now.getTime() - WINDOW_DAYS * 86_400_000);

const [booked, trained] = await Promise.all([
  db.select({ customerId: appointments.customerId })
    .from(appointments)
    .where(and(
      eq(appointments.orgId, orgId),
      ne(appointments.status, "cancelled"), // a cancelled visit is not a served client
      gte(appointments.startAt, since),
    )),
  db.select({ customerId: clientPrograms.customerId })
    .from(programLogs)
    .innerJoin(clientPrograms, eq(programLogs.clientProgramId, clientPrograms.id))
    .where(and(eq(programLogs.orgId, orgId), gte(programLogs.createdAt, since))),
]);

const active = new Set<string>();
for (const r of booked)  active.add(r.customerId);
for (const r of trained) active.add(r.customerId);
return active.size;

The Set does the real work. Someone who both booked and trained appears in both result arrays and collapses to one id, so they're never billed twice. The ne(status, "cancelled") filter is the other decision that carries weight: a cancellation isn't a service you delivered, so it shouldn't push a tenant towards a bigger plan.

Why the de-dupe is in memory, on purpose

You could ask the database to do all of this with COUNT(DISTINCT ...) over a UNION of the two sources, and it would be a perfectly good query. We didn't, and the reason is the kind of boring you want from infrastructure: tests run against pglite, production runs against postgres-js. Pushing the de-dupe into SQL means trusting two different engines to agree on DISTINCT semantics across a UNION. Pulling the ids out and folding them into a JavaScript Set makes the merge identical everywhere. The driver only has to return rows. The counting logic has exactly one implementation.

There's a second version of the function for the billing cron, countActiveClientsByOrg, which drops the per-org filter, fetches every tenant's activity in two queries, and buckets ids into a Map<orgId, Set<customerId>>. Same idea, batched. Orgs with no activity in the window simply never appear in the map, which the callers read as zero. No row per idle tenant, no special-casing.

Keeping the count off the hot path

Here's the part that's easy to get wrong. The billing lifecycle (are you in trial, in grace, or paused?) feels like it should depend on your client count. It doesn't, and conflating the two would put a COUNT query on every page load. The pure decision function takes the count as just one input:

ts
export function evaluateAccess(input: {
  trialEndsAt: Date | null;
  subscriptionActive: boolean;
  activeClients: number;
  now: Date;
}): AccessState { /* ... */ }

The phase, the paused flag and the day counters fall out of the trial date and subscription status alone. The active-client count only decides the requiredTier, the plan we suggest. So the gate that runs on every admin page and every public booking page uses a lite path that passes activeClients: 0 and never touches the database for a count. Only the billing page, which actually renders "X active clients," pays for the real query through getOrgAccess. One screen needs the number and does the work; the rest of the app doesn't.

Mapping a count to a price is then trivial and testable in isolation. tierForClients walks the tiers cheapest-first and returns the first one whose cap fits, or the uncapped top tier:

ts
// solo <= 20, studio <= 75, pro <= 250, scale = unlimited
export function tierForClients(activeClients: number): Tier {
  return TIERS.find((t) => t.maxClients == null || activeClients <= t.maxClients)
    ?? TIERS[TIERS.length - 1];
}

What the count is allowed to do, and what it isn't

A pricing-by-usage system invites an obvious failure mode: silently upgrading someone the moment they cross a threshold. We don't. The daily cron computes everyone's count, snapshots where each tenant sits in the lifecycle, and, behind a feature flag, can email an owner a consent-based "set up billing" nudge. It never charges a card and never moves a plan on its own. The flag is checked twice on that path, so with it off no email goes out and no dedupe row is reserved. Crossing into a bigger tier is something a human agrees to, not something Set.size triggers at 2am. It matters that the money side stays just as restrained: tenants connect their own Stripe account, so payments land in their bank, not ours, and we never reach for a card we weren't given.

You only pay more as you serve more clients. Quiet clients on your list never count.

It's worth being honest about the gap, too. The count function isn't covered by its own unit test yet. The tiers, the tier selection and the access state machine all are, but the two-source merge currently rests on the de-dupe being simple enough to read. That's a fair trade for now, because the logic is one Set and two filters, but it's the first thing I'd lock down before changing what counts as activity.

What's next

The shape holds up well. Adding a third activity source (say, a paid deposit with no appointment yet) is one more select and a few more active.add calls, with the Set absorbing any overlap for free. The more interesting work is at the edges of the window. A tenant watching a client age out of the 90 days and drop their count by one wants to know why, so surfacing a per-client "last counted" date is worth more than any clever query. The number on the invoice is only trustworthy if a salon owner can reconstruct it by hand, and that, more than the SQL, is the real design constraint.

architecturebillingpricingdrizzlepostgres

Building on Bookatu?

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

Developer docs