Backfill endedAt on terminal subscription rows

Terminal rows written before endedAt was derived on every sync (or first-
synced already-terminal) have endedAt = NULL, which the status-agnostic
isSubscriptionInEffect predicate reads as entitled forever. Close them out
with LEAST(currentPeriodEnd, NOW()), mirroring getEndedAtForSync's fallback
when Stripe omits ended_at.

Measured population in production: 7 rows (2026-07-17), so no batching or
temp-index machinery is needed. After deploy, re-run
scripts/bulldozer-payments-init.ts to re-emit the affected rows so their
grants expire in the timefold.
This commit is contained in:
Bilal Godil 2026-07-17 09:57:59 -07:00
parent eb4c07be32
commit de526eaa31
3 changed files with 148 additions and 0 deletions

View File

@ -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;

View File

@ -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<ReturnType<typeof preMigration>>) => {
// 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);
};

View File

@ -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<ReturnType<typeof preMigration>>) => {
// 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);
};