diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts index 242b9523a..9f8f84fab 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts @@ -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) { diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index 70d315a5f..18451c54a 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -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); diff --git a/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts index 3f43cc3ff..9988355d4 100644 --- a/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/migrations/v2beta5/payments/purchases/create-purchase-url/route.ts @@ -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) => { diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 813bc0492..7a61c701e 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -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; type ProductWithMetadata = yup.InferType; +type InlineProduct = yup.InferType; + +export const inlineProductSchemaWithLegacyProductLevelFreeTrial = inlineProductSchema.concat(yupObject({ + free_trial: dayIntervalSchema.optional().meta({ openapiField: { hidden: true } }), +})); +type InlineProductWithLegacyProductLevelFreeTrial = yup.InferType; type SelectedPrice = Product["prices"][string]; export async function ensureClientCanAccessCustomer(options: { @@ -89,7 +95,6 @@ export async function ensureProductIdOrInlineProduct( isAddOnTo: false, displayName: inlineProduct.display_name, customerType: inlineProduct.customer_type, - freeTrial: inlineProduct.free_trial, serverOnly: inlineProduct.server_only, stackable: false, prices: Object.fromEntries(Object.entries(inlineProduct.prices).map(([key, value]) => [key, { @@ -110,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, @@ -617,4 +678,3 @@ export async function grantProductToCustomer(options: { return { type: "subscription", subscriptionId: subscription.id }; } - diff --git a/apps/backend/src/lib/payments/schema/types.ts b/apps/backend/src/lib/payments/schema/types.ts index 21983ff83..2a5c93dbf 100644 --- a/apps/backend/src/lib/payments/schema/types.ts +++ b/apps/backend/src/lib/payments/schema/types.ts @@ -60,6 +60,7 @@ export type ProductSnapshot = { customerType: CustomerType, stackable?: boolean | null, serverOnly?: boolean | null, + /** @deprecated Product-level freeTrial is no longer written. Free trials are configured per-price only. Kept for backward compat with existing stored snapshots. */ freeTrial?: DayInterval | null, isAddOnTo?: false | Record | null, prices: Record>, diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index e890e0185..5565cf81c 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -743,7 +743,6 @@ export function buildDummyPaymentsSetup(): PaymentsSetup { customerType: 'team', serverOnly: false, stackable: false, - freeTrial: twoWeekInterval as any, prices: { monthly: { USD: '39', diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx index dd4da486d..5f18a58da 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx @@ -23,7 +23,7 @@ import { import { SubpageHeader } from "@/components/design-components/subpage-header"; import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; -import { ClockIcon, HardDriveIcon, PackageIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon } from "@phosphor-icons/react"; +import { HardDriveIcon, PackageIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@hexclave/shared/dist/config/schema"; import { typedEntries } from "@hexclave/shared/dist/utils/objects"; import { useState } from "react"; @@ -115,7 +115,6 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex const [serverOnly, setServerOnly] = useState(existingProduct.serverOnly); const [prices, setPrices] = useState>(existingPrices); const [includedItems, setIncludedItems] = useState(existingProduct.includedItems); - const [freeTrial, setFreeTrial] = useState(existingProduct.freeTrial); // Dialog states const [showProductLineDialog, setShowProductLineDialog] = useState(false); @@ -158,7 +157,6 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex prices, includedItems, serverOnly, - freeTrial, }; const handleCreateProductLine = async (productLine: { id: string, displayName: string }) => { @@ -222,7 +220,6 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex prices, includedItems, serverOnly, - freeTrial, }; const success = await updateConfig({ @@ -570,54 +567,6 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex )} - {/* Free Trial */} - Offer a free trial period? -
- - {freeTrial && ( -
- { - const val = parseInt(e.target.value) || 1; - setFreeTrial([val, freeTrial[1]]); - }} - className="h-7 w-16 text-sm rounded-md" - /> - -
- )} -
- {/* Product Line */} Part of a mutually exclusive group?
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 71efa7ddb..7b56b0d67 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -42,7 +42,7 @@ import { } from "@/components/ui"; import { createDefaultDataGridState, DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components"; import { useUpdateConfig } from "@/components/config-update"; -import { ArrowLeftIcon, ClockIcon, CopyIcon, CurrencyDollarIcon, DotsThreeIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, ShoppingCartIcon, StackIcon, TagIcon, TrashIcon, UsersIcon } from "@phosphor-icons/react"; +import { ArrowLeftIcon, CopyIcon, CurrencyDollarIcon, DotsThreeIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, ShoppingCartIcon, StackIcon, TagIcon, TrashIcon, UsersIcon } from "@phosphor-icons/react"; import { CreateCheckoutDialog } from "@/components/payments/create-checkout-dialog"; import type { CustomerType } from "@/components/payments/customer-selector"; import type { CompleteConfig } from "@hexclave/shared/dist/config/schema"; @@ -273,7 +273,6 @@ type PendingProductChanges = { catalogId?: string | null, stackable?: boolean | null, serverOnly?: boolean | null, - freeTrial?: DayInterval | null, isAddOnTo?: Record | null, prices?: Product['prices'], includedItems?: Product['includedItems'], @@ -287,7 +286,6 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec // Dialog states const [addOnDialogOpen, setAddOnDialogOpen] = useState(false); - const [freeTrialPopoverOpen, setFreeTrialPopoverOpen] = useState(false); const [createProductLineDialogOpen, setCreateProductLineDialogOpen] = useState(false); // ===== LOCAL STATE FOR DEFERRED SAVE ===== @@ -302,7 +300,6 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec const localDisplayName = pendingChanges.displayName !== undefined ? pendingChanges.displayName : (product.displayName || ''); const localStackable = pendingChanges.stackable !== undefined ? !!pendingChanges.stackable : !!product.stackable; const localServerOnly = pendingChanges.serverOnly !== undefined ? !!pendingChanges.serverOnly : !!product.serverOnly; - const localFreeTrial = pendingChanges.freeTrial !== undefined ? pendingChanges.freeTrial : (product.freeTrial || null); const localIsAddOnTo = pendingChanges.isAddOnTo !== undefined ? pendingChanges.isAddOnTo : (product.isAddOnTo !== false && typeof product.isAddOnTo === 'object' ? product.isAddOnTo : null); @@ -318,7 +315,6 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec if (pendingChanges.displayName !== undefined) keys.add('displayName'); if (pendingChanges.stackable !== undefined) keys.add('stackable'); if (pendingChanges.serverOnly !== undefined) keys.add('serverOnly'); - if (pendingChanges.freeTrial !== undefined) keys.add('freeTrial'); if (pendingChanges.isAddOnTo !== undefined) keys.add('isAddOnTo'); if (pendingChanges.prices !== undefined) keys.add('prices'); if (pendingChanges.includedItems !== undefined) keys.add('includedItems'); @@ -359,9 +355,6 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec if (pendingChanges.serverOnly !== undefined) { configUpdate[`payments.products.${productId}.serverOnly`] = pendingChanges.serverOnly; } - if (pendingChanges.freeTrial !== undefined) { - configUpdate[`payments.products.${productId}.freeTrial`] = pendingChanges.freeTrial; - } if (pendingChanges.isAddOnTo !== undefined) { configUpdate[`payments.products.${productId}.isAddOnTo`] = pendingChanges.isAddOnTo; } @@ -406,10 +399,6 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec return new Set(Object.keys(product.isAddOnTo)); }); - // Free trial popover state - const [freeTrialCount, setFreeTrialCount] = useState(() => product.freeTrial ? product.freeTrial[0] : 7); - const [freeTrialUnit, setFreeTrialUnit] = useState(() => product.freeTrial ? product.freeTrial[1] : 'day'); - // Computed: add-on parent products from local state const localAddOnParents = useMemo(() => { if (!localIsAddOnTo) return []; @@ -433,12 +422,6 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec })); }, [config.payments.products, productId, product.customerType, product.productLineId]); - const localFreeTrialDisplayText = useMemo(() => { - if (!localFreeTrial) return 'None'; - const [count, unit] = localFreeTrial; - return `${count} ${count === 1 ? unit : unit + 's'}`; - }, [localFreeTrial]); - // ===== CHANGE HANDLERS (update local state) ===== const handleDisplayNameChange = (value: string) => { const originalValue = product.displayName || ''; @@ -534,30 +517,6 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec setAddOnDialogOpen(false); }; - const handleFreeTrialSave = (count: number, unit: DayInterval[1]) => { - const newValue: DayInterval = [count, unit]; - const originalValue = product.freeTrial || null; - - if (originalValue && originalValue[0] === count && originalValue[1] === unit) { - const { freeTrial: _, ...rest } = pendingChanges; - setPendingChanges(rest); - } else { - setPendingChanges({ ...pendingChanges, freeTrial: newValue }); - } - setFreeTrialPopoverOpen(false); - }; - - const handleRemoveFreeTrial = () => { - const originalValue = product.freeTrial || null; - if (originalValue === null) { - const { freeTrial: _, ...rest } = pendingChanges; - setPendingChanges(rest); - } else { - setPendingChanges({ ...pendingChanges, freeTrial: null }); - } - setFreeTrialPopoverOpen(false); - }; - // ===== PRICES HANDLERS (for deferred save) ===== const handlePricesChange = (newPrices: Product['prices']) => { // Clear the "needs at least one price" error as soon as the user adds one back. @@ -646,72 +605,6 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec ) : 'No', }, - { - type: 'custom', - itemKey: 'freeTrial', - icon: , - name: "Free Trial", - tooltip: "Free trial period before billing starts. Customers won't be charged during this period.", - children: ( - - - - - -
-
- setFreeTrialCount(parseInt(e.target.value) || 1)} - /> - -
-
- - {localFreeTrial && ( - - )} -
-
-
-
- ), - }, { type: 'boolean', itemKey: 'serverOnly', diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx index 71b588a12..4820eceab 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx @@ -26,7 +26,7 @@ import { } from "@/components/ui"; import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; -import { ArrowSquareOutIcon, BuildingOfficeIcon, CaretDownIcon, ChatIcon, ClockIcon, CodeIcon, CopyIcon, GearIcon, HardDriveIcon, LightningIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, UserIcon } from "@phosphor-icons/react"; +import { ArrowSquareOutIcon, BuildingOfficeIcon, CaretDownIcon, ChatIcon, CodeIcon, CopyIcon, GearIcon, HardDriveIcon, LightningIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, UserIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@hexclave/shared/dist/config/schema"; import { getUserSpecifiedIdErrorMessage, isValidUserSpecifiedId, sanitizeUserSpecifiedId } from "@hexclave/shared/dist/schema-fields"; import { typedEntries } from "@hexclave/shared/dist/utils/objects"; @@ -71,13 +71,6 @@ const CUSTOMER_TYPE_OPTIONS = [ }, ] as const; -const FREE_TRIAL_UNIT_OPTIONS = [ - { value: 'day', label: 'days' }, - { value: 'week', label: 'weeks' }, - { value: 'month', label: 'months' }, - { value: 'year', label: 'years' }, -]; - const COLOR_CLASSES = { blue: { hover: 'hover:border-blue-500/40 hover:shadow-[0_0_12px_rgba(59,130,246,0.1)]', @@ -311,7 +304,6 @@ export default function PageClient() { const [isInlineProduct, setIsInlineProduct] = useState(false); const [prices, setPrices] = useState>(duplicatePrices); const [includedItems, setIncludedItems] = useState(duplicateData?.includedItems ?? {}); - const [freeTrial, setFreeTrial] = useState(duplicateData?.freeTrial); // Dialog states const [showProductLineDialog, setShowProductLineDialog] = useState(false); @@ -390,7 +382,6 @@ export default function PageClient() { prices, includedItems, serverOnly, - freeTrial, }; const handleSelectCustomerType = (type: 'user' | 'team' | 'custom') => { @@ -492,7 +483,6 @@ export default function PageClient() { prices, includedItems, serverOnly, - freeTrial, }; const success = await updateConfig({ @@ -986,48 +976,6 @@ ${Object.entries(prices).map(([id, price]) => { )} - {/* Free Trial */} - Offer a free trial period? -
- - {freeTrial && ( -
- { - const val = parseInt(e.target.value) || 1; - setFreeTrial([val, freeTrial[1]]); - }} - size="sm" - className="w-16" - /> - setFreeTrial([freeTrial[0], value as 'day' | 'week' | 'month' | 'year'])} - options={FREE_TRIAL_UNIT_OPTIONS} - size="sm" - className="w-28 shrink-0" - /> -
- )} -
- {/* Product Line */} Part of a mutually exclusive group?
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx index a32ba4edc..7bb2f7004 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx @@ -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 }]); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx index 10e079ecc..26ab9ea43 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx @@ -69,7 +69,6 @@ export function ProductDialog({ const [stackable, setStackable] = useState(editingProduct?.stackable || false); const [prices, setPrices] = useState>(editingProduct?.prices || {}); const [includedItems, setIncludedItems] = useState(editingProduct?.includedItems || {}); - const [freeTrial, setFreeTrial] = useState(editingProduct?.freeTrial || undefined); const [serverOnly, setServerOnly] = useState(editingProduct?.serverOnly || false); // Dialog states @@ -175,7 +174,6 @@ export function ProductDialog({ prices, includedItems, serverOnly, - freeTrial, }; await onSave(productId, product); diff --git a/apps/dashboard/src/components/data-table/payment-product-table.tsx b/apps/dashboard/src/components/data-table/payment-product-table.tsx index 1bd92836d..d31da8613 100644 --- a/apps/dashboard/src/components/data-table/payment-product-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-product-table.tsx @@ -52,14 +52,6 @@ const columns: DataGridColumnDef[] = [ {String(value)} ), }, - { - id: "freeTrial", - header: "Free Trial", - accessor: (row) => row.freeTrial?.join(" ") ?? "", - width: 140, - type: "string", - sortable: false, - }, { id: "stackable", header: "Stackable", diff --git a/apps/dashboard/src/components/repeating-input.tsx b/apps/dashboard/src/components/repeating-input.tsx index 2dc29286e..cea044364 100644 --- a/apps/dashboard/src/components/repeating-input.tsx +++ b/apps/dashboard/src/components/repeating-input.tsx @@ -234,19 +234,20 @@ export function RepeatingInput({
Custom
- { - const val = parseInt(e.target.value, 10); - if (val > 0) { - applyCustom(val, effectiveUnit); - } - }} - /> +
+ { + const val = parseInt(e.target.value, 10); + if (val > 0) { + applyCustom(val, effectiveUnit); + } + }} + /> +
{ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts index e1f150407..af534729d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts @@ -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(); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts index 0a3a63f63..9f72ebdd5 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts @@ -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(); diff --git a/docs-mintlify/guides/apps/payments/overview.mdx b/docs-mintlify/guides/apps/payments/overview.mdx index 1e5c7562b..314f217a9 100644 --- a/docs-mintlify/guides/apps/payments/overview.mdx +++ b/docs-mintlify/guides/apps/payments/overview.mdx @@ -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). diff --git a/docs-mintlify/guides/going-further/hexclave-config.mdx b/docs-mintlify/guides/going-further/hexclave-config.mdx index 683e29828..aa7f8b3f4 100644 --- a/docs-mintlify/guides/going-further/hexclave-config.mdx +++ b/docs-mintlify/guides/going-further/hexclave-config.mdx @@ -274,7 +274,7 @@ For more information on how product lines, add-ons, and switching plans work tog | Field | Type | Default | Description | | --- | --- | --- | --- | -| `payments.products.` | `{ displayName?: string, productLineId?: string, customerType: "user" \| "team" \| "custom", freeTrial?: DayInterval, serverOnly?: boolean, stackable?: boolean, isAddOnTo?: false \| Record, prices: Record, includedItems?: Record }` | `{ displayName: product ID, customerType: "user", serverOnly: false, isAddOnTo: false, includedItems: {} }` | Defines one product. `productLineId` places it in a mutually exclusive product line. `customerType` must match the product line when set. `freeTrial` applies to the product. `serverOnly` hides it from client SDK responses. `stackable` allows repeated ownership. `isAddOnTo` requires ownership of one of the listed base products. `prices` defines what can be purchased, and `includedItems` defines granted entitlements. | +| `payments.products.` | `{ displayName?: string, productLineId?: string, customerType: "user" \| "team" \| "custom", serverOnly?: boolean, stackable?: boolean, isAddOnTo?: false \| Record, prices: Record, includedItems?: Record }` | `{ displayName: product ID, customerType: "user", serverOnly: false, isAddOnTo: false, includedItems: {} }` | Defines one product. `productLineId` places it in a mutually exclusive product line. `customerType` must match the product line when set. `serverOnly` hides it from client SDK responses. `stackable` allows repeated ownership. `isAddOnTo` requires ownership of one of the listed base products. `prices` defines what can be purchased (free trials are configured per-price), and `includedItems` defines granted entitlements. | ### Prices diff --git a/docs-mintlify/openapi/admin.json b/docs-mintlify/openapi/admin.json index 16fa08605..36a087e18 100644 --- a/docs-mintlify/openapi/admin.json +++ b/docs-mintlify/openapi/admin.json @@ -3140,7 +3140,7 @@ "/emails/send-email": { "post": { "summary": "Send email", - "description": "Send an email to a list of users (user_ids), all users (all_users), or arbitrary email addresses (emails). The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", + "description": "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", "parameters": [], "tags": [ "Emails" @@ -3161,12 +3161,11 @@ "properties": { "user_id": { "type": "string" - }, - "email": { - "type": "string" } }, - "required": [] + "required": [ + "user_id" + ] } } }, @@ -5083,12 +5082,6 @@ "custom" ] }, - "free_trial": { - "type": "array", - "items": { - "type": "integer" - } - }, "server_only": { "type": "boolean", "default": true @@ -5312,12 +5305,6 @@ "custom" ] }, - "free_trial": { - "type": "array", - "items": { - "type": "integer" - } - }, "server_only": { "type": "boolean", "default": true diff --git a/docs-mintlify/openapi/client.json b/docs-mintlify/openapi/client.json index f564e7098..0969e26ea 100644 --- a/docs-mintlify/openapi/client.json +++ b/docs-mintlify/openapi/client.json @@ -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 diff --git a/docs-mintlify/openapi/server.json b/docs-mintlify/openapi/server.json index fc9b7a94c..7d77a4620 100644 --- a/docs-mintlify/openapi/server.json +++ b/docs-mintlify/openapi/server.json @@ -3100,7 +3100,7 @@ "/emails/send-email": { "post": { "summary": "Send email", - "description": "Send an email to a list of users (user_ids), all users (all_users), or arbitrary email addresses (emails). The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", + "description": "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", "parameters": [], "tags": [ "Emails" @@ -3121,12 +3121,11 @@ "properties": { "user_id": { "type": "string" - }, - "email": { - "type": "string" } }, - "required": [] + "required": [ + "user_id" + ] } } }, @@ -5043,12 +5042,6 @@ "custom" ] }, - "free_trial": { - "type": "array", - "items": { - "type": "integer" - } - }, "server_only": { "type": "boolean", "default": true @@ -5272,12 +5265,6 @@ "custom" ] }, - "free_trial": { - "type": "array", - "items": { - "type": "integer" - } - }, "server_only": { "type": "boolean", "default": true diff --git a/packages/shared/src/config/schema.ts b/packages/shared/src/config/schema.ts index cb262800f..b0c3aef7e 100644 --- a/packages/shared/src/config/schema.ts +++ b/packages/shared/src/config/schema.ts @@ -567,13 +567,20 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment" } // END + // BEGIN 2026-07-01: product-level freeTrial removed; free trials are now configured per-price only. + // Treat product-level freeTrial as legacy input only: copy it to prices visible in the same override, + // then strip the product-level field before validating against the current schema. + if (isBranchOrHigher) { + res = migrateProductLevelFreeTrialsToPrices(res); + } + // END + // BEGIN 2026-07-01: dbSync.externalDatabases contains environment-specific connection strings. // It should never be rendered from branch config, which can be read by branch-scoped tools. if (type === "branch") { res = removeProperty(res, p => p[0] === "dbSync"); } // END - // return the result return res; }; @@ -594,6 +601,196 @@ import.meta.vitest?.test("migrateConfigOverride removes legacy sourceOfTruth ove })).toEqual({}); }); +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 visible in this override is removed because there is nowhere + // override-local to preserve it. Other config layers are not inspected by this migration. + expect(migrateConfigOverride("branch", { + payments: { products: { pro: { freeTrial: [7, "day"] } } }, + })).toEqual({ + payments: { products: { pro: {} } }, + }); + + // 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": [7, "day"], + payments: { products: { pro: { prices: { monthly: { USD: "10" } } } } }, + })).toEqual({ + payments: { products: { pro: { prices: { monthly: { USD: "10" } } } } }, + "payments.products.pro.prices.monthly.freeTrial": [7, "day"], + }); + + // Dotted product IDs and product IDs containing "prices" should be treated as product IDs, not structural paths. + expect(migrateConfigOverride("branch", { + payments: { products: { + "pro.plan": { freeTrial: [7, "day"], prices: { monthly: { USD: "10" } } }, + "pro.prices": { freeTrial: [14, "day"], prices: { monthly: { USD: "20" } } }, + } }, + })).toEqual({ + payments: { products: { + "pro.plan": { prices: { monthly: { USD: "10", freeTrial: [7, "day"] } } }, + "pro.prices": { prices: { monthly: { USD: "20", freeTrial: [14, "day"] } } }, + } }, + }); + + // 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"], + "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 normalizes legacy product-level freeTrial before branch validation", async ({ expect }) => { + const legacyOverride = { + payments: { + products: { + pro: { + displayName: "Pro", + customerType: "user", + serverOnly: false, + stackable: false, + freeTrial: [7, "day"], + prices: { + monthly: { USD: "10" }, + }, + includedItems: {}, + }, + }, + }, + }; + + const unmigratedErrors = await getConfigOverrideErrors(branchConfigSchema, legacyOverride); + expect(unmigratedErrors.status).toBe("error"); + + const migratedOverride = migrateConfigOverride("branch", legacyOverride); + expect(migratedOverride).toEqual({ + payments: { + products: { + pro: { + displayName: "Pro", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + monthly: { USD: "10", freeTrial: [7, "day"] }, + }, + includedItems: {}, + }, + }, + }, + }); + + const migratedErrors = await getConfigOverrideErrors(branchConfigSchema, migratedOverride); + expect(migratedErrors.status).toBe("ok"); +}); + import.meta.vitest?.test("migrateConfigOverride removes legacy branch-level dbSync overrides", ({ expect }) => { const dbSync = { externalDatabases: { @@ -607,11 +804,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, pathCond: (path: (string | symbol)[]) => boolean): any { +function removeProperty(obj: Record, 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function migrateProductLevelFreeTrialsToPrices(obj: Record): any { + const productLevelFreeTrials = new Map(); + collectProductLevelFreeTrials(obj, [], productLevelFreeTrials); + + const pricePathsWithDefinedFreeTrial = new Set(); + 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): any { + if (!isConfigObject(obj)) return obj; + + const res: Record = {}; + 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) { + 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(); + const pricePathsWithFreeTrial = new Set(); + + 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, pathCond: (path: string[]) => boolean, mapper: (value: any) => any): any { const res: Record = Array.isArray(obj) ? [] : {}; for (const [key, value] of typedEntries(obj)) { @@ -845,7 +1163,6 @@ const organizationConfigDefaults = { displayName: key, productLineId: undefined, customerType: "user", - freeTrial: undefined, serverOnly: false, stackable: undefined, isAddOnTo: false, diff --git a/packages/shared/src/schema-fields.ts b/packages/shared/src/schema-fields.ts index dabd35674..44282b5e7 100644 --- a/packages/shared/src/schema-fields.ts +++ b/packages/shared/src/schema-fields.ts @@ -698,7 +698,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(), @@ -712,6 +711,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 } }); @@ -727,7 +751,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( @@ -751,6 +774,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) {