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
This commit is contained in:
parent
c50f1e64ed
commit
9f34ad44a0
@ -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