feat(billing): teardown module for account-delete's 24h undo window (cloud#226 A-2) - #230
Merged
Conversation
unforced
added a commit
that referenced
this pull request
Jul 28, 2026
…on ids `opts.subscription ?? "sub_test_1"` treated an explicit `null` (no stored subscription — the cloud#64 orphan shape) the same as omitted, silently substituting the stub default. Two existing tests intended to exercise `teardownBilling`'s `if (user.stripeSubscriptionId)` false branch never actually reached it. Switch to `=== undefined` so null and omitted are distinct, and add a dedicated test for the null-subscription-id + live-belt case, watched red on unfixed code and again with the belt broken before confirming green. Fold per PR #230 review (cloud#226 A-2).
…cloud#226 A-2) Adds workers/identity/src/billing-teardown.ts: deferBilling (reversible hold on delete-request), resumeBilling (release on undo, with a discriminated result for the un-cancelable case), teardownBilling (the real, sweep-time cancel-everything + delete-customer + NULL-ids teardown, including the cloud#64 orphan belt across active/trialing/ past_due/unpaid subscriptions). Inert — nothing calls these yet; A-3 wires them into the delete/undo routes and the hourly sweep. Two guarantees this PR establishes: no new charge can post after a deletion is requested, and no subscription survives a completed teardown. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
…on ids `opts.subscription ?? "sub_test_1"` treated an explicit `null` (no stored subscription — the cloud#64 orphan shape) the same as omitted, silently substituting the stub default. Two existing tests intended to exercise `teardownBilling`'s `if (user.stripeSubscriptionId)` false branch never actually reached it. Switch to `=== undefined` so null and omitted are distinct, and add a dedicated test for the null-subscription-id + live-belt case, watched red on unfixed code and again with the belt broken before confirming green. Fold per PR #230 review (cloud#226 A-2).
unforced
force-pushed
the
ag-billing-teardown-a2
branch
from
July 28, 2026 00:40
56b4b26 to
18e756e
Compare
unforced
added a commit
that referenced
this pull request
Jul 28, 2026
…e-fix commit Adds the required coverage for the account-delete substrate (8e72fe6), one test (or set) per chokepoint: the account bearer gate (account-api.ts + account-mcp-http.ts), the session JOIN (via the account-token mint path), both password-login paths, magic-link request/consume + the 2FA divert, the signup-collision degrade (verification only — no new code), the drip's three eligibility queries, and the billing/usage/snapshot sweep enumerations. Verified red-then-green: reverted the 12 chokepoint src files (kept migration 0023) to the pre-fix commit, ran all 10 touched test files — 14 of 15 new tests failed for the stated reason (the 15th, signup collision, is a verification test with no new code to break, and passed as expected both before and after). Restored the fix; same 10 files now 463/463 passing. rc.128 -> rc.130 (re-bumped after rebasing onto main: #229 landed rc.128 first, #230 holds rc.129 ahead of this PR in the merge queue).
unforced
added a commit
that referenced
this pull request
Jul 28, 2026
…e-fix commit Adds the required coverage for the account-delete substrate (8e72fe6), one test (or set) per chokepoint: the account bearer gate (account-api.ts + account-mcp-http.ts), the session JOIN (via the account-token mint path), both password-login paths, magic-link request/consume + the 2FA divert, the signup-collision degrade (verification only — no new code), the drip's three eligibility queries, and the billing/usage/snapshot sweep enumerations. Verified red-then-green: reverted the 12 chokepoint src files (kept migration 0023) to the pre-fix commit, ran all 10 touched test files — 14 of 15 new tests failed for the stated reason (the 15th, signup collision, is a verification test with no new code to break, and passed as expected both before and after). Restored the fix; same 10 files now 463/463 passing. rc.128 -> rc.130 (re-bumped after rebasing onto main: #229 landed rc.128 first, #230 holds rc.129 ahead of this PR in the merge queue).
unforced
added a commit
that referenced
this pull request
Jul 28, 2026
…d-time refusal chokepoints (#232) * fix(identity): account-delete substrate (A-1) — migration 0023 + read-time refusal chokepoints Adds the nullable deleted_at/delete_undo_hash/delete_notice_sent_at columns (migration 0023, claiming the slot ahead of the vault-delete train's PR-2a — see cloud#226) and wires the read-time refusal every account-acting surface needs so a tombstoned row can never still act: the account bearer gate (account-api.ts requireAccount, account-mcp-http.ts authenticate), the session JOIN (sessions.ts findActiveSession), both password-login paths (console.ts, oauth-authorize.ts), magic-link request + consume + the 2FA divert (auth-handlers.ts), the onboarding drip's three eligibility queries, and the billing/usage/snapshot sweep enumerations. Deliberately inert: no route sets these columns yet (A-3 owns the delete endpoint, A-4 the undo + convergence sweep). Mirrors migration 0011's suspended_at no-oracle posture rather than inventing a second style, except where deletion is stronger: requireAccount/authenticate answer a deleted owner with the exact "account not found" body a missing row gets, not the distinguishable account_suspended a suspended owner gets. Tests to follow in a separate commit. * test(identity): A-1 chokepoint tests + rc.130 — watched red on the pre-fix commit Adds the required coverage for the account-delete substrate (8e72fe6), one test (or set) per chokepoint: the account bearer gate (account-api.ts + account-mcp-http.ts), the session JOIN (via the account-token mint path), both password-login paths, magic-link request/consume + the 2FA divert, the signup-collision degrade (verification only — no new code), the drip's three eligibility queries, and the billing/usage/snapshot sweep enumerations. Verified red-then-green: reverted the 12 chokepoint src files (kept migration 0023) to the pre-fix commit, ran all 10 touched test files — 14 of 15 new tests failed for the stated reason (the 15th, signup collision, is a verification test with no new code to break, and passed as expected both before and after). Restored the fix; same 10 files now 463/463 passing. rc.128 -> rc.130 (re-bumped after rebasing onto main: #229 landed rc.128 first, #230 holds rc.129 ahead of this PR in the merge queue).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A-2 of the account-deletion train (sibling of #229 / cloud#226 PR-1). Aaron ruled that deleting an account gets a 24-hour undo window: the delete request severs auth and places a reversible hold on billing; real teardown happens at window expiry via an hourly sweep; undo releases the hold.
This PR is inert —
workers/identity/src/billing-teardown.tsis a self-contained module that nothing calls yet. A-3 wires it into the delete/undo routes and the sweep.Three functions over the existing
BillingOverridesStripe-injection seam (billing.ts'scheckoutCore/portalCorepattern — no new mechanism):deferBilling(stripe, subscriptionId)— setscancel_at_period_end = trueon the stored subscription. Tolerant of a missing id, a 404, or an already-canceled subscription (all success). Covers atrialingsubscription too: the trial cancels at its boundary instead of converting and charging.resumeBilling(stripe, subscriptionId)— flips the flag back. Returns a discriminated result ({ resumed: true }vs{ resumed: false, reason: "already_canceled" }) for the case that can't be released: if the period boundary passed during the undo window, Stripe has no un-cancel. Never throws for this case, never claims a restore that didn't happen.teardownBilling(stripe, db, userId)— the real, irreversible teardown (sweep-time only): cancels the stored subscription, sweeps every other subscription under the customer inactive/trialing/past_due/unpaid(the cloud#64 orphan belt — a subscription theusersrow doesn't name would otherwise bill a ghost forever), deletes the customer, then NULLs both Stripe ids — that NULL is the converged marker the sweep keys on. Any failure along the way leaves the ids untouched and returns an unconverged result so the sweep retries; every step is individually idempotent.The two guarantees this establishes: no new charge can post after a deletion is requested, and no subscription survives a completed teardown.
Tests
workers/identity/test/billing-teardown.test.ts— 18 tests against the injected Stripe stub (plain object satisfying the SDK subset used, same patternbilling-lifecycle.ts's tests already use), no network:deferBilling: sets the flag; tolerant of missing/404/already-canceled; a genuine Stripe failure propagates.resumeBilling: flips the flag back; the money case — an already-canceled subscription returns the not-resumed result rather than throwing or claiming success; genuine failures propagate.trialingsubscription reachable only via the belt (not the stored id) is canceled — written so it goes red if the belt is filtered toactiveonly (verified: manually narrowed the belt during development and confirmed this test fails for the stated reason before restoring the fix).customers.delthrows → ids retained, unconverged result, no exception escapes (verified red without the try/catch).customers.delnever reached.Every load-bearing assertion was watched fail on a deliberately-broken variant of the source before being confirmed green against the real implementation.
Gates
bun run typecheck(root): clean.cd workers/identity && bun run typecheck: clean.cd workers/identity && bun x vitest run(identity worker suite): 39 files passed (39), 1071 tests passed (1071), 0 failed. (Full-suite runs on this box hitEADDRNOTAVAILfrom loopback port pressure when all 39 files spin up Miniflare concurrently — an environment characteristic, not a regression: confirmed viagit statusthat this branch touches only the two new files below, so no existing test could have been affected. Ran the suite in batches of 5 files with orphan-reaping between batches to get a clean full accounting.)rc bumped to
0.0.8-rc.129(main is at rc.127; the sibling PR #229 already claimed rc.128).Scope
Does not touch
account-api.ts, add migrations, or wire any route — A-1 (#229) and A-3 own those.https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB