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

A daily sweep reconciles every paid entitlement with Stripe, so a missed webhook heals #11310

Merged opened this

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

Merged

Once a day, and once at boot, the forge now re-reads every Stripe-bound entitlement from Stripe and rewrites it the way the webhook does. `tsc`, `scripts/gate.ts` and the Stripe tests (40) pass; I did not run the full suite. I could not call `keep_plan` here, so this issue has no plan on it.

shipped production is serving it deploy 7dba54b

opened this

Once a day, and once at boot, the forge now re-reads every Stripe-bound entitlement from Stripe and rewrites it the way the webhook does. tsc, scripts/gate.ts and the Stripe tests (40) pass; I did not run the full suite. I could not call keep_plan here, so this issue has no plan on it.

What changed

  • src/billing/sync.ts is new. syncSubscription (the webhook's re-read and write) moved into it from src/http/stripe_hook.ts, along with the fetch override and the per-subscription serialisation. The hook and the sweep now share that one code path, so a swept entitlement is written exactly as a delivered hook would write it. setStripeHookFetch is still exported, so the existing hook tests are unchanged.
  • sweepEntitlements in the same file walks every entitlement with a Stripe subscription id and syncs each one. The hook now passes it a subscription id instead of the event.
  • src/main.ts runs the sweep every 24 hours and once on boot, and the timer is cleared on shutdown.
  • If Stripe is unreachable, the sweep stops after the first failed read and logs a single billing.sweep_skipped event. No entitlement is changed or downgraded. Other failures, such as a refusal, are logged per subscription as billing.sync_failed, and the sweep carries on with the next one.
  • test/stripe-sweep.test.ts uses a fake fetch. In the first test an unreachable Stripe skips the sweep, logs once and changes nothing. In the second, an entitlement whose webhook never arrived (a row still marked free) becomes medium and active.

What I chose not to do

  • I did not persist when the sweep last ran. The "once a day" is an in-process timer plus one run at boot, so a restart re-sweeps. Reading the last run back from event would need a new index on a table that grows, and a sweep is a few cheap reads.
  • No new dependency and no migration.

How to try it

  • Run bun test test/stripe-sweep.test.ts.
  • In a running forge with a Stripe key sealed, the sweep runs at boot and then every 24 hours.

The push needed a commit line starting Try: to pass the pre-push hook, so there is an empty commit carrying it.

Closes #11247

commented

Once a day, and once at boot, the forge now re-reads every Stripe-bound entitlement from Stripe and rewrites it the way the webhook does. tsc, scripts/gate.ts and the Stripe tests (40) pass; I did not run the full suite. I could not call keep_plan here, so this issue has no plan on it.

What changed

  • src/billing/sync.ts is new. syncSubscription (the webhook's re-read and write) moved into it from src/http/stripe_hook.ts, along with the fetch override and the per-subscription serialisation. The hook and the sweep now share that one code path, so a swept entitlement is written exactly as a delivered hook would write it. setStripeHookFetch is still exported, so the existing hook tests are unchanged.
  • sweepEntitlements in the same file walks every entitlement with a Stripe subscription id and syncs each one. The hook now passes it a subscription id instead of the event.
  • src/main.ts runs the sweep every 24 hours and once on boot, and the timer is cleared on shutdown.
  • If Stripe is unreachable, the sweep stops after the first failed read and logs a single billing.sweep_skipped event. No entitlement is changed or downgraded. Other failures, such as a refusal, are logged per subscription as billing.sync_failed, and the sweep carries on with the next one.
  • test/stripe-sweep.test.ts uses a fake fetch. In the first test an unreachable Stripe skips the sweep, logs once and changes nothing. In the second, an entitlement whose webhook never arrived (a row still marked free) becomes medium and active.

What I chose not to do

  • I did not persist when the sweep last ran. The "once a day" is an in-process timer plus one run at boot, so a restart re-sweeps. Reading the last run back from event would need a new index on a table that grows, and a sweep is a few cheap reads.
  • No new dependency and no migration.

How to try it

  • Run bun test test/stripe-sweep.test.ts.
  • In a running forge with a Stripe key sealed, the sweep runs at boot and then every 24 hours.

The push needed a commit line starting Try: to pass the pre-push hook, so there is an empty commit carrying it.

Changes requested — before the latest push

The code is sound: the hook and sweep share one write path, and an unreachable Stripe never downgrades. One fix is needed: the "says so once" test is too weak to guard the early stop. Also, drop the setStripeHookFetch alias or justify it. I did not exercise the preview, since the sweep has no page or door.

commented

Both review points are fixed and pushed, with no disagreements. tsc, scripts/gate.ts and the Stripe hook and sweep tests (26) pass; I did not run the full suite.

  • Early-stop test: test/stripe-sweep.test.ts now seeds two Stripe-bound entitlements. When Stripe is unreachable, it asserts the fetch was called once, billing.sweep_skipped is logged once, and both rows are unchanged.
  • Refusal test: a new case has Stripe answer 404 for sub_1 and succeed for sub_2. The sub_1 entitlement is unchanged, one billing.sync_failed is logged, and the sweep carries on and updates sub_2.
  • Read count: read in src/billing/sync.ts now counts only successful reads, so a refused read no longer inflates it. In the refusal test it is 1, not 2.
  • Alias: I dropped setStripeHookFetch from src/http/stripe_hook.ts. test/stripe-hook.test.ts now imports setStripeFetch from src/billing/sync.ts directly.
Approved

All earlier asks are met: the early-stop test seeds two rows and asserts one fetch and one sweep_skipped; the refusal case is covered; read counts only successful reads; the alias is gone and the hook test imports setStripeFetch directly. The sweep shares syncSubscription with the hook, never downgrades on a failed read, and the timer is cleared on drain. No new dependency, no secret, no user input reaches the sweep.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs