Skip to content

Commit e04782e

Browse files
fix(billing): pending_checkouts ON DELETE CASCADE (mig 069) — unblock team reap (#272)
053_pending_checkouts created the team_id FK without ON DELETE CASCADE — the ONLY team-child table missing it. DeleteTeamHard (the e2e-account reap) and the worker team_deletion_executor delete a team with a bare DELETE FROM teams and rely on children cascading, so a team that ever started a checkout (→ a pending_checkouts row) failed: pq: ... violates foreign key constraint "pending_checkouts_team_id_fkey" This surfaced live the moment test-cohort checkout was armed: a cohort Pro upgrade creates a pending_checkouts row, so the reap 503'd (db_failed) and the cohort team LEAKED — breaking the rule-24 'cohort data is always reaped' guarantee. Found by the real test-card payment run. Migration 069 aligns the FK with every other team-child table (ON DELETE CASCADE), idempotent (DROP IF EXISTS + re-ADD). Already applied live; this codifies it. Test: TestDeleteTeamHard_CascadesPendingCheckouts seeds a pending_checkouts row and asserts DeleteTeamHard cascades it instead of erroring. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 232a991 commit e04782e

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- 069_pending_checkouts_cascade — make pending_checkouts.team_id cascade on team delete.
2+
--
3+
-- WHY THIS EXISTS
4+
-- ---------------
5+
-- 053_pending_checkouts created the FK as `team_id UUID NOT NULL REFERENCES
6+
-- teams(id)` with NO `ON DELETE CASCADE` — the ONLY team-child table that omits
7+
-- it (every other child: deployments, stacks, api_keys, audit_log, vault,
8+
-- custom_domains, pending_deletions, pending_propagations, … all CASCADE).
9+
--
10+
-- DeleteTeamHard (the e2e-account reap) and the worker team_deletion_executor
11+
-- both delete a team with a single `DELETE FROM teams`, relying on the children
12+
-- to cascade. A team that ever started a checkout has a pending_checkouts row,
13+
-- so the delete fails:
14+
-- pq: update or delete on table "teams" violates foreign key constraint
15+
-- "pending_checkouts_team_id_fkey" on table "pending_checkouts"
16+
-- This surfaced the moment test-cohort checkout was armed (a cohort upgrade
17+
-- creates a pending_checkouts row) — the reap 503'd and the cohort team leaked,
18+
-- breaking the rule-24 "cohort data is always reaped" guarantee.
19+
--
20+
-- Fix: align the FK with every other team-child table — ON DELETE CASCADE. A
21+
-- resolved or unresolved pending_checkouts row is per-team bookkeeping; when the
22+
-- team is gone the row is meaningless, so cascading the delete is correct.
23+
-- Idempotent (DROP IF EXISTS + re-ADD) so it is safe to re-run.
24+
25+
ALTER TABLE pending_checkouts DROP CONSTRAINT IF EXISTS pending_checkouts_team_id_fkey;
26+
ALTER TABLE pending_checkouts
27+
ADD CONSTRAINT pending_checkouts_team_id_fkey
28+
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE;

internal/models/e2e_account_models_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,42 @@ func TestDeleteTeamHard_DeletesAndIsIdempotent(t *testing.T) {
7777
require.False(t, deleted, "re-delete of a gone team is a clean no-op")
7878
}
7979

80+
// TestDeleteTeamHard_CascadesPendingCheckouts is the regression guard for
81+
// migration 069: a team that started a checkout has a pending_checkouts row, and
82+
// before 069 that FK had no ON DELETE CASCADE — so DeleteTeamHard (the e2e reap)
83+
// failed with `pending_checkouts_team_id_fkey` and the cohort team LEAKED. This
84+
// surfaced the instant test-cohort checkout was armed (a cohort Pro upgrade
85+
// creates a pending_checkouts row → the reap 503'd). With the cascade, deleting
86+
// the team removes its pending_checkouts rows and the reap succeeds.
87+
func TestDeleteTeamHard_CascadesPendingCheckouts(t *testing.T) {
88+
skipUnlessE2EModelsDB(t)
89+
ctx := context.Background()
90+
db, clean := testhelpers.SetupTestDB(t)
91+
defer clean()
92+
93+
team, err := models.CreateTestCohortTeam(ctx, db, "cohort-with-checkout")
94+
require.NoError(t, err)
95+
96+
// Seed a pending_checkouts row (the exact FK that used to block the delete).
97+
subID := "sub_test_" + uuid.NewString()
98+
_, err = db.ExecContext(ctx,
99+
`INSERT INTO pending_checkouts (subscription_id, team_id, customer_email, plan_tier)
100+
VALUES ($1, $2, $3, $4)`,
101+
subID, team.ID, "cohort@example.com", "pro")
102+
require.NoError(t, err, "seed pending_checkouts")
103+
104+
// Pre-069 this returned the FK-violation error; with the cascade it succeeds.
105+
deleted, err := models.DeleteTeamHard(ctx, db, team.ID)
106+
require.NoError(t, err, "DeleteTeamHard must cascade pending_checkouts, not error on the FK")
107+
require.True(t, deleted)
108+
109+
// The child row is gone too (cascaded), not orphaned.
110+
var n int
111+
require.NoError(t, db.QueryRowContext(ctx,
112+
`SELECT count(*) FROM pending_checkouts WHERE subscription_id = $1`, subID).Scan(&n))
113+
require.Equal(t, 0, n, "pending_checkouts row must be cascade-deleted with the team")
114+
}
115+
80116
func TestMarkTeamResourcesForReaper_RetiersAndExpires(t *testing.T) {
81117
skipUnlessE2EModelsDB(t)
82118
ctx := context.Background()

0 commit comments

Comments
 (0)