From b6a55110a3ef42efe56e8e814a999d56b964d48d Mon Sep 17 00:00:00 2001 From: nams1570 Date: Thu, 7 May 2026 12:05:13 -0700 Subject: [PATCH 1/2] feat(migration): script to backfill teams on internal This gives every team on internal proj w/o a plan the free plan. It is idempotent - only gives a plan if team has no other plans in the pricing plans product line. --- apps/backend/package.json | 1 + .../scripts/backfill-internal-free-plans.ts | 114 ++++++++++++++++++ apps/backend/scripts/db-migrations.ts | 24 +++- .../src/lib/payments/ensure-free-plan.test.ts | 68 ++++++++++- .../src/lib/payments/ensure-free-plan.ts | 28 +++-- package.json | 3 +- 6 files changed, 216 insertions(+), 22 deletions(-) create mode 100644 apps/backend/scripts/backfill-internal-free-plans.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 7b50f2baa0..b68f6c3889 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,6 +38,7 @@ "db:seed": "pnpm run with-env:dev tsx scripts/db-migrations.ts seed", "db:init": "pnpm run with-env:dev tsx scripts/db-migrations.ts init", "db:migrate": "pnpm run with-env:dev tsx scripts/db-migrations.ts migrate", + "db:backfill-internal-free-plans": "pnpm run with-env:dev tsx scripts/db-migrations.ts backfill-internal-free-plans", "generate-migration-imports": "pnpm run with-env tsx scripts/generate-migration-imports.ts", "generate-migration-imports:watch": "chokidar 'prisma/migrations/**/*.sql' -c 'pnpm run generate-migration-imports'", "lint": "eslint .", diff --git a/apps/backend/scripts/backfill-internal-free-plans.ts b/apps/backend/scripts/backfill-internal-free-plans.ts new file mode 100644 index 0000000000..89cbbf3fc9 --- /dev/null +++ b/apps/backend/scripts/backfill-internal-free-plans.ts @@ -0,0 +1,114 @@ +/** + * Grants the `free` plan to every billing team on Stack Auth's own + * billing project that doesn't already have a plan. Runs at deploy / + * db init time. + * + * Why we need it: we used to give the free plan implicitly via an + * "include-by-default" rule. Removing that left some old teams with no + * subscription at all, which made plan-limit checks (user count, + * analytics events, etc.) read 0 quota and reject every request. This + * script puts everyone back on a clean baseline. + * + * Safe to re-run: a team that already has a plan in the free product + * line is left alone. + */ + +import { ensureFreePlanForBillingTeam } from "@/lib/payments/ensure-free-plan"; +// eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts) +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "@/lib/tenancies"; +import { globalPrismaClient } from "@/prisma-client"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; + +// Page size for streaming teams. Big enough to amortise round-trips, +// small enough to stay tiny in memory (~18KB per page). +const TEAM_BATCH_SIZE = 500; + +function log(msg: string) { + console.log(`[Backfill][InternalFreePlans] ${msg}`); +} + +/** + * Yields every billing team in the internal tenancy, page by page, + * ordered by `teamId`. Keyset pagination (`teamId > cursor`) so this + * stays fast on tenancies with millions of teams. + */ +async function* iterateInternalTeamIds( + internalTenancy: Tenancy, + batchSize: number, +): AsyncIterable { + let cursor: string | null = null; + while (true) { + const batch: { teamId: string }[] = await globalPrismaClient.team.findMany({ + where: { + tenancyId: internalTenancy.id, + ...(cursor != null ? { teamId: { gt: cursor } } : {}), + }, + select: { teamId: true }, + orderBy: { teamId: "asc" }, + take: batchSize, + }); + if (batch.length === 0) return; + for (const { teamId } of batch) { + yield teamId; + } + cursor = batch[batch.length - 1].teamId; + } +} + +export async function runBackfillInternalFreePlans(): Promise<{ + granted: number, + failed: number, + total: number, +}> { + log("Starting..."); + const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true); + if (internalTenancy == null) { + throw new StackAssertionError("Internal billing tenancy not found", { + billingProjectId: "internal", + branchId: DEFAULT_BRANCH_ID, + }); + } + + // Fail fast if the `free` product is misconfigured. The grant call + // below silently no-ops in that case; raising here makes the deploy + // log point at the actual cause instead of "0 granted out of N teams". + const freePlanProduct = getOrUndefined(internalTenancy.config.payments.products, "free"); + if ( + freePlanProduct == null + || freePlanProduct.customerType !== "team" + || freePlanProduct.productLineId == null + ) { + throw new StackAssertionError( + "Internal tenancy `free` product is not configured as a team-typed, product-line-tagged plan; cannot run backfill", + { freePlanProduct }, + ); + } + + let granted = 0; + let failed = 0; + let total = 0; + + for await (const teamId of iterateInternalTeamIds(internalTenancy, TEAM_BATCH_SIZE)) { + total++; + try { + if (await ensureFreePlanForBillingTeam(teamId)) granted++; + } catch (e) { + // Per-team isolation: log and keep going. One team's transient + // DB blip shouldn't leave every later team unprocessed; the next + // run will retry whatever failed here. + failed++; + const err = e instanceof Error ? e : new Error(String(e)); + console.error( + `[Backfill][InternalFreePlans][team=${teamId}] Failed: ${err.message}`, + err, + ); + } + if (total % 100 === 0) { + log(`Progress: ${total} (granted=${granted}, failed=${failed})`); + } + } + + log(`Done. granted=${granted} failed=${failed} total=${total}`); + return { granted, failed, total }; +} diff --git a/apps/backend/scripts/db-migrations.ts b/apps/backend/scripts/db-migrations.ts index be5b168e05..4ba543b49c 100644 --- a/apps/backend/scripts/db-migrations.ts +++ b/apps/backend/scripts/db-migrations.ts @@ -8,6 +8,7 @@ import fs from "fs"; import path from "path"; import * as readline from "readline"; import { seed } from "../prisma/seed"; +import { runBackfillInternalFreePlans } from "./backfill-internal-free-plans"; import { runBulldozerPaymentsInit } from "./bulldozer-payments-init"; import { runClickhouseMigrations } from "./clickhouse-migrations"; @@ -179,12 +180,13 @@ const showHelp = () => { Usage: pnpm db-migrations [options] Commands: - reset Drop all data and recreate the database, then apply migrations and seed - generate-migration-file Generate a new migration file using Prisma, then reset and migrate - seed [Advanced] Run database seeding only - init Apply migrations and seed the database - migrate Apply migrations - help Show this help message + reset Drop all data and recreate the database, then apply migrations and seed + generate-migration-file Generate a new migration file using Prisma, then reset and migrate + seed [Advanced] Run database seeding only + init Apply migrations and seed the database + migrate Apply migrations + backfill-internal-free-plans Grant the free plan to internal-tenancy teams that have no plan. Run AFTER seed. + help Show this help message Options: --interactive Prompt before each new migration (not on conditional repeats) @@ -202,6 +204,7 @@ const main = async () => { await dropSchema(); await migrate(undefined, { interactive }); await seed(); + await runBulldozerPaymentsInit(globalPrismaClient); break; } case 'generate-migration-file': { @@ -228,6 +231,15 @@ const main = async () => { await runBulldozerPaymentsInit(globalPrismaClient); break; } + case 'backfill-internal-free-plans': { + // Explicit step — callers must guarantee the internal tenancy has been + // seeded before invoking this (the backfill throws loudly otherwise). + // Bulldozer init runs first so the Subscription LFold the backfill + // reads from is populated. + await runBulldozerPaymentsInit(globalPrismaClient); + await runBackfillInternalFreePlans(); + break; + } case 'help': { showHelp(); break; diff --git a/apps/backend/src/lib/payments/ensure-free-plan.test.ts b/apps/backend/src/lib/payments/ensure-free-plan.test.ts index 655597817f..91470f0686 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.test.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.test.ts @@ -84,7 +84,7 @@ describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => { prisma, }); - await ensureFreePlanForBillingTeam(billingTeamId); + expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(false); const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); expect(subs).toHaveLength(1); @@ -114,7 +114,7 @@ describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => { prisma, }); - await ensureFreePlanForBillingTeam(billingTeamId); + expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(false); const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); expect(subs).toHaveLength(1); @@ -125,19 +125,75 @@ describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => { const { tenancy, prisma } = await getInternal(); const billingTeamId = randomUUID(); - await ensureFreePlanForBillingTeam(billingTeamId); + expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(true); const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); expect(subs).toHaveLength(1); expect(subs[0].productId).toBe("free"); }); - it("idempotent: sequential double-call creates exactly one free sub", async () => { + it("idempotent: sequential double-call creates exactly one free sub (second call returns false)", async () => { const { tenancy, prisma } = await getInternal(); const billingTeamId = randomUUID(); - await ensureFreePlanForBillingTeam(billingTeamId); - await ensureFreePlanForBillingTeam(billingTeamId); + expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(true); + expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(false); + + const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); + expect(subs).toHaveLength(1); + expect(subs[0].productId).toBe("free"); + }); + + it("regression: a team whose only sub has ENDED is treated as orphaned and gets a fresh free grant", async () => { + // The "occupies the line" predicate gates on endedAt (not status), so a + // team whose only sub is canceled+ended in the past should be seen as + // orphaned and re-granted free. Pins this against the old "team has any + // sub" predicate that earlier scripts relied on. + const { tenancy, prisma } = await getInternal(); + const billingTeamId = randomUUID(); + + const teamProduct = getOrUndefined(tenancy.config.payments.products, "team"); + if (teamProduct == null) throw new Error("Internal tenancy missing `team` product"); + + const yesterday = new Date(Date.now() - 24 * 3600 * 1000); + const endedSubId = randomUUID(); + await bulldozerWriteSubscription(prisma, { + id: endedSubId, + tenancyId: tenancy.id, + customerId: billingTeamId, + customerType: "TEAM", + productId: "team", + priceId: null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ProductSnapshot is a structural JSON type + product: teamProduct as any, + quantity: 1, + stripeSubscriptionId: null, + status: "canceled", + currentPeriodStart: yesterday, + currentPeriodEnd: yesterday, + cancelAtPeriodEnd: false, + canceledAt: yesterday, + endedAt: yesterday, + refundedAt: null, + creationSource: "PURCHASE_PAGE", + createdAt: yesterday, + }); + + // Precondition: the team has exactly one sub on record, and it is the + // ended one (no unended subs exist). This is what makes the test + // meaningful — without it, a regression that ignored `endedAt` could + // still pass by virtue of some other unrelated sub being present. + const subMapBefore = await getSubscriptionMapForCustomer({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see `getUnendedSubsForTeam` + prisma: prisma as any, + tenancyId: tenancy.id, + customerType: "team", + customerId: billingTeamId, + }); + expect(Object.keys(subMapBefore)).toEqual([endedSubId]); + expect(await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma)).toHaveLength(0); + + expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(true); const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); expect(subs).toHaveLength(1); diff --git a/apps/backend/src/lib/payments/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts index 584338ee3c..ba9a112bc5 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -100,6 +100,14 @@ export async function createFreePlanSubscriptionRow(options: { * no-ops on misconfiguration, when a plan is already owned, or when a * concurrent caller already established the free sub. * + * Returns `true` iff this call actually inserted a new free-plan subscription + * row (i.e. the team really was orphaned in the source of truth and we held + * the SERIALIZABLE slot that wrote the row). All other paths — misconfig, + * fast-path occupancy hit, slow-path occupancy hit, lost-race concurrent + * insert — return `false`. The deploy-time backfill uses this to count + * orphaned-and-actually-granted teams accurately rather than maintaining its + * own (LFold-lagging) predicate. + * * Two-phase concurrency story: * * 1. Fast path — O(1) read against the `subscriptionMapByCustomer` @@ -126,11 +134,11 @@ export async function createFreePlanSubscriptionRow(options: { * directly, the SERIALIZABLE Prisma tx becomes a Bulldozer insert with * its own concurrency story. */ -export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promise { +export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promise { const internalTenancy = await getInternalBillingTenancy(); const freePlanProduct = getOrUndefined(internalTenancy.config.payments.products, "free"); if (freePlanProduct == null || freePlanProduct.customerType !== "team" || freePlanProduct.productLineId == null) { - return; + return false; } const freeProductLineId = freePlanProduct.productLineId; @@ -175,7 +183,7 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi customerId: billingTeamId, }); if (Object.values(subscriptionMap).some(productLineStillOccupiedBy)) { - return; + return false; } // Slow path: the team appears to have no occupying sub. Re-check under @@ -216,11 +224,13 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi }); }, { level: "serializable" }); - if (createdSub != null) { - // Bulldozer write happens outside the tx — it issues its own BEGIN/ - // COMMIT and can't nest. If it fails after the Prisma insert committed, - // the sub exists in Prisma but not yet in Bulldozer; same trade-off as - // all other dual-write call sites, reconciled by the next sync. - await bulldozerWriteSubscription(internalPrisma, createdSub); + if (createdSub == null) { + return false; } + // Bulldozer write happens outside the tx — it issues its own BEGIN/ + // COMMIT and can't nest. If it fails after the Prisma insert committed, + // the sub exists in Prisma but not yet in Bulldozer; same trade-off as + // all other dual-write call sites, reconciled by the next sync. + await bulldozerWriteSubscription(internalPrisma, createdSub); + return true; } diff --git a/package.json b/package.json index c65a24acb8..daa6e5b07a 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28 && pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34; do sleep 1; done", "wait-until-postgres-is-ready": "command -v pg_isready >/dev/null 2>&1 && pnpm run wait-until-postgres-is-ready:pg_isready || sleep 10 # not everyone has pg_isready installed, so we fallback to sleeping", "wait-until-clickhouse-is-ready": "pnpm exec wait-on http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36/ping", - "start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n", + "start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && pnpm run db:backfill-internal-free-plans && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n", "start-deps": "POSTGRES_DELAY_MS=${POSTGRES_DELAY_MS:-0} pnpm run start-deps:no-delay", "restart-deps": "pnpm pre && pnpm run stop-deps && pnpm run start-deps", "restart-deps:no-delay": "pnpm pre && pnpm run stop-deps && pnpm run start-deps:no-delay", @@ -49,6 +49,7 @@ "db:seed": "pnpm pre && pnpm run --filter=@stackframe/backend db:seed", "db:init": "pnpm pre && pnpm run --filter=@stackframe/backend db:init", "db:migrate": "pnpm pre && pnpm run --filter=@stackframe/backend db:migrate", + "db:backfill-internal-free-plans": "pnpm pre && pnpm run --filter=@stackframe/backend db:backfill-internal-free-plans", "fern": "pnpm pre && pnpm run --filter=@stackframe/docs fern", "dev:full": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999\"", "dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-openapi-docs:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/docs-mintlify --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo --filter=./examples/tanstack-start-demo \"", From f8132751599b0ef66485bc60534e31db9854460a Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 11 May 2026 15:07:39 -0700 Subject: [PATCH 2/2] 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. --- apps/backend/package.json | 1 + apps/backend/scripts/db-migrations.ts | 14 + .../regen-internal-subscriptions-to-latest.ts | 346 +++++++++++ ...n-internal-subscriptions-to-latest.test.ts | 557 ++++++++++++++++++ package.json | 3 +- 5 files changed, 920 insertions(+), 1 deletion(-) create mode 100644 apps/backend/scripts/regen-internal-subscriptions-to-latest.ts create mode 100644 apps/backend/src/scripts/regen-internal-subscriptions-to-latest.test.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index b68f6c3889..588888dbc7 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,6 +39,7 @@ "db:init": "pnpm run with-env:dev tsx scripts/db-migrations.ts init", "db:migrate": "pnpm run with-env:dev tsx scripts/db-migrations.ts migrate", "db:backfill-internal-free-plans": "pnpm run with-env:dev tsx scripts/db-migrations.ts backfill-internal-free-plans", + "db:regen-internal-subscriptions-to-latest": "pnpm run with-env:dev tsx scripts/db-migrations.ts regen-internal-subscriptions-to-latest", "generate-migration-imports": "pnpm run with-env tsx scripts/generate-migration-imports.ts", "generate-migration-imports:watch": "chokidar 'prisma/migrations/**/*.sql' -c 'pnpm run generate-migration-imports'", "lint": "eslint .", diff --git a/apps/backend/scripts/db-migrations.ts b/apps/backend/scripts/db-migrations.ts index 4ba543b49c..fb28523f43 100644 --- a/apps/backend/scripts/db-migrations.ts +++ b/apps/backend/scripts/db-migrations.ts @@ -11,6 +11,7 @@ import { seed } from "../prisma/seed"; import { runBackfillInternalFreePlans } from "./backfill-internal-free-plans"; import { runBulldozerPaymentsInit } from "./bulldozer-payments-init"; import { runClickhouseMigrations } from "./clickhouse-migrations"; +import { runRegenInternalSubscriptionsToLatest } from "./regen-internal-subscriptions-to-latest"; const getClickhouseClient = () => getClickhouseAdminClient(); @@ -186,6 +187,10 @@ Commands: init Apply migrations and seed the database migrate Apply migrations backfill-internal-free-plans Grant the free plan to internal-tenancy teams that have no plan. Run AFTER seed. + regen-internal-subscriptions-to-latest + Bring every active internal-tenancy subscription up to the latest version of its + product (rewrites the stored snapshot; rebases Stripe metadata for live subs). + Idempotent. Run AFTER seed and AFTER backfill-internal-free-plans. help Show this help message Options: @@ -240,6 +245,15 @@ const main = async () => { await runBackfillInternalFreePlans(); break; } + case 'regen-internal-subscriptions-to-latest': { + // Explicit step — callers must guarantee the internal tenancy has been + // seeded. Bulldozer init runs first because the regen reads + // `sub.product` via the Subscription LFold; without init the per-sub + // equality check would compare against a stale view. + await runBulldozerPaymentsInit(globalPrismaClient); + await runRegenInternalSubscriptionsToLatest(); + break; + } case 'help': { showHelp(); break; diff --git a/apps/backend/scripts/regen-internal-subscriptions-to-latest.ts b/apps/backend/scripts/regen-internal-subscriptions-to-latest.ts new file mode 100644 index 0000000000..5bf6dfc24f --- /dev/null +++ b/apps/backend/scripts/regen-internal-subscriptions-to-latest.ts @@ -0,0 +1,346 @@ +/** + * Brings every active subscription on Stack Auth's own billing project up + * to the latest version of its plan. Runs at deploy / db init time. + * + * Why we need it: each Subscription stores a frozen JSON copy of the plan + * it was bought on. When we edit a plan (raise a quota, add an + * entitlement), existing customers don't see the change until something + * rewrites that copy. Subs paid through Stripe also store a version + * pointer in Stripe metadata, and we update that first — otherwise the + * next webhook would put the DB right back to the old version. + * + * Safe to re-run: subs already on the latest version do nothing. + * + */ + +import { Prisma } from "@/generated/prisma/client"; +import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; +import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +import type { ProductSnapshot, SubscriptionRow } from "@/lib/payments/schema/types"; +import { canonicalJsonStringify, computeProductVersionId, upsertProductVersion } from "@/lib/product-versions"; +import { getStripeForAccount } from "@/lib/stripe"; +// eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts) +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import type Stripe from "stripe"; + +// Page size for streaming teams. Big enough to amortise round-trips, +// small enough to not blow up memory on a million-team tenancy. +const TEAM_BATCH_SIZE = 500; + +// Just the slice of the Stripe SDK we use, so tests can pass a tiny mock. +// Real Stripe clients are structurally compatible. +export type StripeSubscriptionsClient = { + retrieve(id: string): Promise<{ metadata: Stripe.Metadata | null }>, + update(id: string, params: { metadata: Record }): Promise, +}; +export type StripeClientForRegen = { + subscriptions: StripeSubscriptionsClient, +}; + +// Per-path tallies for the deploy log. Every scanned sub falls into +// exactly one bucket (alreadyCurrent / one of the skipped-*'s) or into +// `mutated`; subs in `mutated` may also tick `dbWrites` and/or +// `stripeMetadataWrites` depending on which side(s) were stale. +type Counters = { + scannedTeams: number, + scannedSubs: number, + /** at least one write happened (DB and/or Stripe metadata). */ + mutated: number, + /** the stored snapshot was rewritten to the latest plan. */ + dbWrites: number, + /** the version pointer Stripe holds for this sub was updated. */ + stripeMetadataWrites: number, + /** already on the latest plan; nothing to do. */ + alreadyCurrent: number, + /** sub already ended, nothing to regenerate. */ + skippedEnded: number, + /** sub has no productId (legacy / inline product); can't address. */ + skippedNullProductId: number, + /** productId no longer exists in tenancy config (renamed/deleted plan). */ + skippedMissingProduct: number, + /** per-sub try/catch fired; sub left as-is, next run will retry. */ + skippedFailures: number, +}; + +function log(msg: string) { + console.log(`[Regen][InternalSubs] ${msg}`); +} + +/** + * Should we update the prod version metadata Stripe holds for this sub? + * Only for real Stripe-backed subs. We never call live Stripe for + * `TEST_MODE` subs even if they happen to have a Stripe id (dummy seed + * data sometimes does this) — a fake id would just blow up + * `subscriptions.retrieve` against real Stripe. + * + * The DB snapshot rewrite below happens regardless of this gate. + */ +function needsStripeMetadataRebase(sub: SubscriptionRow): boolean { + return sub.stripeSubscriptionId != null && sub.creationSource !== "TEST_MODE"; +} + +/** + * Yields every billing team in the internal tenancy, page by page. + * Same shape as the iterator in `backfill-internal-free-plans.ts`; kept + * separate because the two scripts share nothing else. + * + * If `filter` is given, just yield those ids and skip the DB scan — + * tests use this to scope to their own seeded teams. + */ +async function* iterateInternalTeamIds( + internalTenancy: Tenancy, + batchSize: number, + filter?: ReadonlyArray, +): AsyncIterable { + if (filter != null) { + for (const id of filter) yield id; + return; + } + let cursor: string | null = null; + while (true) { + const batch: { teamId: string }[] = await globalPrismaClient.team.findMany({ + where: { + tenancyId: internalTenancy.id, + ...(cursor != null ? { teamId: { gt: cursor } } : {}), + }, + select: { teamId: true }, + orderBy: { teamId: "asc" }, + take: batchSize, + }); + if (batch.length === 0) return; + for (const { teamId } of batch) { + yield teamId; + } + cursor = batch[batch.length - 1].teamId; + } +} + +export async function runRegenInternalSubscriptionsToLatest(options: { + /** + * Test override. In production we lazily build one from the internal + * tenancy on first need, so deploys without any Stripe-backed subs + * don't need `STACK_STRIPE_SECRET_KEY` set. + */ + stripeClient?: StripeClientForRegen, + /** + * Test scope: process only these team ids and skip the DB enumeration. + * Production callers omit this. + */ + teamIdsFilter?: ReadonlyArray, +} = {}): Promise { + const { teamIdsFilter } = options; + + log("Starting..."); + const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true); + if (internalTenancy == null) { + throw new StackAssertionError("Internal billing tenancy not found", { + billingProjectId: "internal", + branchId: DEFAULT_BRANCH_ID, + }); + } + + const counters: Counters = { + scannedTeams: 0, + scannedSubs: 0, + mutated: 0, + dbWrites: 0, + stripeMetadataWrites: 0, + alreadyCurrent: 0, + skippedEnded: 0, + skippedNullProductId: 0, + skippedMissingProduct: 0, + skippedFailures: 0, + }; + + // Lazy, memoized Stripe client. We don't build it until we actually + // hit a Stripe-backed sub. We cache the PROMISE (not its resolved + // value), so if construction fails once (e.g. missing + // STACK_STRIPE_SECRET_KEY), every later Stripe-backed sub trips the + // per-sub failure handler instead of repeating the lookup N times. + let stripePromise: Promise | null = options.stripeClient != null + ? Promise.resolve(options.stripeClient) + : null; + const getStripe = () => stripePromise ??= getStripeForAccount({ tenancy: internalTenancy }); + + for await (const teamId of iterateInternalTeamIds(internalTenancy, TEAM_BATCH_SIZE, teamIdsFilter)) { + counters.scannedTeams++; + + const subMap = await getSubscriptionMapForCustomer({ + prisma: globalPrismaClient, + tenancyId: internalTenancy.id, + customerType: "team", + customerId: teamId, + }); + + for (const sub of Object.values(subMap)) { + counters.scannedSubs++; + try { + const stripe: StripeClientForRegen | null = needsStripeMetadataRebase(sub) + ? await getStripe() + : null; + await regenSingleSubscription({ + internalTenancy, + sub, + stripe, + counters, + }); + } catch (e) { + // Per-sub isolation: log and keep going. One broken sub should + // never abort the whole migration. The most likely failure + // here is a post-Prisma-commit Bulldozer dual-write — the next + // run of this script heals it on its own (`sub.product` is + // read from Bulldozer, so the equality check downstream sees + // the stale snapshot and re-issues the write). + counters.skippedFailures++; + const err = e instanceof Error ? e : new Error(String(e)); + console.error( + `[Regen][InternalSubs][sub=${sub.id}] Failed: ${err.message}`, + err, + ); + } + } + + if (counters.scannedTeams % 100 === 0) { + log(`Progress: ${counters.scannedTeams} teams (subs scanned=${counters.scannedSubs}, mutated=${counters.mutated})`); + } + } + + log("Done."); + log(` Scanned : ${counters.scannedTeams} teams, ${counters.scannedSubs} subscriptions`); + log(` Mutated : ${counters.mutated} subs (${counters.dbWrites} DB snapshot rewrites, ${counters.stripeMetadataWrites} Stripe metadata rebases)`); + log(` Already current : ${counters.alreadyCurrent}`); + log(` Skipped : ${counters.skippedEnded} ended, ${counters.skippedNullProductId} with null productId, ${counters.skippedMissingProduct} with productId not in config, ${counters.skippedFailures} per-sub failures`); + return counters; +} + +/** + * The per-sub unit of work. Exported so tests can exercise each code + * path (stale snapshot, stale Stripe pointer, fresh, missing plan, etc.) + * directly. May throw — the outer loop owns failure isolation. + */ +export async function regenSingleSubscription(args: { + internalTenancy: Tenancy, + sub: SubscriptionRow, + /** Required whenever `needsStripeMetadataRebase(sub)` is true. */ + stripe: StripeClientForRegen | null, + counters: Counters, +}): Promise { + const { internalTenancy, sub, stripe, counters } = args; + + const nowMillis = Date.now(); + if (sub.endedAtMillis != null && sub.endedAtMillis <= nowMillis) { + counters.skippedEnded++; + return; + } + if (sub.productId == null) { + counters.skippedNullProductId++; + return; + } + + const isStripeBacked = needsStripeMetadataRebase(sub); + if (isStripeBacked && stripe == null) { + throw new StackAssertionError( + "regenSingleSubscription called for Stripe-backed sub without a stripe client", + { subId: sub.id, stripeSubscriptionId: sub.stripeSubscriptionId, creationSource: sub.creationSource }, + ); + } + + const latestProduct = getOrUndefined(internalTenancy.config.payments.products, sub.productId); + if (latestProduct == null) { + counters.skippedMissingProduct++; + console.warn( + `[Regen][InternalSubs][sub=${sub.id}] productId=${sub.productId} no longer exists in internal tenancy config; skipping.`, + ); + return; + } + + const newVersionId = computeProductVersionId(sub.productId, latestProduct); + + // Snapshot equality via canonical JSON (sorted keys, undefineds + // dropped). For pure-JSON ProductSnapshot this is a deep-equal. A + // false negative would just cause one harmless extra rewrite. + const dbSnapshotIsCurrent = canonicalJsonStringify(sub.product as unknown) + === canonicalJsonStringify(latestProduct); + + // For Stripe-backed subs, also check the version pointer Stripe holds. + // If it's stale, the next webhook would overwrite our DB rewrite by + // re-pinning the sub to the old ProductVersion, so we have to rebase + // it too. + let stripeMetadataIsCurrent = true; + let stripeExistingMetadata: Stripe.Metadata | Record | null = null; + if (isStripeBacked) { + const stripeSub = await stripe!.subscriptions.retrieve(sub.stripeSubscriptionId!); + stripeExistingMetadata = stripeSub.metadata ?? {}; + const existingVersionId = (stripeExistingMetadata as Record).productVersionId; + stripeMetadataIsCurrent = existingVersionId === newVersionId; + } + + if (dbSnapshotIsCurrent && stripeMetadataIsCurrent) { + counters.alreadyCurrent++; + return; + } + + // We're going to write at least one side, so make sure the + // ProductVersion row exists first — the Stripe pointer below and any + // downstream reader will dereference it. The id is a content hash, so + // upsert is idempotent. + await upsertProductVersion({ + prisma: globalPrismaClient, + tenancyId: internalTenancy.id, + productId: sub.productId, + productJson: latestProduct, + }); + + // Stripe FIRST, then DB. If the DB write throws afterwards, the next + // webhook reads our updated Stripe pointer and re-pins the DB to the + // new version — i.e. it self-heals. The opposite order would not. + if (isStripeBacked && !stripeMetadataIsCurrent) { + // Spread existing metadata and only override the version pointer. + // Other write paths (purchase-session, switch) set metadata + // wholesale because they own all the keys at create time. We don't, + // so we preserve whatever is there (customerId, etc.). + const merged: Record = { + ...((stripeExistingMetadata ?? {}) as Record), + productVersionId: newVersionId, + }; + await stripe!.subscriptions.update(sub.stripeSubscriptionId!, { metadata: merged }); + counters.stripeMetadataWrites++; + log(`Updated Stripe metadata for sub=${sub.id} stripeSub=${sub.stripeSubscriptionId} productVersionId=${newVersionId}`); + } + + if (!dbSnapshotIsCurrent) { + // Use the tenancy-aware prisma so we stay correct if `internal` + // ever moves off the host DB. + const internalPrisma = await getPrismaClientForTenancy(internalTenancy); + const updated = await retryTransaction(internalPrisma, async (tx) => { + return await tx.subscription.update({ + where: { tenancyId_id: { tenancyId: internalTenancy.id, id: sub.id } }, + data: { product: latestProduct as unknown as Prisma.InputJsonValue }, + }); + }); + // Bulldozer dual-write runs OUTSIDE the Prisma tx — it executes raw + // SQL with its own BEGIN/COMMIT and would otherwise commit our + // outer tx prematurely. Same pattern as `ensureFreePlanForBillingTeam`. + // + // If this raw write fails after the Prisma commit, the Bulldozer + // stored row is left at the old snapshot. The NEXT run of this + // script will detect and fix it: `subMap` is read from Bulldozer, + // so the equality check above sees the stale snapshot and falls + // into this branch again. The outer per-sub catch additionally + // captures the failure to Sentry so the intermittent issue is + // visible while it's happening. + await bulldozerWriteSubscription(internalPrisma, updated); + counters.dbWrites++; + log(`Regenerated DB snapshot + bulldozer for sub=${sub.id} productId=${sub.productId} productVersionId=${newVersionId}`); + } + + counters.mutated++; +} + +// Exposed for tests that want to assert the equality semantics directly. +export function isProductSnapshotCurrent(stored: ProductSnapshot, latest: ProductSnapshot): boolean { + return canonicalJsonStringify(stored) === canonicalJsonStringify(latest); +} diff --git a/apps/backend/src/scripts/regen-internal-subscriptions-to-latest.test.ts b/apps/backend/src/scripts/regen-internal-subscriptions-to-latest.test.ts new file mode 100644 index 0000000000..6cfef36261 --- /dev/null +++ b/apps/backend/src/scripts/regen-internal-subscriptions-to-latest.test.ts @@ -0,0 +1,557 @@ +import { randomUUID } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type Stripe from "stripe"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import { + runRegenInternalSubscriptionsToLatest, + type StripeClientForRegen, +} from "../../scripts/regen-internal-subscriptions-to-latest"; +import { Prisma, PurchaseCreationSource, SubscriptionStatus, CustomerType } from "@/generated/prisma/client"; +import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; +import { getItemQuantityForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +import type { ProductSnapshot } from "@/lib/payments/schema/types"; +import { canonicalJsonStringify, computeProductVersionId } from "@/lib/product-versions"; +// eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; + +type StripeCall = + | { kind: "retrieve", id: string } + | { kind: "update", id: string, metadata: Record }; + +function makeStripeMock(initial: Record): { + client: StripeClientForRegen, + calls: StripeCall[], +} { + const calls: StripeCall[] = []; + const client: StripeClientForRegen = { + subscriptions: { + retrieve: async (id: string) => { + calls.push({ kind: "retrieve", id }); + const metadata = initial[id] ?? {}; + return { metadata }; + }, + update: async (id: string, params: { metadata: Record }) => { + calls.push({ kind: "update", id, metadata: params.metadata }); + // Reflect into the "stored" map so subsequent retrieves see the + // update — useful for idempotency tests. + const filtered: Record = {}; + for (const [k, v] of Object.entries(params.metadata)) { + if (v != null) filtered[k] = v; + } + initial[id] = filtered as Stripe.Metadata; + return {}; + }, + }, + }; + return { client, calls }; +} + +describe.sequential("runRegenInternalSubscriptionsToLatest (real DB)", () => { + async function getInternal() { + const tenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true); + if (tenancy == null) throw new Error("Internal billing tenancy not found"); + const prisma = await getPrismaClientForTenancy(tenancy); + return { tenancy, prisma }; + } + + async function processBulldozerQueue() { + // Drain Bulldozer's queue so downstream views (item quantities etc.) + // catch up. Production has pg_cron doing this every second; in + // tests we trigger it by hand. + await globalPrismaClient.$executeRaw`SELECT public.bulldozer_timefold_process_queue()`; + } + + /** Seeds a Subscription in both Prisma and Bulldozer, like a real grant would. */ + async function seedSubscription(args: { + tenancyId: string, + teamId: string, + productId: string, + productSnapshot: ProductSnapshot, + stripeSubscriptionId?: string | null, + creationSource?: PurchaseCreationSource, + endedAt?: Date | null, + }): Promise<{ id: string }> { + const { tenancyId, teamId, productId, productSnapshot } = args; + const stripeSubId = args.stripeSubscriptionId ?? null; + const creationSource = args.creationSource ?? PurchaseCreationSource.PURCHASE_PAGE; + const endedAt = args.endedAt ?? null; + const now = new Date(); + const sub = await globalPrismaClient.subscription.create({ + data: { + tenancyId, + customerId: teamId, + customerType: CustomerType.TEAM, + status: SubscriptionStatus.active, + productId, + priceId: null, + product: productSnapshot as unknown as Prisma.InputJsonValue, + quantity: 1, + currentPeriodStart: now, + currentPeriodEnd: new Date(now.getTime() + 30 * 24 * 3600 * 1000), + cancelAtPeriodEnd: false, + creationSource, + stripeSubscriptionId: stripeSubId, + endedAt, + }, + }); + await bulldozerWriteSubscription(globalPrismaClient, sub); + await processBulldozerQueue(); + return { id: sub.id }; + } + + async function getSub(tenancyId: string, id: string) { + return await globalPrismaClient.subscription.findUniqueOrThrow({ + where: { tenancyId_id: { tenancyId, id } }, + }); + } + + async function getSubMap(tenancyId: string, teamId: string) { + return await getSubscriptionMapForCustomer({ + prisma: globalPrismaClient, + tenancyId, + customerType: "team", + customerId: teamId, + }); + } + + /** + * Drops a specific item from `product.includedItems`. Used by tests + * that need to control *which* item is removed (e.g. so they can + * later assert how its quantity recomputes). Most tests should use + * `makeStale` instead. + */ + function withoutItem(product: ProductSnapshot, itemId: string): ProductSnapshot { + const { [itemId]: _omit, ...rest } = product.includedItems; + return { ...product, includedItems: rest }; + } + + /** + * Returns a copy of `product` with the first included item dropped. + * Tests that just need *something* to differ from the latest config + * call this; they don't care which item is missing. + * + * The `?? throwErr` on the empty-keys case is deliberate: without it, + * `withoutItem(p, undefined)` would silently return `p` unchanged + * and the next `expect(result.mutated).toBe(1)` would fail for an + * unrelated-looking reason. + */ + function makeStale(product: ProductSnapshot): ProductSnapshot { + const itemId = Object.keys(product.includedItems)[0] + ?? throwErr( + "makeStale: product has no includedItems to drop, cannot construct a stale snapshot", + { product }, + ); + return withoutItem(product, itemId); + } + + // Spy types differ between vitest minor versions; let TS infer them. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- spy types differ between vitest minor versions; let TS infer them + let warnSpy: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see warnSpy above + let errorSpy: any; + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => { + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it("in-product stale sub: rewrites Subscription.product, dual-writes Bulldozer, never calls Stripe", async () => { + const { tenancy } = await getInternal(); + const teamId = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + + // Seed with a stale snapshot (missing one item) and no Stripe link. + const stale = makeStale(growth); + const { id } = await seedSubscription({ + tenancyId: tenancy.id, + teamId, + productId: "growth", + productSnapshot: stale, + stripeSubscriptionId: null, + creationSource: PurchaseCreationSource.API_GRANT, + }); + + const stripe = makeStripeMock({}); + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [teamId], + stripeClient: stripe.client, + }); + + expect(result.mutated).toBe(1); + expect(result.dbWrites).toBe(1); + expect(result.stripeMetadataWrites).toBe(0); + expect(result.alreadyCurrent).toBe(0); + expect(result.skippedFailures).toBe(0); + // No Stripe interaction at all for in-product subs. + expect(stripe.calls).toHaveLength(0); + + const updated = await getSub(tenancy.id, id); + expect(canonicalJsonStringify(updated.product)).toBe(canonicalJsonStringify(growth)); + + const subMap = await getSubMap(tenancy.id, teamId); + expect(canonicalJsonStringify(subMap[id].product)).toBe(canonicalJsonStringify(growth)); + }); + + it("in-product fresh sub: no DB write, no Bulldozer write, no Stripe call", async () => { + const { tenancy } = await getInternal(); + const teamId = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + + const { id } = await seedSubscription({ + tenancyId: tenancy.id, + teamId, + productId: "growth", + productSnapshot: growth, + stripeSubscriptionId: null, + creationSource: PurchaseCreationSource.API_GRANT, + }); + const before = await getSub(tenancy.id, id); + + const stripe = makeStripeMock({}); + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [teamId], + stripeClient: stripe.client, + }); + + expect(result.mutated).toBe(0); + expect(result.dbWrites).toBe(0); + expect(result.stripeMetadataWrites).toBe(0); + expect(result.alreadyCurrent).toBe(1); + expect(stripe.calls).toHaveLength(0); + + const after = await getSub(tenancy.id, id); + expect(after.updatedAt.getTime()).toBe(before.updatedAt.getTime()); + }); + + it("Stripe-backed stale sub: updates Stripe metadata FIRST, then DB + bulldozer; ordering is enforced", async () => { + const { tenancy } = await getInternal(); + const teamId = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + const stale = makeStale(growth); + + const stripeSubId = `stripe-${randomUUID()}`; + const oldVersionId = computeProductVersionId("growth", stale); + const { id } = await seedSubscription({ + tenancyId: tenancy.id, + teamId, + productId: "growth", + productSnapshot: stale, + stripeSubscriptionId: stripeSubId, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + }); + const newVersionId = computeProductVersionId("growth", growth); + expect(newVersionId).not.toBe(oldVersionId); + + const stripe = makeStripeMock({ [stripeSubId]: { productVersionId: oldVersionId, priceId: "abc" } }); + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [teamId], + stripeClient: stripe.client, + }); + + expect(result.mutated).toBe(1); + expect(result.dbWrites).toBe(1); + expect(result.stripeMetadataWrites).toBe(1); + expect(result.alreadyCurrent).toBe(0); + expect(result.skippedFailures).toBe(0); + + // Should have made exactly one retrieve + one update. + const retrieves = stripe.calls.filter((c) => c.kind === "retrieve"); + const updates = stripe.calls.filter((c) => c.kind === "update"); + expect(retrieves).toHaveLength(1); + expect(updates).toHaveLength(1); + expect(retrieves[0].id).toBe(stripeSubId); + expect(updates[0]).toMatchObject({ + kind: "update", + id: stripeSubId, + metadata: { productVersionId: newVersionId, priceId: "abc" }, + }); + + const updated = await getSub(tenancy.id, id); + expect(canonicalJsonStringify(updated.product)).toBe(canonicalJsonStringify(growth)); + }); + + it("Stripe-backed fresh sub: no Stripe update, no DB write, no Bulldozer write", async () => { + const { tenancy } = await getInternal(); + const teamId = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + + const stripeSubId = `stripe-${randomUUID()}`; + const currentVersionId = computeProductVersionId("growth", growth); + const { id } = await seedSubscription({ + tenancyId: tenancy.id, + teamId, + productId: "growth", + productSnapshot: growth, + stripeSubscriptionId: stripeSubId, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + }); + + const stripe = makeStripeMock({ [stripeSubId]: { productVersionId: currentVersionId } }); + const before = await getSub(tenancy.id, id); + + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [teamId], + stripeClient: stripe.client, + }); + + expect(result.mutated).toBe(0); + expect(result.dbWrites).toBe(0); + expect(result.stripeMetadataWrites).toBe(0); + expect(result.alreadyCurrent).toBe(1); + // We DO retrieve to check current metadata, but never call update. + expect(stripe.calls.filter((c) => c.kind === "update")).toHaveLength(0); + + const after = await getSub(tenancy.id, id); + expect(after.updatedAt.getTime()).toBe(before.updatedAt.getTime()); + }); + + it("TEST_MODE sub with a non-null stripeSubscriptionId is treated as pure-DB: snapshot rewritten, Stripe never called", async () => { + // Regression: TEST_MODE subs are simulated entirely in the DB, but + // some old/dummy data has a Stripe id set. We must never call live + // Stripe for them, but we still want their snapshot upgraded. + const { tenancy } = await getInternal(); + const teamId = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + const stale = makeStale(growth); + + const fakeStripeSubId = `stripe-${randomUUID()}`; + const { id } = await seedSubscription({ + tenancyId: tenancy.id, + teamId, + productId: "growth", + productSnapshot: stale, + stripeSubscriptionId: fakeStripeSubId, + creationSource: PurchaseCreationSource.TEST_MODE, + }); + + const stripe = makeStripeMock({}); + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [teamId], + stripeClient: stripe.client, + }); + + expect(result.mutated).toBe(1); + expect(result.dbWrites).toBe(1); + expect(result.stripeMetadataWrites).toBe(0); + expect(result.alreadyCurrent).toBe(0); + expect(result.skippedFailures).toBe(0); + expect(stripe.calls).toHaveLength(0); + + const updated = await getSub(tenancy.id, id); + expect(canonicalJsonStringify(updated.product)).toBe(canonicalJsonStringify(growth)); + }); + + it("Stripe failure on one sub doesn't break the loop: a sibling stale sub still gets regenerated", async () => { + const { tenancy } = await getInternal(); + const failingTeam = randomUUID(); + const healthyTeam = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + const stale = makeStale(growth); + + const failingStripeId = `stripe-${randomUUID()}`; + const oldVersionId = computeProductVersionId("growth", stale); + + await seedSubscription({ + tenancyId: tenancy.id, + teamId: failingTeam, + productId: "growth", + productSnapshot: stale, + stripeSubscriptionId: failingStripeId, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + }); + const { id: healthyId } = await seedSubscription({ + tenancyId: tenancy.id, + teamId: healthyTeam, + productId: "growth", + productSnapshot: stale, + stripeSubscriptionId: null, + creationSource: PurchaseCreationSource.API_GRANT, + }); + + // Build a Stripe mock whose .update throws specifically for the + // failing sub. + const initialMeta: Record = { + [failingStripeId]: { productVersionId: oldVersionId }, + }; + const calls: StripeCall[] = []; + const stripeClient: StripeClientForRegen = { + subscriptions: { + retrieve: async (id: string) => { + calls.push({ kind: "retrieve", id }); + return { metadata: initialMeta[id] ?? {} }; + }, + update: async (id: string, params: { metadata: Record }) => { + calls.push({ kind: "update", id, metadata: params.metadata }); + if (id === failingStripeId) { + throw new Error("Simulated Stripe outage"); + } + return {}; + }, + }, + }; + + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [failingTeam, healthyTeam], + stripeClient, + }); + + expect(result.skippedFailures).toBe(1); + // The healthy in-product sub should still have been regenerated even + // though the Stripe sub failed first. + expect(result.mutated).toBe(1); + expect(result.dbWrites).toBe(1); + expect(result.stripeMetadataWrites).toBe(0); + + const healthy = await getSub(tenancy.id, healthyId); + expect(canonicalJsonStringify(healthy.product)).toBe(canonicalJsonStringify(growth)); + + // Error logged. + expect(errorSpy).toHaveBeenCalled(); + }); + + it("sub with productId no longer in tenancy config: warns and skips, no writes", async () => { + const { tenancy } = await getInternal(); + const teamId = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + + // Seed a sub whose productId is not in config — we still need a real + // snapshot for the Prisma row, so we use growth's shape. + const { id } = await seedSubscription({ + tenancyId: tenancy.id, + teamId, + productId: "ghost-product-that-does-not-exist", + productSnapshot: growth, + stripeSubscriptionId: null, + creationSource: PurchaseCreationSource.API_GRANT, + }); + const before = await getSub(tenancy.id, id); + + const stripe = makeStripeMock({}); + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [teamId], + stripeClient: stripe.client, + }); + + expect(result.skippedMissingProduct).toBe(1); + expect(result.mutated).toBe(0); + expect(result.dbWrites).toBe(0); + expect(warnSpy).toHaveBeenCalled(); + expect(stripe.calls).toHaveLength(0); + + const after = await getSub(tenancy.id, id); + expect(after.updatedAt.getTime()).toBe(before.updatedAt.getTime()); + }); + + it("ended subs are skipped (filter excludes them)", async () => { + const { tenancy } = await getInternal(); + const teamId = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + const stale = makeStale(growth); + + const yesterday = new Date(Date.now() - 24 * 3600 * 1000); + const { id } = await seedSubscription({ + tenancyId: tenancy.id, + teamId, + productId: "growth", + productSnapshot: stale, + stripeSubscriptionId: null, + creationSource: PurchaseCreationSource.API_GRANT, + endedAt: yesterday, + }); + const before = await getSub(tenancy.id, id); + + const stripe = makeStripeMock({}); + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [teamId], + stripeClient: stripe.client, + }); + + expect(result.mutated).toBe(0); + expect(result.skippedEnded).toBe(1); + expect(stripe.calls).toHaveLength(0); + + const after = await getSub(tenancy.id, id); + expect(canonicalJsonStringify(after.product)).toBe(canonicalJsonStringify(stale)); + expect(after.updatedAt.getTime()).toBe(before.updatedAt.getTime()); + }); + + it("Bulldozer end-to-end: after regen, getItemQuantityForCustomer for a newly-added item returns the expected non-zero quantity", async () => { + const { tenancy } = await getInternal(); + const teamId = randomUUID(); + const growth = getOrUndefined(tenancy.config.payments.products, "growth"); + if (growth == null) throw new Error("Internal tenancy missing `growth` product"); + + // Pick an existing item (e.g. analytics_events) and forge a "stale" + // snapshot that's missing it. After the regen, its quantity in + // payments-item-quantities should reflect what `growth.includedItems` + // says for that item. This proves the TimeFold → LFold chain + // recomputed from the fresh snapshot, which is what we depend on. + const candidateItemId = Object.keys(growth.includedItems).find( + (k) => { + const itemConfig = growth.includedItems[k]; + const q = (itemConfig as { quantity?: unknown }).quantity; + return typeof q === "number" && q > 0; + }, + ); + if (candidateItemId == null) { + throw new Error("growth product has no positive-quantity included item to use for this test"); + } + const stale = withoutItem(growth, candidateItemId); + const expectedQuantity = (growth.includedItems[candidateItemId] as { quantity: number }).quantity; + + await seedSubscription({ + tenancyId: tenancy.id, + teamId, + productId: "growth", + productSnapshot: stale, + stripeSubscriptionId: null, + creationSource: PurchaseCreationSource.API_GRANT, + }); + + // Sanity: the stale sub's quantity for the removed item should be + // zero (or equal to what the stale snapshot says). We mostly just + // care that it's NOT what the latest config says. + await processBulldozerQueue(); + const beforeQty = await getItemQuantityForCustomer({ + prisma: globalPrismaClient, + tenancyId: tenancy.id, + itemId: candidateItemId, + customerId: teamId, + customerType: "team", + }); + expect(beforeQty).not.toBe(expectedQuantity); + + const stripe = makeStripeMock({}); + const result = await runRegenInternalSubscriptionsToLatest({ + teamIdsFilter: [teamId], + stripeClient: stripe.client, + }); + expect(result.mutated).toBe(1); + expect(result.dbWrites).toBe(1); + + // Drain the queue so item-quantities catches up. + await processBulldozerQueue(); + + const afterQty = await getItemQuantityForCustomer({ + prisma: globalPrismaClient, + tenancyId: tenancy.id, + itemId: candidateItemId, + customerId: teamId, + customerType: "team", + }); + expect(afterQty).toBe(expectedQuantity); + }); +}); diff --git a/package.json b/package.json index daa6e5b07a..b69a22492e 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28 && pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34; do sleep 1; done", "wait-until-postgres-is-ready": "command -v pg_isready >/dev/null 2>&1 && pnpm run wait-until-postgres-is-ready:pg_isready || sleep 10 # not everyone has pg_isready installed, so we fallback to sleeping", "wait-until-clickhouse-is-ready": "pnpm exec wait-on http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36/ping", - "start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && pnpm run db:backfill-internal-free-plans && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n", + "start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"", "start-deps": "POSTGRES_DELAY_MS=${POSTGRES_DELAY_MS:-0} pnpm run start-deps:no-delay", "restart-deps": "pnpm pre && pnpm run stop-deps && pnpm run start-deps", "restart-deps:no-delay": "pnpm pre && pnpm run stop-deps && pnpm run start-deps:no-delay", @@ -50,6 +50,7 @@ "db:init": "pnpm pre && pnpm run --filter=@stackframe/backend db:init", "db:migrate": "pnpm pre && pnpm run --filter=@stackframe/backend db:migrate", "db:backfill-internal-free-plans": "pnpm pre && pnpm run --filter=@stackframe/backend db:backfill-internal-free-plans", + "db:regen-internal-subscriptions-to-latest": "pnpm pre && pnpm run --filter=@stackframe/backend db:regen-internal-subscriptions-to-latest", "fern": "pnpm pre && pnpm run --filter=@stackframe/docs fern", "dev:full": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999\"", "dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-openapi-docs:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/docs-mintlify --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo --filter=./examples/tanstack-start-demo \"",