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
Check · Review · Land · Live done — Landed and live in production.
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
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.tsis new.syncSubscription(the webhook's re-read and write) moved into it fromsrc/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.setStripeHookFetchis still exported, so the existing hook tests are unchanged.sweepEntitlementsin 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.tsruns 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_skippedevent. No entitlement is changed or downgraded. Other failures, such as a refusal, are logged per subscription asbilling.sync_failed, and the sweep carries on with the next one. test/stripe-sweep.test.tsuses 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
eventwould 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.
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.
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.tsnow seeds two Stripe-bound entitlements. When Stripe is unreachable, it asserts the fetch was called once,billing.sweep_skippedis logged once, and both rows are unchanged. - Refusal test: a new case has Stripe answer 404 for
sub_1and succeed forsub_2. Thesub_1entitlement is unchanged, onebilling.sync_failedis logged, and the sweep carries on and updatessub_2. - Read count:
readinsrc/billing/sync.tsnow counts only successful reads, so a refused read no longer inflates it. In the refusal test it is 1, not 2. - Alias: I dropped
setStripeHookFetchfromsrc/http/stripe_hook.ts.test/stripe-hook.test.tsnow importssetStripeFetchfromsrc/billing/sync.tsdirectly.
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.
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.tsand the Stripe tests (40) pass; I did not run the full suite. I could not callkeep_planhere, so this issue has no plan on it.What changed
src/billing/sync.tsis new.syncSubscription(the webhook's re-read and write) moved into it fromsrc/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.setStripeHookFetchis still exported, so the existing hook tests are unchanged.sweepEntitlementsin 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.tsruns the sweep every 24 hours and once on boot, and the timer is cleared on shutdown.billing.sweep_skippedevent. No entitlement is changed or downgraded. Other failures, such as a refusal, are logged per subscription asbilling.sync_failed, and the sweep carries on with the next one.test/stripe-sweep.test.tsuses 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
eventwould need a new index on a table that grows, and a sweep is a few cheap reads.How to try it
bun test test/stripe-sweep.test.ts.The push needed a commit line starting
Try:to pass the pre-push hook, so there is an empty commit carrying it.Closes #11247