Add Lemon Squeezy Billing System

This commit is contained in:
giancarlo
2024-04-01 21:43:18 +08:00
parent 84a4b45bcd
commit 8784a40a69
59 changed files with 424 additions and 74 deletions

View File

@@ -0,0 +1,261 @@
import { z } from 'zod';
const BillingIntervalSchema = z.enum(['month', 'year']);
const LineItemTypeSchema = z.enum(['base', '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.',
})
.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(),
included: z
.number({
description: 'Included amount of the line item. Displayed to the user.',
})
.optional(),
})
.refine((data) => data.type !== 'metered' || (data.unit && data.included), {
message: 'Metered line items must have a unit and included amount',
path: ['type', 'unit', 'included'],
});
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),
trialPeriod: 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'],
},
);
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()).nonempty(),
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 {
message: 'Only one line item is allowed for Lemon Squeezy',
path: ['products', 'plans'],
};
}
}
}
}
return true;
});
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));
}
export function getBaseLineItem(
config: z.infer<typeof BillingSchema>,
planId: string,
) {
for (const product of config.products) {
for (const plan of product.plans) {
if (plan.id === planId) {
const item = plan.lineItems.find((item) => item.type === 'base');
if (item) {
return item;
}
}
}
}
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');
}

View File

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

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const CancelSubscriptionParamsSchema = z.object({
subscriptionId: z.string(),
invoiceNow: z.boolean().optional(),
});

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const CreateBillingPortalSessionSchema = z.object({
returnUrl: z.string().url(),
customerId: z.string().min(1),
});

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
import { PlanSchema } from '../create-billing-schema';
export const CreateBillingCheckoutSchema = z.object({
returnUrl: z.string().url(),
accountId: z.string().uuid(),
plan: PlanSchema,
trialDays: z.number().optional(),
customerId: z.string().optional(),
customerEmail: z.string().email().optional(),
});

View File

@@ -0,0 +1,5 @@
export * from './create-billing-checkout.schema';
export * from './create-biling-portal-session.schema';
export * from './retrieve-checkout-session.schema';
export * from './cancel-subscription-params.schema';
export * from './report-billing-usage.schema';

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const ReportBillingUsageSchema = z.object({
subscriptionId: z.string(),
usage: z.object({
quantity: z.number(),
}),
});

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
export const RetrieveCheckoutSessionSchema = z.object({
sessionId: z.string(),
});

View File

@@ -0,0 +1,47 @@
import { z } from 'zod';
import {
CancelSubscriptionParamsSchema,
CreateBillingCheckoutSchema,
CreateBillingPortalSessionSchema,
RetrieveCheckoutSessionSchema,
} from '../schema';
import { ReportBillingUsageSchema } from '../schema';
export abstract class BillingStrategyProviderService {
abstract createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
): Promise<{
url: string;
}>;
abstract retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
): Promise<{
checkoutToken: string | null;
status: 'complete' | 'expired' | 'open';
isSessionOpen: boolean;
customer: {
email: string | null;
};
}>;
abstract createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
): Promise<{
checkoutToken: string;
}>;
abstract cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
): Promise<{
success: boolean;
}>;
abstract reportUsage(
params: z.infer<typeof ReportBillingUsageSchema>,
): Promise<{
success: boolean;
}>;
}

View File

@@ -0,0 +1,44 @@
import { Database } from '@kit/supabase/database';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
/**
* @name BillingWebhookHandlerService
* @description Represents an abstract class for handling billing webhook events.
*/
export abstract class BillingWebhookHandlerService {
// Verifies the webhook signature - should throw an error if the signature is invalid
abstract verifyWebhookSignature(request: Request): Promise<unknown>;
abstract handleWebhookEvent(
event: unknown,
params: {
// this method is called when a checkout session is completed
onCheckoutSessionCompleted: (
subscription: UpsertSubscriptionParams | UpsertOrderParams,
customerId: string,
) => Promise<unknown>;
// this method is called when a subscription is updated
onSubscriptionUpdated: (
subscription: UpsertSubscriptionParams,
customerId: string,
) => Promise<unknown>;
// this method is called when a subscription is deleted
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
// this method is called when a payment is succeeded. This is used for
// one-time payments
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
// this method is called when a payment is failed. This is used for
// one-time payments
onPaymentFailed: (sessionId: string) => Promise<unknown>;
},
): Promise<unknown>;
}