This commit is contained in:
Vedanta-Gawande 2026-07-19 00:01:54 -07:00 committed by GitHub
commit 40d8cdd8f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 613 additions and 312 deletions

View File

@ -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) {

View File

@ -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);

View File

@ -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) => {

View File

@ -6,7 +6,7 @@ import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { getPrismaClientForTenancy, PrismaClientTransaction } from "@/prisma-client";
import { KnownErrors } from "@hexclave/shared";
import type { UsersCrud } from "@hexclave/shared/dist/interface/crud/users";
import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@hexclave/shared/dist/schema-fields";
import { dayIntervalSchema, inlineProductSchema, productSchema, productSchemaWithMetadata, yupObject } from "@hexclave/shared/dist/schema-fields";
import { SUPPORTED_CURRENCIES } from "@hexclave/shared/dist/utils/currency-constants";
import { addInterval } from "@hexclave/shared/dist/utils/dates";
import { captureError, HexclaveAssertionError, StatusError, throwErr } from "@hexclave/shared/dist/utils/errors";
@ -21,6 +21,12 @@ import { Tenancy } from "./tenancies";
type Product = yup.InferType<typeof productSchema>;
type ProductWithMetadata = yup.InferType<typeof productSchemaWithMetadata>;
type InlineProduct = yup.InferType<typeof inlineProductSchema>;
export const inlineProductSchemaWithLegacyProductLevelFreeTrial = inlineProductSchema.concat(yupObject({
free_trial: dayIntervalSchema.optional().meta({ openapiField: { hidden: true } }),
}));
type InlineProductWithLegacyProductLevelFreeTrial = yup.InferType<typeof inlineProductSchemaWithLegacyProductLevelFreeTrial>;
type SelectedPrice = Product["prices"][string];
export async function ensureClientCanAccessCustomer(options: {
@ -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 };
}

View File

@ -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<string, true> | null,
prices: Record<string, Record<string, Json>>,

View File

@ -743,7 +743,6 @@ export function buildDummyPaymentsSetup(): PaymentsSetup {
customerType: 'team',
serverOnly: false,
stackable: false,
freeTrial: twoWeekInterval as any,
prices: {
monthly: {
USD: '39',

View File

@ -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<Record<string, Price>>(existingPrices);
const [includedItems, setIncludedItems] = useState<Product['includedItems']>(existingProduct.includedItems);
const [freeTrial, setFreeTrial] = useState<Product['freeTrial']>(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 */}
<span className="text-sm text-foreground/70 py-2 flex items-center border-b border-border/20">Offer a free trial period?</span>
<div className="py-2 flex items-center border-b border-border/20">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
id="free-trial"
checked={!!freeTrial}
onCheckedChange={(checked) => {
if (checked) {
setFreeTrial([7, 'day']);
} else {
setFreeTrial(undefined);
}
}}
/>
<ClockIcon className="h-4 w-4 text-foreground/50" />
<span className="text-sm font-medium">Free trial</span>
</label>
{freeTrial && (
<div className="flex items-center gap-2 ml-4">
<Input
type="number"
min={1}
value={freeTrial[0]}
onChange={(e) => {
const val = parseInt(e.target.value) || 1;
setFreeTrial([val, freeTrial[1]]);
}}
className="h-7 w-16 text-sm rounded-md"
/>
<Select
value={freeTrial[1]}
onValueChange={(value) => setFreeTrial([freeTrial[0], value as 'day' | 'week' | 'month' | 'year'])}
>
<SelectTrigger className="h-7 w-24 rounded-md text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">days</SelectItem>
<SelectItem value="week">weeks</SelectItem>
<SelectItem value="month">months</SelectItem>
<SelectItem value="year">years</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Product Line */}
<span className="text-sm text-foreground/70 py-2 flex items-center border-b border-border/20">Part of a mutually exclusive group?</span>
<div className="py-2 flex items-center border-b border-border/20">

View File

@ -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<string, true> | 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<DayInterval[1]>(() => 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
</span>
) : 'No',
},
{
type: 'custom',
itemKey: 'freeTrial',
icon: <ClockIcon size={16} />,
name: "Free Trial",
tooltip: "Free trial period before billing starts. Customers won't be charged during this period.",
children: (
<Popover open={freeTrialPopoverOpen} onOpenChange={setFreeTrialPopoverOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"w-full px-1 py-0 h-[unset] border-transparent rounded text-left text-foreground",
"hover:ring-1 hover:ring-slate-300 dark:hover:ring-gray-500 hover:bg-slate-50 dark:hover:bg-gray-800 hover:cursor-pointer",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-slate-500 dark:focus-visible:ring-gray-50",
"transition-colors duration-150 hover:transition-none"
)}
>
{localFreeTrialDisplayText}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
className="w-20"
type="number"
min={1}
value={freeTrialCount}
onChange={(e) => setFreeTrialCount(parseInt(e.target.value) || 1)}
/>
<Select value={freeTrialUnit} onValueChange={(v) => setFreeTrialUnit(v as DayInterval[1])}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_INTERVAL_UNITS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}{freeTrialCount !== 1 ? 's' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1"
onClick={() => handleFreeTrialSave(freeTrialCount, freeTrialUnit)}
>
Apply
</Button>
{localFreeTrial && (
<Button
size="sm"
variant="outline"
onClick={handleRemoveFreeTrial}
>
Remove
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
),
},
{
type: 'boolean',
itemKey: 'serverOnly',

View File

@ -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<Record<string, Price>>(duplicatePrices);
const [includedItems, setIncludedItems] = useState<Product['includedItems']>(duplicateData?.includedItems ?? {});
const [freeTrial, setFreeTrial] = useState<Product['freeTrial']>(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 */}
<span className="text-sm text-foreground/70 py-2 flex items-center border-b border-border/20">Offer a free trial period?</span>
<div className="py-2 flex items-center border-b border-border/20">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
id="free-trial"
checked={!!freeTrial}
onCheckedChange={(checked) => {
if (checked) {
setFreeTrial([7, 'day']);
} else {
setFreeTrial(undefined);
}
}}
/>
<ClockIcon className="h-4 w-4 text-foreground/50" />
<span className="text-sm font-medium">Free trial</span>
</label>
{freeTrial && (
<div className="flex items-center gap-2 ml-4">
<DesignInput
type="number"
min={1}
value={freeTrial[0]}
onChange={(e) => {
const val = parseInt(e.target.value) || 1;
setFreeTrial([val, freeTrial[1]]);
}}
size="sm"
className="w-16"
/>
<DesignSelectorDropdown
value={freeTrial[1]}
onValueChange={(value) => setFreeTrial([freeTrial[0], value as 'day' | 'week' | 'month' | 'year'])}
options={FREE_TRIAL_UNIT_OPTIONS}
size="sm"
className="w-28 shrink-0"
/>
</div>
)}
</div>
{/* Product Line */}
<span className="text-sm text-foreground/70 py-2 flex items-center border-b border-border/20">Part of a mutually exclusive group?</span>
<div className="py-2 flex items-center border-b border-border/20">

View File

@ -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 }]);

View File

@ -69,7 +69,6 @@ export function ProductDialog({
const [stackable, setStackable] = useState(editingProduct?.stackable || false);
const [prices, setPrices] = useState<Record<string, Price>>(editingProduct?.prices || {});
const [includedItems, setIncludedItems] = useState<Product['includedItems']>(editingProduct?.includedItems || {});
const [freeTrial, setFreeTrial] = useState<Product['freeTrial']>(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);

View File

@ -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",

View File

@ -234,19 +234,20 @@ export function RepeatingInput({
<div className="border-t border-black/[0.06] dark:border-white/[0.06] p-3">
<div className="text-xs font-medium text-muted-foreground mb-2">Custom</div>
<div className="flex gap-2">
<DesignInput
type="number"
min={1}
size="sm"
className="w-20"
value={effectiveSelection === 'custom' ? intervalCount : 1}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (val > 0) {
applyCustom(val, effectiveUnit);
}
}}
/>
<div className="w-20 shrink-0">
<DesignInput
type="number"
min={1}
size="sm"
value={effectiveSelection === 'custom' ? intervalCount : 1}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (val > 0) {
applyCustom(val, effectiveUnit);
}
}}
/>
</div>
<DesignSelectorDropdown
value={effectiveUnit}
onValueChange={(v) => {

View File

@ -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();

View File

@ -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();

View File

@ -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).

View File

@ -274,7 +274,7 @@ For more information on how product lines, add-ons, and switching plans work tog
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `payments.products.<productId>` | `{ displayName?: string, productLineId?: string, customerType: "user" \| "team" \| "custom", freeTrial?: DayInterval, serverOnly?: boolean, stackable?: boolean, isAddOnTo?: false \| Record<string, true>, prices: Record<string, ProductPrice>, includedItems?: Record<string, IncludedItem> }` | `{ 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.<productId>` | `{ displayName?: string, productLineId?: string, customerType: "user" \| "team" \| "custom", serverOnly?: boolean, stackable?: boolean, isAddOnTo?: false \| Record<string, true>, prices: Record<string, ProductPrice>, includedItems?: Record<string, IncludedItem> }` | `{ 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

View File

@ -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

View File

@ -3837,12 +3837,6 @@
"custom"
]
},
"free_trial": {
"type": "array",
"items": {
"type": "integer"
}
},
"server_only": {
"type": "boolean",
"default": true
@ -4066,12 +4060,6 @@
"custom"
]
},
"free_trial": {
"type": "array",
"items": {
"type": "integer"
}
},
"server_only": {
"type": "boolean",
"default": true

View File

@ -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

View File

@ -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<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)) {
@ -845,7 +1163,6 @@ const organizationConfigDefaults = {
displayName: key,
productLineId: undefined,
customerType: "user",
freeTrial: undefined,
serverOnly: false,
stackable: undefined,
isAddOnTo: false,

View File

@ -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) {