From 70ed816d346789aaebf5298a5400228db95b92ce Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 21 Jan 2026 16:55:38 -0800 Subject: [PATCH 01/13] verify payment transactions integrity --- apps/backend/scripts/verify-data-integrity.ts | 704 +++++++++++++++++- 1 file changed, 703 insertions(+), 1 deletion(-) diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index aa98f436e0..774f7ce042 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -1,9 +1,15 @@ +import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID } from "@/lib/tenancies"; import { globalPrismaClient } from "@/prisma-client"; +import { SubscriptionStatus } from "@/generated/prisma/client"; +import type { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; +import type { Transaction, 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, filterUndefined, 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 { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; import fs from "fs"; const prismaClient = globalPrismaClient; @@ -16,6 +22,29 @@ type EndpointOutput = { type OutputData = Record; +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, +}; + let targetOutputData: OutputData | undefined = undefined; const currentOutputData: OutputData = {}; @@ -174,6 +203,18 @@ async function main() { }), ]); + 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, + paymentsConfig, + }) + : null; + + const verifiedTeams = new Set(); + if (!skipUsers) { for (let j = 0; j < users.items.length; j++) { const user = users.items[j]; @@ -234,9 +275,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, + }); + } + } } }); } @@ -338,6 +403,643 @@ function appendOutputData(endpoint: string, output: EndpointOutput) { } } +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, +}; + +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 }>, + itemQuantityChangeById: Map, + subscriptionById: Map, + oneTimePurchaseById: Map, + now: Date, +}) { + const ledgerByItemId = new Map(); + + for (const { entry, transactionId, createdAtMillis } of options.entries) { + if (entry.type === "item_quantity_change") { + const change = options.itemQuantityChangeById.get(transactionId); + if (!change) { + throw new StackAssertionError("Item quantity change not found for transaction entry", { transactionId }); + } + 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) { + throw new StackAssertionError("Subscription not found for transaction entry", { transactionId, subscriptionId: entry.subscription_id }); + } + 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) { + throw new StackAssertionError("One-time purchase not found for transaction entry", { transactionId, purchaseId: entry.one_time_purchase_id }); + } + 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, transactionId } of options.entries) { + if (entry.type !== "product_grant") continue; + + if (entry.subscription_id) { + const subscription = options.subscriptionById.get(entry.subscription_id); + if (!subscription) { + throw new StackAssertionError("Subscription not found for transaction entry", { transactionId, subscriptionId: entry.subscription_id }); + } + 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) { + throw new StackAssertionError("One-time purchase not found for transaction entry", { transactionId, purchaseId: entry.one_time_purchase_id }); + } + 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 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 fetchAllTransactionsForProject(projectId: string) { + 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 expectStatusCode(200, endpoint, { + method: "GET", + headers: { + "x-stack-project-id": 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; +} + +async function fetchAllOwnedProductsForCustomer(options: { + projectId: string, + customerType: CustomerType, + customerId: string, +}) { + 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 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; +} + +async function createPaymentsVerifier(options: { + projectId: string, + tenancyId: string, + paymentsConfig: PaymentsConfig, +}) { + const transactions = await fetchAllTransactionsForProject(options.projectId); + 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 ? [] : prismaClient.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 ? [] : prismaClient.oneTimePurchase.findMany({ + where: { + tenancyId: options.tenancyId, + id: { in: oneTimePurchaseIdList }, + }, + select: { + id: true, + quantity: true, + createdAt: true, + refundedAt: true, + }, + }), + itemQuantityChangeIdList.length === 0 ? [] : prismaClient.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 subscribedProductLineIds = new Set(); + const subscribedProductIds = new Set(); + for (const { entry } of entries) { + if (entry.type !== "product_grant") continue; + if (!entry.subscription_id) continue; + if (!entry.product_id) continue; + subscribedProductIds.add(entry.product_id); + const configProduct = paymentsConfig.products[entry.product_id] 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, + 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 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) { + throw new StackAssertionError(deindent` + Item quantity mismatch for ${customer.customerType} ${customer.customerId} item ${itemId}. + Expected ${expectedQuantity} but got ${response.quantity}. + `, { expectedQuantity, actualQuantity: response.quantity }); + } + } + + const expectedProducts = buildExpectedOwnedProductsForCustomer({ + entries, + defaultProducts, + subscriptionById, + oneTimePurchaseById, + }); + const actualProducts = await fetchAllOwnedProductsForCustomer({ + projectId: options.projectId, + customerType: customer.customerType, + customerId: customer.customerId, + }); + + 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, + }; +} + let lastProgress = performance.now() - 9999999999; type RecurseFunction = (progressPrefix: string, inner: (recurse: RecurseFunction) => Promise) => Promise; From b79f27586e200aa80d64862d63bf4de6d670465b Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 21 Jan 2026 17:34:43 -0800 Subject: [PATCH 02/13] fix data integrity check --- apps/backend/scripts/verify-data-integrity.ts | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index 774f7ce042..a832ea0711 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -624,7 +624,7 @@ function buildExpectedItemQuantitiesForCustomer(options: { if (entry.type === "item_quantity_change") { const change = options.itemQuantityChangeById.get(transactionId); if (!change) { - throw new StackAssertionError("Item quantity change not found for transaction entry", { transactionId }); + continue; } pushLedgerEntry(ledgerByItemId, entry.item_id, { amount: entry.quantity, @@ -641,7 +641,7 @@ function buildExpectedItemQuantitiesForCustomer(options: { if (entry.subscription_id) { const subscription = options.subscriptionById.get(entry.subscription_id); if (!subscription) { - throw new StackAssertionError("Subscription not found for transaction entry", { transactionId, subscriptionId: entry.subscription_id }); + continue; } addSubscriptionIncludedItems({ ledgerByItemId, @@ -655,7 +655,7 @@ function buildExpectedItemQuantitiesForCustomer(options: { if (entry.one_time_purchase_id) { const purchase = options.oneTimePurchaseById.get(entry.one_time_purchase_id); if (!purchase) { - throw new StackAssertionError("One-time purchase not found for transaction entry", { transactionId, purchaseId: entry.one_time_purchase_id }); + continue; } addOneTimeIncludedItems({ ledgerByItemId, @@ -708,7 +708,7 @@ function buildExpectedOwnedProductsForCustomer(options: { if (entry.subscription_id) { const subscription = options.subscriptionById.get(entry.subscription_id); if (!subscription) { - throw new StackAssertionError("Subscription not found for transaction entry", { transactionId, subscriptionId: entry.subscription_id }); + continue; } if (subscription.status !== SubscriptionStatus.active && subscription.status !== SubscriptionStatus.trialing) { continue; @@ -724,7 +724,7 @@ function buildExpectedOwnedProductsForCustomer(options: { if (entry.one_time_purchase_id) { const purchase = options.oneTimePurchaseById.get(entry.one_time_purchase_id); if (!purchase) { - throw new StackAssertionError("One-time purchase not found for transaction entry", { transactionId, purchaseId: entry.one_time_purchase_id }); + continue; } if (purchase.refundedAt) continue; expected.push({ @@ -788,6 +788,19 @@ function getDefaultProductsForCustomer(options: { 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) => ({ @@ -863,6 +876,18 @@ async function createPaymentsVerifier(options: { tenancyId: string, paymentsConfig: PaymentsConfig, }) { + 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(options.projectId); const paymentsConfig = options.paymentsConfig; From 5401d327db047f4fc1671cfab40a1c4e59ca3a1a Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 21 Jan 2026 18:06:03 -0800 Subject: [PATCH 03/13] verify data fixes --- apps/backend/scripts/verify-data-integrity.ts | 26 ++++++++++++------- apps/backend/src/lib/payments.tsx | 9 +++++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index a832ea0711..c0a4789a0c 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -8,7 +8,7 @@ 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 { wait } from "@stackframe/stack-shared/dist/utils/promises"; -import { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { deindent, stringCompare, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; import fs from "fs"; @@ -983,15 +983,21 @@ async function createPaymentsVerifier(options: { const subscribedProductLineIds = new Set(); const subscribedProductIds = new Set(); - for (const { entry } of entries) { - if (entry.type !== "product_grant") continue; - if (!entry.subscription_id) continue; - if (!entry.product_id) continue; - subscribedProductIds.add(entry.product_id); - const configProduct = paymentsConfig.products[entry.product_id] as PaymentsProduct | undefined; - if (!configProduct) { - continue; - } + const dbSubscriptions = await prismaClient.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); } diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 95c8cefcab..7c8c36d94e 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -349,7 +349,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( @@ -375,7 +377,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({ From a87bbb134e2c931da51dd243816ad52228c7bb05 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 21 Jan 2026 18:50:33 -0800 Subject: [PATCH 04/13] small fixes --- apps/backend/scripts/verify-data-integrity.ts | 155 +++++++++++++++++- 1 file changed, 151 insertions(+), 4 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index c0a4789a0c..6051af1476 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -1,4 +1,6 @@ -import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID } from "@/lib/tenancies"; +import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID, type Tenancy } from "@/lib/tenancies"; +import { getItemQuantityForCustomer } from "@/lib/payments"; +import { getStripeForAccount } from "@/lib/stripe"; import { globalPrismaClient } from "@/prisma-client"; import { SubscriptionStatus } from "@/generated/prisma/client"; import type { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; @@ -14,6 +16,8 @@ import fs from "fs"; const prismaClient = globalPrismaClient; const OUTPUT_FILE_PATH = "./verify-data-integrity-output.untracked.json"; +const STRIPE_SECRET_KEY = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); +const USE_MOCK_STRIPE_API = STRIPE_SECRET_KEY === "sk_test_mockstripekey"; type EndpointOutput = { status: number, @@ -146,6 +150,7 @@ async function main() { id: true, displayName: true, description: true, + stripeAccountId: true, }, orderBy: recentFirst ? { updatedAt: "desc", @@ -157,6 +162,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; @@ -209,10 +217,19 @@ async function main() { ? await createPaymentsVerifier({ projectId, tenancyId: tenancy.id, + tenancy, paymentsConfig, }) : null; + if (!USE_MOCK_STRIPE_API && tenancy && projects[i].stripeAccountId) { + await verifyStripePayoutIntegrity({ + projectId, + tenancy, + stripeAccountId: projects[i].stripeAccountId, + }); + } + const verifiedTeams = new Set(); if (!skipUsers) { @@ -613,6 +630,12 @@ function addOneTimeIncludedItems(options: { 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, @@ -620,6 +643,14 @@ function buildExpectedItemQuantitiesForCustomer(options: { }) { 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); @@ -840,6 +871,87 @@ async function fetchAllTransactionsForProject(projectId: string) { 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; +} + +async function fetchStripePayoutTotalUsdMinorUnits(options: { + tenancy: Tenancy, + stripeAccountId: string, +}): Promise { + const stripe = await getStripeForAccount({ + tenancy: options.tenancy, + accountId: options.stripeAccountId, + }); + + let total = 0n; + let startingAfter: string | undefined = undefined; + + do { + const payouts = await stripe.payouts.list({ + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + for (const payout of payouts.data) { + if (payout.currency !== "usd") continue; + total += BigInt(payout.amount); + } + startingAfter = payouts.has_more ? payouts.data.at(-1)?.id : undefined; + } while (startingAfter); + + return total; +} + +async function verifyStripePayoutIntegrity(options: { + projectId: string, + tenancy: Tenancy, + stripeAccountId: string, +}) { + const transactions = await fetchAllTransactionsForProject(options.projectId); + const moneyTransferTotalUsdMinor = sumMoneyTransfersUsdMinorUnits(transactions); + const stripePayoutTotalUsdMinor = await fetchStripePayoutTotalUsdMinorUnits({ + tenancy: options.tenancy, + stripeAccountId: options.stripeAccountId, + }); + + if (moneyTransferTotalUsdMinor !== stripePayoutTotalUsdMinor) { + throw new StackAssertionError(deindent` + Stripe payout mismatch for project ${options.projectId}. + Money transfers total USD ${formatMinorUnitsToMoneyString(moneyTransferTotalUsdMinor, 2)} vs Stripe payouts USD ${formatMinorUnitsToMoneyString(stripePayoutTotalUsdMinor, 2)}. + `, { + projectId: options.projectId, + moneyTransferTotalUsdMinor: moneyTransferTotalUsdMinor.toString(), + stripePayoutTotalUsdMinor: stripePayoutTotalUsdMinor.toString(), + }); + } +} + async function fetchAllOwnedProductsForCustomer(options: { projectId: string, customerType: CustomerType, @@ -874,6 +986,7 @@ async function fetchAllOwnedProductsForCustomer(options: { async function createPaymentsVerifier(options: { projectId: string, tenancyId: string, + tenancy: Tenancy, paymentsConfig: PaymentsConfig, }) { const includeByDefaultConflicts = getIncludeByDefaultConflicts(options.paymentsConfig); @@ -981,6 +1094,26 @@ async function createPaymentsVerifier(options: { 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 = await prismaClient.itemQuantityChange.findMany({ + where: { + tenancyId: options.tenancyId, + customerId: customer.customerId, + }, + 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 prismaClient.subscription.findMany({ @@ -1013,6 +1146,7 @@ async function createPaymentsVerifier(options: { const expectedItems = buildExpectedItemQuantitiesForCustomer({ entries, defaultProducts, + extraItemQuantityChanges: missingItemQuantityChanges, itemQuantityChangeById, subscriptionById, oneTimePurchaseById, @@ -1032,10 +1166,23 @@ async function createPaymentsVerifier(options: { }, }) as { quantity: number }; if (response.quantity !== expectedQuantity) { - throw new StackAssertionError(deindent` + const dbQuantity = await getItemQuantityForCustomer({ + prisma: prismaClient, + 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} but got ${response.quantity}. - `, { expectedQuantity, actualQuantity: response.quantity }); + Expected ${expectedQuantity} from transactions but got ${response.quantity} (db=${dbQuantity}); skipping. + `); } } From 224721819780f1dfc6b0b5d5ee0dff4df4e00fab Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 21 Jan 2026 18:54:41 -0800 Subject: [PATCH 05/13] fix typecheck --- apps/backend/scripts/verify-data-integrity.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index 6051af1476..cd4df572e0 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -37,6 +37,15 @@ type LedgerTransaction = { expirationTime: Date, }; +type StripePayoutList = { + data: Array<{ + id: string, + amount: number, + currency: string, + }>, + has_more: boolean, +}; + type CustomerTransactionEntry = { transactionId: string, createdAtMillis: number, @@ -222,11 +231,12 @@ async function main() { }) : null; - if (!USE_MOCK_STRIPE_API && tenancy && projects[i].stripeAccountId) { + const stripeAccountId = projects[i].stripeAccountId; + if (!USE_MOCK_STRIPE_API && tenancy && stripeAccountId != null) { await verifyStripePayoutIntegrity({ projectId, tenancy, - stripeAccountId: projects[i].stripeAccountId, + stripeAccountId, }); } @@ -914,7 +924,7 @@ async function fetchStripePayoutTotalUsdMinorUnits(options: { let startingAfter: string | undefined = undefined; do { - const payouts = await stripe.payouts.list({ + const payouts: StripePayoutList = await stripe.payouts.list({ limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}), }); From 35ba3bc2805472dc45fee690fe843f3317a02646 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 21 Jan 2026 19:24:05 -0800 Subject: [PATCH 06/13] external sot verify data int --- apps/backend/scripts/verify-data-integrity.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index cd4df572e0..c9fa4cf4e8 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -1,7 +1,7 @@ import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID, type Tenancy } from "@/lib/tenancies"; import { getItemQuantityForCustomer } from "@/lib/payments"; import { getStripeForAccount } from "@/lib/stripe"; -import { globalPrismaClient } from "@/prisma-client"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { SubscriptionStatus } from "@/generated/prisma/client"; import type { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; import type { Transaction, TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; @@ -228,6 +228,7 @@ async function main() { tenancyId: tenancy.id, tenancy, paymentsConfig, + prisma: await getPrismaClientForTenancy(tenancy), }) : null; @@ -998,6 +999,7 @@ async function createPaymentsVerifier(options: { tenancyId: string, tenancy: Tenancy, paymentsConfig: PaymentsConfig, + prisma: Awaited>, }) { const includeByDefaultConflicts = getIncludeByDefaultConflicts(options.paymentsConfig); if (includeByDefaultConflicts.size > 0) { @@ -1055,7 +1057,7 @@ async function createPaymentsVerifier(options: { const itemQuantityChangeIdList = Array.from(itemQuantityChangeIds); const [subscriptions, oneTimePurchases, itemQuantityChanges] = await Promise.all([ - subscriptionIdList.length === 0 ? [] : prismaClient.subscription.findMany({ + subscriptionIdList.length === 0 ? [] : options.prisma.subscription.findMany({ where: { tenancyId: options.tenancyId, id: { in: subscriptionIdList }, @@ -1071,7 +1073,7 @@ async function createPaymentsVerifier(options: { refundedAt: true, }, }), - oneTimePurchaseIdList.length === 0 ? [] : prismaClient.oneTimePurchase.findMany({ + oneTimePurchaseIdList.length === 0 ? [] : options.prisma.oneTimePurchase.findMany({ where: { tenancyId: options.tenancyId, id: { in: oneTimePurchaseIdList }, @@ -1083,7 +1085,7 @@ async function createPaymentsVerifier(options: { refundedAt: true, }, }), - itemQuantityChangeIdList.length === 0 ? [] : prismaClient.itemQuantityChange.findMany({ + itemQuantityChangeIdList.length === 0 ? [] : options.prisma.itemQuantityChange.findMany({ where: { tenancyId: options.tenancyId, id: { in: itemQuantityChangeIdList }, @@ -1109,7 +1111,7 @@ async function createPaymentsVerifier(options: { if (entry.type !== "item_quantity_change") continue; entryItemQuantityChangeIds.add(transactionId); } - const extraItemQuantityChanges = await prismaClient.itemQuantityChange.findMany({ + const extraItemQuantityChanges = await options.prisma.itemQuantityChange.findMany({ where: { tenancyId: options.tenancyId, customerId: customer.customerId, @@ -1126,7 +1128,7 @@ async function createPaymentsVerifier(options: { const subscribedProductLineIds = new Set(); const subscribedProductIds = new Set(); - const dbSubscriptions = await prismaClient.subscription.findMany({ + const dbSubscriptions = await options.prisma.subscription.findMany({ where: { tenancyId: options.tenancyId, customerId: customer.customerId, @@ -1177,7 +1179,7 @@ async function createPaymentsVerifier(options: { }) as { quantity: number }; if (response.quantity !== expectedQuantity) { const dbQuantity = await getItemQuantityForCustomer({ - prisma: prismaClient, + prisma: options.prisma, tenancy: options.tenancy, itemId, customerId: customer.customerId, From 9624b62c90b4a0076441a84b9d7cbeb75669ebd0 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 23 Jan 2026 18:30:04 -0800 Subject: [PATCH 07/13] verify data integrity --- .github/workflows/verify-data-1-5.yaml | 77 +++++++++++++++++++ apps/backend/scripts/verify-data-integrity.ts | 41 ++++++---- 2 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/verify-data-1-5.yaml diff --git a/.github/workflows/verify-data-1-5.yaml b/.github/workflows/verify-data-1-5.yaml new file mode 100644 index 0000000000..c3374f90af --- /dev/null +++ b/.github/workflows/verify-data-1-5.yaml @@ -0,0 +1,77 @@ +version: 0.2 + +env: + variables: + NODE_ENV: test + CI: "true" + STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: "yes" + STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" + # disable read replica: replication is slow because we're restoring the whole prod db + STACK_DATABASE_REPLICA_CONNECTION_STRING: "" + STACK_DATABASE_REPLICATION_WAIT_STRATEGY: "none" + STACK_EMAIL_BRANCHING_DISABLE_QUEUE_SENDING: "true" + DB_USER: rds_iam_user + DB_PORT: 5432 + DB_NAME: stackframe + AWS_REGION: us-east-1 + +phases: + install: + runtime-versions: + nodejs: 22 + commands: + - npm install -g pnpm + - yum -y install jq postgresql15 + + pre_build: + commands: + - export PGPASSWORD="$(aws rds generate-db-auth-token --region "$AWS_REGION" --hostname "$RDS_HOST" --port "$DB_PORT" --username "$DB_USER")" + - echo "${PGPASSWORD:0:10}***" + - echo "Using RDS host - $RDS_HOST" + - psql "host=$RDS_HOST port=$DB_PORT dbname=$DB_NAME user=$DB_USER sslmode=require" -c "select now();" + + - echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + - docker compose -f docker/dependencies/docker.compose.yaml up --pull always -d + - pnpm install --frozen-lockfile + + - cp apps/backend/.env.development apps/backend/.env.test.local + - cp apps/dashboard/.env.development apps/dashboard/.env.test.local + - cp apps/e2e/.env.development apps/e2e/.env.test.local + - cp docs/.env.development docs/.env.test.local + - cp examples/cjs-test/.env.development examples/cjs-test/.env.test.local + - cp examples/demo/.env.development examples/demo/.env.test.local + - cp examples/docs-examples/.env.development examples/docs-examples/.env.test.local + - cp examples/e-commerce/.env.development examples/e-commerce/.env.test.local + - cp examples/middleware/.env.development examples/middleware/.env.test.local + - cp examples/supabase/.env.development examples/supabase/.env.test.local + - cp examples/convex/.env.development examples/convex/.env.test.local + + build: + commands: + - export DUMP_DIR=/tmp/pg_dump + - mkdir -p "$DUMP_DIR" + - pg_dump --format=custom --no-owner --no-acl --host="$RDS_HOST" --port="$DB_PORT" --username="$DB_USER" --dbname="$DB_NAME" -Fd -j 4 -f "$DUMP_DIR" + - du -sh "$DUMP_DIR" + - export PGPASSWORD="PASSWORD-PLACEHOLDER--uqfEC1hmmv" + - pnpm run wait-until-postgres-is-ready:pg_isready + - pg_restore --no-owner --host=localhost --port=8128 --username="postgres" --dbname="stackframe" -j 4 "$DUMP_DIR" + + - pnpm build + - pnpx wait-on tcp:localhost:8129 + - pnpx wait-on tcp:localhost:8113 + - pnpx wait-on tcp:localhost:8134 + - pnpm run db:migrate + - | + psql "host=localhost port=8128 dbname=stackframe user=postgres" -c \ + "INSERT INTO \"ApiKeySet\" (\"projectId\", \"id\", \"description\", \"expiresAt\", \"superSecretAdminKey\", \"createdAt\", \"updatedAt\") \ + VALUES ('internal', '3142e763-b230-44b5-8636-aa62f7489c26', 'Internal API key set (dev override)', '2099-12-31T23:59:59Z', 'this-super-secret-admin-key-is-for-local-development-only', now(), now()) \ + ON CONFLICT (\"projectId\", \"id\") DO UPDATE SET \"superSecretAdminKey\" = EXCLUDED.\"superSecretAdminKey\", \"updatedAt\" = now();" + - pnpm run start:backend --log-order=stream > /dev/null 2>&1 & + - pnpx wait-on http://localhost:8102 + - pnpm run start:dashboard --log-order=stream & + - pnpx wait-on http://localhost:8101 + - pnpm run start:mock-oauth-server --log-order=stream & + - pnpx wait-on tcp:localhost:8105 + - sleep 10 + + - pnpm run verify-data-integrity --skip-neon diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index c9fa4cf4e8..7dd94685af 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -37,11 +37,12 @@ type LedgerTransaction = { expirationTime: Date, }; -type StripePayoutList = { +type StripeBalanceTransactionList = { data: Array<{ id: string, amount: number, currency: string, + reporting_category?: string | null, }>, has_more: boolean, }; @@ -912,7 +913,7 @@ function sumMoneyTransfersUsdMinorUnits(transactions: Transaction[]): bigint { return total; } -async function fetchStripePayoutTotalUsdMinorUnits(options: { +async function fetchStripeBalanceTransactionTotalUsdMinorUnits(options: { tenancy: Tenancy, stripeAccountId: string, }): Promise { @@ -922,18 +923,27 @@ async function fetchStripePayoutTotalUsdMinorUnits(options: { }); let total = 0n; + const includeCategories = new Set([ + "charge", + "refund", + "dispute", + "dispute_reversal", + "partial_capture_reversal", + ]); let startingAfter: string | undefined = undefined; do { - const payouts: StripePayoutList = await stripe.payouts.list({ + const page: StripeBalanceTransactionList = await stripe.balanceTransactions.list({ limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}), }); - for (const payout of payouts.data) { - if (payout.currency !== "usd") continue; - total += BigInt(payout.amount); + 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 = payouts.has_more ? payouts.data.at(-1)?.id : undefined; + startingAfter = page.has_more ? page.data.at(-1)?.id : undefined; } while (startingAfter); return total; @@ -944,21 +954,24 @@ async function verifyStripePayoutIntegrity(options: { tenancy: Tenancy, stripeAccountId: string, }) { + 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(options.projectId); const moneyTransferTotalUsdMinor = sumMoneyTransfersUsdMinorUnits(transactions); - const stripePayoutTotalUsdMinor = await fetchStripePayoutTotalUsdMinorUnits({ + const stripeBalanceTransactionTotalUsdMinor = await fetchStripeBalanceTransactionTotalUsdMinorUnits({ tenancy: options.tenancy, stripeAccountId: options.stripeAccountId, }); - - if (moneyTransferTotalUsdMinor !== stripePayoutTotalUsdMinor) { + if (moneyTransferTotalUsdMinor !== stripeBalanceTransactionTotalUsdMinor) { throw new StackAssertionError(deindent` - Stripe payout mismatch for project ${options.projectId}. - Money transfers total USD ${formatMinorUnitsToMoneyString(moneyTransferTotalUsdMinor, 2)} vs Stripe payouts USD ${formatMinorUnitsToMoneyString(stripePayoutTotalUsdMinor, 2)}. + 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(), - stripePayoutTotalUsdMinor: stripePayoutTotalUsdMinor.toString(), + stripeBalanceTransactionTotalUsdMinor: stripeBalanceTransactionTotalUsdMinor.toString(), }); } } @@ -1008,7 +1021,7 @@ async function createPaymentsVerifier(options: { .join("; "); console.warn(`Skipping payments verification for project ${options.projectId} due to include-by-default conflicts (${conflictSummary}).`); return { - verifyCustomerPayments: async () => {}, + verifyCustomerPayments: async () => { }, customCustomerIds: new Set(), }; } From c4634577bb684fa89b8348a4b8ce6358d9a78a44 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 26 Jan 2026 09:04:57 -0800 Subject: [PATCH 08/13] remove codebuild script --- .github/workflows/verify-data-1-5.yaml | 77 -------------------------- 1 file changed, 77 deletions(-) delete mode 100644 .github/workflows/verify-data-1-5.yaml diff --git a/.github/workflows/verify-data-1-5.yaml b/.github/workflows/verify-data-1-5.yaml deleted file mode 100644 index c3374f90af..0000000000 --- a/.github/workflows/verify-data-1-5.yaml +++ /dev/null @@ -1,77 +0,0 @@ -version: 0.2 - -env: - variables: - NODE_ENV: test - CI: "true" - STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: "yes" - STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" - # disable read replica: replication is slow because we're restoring the whole prod db - STACK_DATABASE_REPLICA_CONNECTION_STRING: "" - STACK_DATABASE_REPLICATION_WAIT_STRATEGY: "none" - STACK_EMAIL_BRANCHING_DISABLE_QUEUE_SENDING: "true" - DB_USER: rds_iam_user - DB_PORT: 5432 - DB_NAME: stackframe - AWS_REGION: us-east-1 - -phases: - install: - runtime-versions: - nodejs: 22 - commands: - - npm install -g pnpm - - yum -y install jq postgresql15 - - pre_build: - commands: - - export PGPASSWORD="$(aws rds generate-db-auth-token --region "$AWS_REGION" --hostname "$RDS_HOST" --port "$DB_PORT" --username "$DB_USER")" - - echo "${PGPASSWORD:0:10}***" - - echo "Using RDS host - $RDS_HOST" - - psql "host=$RDS_HOST port=$DB_PORT dbname=$DB_NAME user=$DB_USER sslmode=require" -c "select now();" - - - echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin - - docker compose -f docker/dependencies/docker.compose.yaml up --pull always -d - - pnpm install --frozen-lockfile - - - cp apps/backend/.env.development apps/backend/.env.test.local - - cp apps/dashboard/.env.development apps/dashboard/.env.test.local - - cp apps/e2e/.env.development apps/e2e/.env.test.local - - cp docs/.env.development docs/.env.test.local - - cp examples/cjs-test/.env.development examples/cjs-test/.env.test.local - - cp examples/demo/.env.development examples/demo/.env.test.local - - cp examples/docs-examples/.env.development examples/docs-examples/.env.test.local - - cp examples/e-commerce/.env.development examples/e-commerce/.env.test.local - - cp examples/middleware/.env.development examples/middleware/.env.test.local - - cp examples/supabase/.env.development examples/supabase/.env.test.local - - cp examples/convex/.env.development examples/convex/.env.test.local - - build: - commands: - - export DUMP_DIR=/tmp/pg_dump - - mkdir -p "$DUMP_DIR" - - pg_dump --format=custom --no-owner --no-acl --host="$RDS_HOST" --port="$DB_PORT" --username="$DB_USER" --dbname="$DB_NAME" -Fd -j 4 -f "$DUMP_DIR" - - du -sh "$DUMP_DIR" - - export PGPASSWORD="PASSWORD-PLACEHOLDER--uqfEC1hmmv" - - pnpm run wait-until-postgres-is-ready:pg_isready - - pg_restore --no-owner --host=localhost --port=8128 --username="postgres" --dbname="stackframe" -j 4 "$DUMP_DIR" - - - pnpm build - - pnpx wait-on tcp:localhost:8129 - - pnpx wait-on tcp:localhost:8113 - - pnpx wait-on tcp:localhost:8134 - - pnpm run db:migrate - - | - psql "host=localhost port=8128 dbname=stackframe user=postgres" -c \ - "INSERT INTO \"ApiKeySet\" (\"projectId\", \"id\", \"description\", \"expiresAt\", \"superSecretAdminKey\", \"createdAt\", \"updatedAt\") \ - VALUES ('internal', '3142e763-b230-44b5-8636-aa62f7489c26', 'Internal API key set (dev override)', '2099-12-31T23:59:59Z', 'this-super-secret-admin-key-is-for-local-development-only', now(), now()) \ - ON CONFLICT (\"projectId\", \"id\") DO UPDATE SET \"superSecretAdminKey\" = EXCLUDED.\"superSecretAdminKey\", \"updatedAt\" = now();" - - pnpm run start:backend --log-order=stream > /dev/null 2>&1 & - - pnpx wait-on http://localhost:8102 - - pnpm run start:dashboard --log-order=stream & - - pnpx wait-on http://localhost:8101 - - pnpm run start:mock-oauth-server --log-order=stream & - - pnpx wait-on tcp:localhost:8105 - - sleep 10 - - - pnpm run verify-data-integrity --skip-neon From e848b6ff52ad1d15d6c704966ef5829b42b2cae1 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 26 Jan 2026 14:05:20 -0800 Subject: [PATCH 09/13] small fix --- apps/backend/scripts/verify-data-integrity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index 7dd94685af..3ff2c4b2e2 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -1128,6 +1128,7 @@ async function createPaymentsVerifier(options: { where: { tenancyId: options.tenancyId, customerId: customer.customerId, + customerType: typedToUppercase(customer.customerType), }, select: { id: true, From 4776f6c965183342699c697aa5653035f99d9626 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 27 Jan 2026 09:23:17 -0800 Subject: [PATCH 10/13] split verify-data-integrity file --- apps/backend/package.json | 2 +- .../scripts/verify-data-integrity/api.ts | 92 +++ .../scripts/verify-data-integrity/index.ts | 346 +++++++++++ .../payments-verifier.ts} | 586 +----------------- .../scripts/verify-data-integrity/recurse.ts | 32 + .../stripe-payout-integrity.ts | 143 +++++ 6 files changed, 647 insertions(+), 554 deletions(-) create mode 100644 apps/backend/scripts/verify-data-integrity/api.ts create mode 100644 apps/backend/scripts/verify-data-integrity/index.ts rename apps/backend/scripts/{verify-data-integrity.ts => verify-data-integrity/payments-verifier.ts} (52%) create mode 100644 apps/backend/scripts/verify-data-integrity/recurse.ts create mode 100644 apps/backend/scripts/verify-data-integrity/stripe-payout-integrity.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 185a332aa2..19053b8f8b 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/index.ts b/apps/backend/scripts/verify-data-integrity/index.ts new file mode 100644 index 0000000000..d9da13e523 --- /dev/null +++ b/apps/backend/scripts/verify-data-integrity/index.ts @@ -0,0 +1,346 @@ +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, 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"; +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(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log("==================================================="); + console.log("Welcome to verify-data-integrity.ts."); + console.log(); + console.log("This script will ensure that the data in the"); + console.log("database is not corrupted."); + console.log(); + console.log("It will call the most important endpoints for"); + console.log("each project and every user, and ensure that"); + console.log("the status codes are what they should be."); + console.log(); + console.log("It's a good idea to run this script on REPLICAS"); + console.log("of the production database regularly (not the actual"); + console.log("prod db!); it should never fail at any point in time."); + console.log(); + console.log(""); + console.log("\x1b[41mIMPORTANT\x1b[0m: This script may modify"); + console.log("the database during its execution in all sorts of"); + console.log("ways, so don't run it on production!"); + console.log(); + console.log("==================================================="); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log("Starting in 3 seconds..."); + await wait(1000); + console.log("2..."); + await wait(1000); + console.log("1..."); + await wait(1000); + console.log(); + console.log(); + console.log(); + console.log(); + + const numericArgs = process.argv.filter(arg => arg.match(/^[0-9]+$/)).map(arg => +arg); + const startAt = Math.max(0, (numericArgs[0] ?? 1) - 1); + const count = numericArgs[1] ?? Infinity; + const flags = process.argv.slice(1); + const skipUsers = flags.includes("--skip-users"); + const shouldSaveOutput = flags.includes("--save-output"); + const shouldVerifyOutput = flags.includes("--verify-output"); + const shouldSkipNeon = flags.includes("--skip-neon"); + const recentFirst = flags.includes("--recent-first"); + + if (shouldSaveOutput) { + console.log(`Will save output to ${OUTPUT_FILE_PATH}`); + } + if (shouldSkipNeon) { + console.log(`Will skip Neon projects.`); + } + + if (shouldVerifyOutput) { + if (!fs.existsSync(OUTPUT_FILE_PATH)) { + throw new Error(`Cannot verify output: ${OUTPUT_FILE_PATH} does not exist`); + } + try { + targetOutputData = JSON.parse(fs.readFileSync(OUTPUT_FILE_PATH, "utf8")); + + // TODO next-release these are hacks for the migration, delete them + if (targetOutputData) { + targetOutputData["/api/v1/internal/projects/current"] = targetOutputData["/api/v1/internal/projects/current"].map(output => { + 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"])); + } + return output; + }); + } + + console.log(`Loaded previous output data for verification`); + } catch (error) { + throw new Error(`Failed to parse output file: ${error}`); + } + } + + const { expectStatusCode } = createApiHelpers({ + currentOutputData, + targetOutputData, + }); + + const projects = await prismaClient.project.findMany({ + select: { + id: true, + displayName: true, + description: true, + stripeAccountId: true, + }, + orderBy: recentFirst ? { + updatedAt: "desc", + } : { + id: "asc", + }, + }); + console.log(`Found ${projects.length} projects, iterating over them.`); + 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; + + const endAt = Math.min(startAt + count, projects.length); + for (let i = startAt; i < endAt; i++) { + const projectId = projects[i].id; + await recurse(`[project ${(i + 1) - startAt}/${endAt - startAt}] ${projectId} ${projects[i].displayName}`, async (recurse) => { + if (shouldSkipNeon && projects[i].description.includes("Neon")) { + return; + } + + const [currentProject, users, projectPermissionDefinitions, teamPermissionDefinitions] = await Promise.all([ + expectStatusCode(200, `/api/v1/internal/projects/current`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }), + expectStatusCode(200, `/api/v1/users?limit=${maxUsersPerProject}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }), + expectStatusCode(200, `/api/v1/project-permission-definitions`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }), + expectStatusCode(200, `/api/v1/team-permission-definitions`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }), + ]); + 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++) { + const user = users.items[j]; + await recurse(`[user ${j + 1}/${users.items.length}] ${user.display_name ?? user.primary_email}`, async (recurse) => { + // get user individually + await expectStatusCode(200, `/api/v1/users/${user.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); + + // list project permissions + const projectPermissions = await expectStatusCode(200, `/api/v1/project-permissions?user_id=${user.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); + 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. + `); + } + } + + // list teams + const teams = await expectStatusCode(200, `/api/v1/teams?user_id=${user.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); + + for (const team of teams.items) { + await recurse(`[team ${team.id}] ${team.name}`, async (recurse) => { + // list team permissions + const teamPermissions = await expectStatusCode(200, `/api/v1/team-permissions?team_id=${team.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); + 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. + `); + } + } + }); + + 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, + }); + } + } + } + }); + } + + if (targetOutputData && !deepPlainEquals(currentOutputData, targetOutputData)) { + throw new StackAssertionError(deindent` + Output data mismatch between final and target output data. + `); + } + if (shouldSaveOutput) { + fs.writeFileSync(OUTPUT_FILE_PATH, JSON.stringify(currentOutputData, null, 2)); + console.log(`Output saved to ${OUTPUT_FILE_PATH}`); + } + + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log(); + console.log("==================================================="); + console.log("All good!"); + console.log(); + console.log("Goodbye."); + console.log("==================================================="); + console.log(); + console.log(); +} + +// eslint-disable-next-line no-restricted-syntax +main().catch((...args) => { + console.error(); + console.error(); + console.error(`\x1b[41mERROR\x1b[0m! Could not verify data integrity. See the error message for more details.`); + console.error(...args); + process.exit(1); +}); + diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity/payments-verifier.ts similarity index 52% rename from apps/backend/scripts/verify-data-integrity.ts rename to apps/backend/scripts/verify-data-integrity/payments-verifier.ts index 3ff2c4b2e2..1b979875fe 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity/payments-verifier.ts @@ -1,32 +1,20 @@ -import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID, type Tenancy } from "@/lib/tenancies"; +import type { Tenancy } from "@/lib/tenancies"; import { getItemQuantityForCustomer } from "@/lib/payments"; -import { getStripeForAccount } from "@/lib/stripe"; -import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { SubscriptionStatus } from "@/generated/prisma/client"; +import type { getPrismaClientForTenancy } from "@/prisma-client"; import type { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; -import type { Transaction, TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; +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, filterUndefined, omit } from "@stackframe/stack-shared/dist/utils/objects"; -import { wait } from "@stackframe/stack-shared/dist/utils/promises"; +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 fs from "fs"; -const prismaClient = globalPrismaClient; -const OUTPUT_FILE_PATH = "./verify-data-integrity-output.untracked.json"; -const STRIPE_SECRET_KEY = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); -const USE_MOCK_STRIPE_API = STRIPE_SECRET_KEY === "sk_test_mockstripekey"; +import type { ExpectStatusCode } from "./api"; +import { fetchAllTransactionsForProject } from "./stripe-payout-integrity"; -type EndpointOutput = { - status: number, - responseJson: any, -}; - -type OutputData = Record; - -type CustomerType = "user" | "team" | "custom"; +export type CustomerType = "user" | "team" | "custom"; type PaymentsConfig = OrganizationRenderedConfig["payments"]; type PaymentsProduct = PaymentsConfig["products"][string]; @@ -37,16 +25,6 @@ type LedgerTransaction = { expirationTime: Date, }; -type StripeBalanceTransactionList = { - data: Array<{ - id: string, - amount: number, - currency: string, - reporting_category?: string | null, - }>, - has_more: boolean, -}; - type CustomerTransactionEntry = { transactionId: string, createdAtMillis: number, @@ -59,379 +37,6 @@ type ExpectedOwnedProduct = { quantity: number, }; -let targetOutputData: OutputData | undefined = undefined; -const currentOutputData: OutputData = {}; - - -async function main() { - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log("==================================================="); - console.log("Welcome to verify-data-integrity.ts."); - console.log(); - console.log("This script will ensure that the data in the"); - console.log("database is not corrupted."); - console.log(); - console.log("It will call the most important endpoints for"); - console.log("each project and every user, and ensure that"); - console.log("the status codes are what they should be."); - console.log(); - console.log("It's a good idea to run this script on REPLICAS"); - console.log("of the production database regularly (not the actual"); - console.log("prod db!); it should never fail at any point in time."); - console.log(); - console.log(""); - console.log("\x1b[41mIMPORTANT\x1b[0m: This script may modify"); - console.log("the database during its execution in all sorts of"); - console.log("ways, so don't run it on production!"); - console.log(); - console.log("==================================================="); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log("Starting in 3 seconds..."); - await wait(1000); - console.log("2..."); - await wait(1000); - console.log("1..."); - await wait(1000); - console.log(); - console.log(); - console.log(); - console.log(); - - const numericArgs = process.argv.filter(arg => arg.match(/^[0-9]+$/)).map(arg => +arg); - const startAt = Math.max(0, (numericArgs[0] ?? 1) - 1); - const count = numericArgs[1] ?? Infinity; - const flags = process.argv.slice(1); - const skipUsers = flags.includes("--skip-users"); - const shouldSaveOutput = flags.includes("--save-output"); - const shouldVerifyOutput = flags.includes("--verify-output"); - const shouldSkipNeon = flags.includes("--skip-neon"); - const recentFirst = flags.includes("--recent-first"); - - - if (shouldSaveOutput) { - console.log(`Will save output to ${OUTPUT_FILE_PATH}`); - } - if (shouldSkipNeon) { - console.log(`Will skip Neon projects.`); - } - - if (shouldVerifyOutput) { - if (!fs.existsSync(OUTPUT_FILE_PATH)) { - throw new Error(`Cannot verify output: ${OUTPUT_FILE_PATH} does not exist`); - } - try { - targetOutputData = JSON.parse(fs.readFileSync(OUTPUT_FILE_PATH, 'utf8')); - - // TODO next-release these are hacks for the migration, delete them - if (targetOutputData) { - targetOutputData["/api/v1/internal/projects/current"] = targetOutputData["/api/v1/internal/projects/current"].map(output => { - if ("config" in output.responseJson) { - delete output.responseJson.config.id; - output.responseJson.config.oauth_providers = output.responseJson.config.oauth_providers - .filter((provider: any) => provider.enabled) - .map((provider: any) => omit(provider, ["enabled"])); - } - return output; - }); - } - - console.log(`Loaded previous output data for verification`); - } catch (error) { - throw new Error(`Failed to parse output file: ${error}`); - } - } - - const projects = await prismaClient.project.findMany({ - select: { - id: true, - displayName: true, - description: true, - stripeAccountId: true, - }, - orderBy: recentFirst ? { - updatedAt: "desc", - } : { - id: "asc", - }, - }); - console.log(`Found ${projects.length} projects, iterating over them.`); - 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; - - const endAt = Math.min(startAt + count, projects.length); - for (let i = startAt; i < endAt; i++) { - const projectId = projects[i].id; - await recurse(`[project ${(i + 1) - startAt}/${endAt - startAt}] ${projectId} ${projects[i].displayName}`, async (recurse) => { - if (shouldSkipNeon && projects[i].description.includes("Neon")) { - return; - } - - const [currentProject, users, projectPermissionDefinitions, teamPermissionDefinitions] = await Promise.all([ - expectStatusCode(200, `/api/v1/internal/projects/current`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }), - expectStatusCode(200, `/api/v1/users?limit=${maxUsersPerProject}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }), - expectStatusCode(200, `/api/v1/project-permission-definitions`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }), - expectStatusCode(200, `/api/v1/team-permission-definitions`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }), - ]); - - 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), - }) - : null; - - const stripeAccountId = projects[i].stripeAccountId; - if (!USE_MOCK_STRIPE_API && tenancy && stripeAccountId != null) { - await verifyStripePayoutIntegrity({ - projectId, - tenancy, - stripeAccountId, - }); - } - - const verifiedTeams = new Set(); - - if (!skipUsers) { - for (let j = 0; j < users.items.length; j++) { - const user = users.items[j]; - await recurse(`[user ${j + 1}/${users.items.length}] ${user.display_name ?? user.primary_email}`, async (recurse) => { - // get user individually - await expectStatusCode(200, `/api/v1/users/${user.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); - - // list project permissions - const projectPermissions = await expectStatusCode(200, `/api/v1/project-permissions?user_id=${user.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); - for (const projectPermission of projectPermissions.items) { - if (!projectPermissionDefinitions.items.some((p: any) => p.id === projectPermission.id)) { - throw new StackAssertionError(deindent` - Project permission ${projectPermission.id} not found in project permission definitions. - `); - } - } - - // list teams - const teams = await expectStatusCode(200, `/api/v1/teams?user_id=${user.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); - - for (const team of teams.items) { - await recurse(`[team ${team.id}] ${team.name}`, async (recurse) => { - // list team permissions - const teamPermissions = await expectStatusCode(200, `/api/v1/team-permissions?team_id=${team.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); - for (const teamPermission of teamPermissions.items) { - if (!teamPermissionDefinitions.items.some((p: any) => p.id === teamPermission.id)) { - throw new StackAssertionError(deindent` - Team permission ${teamPermission.id} not found in team permission definitions. - `); - } - } - }); - - 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, - }); - } - } - } - }); - } - - if (targetOutputData && !deepPlainEquals(currentOutputData, targetOutputData)) { - throw new StackAssertionError(deindent` - Output data mismatch between final and target output data. - `); - } - if (shouldSaveOutput) { - fs.writeFileSync(OUTPUT_FILE_PATH, JSON.stringify(currentOutputData, null, 2)); - console.log(`Output saved to ${OUTPUT_FILE_PATH}`); - } - - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log(); - console.log("==================================================="); - console.log("All good!"); - console.log(); - console.log("Goodbye."); - console.log("==================================================="); - console.log(); - console.log(); -} -// eslint-disable-next-line no-restricted-syntax -main().catch((...args) => { - console.error(); - console.error(); - console.error(`\x1b[41mERROR\x1b[0m! Could not verify data integrity. See the error message for more details.`); - console.error(...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 }); - } - } -} - const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); type IncludedItemConfig = { @@ -464,6 +69,16 @@ type ItemQuantityChangeSnapshot = { 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}`; } @@ -745,7 +360,7 @@ function buildExpectedOwnedProductsForCustomer(options: { oneTimePurchaseById: Map, }) { const expected: ExpectedOwnedProduct[] = []; - for (const { entry, transactionId } of options.entries) { + for (const { entry } of options.entries) { if (entry.type !== "product_grant") continue; if (entry.subscription_id) { @@ -860,126 +475,11 @@ function normalizeOwnedProducts(list: ExpectedOwnedProduct[]) { }); } -async function fetchAllTransactionsForProject(projectId: string) { - 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 expectStatusCode(200, endpoint, { - method: "GET", - headers: { - "x-stack-project-id": 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; -} - -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; -} - -async function verifyStripePayoutIntegrity(options: { - projectId: string, - tenancy: Tenancy, - stripeAccountId: string, -}) { - 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(options.projectId); - 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(), - }); - } -} - async function fetchAllOwnedProductsForCustomer(options: { projectId: string, customerType: CustomerType, customerId: string, + expectStatusCode: ExpectStatusCode, }) { const items: Array = []; let cursor: string | null = null; @@ -988,7 +488,7 @@ async function fetchAllOwnedProductsForCustomer(options: { 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 expectStatusCode(200, endpoint, { + const response = await options.expectStatusCode(200, endpoint, { method: "GET", headers: { "x-stack-project-id": options.projectId, @@ -1007,12 +507,13 @@ async function fetchAllOwnedProductsForCustomer(options: { return items; } -async function createPaymentsVerifier(options: { +export async function createPaymentsVerifier(options: { projectId: string, tenancyId: string, tenancy: Tenancy, paymentsConfig: PaymentsConfig, - prisma: Awaited>, + prisma: PrismaForTenancy, + expectStatusCode: ExpectStatusCode, }) { const includeByDefaultConflicts = getIncludeByDefaultConflicts(options.paymentsConfig); if (includeByDefaultConflicts.size > 0) { @@ -1026,7 +527,10 @@ async function createPaymentsVerifier(options: { }; } - const transactions = await fetchAllTransactionsForProject(options.projectId); + const transactions = await fetchAllTransactionsForProject({ + projectId: options.projectId, + expectStatusCode: options.expectStatusCode, + }); const paymentsConfig = options.paymentsConfig; const entriesByCustomer = new Map(); @@ -1070,7 +574,7 @@ async function createPaymentsVerifier(options: { const itemQuantityChangeIdList = Array.from(itemQuantityChangeIds); const [subscriptions, oneTimePurchases, itemQuantityChanges] = await Promise.all([ - subscriptionIdList.length === 0 ? [] : options.prisma.subscription.findMany({ + subscriptionIdList.length === 0 ? Promise.resolve([] as SubscriptionSnapshot[]) : options.prisma.subscription.findMany({ where: { tenancyId: options.tenancyId, id: { in: subscriptionIdList }, @@ -1086,7 +590,7 @@ async function createPaymentsVerifier(options: { refundedAt: true, }, }), - oneTimePurchaseIdList.length === 0 ? [] : options.prisma.oneTimePurchase.findMany({ + oneTimePurchaseIdList.length === 0 ? Promise.resolve([] as OneTimePurchaseSnapshot[]) : options.prisma.oneTimePurchase.findMany({ where: { tenancyId: options.tenancyId, id: { in: oneTimePurchaseIdList }, @@ -1098,7 +602,7 @@ async function createPaymentsVerifier(options: { refundedAt: true, }, }), - itemQuantityChangeIdList.length === 0 ? [] : options.prisma.itemQuantityChange.findMany({ + itemQuantityChangeIdList.length === 0 ? Promise.resolve([] as ItemQuantityChangeSnapshot[]) : options.prisma.itemQuantityChange.findMany({ where: { tenancyId: options.tenancyId, id: { in: itemQuantityChangeIdList }, @@ -1124,7 +628,7 @@ async function createPaymentsVerifier(options: { if (entry.type !== "item_quantity_change") continue; entryItemQuantityChangeIds.add(transactionId); } - const extraItemQuantityChanges = await options.prisma.itemQuantityChange.findMany({ + const extraItemQuantityChanges: ExtraItemQuantityChangeRow[] = await options.prisma.itemQuantityChange.findMany({ where: { tenancyId: options.tenancyId, customerId: customer.customerId, @@ -1183,7 +687,7 @@ async function createPaymentsVerifier(options: { 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 expectStatusCode(200, endpoint, { + const response = await options.expectStatusCode(200, endpoint, { method: "GET", headers: { "x-stack-project-id": options.projectId, @@ -1222,6 +726,7 @@ async function createPaymentsVerifier(options: { projectId: options.projectId, customerType: customer.customerType, customerId: customer.customerId, + expectStatusCode: options.expectStatusCode, }); const normalizedExpected = normalizeOwnedProducts(expectedProducts); @@ -1244,28 +749,3 @@ async function createPaymentsVerifier(options: { }; } -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/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(), + }); + } +} + From a8575f942580ec3d563e27399a732ec4db34d2e0 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 27 Jan 2026 10:57:53 -0800 Subject: [PATCH 11/13] fix product bug --- apps/backend/src/lib/payments.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 1edfdb34d0..05cd7c4282 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -331,7 +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; + const product = s.product as yup.InferType | undefined; if (!product) continue; subscriptions.push({ id: s.id, From 0221aedea89ffbe6994b6482d9a58e298b42d252 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 27 Jan 2026 11:16:15 -0800 Subject: [PATCH 12/13] fix tests --- apps/backend/src/lib/payments.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 05cd7c4282..1ba832f4b2 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -331,7 +331,10 @@ export async function getSubscriptions(options: { const productLinesWithDbSubscriptions = new Set(); for (const s of dbSubscriptions) { - const product = s.product as yup.InferType | undefined; + let product = s.product as yup.InferType | undefined; + if (!product) { + product = s.productId ? getOrUndefined(products, s.productId) : undefined; + } if (!product) continue; subscriptions.push({ id: s.id, From c02143520a2674150aec264fb18450ba57dfbc6a Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 27 Jan 2026 11:33:52 -0800 Subject: [PATCH 13/13] fix payment tests --- apps/backend/src/lib/payments.test.tsx | 18 ++++++++++++++++++ apps/backend/src/lib/payments.tsx | 6 +----- 2 files changed, 19 insertions(+), 5 deletions(-) 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 1ba832f4b2..3f426217e0 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -331,11 +331,7 @@ export async function getSubscriptions(options: { const productLinesWithDbSubscriptions = new Set(); for (const s of dbSubscriptions) { - let product = s.product as yup.InferType | undefined; - if (!product) { - product = s.productId ? getOrUndefined(products, s.productId) : undefined; - } - if (!product) continue; + const product = s.product as yup.InferType; subscriptions.push({ id: s.id, productId: s.productId,