This update refines the process of creating a billing schema by filtering out metered line items. The change is necessary as metered line items can be shared across different plans, potentially causing conflicts or duplicates in the schema.
436 lines
11 KiB
TypeScript
436 lines
11 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
export enum LineItemType {
|
|
Flat = 'flat',
|
|
PerSeat = 'per_seat',
|
|
Metered = 'metered',
|
|
}
|
|
|
|
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 !== LineItemType.Metered ||
|
|
(data.unit && data.tiers !== undefined),
|
|
{
|
|
message: 'Metered line items must have a unit and tiers',
|
|
path: ['type', 'unit', 'tiers'],
|
|
},
|
|
)
|
|
.refine(
|
|
(data) => {
|
|
if (data.type === LineItemType.Metered) {
|
|
return data.cost === 0;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
{
|
|
message:
|
|
'Metered line items must have a cost of 0. Please add a different line item type for a flat fee (Stripe)',
|
|
path: ['type', 'cost'],
|
|
},
|
|
);
|
|
|
|
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(),
|
|
custom: z.boolean().default(false).optional(),
|
|
label: z.string().min(1).optional(),
|
|
buttonLabel: z.string().min(1).optional(),
|
|
href: z.string().min(1).optional(),
|
|
lineItems: z.array(LineItemSchema).refine(
|
|
(schema) => {
|
|
const types = schema.map((item) => item.type);
|
|
|
|
const perSeat = types.filter(
|
|
(type) => type === LineItemType.PerSeat,
|
|
).length;
|
|
|
|
const flat = types.filter((type) => type === LineItemType.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) => {
|
|
if (data.custom) {
|
|
return data.lineItems.length === 0;
|
|
}
|
|
|
|
return data.lineItems.length > 0;
|
|
},
|
|
{
|
|
message: 'Non-Custom Plans must have at least one line item',
|
|
path: ['lineItems'],
|
|
},
|
|
)
|
|
.refine(
|
|
(data) => {
|
|
if (data.custom) {
|
|
return data.lineItems.length === 0;
|
|
}
|
|
|
|
return data.lineItems.length > 0;
|
|
},
|
|
{
|
|
message: 'Custom Plans must have 0 line items',
|
|
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) => {
|
|
// metered line items can be shared across plans
|
|
const lineItems = item.lineItems.filter(
|
|
(item) => item.type !== LineItemType.Metered,
|
|
);
|
|
|
|
const ids = 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 !== LineItemType.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<typeof BillingSchema>) {
|
|
return BillingSchema.parse(config);
|
|
}
|
|
|
|
export type BillingConfig = z.infer<typeof BillingSchema>;
|
|
export type ProductSchema = z.infer<typeof ProductSchema>;
|
|
|
|
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
|
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<typeof BillingSchema>,
|
|
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 === LineItemType.Flat,
|
|
);
|
|
|
|
if (flatLineItem) {
|
|
return flatLineItem;
|
|
}
|
|
|
|
return plan.lineItems[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error('Base line item not found');
|
|
}
|
|
|
|
export function getProductPlanPair(
|
|
config: z.infer<typeof BillingSchema>,
|
|
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<typeof BillingSchema>,
|
|
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<typeof BillingSchema>,
|
|
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`);
|
|
}
|