mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
eb4c07be32
commit
de526eaa31
@ -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;
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user