Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Rewrite legacy `include-by-default` price sentinel in historical product JSON
-- snapshots to an empty price map, and coalesce any missing `includedItems` to
-- an empty record so downstream readers (e.g. mapProductSnapshotToInlineProduct)
-- don't throw on legacy snapshots. Include-by-default was deprecated in the
-- bulldozer payments rework and is no longer supported.
--
-- Scale note: prod has ~5 products affected at the time of writing, so a
-- single-statement UPDATE inside Prisma's default migration transaction is fine.
-- If this ever needs to run against a larger affected row set, batch it or
-- split the migration so it runs outside a transaction.

UPDATE "Subscription"
SET "product" = jsonb_set(
jsonb_set("product"::jsonb, '{prices}', '{}'::jsonb),
'{includedItems}',
COALESCE("product"::jsonb->'includedItems', '{}'::jsonb)
)::json
WHERE "product"->>'prices' = 'include-by-default';

UPDATE "OneTimePurchase"
SET "product" = jsonb_set(
jsonb_set("product"::jsonb, '{prices}', '{}'::jsonb),
'{includedItems}',
COALESCE("product"::jsonb->'includedItems', '{}'::jsonb)
)::json
WHERE "product"->>'prices' = 'include-by-default';

UPDATE "ProductVersion"
SET "productJson" = jsonb_set(
jsonb_set("productJson"::jsonb, '{prices}', '{}'::jsonb),
'{includedItems}',
COALESCE("productJson"::jsonb->'includedItems', '{}'::jsonb)
)::json
WHERE "productJson"->>'prices' = 'include-by-default';
116 changes: 0 additions & 116 deletions apps/backend/scripts/verify-data-integrity/payments-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { fetchAllTransactionsForProject } from "./stripe-payout-integrity";
export type CustomerType = "user" | "team" | "custom";

type PaymentsConfig = OrganizationRenderedConfig["payments"];
type PaymentsProduct = PaymentsConfig["products"][string];

