Engineering
One gate for every send and every charge
A sandbox runs the same booking code as a live business, so every email, text and Stripe call had to be caught somewhere. We funnelled messages through one send gate and money through one Stripe gateway, then wrote static tests that fail when a new path appears. Here is how each choke point works, and the way round them that review still found.
The short version
- Every transport that can carry a business's message to a real person asks one module, lib/send-gate.ts, before it sends. A sandbox's message goes to its outbox instead.
- The gate has three answers: live, sandbox and missing. Missing is a failed send, never a live one.
- Every Stripe client is built in lib/stripe.ts, which answers a sandbox's made-up account with a simulator. Every fall-back to Bookatu's own account is fenced.
- Static tests list every transport and every call to the platform account, and fail when one is added without a gate.
- Review still found a refund that slipped past the fence when its sandbox was deleted mid-flight. The fence now refuses the simulator's own ids, whoever they belong to.
Test mode only works if a sandbox runs the real code. A sandbox booking must build the real confirmation from the real template, so a developer sees what their client would. But it must never arrive. Money is the same: the real deposit flow runs, and no card is charged. We could have put an if at every call site, but there are dozens, and the next one written would forget. So we looked for the narrowest points everything already passes through.
One gate for messages
Every transport that can put a business's message in front of a person, an email through Resend or a text or WhatsApp through Twilio, now asks the send gate first, with the business the message is from:
export async function captureIfSandbox(msg: OutgoingMessage): Promise<GateResult> {
const mode = await sendModeOf(msg.orgId);
if (mode === "missing") throw new SendGateRefused(msg.orgId);
if (mode === "live") return { captured: false };
const id = await recordOutboxMessage({
orgId: msg.orgId,
channel: msg.channel,
kind: msg.kind,
// ...the recipient, subject, bodies and the booking it was about
});
return { captured: true, id };
}A live business gets captured: false, and the caller sends exactly as before. A sandbox's message is written to its outbox, which the developer reads in the admin or through GET /api/v1/{slug}/sandbox/outbox. Each sandbox keeps its newest 500.
Missing is not live
The third answer came from our design critic. A send job can hold a business id while that sandbox is reset or expired. The first lookup answered a missing business as not a sandbox, which reads as live, and some senders fall back to the platform's own Twilio account when a business has none. Together, a deleted sandbox's text could have gone out for real. Now a missing business throws SendGateRefused, a failed send, and a database error throws too, so the caller's own failure path runs. Texts needed one more step: the provider used to read Twilio credentials and then send. It now decides first:
export async function getSmsProvider(orgId: string): Promise<SmsProvider | null> {
if (await sendsToOutbox(orgId)) return new OutboxSmsProvider(orgId);
const creds = await twilioCreds(orgId);
if (creds) return new TwilioSmsProvider(creds, orgId);
return null;
}Underneath, a trigger from migration 0186 refuses an outbox row for a live business. So even a wrong answer from the gate could only fail loudly. It could never swallow a real client's reminder.
A test that finds new transports
A gate is only as good as its coverage. tests/unit/r15-send-gate-guard.test.ts scans lib/ and app/ for every way code can reach a real inbox or phone: the Resend and Twilio endpoints, Meta's Graph API and the push services. Each file it finds must ask the gate before the send, inside the function that sends, or sit on an allowlist with a written reason. There are four, such as account mail to a Bookatu login and the expiry warning to a sandbox's own maker. A new transport anywhere fails the test until someone decides which it is.
One gateway for money
Stripe was easier, because it was already funnelled. Every Stripe client is built in lib/stripe.ts, so the API version is pinned in one place. A sandbox's account is always its made-up acct_sandbox_ id, and the gateway answers that before it imports the SDK:
// TEST MODE (release 15): a sandbox's made-up account (acct_sandbox_...) is
// answered by the payment simulator (lib/sandbox-stripe.ts). No real client
// is built for it and STRIPE_SECRET_KEY is never read for it.
if (isSandboxStripeAccount(accountId)) return createSandboxStripe(accountId);
const { default: Stripe } = await import("stripe");So the deposit, card on file, gift card, shop, refund and no-show fee paths run unchanged against a simulated account. The simulator implements only what the code calls, and anything else throws, so a method we forgot fails closed instead of quietly reaching Stripe.
Its ids are the usual prefix, then _test_sbx_ and 28 random letters and digits. It answers only for a business with both sandbox marks whose made-up account is exactly its own, and only ids it made for that business. Its errors use their own codes, never Stripe's resource_missing, so no caller's fall-back for a missing payment intent fires. Simulated events run through the real webhook handler with a fixed id per object and type, so a double click is deduplicated like a redelivery. A signed event from Stripe that concerns a sandbox is answered 200 and dropped.
Fencing the fall-backs
The other way out is Bookatu's own Stripe account, which some payment paths fall back to when a business has no connected account. Each now calls a fence first:
if (!org?.stripeAccountId) refusePlatformStripeFor(org, "a deposit refund", apt.stripePaymentIntentId);
const stripe = org?.stripeAccountId
? await forConnectedAccount(org.stripeAccountId)
: await getPlatformStripe();
await stripe.refunds.create({ payment_intent: apt.stripePaymentIntentId });tests/unit/r15-platform-stripe-fences.test.ts lists every call to getPlatformStripe, file by file, with the reason each is safe. It fails when a call is added, moved or loses its fence, and when a Stripe client is built anywhere but lib/stripe.ts.
What review still found
A final review read every path again and found a refund that got past the fence. A sandbox booking with a paid test deposit is cancelled through the MCP server while the developer, or the daily sweep, deletes the sandbox. The booking was loaded before the delete. When refundDeposit asks for the business there is none, and paymentsSimulated(null) is false. getPlatformStripe was meant as a last line, but it read only the business named in the URL, and the MCP server has none. So refunds.create would have gone to Stripe with Bookatu's live secret and a simulator id.
Two changes close it. The fence now refuses the simulator's own ids whatever the business:
export function refusePlatformStripeFor(org: PaymentsOrg | null | undefined, what: string, objectId?: string | null): void {
if (paymentsSimulated(org) || isSandboxObjectId(objectId)) {
throw new SandboxStripeUnsupported(`${what} on Bookatu's own Stripe account for a test business`);
}
}And the last line also refuses during any API call made with a test credential:
export async function getPlatformStripe(): Promise<Stripe> {
const ctx = apiContext();
if (ctx?.org?.sandbox || ctx?.credential?.livemode === false) {
throw new SandboxStripeUnsupported("Bookatu's own Stripe account during an API call to a test business");
}tests/integration/r15-final-stripe-fence.test.ts swaps the Stripe SDK for a recorder. Its four refusal tests failed on the code before the fix, and a characterization case checks that a live business with no account of its own still refunds a real id on the platform account.
Review found one more send the gate cannot cover, because it is no business's message. The button that sends a new email confirmation link mailed a Bookatu login on every press, and anyone can make a developer login for any address. The limit now lives in lib/welcome-email.ts, inside the send all three buttons go through, so they share it.
What we take from it
- Gate at the narrowest point everything already passes through, not at each caller.
- Give not found its own answer. Folded into the default, a deleted thing gets the live path.
- A gate needs a test that finds new ways round it, not only one that checks the old ones.
- Make test ids look like nothing real, then refuse them at every door.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs