Devin/1782934151-remove-product-level-free-trial (#1722)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/hexclave/hexclave/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Removed product-level free trials; free trials are now configured per
price. Adds backward-compat for legacy inline inputs and migrates
existing config so current trials keep working.

- **Refactors**
- Enforce price-level trials in `productSchema` and
`inlineProductSchema` (remove product `freeTrial`/`free_trial`).
- Accept legacy inline product `free_trial` via
`inlineProductSchemaWithLegacyProductLevelFreeTrial`, then migrate with
`normalizeInlineProductLegacyProductLevelFreeTrial` before use.
- Updated API routes to use the new schema/normalizer; added E2E tests
for migration on grant and purchase validation.
  - Removed product-level trial UI columns and prompts in the dashboard.
- Removed product-level trial fields from OpenAPI specs and updated docs
to price-level only.
- Config migration (`packages/shared/src/config/schema.ts`) moves
`payments.products.*.freeTrial` onto each defined price and strips the
product-level field.

- **Migration**
- Move any product-level `freeTrial`/`free_trial` to each price:
`payments.products.<productId>.prices.<priceId>.freeTrial`.
- Existing overrides are auto-migrated; price-specific values take
precedence.
- Inline API callers can still send legacy `free_trial`; it is migrated
server-side, but should be removed from clients going forward.

<sup>Written for commit 1d29d402fe.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1722?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 commit is contained in:
Vedanta-Gawande
2026-07-03 08:46:12 +05:30
committed by GitHub
parent 9348dc99f9
commit b35bf0e90f
14 changed files with 517 additions and 89 deletions
@@ -1,10 +1,10 @@
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, productToInlineProduct } from "@/lib/payments";
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, inlineProductSchemaWithLegacyProductLevelFreeTrial, isActiveSubscription, isAddOnProduct, normalizeInlineProductLegacyProductLevelFreeTrial, productToInlineProduct } from "@/lib/payments";
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
import { customerProductsListResponseSchema } from "@hexclave/shared/dist/interface/crud/products";
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, serverOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, serverOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { typedEntries, typedFromEntries } from "@hexclave/shared/dist/utils/objects";
import { stringCompare } from "@hexclave/shared/dist/utils/strings";
@@ -179,7 +179,7 @@ export const POST = createSmartRouteHandler({
}).defined(),
body: yupObject({
product_id: yupString().optional(),
product_inline: inlineProductSchema.optional(),
product_inline: inlineProductSchemaWithLegacyProductLevelFreeTrial.optional(),
quantity: yupNumber().integer().min(1).default(1),
}).defined(),
}),
@@ -200,11 +200,12 @@ export const POST = createSmartRouteHandler({
customerType: params.customer_type,
customerId: params.customer_id,
});
const productInline = normalizeInlineProductLegacyProductLevelFreeTrial(body.product_inline);
const product = await ensureProductIdOrInlineProduct(
tenancy,
auth.type,
body.product_id,
body.product_inline,
productInline,
);
if (params.customer_type !== product.customerType) {
@@ -1,12 +1,12 @@
import { CustomerType } from "@/generated/prisma/client";
import { customerOwnsProduct, ensureClientCanAccessCustomer, ensureProductIdOrInlineProduct } from "@/lib/payments";
import { customerOwnsProduct, ensureClientCanAccessCustomer, ensureProductIdOrInlineProduct, inlineProductSchemaWithLegacyProductLevelFreeTrial, normalizeInlineProductLegacyProductLevelFreeTrial } from "@/lib/payments";
import { getOwnedProductsForCustomer } from "@/lib/payments/customer-data";
import { validateRedirectUrl } from "@/lib/redirect-urls";
import { getHexclaveStripe, getStripeForAccount } from "@/lib/stripe";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, urlSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, urlSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { throwErr } from "@hexclave/shared/dist/utils/errors";
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
@@ -43,7 +43,7 @@ export const POST = createSmartRouteHandler({
exampleValue: "prod_premium_monthly"
}
}),
product_inline: inlineProductSchema.optional().meta({
product_inline: inlineProductSchemaWithLegacyProductLevelFreeTrial.optional().meta({
openapiField: {
description: "Inline product definition. Either this or product_id should be given."
}
@@ -83,7 +83,8 @@ export const POST = createSmartRouteHandler({
});
}
const productConfig = await ensureProductIdOrInlineProduct(tenancy, req.auth.type, req.body.product_id, req.body.product_inline);
const productInline = normalizeInlineProductLegacyProductLevelFreeTrial(req.body.product_inline);
const productConfig = await ensureProductIdOrInlineProduct(tenancy, req.auth.type, req.body.product_id, productInline);
const customerType = productConfig.customerType;
if (req.body.customer_type !== customerType) {
throw new KnownErrors.ProductCustomerTypeDoesNotMatch(req.body.product_id, req.body.customer_id, customerType, req.body.customer_type);
@@ -1,6 +1,7 @@
import { POST as latestHandler } from "@/app/api/latest/payments/purchases/create-purchase-url/route";
import { inlineProductSchemaWithLegacyProductLevelFreeTrial } from "@/lib/payments";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { ensureObjectSchema, inlineProductSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { ensureObjectSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { normalizePurchaseBody } from "../offers-compat";
const latestInit = latestHandler.initArgs[0];
@@ -13,7 +14,7 @@ export const POST = createSmartRouteHandler({
request: requestSchema.concat(yupObject({
body: requestBodySchema.concat(yupObject({
offer_id: yupString().optional(),
offer_inline: inlineProductSchema.optional(),
offer_inline: inlineProductSchemaWithLegacyProductLevelFreeTrial.optional(),
})),
})),
handler: async (_req, fullReq) => {
+63 -2
View File
@@ -6,7 +6,7 @@ import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { getPrismaClientForTenancy, PrismaClientTransaction } from "@/prisma-client";
import { KnownErrors } from "@hexclave/shared";
import type { UsersCrud } from "@hexclave/shared/dist/interface/crud/users";
import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@hexclave/shared/dist/schema-fields";
import { dayIntervalSchema, inlineProductSchema, productSchema, productSchemaWithMetadata, yupObject } 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 { captureError, HexclaveAssertionError, StatusError, throwErr } from "@hexclave/shared/dist/utils/errors";
@@ -21,6 +21,12 @@ import { Tenancy } from "./tenancies";
type Product = yup.InferType<typeof productSchema>;
type ProductWithMetadata = yup.InferType<typeof productSchemaWithMetadata>;
type InlineProduct = yup.InferType<typeof inlineProductSchema>;
export const inlineProductSchemaWithLegacyProductLevelFreeTrial = inlineProductSchema.concat(yupObject({
free_trial: dayIntervalSchema.optional().meta({ openapiField: { hidden: true } }),
}));
type InlineProductWithLegacyProductLevelFreeTrial = yup.InferType<typeof inlineProductSchemaWithLegacyProductLevelFreeTrial>;
type SelectedPrice = Product["prices"][string];
export async function ensureClientCanAccessCustomer(options: {
@@ -109,6 +115,62 @@ export async function ensureProductIdOrInlineProduct(
}
}
export function normalizeInlineProductLegacyProductLevelFreeTrial(
inlineProduct: InlineProductWithLegacyProductLevelFreeTrial | undefined,
): InlineProduct | undefined {
if (inlineProduct === undefined) return undefined;
const { free_trial: legacyFreeTrial, ...inlineProductWithoutLegacyFreeTrial } = inlineProduct;
if (legacyFreeTrial === undefined) return inlineProductWithoutLegacyFreeTrial;
return {
...inlineProductWithoutLegacyFreeTrial,
prices: typedFromEntries(typedEntries(inlineProductWithoutLegacyFreeTrial.prices).map(([priceId, price]) => [
priceId,
price.free_trial === undefined ? { ...price, free_trial: legacyFreeTrial } : price,
])),
};
}
import.meta.vitest?.test("normalizeInlineProductLegacyProductLevelFreeTrial migrates legacy product-level trials to prices", ({ expect }) => {
const inlineProduct = {
display_name: "Pro",
customer_type: "user",
free_trial: [7, "day"],
server_only: true,
stackable: false,
prices: {
monthly: { USD: "10" },
annual: { USD: "100", free_trial: [14, "day"] },
},
included_items: {},
} satisfies InlineProductWithLegacyProductLevelFreeTrial;
expect(normalizeInlineProductLegacyProductLevelFreeTrial(inlineProduct)).toEqual({
display_name: "Pro",
customer_type: "user",
server_only: true,
stackable: false,
prices: {
monthly: { USD: "10", free_trial: [7, "day"] },
annual: { USD: "100", free_trial: [14, "day"] },
},
included_items: {},
});
expect(inlineProduct).toEqual({
display_name: "Pro",
customer_type: "user",
free_trial: [7, "day"],
server_only: true,
stackable: false,
prices: {
monthly: { USD: "10" },
annual: { USD: "100", free_trial: [14, "day"] },
},
included_items: {},
});
});
// ── Legacy functions deleted ──
// computeLedgerBalanceAtNow, addWhenRepeatedItemWindowTransactions,
// getItemQuantityForCustomerLegacy, Subscription type, getSubscriptions,
@@ -616,4 +678,3 @@ export async function grantProductToCustomer(options: {
return { type: "subscription", subscriptionId: subscription.id };
}
@@ -578,10 +578,6 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete
prompt += `- **Product ID**: \`${id}\`\n`;
prompt += `- **Display Name**: ${product.displayName || 'Untitled Product'}\n`;
prompt += `- **Customer Type**: ${product.customerType}\n`;
if (product.freeTrial) {
const [count, unit] = product.freeTrial;
prompt += `- **Free Trial**: ${count} ${count === 1 ? unit : unit + 's'}\n`;
}
prompt += `- **Server Only**: ${product.serverOnly ? 'Yes' : 'No'}\n`;
prompt += `- **Stackable**: ${product.stackable ? 'Yes' : 'No'}\n`;
if (product.isAddOnTo && typeof product.isAddOnTo === 'object') {
@@ -671,9 +667,6 @@ function ProductCard({ id, product, allProducts, existingItems, onSave, onDelete
if (product.isAddOnTo && typeof product.isAddOnTo === 'object') {
prompt += `- This is an add-on product. Customers must already have one of the base products to purchase this.\n`;
}
if (product.freeTrial) {
prompt += `- This product includes a free trial period. Customers will not be charged until the trial ends.\n`;
}
if (itemsList.length > 0) {
prompt += `- When a customer purchases this product, they will automatically receive the included items listed above.\n`;
}
@@ -1248,7 +1241,6 @@ function ProductLineView({ groupedProducts, groups, existingItems, onSaveProduct
prices: {},
includedItems: {},
serverOnly: false,
freeTrial: undefined,
};
setDrafts((prev) => [...prev, { key: candidate, productLineId: undefined, product: newProduct }]);
@@ -52,14 +52,6 @@ const columns: DataGridColumnDef<PaymentProduct>[] = [
<span className="capitalize">{String(value)}</span>
),
},
{
id: "freeTrial",
header: "Free Trial",
accessor: (row) => row.freeTrial?.join(" ") ?? "",
width: 140,
type: "string",
sortable: false,
},
{
id: "stackable",
header: "Stackable",
@@ -900,6 +900,72 @@ it("should grant inline product without needing configuration", async ({ expect
`);
});
it("should migrate legacy inline product-level free_trial when directly granting a product", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
const { userId } = await Auth.fastSignUp();
const grantResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
method: "POST",
accessType: "server",
body: {
product_inline: {
display_name: "Legacy Inline Trial Grant",
customer_type: "user",
free_trial: [7, "day"],
server_only: true,
prices: {
monthly: {
USD: "1000",
interval: [1, "month"],
},
annual: {
USD: "10000",
interval: [1, "year"],
free_trial: [14, "day"],
},
},
included_items: {},
},
},
});
expect(grantResponse.status).toBe(200);
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "server",
});
expect(listResponse.status).toBe(200);
expect(listResponse.body.items).toHaveLength(1);
const product = listResponse.body.items[0].product;
expect(product).not.toHaveProperty("free_trial");
expect(product.prices).toMatchInlineSnapshot(`
{
"annual": {
"USD": "10000",
"free_trial": [
14,
"day",
],
"interval": [
1,
"year",
],
},
"monthly": {
"USD": "1000",
"free_trial": [
7,
"day",
],
"interval": [
1,
"month",
],
},
}
`);
});
it("should allow canceling an inline product subscription via subscription_id", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
@@ -107,6 +107,78 @@ it("should allow valid code and return product data", async ({ expect }) => {
`);
});
it("should migrate legacy inline product-level free_trial to price-level free_trial", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await Project.updateConfig({ payments: { testMode: false } });
const { userId } = await Auth.fastSignUp();
const createResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "server",
body: {
customer_type: "user",
customer_id: userId,
product_inline: {
display_name: "Legacy Inline Trial",
customer_type: "user",
free_trial: [7, "day"],
server_only: true,
prices: {
monthly: {
USD: "1000",
interval: [1, "month"],
},
annual: {
USD: "10000",
interval: [1, "year"],
free_trial: [14, "day"],
},
},
included_items: {},
},
},
});
expect(createResponse.status).toBe(200);
const url = (createResponse.body as { url: string }).url;
const code = url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
expect(code).toBeDefined();
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
method: "POST",
accessType: "client",
body: { full_code: code },
});
expect(validateResponse.status).toBe(200);
expect(validateResponse.body.product).not.toHaveProperty("free_trial");
expect(validateResponse.body.product.prices).toMatchInlineSnapshot(`
{
"annual": {
"USD": "10000",
"free_trial": [
14,
"day",
],
"interval": [
1,
"year",
],
},
"monthly": {
"USD": "1000",
"free_trial": [
7,
"day",
],
"interval": [
1,
"month",
],
},
}
`);
});
it("should set already_bought_non_stackable when user already owns non-stackable product", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
@@ -62,7 +62,7 @@ A few additional options:
- **Product lines** - Assign products to a product line to make them mutually exclusive (e.g. plan tiers). Configure lines in **Payments -> Product Lines**.
- **Add-ons** - Set **isAddOnTo** to require the customer to already own a specific base product before purchasing this one.
- **Free trial** - Give customers a trial period before charging. Can be set on the product or on individual prices.
- **Free trial** - Give customers a trial period before charging. Configure free trials on individual prices.
- **Server-only** - Hide the product from client SDK responses. Useful for products that should only be granted programmatically.
- **Stackable** - Allow multiple purchases of the same product (default is one per customer).
-12
View File
@@ -4917,12 +4917,6 @@
"custom"
]
},
"free_trial": {
"type": "array",
"items": {
"type": "integer"
}
},
"server_only": {
"type": "boolean",
"default": true
@@ -5146,12 +5140,6 @@
"custom"
]
},
"free_trial": {
"type": "array",
"items": {
"type": "integer"
}
},
"server_only": {
"type": "boolean",
"default": true
-12
View File
@@ -3837,12 +3837,6 @@
"custom"
]
},
"free_trial": {
"type": "array",
"items": {
"type": "integer"
}
},
"server_only": {
"type": "boolean",
"default": true
@@ -4066,12 +4060,6 @@
"custom"
]
},
"free_trial": {
"type": "array",
"items": {
"type": "integer"
}
},
"server_only": {
"type": "boolean",
"default": true
-12
View File
@@ -4877,12 +4877,6 @@
"custom"
]
},
"free_trial": {
"type": "array",
"items": {
"type": "integer"
}
},
"server_only": {
"type": "boolean",
"default": true
@@ -5106,12 +5100,6 @@
"custom"
]
},
"free_trial": {
"type": "array",
"items": {
"type": "integer"
}
},
"server_only": {
"type": "boolean",
"default": true
+252 -22
View File
@@ -568,13 +568,9 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment"
// END
// BEGIN 2026-07-01: product-level freeTrial removed; free trials are now configured per-price only.
// Strip any saved product-level freeTrial overrides so they don't cause validation errors.
// Move saved product-level freeTrial overrides onto prices before stripping them so existing trials keep working.
if (isBranchOrHigher) {
// p.slice(3, -2) checks for "prices" only in positions where it structurally indicates a price sub-object
// (i.e., followed by at least a price-ID segment before the trailing "freeTrial"). This correctly handles
// dotted product IDs containing "prices" as a segment (e.g., "pro.prices") — those land at p.length-2
// which is excluded by the slice, so the product-level freeTrial is still removed.
res = removeProperty(res, p => p.length >= 4 && p[0] === "payments" && p[1] === "products" && p[p.length - 1] === "freeTrial" && !p.slice(3, -2).includes("prices"));
res = migrateProductLevelFreeTrialsToPrices(res);
}
// END
@@ -604,37 +600,149 @@ import.meta.vitest?.test("migrateConfigOverride removes legacy sourceOfTruth ove
})).toEqual({});
});
import.meta.vitest?.test("migrateConfigOverride strips product-level freeTrial but keeps price-level freeTrial", ({ expect }) => {
// Product-level freeTrial (nested) should be removed
import.meta.vitest?.test("migrateConfigOverride moves product-level freeTrial to prices", ({ expect }) => {
// Nested config: product-level freeTrial is copied to every price, then removed from the product.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"], prices: { monthly: { USD: "10" }, annual: { USD: "100" } } } } },
})).toEqual({
payments: { products: { pro: { prices: { monthly: { USD: "10", freeTrial: [7, "day"] }, annual: { USD: "100", freeTrial: [7, "day"] } } } } },
});
// Dot-notation config: preserve the dot-notation style while adding price-level freeTrial overrides.
expect(migrateConfigOverride("branch", {
"payments.products.pro.freeTrial": [14, "day"],
"payments.products.pro.prices.monthly.USD": "10",
"payments.products.pro.prices.annual": { USD: "100" },
})).toEqual({
"payments.products.pro.prices.monthly.USD": "10",
"payments.products.pro.prices.annual": { USD: "100" },
"payments.products.pro.prices.monthly.freeTrial": [14, "day"],
"payments.products.pro.prices.annual.freeTrial": [14, "day"],
});
// Price-level freeTrial is already the new source of truth and should be preserved.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { prices: { monthly: { freeTrial: [7, "day"] } } } } },
})).toEqual({
payments: { products: { pro: { prices: { monthly: { freeTrial: [7, "day"] } } } } },
});
// Existing price-specific trials win over the old product-level default.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"], prices: { monthly: { freeTrial: [14, "day"] }, annual: { USD: "100" } } } } },
})).toEqual({
payments: { products: { pro: { prices: { monthly: { freeTrial: [14, "day"] }, annual: { USD: "100", freeTrial: [7, "day"] } } } } },
});
// Product-level freeTrial with no prices is simply removed because there is nowhere safe to preserve it.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"] } } },
})).toEqual({
payments: { products: { pro: {} } },
});
// Product-level freeTrial (dot-notation) should be removed
// Mixed config: a flat product-level freeTrial can still be applied to nested prices in the same override.
expect(migrateConfigOverride("branch", {
"payments.products.pro.freeTrial": [14, "day"],
})).toEqual({});
// Price-level freeTrial should be preserved
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { prices: { monthly: { freeTrial: [7, "day"] } } } } },
"payments.products.pro.freeTrial": [7, "day"],
payments: { products: { pro: { prices: { monthly: { USD: "10" } } } } },
})).toEqual({
payments: { products: { pro: { prices: { monthly: { freeTrial: [7, "day"] } } } } },
payments: { products: { pro: { prices: { monthly: { USD: "10" } } } } },
"payments.products.pro.prices.monthly.freeTrial": [7, "day"],
});
// Mixed: product-level removed, price-level kept
// Dotted product IDs and product IDs containing "prices" should be treated as product IDs, not structural paths.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"], prices: { monthly: { freeTrial: [14, "day"] } } } } },
payments: { products: {
"pro.plan": { freeTrial: [7, "day"], prices: { monthly: { USD: "10" } } },
"pro.prices": { freeTrial: [14, "day"], prices: { monthly: { USD: "20" } } },
} },
})).toEqual({
payments: { products: { pro: { prices: { monthly: { freeTrial: [14, "day"] } } } } },
payments: { products: {
"pro.plan": { prices: { monthly: { USD: "10", freeTrial: [7, "day"] } } },
"pro.prices": { prices: { monthly: { USD: "20", freeTrial: [14, "day"] } } },
} },
});
// Dotted product ID containing "prices" as a segment — product-level freeTrial should still be removed
// Flat dotted product IDs containing "prices" use the second "prices" segment as the structural price map.
expect(migrateConfigOverride("branch", {
"payments.products.pro.prices.freeTrial": [7, "day"],
})).toEqual({});
"payments.products.pro.prices.prices.monthly.USD": "10",
})).toEqual({
"payments.products.pro.prices.prices.monthly.USD": "10",
"payments.products.pro.prices.prices.monthly.freeTrial": [7, "day"],
});
// Dotted price IDs should still get the migrated freeTrial and keep their ID intact.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"], prices: { "monthly.v2": { USD: "10" } } } } },
})).toEqual({
payments: { products: { pro: { prices: { "monthly.v2": { USD: "10", freeTrial: [7, "day"] } } } } },
});
// Flat dotted price IDs should be preserved when inferring where to place the migrated freeTrial.
expect(migrateConfigOverride("branch", {
"payments.products.pro.freeTrial": [7, "day"],
"payments.products.pro.prices.monthly.v2.USD": "10",
})).toEqual({
"payments.products.pro.prices.monthly.v2.USD": "10",
"payments.products.pro.prices.monthly.v2.freeTrial": [7, "day"],
});
// Mixed config: a nested product-level freeTrial can still be applied to dot-notation prices.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"] } } },
"payments.products.pro.prices.monthly.USD": "10",
})).toEqual({
payments: { products: { pro: {} } },
"payments.products.pro.prices.monthly.USD": "10",
"payments.products.pro.prices.monthly.freeTrial": [7, "day"],
});
// Existing dot-notation price-specific trials still win when the old product-level trial is nested.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"] } } },
"payments.products.pro.prices.monthly.USD": "10",
"payments.products.pro.prices.monthly.freeTrial": [14, "day"],
})).toEqual({
payments: { products: { pro: {} } },
"payments.products.pro.prices.monthly.USD": "10",
"payments.products.pro.prices.monthly.freeTrial": [14, "day"],
});
// Price-specific trials also win when the price object is nested but the override is dot-notation.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"], prices: { monthly: { USD: "10" } } } } },
"payments.products.pro.prices.monthly.freeTrial": [14, "day"],
})).toEqual({
payments: { products: { pro: { prices: { monthly: { USD: "10" } } } } },
"payments.products.pro.prices.monthly.freeTrial": [14, "day"],
});
// Mixed config with dotted price IDs should copy the nested product trial to the inferred flat price path.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"] } } },
"payments.products.pro.prices.monthly.v2.USD": "10",
})).toEqual({
payments: { products: { pro: {} } },
"payments.products.pro.prices.monthly.v2.USD": "10",
"payments.products.pro.prices.monthly.v2.freeTrial": [7, "day"],
});
// Explicit undefined means "no price-specific trial", so the product-level trial should still copy over it.
expect(migrateConfigOverride("branch", {
payments: { products: { pro: { freeTrial: [7, "day"], prices: { monthly: { USD: "10", freeTrial: undefined } } } } },
})).toEqual({
payments: { products: { pro: { prices: { monthly: { USD: "10", freeTrial: [7, "day"] } } } } },
});
expect(migrateConfigOverride("branch", {
"payments.products.pro.freeTrial": [7, "day"],
"payments.products.pro.prices.monthly.USD": "10",
"payments.products.pro.prices.monthly.freeTrial": undefined,
})).toEqual({
"payments.products.pro.prices.monthly.USD": "10",
"payments.products.pro.prices.monthly.freeTrial": [7, "day"],
});
});
import.meta.vitest?.test("migrateConfigOverride removes legacy branch-level dbSync overrides", ({ expect }) => {
@@ -650,10 +758,132 @@ import.meta.vitest?.test("migrateConfigOverride removes legacy branch-level dbSy
expect(migrateConfigOverride("branch", { dbSync })).toEqual({});
expect(migrateConfigOverride("environment", { dbSync })).toEqual({ dbSync });
});
function removeProperty(obj: Record<string, any>, pathCond: (path: (string | symbol)[]) => boolean): any {
function removeProperty(obj: Record<string, any>, pathCond: (path: string[]) => boolean): any {
return mapProperty(obj, pathCond, () => undefined);
}
function isProductLevelFreeTrialPath(path: string[]): boolean {
// p.slice(3, -2) checks for "prices" only in positions where it structurally indicates a price sub-object
// (i.e., followed by at least a price-ID segment before the trailing "freeTrial"). This correctly handles
// dotted product IDs containing "prices" as a segment (e.g., "pro.prices") — those land at p.length-2
// which is excluded by the slice, so the product-level freeTrial is still considered product-level.
return path.length >= 4 && path[0] === "payments" && path[1] === "products" && path[path.length - 1] === "freeTrial" && !path.slice(3, -2).includes("prices");
}
function isConfigObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function migrateProductLevelFreeTrialsToPrices(obj: Record<string, any>): any {
const productLevelFreeTrials = new Map<string, any>();
collectProductLevelFreeTrials(obj, [], productLevelFreeTrials);
const pricePathsWithDefinedFreeTrial = new Set<string>();
for (const productPath of productLevelFreeTrials.keys()) {
const priceInfo = collectPriceInfoForProduct(obj, productPath);
for (const pricePath of priceInfo.pricePathsWithFreeTrial) {
pricePathsWithDefinedFreeTrial.add(pricePath);
}
}
const res = migrateNestedProductLevelFreeTrialsToPrices(obj, [], pricePathsWithDefinedFreeTrial);
for (const [productPath, freeTrial] of productLevelFreeTrials) {
const priceInfo = collectPriceInfoForProduct(res, productPath);
for (const pricePath of priceInfo.pricePaths) {
if (!priceInfo.pricePathsWithFreeTrial.has(pricePath)) {
set(res, `${pricePath}.freeTrial`, freeTrial);
}
}
}
return removeProperty(res, isProductLevelFreeTrialPath);
}
function migrateNestedProductLevelFreeTrialsToPrices(obj: any, basePath: string[], pricePathsWithDefinedFreeTrial: Set<string>): any {
if (!isConfigObject(obj)) return obj;
const res: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const path = [...basePath, ...key.split(".")];
res[key] = migrateNestedProductLevelFreeTrialsToPrices(value, path, pricePathsWithDefinedFreeTrial);
}
if (has(res, "freeTrial") && isProductLevelFreeTrialPath([...basePath, "freeTrial"])) {
const freeTrial = getOrUndefined(res, "freeTrial");
const prices = res.prices;
if (freeTrial !== undefined && isConfigObject(prices)) {
for (const [priceId, price] of Object.entries(prices)) {
const pricePath = [...basePath, "prices", ...priceId.split(".")].join(".");
if (isConfigObject(price) && getOrUndefined(price, "freeTrial") === undefined && !pricePathsWithDefinedFreeTrial.has(pricePath)) {
prices[priceId] = { ...price, freeTrial };
}
}
}
Reflect.deleteProperty(res, "freeTrial");
}
return res;
}
function collectProductLevelFreeTrials(obj: any, basePath: string[], result: Map<string, any>) {
if (!isConfigObject(obj)) return;
for (const [key, value] of Object.entries(obj)) {
const path = [...basePath, ...key.split(".")];
if (isProductLevelFreeTrialPath(path) && value !== undefined) {
result.set(path.slice(0, -1).join("."), value);
}
collectProductLevelFreeTrials(value, path, result);
}
}
const priceFieldNames = new Set([
...SUPPORTED_CURRENCIES.map(currency => currency.code),
"interval",
"serverOnly",
"freeTrial",
]);
function collectPriceInfoForProduct(obj: any, productPath: string) {
const productPathParts = productPath.split(".");
const pricePaths = new Set<string>();
const pricePathsWithFreeTrial = new Set<string>();
const collect = (value: any, basePath: string[]) => {
if (!isConfigObject(value)) return;
for (const [key, child] of Object.entries(value)) {
const path = [...basePath, ...key.split(".")];
const remaining = path.slice(productPathParts.length);
const isUnderProductPrices = path.slice(0, productPathParts.length).join(".") === productPath && remaining[0] === "prices";
if (isUnderProductPrices && remaining.length >= 2) {
if (isConfigObject(child)) {
pricePaths.add(path.join("."));
if (getOrUndefined(child, "freeTrial") !== undefined) {
pricePathsWithFreeTrial.add(path.join("."));
}
} else {
const fieldName = remaining[remaining.length - 1];
if (priceFieldNames.has(fieldName)) {
const pricePath = path.slice(0, -1).join(".");
pricePaths.add(pricePath);
if (fieldName === "freeTrial" && child !== undefined) {
pricePathsWithFreeTrial.add(pricePath);
}
}
}
}
collect(child, path);
}
};
collect(obj, []);
return { pricePaths, pricePathsWithFreeTrial };
}
function mapProperty(obj: Record<string, any>, pathCond: (path: string[]) => boolean, mapper: (value: any) => any): any {
const res: Record<string, any> = Array.isArray(obj) ? [] : {};
for (const [key, value] of typedEntries(obj)) {
+50 -2
View File
@@ -687,7 +687,6 @@ export const productSchema = yupObject({
),
).optional().meta({ openapiField: { description: 'The products that this product is an add-on to. If this is set, the customer must already have one of the products in the record to be able to purchase this product.', exampleValue: { "product-id": true } } }),
customerType: customerTypeSchema.defined(),
freeTrial: dayIntervalSchema.optional(),
serverOnly: yupBoolean(),
stackable: yupBoolean(),
prices: pricesSchema.defined(),
@@ -701,6 +700,31 @@ export const productSchema = yupObject({
),
});
import.meta.vitest?.test("productSchema rejects product-level freeTrial and keeps price-level freeTrial", async ({ expect }) => {
const product = {
displayName: "Pro",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
monthly: {
USD: "10",
freeTrial: [14, "day"],
},
},
includedItems: {},
};
await expect(yupValidate(productSchema, product, { context: { noUnknownPathPrefixes: [""] } })).resolves.toMatchObject({
prices: {
monthly: {
freeTrial: [14, "day"],
},
},
});
await expect(yupValidate(productSchema, { ...product, freeTrial: [14, "day"] }, { context: { noUnknownPathPrefixes: [""] } })).rejects.toThrow("Object contains unknown properties: freeTrial");
});
const productMetadataExample = { featureFlag: true, source: 'marketing-campaign' } as const;
export const productClientMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientMetaDataDescription('product'), exampleValue: productMetadataExample } });
@@ -716,7 +740,6 @@ export const productSchemaWithMetadata = productSchema.concat(yupObject({
export const inlineProductSchema = yupObject({
display_name: yupString().defined(),
customer_type: customerTypeSchema.defined(),
free_trial: dayIntervalSchema.optional(),
server_only: yupBoolean().default(true),
stackable: yupBoolean().default(false),
prices: yupRecord(
@@ -740,6 +763,31 @@ export const inlineProductSchema = yupObject({
server_metadata: productServerMetadataSchema.optional(),
});
import.meta.vitest?.test("inlineProductSchema rejects product-level free_trial and keeps price-level free_trial", async ({ expect }) => {
const inlineProduct = {
display_name: "Pro",
customer_type: "user",
server_only: true,
stackable: false,
prices: {
monthly: {
USD: "10",
free_trial: [14, "day"],
},
},
included_items: {},
};
await expect(yupValidate(inlineProductSchema, inlineProduct, { context: { noUnknownPathPrefixes: [""] } })).resolves.toMatchObject({
prices: {
monthly: {
free_trial: [14, "day"],
},
},
});
await expect(yupValidate(inlineProductSchema, { ...inlineProduct, free_trial: [14, "day"] }, { context: { noUnknownPathPrefixes: [""] } })).rejects.toThrow("Object contains unknown properties: free_trial");
});
// Users
export class ReplaceFieldWithOwnUserId extends Error {
constructor(public readonly path: string) {