mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
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
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:
parent
4beba4942b
commit
9dad929447
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user