---
title: "How we ship features that touch money without changing anyone's price by accident"
description: "A pricing bug is the kind you cannot take back once a card is charged. Here is the discipline we use to add discounts, staff pricing and sales to the booking flow without a single price moving until we mean it to."
url: "https://bookatu.com/en-nz/blog/shipping-money-features-safely"
language: "en"
section: "Blog"
category: "Engineering"
author: "The Bookatu engineering team"
published: "2026-07-01"
updated: "2026-07-01"
tags: ["feature flags", "money-safe code", "pricing engine design", "server-side validation", "trust boundary"]
---

# How we ship features that touch money without changing anyone's price by accident

A pricing bug is the kind you cannot take back once a card is charged. Here is the discipline we use to add discounts, staff pricing and sales to the booking flow without a single price moving until we mean it to.

> **The short version**
>
> - Anything that can move a price ships behind a flag that is off by default. The code is in production but does nothing until we turn it on.
> - The final price is worked out again on the server at checkout. A number that arrived from the browser is never trusted.
> - The discount maths lives in one small pure function with its own tests, so it can be reasoned about on its own.
> - Before a pricing feature goes live, a set of adversarial checks tries to make it overcharge, undercharge, or go below zero.

Most bugs cost you an apology. A pricing bug costs you a refund, a chargeback, and a client who no longer trusts the number on the screen. The stakes are lopsided, so the process around anything that touches money is deliberately slower than the rest of our work. This is the shape of it, using a recent feature as the example: a whole-category sale, where a salon can put every facial on sale at once.

*Diagram: The price pipeline: base price, then staff price, then category sale, then the best coupon, then the amount charged*

Diagram labels: REBUILT ON THE SERVER, EVERY BOOKING · Base price · Staff price · Category sale · Best coupon · Charged

*Every booking's price is built in the same fixed order, and the whole sum is redone on the server.*

## Off by default, in production, doing nothing

The first line of the feature is a flag. It reads an environment variable and defaults to off. That means we can merge and deploy the code long before it does anything, and every price stays byte-for-byte identical to the day before. There is no big-bang launch to be nervous about, because the launch is a separate, boring step we take once we are confident.

```ts
export const FEATURE_LIVE =
  process.env.FEATURE_LIVE === "true";

// Read at the one place it matters. When the flag is off, the branch
// is skipped entirely: no extra query, no price change, nothing.
if (FEATURE_LIVE) {
  const pct = await activeCategorySalePercent(orgId, "service", service.category);
  if (pct > 0) fullPriceCents = applyCategorySaleCents(fullPriceCents, pct);
}
```

*Diagram: The flag lifecycle: written behind a flag that is off by default, merged and deployed while still off, then after the audit passes the flag is flipped and the feature goes live*

Diagram labels: 1 · Behind a flag · off by default · 2 · Merge & deploy · still off · 3 · Audit passes · 0 money bugs · 4 · Flip the flag · the go-live · Live

*The launch is a separate, boring step. The code ships long before it does anything.*

## One function, one job

The arithmetic of the discount lives in a single pure function. It takes cents and a percent, and it returns cents. It has no database, no request, and no clock, which means it is trivial to test every edge of it in isolation: a zero sale, a normal sale, a clamp at the top, a negative that should never happen. When the maths is one small function you can hold in your head, you stop guessing about it.

```ts
export function applyCategorySaleCents(
  baseCents: number,
  salePercent: number | null | undefined,
): number {
  const base = cents(baseCents);
  const pct = Math.max(0, Math.min(100, Math.round(salePercent ?? 0)));
  if (pct === 0) return base;
  return cents((base * (100 - pct)) / 100);
}
```

## Recompute on the server, always

The browser shows a price so the client knows what they will pay, but that number is only ever a display. When a booking comes in, the server loads the real service, resolves the staff price, applies any live category sale, and then works out the discount, all from data it fetched itself. If someone edited the request on the way in, it changes nothing, because the price the card is charged was never the price the browser sent. This is the single most important habit in money code: the trust boundary is the server, and the server does the sum.

*Diagram: The browser only displays a price; the server independently loads the service, resolves the staff and sale price, applies the best coupon, and charges that amount*

Diagram labels: BROWSER · Shows a price so the client · knows what they will pay. · A display only. Never trusted. · request · SERVER · SOURCE OF TRUTH · 1 · Load the real service · 2 · Resolve staff + sale price · 3 · Apply the best coupon · 4 · Charge exactly that

*The number the browser shows is only a display. The server fetches its own data and does the sum again.*

## Try to break it before a customer does

Passing tests tell you the cases you thought of work. They do not tell you about the case you missed. So before a pricing flag goes on, we run an adversarial pass whose only job is to find a way to make the money wrong. Separate checks each take a different angle: one hunts for any path that overcharges the client, one hunts for a path that loses the business money, one checks the feature is genuinely inert when the flag is off, and one goes after rounding and boundary drift. A sale stacked on a coupon on a deposit is exactly where a fractional cent hides, so that is where we look hardest.

*Diagram: One new pricing feature is checked by four adversarial skeptics — does it overcharge, lose money, leak when the flag is off, or drift on rounding — and only a clean sweep is a go*

Diagram labels: New pricing feature · Overcharges? · Loses money? · Leaks when off? · Rounding drift? · Clean sweep → GO

*Four skeptics, each a different way to be wrong. Only a clean sweep is a GO.*

None of this is clever. It is a flag, a small function, a server that does its own maths, and a habit of trying to break your own work before it ships. Put together, it is the difference between adding a feature that touches money and losing sleep over it.
