Refactor and improve billing module

The billing module has been refined and enhanced to include deeper validation and detailing of billing plans and products. The checkout session creation process was revised to handle more complex scenarios, incorporating better parsing and validation. Additional validations were added for the plan and product schemas, improving product details extraction, and rearranging of module exports was made for better organization. The code refactor allows easier future modifications and upgrades for recurring and one-time payments with nuanced product configurations.
This commit is contained in:
giancarlo
2024-03-27 21:06:34 +08:00
parent 7579ee9a2c
commit c3a4a05b22
22 changed files with 578 additions and 225 deletions

View File

@@ -1,96 +1,221 @@
import { z } from 'zod';
const Interval = z.enum(['month', 'year']);
const PaymentType = z.enum(['recurring', 'one-time']);
export const RecurringPlanInterval = z.enum(['month', 'year']);
export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']);
const PlanSchema = z.object({
id: z.string().min(1),
export const PaymentType = z.enum(['recurring', 'one-time']);
export const LineItemUsageType = z.enum(['licensed', 'metered']);
const RecurringLineItemSchema = z
.object({
id: z.string().min(1),
interval: RecurringPlanInterval,
metered: z.boolean().optional().default(false),
costPerUnit: z.number().positive().optional(),
perSeat: z.boolean().default(false).optional().default(false),
usageType: LineItemUsageType.optional().default('licensed'),
})
.refine(
(schema) => {
if (!schema.metered && schema.perSeat) {
return false;
}
return true;
},
{
message: 'Line item must be either metered or a member seat',
path: ['metered', 'perSeat'],
},
)
.refine(
(schema) => {
if (!schema.metered && !schema.usageType) {
return false;
}
return true;
},
{
message: 'Line item must have a usage type',
path: ['usageType'],
},
);
const RecurringSchema = z
.object({
interval: RecurringPlanInterval,
metered: z.boolean().optional(),
costPerUnit: z.number().positive().optional(),
perSeat: z.boolean().optional(),
usageType: LineItemUsageType.optional(),
addOns: z.array(RecurringLineItemSchema).default([]).optional(),
})
.refine(
(schema) => {
if (schema.metered) {
return schema.costPerUnit !== undefined;
}
},
{
message: 'Metered plans must have a cost per unit',
path: ['costPerUnit'],
},
)
.refine(
(schema) => {
if (schema.perSeat && !schema.metered) {
return false;
}
return true;
},
{
message: 'Per seat plans must be metered',
path: ['perSeat'],
},
)
.refine(
(schema) => {
return schema.metered && schema.usageType;
},
{
message: 'Metered plans must have a usage type',
path: ['usageType'],
},
);
export const RecurringPlanSchema = z.object({
name: z.string().min(1).max(100),
price: z.string().min(1).max(100),
trialPeriodDays: z.number().optional(),
interval: Interval,
perSeat: z.boolean().optional().default(false),
id: z.string().min(1),
price: z.number().positive(),
trialDays: z.number().positive().optional(),
recurring: RecurringSchema,
});
const ProductSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
currency: z.string().optional().default('USD'),
plans: z.array(PlanSchema),
features: z.array(z.string()),
badge: z.string().optional(),
highlighted: z.boolean().optional(),
hidden: z.boolean().optional(),
paymentType: PaymentType.optional().default('recurring'),
export const OneTimePlanSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(100),
price: z.number().positive(),
});
export const ProductSchema = z
.object({
id: z.string(),
name: z.string(),
description: z.string(),
currency: z.string().optional().default('USD'),
plans: z.union([
RecurringPlanSchema.array().nonempty(),
OneTimePlanSchema.array().nonempty(),
]),
paymentType: PaymentType,
features: z.array(z.string()),
badge: z.string().min(1).optional(),
highlighted: z.boolean().default(false).optional(),
hidden: z.boolean().default(false).optional(),
})
.refine(
(schema) => {
console.log(schema);
const recurringPlans = schema.plans.filter((plan) => 'recurring' in plan);
if (recurringPlans.length && schema.paymentType === 'one-time') {
return false;
}
return true;
},
{
message: 'One-time products cannot have recurring plans',
path: ['paymentType'],
},
)
.refine(
(schema) => {
const recurringPlans = schema.plans.filter((plan) => 'recurring' in plan);
if (recurringPlans.length === 0 && schema.paymentType === 'recurring') {
return false;
}
return true;
},
{
message:
'The product must have at least one recurring plan if the payment type is recurring',
path: ['paymentType'],
},
)
.refine(
(schema) => {
return !(schema.paymentType === 'one-time' && schema.plans.length > 1);
},
{
message: 'One-time products can only have one plan',
path: ['plans'],
},
);
export const BillingSchema = z
.object({
products: z.array(ProductSchema),
products: z.array(ProductSchema).nonempty(),
provider: BillingProvider,
})
.refine((schema) => {
// verify dupe product ids
const ids = schema.products.map((product) => product.id);
.refine(
(schema) => {
const ids = schema.products.map((product) => product.id);
if (new Set(ids).size !== ids.length) {
return {
message: 'Duplicate product IDs',
path: ['products'],
};
}
return new Set(ids).size === ids.length;
},
{
message: 'Duplicate product IDs',
path: ['products'],
},
)
.refine(
(schema) => {
const planIds = getAllPlanIds(schema);
return true;
})
.refine((schema) => {
// verify dupe plan ids
const planIds = schema.products.flatMap((product) =>
product.plans.map((plan) => plan.id),
);
if (new Set(planIds).size !== planIds.length) {
return {
message: 'Duplicate plan IDs',
path: ['products'],
};
}
return true;
});
return new Set(planIds).size === planIds.length;
},
{
message: 'Duplicate plan IDs',
path: ['products'],
},
);
/**
* Create and validate the billing schema
* @param config
* @param config The billing configuration
*/
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
return BillingSchema.parse(config);
}
/**
* Returns an array of billing plans based on the provided configuration.
*
* @param {Object} config - The configuration object containing product and plan information.
*/
export function getBillingPlans(config: z.infer<typeof BillingSchema>) {
return config.products.flatMap((product) => product.plans);
}
/**
* Retrieves the intervals of all plans specified in the given configuration.
*
* @param {Object} config - The billing configuration containing products and plans.
* @param config The billing configuration containing products and plans.
*/
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
return Array.from(
new Set(
config.products.flatMap((product) =>
product.plans.map((plan) => plan.interval),
),
config.products.flatMap((product) => {
const isRecurring = product.paymentType === 'recurring';
if (isRecurring) {
const plans = product.plans as z.infer<typeof RecurringPlanSchema>[];
return plans.map((plan) => plan.recurring.interval);
}
return [];
}),
),
);
).filter(Boolean);
}
export function getProductPlanPairFromId(
@@ -98,12 +223,30 @@ export function getProductPlanPairFromId(
planId: string,
) {
for (const product of config.products) {
const plan = product.plans.find((plan) => plan.id === planId);
if (plan) {
return { product, plan };
for (const plan of product.plans) {
if (plan.id === planId) {
return { product, plan };
}
}
}
throw new Error(`Plan with ID ${planId} not found`);
throw new Error('Plan not found');
}
export function getAllPlanIds(config: z.infer<typeof BillingSchema>) {
const ids: string[] = [];
for (const product of config.products) {
for (const plan of product.plans) {
ids.push(plan.id);
}
}
return ids;
}
export function isRecurringPlan(
plan: z.infer<typeof RecurringPlanSchema | typeof OneTimePlanSchema>,
): plan is z.infer<typeof RecurringPlanSchema> {
return 'recurring' in plan;
}

View File

@@ -1,3 +1,4 @@
export * from './create-billing-schema';
export * from './services/billing-strategy-provider.service';
export * from './services/billing-webhook-handler.service';
export * from './line-items-mapper';

View File

@@ -0,0 +1,51 @@
import { z } from 'zod';
import { ProductSchema, isRecurringPlan } from './create-billing-schema';
export function getLineItemsFromPlanId(
product: z.infer<typeof ProductSchema>,
planId: string,
) {
const plan = product.plans.find((plan) => plan.id === planId);
if (!plan) {
throw new Error('Plan not found');
}
const lineItems = [];
let trialDays = undefined;
if (isRecurringPlan(plan)) {
const lineItem: {
id: string;
quantity: number;
usageType?: 'metered' | 'licensed';
} = {
id: plan.id,
quantity: 1,
};
trialDays = plan.trialDays;
if (plan.recurring.usageType) {
lineItem.usageType = plan.recurring.usageType;
}
lineItems.push(lineItem);
if (plan.recurring.addOns) {
for (const addOn of plan.recurring.addOns) {
lineItems.push({
id: addOn.id,
quantity: 1,
});
}
}
}
return {
lineItems,
trialDays,
};
}

View File

@@ -1,13 +1,31 @@
import { z } from 'zod';
export const CreateBillingCheckoutSchema = z.object({
returnUrl: z.string().url(),
accountId: z.string(),
planId: z.string(),
paymentType: z.enum(['recurring', 'one-time']),
import { LineItemUsageType, PaymentType } from '../create-billing-schema';
trialPeriodDays: z.number().optional(),
customerId: z.string().optional(),
customerEmail: z.string().optional(),
});
export const CreateBillingCheckoutSchema = z
.object({
returnUrl: z.string().url(),
accountId: z.string(),
paymentType: PaymentType,
lineItems: z.array(
z.object({
id: z.string(),
quantity: z.number().int().positive(),
usageType: LineItemUsageType.optional(),
}),
),
trialDays: z.number().optional(),
customerId: z.string().optional(),
customerEmail: z.string().optional(),
})
.refine(
(schema) => {
if (schema.paymentType === 'one-time' && schema.trialDays) {
return false;
}
},
{
message: 'Trial days are only allowed for recurring payments',
path: ['trialDays'],
},
);