Skip to content

feat(billing): teardown module for account-delete's 24h undo window (cloud#226 A-2) - #230

Merged
unforced merged 2 commits into
mainfrom
ag-billing-teardown-a2
Jul 28, 2026
Merged

feat(billing): teardown module for account-delete's 24h undo window (cloud#226 A-2)#230
unforced merged 2 commits into
mainfrom
ag-billing-teardown-a2

Conversation

@unforced

Copy link
Copy Markdown
Contributor

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 inertworkers/identity/src/billing-teardown.ts is 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 BillingOverrides Stripe-injection seam (billing.ts's checkoutCore/portalCore pattern — no new mechanism):

  • deferBilling(stripe, subscriptionId) — sets cancel_at_period_end = true on the stored subscription. Tolerant of a missing id, a 404, or an already-canceled subscription (all success). Covers a trialing subscription 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 in active/trialing/past_due/unpaid (the cloud#64 orphan belt — a subscription the users row 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 pattern billing-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.
  • The money test: a trialing subscription reachable only via the belt (not the stored id) is canceled — written so it goes red if the belt is filtered to active only (verified: manually narrowed the belt during development and confirmed this test fails for the stated reason before restoring the fix).
  • The orphan belt: stored id + an unrelated live subscription are both canceled (de-duplicated, not double-canceled), customer deleted, both ids NULLed.
  • Partial failure: customers.del throws → ids retained, unconverged result, no exception escapes (verified red without the try/catch).
  • Mid-belt failure: canceling an orphan throws → unconverged, customers.del never reached.
  • Idempotence: running teardown twice reaches the same terminal state without throwing.
  • Edge cases: a user who never had Stripe ids, and a nonexistent user id, both converge trivially with zero Stripe calls.

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 hit EADDRNOTAVAIL from loopback port pressure when all 39 files spin up Miniflare concurrently — an environment characteristic, not a regression: confirmed via git status that 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

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).
unforced and others added 2 commits July 27, 2026 18:37
…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
unforced force-pushed the ag-billing-teardown-a2 branch from 56b4b26 to 18e756e Compare July 28, 2026 00:40
@unforced
unforced merged commit 8435332 into main Jul 28, 2026
3 checks passed
@unforced
unforced deleted the ag-billing-teardown-a2 branch July 28, 2026 00:43
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant