Building & Shipping

The First Real Payments Landed. The Dashboard Said Zero.

Brett Ridenour Brett Ridenour · Published July 2026

The first two real pay-link payments came through Freebo yesterday afternoon. Money moved. Stripe confirmed. The ledger caught them. The “amount due” on the reservation dropped to zero.

The dashboard’s Payment History card said: no payments yet.

Two payments. Correct in three places. Missing in the one place a human is going to look first.

What I expected vs what I found

I opened the dashboard expecting to see two neat rows with amounts and timestamps and a “receipt” link. There was nothing there. My first thought was the classic “am I on the right page” flinch. I was.

Then I checked the ledger tab. Both payments, correct amounts, correct times, correct references. Then I checked the reservation itself — balance zero, paid in full. Then I checked Stripe — both charges succeeded, both funds flowing to the connected account.

Everything downstream of “did the money arrive” was fine. The one thing broken was the summary card the operator would open the app to see.

The two-write architecture (and why it broke)

Freebo’s payment flow, simplified: a Stripe webhook fires on payment_intent.succeeded, the webhook service emits a ledger event, a worker posts the double-entry transaction into Formance, and everything downstream — balances, receipts, “amount due” — is derived from that ledger.

That’s the money path. It worked perfectly.

There’s also a display path. The dashboard’s Payment History card doesn’t read the ledger directly. It reads from a mirror table called payment_intents — a denormalized cache of “which PI belongs to which reservation, for which operator, for how much.” The mirror exists so a busy dashboard doesn’t have to walk Formance for every render.

The rule is: any successful payment should end up with a row in payment_intents. There are two code paths that try to write that row — belt and suspenders. The webhook-side linker writes it after a successful payment_intent.succeeded. The pay-page’s own session-creation flow also writes it, so the row exists the moment the customer starts checkout, before the webhook even lands.

Both were broken. In different ways.

Bug one: a deliberate revert that took the belt with it

A few weeks back I held a PR that was hardening webhook failure semantics — turning silent linking failures into loud ones, so a broken mirror would page instead of quietly rot. Good instinct. But the revert that put it on hold also removed the linker itself from the webhook path, not just the fail-loud wrapper around it. The belt came off in the process.

The webhook was still doing the important thing — emitting the ledger event. Money kept moving. Nothing screamed. The mirror-table write just… wasn’t happening anymore on the webhook side.

Bug two: an empty string where a null belonged

That would still have been fine if the pay-page’s own linker had been picking up the slack. It wasn’t. And this one’s the more embarrassing one.

The pay page creates a Stripe Checkout Session against the connected account, then calls linkPaymentIntent(...) to insert the mirror row. The customer ID passed in was customerId || '' — the classic “if we don’t have it, pass an empty string” fallback. Empty string is not null. It’s a value.

pay-page session creation
− src/routes/public/pay.ts (before)
await linkPaymentIntent(
paymentIntentId,
customerId || '',
accountId,
reservationId,
amount,
);
+ src/routes/public/pay.ts (after)
await linkPaymentIntent(
paymentIntentId,
customerId || null,
accountId,
reservationId,
amount,
);

The payment_intents table has a foreign key to stripe_customers on that column. null is allowed by the FK (customer optional). '' is not — it’s an actual value that has to exist in stripe_customers. It never does. Postgres refuses the insert. The linker catches the error, logs it as a warning, and returns.

Nothing tells anyone downstream that the mirror is now missing a row that the entire dashboard depends on.

The money path worked because it doesn’t care about your empty strings. The display path failed because it does.

— the reveal

The other half of bug two: the FK would have failed anyway

Even after swapping '' for null, there was a second, subtler problem: connected-account customers weren’t being registered in the stripe_customers cache table at all. When the pay page created a fresh customer via customers.create on the connected account, that ID existed in Stripe — but Freebo’s own cache had no record of it. So even a legitimate, non-empty customerId was going to trip the FK.

The fix was to call ensureStripeCustomer(...) right after creating the Stripe-side customer, so the cache row exists before anything tries to reference it. Non-fatal — if the cache write fails, the payment still goes through. The dashboard just gracefully degrades to “no history row for this one.”

The defensive ensureStripeCustomer call added to the pay-page session flow

What actually shipped

Three changes, all small:

  1. ensureStripeCustomer(...) gets called on the pay page immediately after the connected-account customer is created, so the FK target exists.
  2. customerId || '' becomes customerId || null and the function signature widens to string | null so the type system stops enabling this class of bug.
  3. The webhook-side linker comes back — but as an explicitly best-effort, non-fatal step after the ledger emit. If it fails, the webhook still returns 200 and the money path is untouched. The fail-loud hardening PR that started this whole thing stays held; that’s a bigger surgical change for a different day.

Two new regression tests: one that asserts the mirror row lands on success, one that asserts a mirror-write failure doesn’t take down the webhook. Both would have caught this. Neither existed before.

  1. several weeks ago
    Revert removes webhook-side mirror-table linker along with a fail-loud hardening PR
    The intent was to hold the hardening. The linker went with it as collateral.
  2. in production
    Pay-page mirror linker silently fails on every real payment
    Empty-string customer id + missing stripe_customers cache row = FK rejection every time. Warning log, no alert.
  3. yesterday
    First real pay-link payments land
    Ledger correct, totals correct, Payment History empty. First customer surfaces the gap.
  4. same afternoon
    Fix shipped
    ensureStripeCustomer before session creation, '' → null, webhook linker restored non-fatal, two regression tests.

The lesson I keep re-learning

Staging never caught this because staging test payments walked a different path: those customers already existed in stripe_customers from earlier fixture runs, so even the broken customerId || '' code path found what it needed by accident. A brand-new production customer, with no cache row, walked ground nobody had rehearsed.

That’s the shape of a lot of first-real-customer bugs. It isn’t that the code is untested. It’s that the test data has been quietly satisfying invariants the real world won’t. Every staging user is a returning customer. Every real customer is, once, a first-time one.

The narrower lesson — mirror tables have their own liveness contract, and it isn’t automatic — is worth writing down too. The ledger is the source of truth for money, but a summary card that reads from a cache is only correct if something is keeping the cache honest. “Something” needs to be a specific job, with a specific owner, with a specific alarm when it silently stops running. Otherwise you get exactly what I got: three places right, one place wrong, and the one place wrong is the one your customer looks at first.