DEV Community

Nabeel Hassan
Nabeel Hassan

Posted on

Five Code Paths Write the Same Subscription Row. On Purpose.

I run a small SaaS called VoiceDash. It gives voice AI agencies a white label portal they can hand to their own clients. Next.js on Vercel, Postgres behind Prisma, Stripe for billing.

The first version of the billing code had exactly one place that wrote subscription state: the Stripe webhook. That is what every tutorial shows you. It is also the version that locked a paying customer out of the product.

The failure

An agency owner picked a plan, paid, and Stripe redirected them back into the app. The layout that wraps every agency page looks for a subscription row that is active or trialing. There was none yet. So it did what it was written to do and redirected them to the plan picker.

They had a receipt from Stripe and a paywall from me.

The webhook was not lost. It was just later than the redirect, by a couple of seconds. And on preview deployments it never arrived at all, because the endpoint registered with Stripe points at production.

The bug was not the timing. The bug was my mental model. I was treating my database as the place where subscription state lives, and the webhook as the write that puts it there. Stripe is where subscription state lives. My tables are a cache of it.

Once I said that out loud, the question stopped being "why did my write not happen" and became "when do I refill this cache, and what do I show while it is cold".

Five writers, on purpose

Today, five different code paths write the same subscription state. I put every one of them there deliberately.

  1. The webhook, at /api/webhooks/stripe. It handles checkout.session.completed, customer.subscription.created, .updated, .deleted, invoice.paid and invoice.payment_failed. This is the only writer that runs with no user present, so it is the one that catches renewals, cancellations and dead cards.
  2. The checkout return page. When Stripe sends the customer back with ?session_id=..., the server component retrieves that session from the Stripe API itself and writes what it finds, before it renders anything.
  3. A client side verify call. The same page also POSTs the session id to /api/stripe/verify-session on mount. If Stripe says the checkout is complete but the subscription is not attached to it yet, that route answers 202 with retry: true, and the browser tries again up to five times, two seconds apart.
  4. The plan change route. When somebody upgrades or downgrades in place, /api/stripe/checkout swaps the subscription item in Stripe and writes the new plan in the same request. The comment I left in there is blunt: webhooks might not reach this deployment.
  5. A read time reconcile. The agency layout calls syncSubscriptionFromStripe on render. It lists the customer's subscriptions, picks the most recent one that is active, trialing, past due or unpaid, and refreshes the row.

Five writers for one row sounds like the opening of a data corruption story. It is not, and the reason is a single line in the schema.

The unique constraint is the whole trick

model WorkspaceSubscription {
  id                   String   @id @default(cuid())
  workspaceId          String
  stripeSubscriptionId String   @unique
  plan                 Plan
  status               String
  currentPeriodEnd     DateTime
}
Enter fullscreen mode Exit fullscreen mode

Every one of those five paths ends in the same call:

await prisma.workspaceSubscription.upsert({
  where: { stripeSubscriptionId: sub.id },
  create: { workspaceId, stripeSubscriptionId: sub.id, plan, billingCycle, status: sub.status, currentPeriodEnd },
  update: { plan, billingCycle, status: sub.status, currentPeriodEnd },
});
Enter fullscreen mode Exit fullscreen mode

Same key, same shape, values that the writer just read from Stripe a moment earlier. That makes each write idempotent, and it makes last write wins the correct policy rather than a scary one, because whoever wrote last also asked Stripe last. Order does not matter. Duplicates do not matter. A webhook landing four seconds after the redirect path already wrote the row is not a conflict, it is a no-op that happens to rewrite identical values.

That is the part I would go back and tell myself. Redundant writers are cheap when the write is idempotent and keyed on the external system's identifier. They are terrifying when the write is an insert keyed on your own id. I did not need to pick the one correct writer. I needed to make writing safe, and then have as many writers as I had entry points, because every entry point is another chance to be fresh.

Do not gate access on your convenience copy

The workspace row also carries a plan column. It is a denormalized copy, and it is what the limit checks read, because counting clients against a plan should not require a join to billing.

Access is not gated on it. Access is gated on the subscription row:

subscriptions: {
  where: { status: { in: ["active", "trialing"] }, currentPeriodEnd: { gt: new Date() } },
  take: 1,
}
Enter fullscreen mode Exit fullscreen mode

The plan says what you get. The subscription row says whether you get anything at all. Keeping those two questions separate is what stops a stale plan value from becoming either a free ride or a lockout.

The same reasoning quietly killed two columns. My workspace table still has a trialEndsAt and a clientSlots that defaults to 10. Nothing reads either of them. Trial days left is computed from the subscription row's currentPeriodEnd, because that is Stripe's answer rather than my copy of it. Denormalized copies of someone else's state rot the moment you stop writing them, and they rot silently, since a stale column looks exactly like a fresh one.

Throttle it, and be wrong in the right direction

Reconciling on every render means a Stripe API call on every page navigation. So the sync skips if the row was updated in the last 60 seconds. The settings page passes force: true, because that is where somebody lands right after changing something in the Stripe customer portal and expects to see it.

The failure policy matters more than the throttle. If Stripe times out or errors, the sync logs it and returns. The page renders from the database.

Sixty seconds of staleness on a plan name costs nothing. Showing a paywall to a paying customer because Stripe had a bad minute is the exact failure I already shipped once. When you cannot be both fresh and available, pick the direction where being wrong is survivable.

What I left inconsistent, deliberately

Plan limits are checked only at creation time. Creating a client counts the existing ones, compares against the plan limit and returns a 403 if you are at the cap. Nothing runs on downgrade.

So an agency on the 5 client plan that drops to the 1 client plan keeps all five dashboards. They just cannot add a sixth. I could enforce at read time and hide the extras, but that means taking a live dashboard away from someone's client, which is a way to lose a customer, not a way to upsell one.

And a real one I have not fixed: count then create is not atomic. Two simultaneous create requests on a one client plan can both pass the check. For one agency owner clicking a button in one browser tab, it has not happened. The fix is a transaction or a counter with a constraint behind it, which is a real change rather than a one line patch. I would rather write it down here than pretend the race is not in my code.

The rule I use now

When an external system owns a piece of state:

  • decide which of your tables are a cache of it, and say so out loud in the code
  • make every write idempotent and keyed on the provider's id, with a unique constraint behind it
  • then add a writer at every entry point, because each one is a chance to be fresh, not a chance to conflict
  • gate access on the record that mirrors the provider, not on your convenience copy
  • when the provider is unreachable, serve stale, never serve a paywall

Curious how other people handle this one. Do you reconcile at read time like this, run a scheduled job, or trust the webhook and eat the occasional support ticket?

Top comments (0)