diff --git a/apps/backend/package.json b/apps/backend/package.json index ea5ee5a11e..4bff40acff 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -41,7 +41,7 @@ "codegen-docs:watch": "pnpm run with-env tsx watch --exclude '**/node_modules/**' --clear-screen=false scripts/generate-openapi-fumadocs.ts", "generate-keys": "pnpm run with-env tsx scripts/generate-keys.ts", "db-seed-script": "pnpm run db:seed", - "verify-data-integrity": "pnpm run with-env:dev tsx scripts/verify-data-integrity.ts", + "verify-data-integrity": "pnpm run with-env:dev tsx scripts/verify-data-integrity/index.ts", "run-email-queue": "pnpm run with-env:dev tsx scripts/run-email-queue.ts" }, "prisma": { diff --git a/apps/backend/scripts/verify-data-integrity/api.ts b/apps/backend/scripts/verify-data-integrity/api.ts new file mode 100644 index 0000000000..17ffac578e --- /dev/null +++ b/apps/backend/scripts/verify-data-integrity/api.ts @@ -0,0 +1,92 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { deepPlainEquals, filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; + +export type EndpointOutput = { + status: number, + responseJson: any, +}; + +export type OutputData = Record; + +export type ExpectStatusCode = ( + expectedStatusCode: number, + endpoint: string, + request: RequestInit, +) => Promise; + +export function createApiHelpers(options: { + currentOutputData: OutputData, + targetOutputData?: OutputData, +}) { + const { currentOutputData, targetOutputData } = options; + + function appendOutputData(endpoint: string, output: EndpointOutput) { + if (!(endpoint in currentOutputData)) { + currentOutputData[endpoint] = []; + } + const newLength = currentOutputData[endpoint].push(output); + if (targetOutputData) { + if (!(endpoint in targetOutputData)) { + throw new StackAssertionError(deindent` + Output data mismatch for endpoint ${endpoint}: + Expected ${endpoint} to be in targetOutputData, but it is not. + `, { endpoint }); + } + if (targetOutputData[endpoint].length < newLength) { + throw new StackAssertionError(deindent` + Output data mismatch for endpoint ${endpoint}: + Expected ${targetOutputData[endpoint].length} outputs but got at least ${newLength}. + `, { endpoint }); + } + if (!(deepPlainEquals(targetOutputData[endpoint][newLength - 1], output))) { + throw new StackAssertionError(deindent` + Output data mismatch for endpoint ${endpoint}: + Expected output[${JSON.stringify(endpoint)}][${newLength - 1}] to be: + ${JSON.stringify(targetOutputData[endpoint][newLength - 1], null, 2)} + but got: + ${JSON.stringify(output, null, 2)}. + `, { endpoint }); + } + } + } + + const expectStatusCode: ExpectStatusCode = async (expectedStatusCode, endpoint, request) => { + const apiUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")); + const response = await fetch(new URL(endpoint, apiUrl), { + ...request, + headers: { + "x-stack-disable-artificial-development-delay": "yes", + "x-stack-development-disable-extended-logging": "yes", + ...filterUndefined(request.headers ?? {}), + }, + }); + + const responseText = await response.text(); + + if (response.status !== expectedStatusCode) { + throw new StackAssertionError(deindent` + Expected status code ${expectedStatusCode} but got ${response.status} for ${endpoint}: + + ${responseText} + `, { request, response }); + } + + const responseJson = JSON.parse(responseText); + const currentOutput: EndpointOutput = { + status: response.status, + responseJson, + }; + + appendOutputData(endpoint, currentOutput); + + return responseJson; + }; + + return { + appendOutputData, + expectStatusCode, + }; +} + diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity/index.ts similarity index 75% rename from apps/backend/scripts/verify-data-integrity.ts rename to apps/backend/scripts/verify-data-integrity/index.ts index aa98f436e0..d9da13e523 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity/index.ts @@ -1,24 +1,27 @@ -import { globalPrismaClient } from "@/prisma-client"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import type { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import { deepPlainEquals, filterUndefined, omit } from "@stackframe/stack-shared/dist/utils/objects"; +import { deepPlainEquals, omit } from "@stackframe/stack-shared/dist/utils/objects"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import fs from "fs"; +import { createApiHelpers, type OutputData } from "./api"; +import { createPaymentsVerifier } from "./payments-verifier"; +import { createRecurse } from "./recurse"; +import { verifyStripePayoutIntegrity } from "./stripe-payout-integrity"; + const prismaClient = globalPrismaClient; const OUTPUT_FILE_PATH = "./verify-data-integrity-output.untracked.json"; - -type EndpointOutput = { - status: number, - responseJson: any, -}; - -type OutputData = Record; +const STRIPE_SECRET_KEY = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); +const USE_MOCK_STRIPE_API = STRIPE_SECRET_KEY === "sk_test_mockstripekey"; let targetOutputData: OutputData | undefined = undefined; const currentOutputData: OutputData = {}; +const recurse = createRecurse(); async function main() { console.log(); @@ -78,7 +81,6 @@ async function main() { const shouldSkipNeon = flags.includes("--skip-neon"); const recentFirst = flags.includes("--recent-first"); - if (shouldSaveOutput) { console.log(`Will save output to ${OUTPUT_FILE_PATH}`); } @@ -91,7 +93,7 @@ async function main() { throw new Error(`Cannot verify output: ${OUTPUT_FILE_PATH} does not exist`); } try { - targetOutputData = JSON.parse(fs.readFileSync(OUTPUT_FILE_PATH, 'utf8')); + targetOutputData = JSON.parse(fs.readFileSync(OUTPUT_FILE_PATH, "utf8")); // TODO next-release these are hacks for the migration, delete them if (targetOutputData) { @@ -99,6 +101,8 @@ async function main() { if ("config" in output.responseJson) { delete output.responseJson.config.id; output.responseJson.config.oauth_providers = output.responseJson.config.oauth_providers + // `any` because this is historical output JSON from disk. + // We intentionally keep this "migration hack" untyped. .filter((provider: any) => provider.enabled) .map((provider: any) => omit(provider, ["enabled"])); } @@ -112,11 +116,17 @@ async function main() { } } + const { expectStatusCode } = createApiHelpers({ + currentOutputData, + targetOutputData, + }); + const projects = await prismaClient.project.findMany({ select: { id: true, displayName: true, description: true, + stripeAccountId: true, }, orderBy: recentFirst ? { updatedAt: "desc", @@ -128,6 +138,9 @@ async function main() { if (startAt !== 0) { console.log(`Starting at project ${startAt}.`); } + if (USE_MOCK_STRIPE_API) { + console.warn("Using mock Stripe server (STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey); skipping Stripe payout integrity checks."); + } const maxUsersPerProject = 100; @@ -173,6 +186,32 @@ async function main() { }, }), ]); + void currentProject; + + const tenancy = await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID, true); + const paymentsConfig = tenancy ? (tenancy.config as OrganizationRenderedConfig).payments : undefined; + const paymentsVerifier = tenancy && paymentsConfig + ? await createPaymentsVerifier({ + projectId, + tenancyId: tenancy.id, + tenancy, + paymentsConfig, + prisma: await getPrismaClientForTenancy(tenancy), + expectStatusCode, + }) + : null; + + const stripeAccountId = projects[i].stripeAccountId; + if (!USE_MOCK_STRIPE_API && tenancy && stripeAccountId != null) { + await verifyStripePayoutIntegrity({ + projectId, + tenancy, + stripeAccountId, + expectStatusCode, + }); + } + + const verifiedTeams = new Set(); if (!skipUsers) { for (let j = 0; j < users.items.length; j++) { @@ -198,6 +237,8 @@ async function main() { }, }); for (const projectPermission of projectPermissions.items) { + // `any` because these endpoint response types aren't imported here, + // and this script is intentionally tolerant of response shape changes. if (!projectPermissionDefinitions.items.some((p: any) => p.id === projectPermission.id)) { throw new StackAssertionError(deindent` Project permission ${projectPermission.id} not found in project permission definitions. @@ -227,6 +268,8 @@ async function main() { }, }); for (const teamPermission of teamPermissions.items) { + // `any` because these endpoint response types aren't imported here, + // and this script is intentionally tolerant of response shape changes. if (!teamPermissionDefinitions.items.some((p: any) => p.id === teamPermission.id)) { throw new StackAssertionError(deindent` Team permission ${teamPermission.id} not found in team permission definitions. @@ -234,9 +277,33 @@ async function main() { } } }); + + if (paymentsVerifier && !verifiedTeams.has(team.id)) { + await paymentsVerifier.verifyCustomerPayments({ + customerType: "team", + customerId: team.id, + }); + verifiedTeams.add(team.id); + } + } + + if (paymentsVerifier) { + await paymentsVerifier.verifyCustomerPayments({ + customerType: "user", + customerId: user.id, + }); } }); } + + if (paymentsVerifier) { + for (const customCustomerId of paymentsVerifier.customCustomerIds) { + await paymentsVerifier.verifyCustomerPayments({ + customerType: "custom", + customerId: customCustomerId, + }); + } + } } }); } @@ -267,6 +334,7 @@ async function main() { console.log(); console.log(); } + // eslint-disable-next-line no-restricted-syntax main().catch((...args) => { console.error(); @@ -276,90 +344,3 @@ main().catch((...args) => { process.exit(1); }); -async function expectStatusCode(expectedStatusCode: number, endpoint: string, request: RequestInit) { - const apiUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")); - const response = await fetch(new URL(endpoint, apiUrl), { - ...request, - headers: { - "x-stack-disable-artificial-development-delay": "yes", - "x-stack-development-disable-extended-logging": "yes", - ...filterUndefined(request.headers ?? {}), - }, - }); - - const responseText = await response.text(); - - if (response.status !== expectedStatusCode) { - throw new StackAssertionError(deindent` - Expected status code ${expectedStatusCode} but got ${response.status} for ${endpoint}: - - ${responseText} - `, { request, response }); - } - - const responseJson = JSON.parse(responseText); - const currentOutput: EndpointOutput = { - status: response.status, - responseJson, - }; - - appendOutputData(endpoint, currentOutput); - - return responseJson; -} - -function appendOutputData(endpoint: string, output: EndpointOutput) { - if (!(endpoint in currentOutputData)) { - currentOutputData[endpoint] = []; - } - const newLength = currentOutputData[endpoint].push(output); - if (targetOutputData) { - if (!(endpoint in targetOutputData)) { - throw new StackAssertionError(deindent` - Output data mismatch for endpoint ${endpoint}: - Expected ${endpoint} to be in targetOutputData, but it is not. - `, { endpoint }); - } - if (targetOutputData[endpoint].length < newLength) { - throw new StackAssertionError(deindent` - Output data mismatch for endpoint ${endpoint}: - Expected ${targetOutputData[endpoint].length} outputs but got at least ${newLength}. - `, { endpoint }); - } - if (!(deepPlainEquals(targetOutputData[endpoint][newLength - 1], output))) { - throw new StackAssertionError(deindent` - Output data mismatch for endpoint ${endpoint}: - Expected output[${JSON.stringify(endpoint)}][${newLength - 1}] to be: - ${JSON.stringify(targetOutputData[endpoint][newLength - 1], null, 2)} - but got: - ${JSON.stringify(output, null, 2)}. - `, { endpoint }); - } - } -} - -let lastProgress = performance.now() - 9999999999; - -type RecurseFunction = (progressPrefix: string, inner: (recurse: RecurseFunction) => Promise) => Promise; - -const _recurse = async (progressPrefix: string | ((...args: any[]) => void), inner: Parameters[1]): Promise => { - const progressFunc = typeof progressPrefix === "function" ? progressPrefix : (...args: any[]) => { - console.log(`${progressPrefix}`, ...args); - }; - if (performance.now() - lastProgress > 1000) { - progressFunc(); - lastProgress = performance.now(); - } - try { - return await inner( - (progressPrefix, inner) => _recurse( - (...args) => progressFunc(progressPrefix, ...args), - inner, - ), - ); - } catch (error) { - progressFunc(`\x1b[41mERROR\x1b[0m!`); - throw error; - } -}; -const recurse: RecurseFunction = _recurse; diff --git a/apps/backend/scripts/verify-data-integrity/payments-verifier.ts b/apps/backend/scripts/verify-data-integrity/payments-verifier.ts new file mode 100644 index 0000000000..1b979875fe --- /dev/null +++ b/apps/backend/scripts/verify-data-integrity/payments-verifier.ts @@ -0,0 +1,751 @@ +import type { Tenancy } from "@/lib/tenancies"; +import { getItemQuantityForCustomer } from "@/lib/payments"; +import { SubscriptionStatus } from "@/generated/prisma/client"; +import type { getPrismaClientForTenancy } from "@/prisma-client"; +import type { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; +import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; +import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed, type DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects"; +import { deindent, stringCompare, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; +import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; + +import type { ExpectStatusCode } from "./api"; +import { fetchAllTransactionsForProject } from "./stripe-payout-integrity"; + +export type CustomerType = "user" | "team" | "custom"; + +type PaymentsConfig = OrganizationRenderedConfig["payments"]; +type PaymentsProduct = PaymentsConfig["products"][string]; + +type LedgerTransaction = { + amount: number, + grantTime: Date, + expirationTime: Date, +}; + +type CustomerTransactionEntry = { + transactionId: string, + createdAtMillis: number, + entry: TransactionEntry, +}; + +type ExpectedOwnedProduct = { + id: string | null, + type: "one_time" | "subscription", + quantity: number, +}; + +const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); + +type IncludedItemConfig = { + quantity?: number, + repeat?: DayInterval | "never" | null, + expires?: "never" | "when-purchase-expires" | "when-repeated" | null, +}; + +type SubscriptionSnapshot = { + id: string, + quantity: number, + status: SubscriptionStatus, + currentPeriodStart: Date, + currentPeriodEnd: Date | null, + cancelAtPeriodEnd: boolean, + createdAt: Date, + refundedAt: Date | null, +}; + +type OneTimePurchaseSnapshot = { + id: string, + quantity: number, + createdAt: Date, + refundedAt: Date | null, +}; + +type ItemQuantityChangeSnapshot = { + id: string, + createdAt: Date, + expiresAt: Date | null, +}; + +type PrismaForTenancy = Awaited>; + +type ExtraItemQuantityChangeRow = { + id: string, + itemId: string, + quantity: number, + createdAt: Date, + expiresAt: Date | null, +}; + +function getCustomerKey(customerType: CustomerType, customerId: string) { + return `${customerType}:${customerId}`; +} + +function isCustomerTransactionEntry(entry: TransactionEntry): entry is Extract { + return "customer_type" in entry && "customer_id" in entry; +} + +function normalizeRepeat(repeat: unknown): DayInterval | null { + if (repeat === "never") return null; + if (!Array.isArray(repeat) || repeat.length !== 2) return null; + const [amount, unit] = repeat; + if (typeof amount !== "number") return null; + if (unit !== "day" && unit !== "week" && unit !== "month" && unit !== "year") return null; + return [amount, unit]; +} + +function pushLedgerEntry(ledgerByItemId: Map, itemId: string, entry: LedgerTransaction) { + const existing = ledgerByItemId.get(itemId); + if (existing) { + existing.push(entry); + return; + } + ledgerByItemId.set(itemId, [entry]); +} + +function computeLedgerBalanceAtNow(transactions: LedgerTransaction[], now: Date): number { + const grantedAt = new Map(); + const expiredAt = new Map(); + const usedAt = new Map(); + const timeSet = new Set(); + + for (const t of transactions) { + const grantTime = t.grantTime.getTime(); + if (t.grantTime <= now && t.amount < 0 && t.expirationTime > now) { + usedAt.set(grantTime, (-1 * t.amount) + (usedAt.get(grantTime) ?? 0)); + } + if (t.grantTime <= now && t.amount > 0) { + grantedAt.set(grantTime, (grantedAt.get(grantTime) ?? 0) + t.amount); + } + if (t.expirationTime <= now && t.amount > 0) { + const time2 = t.expirationTime.getTime(); + expiredAt.set(time2, (expiredAt.get(time2) ?? 0) + t.amount); + timeSet.add(time2); + } + timeSet.add(grantTime); + } + const times = Array.from(timeSet.values()).sort((a, b) => a - b); + if (times.length === 0) { + return 0; + } + + let grantedSum = 0; + let expiredSum = 0; + let usedSum = 0; + let usedOrExpiredSum = 0; + for (const t of times) { + const g = grantedAt.get(t) ?? 0; + const e = expiredAt.get(t) ?? 0; + const u = usedAt.get(t) ?? 0; + grantedSum += g; + expiredSum += e; + usedSum += u; + usedOrExpiredSum = Math.max(usedOrExpiredSum + u, expiredSum); + } + return grantedSum - usedOrExpiredSum; +} + +function addWhenRepeatedItemWindowTransactions(options: { + baseQty: number, + repeat: DayInterval, + anchor: Date, + nowClamped: Date, + hardEnd: Date | null, +}): LedgerTransaction[] { + const { baseQty, repeat, anchor, nowClamped } = options; + const endLimit = options.hardEnd ?? FAR_FUTURE_DATE; + const finalNow = nowClamped < endLimit ? nowClamped : endLimit; + if (finalNow < anchor) return []; + + const entries: LedgerTransaction[] = []; + const elapsed = getIntervalsElapsed(anchor, finalNow, repeat); + + for (let i = 0; i <= elapsed; i++) { + const windowStart = addInterval(new Date(anchor), [repeat[0] * i, repeat[1]]); + const windowEnd = addInterval(new Date(windowStart), repeat); + entries.push({ amount: baseQty, grantTime: windowStart, expirationTime: windowEnd }); + } + + return entries; +} + +function addSubscriptionIncludedItems(options: { + ledgerByItemId: Map, + includedItems: Record | undefined, + subscription: Pick, + now: Date, +}) { + const { subscription, ledgerByItemId, includedItems, now } = options; + for (const [itemId, inc] of Object.entries(includedItems ?? {})) { + const baseQty = (inc.quantity ?? 0) * subscription.quantity; + if (baseQty <= 0) continue; + const pStart = subscription.currentPeriodStart; + const pEnd = subscription.currentPeriodEnd ?? FAR_FUTURE_DATE; + const nowClamped = now < pEnd ? now : pEnd; + if (nowClamped < pStart) continue; + + const repeat = normalizeRepeat(inc.repeat ?? null); + const expires = inc.expires ?? "never"; + + if (!repeat) { + const expirationTime = expires === "when-purchase-expires" ? pEnd : FAR_FUTURE_DATE; + pushLedgerEntry(ledgerByItemId, itemId, { + amount: baseQty, + grantTime: pStart, + expirationTime, + }); + continue; + } + + if (expires === "when-purchase-expires") { + const elapsed = getIntervalsElapsed(pStart, nowClamped, repeat); + const occurrences = elapsed + 1; + const amount = occurrences * baseQty; + pushLedgerEntry(ledgerByItemId, itemId, { + amount, + grantTime: pStart, + expirationTime: pEnd, + }); + continue; + } + + if (expires === "when-repeated") { + const entries = addWhenRepeatedItemWindowTransactions({ + baseQty, + repeat, + anchor: subscription.createdAt, + nowClamped, + hardEnd: subscription.currentPeriodEnd, + }); + for (const entry of entries) { + pushLedgerEntry(ledgerByItemId, itemId, entry); + } + continue; + } + + const elapsed = getIntervalsElapsed(pStart, nowClamped, repeat); + const occurrences = elapsed + 1; + const amount = occurrences * baseQty; + pushLedgerEntry(ledgerByItemId, itemId, { + amount, + grantTime: pStart, + expirationTime: FAR_FUTURE_DATE, + }); + } +} + +function addOneTimeIncludedItems(options: { + ledgerByItemId: Map, + includedItems: Record | undefined, + quantity: number, + createdAt: Date, +}) { + const { ledgerByItemId, includedItems, quantity, createdAt } = options; + for (const [itemId, inc] of Object.entries(includedItems ?? {})) { + const baseQty = (inc.quantity ?? 0) * quantity; + if (baseQty <= 0) continue; + pushLedgerEntry(ledgerByItemId, itemId, { + amount: baseQty, + grantTime: createdAt, + expirationTime: FAR_FUTURE_DATE, + }); + } +} + +function buildExpectedItemQuantitiesForCustomer(options: { + entries: CustomerTransactionEntry[], + defaultProducts: Array<{ productId: string, product: PaymentsProduct }>, + extraItemQuantityChanges: Array<{ + itemId: string, + quantity: number, + createdAt: Date, + expiresAt: Date | null, + }>, + itemQuantityChangeById: Map, + subscriptionById: Map, + oneTimePurchaseById: Map, + now: Date, +}) { + const ledgerByItemId = new Map(); + + for (const change of options.extraItemQuantityChanges) { + pushLedgerEntry(ledgerByItemId, change.itemId, { + amount: change.quantity, + grantTime: change.createdAt, + expirationTime: change.expiresAt ?? FAR_FUTURE_DATE, + }); + } + + for (const { entry, transactionId, createdAtMillis } of options.entries) { + if (entry.type === "item_quantity_change") { + const change = options.itemQuantityChangeById.get(transactionId); + if (!change) { + continue; + } + pushLedgerEntry(ledgerByItemId, entry.item_id, { + amount: entry.quantity, + grantTime: change.createdAt, + expirationTime: change.expiresAt ?? FAR_FUTURE_DATE, + }); + continue; + } + + if (entry.type !== "product_grant") continue; + + const includedItems = entry.product.included_items; + + if (entry.subscription_id) { + const subscription = options.subscriptionById.get(entry.subscription_id); + if (!subscription) { + continue; + } + addSubscriptionIncludedItems({ + ledgerByItemId, + includedItems, + subscription, + now: options.now, + }); + continue; + } + + if (entry.one_time_purchase_id) { + const purchase = options.oneTimePurchaseById.get(entry.one_time_purchase_id); + if (!purchase) { + continue; + } + addOneTimeIncludedItems({ + ledgerByItemId, + includedItems, + quantity: purchase.quantity, + createdAt: purchase.createdAt, + }); + continue; + } + + addOneTimeIncludedItems({ + ledgerByItemId, + includedItems, + quantity: entry.quantity, + createdAt: new Date(createdAtMillis), + }); + } + + for (const { product } of options.defaultProducts) { + addSubscriptionIncludedItems({ + ledgerByItemId, + includedItems: product.includedItems, + subscription: { + quantity: 1, + currentPeriodStart: DEFAULT_PRODUCT_START_DATE, + currentPeriodEnd: null, + createdAt: DEFAULT_PRODUCT_START_DATE, + }, + now: options.now, + }); + } + + const results = new Map(); + for (const [itemId, ledger] of ledgerByItemId) { + results.set(itemId, computeLedgerBalanceAtNow(ledger, options.now)); + } + return results; +} + +function buildExpectedOwnedProductsForCustomer(options: { + entries: CustomerTransactionEntry[], + defaultProducts: Array<{ productId: string, product: PaymentsProduct }>, + subscriptionById: Map, + oneTimePurchaseById: Map, +}) { + const expected: ExpectedOwnedProduct[] = []; + for (const { entry } of options.entries) { + if (entry.type !== "product_grant") continue; + + if (entry.subscription_id) { + const subscription = options.subscriptionById.get(entry.subscription_id); + if (!subscription) { + continue; + } + if (subscription.status !== SubscriptionStatus.active && subscription.status !== SubscriptionStatus.trialing) { + continue; + } + expected.push({ + id: entry.product_id ?? null, + type: "subscription", + quantity: subscription.quantity, + }); + continue; + } + + if (entry.one_time_purchase_id) { + const purchase = options.oneTimePurchaseById.get(entry.one_time_purchase_id); + if (!purchase) { + continue; + } + if (purchase.refundedAt) continue; + expected.push({ + id: entry.product_id ?? null, + type: "one_time", + quantity: purchase.quantity, + }); + continue; + } + + expected.push({ + id: entry.product_id ?? null, + type: "one_time", + quantity: entry.quantity, + }); + } + + for (const { productId } of options.defaultProducts) { + expected.push({ + id: productId, + type: "subscription", + quantity: 1, + }); + } + + return expected; +} + +function getDefaultProductsForCustomer(options: { + paymentsConfig: PaymentsConfig, + customerType: CustomerType, + subscribedProductLineIds: Set, + subscribedProductIds: Set, +}) { + const defaultsByProductLine = new Map(); + const ungroupedDefaults: Array<{ productId: string, product: PaymentsProduct }> = []; + + for (const [productId, product] of Object.entries(options.paymentsConfig.products)) { + if (product.customerType !== options.customerType) continue; + if (product.prices !== "include-by-default") continue; + + if (product.productLineId) { + if (!defaultsByProductLine.has(product.productLineId)) { + defaultsByProductLine.set(product.productLineId, { productId, product }); + } + continue; + } + + ungroupedDefaults.push({ productId, product }); + } + + const defaults: Array<{ productId: string, product: PaymentsProduct }> = []; + for (const [productLineId, product] of defaultsByProductLine) { + if (options.subscribedProductLineIds.has(productLineId)) continue; + defaults.push(product); + } + for (const product of ungroupedDefaults) { + if (options.subscribedProductIds.has(product.productId)) continue; + defaults.push(product); + } + return defaults; +} + +function getIncludeByDefaultConflicts(paymentsConfig: PaymentsConfig) { + const conflicts = new Map(); + for (const productLineId of Object.keys(paymentsConfig.productLines)) { + const defaultProducts = Object.entries(paymentsConfig.products) + .filter(([_, product]) => product.productLineId === productLineId && product.prices === "include-by-default") + .map(([productId]) => productId); + if (defaultProducts.length > 1) { + conflicts.set(productLineId, defaultProducts); + } + } + return conflicts; +} + +function normalizeOwnedProducts(list: ExpectedOwnedProduct[]) { + return list + .map((item) => ({ + id: item.id ?? null, + type: item.type, + quantity: item.quantity, + })) + .sort((a, b) => { + const aId = a.id ?? ""; + const bId = b.id ?? ""; + if (aId !== bId) return stringCompare(aId, bId); + if (a.type !== b.type) return stringCompare(a.type, b.type); + return a.quantity - b.quantity; + }); +} + +async function fetchAllOwnedProductsForCustomer(options: { + projectId: string, + customerType: CustomerType, + customerId: string, + expectStatusCode: ExpectStatusCode, +}) { + const items: Array = []; + let cursor: string | null = null; + + do { + const params = new URLSearchParams({ limit: "100" }); + if (cursor) params.set("cursor", cursor); + const endpoint = urlString`/api/v1/payments/products/${options.customerType}/${options.customerId}` + (params.toString() ? `?${params.toString()}` : ""); + const response = await options.expectStatusCode(200, endpoint, { + method: "GET", + headers: { + "x-stack-project-id": options.projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }) as { items: Array, pagination: { next_cursor: string | null } }; + items.push(...response.items.map((item) => ({ + id: item.id ?? null, + type: item.type, + quantity: item.quantity, + }))); + cursor = response.pagination.next_cursor; + } while (cursor); + + return items; +} + +export async function createPaymentsVerifier(options: { + projectId: string, + tenancyId: string, + tenancy: Tenancy, + paymentsConfig: PaymentsConfig, + prisma: PrismaForTenancy, + expectStatusCode: ExpectStatusCode, +}) { + const includeByDefaultConflicts = getIncludeByDefaultConflicts(options.paymentsConfig); + if (includeByDefaultConflicts.size > 0) { + const conflictSummary = Array.from(includeByDefaultConflicts.entries()) + .map(([productLineId, productIds]) => `${productLineId}: ${productIds.join(", ")}`) + .join("; "); + console.warn(`Skipping payments verification for project ${options.projectId} due to include-by-default conflicts (${conflictSummary}).`); + return { + verifyCustomerPayments: async () => { }, + customCustomerIds: new Set(), + }; + } + + const transactions = await fetchAllTransactionsForProject({ + projectId: options.projectId, + expectStatusCode: options.expectStatusCode, + }); + const paymentsConfig = options.paymentsConfig; + + const entriesByCustomer = new Map(); + const subscriptionIds = new Set(); + const oneTimePurchaseIds = new Set(); + const itemQuantityChangeIds = new Set(); + const customCustomerIds = new Set(); + + for (const transaction of transactions) { + for (const entry of transaction.entries) { + if (!isCustomerTransactionEntry(entry)) continue; + const customerKey = getCustomerKey(entry.customer_type, entry.customer_id); + const entries = entriesByCustomer.get(customerKey) ?? []; + entries.push({ + transactionId: transaction.id, + createdAtMillis: transaction.created_at_millis, + entry, + }); + entriesByCustomer.set(customerKey, entries); + + if (entry.customer_type === "custom") { + customCustomerIds.add(entry.customer_id); + } + + if (entry.type === "item_quantity_change") { + itemQuantityChangeIds.add(transaction.id); + continue; + } + if (entry.type !== "product_grant") continue; + if (entry.subscription_id) { + subscriptionIds.add(entry.subscription_id); + } + if (entry.one_time_purchase_id) { + oneTimePurchaseIds.add(entry.one_time_purchase_id); + } + } + } + + const subscriptionIdList = Array.from(subscriptionIds); + const oneTimePurchaseIdList = Array.from(oneTimePurchaseIds); + const itemQuantityChangeIdList = Array.from(itemQuantityChangeIds); + + const [subscriptions, oneTimePurchases, itemQuantityChanges] = await Promise.all([ + subscriptionIdList.length === 0 ? Promise.resolve([] as SubscriptionSnapshot[]) : options.prisma.subscription.findMany({ + where: { + tenancyId: options.tenancyId, + id: { in: subscriptionIdList }, + }, + select: { + id: true, + quantity: true, + status: true, + currentPeriodStart: true, + currentPeriodEnd: true, + cancelAtPeriodEnd: true, + createdAt: true, + refundedAt: true, + }, + }), + oneTimePurchaseIdList.length === 0 ? Promise.resolve([] as OneTimePurchaseSnapshot[]) : options.prisma.oneTimePurchase.findMany({ + where: { + tenancyId: options.tenancyId, + id: { in: oneTimePurchaseIdList }, + }, + select: { + id: true, + quantity: true, + createdAt: true, + refundedAt: true, + }, + }), + itemQuantityChangeIdList.length === 0 ? Promise.resolve([] as ItemQuantityChangeSnapshot[]) : options.prisma.itemQuantityChange.findMany({ + where: { + tenancyId: options.tenancyId, + id: { in: itemQuantityChangeIdList }, + }, + select: { + id: true, + createdAt: true, + expiresAt: true, + }, + }), + ]); + + const subscriptionById = new Map(subscriptions.map((subscription) => [subscription.id, subscription])); + const oneTimePurchaseById = new Map(oneTimePurchases.map((purchase) => [purchase.id, purchase])); + const itemQuantityChangeById = new Map(itemQuantityChanges.map((change) => [change.id, change])); + + async function verifyCustomerPayments(customer: { customerType: CustomerType, customerId: string }) { + const entries = entriesByCustomer.get(getCustomerKey(customer.customerType, customer.customerId)) ?? []; + const now = new Date(); + + const entryItemQuantityChangeIds = new Set(); + for (const { entry, transactionId } of entries) { + if (entry.type !== "item_quantity_change") continue; + entryItemQuantityChangeIds.add(transactionId); + } + const extraItemQuantityChanges: ExtraItemQuantityChangeRow[] = await options.prisma.itemQuantityChange.findMany({ + where: { + tenancyId: options.tenancyId, + customerId: customer.customerId, + customerType: typedToUppercase(customer.customerType), + }, + select: { + id: true, + itemId: true, + quantity: true, + createdAt: true, + expiresAt: true, + }, + }); + const missingItemQuantityChanges = extraItemQuantityChanges.filter((change) => !entryItemQuantityChangeIds.has(change.id)); + + const subscribedProductLineIds = new Set(); + const subscribedProductIds = new Set(); + const dbSubscriptions = await options.prisma.subscription.findMany({ + where: { + tenancyId: options.tenancyId, + customerId: customer.customerId, + customerType: typedToUppercase(customer.customerType), + }, + select: { + productId: true, + }, + }); + for (const { productId } of dbSubscriptions) { + if (!productId) continue; + subscribedProductIds.add(productId); + const configProduct = paymentsConfig.products[productId] as PaymentsProduct | undefined; + if (!configProduct) continue; + if (configProduct.productLineId) { + subscribedProductLineIds.add(configProduct.productLineId); + } + } + + const defaultProducts = getDefaultProductsForCustomer({ + paymentsConfig, + customerType: customer.customerType, + subscribedProductLineIds, + subscribedProductIds, + }); + + const expectedItems = buildExpectedItemQuantitiesForCustomer({ + entries, + defaultProducts, + extraItemQuantityChanges: missingItemQuantityChanges, + itemQuantityChangeById, + subscriptionById, + oneTimePurchaseById, + now, + }); + + for (const [itemId, item] of Object.entries(paymentsConfig.items)) { + if (item.customerType !== customer.customerType) continue; + const expectedQuantity = expectedItems.get(itemId) ?? 0; + const endpoint = urlString`/api/v1/payments/items/${customer.customerType}/${customer.customerId}/${itemId}`; + const response = await options.expectStatusCode(200, endpoint, { + method: "GET", + headers: { + "x-stack-project-id": options.projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }) as { quantity: number }; + if (response.quantity !== expectedQuantity) { + const dbQuantity = await getItemQuantityForCustomer({ + prisma: options.prisma, + tenancy: options.tenancy, + itemId, + customerId: customer.customerId, + customerType: customer.customerType, + }); + if (dbQuantity !== response.quantity) { + throw new StackAssertionError(deindent` + Item quantity mismatch for ${customer.customerType} ${customer.customerId} item ${itemId}. + Expected ${expectedQuantity} but got ${response.quantity}. + `, { expectedQuantity, actualQuantity: response.quantity, dbQuantity }); + } + console.warn(deindent` + Item quantity mismatch for ${customer.customerType} ${customer.customerId} item ${itemId}. + Expected ${expectedQuantity} from transactions but got ${response.quantity} (db=${dbQuantity}); skipping. + `); + } + } + + const expectedProducts = buildExpectedOwnedProductsForCustomer({ + entries, + defaultProducts, + subscriptionById, + oneTimePurchaseById, + }); + const actualProducts = await fetchAllOwnedProductsForCustomer({ + projectId: options.projectId, + customerType: customer.customerType, + customerId: customer.customerId, + expectStatusCode: options.expectStatusCode, + }); + + const normalizedExpected = normalizeOwnedProducts(expectedProducts); + const normalizedActual = normalizeOwnedProducts(actualProducts); + + if (!deepPlainEquals(normalizedExpected, normalizedActual)) { + throw new StackAssertionError(deindent` + Owned products mismatch for ${customer.customerType} ${customer.customerId}. + Expected: + ${JSON.stringify(normalizedExpected, null, 2)} + Actual: + ${JSON.stringify(normalizedActual, null, 2)} + `, { expected: normalizedExpected, actual: normalizedActual }); + } + } + + return { + verifyCustomerPayments, + customCustomerIds, + }; +} + diff --git a/apps/backend/scripts/verify-data-integrity/recurse.ts b/apps/backend/scripts/verify-data-integrity/recurse.ts new file mode 100644 index 0000000000..d651a331bc --- /dev/null +++ b/apps/backend/scripts/verify-data-integrity/recurse.ts @@ -0,0 +1,32 @@ +export type RecurseFunction = (progressPrefix: string, inner: (recurse: RecurseFunction) => Promise) => Promise; + +export function createRecurse(): RecurseFunction { + let lastProgress = performance.now() - 9999999999; + + const _recurse = async ( + progressPrefix: string | ((...args: any[]) => void), + inner: Parameters[1], + ): Promise => { + const progressFunc = typeof progressPrefix === "function" ? progressPrefix : (...args: any[]) => { + console.log(`${progressPrefix}`, ...args); + }; + if (performance.now() - lastProgress > 1000) { + progressFunc(); + lastProgress = performance.now(); + } + try { + return await inner( + (progressPrefix, inner) => _recurse( + (...args) => progressFunc(progressPrefix, ...args), + inner, + ), + ); + } catch (error) { + progressFunc(`\x1b[41mERROR\x1b[0m!`); + throw error; + } + }; + + return _recurse; +} + diff --git a/apps/backend/scripts/verify-data-integrity/stripe-payout-integrity.ts b/apps/backend/scripts/verify-data-integrity/stripe-payout-integrity.ts new file mode 100644 index 0000000000..674eb65726 --- /dev/null +++ b/apps/backend/scripts/verify-data-integrity/stripe-payout-integrity.ts @@ -0,0 +1,143 @@ +import type { Tenancy } from "@/lib/tenancies"; +import { getStripeForAccount } from "@/lib/stripe"; +import type { Transaction } from "@stackframe/stack-shared/dist/interface/crud/transactions"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; +import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; + +import type { ExpectStatusCode } from "./api"; + +export async function fetchAllTransactionsForProject(options: { + projectId: string, + expectStatusCode: ExpectStatusCode, +}) { + const transactions: Transaction[] = []; + let cursor: string | null = null; + + do { + const params = new URLSearchParams({ limit: "200" }); + if (cursor) params.set("cursor", cursor); + const endpoint = urlString`/api/v1/internal/payments/transactions` + (params.toString() ? `?${params.toString()}` : ""); + const response = await options.expectStatusCode(200, endpoint, { + method: "GET", + headers: { + "x-stack-project-id": options.projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }) as { transactions: Transaction[], next_cursor: string | null }; + transactions.push(...response.transactions); + cursor = response.next_cursor; + } while (cursor); + + return transactions; +} + +function parseMoneyAmountToMinorUnits(amount: string, decimals: number): bigint { + const [wholePart, fractionalPart = ""] = amount.split("."); + if (fractionalPart.length > decimals) { + throw new StackAssertionError("Money amount has too many decimals", { amount, decimals }); + } + const paddedFraction = fractionalPart.padEnd(decimals, "0"); + return BigInt(`${wholePart}${paddedFraction}`); +} + +function formatMinorUnitsToMoneyString(amount: bigint, decimals: number): string { + const isNegative = amount < 0n; + const absolute = isNegative ? -amount : amount; + const absoluteString = absolute.toString().padStart(decimals + 1, "0"); + const wholePart = absoluteString.slice(0, -decimals); + const fractionalPart = absoluteString.slice(-decimals).replace(/0+$/, ""); + const rendered = fractionalPart.length > 0 ? `${wholePart}.${fractionalPart}` : wholePart; + return isNegative ? `-${rendered}` : rendered; +} + +function sumMoneyTransfersUsdMinorUnits(transactions: Transaction[]): bigint { + let total = 0n; + for (const transaction of transactions) { + for (const entry of transaction.entries) { + if (entry.type !== "money_transfer") continue; + total += parseMoneyAmountToMinorUnits(entry.net_amount.USD, 2); + } + } + return total; +} + +type StripeBalanceTransactionList = { + data: Array<{ + id: string, + amount: number, + currency: string, + reporting_category?: string | null, + }>, + has_more: boolean, +}; + +async function fetchStripeBalanceTransactionTotalUsdMinorUnits(options: { + tenancy: Tenancy, + stripeAccountId: string, +}): Promise { + const stripe = await getStripeForAccount({ + tenancy: options.tenancy, + accountId: options.stripeAccountId, + }); + + let total = 0n; + const includeCategories = new Set([ + "charge", + "refund", + "dispute", + "dispute_reversal", + "partial_capture_reversal", + ]); + let startingAfter: string | undefined = undefined; + + do { + const page: StripeBalanceTransactionList = await stripe.balanceTransactions.list({ + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + for (const balanceTransaction of page.data) { + if (balanceTransaction.currency !== "usd") continue; + if (!balanceTransaction.reporting_category) continue; + if (!includeCategories.has(balanceTransaction.reporting_category)) continue; + total += BigInt(balanceTransaction.amount); + } + startingAfter = page.has_more ? page.data.at(-1)?.id : undefined; + } while (startingAfter); + + return total; +} + +export async function verifyStripePayoutIntegrity(options: { + projectId: string, + tenancy: Tenancy, + stripeAccountId: string, + expectStatusCode: ExpectStatusCode, +}) { + if (options.projectId === '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063') { + // Dummy project doesn't have a real stripe account, so we skip the verification. + return; + } + const transactions = await fetchAllTransactionsForProject({ + projectId: options.projectId, + expectStatusCode: options.expectStatusCode, + }); + const moneyTransferTotalUsdMinor = sumMoneyTransfersUsdMinorUnits(transactions); + const stripeBalanceTransactionTotalUsdMinor = await fetchStripeBalanceTransactionTotalUsdMinorUnits({ + tenancy: options.tenancy, + stripeAccountId: options.stripeAccountId, + }); + if (moneyTransferTotalUsdMinor !== stripeBalanceTransactionTotalUsdMinor) { + throw new StackAssertionError(deindent` + Stripe balance transaction mismatch for project ${options.projectId}. + Money transfers total USD ${formatMinorUnitsToMoneyString(moneyTransferTotalUsdMinor, 2)} vs Stripe balance transactions USD ${formatMinorUnitsToMoneyString(stripeBalanceTransactionTotalUsdMinor, 2)}. + `, { + projectId: options.projectId, + moneyTransferTotalUsdMinor: moneyTransferTotalUsdMinor.toString(), + stripeBalanceTransactionTotalUsdMinor: stripeBalanceTransactionTotalUsdMinor.toString(), + }); + } +} + diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index f2d9d3144c..ca61dca576 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -162,6 +162,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'off1', + product: tenancy.config.payments.products['off1'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-02-28T23:59:59.000Z'), quantity: 2, @@ -200,6 +201,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offW', + product: tenancy.config.payments.products['offW'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -239,6 +241,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offW', + product: tenancy.config.payments.products['offW'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -278,6 +281,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offR', + product: tenancy.config.payments.products['offR'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -317,6 +321,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offN', + product: tenancy.config.payments.products['offN'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 3, @@ -352,6 +357,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offRC', + product: tenancy.config.payments.products['offRC'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -400,6 +406,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { const end = inFirstPeriod ? new Date('2025-03-01T00:00:00.000Z') : new Date('2025-04-01T00:00:00.000Z'); return [{ productId: 'offRR', + product: tenancy.config.payments.products['offRR'], currentPeriodStart: start, currentPeriodEnd: end, quantity: 1, @@ -444,6 +451,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offMD', + product: tenancy.config.payments.products['offMD'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -492,6 +500,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offBF', + product: tenancy.config.payments.products['offBF'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -547,6 +556,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { findMany: async () => [ { productId: 'off1', + product: tenancy.config.payments.products['off1'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 3, @@ -554,6 +564,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { }, { productId: 'off2', + product: tenancy.config.payments.products['off2'], currentPeriodStart: new Date('2025-01-15T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-15T00:00:00.000Z'), quantity: 5, @@ -594,6 +605,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offBundle', + product: tenancy.config.payments.products['offBundle'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 2, @@ -631,6 +643,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offT', + product: tenancy.config.payments.products['offT'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 3, @@ -666,6 +679,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offC', + product: tenancy.config.payments.products['offC'], currentPeriodStart: new Date('2024-12-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-01-01T00:00:00.000Z'), quantity: 1, @@ -706,6 +720,7 @@ describe('getItemQuantityForCustomer - subscriptions', () => { subscription: { findMany: async () => [{ productId: 'offU', + product: tenancy.config.payments.products['offU'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 2, @@ -975,6 +990,7 @@ describe('validatePurchaseSession - one-time purchase rules', () => { subscription: { findMany: async () => [{ productId: 'product-sub', + product: tenancy.config.payments.products['product-sub'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -1030,6 +1046,7 @@ describe('validatePurchaseSession - one-time purchase rules', () => { subscription: { findMany: async () => [{ productId: 'product-sub-stackable', + product: tenancy.config.payments.products['product-sub-stackable'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 1, @@ -1102,6 +1119,7 @@ describe('combined sources - one-time purchases + manual changes + subscriptions subscription: { findMany: async () => [{ productId: 'offSub', + product: tenancy.config.payments.products['offSub'], currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), quantity: 2, diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index fce17ff31d..3f426217e0 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -331,8 +331,7 @@ export async function getSubscriptions(options: { const productLinesWithDbSubscriptions = new Set(); for (const s of dbSubscriptions) { - const product = s.productId ? getOrUndefined(products, s.productId) : s.product as yup.InferType; - if (!product) continue; + const product = s.product as yup.InferType; subscriptions.push({ id: s.id, productId: s.productId, @@ -352,7 +351,9 @@ export async function getSubscriptions(options: { for (const productLineId of Object.keys(productLines)) { if (productLinesWithDbSubscriptions.has(productLineId)) continue; - const productsInProductLine = typedEntries(products).filter(([_, product]) => product.productLineId === productLineId); + const productsInProductLine = typedEntries(products).filter(([_, product]) => ( + product.productLineId === productLineId && product.customerType === options.customerType + )); const defaultProductLineProducts = productsInProductLine.filter(([_, product]) => product.prices === "include-by-default"); if (defaultProductLineProducts.length > 1) { throw new StackAssertionError( @@ -378,7 +379,10 @@ export async function getSubscriptions(options: { } const ungroupedDefaults = typedEntries(products).filter(([id, product]) => ( - product.productLineId === undefined && product.prices === "include-by-default" && !subscriptions.some((s) => s.productId === id) + product.productLineId === undefined && + product.prices === "include-by-default" && + product.customerType === options.customerType && + !subscriptions.some((s) => s.productId === id) )); for (const [productId, product] of ungroupedDefaults) { subscriptions.push({