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.
## 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,