Skip to content

Commit b6ea8a5

Browse files
committed
feat(migration): Migrate internal teams to latest prod versions
Stripe subscriptions need to have their product version updated in the stripe metadata, else on stripe webhook event they will be reset back to old prod snapshot. These two scripts will need to be run explicitly in prod. They're not part of the db:migrate script.
1 parent b6a5511 commit b6ea8a5

5 files changed

Lines changed: 922 additions & 1 deletion

File tree

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"db:init": "pnpm run with-env:dev tsx scripts/db-migrations.ts init",
4040
"db:migrate": "pnpm run with-env:dev tsx scripts/db-migrations.ts migrate",
4141
"db:backfill-internal-free-plans": "pnpm run with-env:dev tsx scripts/db-migrations.ts backfill-internal-free-plans",
42+
"db:regen-internal-subscriptions-to-latest": "pnpm run with-env:dev tsx scripts/db-migrations.ts regen-internal-subscriptions-to-latest",
4243
"generate-migration-imports": "pnpm run with-env tsx scripts/generate-migration-imports.ts",
4344
"generate-migration-imports:watch": "chokidar 'prisma/migrations/**/*.sql' -c 'pnpm run generate-migration-imports'",
4445
"lint": "eslint .",

apps/backend/scripts/db-migrations.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { seed } from "../prisma/seed";
1111
import { runBackfillInternalFreePlans } from "./backfill-internal-free-plans";
1212
import { runBulldozerPaymentsInit } from "./bulldozer-payments-init";
1313
import { runClickhouseMigrations } from "./clickhouse-migrations";
14+
import { runRegenInternalSubscriptionsToLatest } from "./regen-internal-subscriptions-to-latest";
1415

1516
const getClickhouseClient = () => getClickhouseAdminClient();
1617

