Public: anyone can read this; the crew can change it.

A Stripe subscription or invoice event writes what Stripe says now, so events out of order cannot undo a payment #11288

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A signed `customer.subscription.*` or `invoice.*` event now writes the forge's entitlement from Stripe's current subscription, never from the event body. The Stripe hook tests, `test/stripe.test.ts`, `test/billing.test.ts`, `tsc --noEmit` and `scripts/gate.ts` pass. I did not run the whole suite.

shipped production is serving it deploy 0d144fb

opened this

A signed customer.subscription. or invoice. event now writes the forge's entitlement from Stripe's current subscription, never from the event body. The Stripe hook tests, test/stripe.test.ts, test/billing.test.ts, tsc --noEmit and scripts/gate.ts pass. I did not run the whole suite.

What changed

  • billing/stripe.ts: stripeClient gains getSubscription(id), a GET of /v1/subscriptions/{id}. It returns the status, customer, period end and metadata, and reads the period end from the subscription or its first item.
  • billing/subscription.ts (new):

- subscriptionIdOf(event) reads only the sub_… id out of the event body. For invoice.* it takes data.object.subscription or parent.subscription_details.subscription. - entitlementFrom(db, sub) turns the subscription Stripe returns into the entitlement to write. active and trialing become active, past_due becomes grace, and everything else becomes none on the free plan.

  • http/stripe_hook.ts: for a new event of those types, the hook opens the sealed key, re-reads the subscription and calls putEntitlement (source stripe). A replayed event id does nothing. If Stripe is unreachable or refuses, nothing is written and the hook still answers 200. A test seam, setStripeHookFetch, lets the tests swap in a fake fetch.
  • Opening the sealed key writes a secret.read audit row with no actor, because the actor column rejects an arbitrary label. openKey now accepts an undefined actor. One existing test counts those rows and used an invoice.paid event, so I switched it to an event type the hook ignores.

Tests (fake fetch, in test/stripe-hook.test.ts)

  • invoice.paid then a stale subscription.updated carrying past_due ends on Stripe's active and medium.
  • Stripe unreachable leaves the row as it was and answers 200.
  • A later event follows Stripe to canceled, which gives free and none.

Choices and not done

  • The forge comes from the stored row with that subscription_id, or else from the subscription's metadata.forge. The plan comes from metadata.plan when it names a known plan. Checkout isn't built yet, so it will need to set that metadata.
  • A subscription that names no known forge writes nothing.
  • A failed read is not retried: the event id is stored first, so the next event for that subscription re-reads Stripe.
  • I did not call keep_plan; it was not available to me.

To try Run bun test test/stripe-hook.test.ts. On a running forge with Stripe connected, POST a signed invoice.paid to /hooks/stripe.

🤖 Generated with Claude Code

Closes #11245

commented

A signed customer.subscription. or invoice. event now writes the forge's entitlement from Stripe's current subscription, never from the event body. The Stripe hook tests, test/stripe.test.ts, test/billing.test.ts, tsc --noEmit and scripts/gate.ts pass. I did not run the whole suite.

What changed

  • billing/stripe.ts: stripeClient gains getSubscription(id), a GET of /v1/subscriptions/{id}. It returns the status, customer, period end and metadata, and reads the period end from the subscription or its first item.
  • billing/subscription.ts (new):

- subscriptionIdOf(event) reads only the sub_… id out of the event body. For invoice.* it takes data.object.subscription or parent.subscription_details.subscription. - entitlementFrom(db, sub) turns the subscription Stripe returns into the entitlement to write. active and trialing become active, past_due becomes grace, and everything else becomes none on the free plan.

  • http/stripe_hook.ts: for a new event of those types, the hook opens the sealed key, re-reads the subscription and calls putEntitlement (source stripe). A replayed event id does nothing. If Stripe is unreachable or refuses, nothing is written and the hook still answers 200. A test seam, setStripeHookFetch, lets the tests swap in a fake fetch.
  • Opening the sealed key writes a secret.read audit row with no actor, because the actor column rejects an arbitrary label. openKey now accepts an undefined actor. One existing test counts those rows and used an invoice.paid event, so I switched it to an event type the hook ignores.

Tests (fake fetch, in test/stripe-hook.test.ts)

  • invoice.paid then a stale subscription.updated carrying past_due ends on Stripe's active and medium.
  • Stripe unreachable leaves the row as it was and answers 200.
  • A later event follows Stripe to canceled, which gives free and none.

Choices and not done

  • The forge comes from the stored row with that subscription_id, or else from the subscription's metadata.forge. The plan comes from metadata.plan when it names a known plan. Checkout isn't built yet, so it will need to set that metadata.
  • A subscription that names no known forge writes nothing.
  • A failed read is not retried: the event id is stored first, so the next event for that subscription re-reads Stripe.
  • I did not call keep_plan; it was not available to me.

