BuildJuly 8, 20265 min read

Building Pod Wallet: why the balance isn't a column

How Pickleloonies' in-progress prepaid court-fee wallet derives every balance from an append-only ledger instead of a stored number — and the trigger-ordering bug that almost shipped.

by VincentAI-drafted, edited by Vincent
A close-up of a pickleball paddle and ball resting on a court
Photo by Jon Matthews on Unsplash

Pod Wallet is the feature I've spent the most hours on without shipping anything. It's a prepaid, per-pod balance — think of it like a Starbucks card for court fees, except the app never actually stores how much money is on the card. That sentence sounds like a bug. It's the whole design.

Why I didn't want a balance column

The obvious schema is a balance numeric column on the pod-member row: deposit, increment it; charge a fee, decrement it. It's the schema everyone reaches for first, and it's the schema I almost shipped.

The problem is that a balance column is a cached answer to a question you can always just ask directly. The real source of truth is "every deposit and every charge that ever happened to this member." The column is a second copy of that answer, kept in sync by hand in every code path that touches money. One missed decrement and the number stops matching reality — nobody notices until a member disputes a charge and there's no way to prove which version was right.

So there's no column. A member's balance is sum(amount) over their rows in ledger_entries, an append-only table where positive amounts are credits and negative amounts are debits. The pod's total balance is the same sum with no member filter. "How much does this pod currently owe across all members" is the same query with the sign flipped. One table, three answers, none able to drift from each other, because there's only one of them.

This isn't a new convention, either — it's the same signed-amount pattern a debtor-reminder cron job already used to track who owed the pod money. I extended a mental model that was already load-bearing elsewhere in the app instead of inventing a new one.

What falls out for free

Once "balance" is a query instead of a stored fact, a bunch of features stop being separate features and become filters on the same table. Auto-charging a session fee at RSVP time is a trigger that inserts a debit row — and if the resulting balance would go negative, it blocks the RSVP with a clear error (WALLET_INSUFFICIENT_BALANCE, or WALLET_IN_DEBT for a pod that won't let already-negative members RSVP until they top up) instead of letting it through and hoping for the best.

Auto-refunding before a configurable cancellation cutoff is just another insert, a credit row with the opposite sign. After the cutoff, cancellation itself gets blocked instead of firing a refund nobody meant to authorize. Guests get charged the same way, debited against the inviting member — consistent with how guests already ride on their host's identity everywhere in the RSVP and guest system. None of it needed new tables, just one ledger and triggers that know when to write to it.

The mistake I almost shipped

RSVPing in fires more than one trigger — capacity check, wallet charge, waitlist logic — and I wrote the wallet-charge trigger first without thinking hard about which one Postgres actually runs first. That mattered: if the charge fires before the capacity check, you can debit someone for a seat that doesn't exist, then have the capacity trigger reject the RSVP a moment later. I caught it tracing a full-session scenario by hand, not because a test told me. The fix was being explicit about trigger order, so capacity always resolves before money moves.

The second problem only showed up under concurrency: one open spot, two members RSVPing at the same instant, both with enough balance to cover the fee. Check-then-write logic can interleave between simultaneous requests so both pass their checks before either commits — two people seated in one spot, or one charged for a seat already gone. The fix was a database-level lock so the capacity check and the wallet charge happen as one atomic unit, not two steps that can race — the same category of bug the RSVP system's waitlist promotion already had to solve.

Stripe events, once

Deposits, saved cards, Stripe Connect account status changes, auto-reload payment intents — it all arrives as webhook events that become rows in a payments table plus matching ledger_entries rows, via a small, pure, unit-testable reconciliation function with no network calls inside it, just "given this event, what rows should exist."

What actually prevents double-charging is a stripe_webhook_events idempotency table. The handler claims an event by ID before processing it, and releases the claim on failure so Stripe's automatic retry can safely reprocess it. An already-succeeded, already-claimed event just gets skipped. Retries are supposed to be safe to receive twice — the idempotency table is what makes that actually true.

What's still unfinished

Pod Wallet is built and under active testing, not live for any real pod. The schema, triggers, webhook, and reconciliation function are all written and passing their tests, but nothing here has processed a real deposit from a real member yet. I want more concurrent-RSVP scenarios exercised, and I want to watch the reconciliation function against Stripe's test-mode event stream longer before I trust it with anyone's money.

If you want the pattern this extends, the RSVP and guest system data model covers the trigger-driven waitlist promotion that Pod Wallet's locking borrows from. For the non-technical version of why court fees needed fixing in the first place, there's Pickleball fees without drama. And if you want the broader context of what shipped around the same time as this work, the May shipping log has it.

Frequently asked questions

+Why not just store a balance number on the pod or member row?

Because then you have two sources of truth — the number and the transaction history — and they will eventually disagree. Summing the ledger means the balance is always exactly what the transaction history says it is, by construction.

+What happens if a member doesn't have enough in their wallet when they RSVP?

A database trigger blocks the RSVP and returns a clear error — WALLET_INSUFFICIENT_BALANCE if they're short, WALLET_IN_DEBT if the pod itself has disabled play for members already negative. Nothing charges silently and nothing gets seated without paying.

+How does a Stripe webhook avoid double-charging on a retry?

A stripe_webhook_events table claims each event ID before processing it. If handling fails partway through, the claim is released so Stripe's automatic retry can safely reprocess it — but a successful, already-claimed event is never processed twice.

+Is Pod Wallet live yet?

No. It's built and under test, not deployed to real pods. This post describes the design as it stands right now, not a shipped feature.

+Do guests get their own wallet?

No. A guest's session fee is charged to the inviting member's ledger, the same way the rest of the guest system treats guests as attached to a host rather than independent.