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

@@ -176,7 +176,23 @@ const BillingSchema = z
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);

View File

@@ -29,6 +29,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:^",
"@kit/stripe": "workspace:^",
"@kit/lemon-squeezy": "workspace:^",
"@kit/supabase": "workspace:^",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",

View File

@@ -16,12 +16,16 @@ export class BillingGatewayFactoryService {
return new StripeBillingStrategyService();
}
case 'paddle': {
throw new Error('Paddle is not supported yet');
case 'lemon-squeezy': {
const { LemonSqueezyBillingStrategyService } = await import(
'@kit/lemon-squeezy'
);
return new LemonSqueezyBillingStrategyService();
}
case 'lemon-squeezy': {
throw new Error('Lemon Squeezy is not supported yet');
case 'paddle': {
throw new Error('Paddle is not supported yet');
}
default:

View File

@@ -0,0 +1,50 @@
{
"name": "@kit/lemon-squeezy",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "docker run --rm -it --name=stripe -v ~/.config/stripe:/root/.config/stripe stripe/stripe-cli:latest listen --forward-to http://host.docker.internal:3000/api/billing/webhook"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"peerDependencies": {
"@kit/billing": "0.1.0",
"@kit/shared": "0.1.0",
"@kit/supabase": "0.1.0",
"@kit/ui": "0.1.0"
},
"dependencies": {
"@lemonsqueezy/lemonsqueezy.js": "2.2.0"
},
"devDependencies": {
"@kit/billing": "workspace:^",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:^",
"@kit/supabase": "workspace:^",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:^"
},
"eslintConfig": {
"root": true,
"extends": [
"@kit/eslint-config/base",
"@kit/eslint-config/react"
]
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1 @@
export * from './services/lemon-squeezy-billing-strategy.service';

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const getLemonSqueezyEnv = () =>
z
.object({
secretKey: z.string().min(1),
webhooksSecret: z.string().min(1),
storeId: z.number().positive(),
})
.parse({
secretKey: process.env.LEMON_SQUEEZY_SECRET_KEY,
webhooksSecret: process.env.LEMON_SQUEEZY_WEBHOOK_SECRET,
storeId: process.env.LEMON_SQUEEZY_STORE_ID,
});

View File

@@ -0,0 +1,27 @@
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
import { z } from 'zod';
import { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
/**
* Creates a LemonSqueezy billing portal session for the given parameters.
*
* @param {object} params - The parameters required to create the billing portal session.
* @return {Promise<string>} - A promise that resolves to the URL of the customer portal.
* @throws {Error} - If no customer is found with the given customerId.
*/
export async function createLemonSqueezyBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
await initializeLemonSqueezyClient();
const customer = await getCustomer(params.customerId);
if (!customer?.data) {
throw new Error('No customer found');
}
return customer.data.data.attributes.urls.customer_portal;
}

View File

@@ -0,0 +1,82 @@
import {
NewCheckout,
createCheckout,
getCustomer,
} from '@lemonsqueezy/lemonsqueezy.js';
import { z } from 'zod';
import { CreateBillingCheckoutSchema } from '@kit/billing/schema';
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
/**
* Creates a checkout for a Lemon Squeezy product.
*
* @param {object} params - The parameters for creating the checkout.
* @return {Promise<object>} - A promise that resolves to the created Lemon Squeezy checkout.
* @throws {Error} - If no line items are found in the subscription.
*/
export async function createLemonSqueezyCheckout(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
await initializeLemonSqueezyClient();
const lineItem = params.plan.lineItems[0];
if (!lineItem) {
throw new Error('No line items found in subscription');
}
const env = getLemonSqueezyEnv();
const storeId = env.storeId;
const variantId = lineItem.id;
const urls = getUrls({
returnUrl: params.returnUrl,
});
const customer = params.customerId
? await getCustomer(params.customerId)
: null;
let customerEmail = params.customerEmail;
// if we can find an existing customer using the ID,
// we use the email from the customer object so that we can
// link the previous subscription to this one
// otherwise it will create a new customer if another email is provided (ex. a different team member)
if (customer?.data) {
customerEmail = customer.data.data.attributes.email;
}
const newCheckout: NewCheckout = {
checkoutOptions: {
embed: true,
media: true,
logo: true,
},
checkoutData: {
email: customerEmail,
custom: {
account_id: params.accountId,
},
},
productOptions: {
redirectUrl: urls.return_url,
},
expiresAt: null,
preview: true,
testMode: process.env.NODE_ENV !== 'production',
};
return createCheckout(storeId, variantId, newCheckout);
}
function getUrls(params: { returnUrl: string }) {
const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
return {
return_url: returnUrl,
};
}

View File

@@ -0,0 +1,89 @@
import {
cancelSubscription,
createUsageRecord,
getCheckout,
} from '@lemonsqueezy/lemonsqueezy.js';
import 'server-only';
import { z } from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import {
CancelSubscriptionParamsSchema,
CreateBillingCheckoutSchema,
CreateBillingPortalSessionSchema,
ReportBillingUsageSchema,
RetrieveCheckoutSessionSchema,
} from '@kit/billing/schema';
import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-billing-portal-session';
import { createLemonSqueezyCheckout } from './create-lemon-squeezy-checkout';
export class LemonSqueezyBillingStrategyService
implements BillingStrategyProviderService
{
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
const { data: response } = await createLemonSqueezyCheckout(params);
if (!response?.data.id) {
throw new Error('Failed to create checkout session');
}
return { checkoutToken: response.data.id };
}
async createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
const url = await createLemonSqueezyBillingPortalSession(params);
if (!url) {
throw new Error('Failed to create billing portal session');
}
return { url };
}
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
) {
await cancelSubscription(params.subscriptionId);
return { success: true };
}
async retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
) {
const session = await getCheckout(params.sessionId);
if (!session.data) {
throw new Error('Failed to retrieve checkout session');
}
const data = session.data.data;
return {
checkoutToken: data.id,
isSessionOpen: false,
status: 'complete' as const,
customer: {
email: data.attributes.checkout_data.email,
},
};
}
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
const { error } = await createUsageRecord({
quantity: params.usage.quantity,
subscriptionItemId: params.subscriptionId,
});
if (error) {
throw new Error('Failed to report usage');
}
return { success: true };
}
}

View File

@@ -0,0 +1,26 @@
import 'server-only';
import { Logger } from '@kit/shared/logger';
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
/**
* @description Initialize the Lemon Squeezy client
*/
export async function initializeLemonSqueezyClient() {
const { lemonSqueezySetup } = await import('@lemonsqueezy/lemonsqueezy.js');
const env = getLemonSqueezyEnv();
lemonSqueezySetup({
apiKey: env.secretKey,
onError(error) {
Logger.error(
{
name: `billing.lemon-squeezy`,
error: error.message,
},
'Error in Lemon Squeezy SDK',
);
},
});
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,9 +0,0 @@
enum StripeWebhooks {
AsyncPaymentSuccess = 'checkout.session.async_payment_succeeded',
Completed = 'checkout.session.completed',
AsyncPaymentFailed = 'checkout.session.async_payment_failed',
SubscriptionDeleted = 'customer.subscription.deleted',
SubscriptionUpdated = 'customer.subscription.updated',
}
export default StripeWebhooks;