type LedgerTransaction = {
amount: number,
Expand All @@ -37,8 +36,6 @@ type ExpectedOwnedProduct = {
quantity: number,
};

const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z");

type IncludedItemConfig = {
quantity?: number,
repeat?: DayInterval | "never" | null,
Expand Down Expand Up @@ -257,7 +254,6 @@ function addOneTimeIncludedItems(options: {

function buildExpectedItemQuantitiesForCustomer(options: {
entries: CustomerTransactionEntry[],
defaultProducts: Array<{ productId: string, product: PaymentsProduct }>,
extraItemQuantityChanges: Array<{
itemId: string,
quantity: number,
Expand Down Expand Up @@ -333,20 +329,6 @@ function buildExpectedItemQuantitiesForCustomer(options: {
});
}

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<string, number>();
for (const [itemId, ledger] of ledgerByItemId) {
results.set(itemId, computeLedgerBalanceAtNow(ledger, options.now));
Expand All @@ -356,7 +338,6 @@ function buildExpectedItemQuantitiesForCustomer(options: {

function buildExpectedOwnedProductsForCustomer(options: {
entries: CustomerTransactionEntry[],
defaultProducts: Array<{ productId: string, product: PaymentsProduct }>,
subscriptionById: Map<string, SubscriptionSnapshot>,
oneTimePurchaseById: Map<string, OneTimePurchaseSnapshot>,
}) {
Expand Down Expand Up @@ -407,65 +388,9 @@ function buildExpectedOwnedProductsForCustomer(options: {
});
}

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<string>,
subscribedProductIds: Set<string>,
}) {
const defaultsByProductLine = new Map<string, { productId: string, product: PaymentsProduct }>();
const ungroupedDefaults: Array<{ productId: string, product: PaymentsProduct }> = [];

for (const [productId, product] of Object.entries(options.paymentsConfig.products)) {
if (product.customerType !== options.customerType) continue;
if (product.prices !== "include-by-default") continue;

if (product.productLineId) {
if (!defaultsByProductLine.has(product.productLineId)) {
defaultsByProductLine.set(product.productLineId, { productId, product });
}
continue;
}

ungroupedDefaults.push({ productId, product });
}

const defaults: Array<{ productId: string, product: PaymentsProduct }> = [];
for (const [productLineId, product] of defaultsByProductLine) {
if (options.subscribedProductLineIds.has(productLineId)) continue;
defaults.push(product);
}
for (const product of ungroupedDefaults) {
if (options.subscribedProductIds.has(product.productId)) continue;
defaults.push(product);
}
return defaults;
}

function getIncludeByDefaultConflicts(paymentsConfig: PaymentsConfig) {
const conflicts = new Map<string, string[]>();
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[]) {
// Aggregate entries by (id, type) — the bulldozer LFold sums quantities per product
const merged = new Map<string, ExpectedOwnedProduct>();
Expand Down Expand Up @@ -530,18 +455,6 @@ export async function createPaymentsVerifier(options: {
prisma: PrismaForTenancy,
expectStatusCode: ExpectStatusCode,
}) {
const includeByDefaultConflicts = getIncludeByDefaultConflicts(options.paymentsConfig);
if (includeByDefaultConflicts.size > 0) {
const conflictSummary = Array.from(includeByDefaultConflicts.entries())
.map(([productLineId, productIds]) => `${productLineId}: ${productIds.join(", ")}`)
.join("; ");
console.warn(`Skipping payments verification for project ${options.projectId} due to include-by-default conflicts (${conflictSummary}).`);
return {
verifyCustomerPayments: async () => { },
customCustomerIds: new Set<string>(),
};
}

const transactions = await fetchAllTransactionsForProject({
projectId: options.projectId,
expectStatusCode: options.expectStatusCode,
Expand Down Expand Up @@ -660,36 +573,8 @@ export async function createPaymentsVerifier(options: {
});
const missingItemQuantityChanges = extraItemQuantityChanges.filter((change) => !entryItemQuantityChangeIds.has(change.id));

const subscribedProductLineIds = new Set<string>();
const subscribedProductIds = new Set<string>();
const dbSubscriptions = await options.prisma.subscription.findMany({
where: {
tenancyId: options.tenancyId,
customerId: customer.customerId,
customerType: typedToUppercase(customer.customerType),
},
select: {
productId: true,
},
});
for (const { productId } of dbSubscriptions) {
if (!productId) continue;
subscribedProductIds.add(productId);
const configProduct = paymentsConfig.products[productId] as PaymentsProduct | undefined;
if (!configProduct) continue;
if (configProduct.productLineId) {
subscribedProductLineIds.add(configProduct.productLineId);
}
}

// include-by-default products are no longer automatically granted.
// Old customers may still have them, but the bulldozer pipeline doesn't
// produce ownership for them. Skip default products in verification.
const defaultProducts: Array<{ productId: string, product: PaymentsProduct }> = [];

const expectedItems = buildExpectedItemQuantitiesForCustomer({
entries,
defaultProducts,
extraItemQuantityChanges: missingItemQuantityChanges,
itemQuantityChangeById,
subscriptionById,
Expand Down Expand Up @@ -732,7 +617,6 @@ export async function createPaymentsVerifier(options: {

const expectedProducts = buildExpectedOwnedProductsForCustomer({
entries,
defaultProducts,
subscriptionById,
oneTimePurchaseById,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,25 @@ const writeResponseSchema = yupObject({
bodyType: yupString().oneOf(["success"]).defined(),
});

function findIncludeByDefaultPath(value: unknown, path: string[] = []): string | null {
if (value === "include-by-default") {
// Only flag the deprecated sentinel when it sits at `payments.products.*.prices`;
// anywhere else it's just a string literal that happens to match.
if (path.length === 4 && path[0] === "payments" && path[1] === "products" && path[3] === "prices") {
return path.join(".");
}
return null;
}
if (value && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, ...key.split(".")];
const found = findIncludeByDefaultPath(child, childPath);
if (found) return found;
}
}
return null;
}

async function parseAndValidateConfig(
configString: string,
levelConfig: typeof levelConfigs["branch" | "environment" | "project"]
Expand All @@ -182,6 +201,17 @@ async function parseAndValidateConfig(
throw e;
}

// Reject writes that use the deprecated `include-by-default` price sentinel. Reads of
// old stored configs still get migrated silently (see migrateConfigOverride) so existing
// data keeps loading, but new writes must use an explicit $0 price instead.
const legacyPath = findIncludeByDefaultPath(parsedConfig);
if (legacyPath) {
throw new StatusError(
StatusError.BadRequest,
`"include-by-default" is no longer supported at ${legacyPath}. Use an explicit $0 price instead.`,
);
}

const migratedConfig = levelConfig.migrate(parsedConfig);
const overrideError = await getConfigOverrideErrors(levelConfig.schema, migratedConfig);
if (overrideError.status === "error") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,21 +213,7 @@ function mapProductSnapshotToInlineProduct(product: unknown): InlineProduct {

const customerType = readCustomerType(product.customerType, "product snapshot");
const includedItemsRaw = product.includedItems;
// Legacy include-by-default products may have no includedItems in their snapshot
if (!isRecord(includedItemsRaw)) {
if (product.prices === "include-by-default") {
return {
display_name: typeof product.displayName === "string" ? product.displayName : "Unknown Product",
customer_type: customerType,
server_only: product.serverOnly === true,
stackable: product.stackable === true,
prices: {},
included_items: {},
client_metadata: isRecord(product.clientMetadata) ? product.clientMetadata : null,
client_read_only_metadata: isRecord(product.clientReadOnlyMetadata) ? product.clientReadOnlyMetadata : null,
server_metadata: isRecord(product.serverMetadata) ? product.serverMetadata : null,
};
}
throw new StackAssertionError("Invalid includedItems in product snapshot", { product });
}
const includedItems: InlineProduct["included_items"] = {};
Expand Down Expand Up @@ -264,29 +250,27 @@ function mapProductSnapshotToInlineProduct(product: unknown): InlineProduct {
}

const prices: InlineProduct["prices"] = {};
if (product.prices !== "include-by-default") {
if (!isRecord(product.prices)) {
throw new StackAssertionError("Invalid prices in product snapshot", { product });
if (!isRecord(product.prices)) {
throw new StackAssertionError("Invalid prices in product snapshot", { product });
}
for (const [priceId, value] of Object.entries(product.prices)) {
if (!isRecord(value)) {
throw new StackAssertionError("Invalid price config in product snapshot", { priceId, value });
}
for (const [priceId, value] of Object.entries(product.prices)) {
if (!isRecord(value)) {
throw new StackAssertionError("Invalid price config in product snapshot", { priceId, value });
}
const mappedPrice: InlineProduct["prices"][string] = {};
for (const currency of SUPPORTED_CURRENCIES) {
const amount = value[currency.code];
if (typeof amount === "string") {
mappedPrice[currency.code] = amount;
}
const mappedPrice: InlineProduct["prices"][string] = {};
for (const currency of SUPPORTED_CURRENCIES) {
const amount = value[currency.code];
if (typeof amount === "string") {
mappedPrice[currency.code] = amount;
}
if (value.interval !== undefined && value.interval !== null) {
mappedPrice.interval = readDayInterval(value.interval, `price interval for ${priceId}`);
}
if (value.freeTrial !== undefined && value.freeTrial !== null) {
mappedPrice.free_trial = readDayInterval(value.freeTrial, `price freeTrial for ${priceId}`);
}
prices[priceId] = mappedPrice;
}
if (value.interval !== undefined && value.interval !== null) {
mappedPrice.interval = readDayInterval(value.interval, `price interval for ${priceId}`);
}
if (value.freeTrial !== undefined && value.freeTrial !== null) {
mappedPrice.free_trial = readDayInterval(value.freeTrial, `price freeTrial for ${priceId}`);
}
prices[priceId] = mappedPrice;
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type ProductPriceEntry = SelectedPrice & ProductPriceEntryExtras;

export type ProductWithPrices = {
displayName?: string,
prices?: Record<string, ProductPriceEntry> | "include-by-default",
prices?: Record<string, ProductPriceEntry>,
} | null | undefined;

type ProductSnapshot = (TransactionEntry & { type: "product_grant" })["product"];
Expand All @@ -32,7 +32,7 @@ export function resolveSelectedPriceFromProduct(product: ProductWithPrices, pric
if (!product) return null;
if (!priceId) return null;
const prices = product.prices;
if (!prices || prices === "include-by-default") return null;
if (!prices) return null;
const selected = prices[priceId as keyof typeof prices] as ProductPriceEntry | undefined;
if (!selected) return null;
const { serverOnly: _serverOnly, freeTrial: _freeTrial, ...rest } = selected as any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export const GET = createSmartRouteHandler({
if (product.customerType !== params.customer_type) continue;
if (auth.type === "client" && product.serverOnly) continue;
if (!product.productLineId) continue;
if (product.prices === "include-by-default") continue;
const hasIntervalPrice = typedEntries(product.prices).some(([, price]) => price.interval);
if (!hasIntervalPrice) continue;
if (product.isAddOnTo && typedKeys(product.isAddOnTo).length > 0) continue;
Expand Down
Loading
Loading