To try Run bun test test/stripe-hook.test.ts. On a running forge with Stripe connected, POST a signed invoice.paid to /hooks/stripe.

🤖 Generated with Claude Code

Changes requested — before the latest push

Read from the diff only; I did not exercise the preview. The design (re-read Stripe, write from it) is right, but the ordering guarantee is not held and several added behaviours have no test.

  1. (first) Race: syncSubscription awaits Stripe between the read and putEntitlement, and nothing serialises per subscription. Two concurrent deliveries can read old then new state but write in the opposite order, so the stale read lands last. That is exactly what the issue forbids. Serialise per subscription id (a promise chain keyed by id) so read+write is one critical section, and add a test with a deferred fake fetch that resolves out of order.
  2. Missing tests for added behaviour: subscriptionIdOf (invoice parent.subscription_details path, expanded object ids, malformed/non-sub_ ids), getSubscription parsing (customer as object, period end on item vs subscription, missing id/status error), entitlementFrom (unknown forge writes nothing, known forge via stored subscription_id, unknown metadata plan fallback), and a replayed event id not re-reading Stripe.
  3. entitlementFrom can write status active/grace with plan FREE when metadata.plan is unknown and no row exists; fall back to writing nothing (or none) instead of an inconsistent paid status on the free plan.
  4. putEntitlement is given size "small" when the forge is not found in listChildrenNow, which may reset a forge size; keep the stored size.
  5. The bare catch swallows errors silently; record a counter or audit line so a persistently failing read is visible.
  6. Hook doc comment line is over-long; reflow.
commented

This pull now serialises Stripe reads and writes per subscription, so events delivered out of order end on what Stripe says now. The type check and the three Stripe test files (44 tests) pass; there is no scripts/gate.ts in this clone. I did not run the preview.

Changes for the review points

  1. Race: syncSubscription runs the Stripe read and the write as one job on a promise chain keyed by subscription id. A second delivery for the same subscription waits for the first write before it reads. A test with a deferred fake fetch holds the first read back, and shows the second read does not start until the first has written. The first read resolves as past_due, the second as active, and the stored row ends active.
  2. Missing tests: test/subscription.test.ts is new. It covers:

- subscriptionIdOf: the invoice parent.subscription_details path, expanded objects, and malformed or non-sub_ ids. - getSubscription: customer as an object, period end on an item versus on the subscription, and a missing id or status. - entitlementFrom: an unknown forge writes nothing, a forge found through its stored subscription_id, and an unknown metadata plan.

test/stripe-hook.test.ts gains tests that a replayed event id does not re-read Stripe, and that a failed read is audited.

  1. Inconsistent paid status: entitlementFrom now returns nothing when the status is paid and the plan is unknown with no stored plan. A cancelled subscription still writes the free plan.
  2. Size, where I disagree in part: putEntitlement never writes a forge size. size only supplies the "from" plan in the change log for a forge with no row, and the return value. I still stopped the blanket "small": the fallback is now the forge's size, then its stored plan, then "small".
  3. Silent catch: a failed Stripe read, or an exception during sync, now appends a stripe.sync.failed event to the audit log.
  4. Doc comment: reflowed.

Not done: the chain is per process, which is enough while the forge runs as one process.

commented

The check failed because the new audit kind stripe.sync.failed had no audience in the event audience coverage test. I renamed it to billing.sync_failed. The existing billing. prefix already classifies that for admins, which fits the billing audit trail.

The type check passes, and the event audience coverage test and the Stripe hook tests (23 in total) pass locally. I did not run the full suite; the forge's check does that on this push.

Everything else is as in my last description: the per-subscription serialising of Stripe reads and writes, the new tests, and the no-paid-status-on-an-unknown-plan fix.

commented

I merged main into the branch and pushed; the type check and the stripe-hook, subscription and event-audience-coverage tests (39) pass. I did not run the full suite, which the forge's check does on this push.

The conflicts were in src/http/stripe_hook.ts and test/stripe-hook.test.ts, where main's checkout.session.completed binding (#11289) overlapped this pull's Stripe sync. I kept both:

  • Handler: the event-id insert and the checkout binding stay in one transaction, so a binding that throws keeps no event id and Stripe's retry still binds. The transaction now returns whether the id was new. Only a fresh customer.subscription. or invoice. event then goes on to the per-subscription sync, and a replay still triggers no second read of Stripe.
  • Tests: both groups are in test/stripe-hook.test.ts, the subscription and invoice tests and the checkout.session.completed tests. I also removed the duplicate getStored import the merge left.
Approved

Earlier asks are met: the per-subscription serialised read+write (with a deferred-fetch test), the new subscription.test.ts, no paid status on an unknown plan, the audited failures (now billing.sync_failed, inside the existing audience), and the doc comment. The merge with #11289 keeps the event id and checkout binding in one transaction and syncs only fresh events. The body never supplies state: only a validated sub_ id is read from it. Non-blocking: the serialising chain is per process, as noted in the pull.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs