diff --git a/apps/backend/package.json b/apps/backend/package.json index 7b50f2baa0..588888dbc7 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,6 +38,8 @@ "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", + "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/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..fb28523f43 100644 --- a/apps/backend/scripts/db-migrations.ts +++ b/apps/backend/scripts/db-migrations.ts @@ -8,8 +8,10 @@ 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"; +import { runRegenInternalSubscriptionsToLatest } from "./regen-internal-subscriptions-to-latest"; const getClickhouseClient = () => getClickhouseAdminClient(); @@ -179,12 +181,17 @@ 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. + 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: --interactive Prompt before each new migration (not on conditional repeats) @@ -202,6 +209,7 @@ const main = async () => { await dropSchema(); await migrate(undefined, { interactive }); await seed(); + await runBulldozerPaymentsInit(globalPrismaClient); break; } case 'generate-migration-file': { @@ -228,6 +236,24 @@ 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 '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/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/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 c65a24acb8..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 && 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", @@ -49,6 +49,8 @@ "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", + "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 \"",