DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A failed payment still gets Premium, and the column that stops a retried Stripe event from undoing a cancellation

Munchable has one row per account that decides whether you are a paying customer. It holds a plan, a Stripe status, a customer id, a subscription id, a period end, and nothing else. No condition, no health attribute, nothing about what you scan. That boundary is written into the schema comment, because the row is the one place where the billing system and the rest of the product touch, and it is the obvious place for something to leak into later.

It is written only by the Stripe webhook. It is read by one endpoint, through the caller's own role, and you can watch it refuse an anonymous reader:

curl -i https://munchable.app/api/entitlement
HTTP/2 401
{"error":"unauthorized"}
Enter fullscreen mode Exit fullscreen mode

Three things about that row turned out to be decisions rather than plumbing.

past_due is still Premium

/** Stripe statuses that still grant Premium (grace on past_due). */
const ACTIVE_STATUSES = new Set(['active', 'trialing', 'past_due']);
Enter fullscreen mode Exit fullscreen mode

active and trialing are obvious. past_due means the renewal charge failed and Stripe is retrying it on its dunning schedule.

Treating that as "not a customer" is the intuitive reading and the wrong one. The overwhelmingly common cause of a failed renewal is an expired card, not a decision. The person has not cancelled, has not complained and in most cases does not know yet. Revoking access on the first failed charge means the first thing they learn about their expired card is that the app they pay for stopped working, which converts a payment problem into a cancellation and a support ticket.

Stripe already runs the retry schedule and already has an opinion about when to give up: the subscription moves to canceled or unpaid when retries are exhausted. So the rule here is "trust the dunning window", and the window is Stripe's, not one I invented and would have to maintain.

The honest cost of that choice is that somebody whose card has genuinely gone can keep scanning for a couple of weeks. For an app at this price, a fortnight of access is a rounding error next to the churn the strict version would cause.

The same three statuses, in three files, for three different reasons

That set appears three times in this codebase, and I left it that way deliberately:

  • the read endpoint asks may this account use Premium features?
  • the webhook's subscription handler asks should this account's plan now be premium or free?
  • the monthly contributor settlement asks does this account have a bill a discount could land on?

Three questions. Today, three identical answers. Hoisting them into one shared constant would look tidier and would be a bug waiting for the day one of them diverges, because nothing about the questions requires them to agree. If a contributor coupon could only be applied to an account in good standing, that third list loses past_due and the other two keep it, and a shared constant would make that a cross-cutting change instead of a one-line one.

A duplicated literal that three callers arrived at independently is not the same thing as a constant three callers share. The first is a coincidence you can change in one place; the second is a claim that they are the same rule.

Stripe delivers at least once, in any order

This is the one that bites, because it is invisible until it is not.

Webhook events can arrive more than once and out of order. The sequence to worry about is mundane: a subscription updates, the customer cancels, and the retry of the first event arrives after the cancellation. Handled naively, the cancelled account is Premium again, and the only trace is a row with an older state and a fresh updated_at.

The fix is one column and one clause. The row stores the created of the event that last wrote it, and the upsert only applies when the incoming event is not older than that:

function notOlderThan(lastEventCreated: Date) {
  return or(
    isNull(entitlements.lastEventCreated),
    lte(entitlements.lastEventCreated, lastEventCreated),
  );
}

await db.insert(entitlements).values(values).onConflictDoUpdate({
  target: entitlements.userId,
  set: values,
  // Ordering guard: a stale event never overwrites a newer state.
  setWhere: notOlderThan(lastEventCreated),
});
Enter fullscreen mode Exit fullscreen mode

A stale event is not an error. It updates zero rows and the handler returns 200, which is exactly what you want: Stripe's delivery was fine, the event was real, and its content was simply already superseded. Nothing to retry, nothing to alert on.

Note that this is a different mechanism from deduplication. Idempotency stops the same event being applied twice. This stops a different, older event being applied after a newer one, which no event id check can catch.

The deleted-account case: update only, never insert

When an account is erased, the subscription is cancelled at Stripe and the entitlement row is deleted inside the erasure transaction. Stripe then sends customer.subscription.deleted, as it should.

If that handler were an upsert, it would insert a row for a user who no longer exists: an orphan billing record for a deleted account, created by the very act of cleaning up. So it is an UPDATE with no insert path. For a live account it writes the cancellation. For an erased one it affects zero rows and that is the correct outcome.

The comment above it says why, and it is the kind of comment I want to find in a year: update only, never insert, because the entitlement row may have been erased by account deletion, and a late cancellation event must not be able to re-create an orphan row for a user who is gone.

Right to erasure means the row stays gone, including when a third party sends you a well-formed reason to recreate it.

No row is a real state

The last thing worth saying is about the absent case. No row means free, and free is not a degraded mode: the app works, with five scans a month across all seven conditions. So the read endpoint has an explicit branch that answers plan: 'free' for a missing row, rather than treating absence as an error or inferring it from a join.

A billing model where the unpaid state is the absence of a row has one nice property: a new account needs no billing write at all to work correctly. Sign in at munchable.app/get-started and nothing in Stripe knows you exist, which is the right amount of coupling for somebody who has not paid anybody anything.

Related: why we pay contributors with a coupon rather than a balance credit, and why the checkout return pages are the one segment with an inherited robots directive.

Top comments (1)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

The out-of-order protection via a monotonic timestamp column is the right instinct — webhook handlers are exactly where a retried event undoes a newer state, and it stays invisible until Stripe retries. The RLS angle on the same row: it is written by the webhook (a privileged role that must bypass RLS) and read by the user (a role that must not), and that split is where the real failure lives — the privileged credential. If the service-role / DB-admin key that writes this row is the one that ends up in a client bundle or a public env, RLS on the read path buys you nothing, because the attacker just uses the writer. Worth asserting explicitly: only a server-side credential can write the entitlement row, and an anonymous read returns 401 (as you show). On idempotency, key the retry on the Stripe event.id with a unique constraint so a duplicate delivery is a no-op at the table, not a branch in code. Two-account probe for the read half: github.com/cekuu35/supabase-rls-le...