fix: stale include-by-default price doesnt crash page (#1621)
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled

### Summary of Changes
Some stale data in bulldozer causes a price validation error which
causes a 500. We let it fail softly


<!-- This is an auto-generated description by cubic. -->
---
## 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.

<sup>Written for commit 9f34ad44a0.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1621?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Aman Ganapathy 2026-06-17 13:18:42 -07:00 committed by GitHub
parent 4beba4942b
commit 9dad929447
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 78 additions and 6 deletions

View File

@ -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<typeof productToInlineProduct>[0]),
product: productToInlineProduct(p.product as Parameters<typeof productToInlineProduct>[0], {
productId,
customerType: params.customer_type,
customerId: params.customer_id,
}),
type,
subscription: sub ? {
subscription_id: sub.id,

View File

@ -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,

View File

@ -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<typeof inlineProductSchema> {
/** 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<typeof inlineProductSchema> {
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,