diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql new file mode 100644 index 000000000..e569b3230 --- /dev/null +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql @@ -0,0 +1,16 @@ +-- Terminal subscription rows written before endedAt was derived on every sync +-- (and terminal-on-first-sync rows, whose create path never set it) have +-- endedAt = NULL. The status-agnostic isSubscriptionInEffect predicate reads +-- NULL as "entitled forever", so these rows must be closed out. +-- +-- endedAt = LEAST(currentPeriodEnd, NOW()) mirrors getEndedAtForSync's +-- fallback when Stripe omits ended_at: the paid-through boundary if it's +-- already past, otherwise now. +-- +-- No batching/temp-index machinery: the affected population was measured in +-- production at 7 rows (2026-07-17), and a plain UPDATE only takes row locks +-- on matching rows, so the seq scan is safe regardless of table size. +UPDATE "Subscription" +SET "endedAt" = LEAST("currentPeriodEnd", NOW()) +WHERE "status" IN ('canceled', 'incomplete_expired', 'unpaid') + AND "endedAt" IS NULL; diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts new file mode 100644 index 000000000..07e30ff59 --- /dev/null +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +const insertSubscription = async (sql: Sql, options: { + tenancyId: string, + status: string, + currentPeriodEnd: Date, + endedAt?: Date | null, +}) => { + const id = randomUUID(); + await sql` + INSERT INTO "Subscription" ( + "id", "tenancyId", "customerId", "customerType", "product", "quantity", + "stripeSubscriptionId", "status", "currentPeriodStart", "currentPeriodEnd", + "cancelAtPeriodEnd", "endedAt", "creationSource", "createdAt", "updatedAt" + ) VALUES ( + ${id}::uuid, ${options.tenancyId}::uuid, ${`customer-${id}`}, 'TEAM', + ${sql.json({ displayName: 'Test Product', customerType: 'team' })}, 1, + ${`sub_${id}`}, ${options.status}::"SubscriptionStatus", + '2026-01-01', ${options.currentPeriodEnd}, + false, ${options.endedAt ?? null}, 'PURCHASE_PAGE', NOW(), NOW() + ) + `; + return id; +}; + +export const preMigration = async (sql: Sql) => { + const tenancyId = randomUUID(); + const pastPeriodEnd = new Date('2026-02-01T00:00:00Z'); + + // One row per terminal status, all with null endedAt and a past period end. + const canceledId = await insertSubscription(sql, { tenancyId, status: 'canceled', currentPeriodEnd: pastPeriodEnd }); + const incompleteExpiredId = await insertSubscription(sql, { tenancyId, status: 'incomplete_expired', currentPeriodEnd: pastPeriodEnd }); + const unpaidId = await insertSubscription(sql, { tenancyId, status: 'unpaid', currentPeriodEnd: pastPeriodEnd }); + + // Terminal row whose period end is in the future: endedAt must be capped at + // the migration's NOW() (matching getEndedAtForSync's last-resort fallback), + // not scheduled for the future. + const futurePeriodEnd = new Date('2100-01-01T00:00:00Z'); + const futureCanceledId = await insertSubscription(sql, { tenancyId, status: 'canceled', currentPeriodEnd: futurePeriodEnd }); + + // The DB's own clock, so the post-migration bound check compares + // apples-to-apples (the Subscription timestamp columns have no time zone, + // so JS-side Date comparisons would be off by the session's tz offset). + const [{ now: dbNowBeforeMigration }] = await sql`SELECT NOW() AS now`; + + return { tenancyId, canceledId, incompleteExpiredId, unpaidId, futureCanceledId, dbNowBeforeMigration: dbNowBeforeMigration as Date }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + // Compare in SQL so stored values never round-trip through JS Dates. + const rows = await sql` + SELECT + "id", + ("endedAt" IS NOT NULL) AS "hasEndedAt", + ("endedAt" = "currentPeriodEnd") AS "matchesPeriodEnd", + ("endedAt" >= ${ctx.dbNowBeforeMigration} AND "endedAt" <= NOW() AND "endedAt" < "currentPeriodEnd") AS "cappedAtNow" + FROM "Subscription" WHERE "tenancyId" = ${ctx.tenancyId}::uuid + `; + const byId = new Map(rows.map((row) => [row.id, row])); + + // Past period end: backfilled to exactly currentPeriodEnd. + for (const id of [ctx.canceledId, ctx.incompleteExpiredId, ctx.unpaidId]) { + expect(byId.get(id)?.hasEndedAt).toBe(true); + expect(byId.get(id)?.matchesPeriodEnd).toBe(true); + } + + // Future period end: capped at the migration's NOW(), not scheduled for 2100. + expect(byId.get(ctx.futureCanceledId)?.hasEndedAt).toBe(true); + expect(byId.get(ctx.futureCanceledId)?.cappedAtNow).toBe(true); +}; diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts new file mode 100644 index 000000000..f955c5ea9 --- /dev/null +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts @@ -0,0 +1,60 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +const insertSubscription = async (sql: Sql, options: { + tenancyId: string, + status: string, + endedAt: Date | null, +}) => { + const id = randomUUID(); + await sql` + INSERT INTO "Subscription" ( + "id", "tenancyId", "customerId", "customerType", "product", "quantity", + "stripeSubscriptionId", "status", "currentPeriodStart", "currentPeriodEnd", + "cancelAtPeriodEnd", "endedAt", "creationSource", "createdAt", "updatedAt" + ) VALUES ( + ${id}::uuid, ${options.tenancyId}::uuid, ${`customer-${id}`}, 'TEAM', + ${sql.json({ displayName: 'Test Product', customerType: 'team' })}, 1, + ${`sub_${id}`}, ${options.status}::"SubscriptionStatus", + '2026-01-01', '2026-02-01', + false, ${options.endedAt}, 'PURCHASE_PAGE', NOW(), NOW() + ) + `; + return id; +}; + +export const preMigration = async (sql: Sql) => { + const tenancyId = randomUUID(); + const existingEndedAt = new Date('2026-01-15T12:00:00Z'); + + // Non-terminal rows with null endedAt: live subscriptions, must stay null. + const activeId = await insertSubscription(sql, { tenancyId, status: 'active', endedAt: null }); + const trialingId = await insertSubscription(sql, { tenancyId, status: 'trialing', endedAt: null }); + // `incomplete` is non-terminal (Stripe expires it to incomplete_expired + // within ~24h, which arrives via webhook) — deliberately not backfilled. + const incompleteId = await insertSubscription(sql, { tenancyId, status: 'incomplete', endedAt: null }); + const pastDueId = await insertSubscription(sql, { tenancyId, status: 'past_due', endedAt: null }); + + // Terminal row whose endedAt is already set: must keep its original value. + const alreadyEndedId = await insertSubscription(sql, { tenancyId, status: 'canceled', endedAt: existingEndedAt }); + + return { tenancyId, activeId, trialingId, incompleteId, pastDueId, alreadyEndedId, existingEndedAt }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + // Compare the already-set endedAt in SQL against the same JS Date param used + // at insert time — both cross the driver's tz-naive conversion identically, + // so equality holds regardless of the session time zone. + const rows = await sql` + SELECT "id", "endedAt", ("endedAt" = ${ctx.existingEndedAt}) AS "matchesOriginal" + FROM "Subscription" WHERE "tenancyId" = ${ctx.tenancyId}::uuid + `; + const byId = new Map(rows.map((row) => [row.id, row])); + + for (const id of [ctx.activeId, ctx.trialingId, ctx.incompleteId, ctx.pastDueId]) { + expect(byId.get(id)?.endedAt).toBeNull(); + } + + expect(byId.get(ctx.alreadyEndedId)?.matchesOriginal).toBe(true); +};