DEV Community

Isaiah Kim
Isaiah Kim

Posted on

Getting paid is a state-machine problem

I build a lot of things on top of one shared Core — a capabilities layer where every hard problem gets solved once and reused. The wedge I keep coming back to is the least glamorous thing a small business deals with: getting paid. Invoices go out, some get paid, some get ignored, a few get reversed weeks later. I used to think the product was the invoice. It turned out the product is everything that happens after you hit send.

Here's what I learned modeling that middle as real engineering.

How do you model an invoice that might get paid, ignored, or reversed?

Model it as a state machine, not a paid boolean. The first version of the invoices schema in Supabase had a status column with three values — draft, sent, paid — and it fell apart the first time a payment came back as a chargeback three weeks after the money had "arrived."

Why a paid boolean is a trap

A boolean encodes one transition and assumes it's terminal. But money moving is not a one-way door. A paid invoice can be disputed. A dispute can be won or lost. A lost dispute leaves you needing to recover funds you already counted as revenue. If paid is the end of your graph, you have nowhere to put any of that, so it leaks into ad-hoc columns and if statements scattered across the app.

The tell is when you find yourself writing if (invoice.paid && invoice.disputed && !invoice.recovered). That's three booleans pretending to be one state, and every new edge case adds another boolean and doubles the combinations you have to reason about.

The states that actually matter

I rewrote it as an explicit lifecycle where every value is a real place an invoice can sit, and every transition is something that actually happens in the world:

type InvoiceState =
  | 'draft'
  | 'sent'
  | 'viewed'        // opened the link, hasn't paid
  | 'overdue'       // past due date, still in chase
  | 'paid'
  | 'disputed'      // chargeback opened
  | 'dispute_won'
  | 'dispute_lost'  // funds clawed back
  | 'in_recovery'   // chasing money we already lost
  | 'recovered'
  | 'written_off';

// transitions are a lookup, not a pile of ifs
const NEXT: Record<InvoiceState, InvoiceState[]> = {
  draft:        ['sent'],
  sent:         ['viewed', 'overdue', 'paid'],
  viewed:       ['overdue', 'paid'],
  overdue:      ['paid', 'written_off'],
  paid:         ['disputed'],
  disputed:     ['dispute_won', 'dispute_lost'],
  dispute_won:  ['paid'],
  dispute_lost: ['in_recovery', 'written_off'],
  in_recovery:  ['recovered', 'written_off'],
  recovered:    [],
  dispute_won_terminal: [] as never,
  written_off:  [],
  recovered_terminal: [] as never,
};
Enter fullscreen mode Exit fullscreen mode

Once the states were explicit, a lot of downstream code got boring in the best way. The aging view — the rows that show which invoices are drifting from sent toward overdue — is just a query grouped by state and time-in-state. When I replaced an old placeholder in the Mercury workspace with the real KynthMark org avatar and invoice aging rows, the rows had something honest to render because the state model underneath them was honest.

What is a chase cadence, and how do you build one?

A chase cadence is a scheduled ladder of reminders that escalates as an invoice ages, and the thing I learned is that it should be data, not code. My first instinct was to write the follow-up logic imperatively — send at day 3, day 7, day 14, change the tone each time. That works until the second product needs a slightly different ladder and you're copy-pasting setTimeout-shaped logic.

So the cadence is rows. Each rung is a record: how many days after the due date it fires, which channel, and which tone.

[
  { "offsetDays": -2, "channel": "email", "tone": "friendly_heads_up" },
  { "offsetDays": 1,  "channel": "email", "tone": "gentle_nudge" },
  { "offsetDays": 7,  "channel": "email", "tone": "firm_reminder" },
  { "offsetDays": 14, "channel": "sms",   "tone": "direct" },
  { "offsetDays": 30, "channel": "email", "tone": "final_notice" }
]
Enter fullscreen mode Exit fullscreen mode

When I redid the cadence display in the workspace, I rendered the ladder literally as rows — one line per rung — because that's what it is in the data. The UI stopped being a diagram I hand-drew and became a view of the table. That's a small thing, but it's the through-line of the whole Core: when the model is right, the interface is a window onto it instead of a second source of truth you have to keep in sync.

The other reason cadence-as-data matters: a rung is a scheduled effect, and scheduled effects are exactly what breaks when a serverless instance dies mid-flight. If you fire these from a "run after the response" hook and the box goes away, the reminder is orphaned. The fix is to give each scheduled send a lease and run a reaper that moves anything whose lease lapsed into a terminal state — but that's a whole article on its own.

How do you handle chargebacks without special-casing every provider?

Route payments through a capability interface instead of calling the Stripe SDK directly. This is the single decision that keeps the ugly middle from metastasizing across the codebase. In the Core, apps don't import stripe. They call a payments capability, and the capability resolves to a driver based on which environment keys are present.

// apps never see the vendor — they see the capability
export function resolvePayments(): PaymentsDriver {
  if (process.env.STRIPE_SECRET_KEY) return stripeDriver();
  // more drivers slot in here by key presence
  return stubPayments(); // first-class stub, builds with zero creds
}

interface PaymentsDriver {
  charge(invoiceId: string, amount: Cents): Promise<ChargeResult>;
  onDispute(handler: (e: DisputeEvent) => Promise<void>): void;
  refund(chargeId: string): Promise<RefundResult>;
}
Enter fullscreen mode Exit fullscreen mode

The stub matters more than it looks. Because a missing key falls back to a first-class stub instead of throwing, every app in the monorepo builds and renders with no credentials set. A demo can run the entire invoice → chase → dispute → recovery flow against the stub driver, deterministically, without a real Stripe account in the loop. That's what lets the Playwright automation drive the whole thing in a real browser end to end — the money side is real code paths, just with a driver that never touches a network.

And when a chargeback lands, it enters through onDispute in exactly one place. The capability translates the vendor's dispute event into the disputed transition from my own state machine. Nothing downstream knows or cares that it was Stripe. If I ever swap providers, the state graph doesn't move.

Why is the ugly middle the actual product?

Because the invoice is a commodity and the recovery is not. Anyone can render a PDF with a total on it. The hard, valuable part is the state machine that knows an invoice is drifting overdue, the cadence ladder that escalates on its own, and the recovery path for money that already left. That middle is unglamorous, full of edge cases, and exactly where small businesses lose real money — which is why it's worth building carefully.

Framing it as engineering changed how I think about the whole thing. Once the payments capability, the invoice lifecycle, and the cadence ladder were solved once in the Core, the next product that needed to get someone paid didn't start from scratch. It snapped together from finished pieces, and I got to spend my time on the parts that were actually new. That's the quiet payoff of solving the hard thing once: building starts to feel less like building and more like composing. The ugly middle, done properly, is the part you never have to write twice.

Top comments (0)