mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge b055b94af6 into 7ed89a2a9c
This commit is contained in:
commit
40d8cdd8f0
@ -1,10 +1,10 @@
|
||||
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, productToInlineProduct } from "@/lib/payments";
|
||||
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, inlineProductSchemaWithLegacyProductLevelFreeTrial, isActiveSubscription, isAddOnProduct, normalizeInlineProductLegacyProductLevelFreeTrial, productToInlineProduct } from "@/lib/payments";
|
||||
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
|
||||
import { getPrismaClientForTenancy } from "@/prisma-client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { KnownErrors } from "@hexclave/shared";
|
||||
import { customerProductsListResponseSchema } from "@hexclave/shared/dist/interface/crud/products";
|
||||
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, serverOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { adaptSchema, clientOrHigherAuthTypeSchema, serverOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { typedEntries, typedFromEntries } from "@hexclave/shared/dist/utils/objects";
|
||||
import { stringCompare } from "@hexclave/shared/dist/utils/strings";
|
||||
@ -179,7 +179,7 @@ export const POST = createSmartRouteHandler({
|
||||
}).defined(),
|
||||
body: yupObject({
|
||||
product_id: yupString().optional(),
|
||||
product_inline: inlineProductSchema.optional(),
|
||||
product_inline: inlineProductSchemaWithLegacyProductLevelFreeTrial.optional(),
|
||||
quantity: yupNumber().integer().min(1).default(1),
|
||||
}).defined(),
|
||||
}),
|
||||
@ -200,11 +200,12 @@ export const POST = createSmartRouteHandler({
|
||||
customerType: params.customer_type,
|
||||
customerId: params.customer_id,
|
||||
});
|
||||
const productInline = normalizeInlineProductLegacyProductLevelFreeTrial(body.product_inline);
|
||||
const product = await ensureProductIdOrInlineProduct(
|
||||
tenancy,
|
||||
auth.type,
|
||||
body.product_id,
|
||||
body.product_inline,
|
||||
productInline,
|
||||
);
|
||||
|
||||
if (params.customer_type !== product.customerType) {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { CustomerType } from "@/generated/prisma/client";
|
||||
import { customerOwnsProduct, ensureClientCanAccessCustomer, ensureProductIdOrInlineProduct } from "@/lib/payments";
|
||||
import { customerOwnsProduct, ensureClientCanAccessCustomer, ensureProductIdOrInlineProduct, inlineProductSchemaWithLegacyProductLevelFreeTrial, normalizeInlineProductLegacyProductLevelFreeTrial } from "@/lib/payments";
|
||||
import { getOwnedProductsForCustomer } from "@/lib/payments/customer-data";
|
||||
import { validateRedirectUrl } from "@/lib/redirect-urls";
|
||||
import { getHexclaveStripe, getStripeForAccount } from "@/lib/stripe";
|
||||
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
|
||||
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, urlSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { adaptSchema, clientOrHigherAuthTypeSchema, urlSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
|
||||
@ -43,7 +43,7 @@ export const POST = createSmartRouteHandler({
|
||||
exampleValue: "prod_premium_monthly"
|
||||
}
|
||||
}),
|
||||
product_inline: inlineProductSchema.optional().meta({
|
||||
product_inline: inlineProductSchemaWithLegacyProductLevelFreeTrial.optional().meta({
|
||||
openapiField: {
|
||||
description: "Inline product definition. Either this or product_id should be given."
|
||||
}
|
||||
@ -83,7 +83,8 @@ export const POST = createSmartRouteHandler({
|
||||
});
|
||||
}
|
||||
|
||||
const productConfig = await ensureProductIdOrInlineProduct(tenancy, req.auth.type, req.body.product_id, req.body.product_inline);
|
||||
const productInline = normalizeInlineProductLegacyProductLevelFreeTrial(req.body.product_inline);
|
||||
const productConfig = await ensureProductIdOrInlineProduct(tenancy, req.auth.type, req.body.product_id, productInline);
|
||||
const customerType = productConfig.customerType;
|
||||
if (req.body.customer_type !== customerType) {
|
||||
throw new KnownErrors.ProductCustomerTypeDoesNotMatch(req.body.product_id, req.body.customer_id, customerType, req.body.customer_type);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { POST as latestHandler } from "@/app/api/latest/payments/purchases/create-purchase-url/route";
|
||||
import { inlineProductSchemaWithLegacyProductLevelFreeTrial } from "@/lib/payments";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { ensureObjectSchema, inlineProductSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { ensureObjectSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { normalizePurchaseBody } from "../offers-compat";
|
||||
|
||||
const latestInit = latestHandler.initArgs[0];
|
||||
@ -13,7 +14,7 @@ export const POST = createSmartRouteHandler({
|
||||
request: requestSchema.concat(yupObject({
|
||||
body: requestBodySchema.concat(yupObject({
|
||||
offer_id: yupString().optional(),
|
||||
offer_inline: inlineProductSchema.optional(),
|
||||
offer_inline: inlineProductSchemaWithLegacyProductLevelFreeTrial.optional(),
|
||||
})),
|
||||
})),
|
||||
handler: async (_req, fullReq) => {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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>>,
|
||||
|
||||
@ -743,7 +743,6 @@ export function buildDummyPaymentsSetup(): PaymentsSetup {
|
||||
customerType: 'team',
|
||||
serverOnly: false,
|
||||
stackable: false,
|
||||
freeTrial: twoWeekInterval as any,
|
||||
prices: {
|
||||
monthly: {
|
||||
USD: '39',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 }]);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -900,6 +900,72 @@ it("should grant inline product without needing configuration", async ({ expect
|
||||
`);
|
||||
});
|
||||
|
||||
it("should migrate legacy inline product-level free_trial when directly granting a product", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
|
||||
await Payments.setup();
|
||||
const { userId } = await Auth.fastSignUp();
|
||||
|
||||
const grantResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
product_inline: {
|
||||
display_name: "Legacy Inline Trial Grant",
|
||||
customer_type: "user",
|
||||
free_trial: [7, "day"],
|
||||
server_only: true,
|
||||
prices: {
|
||||
monthly: {
|
||||
USD: "1000",
|
||||
interval: [1, "month"],
|
||||
},
|
||||
annual: {
|
||||
USD: "10000",
|
||||
interval: [1, "year"],
|
||||
free_trial: [14, "day"],
|
||||
},
|
||||
},
|
||||
included_items: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(grantResponse.status).toBe(200);
|
||||
|
||||
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
|
||||
accessType: "server",
|
||||
});
|
||||
expect(listResponse.status).toBe(200);
|
||||
expect(listResponse.body.items).toHaveLength(1);
|
||||
const product = listResponse.body.items[0].product;
|
||||
expect(product).not.toHaveProperty("free_trial");
|
||||
expect(product.prices).toMatchInlineSnapshot(`
|
||||
{
|
||||
"annual": {
|
||||
"USD": "10000",
|
||||
"free_trial": [
|
||||
14,
|
||||
"day",
|
||||
],
|
||||
"interval": [
|
||||
1,
|
||||
"year",
|
||||
],
|
||||
},
|
||||
"monthly": {
|
||||
"USD": "1000",
|
||||
"free_trial": [
|
||||
7,
|
||||
"day",
|
||||
],
|
||||
"interval": [
|
||||
1,
|
||||
"month",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should allow canceling an inline product subscription via subscription_id", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
|
||||
await Payments.setup();
|
||||
|
||||
@ -107,6 +107,78 @@ it("should allow valid code and return product data", async ({ expect }) => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("should migrate legacy inline product-level free_trial to price-level free_trial", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await Payments.setup();
|
||||
await Project.updateConfig({ payments: { testMode: false } });
|
||||
|
||||
const { userId } = await Auth.fastSignUp();
|
||||
const createResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
customer_type: "user",
|
||||
customer_id: userId,
|
||||
product_inline: {
|
||||
display_name: "Legacy Inline Trial",
|
||||
customer_type: "user",
|
||||
free_trial: [7, "day"],
|
||||
server_only: true,
|
||||
prices: {
|
||||
monthly: {
|
||||
USD: "1000",
|
||||
interval: [1, "month"],
|
||||
},
|
||||
annual: {
|
||||
USD: "10000",
|
||||
interval: [1, "year"],
|
||||
free_trial: [14, "day"],
|
||||
},
|
||||
},
|
||||
included_items: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(createResponse.status).toBe(200);
|
||||
const url = (createResponse.body as { url: string }).url;
|
||||
const code = url.match(/\/purchase\/([a-z0-9-_]+)/)?.[1];
|
||||
expect(code).toBeDefined();
|
||||
|
||||
const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { full_code: code },
|
||||
});
|
||||
expect(validateResponse.status).toBe(200);
|
||||
expect(validateResponse.body.product).not.toHaveProperty("free_trial");
|
||||
expect(validateResponse.body.product.prices).toMatchInlineSnapshot(`
|
||||
{
|
||||
"annual": {
|
||||
"USD": "10000",
|
||||
"free_trial": [
|
||||
14,
|
||||
"day",
|
||||
],
|
||||
"interval": [
|
||||
1,
|
||||
"year",
|
||||
],
|
||||
},
|
||||
"monthly": {
|
||||
"USD": "1000",
|
||||
"free_trial": [
|
||||
7,
|
||||
"day",
|
||||
],
|
||||
"interval": [
|
||||
1,
|
||||
"month",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should set already_bought_non_stackable when user already owns non-stackable product", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await Payments.setup();
|
||||
|
||||
@ -62,7 +62,7 @@ A few additional options:
|
||||
|
||||
- **Product lines** - Assign products to a product line to make them mutually exclusive (e.g. plan tiers). Configure lines in **Payments -> Product Lines**.
|
||||
- **Add-ons** - Set **isAddOnTo** to require the customer to already own a specific base product before purchasing this one.
|
||||
- **Free trial** - Give customers a trial period before charging. Can be set on the product or on individual prices.
|
||||
- **Free trial** - Give customers a trial period before charging. Configure free trials on individual prices.
|
||||
- **Server-only** - Hide the product from client SDK responses. Useful for products that should only be granted programmatically.
|
||||
- **Stackable** - Allow multiple purchases of the same product (default is one per customer).
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user