From 9dad9294479edc5088261473e65880dbe5c90fd8 Mon Sep 17 00:00:00 2001 From: Aman Ganapathy <84686202+nams1570@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:18:42 -0700 Subject: [PATCH] fix: stale include-by-default price doesnt crash page (#1621) ### Summary of Changes Some stale data in bulldozer causes a price validation error which causes a 500. We let it fail softly --- ## Summary by cubic Fixes 500s in payments views by handling legacy product snapshots with `prices: "include-by-default"` and other invalid price shapes. Stale data now degrades gracefully, and we capture diagnostics instead of crashing. - **Bug Fixes** - Normalize snapshot prices in `productToInlineProduct`: treat `"include-by-default"` as `{}` and fall back to `{}` for any non-object; capture errors for diagnostics. - `productToInlineProduct` now accepts context (`productId`, `customerType`, `customerId`); updated products and validate-code routes to pass it. - Added tests to verify price normalization and prevent response validation failures. Written for commit 9f34ad44a08c8cac9ce7ea245c9c6fd957e6194a. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit * **Bug Fixes** * Improved validation and error handling for product pricing data in payment operations. * Enhanced handling of malformed product snapshot data to ensure stability. * **Improvements** * Strengthened product context consistency across payment endpoints and purchase code validation flows. --- .../[customer_type]/[customer_id]/route.ts | 12 +++- .../payments/purchases/validate-code/route.ts | 6 +- apps/backend/src/lib/payments.tsx | 66 ++++++++++++++++++- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts index 827701348..242b9523a 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts @@ -83,7 +83,11 @@ export const GET = createSmartRouteHandler({ if (!hasIntervalPrice) continue; if (isAddOnProduct(product)) continue; - const inlineProduct = productToInlineProduct(product); + const inlineProduct = productToInlineProduct(product, { + productId, + customerType: params.customer_type, + customerId: params.customer_id, + }); const intervalPrices = typedFromEntries( typedEntries(inlineProduct.prices).filter(([, price]) => price.interval), ); @@ -114,7 +118,11 @@ export const GET = createSmartRouteHandler({ id: productId === "__null__" ? null : productId, quantity: p.quantity, // ProductSnapshot uses null where the Yup productSchema uses undefined; the data is equivalent - product: productToInlineProduct(p.product as Parameters[0]), + product: productToInlineProduct(p.product as Parameters[0], { + productId, + customerType: params.customer_type, + customerId: params.customer_id, + }), type, subscription: sub ? { subscription_id: sub.id, diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts index 3209750fd..7c25540f5 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -97,7 +97,11 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - product: productToInlineProduct(product), + product: productToInlineProduct(product, { + productId: verificationCode.data.productId ?? null, + customerType: product.customerType, + customerId: verificationCode.data.customerId, + }), stripe_account_id: verificationCode.data.stripeAccountId ?? null, project_id: tenancy.project.id, project_logo_url: tenancy.project.logo_url ?? null, diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 3050760b9..7a27df3d0 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -9,7 +9,7 @@ import type { UsersCrud } from "@hexclave/shared/dist/interface/crud/users"; import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@hexclave/shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@hexclave/shared/dist/utils/currency-constants"; import { addInterval } from "@hexclave/shared/dist/utils/dates"; -import { HexclaveAssertionError, StatusError, throwErr } from "@hexclave/shared/dist/utils/errors"; +import { captureError, HexclaveAssertionError, StatusError, throwErr } from "@hexclave/shared/dist/utils/errors"; import { filterUndefined, getOrUndefined, has, typedEntries, typedFromEntries, typedKeys, typedValues } from "@hexclave/shared/dist/utils/objects"; import { typedToUppercase } from "@hexclave/shared/dist/utils/strings"; import { isUuid } from "@hexclave/shared/dist/utils/uuids"; @@ -296,7 +296,40 @@ export async function getDefaultCardPaymentMethodSummary(options: { }; } -export function productToInlineProduct(product: ProductWithMetadata): yup.InferType { +/** Identifies which customer/product a snapshot belongs to, for diagnostics. */ +export type ProductSnapshotContext = { + productId: string | null, + customerType: string, + customerId: string, +}; + +function isPriceRecord(value: unknown): value is ProductWithMetadata["prices"] { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Legacy free-plan snapshots stored `prices` as the string "include-by-default" +// instead of a record. Live config was migrated to `{}`, but frozen snapshots +// (subscriptions + Bulldozer materialized tables) weren't, so `typedEntries` on +// that string explodes into currency-less price entries that fail response +// validation and 500 the whole payments view. Normalize the sentinel to `{}` +// (free plans need no prices); for any other non-object, capture it and degrade +// to `{}` so one bad row can't block a customer. +// TODO: remove this once no snapshot/Bulldozer data contains "include-by-default". +function normalizeProductSnapshotPrices(product: ProductWithMetadata, context: ProductSnapshotContext): ProductWithMetadata["prices"] { + const prices: unknown = product.prices; + if (isPriceRecord(prices)) { + return prices; + } + if (prices !== "include-by-default") { + captureError("product-to-inline-product-invalid-prices", new HexclaveAssertionError( + "Product snapshot has an unexpected non-object `prices`; degrading to empty prices to avoid blocking the customer's payments view. The underlying snapshot should be investigated and repaired.", + { productId: context.productId, customerType: context.customerType, customerId: context.customerId, product }, + )); + } + return {}; +} + +export function productToInlineProduct(product: ProductWithMetadata, context: ProductSnapshotContext): yup.InferType { return { display_name: product.displayName ?? "Product", customer_type: product.customerType, @@ -306,7 +339,7 @@ export function productToInlineProduct(product: ProductWithMetadata): yup.InferT client_metadata: product.clientMetadata ?? null, client_read_only_metadata: product.clientReadOnlyMetadata ?? null, server_metadata: product.serverMetadata ?? null, - prices: typedFromEntries(typedEntries(product.prices).map(([key, value]) => [key, filterUndefined({ + prices: typedFromEntries(typedEntries(normalizeProductSnapshotPrices(product, context)).map(([key, value]) => [key, filterUndefined({ ...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])), interval: value.interval, free_trial: value.freeTrial, @@ -314,6 +347,33 @@ export function productToInlineProduct(product: ProductWithMetadata): yup.InferT }; } +import.meta.vitest?.describe("productToInlineProduct prices normalization", (test) => { + const context = { productId: "free", customerType: "team", customerId: "team_1" }; + // Snapshots are untrusted JSON, so tests deliberately feed `prices` values that + // violate the type. Centralize that single boundary cast here. + const snapshotWithPrices = (prices: unknown): ProductWithMetadata => ({ + displayName: "Free Plan", + customerType: "team", + serverOnly: false, + stackable: false, + includedItems: {}, + prices, + }) as ProductWithMetadata; + + test("normal case: a valid prices record passes through unchanged", ({ expect }) => { + const inline = productToInlineProduct( + snapshotWithPrices({ monthly: { USD: "0", interval: [1, "month"] } }), + context, + ); + expect(inline.prices).toEqual({ monthly: { USD: "0", interval: [1, "month"] } }); + }); + + test('legacy "include-by-default" sentinel is normalized to {} (no explosion, schema-valid)', ({ expect }) => { + const inline = productToInlineProduct(snapshotWithPrices("include-by-default"), context); + expect(inline.prices).toEqual({}); + }); +}); + export async function validatePurchaseSession(options: { prisma: PrismaClientTransaction, tenancyId: string,