@@ -186,6 +187,10 @@ Commands:
186187
init Apply migrations and seed the database
187188
migrate Apply migrations
188189
backfill-internal-free-plans Grant the free plan to internal-tenancy teams that have no plan. Run AFTER seed.
190+
regen-internal-subscriptions-to-latest
191+
Bring every active internal-tenancy subscription up to the latest version of its
192+
product (rewrites the stored snapshot; rebases Stripe metadata for live subs).
193+
Idempotent. Run AFTER seed and AFTER backfill-internal-free-plans.
189194
help Show this help message
190195
191196
Options:
@@ -240,6 +245,15 @@ const main = async () => {
240245
await runBackfillInternalFreePlans();
241246
break;
242247
}
248+
case 'regen-internal-subscriptions-to-latest': {
249+
// Explicit step — callers must guarantee the internal tenancy has been
250+
// seeded. Bulldozer init runs first because the regen reads
251+
// `sub.product` via the Subscription LFold; without init the per-sub
252+
// equality check would compare against a stale view.
253+
await runBulldozerPaymentsInit(globalPrismaClient);
254+
await runRegenInternalSubscriptionsToLatest();
255+
break;
256+
}
243257
case 'help': {
244258
showHelp();
245259
break;
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/**
2+
* Brings every active subscription on Stack Auth's own billing project up
3+
* to the latest version of its plan. Runs at deploy / db init time.
4+
*
5+
* Why we need it: each Subscription stores a frozen JSON copy of the plan
6+
* it was bought on. When we edit a plan (raise a quota, add an
7+
* entitlement), existing customers don't see the change until something
8+
* rewrites that copy. Subs paid through Stripe also store a version
9+
* pointer in Stripe metadata, and we update that first — otherwise the
10+
* next webhook would put the DB right back to the old version.
11+
*
12+
* Safe to re-run: subs already on the latest version do nothing.
13+
*
14+
*/
15+
16+
import { Prisma } from "@/generated/prisma/client";
17+
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
18+
import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
19+
import type { ProductSnapshot, SubscriptionRow } from "@/lib/payments/schema/types";
20+
import { canonicalJsonStringify, computeProductVersionId, upsertProductVersion } from "@/lib/product-versions";
21+
import { getStripeForAccount } from "@/lib/stripe";
22+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts)
23+
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "@/lib/tenancies";
24+
import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client";
25+
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
26+
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
27+
import type Stripe from "stripe";
28+
29+
// Page size for streaming teams. Big enough to amortise round-trips,
30+
// small enough to not blow up memory on a million-team tenancy.
31+
const TEAM_BATCH_SIZE = 500;
32+
33+
// Just the slice of the Stripe SDK we use, so tests can pass a tiny mock.
34+
// Real Stripe clients are structurally compatible.
35+
export type StripeSubscriptionsClient = {
36+
retrieve(id: string): Promise<{ metadata: Stripe.Metadata | null }>,
37+
update(id: string, params: { metadata: Record<string, string | null> }): Promise<unknown>,
38+
};
39+
export type StripeClientForRegen = {
40+
subscriptions: StripeSubscriptionsClient,
41+
};
42+
43+
// Per-path tallies for the deploy log. Every scanned sub falls into
44+
// exactly one bucket (alreadyCurrent / one of the skipped-*'s) or into
45+
// `mutated`; subs in `mutated` may also tick `dbWrites` and/or
46+
// `stripeMetadataWrites` depending on which side(s) were stale.
47+
type Counters = {
48+
scannedTeams: number,
49+
scannedSubs: number,
50+
/** at least one write happened (DB and/or Stripe metadata). */
51+
mutated: number,
52+
/** the stored snapshot was rewritten to the latest plan. */
53+
dbWrites: number,
54+
/** the version pointer Stripe holds for this sub was updated. */
55+
stripeMetadataWrites: number,
56+
/** already on the latest plan; nothing to do. */
57+
alreadyCurrent: number,
58+
/** sub already ended, nothing to regenerate. */
59+
skippedEnded: number,
60+
/** sub has no productId (legacy / inline product); can't address. */
61+
skippedNullProductId: number,
62+
/** productId no longer exists in tenancy config (renamed/deleted plan). */
63+
skippedMissingProduct: number,
64+
/** per-sub try/catch fired; sub left as-is, next run will retry. */
65+
skippedFailures: number,
66+
};
67+
68+
function log(msg: string) {
69+
console.log(`[Regen][InternalSubs] ${msg}`);
70+
}
71+
72+
/**
73+
* Should we update the prod version metadata Stripe holds for this sub?
74+
* Only for real Stripe-backed subs. We never call live Stripe for
75+
* `TEST_MODE` subs even if they happen to have a Stripe id (dummy seed
76+
* data sometimes does this) — a fake id would just blow up
77+
* `subscriptions.retrieve` against real Stripe.
78+
*
79+
* The DB snapshot rewrite below happens regardless of this gate.
80+
*/
81+
function needsStripeMetadataRebase(sub: SubscriptionRow): boolean {
82+
return sub.stripeSubscriptionId != null && sub.creationSource !== "TEST_MODE";
83+
}
84+
85+
/**
86+
* Yields every billing team in the internal tenancy, page by page.
87+
* Same shape as the iterator in `backfill-internal-free-plans.ts`; kept
88+
* separate because the two scripts share nothing else.
89+
*
90+
* If `filter` is given, just yield those ids and skip the DB scan —
91+
* tests use this to scope to their own seeded teams.
92+
*/
93+
async function* iterateInternalTeamIds(
94+
internalTenancy: Tenancy,
95+
batchSize: number,
96+
filter?: ReadonlyArray<string>,
97+
): AsyncIterable<string> {
98+
if (filter != null) {
99+
for (const id of filter) yield id;
100+
return;
101+
}
102+
let cursor: string | null = null;
103+
while (true) {
104+
const batch: { teamId: string }[] = await globalPrismaClient.team.findMany({
105+
where: {
106+
tenancyId: internalTenancy.id,
107+
...(cursor != null ? { teamId: { gt: cursor } } : {}),
108+
},
109+
select: { teamId: true },
110+
orderBy: { teamId: "asc" },
111+
take: batchSize,
112+
});
113+
if (batch.length === 0) return;
114+
for (const { teamId } of batch) {
115+
yield teamId;
116+
}
117+
cursor = batch[batch.length - 1].teamId;
118+
}
119+
}
120+
121+
export async function runRegenInternalSubscriptionsToLatest(options: {
122+
/**
123+
* Test override. In production we lazily build one from the internal
124+
* tenancy on first need, so deploys without any Stripe-backed subs
125+
* don't need `STACK_STRIPE_SECRET_KEY` set.
126+
*/
127+
stripeClient?: StripeClientForRegen,
128+
/**
129+
* Test scope: process only these team ids and skip the DB enumeration.
130+
* Production callers omit this.
131+
*/
132+
teamIdsFilter?: ReadonlyArray<string>,
133+
} = {}): Promise<Counters> {
134+
const { teamIdsFilter } = options;
135+
136+
log("Starting...");
137+
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true);
138+
if (internalTenancy == null) {
139+
throw new StackAssertionError("Internal billing tenancy not found", {
140+
billingProjectId: "internal",
141+
branchId: DEFAULT_BRANCH_ID,
142+
});
143+
}
144+
145+
const counters: Counters = {
146+
scannedTeams: 0,
147+
scannedSubs: 0,
148+
mutated: 0,
149+
dbWrites: 0,
150+
stripeMetadataWrites: 0,
151+
alreadyCurrent: 0,
152+
skippedEnded: 0,
153+
skippedNullProductId: 0,
154+
skippedMissingProduct: 0,
155+
skippedFailures: 0,
156+
};
157+
158+
// Lazy, memoized Stripe client. We don't build it until we actually
159+
// hit a Stripe-backed sub. We cache the PROMISE (not its resolved
160+
// value), so if construction fails once (e.g. missing
161+
// STACK_STRIPE_SECRET_KEY), every later Stripe-backed sub trips the
162+
// per-sub failure handler instead of repeating the lookup N times.
163+
let stripePromise: Promise<StripeClientForRegen> | null = options.stripeClient != null
164+
? Promise.resolve(options.stripeClient)
165+
: null;
166+
const getStripe = () => stripePromise ??= (
167+
getStripeForAccount({ tenancy: internalTenancy }) as unknown as Promise<StripeClientForRegen>
168+
);
169+
170+
for await (const teamId of iterateInternalTeamIds(internalTenancy, TEAM_BATCH_SIZE, teamIdsFilter)) {
171+
counters.scannedTeams++;
172+
173+
const subMap = await getSubscriptionMapForCustomer({
174+
prisma: globalPrismaClient,
175+
tenancyId: internalTenancy.id,
176+
customerType: "team",
177+
customerId: teamId,
178+
});
179+
180+
for (const sub of Object.values(subMap)) {
181+
counters.scannedSubs++;
182+
try {
183+
const stripe: StripeClientForRegen | null = needsStripeMetadataRebase(sub)
184+
? await getStripe()
185+
: null;
186+
await regenSingleSubscription({
187+
internalTenancy,
188+
sub,
189+
stripe,
190+
counters,
191+
});
192+
} catch (e) {
193+
// Per-sub isolation: log and keep going. One broken sub should
194+
// never abort the whole migration. The most likely failure
195+
// here is a post-Prisma-commit Bulldozer dual-write — the next
196+
// run of this script heals it on its own (`sub.product` is
197+
// read from Bulldozer, so the equality check downstream sees
198+
// the stale snapshot and re-issues the write).
199+
counters.skippedFailures++;
200+
const err = e instanceof Error ? e : new Error(String(e));
201+
console.error(
202+
`[Regen][InternalSubs][sub=${sub.id}] Failed: ${err.message}`,
203+
err,
204+
);
205+
}
206+
}
207+
208+
if (counters.scannedTeams % 100 === 0) {
209+
log(`Progress: ${counters.scannedTeams} teams (subs scanned=${counters.scannedSubs}, mutated=${counters.mutated})`);
210+
}
211+
}
212+
213+
log("Done.");
214+
log(` Scanned : ${counters.scannedTeams} teams, ${counters.scannedSubs} subscriptions`);
215+
log(` Mutated : ${counters.mutated} subs (${counters.dbWrites} DB snapshot rewrites, ${counters.stripeMetadataWrites} Stripe metadata rebases)`);
216+
log(` Already current : ${counters.alreadyCurrent}`);
217+
log(` Skipped : ${counters.skippedEnded} ended, ${counters.skippedNullProductId} with null productId, ${counters.skippedMissingProduct} with productId not in config, ${counters.skippedFailures} per-sub failures`);
218+
return counters;
219+
}
220+
221+
/**
222+
* The per-sub unit of work. Exported so tests can exercise each code
223+
* path (stale snapshot, stale Stripe pointer, fresh, missing plan, etc.)
224+
* directly. May throw — the outer loop owns failure isolation.
225+
*/
226+
export async function regenSingleSubscription(args: {
227+
internalTenancy: Tenancy,
228+
sub: SubscriptionRow,
229+
/** Required whenever `needsStripeMetadataRebase(sub)` is true. */
230+
stripe: StripeClientForRegen | null,
231+
counters: Counters,
232+
}): Promise<void> {
233+
const { internalTenancy, sub, stripe, counters } = args;
234+
235+
const nowMillis = Date.now();
236+
if (sub.endedAtMillis != null && sub.endedAtMillis <= nowMillis) {
237+
counters.skippedEnded++;
238+
return;
239+
}
240+
if (sub.productId == null) {
241+
counters.skippedNullProductId++;
242+
return;
243+
}
244+
245+
const isStripeBacked = needsStripeMetadataRebase(sub);
246+
if (isStripeBacked && stripe == null) {
247+
throw new StackAssertionError(
248+
"regenSingleSubscription called for Stripe-backed sub without a stripe client",
249+
{ subId: sub.id, stripeSubscriptionId: sub.stripeSubscriptionId, creationSource: sub.creationSource },
250+
);
251+
}
252+
253+
const latestProduct = getOrUndefined(internalTenancy.config.payments.products, sub.productId);
254+
if (latestProduct == null) {
255+
counters.skippedMissingProduct++;
256+
console.warn(
257+
`[Regen][InternalSubs][sub=${sub.id}] productId=${sub.productId} no longer exists in internal tenancy config; skipping.`,
258+
);
259+
return;
260+
}
261+
262+
const newVersionId = computeProductVersionId(sub.productId, latestProduct);
263+
264+
// Snapshot equality via canonical JSON (sorted keys, undefineds
265+
// dropped). For pure-JSON ProductSnapshot this is a deep-equal. A
266+
// false negative would just cause one harmless extra rewrite.
267+
const dbSnapshotIsCurrent = canonicalJsonStringify(sub.product as unknown)
268+
=== canonicalJsonStringify(latestProduct);
269+
270+
// For Stripe-backed subs, also check the version pointer Stripe holds.
271+
// If it's stale, the next webhook would overwrite our DB rewrite by
272+
// re-pinning the sub to the old ProductVersion, so we have to rebase
273+
// it too.
274+
let stripeMetadataIsCurrent = true;
275+
let stripeExistingMetadata: Stripe.Metadata | Record<string, string | undefined> | null = null;
276+
if (isStripeBacked) {
277+
const stripeSub = await stripe!.subscriptions.retrieve(sub.stripeSubscriptionId!);
278+
stripeExistingMetadata = stripeSub.metadata ?? {};
279+
const existingVersionId = (stripeExistingMetadata as Record<string, string | undefined>).productVersionId;
280+
stripeMetadataIsCurrent = existingVersionId === newVersionId;
281+
}
282+
283+
if (dbSnapshotIsCurrent && stripeMetadataIsCurrent) {
284+
counters.alreadyCurrent++;
285+
return;
286+
}
287+
288+
// We're going to write at least one side, so make sure the
289+
// ProductVersion row exists first — the Stripe pointer below and any
290+
// downstream reader will dereference it. The id is a content hash, so
291+
// upsert is idempotent.
292+
await upsertProductVersion({
293+
prisma: globalPrismaClient,
294+
tenancyId: internalTenancy.id,
295+
productId: sub.productId,
296+
productJson: latestProduct,
297+
});
298+
299+
// Stripe FIRST, then DB. If the DB write throws afterwards, the next
300+
// webhook reads our updated Stripe pointer and re-pins the DB to the
301+
// new version — i.e. it self-heals. The opposite order would not.
302+
if (isStripeBacked && !stripeMetadataIsCurrent) {
303+
// Spread existing metadata and only override the version pointer.
304+
// Other write paths (purchase-session, switch) set metadata
305+
// wholesale because they own all the keys at create time. We don't,
306+
// so we preserve whatever is there (customerId, etc.).
307+
const merged: Record<string, string | null> = {
308+
...((stripeExistingMetadata ?? {}) as Record<string, string>),
309+
productVersionId: newVersionId,
310+
};
311+
await stripe!.subscriptions.update(sub.stripeSubscriptionId!, { metadata: merged });
312+
counters.stripeMetadataWrites++;
313+
log(`Updated Stripe metadata for sub=${sub.id} stripeSub=${sub.stripeSubscriptionId} productVersionId=${newVersionId}`);
314+
}
315+
316+
if (!dbSnapshotIsCurrent) {
317+
// Use the tenancy-aware prisma so we stay correct if `internal`
318+
// ever moves off the host DB.
319+
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
320+
const updated = await retryTransaction(internalPrisma, async (tx) => {
321+
return await tx.subscription.update({
322+
where: { tenancyId_id: { tenancyId: internalTenancy.id, id: sub.id } },
323+
data: { product: latestProduct as unknown as Prisma.InputJsonValue },
324+
});
325+
});
326+
// Bulldozer dual-write runs OUTSIDE the Prisma tx — it executes raw
327+
// SQL with its own BEGIN/COMMIT and would otherwise commit our
328+
// outer tx prematurely. Same pattern as `ensureFreePlanForBillingTeam`.
329+
//
330+
// If this raw write fails after the Prisma commit, the Bulldozer
331+
// stored row is left at the old snapshot. The NEXT run of this
332+
// script will detect and fix it: `subMap` is read from Bulldozer,
333+
// so the equality check above sees the stale snapshot and falls
334+
// into this branch again. The outer per-sub catch additionally
335+
// captures the failure to Sentry so the intermittent issue is
336+
// visible while it's happening.
337+
await bulldozerWriteSubscription(internalPrisma, updated);
338+
counters.dbWrites++;
339+
log(`Regenerated DB snapshot + bulldozer for sub=${sub.id} productId=${sub.productId} productVersionId=${newVersionId}`);
340+
}
341+
342+
counters.mutated++;
343+
}
344+
345+
// Exposed for tests that want to assert the equality semantics directly.
346+
export function isProductSnapshotCurrent(stored: ProductSnapshot, latest: ProductSnapshot): boolean {
347+
return canonicalJsonStringify(stored) === canonicalJsonStringify(latest);
348+
}

0 commit comments

Comments
 (0)