* Update Stripe SDK and dependencies 1. Upgrade `stripe` package from version 17.7.0 to 18.0.0 in `package.json`. 2. Update `STRIPE_API_VERSION` in `stripe-sdk.ts` to '2025-03-31.basil'. 3. Refactor `StripeWebhookHandlerService` to retrieve subscription details using Supabase client, ensuring compatibility with the new Stripe version. 4. Introduce helper methods `getPeriodStartsAt` and `getPeriodEndsAt` for better handling of subscription periods based on the Stripe API changes. These changes enhance the integration with the latest Stripe API and improve the overall reliability of the billing service. * Refactor billing payload builders to remove config dependency Removed direct dependency on `BillingConfig` in subscription payload builders. Introduced `PlanTypeMap` to streamline plan type resolutions. Updated webhook handlers and event processing functions to handle plan types more efficiently and improve extensibility. * Refactor Stripe subscription handling for improved accuracy
447 lines
11 KiB
TypeScript
447 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(),
|
|
hidden: z
|
|
.boolean({
|
|
description: 'Hide this product from being displayed to users.',
|
|
})
|
|
.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 type PlanTypeMap = Map<string, z.infer<typeof LineItemTypeSchema>>;
|
|
|
|
/**
|
|
* @name getPlanTypesMap
|
|
* @description Get all line item types for all plans in the config
|
|
* @param config
|
|
*/
|
|
export function getPlanTypesMap(
|
|
config: z.infer<typeof BillingSchema>,
|
|
): PlanTypeMap {
|
|
const planTypes: PlanTypeMap = new Map();
|
|
|
|
for (const product of config.products) {
|
|
for (const plan of product.plans) {
|
|
for (const lineItem of plan.lineItems) {
|
|
planTypes.set(lineItem.id, lineItem.type);
|
|
}
|
|
}
|
|
}
|
|
|
|
return planTypes;
|
|
}
|