mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
+5
-4
@@ -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);
|
||||
|
||||
+3
-2
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
-8
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user