From a0838f8fcdb758403348c9edcf8899288d07df7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 11:43:03 +0530 Subject: [PATCH] =?UTF-8?q?fix(billing):=20pending=5Fcheckouts=20ON=20DELE?= =?UTF-8?q?TE=20CASCADE=20(mig=20069)=20=E2=80=94=20unblock=20team=20reap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Opus 4.8 (1M context) --- .../069_pending_checkouts_cascade.sql | 28 +++++++++++++++ internal/models/e2e_account_models_test.go | 36 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 internal/db/migrations/069_pending_checkouts_cascade.sql diff --git a/internal/db/migrations/069_pending_checkouts_cascade.sql b/internal/db/migrations/069_pending_checkouts_cascade.sql new file mode 100644 index 0000000..85a9234 --- /dev/null +++ b/internal/db/migrations/069_pending_checkouts_cascade.sql @@ -0,0 +1,28 @@ +-- 069_pending_checkouts_cascade — make pending_checkouts.team_id cascade on team delete. +-- +-- WHY THIS EXISTS +-- --------------- +-- 053_pending_checkouts created the FK as `team_id UUID NOT NULL REFERENCES +-- teams(id)` with NO `ON DELETE CASCADE` — the ONLY team-child table that omits +-- it (every other child: deployments, stacks, api_keys, audit_log, vault, +-- custom_domains, pending_deletions, pending_propagations, … all CASCADE). +-- +-- DeleteTeamHard (the e2e-account reap) and the worker team_deletion_executor +-- both delete a team with a single `DELETE FROM teams`, relying on the children +-- to cascade. A team that ever started a checkout has a pending_checkouts row, +-- so the delete fails: +-- pq: update or delete on table "teams" violates foreign key constraint +-- "pending_checkouts_team_id_fkey" on table "pending_checkouts" +-- This surfaced the moment test-cohort checkout was armed (a cohort upgrade +-- creates a pending_checkouts row) — the reap 503'd and the cohort team leaked, +-- breaking the rule-24 "cohort data is always reaped" guarantee. +-- +-- Fix: align the FK with every other team-child table — ON DELETE CASCADE. A +-- resolved or unresolved pending_checkouts row is per-team bookkeeping; when the +-- team is gone the row is meaningless, so cascading the delete is correct. +-- Idempotent (DROP IF EXISTS + re-ADD) so it is safe to re-run. + +ALTER TABLE pending_checkouts DROP CONSTRAINT IF EXISTS pending_checkouts_team_id_fkey; +ALTER TABLE pending_checkouts + ADD CONSTRAINT pending_checkouts_team_id_fkey + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE; diff --git a/internal/models/e2e_account_models_test.go b/internal/models/e2e_account_models_test.go index f8ee008..d5c014c 100644 --- a/internal/models/e2e_account_models_test.go +++ b/internal/models/e2e_account_models_test.go @@ -77,6 +77,42 @@ func TestDeleteTeamHard_DeletesAndIsIdempotent(t *testing.T) { require.False(t, deleted, "re-delete of a gone team is a clean no-op") } +// TestDeleteTeamHard_CascadesPendingCheckouts is the regression guard for +// migration 069: a team that started a checkout has a pending_checkouts row, and +// before 069 that FK had no ON DELETE CASCADE — so DeleteTeamHard (the e2e reap) +// failed with `pending_checkouts_team_id_fkey` and the cohort team LEAKED. This +// surfaced the instant test-cohort checkout was armed (a cohort Pro upgrade +// creates a pending_checkouts row → the reap 503'd). With the cascade, deleting +// the team removes its pending_checkouts rows and the reap succeeds. +func TestDeleteTeamHard_CascadesPendingCheckouts(t *testing.T) { + skipUnlessE2EModelsDB(t) + ctx := context.Background() + db, clean := testhelpers.SetupTestDB(t) + defer clean() + + team, err := models.CreateTestCohortTeam(ctx, db, "cohort-with-checkout") + require.NoError(t, err) + + // Seed a pending_checkouts row (the exact FK that used to block the delete). + subID := "sub_test_" + uuid.NewString() + _, err = db.ExecContext(ctx, + `INSERT INTO pending_checkouts (subscription_id, team_id, customer_email, plan_tier) + VALUES ($1, $2, $3, $4)`, + subID, team.ID, "cohort@example.com", "pro") + require.NoError(t, err, "seed pending_checkouts") + + // Pre-069 this returned the FK-violation error; with the cascade it succeeds. + deleted, err := models.DeleteTeamHard(ctx, db, team.ID) + require.NoError(t, err, "DeleteTeamHard must cascade pending_checkouts, not error on the FK") + require.True(t, deleted) + + // The child row is gone too (cascaded), not orphaned. + var n int + require.NoError(t, db.QueryRowContext(ctx, + `SELECT count(*) FROM pending_checkouts WHERE subscription_id = $1`, subID).Scan(&n)) + require.Equal(t, 0, n, "pending_checkouts row must be cascade-deleted with the team") +} + func TestMarkTeamResourcesForReaper_RetiersAndExpires(t *testing.T) { skipUnlessE2EModelsDB(t) ctx := context.Background()