import { z } from 'zod'; const BillingIntervalSchema = z.enum(['month', 'year']); const LineItemTypeSchema = z.enum(['flat', 'per-seat', 'metered']); export const BillingProviderSchema = z.enum([ 'stripe', 'paddle', 'lemon-squeezy', ]); export const PaymentTypeSchema = z.enum(['one-time', 'recurring']); export const LineItemSchema = z .object({ id: z .string({ description: 'Unique identifier for the line item. Defined by the Provider.', }) .min(1), name: z .string({ description: 'Name of the line item. Displayed to the user.', }) .min(1), description: z .string({ description: 'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' + ' from the line item. This is useful if you want to provide a more detailed description to the user.', }) .optional(), cost: z .number({ description: 'Cost of the line item. Displayed to the user.', }) .min(0), type: LineItemTypeSchema, unit: z .string({ description: 'Unit of the line item. Displayed to the user. Example "seat" or "GB"', }) .optional(), setupFee: z .number({ description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`, }) .positive() .optional(), tiers: z .array( z.object({ cost: z.number().min(0), upTo: z.union([z.number().min(0), z.literal('unlimited')]), }), ) .optional(), }) .refine( (data) => data.type !== 'metered' || (data.unit && data.tiers !== undefined), { message: 'Metered line items must have a unit and tiers', path: ['type', 'unit', 'tiers'], }, ); export const PlanSchema = z .object({ id: z .string({ description: 'Unique identifier for the plan. Defined by yourself.', }) .min(1), name: z .string({ description: 'Name of the plan. Displayed to the user.', }) .min(1), interval: BillingIntervalSchema.optional(), lineItems: z.array(LineItemSchema).refine( (schema) => { const types = schema.map((item) => item.type); const perSeat = types.filter((type) => type === 'per-seat').length; const flat = types.filter((type) => type === 'flat').length; return perSeat <= 1 && flat <= 1; }, { message: 'Plans can only have one per-seat and one flat line item', path: ['lineItems'], }, ), trialDays: z .number({ description: 'Number of days for the trial period. Leave empty for no trial.', }) .positive() .optional(), paymentType: PaymentTypeSchema, }) .refine((data) => data.lineItems.length > 0, { message: 'Plans must have at least one line item', path: ['lineItems'], }) .refine( (data) => data.paymentType !== 'one-time' || data.interval === undefined, { message: 'One-time plans must not have an interval', path: ['paymentType', 'interval'], }, ) .refine( (data) => data.paymentType !== 'recurring' || data.interval !== undefined, { message: 'Recurring plans must have an interval', path: ['paymentType', 'interval'], }, ) .refine( (item) => { const ids = item.lineItems.map((item) => item.id); return ids.length === new Set(ids).size; }, { message: 'Line item IDs must be unique', path: ['lineItems'], }, ) .refine( (data) => { if (data.paymentType === 'one-time') { const nonFlatLineItems = data.lineItems.filter( (item) => item.type !== 'flat', ); return nonFlatLineItems.length === 0; } return true; }, { message: 'One-time plans must not have non-flat line items', path: ['paymentType', 'lineItems'], }, ); const ProductSchema = z .object({ id: z .string({ description: 'Unique identifier for the product. Defined by th Provider.', }) .min(1), name: z .string({ description: 'Name of the product. Displayed to the user.', }) .min(1), description: z .string({ description: 'Description of the product. Displayed to the user.', }) .min(1), currency: z .string({ description: 'Currency code for the product. Displayed to the user.', }) .min(3) .max(3), badge: z .string({ description: 'Badge for the product. Displayed to the user. Example: "Popular"', }) .optional(), features: z .array( z.string({ description: 'Features of the product. Displayed to the user.', }), ) .nonempty(), enableDiscountField: z .boolean({ description: 'Enable discount field for the product in the checkout.', }) .optional(), highlighted: z .boolean({ description: 'Highlight this product. Displayed to the user.', }) .optional(), plans: z.array(PlanSchema), }) .refine((data) => data.plans.length > 0, { message: 'Products must have at least one plan', path: ['plans'], }) .refine( (item) => { const planIds = item.plans.map((plan) => plan.id); return planIds.length === new Set(planIds).size; }, { message: 'Plan IDs must be unique', path: ['plans'], }, ); const BillingSchema = z .object({ provider: BillingProviderSchema, products: z.array(ProductSchema).nonempty(), }) .refine( (data) => { const ids = data.products.flatMap((product) => product.plans.flatMap((plan) => plan.lineItems.map((item) => item.id)), ); return ids.length === new Set(ids).size; }, { message: 'Line item IDs must be unique', path: ['products'], }, ) .refine( (schema) => { if (schema.provider === 'lemon-squeezy') { for (const product of schema.products) { for (const plan of product.plans) { if (plan.lineItems.length > 1) { return false; } } } } return true; }, { message: 'Lemon Squeezy only supports one line item per plan', path: ['provider', 'products'], }, ) .refine( (schema) => { if (schema.provider !== 'lemon-squeezy') { // Check if there are any flat fee metered items const setupFeeItems = schema.products.flatMap((product) => product.plans.flatMap((plan) => plan.lineItems.filter((item) => item.setupFee), ), ); // If there are any flat fee metered items, return an error if (setupFeeItems.length > 0) { return false; } } return true; }, { message: 'Setup fee metered items are only supported by Lemon Squeezy. For Stripe and Paddle, please use a separate line item for the setup fee.', path: ['products', 'plans', 'lineItems'], }, ); export function createBillingSchema(config: z.infer) { return BillingSchema.parse(config); } export type BillingConfig = z.infer; export type ProductSchema = z.infer; export function getPlanIntervals(config: z.infer) { const intervals = config.products .flatMap((product) => product.plans.map((plan) => plan.interval)) .filter(Boolean); return Array.from(new Set(intervals)); } /** * @name getPrimaryLineItem * @description Get the primary line item for a plan * By default, the primary line item is the first line item in the plan for Lemon Squeezy * For other providers, the primary line item is the first flat line item in the plan. If there are no flat line items, * the first line item is returned. * * @param config * @param planId */ export function getPrimaryLineItem( config: z.infer, planId: string, ) { for (const product of config.products) { for (const plan of product.plans) { if (plan.id === planId) { // Lemon Squeezy only supports one line item per plan if (config.provider === 'lemon-squeezy') { return plan.lineItems[0]; } const flatLineItem = plan.lineItems.find( (item) => item.type === 'flat', ); if (flatLineItem) { return flatLineItem; } return plan.lineItems[0]; } } } throw new Error('Base line item not found'); } export function getProductPlanPair( config: z.infer, planId: string, ) { for (const product of config.products) { for (const plan of product.plans) { if (plan.id === planId) { return { product, plan }; } } } throw new Error('Plan not found'); } export function getProductPlanPairByVariantId( config: z.infer, planId: string, ) { for (const product of config.products) { for (const plan of product.plans) { for (const lineItem of plan.lineItems) { if (lineItem.id === planId) { return { product, plan }; } } } } throw new Error('Plan not found'); } export function getLineItemTypeById( config: z.infer, id: string, ) { for (const product of config.products) { for (const plan of product.plans) { for (const lineItem of plan.lineItems) { if (lineItem.id === id) { return lineItem.type; } } } } throw new Error(`Line Item with ID ${id} not found`); }