All Posts
CampbellSoft Studios

Rebuilding the SongStitched Order State Machine

We had a state called "paid" that meant six different things. Here's why we tore it out and what we replaced it with.

SongStitched Engineering State Machines Postgres

SongStitched is a custom AI-song service we built and operate. Customers fill out an intake form, pay through Stripe, and we deliver a song made just for them. Sounds straightforward — but every order moves through a sequence of human and machine steps, and the data model behind that sequence has to be honest about where each order actually is.

For a few months it wasn’t. We had an order status called paid, and over time it grew to mean any of the following:

  • The customer paid and the order is sitting in our queue.
  • The customer paid and we’re actively writing lyrics.
  • The customer paid, we generated a preview, and we’re waiting for them to approve it.
  • The customer paid, the preview was approved, and we’re rendering the full song.
  • The customer paid and the order is complete except we forgot to flip a flag.

When one column means five things, every query that touches it has to ask a follow-up question. The admin dashboard had to inspect three other timestamps to figure out which “paid” it was looking at. The customer-facing status page had a switch statement that nobody fully trusted. And the worst part — when something stalled, we couldn’t tell whether it was stuck waiting on us or waiting on the customer.

The rewrite

We replaced paid with a real state machine. Eight states, each one meaning exactly one thing:

  1. pending_payment — Stripe checkout in flight.
  2. awaiting_review — Paid, in our queue, no human has touched it yet.
  3. queued — A producer has claimed it but hasn’t started.
  4. in_progress — Lyrics or composition actively being worked on.
  5. preview_ready — Customer has been emailed a 30s preview and we’re waiting on their feedback.
  6. revision_requested — Customer asked for changes; back to a producer.
  7. completed — Final song delivered.
  8. cancelled — Refunded or abandoned.

Each transition is explicit. You can only move forward (or to cancelled / revision_requested), and every transition writes a row to an audit log. The admin UI groups orders by state instead of trying to reconstruct it from timestamps.

What we got out of it

A handful of things we genuinely didn’t expect:

  • The “stuck order” problem disappeared. Every state now has a clear owner — us or the customer — so a stalled order is immediately answerable.
  • Email automation got simpler. Instead of “send the preview email if status is paid AND preview_url is not null AND preview_emailed_at is null,” it’s “send when status flips to preview_ready.” That’s it.
  • We caught a real bug. During the migration we found a code path that flipped status to paid before the Stripe webhook confirmed payment. It hadn’t burned us yet, but the new schema flagged it instantly because there was no longer a fuzzy state to hide in.
  • Refunds got cleaner. cancelled is its own terminus, separate from completed. Reporting that filters “completed orders only” for revenue stopped accidentally including refunded ones.

The lesson

The states in your database aren’t documentation of what’s happening — they’re permission slips for what’s allowed to happen next. If a single status value can mean five things, your code is doing the disambiguation work that the schema should be doing. That work is invisible until it isn’t.

We’re applying the same lens elsewhere now. Anywhere we have an enum that’s quietly grown vague, we ask: if a new engineer joined tomorrow, would they be able to tell what each value means without reading three other columns? If not, it’s not a status — it’s a riddle.