B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,5 @@
# Billing / Lemon Squeezy - @kit/lemon-squeezy
This package is responsible for handling all billing related operations using Lemon Squeezy.
Please refer to the [documentation](https://makerkit.dev/docs/next-supabase-turbo/billing/lemon-squeezy).

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

17
packages/billing/lemon-squeezy/node_modules/.bin/next generated vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@"
else
exec node "$basedir/../next/dist/bin/next" "$@"
fi

View File

@@ -0,0 +1 @@
../../../core

View File

@@ -0,0 +1 @@
../../../../../tooling/eslint

View File

@@ -0,0 +1 @@
../../../../../tooling/prettier

1
packages/billing/lemon-squeezy/node_modules/@kit/shared generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../shared

View File

@@ -0,0 +1 @@
../../../../supabase

View File

@@ -0,0 +1 @@
../../../../../tooling/typescript

1
packages/billing/lemon-squeezy/node_modules/@kit/ui generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../ui

View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@lemonsqueezy+lemonsqueezy.js@4.0.0/node_modules/@lemonsqueezy/lemonsqueezy.js

View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react

1
packages/billing/lemon-squeezy/node_modules/next generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next

1
packages/billing/lemon-squeezy/node_modules/react generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/react@19.1.0/node_modules/react

1
packages/billing/lemon-squeezy/node_modules/zod generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod

View File

@@ -0,0 +1,39 @@
{
"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"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"dependencies": {
"@lemonsqueezy/lemonsqueezy.js": "4.0.0"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.4",
"next": "15.3.2",
"react": "19.1.0",
"zod": "^3.24.4"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1 @@
export * from './lemon-squeezy-embedded-checkout';

View File

@@ -0,0 +1,68 @@
'use client';
import { useEffect } from 'react';
interface LemonSqueezyWindow extends Window {
createLemonSqueezy: () => void;
LemonSqueezy: {
Setup: (options: {
eventHandler: (event: { event: string } | string) => void;
}) => void;
Refresh: () => void;
Url: {
Open: (url: string) => void;
Close: () => void;
};
};
}
export function LemonSqueezyEmbeddedCheckout(props: {
checkoutToken: string;
onClose?: () => void;
}) {
useLoadScript(props.checkoutToken, props.onClose);
return null;
}
function useLoadScript(
checkoutToken: string,
onClose: (() => void) | undefined,
) {
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://app.lemonsqueezy.com/js/lemon.js';
script.onload = () => {
const win = window as unknown as LemonSqueezyWindow;
win.createLemonSqueezy();
win.LemonSqueezy.Url.Open(checkoutToken);
if (onClose) {
win.LemonSqueezy.Setup({
eventHandler: (event) => {
if (typeof event === 'string') {
if (event === 'close') {
onClose();
}
return;
}
if (event.event === 'PaymentMethodUpdate.Closed') {
onClose();
}
},
});
}
};
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, [checkoutToken, onClose]);
}

View File

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

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
/**
* @name getLemonSqueezyEnv
* @description Get the Lemon Squeezy environment variables.
* It will throw an error if any of the required variables are missing.
*/
export const getLemonSqueezyEnv = () =>
z
.object({
secretKey: z
.string({
description: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`,
})
.min(1),
webhooksSecret: z
.string({
description: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`,
})
.min(1)
.max(40),
storeId: z
.string({
description: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`,
})
.min(1),
})
.parse({
secretKey: process.env.LEMON_SQUEEZY_SECRET_KEY,
webhooksSecret: process.env.LEMON_SQUEEZY_SIGNING_SECRET,
storeId: process.env.LEMON_SQUEEZY_STORE_ID,
});

View File

@@ -0,0 +1,24 @@
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
import { z } from 'zod';
import type { 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.
*/
export async function createLemonSqueezyBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
await initializeLemonSqueezyClient();
const { data, error } = await getCustomer(params.customerId);
return {
data: data?.data.attributes.urls.customer_portal,
error,
};
}

View File

@@ -0,0 +1,76 @@
import {
NewCheckout,
createCheckout,
getCustomer,
} from '@lemonsqueezy/lemonsqueezy.js';
import { z } from 'zod';
import type { 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.
*/
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 = Number(env.storeId);
const variantId = Number(lineItem.id);
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,
discount: params.enableDiscountField ?? false,
},
checkoutData: {
email: customerEmail,
variantQuantities: params.variantQuantities.map((item) => {
return {
quantity: item.quantity,
variantId: Number(item.variantId),
};
}),
custom: {
account_id: params.accountId,
},
},
productOptions: {
redirectUrl: params.returnUrl,
// only show the selected variant ID
enabledVariants: [variantId],
},
expiresAt: null,
preview: true,
testMode: process.env.NODE_ENV !== 'production',
};
return createCheckout(storeId, variantId, newCheckout);
}

View File

@@ -0,0 +1,487 @@
import 'server-only';
import {
cancelSubscription,
createUsageRecord,
getCheckout,
getSubscription,
getVariant,
listUsageRecords,
updateSubscriptionItem,
} from '@lemonsqueezy/lemonsqueezy.js';
import { z } from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import type {
CancelSubscriptionParamsSchema,
CreateBillingCheckoutSchema,
CreateBillingPortalSessionSchema,
QueryBillingUsageSchema,
ReportBillingUsageSchema,
RetrieveCheckoutSessionSchema,
UpdateSubscriptionParamsSchema,
} from '@kit/billing/schema';
import { getLogger } from '@kit/shared/logger';
import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-billing-portal-session';
import { createLemonSqueezyCheckout } from './create-lemon-squeezy-checkout';
import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service';
/**
* @name LemonSqueezyBillingStrategyService
* @description This class is used to create a billing strategy for Lemon Squeezy
*/
export class LemonSqueezyBillingStrategyService
implements BillingStrategyProviderService
{
private readonly namespace = 'billing.lemon-squeezy';
/**
* @name createCheckoutSession
* @description Creates a checkout session for a customer
* @param params
*/
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
...params,
};
logger.info(ctx, 'Creating checkout session...');
const { data: response, error } = await createLemonSqueezyCheckout(params);
if (error ?? !response?.data.id) {
console.log(error);
logger.error(
{
...ctx,
error: error?.message,
},
'Failed to create checkout session',
);
throw new Error('Failed to create checkout session');
}
logger.info(ctx, 'Checkout session created successfully');
return {
checkoutToken: response.data.attributes.url,
};
}
/**
* @name createBillingPortalSession
* @description Creates a billing portal session for a customer
* @param params
*/
async createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
...params,
};
logger.info(ctx, 'Creating billing portal session...');
const { data, error } =
await createLemonSqueezyBillingPortalSession(params);
if (error ?? !data) {
logger.error(
{
...ctx,
error: error?.message,
},
'Failed to create billing portal session',
);
throw new Error('Failed to create billing portal session');
}
logger.info(ctx, 'Billing portal session created successfully');
return { url: data };
}
/**
* @name cancelSubscription
* @description Cancels a subscription
* @param params
*/
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionId: params.subscriptionId,
};
logger.info(ctx, 'Cancelling subscription...');
try {
const { error } = await cancelSubscription(params.subscriptionId);
if (error) {
logger.error(
{
...ctx,
error: error.message,
},
'Failed to cancel subscription',
);
throw new Error('Failed to cancel subscription');
}
logger.info(ctx, 'Subscription cancelled successfully');
return { success: true };
} catch (error) {
logger.info(
{
...ctx,
error: (error as Error)?.message,
},
`Failed to cancel subscription. It may have already been cancelled on the user's end.`,
);
return { success: false };
}
}
/**
* @name retrieveCheckoutSession
* @description Retrieves a checkout session
* @param params
*/
async retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
sessionId: params.sessionId,
};
logger.info(ctx, 'Retrieving checkout session...');
const { data: session, error } = await getCheckout(params.sessionId);
if (error ?? !session?.data) {
logger.error(
{
...ctx,
error: error?.message,
},
'Failed to retrieve checkout session',
);
throw new Error('Failed to retrieve checkout session');
}
logger.info(ctx, 'Checkout session retrieved successfully');
const { id, attributes } = session.data;
return {
checkoutToken: id,
isSessionOpen: false,
status: 'complete' as const,
customer: {
email: attributes.checkout_data.email,
},
};
}
/**
* @name reportUsage
* @description Reports the usage of the billing
* @param params
*/
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionItemId: params.id,
};
logger.info(ctx, 'Reporting usage...');
const { error } = await createUsageRecord({
quantity: params.usage.quantity,
subscriptionItemId: params.id,
action: params.usage.action,
});
if (error) {
logger.error(
{
...ctx,
error,
},
'Failed to report usage',
);
throw new Error('Failed to report usage');
}
logger.info(ctx, 'Usage reported successfully');
return { success: true };
}
/**
* @name queryUsage
* @description Queries the usage of the metered billing
* @param params
*/
async queryUsage(
params: z.infer<typeof QueryBillingUsageSchema>,
): Promise<{ value: number }> {
const logger = await getLogger();
const ctx = {
name: this.namespace,
...params,
};
if (!('page' in params.filter)) {
logger.error(ctx, `Page parameters are required for Lemon Squeezy`);
throw new Error('Page is required');
}
logger.info(ctx, 'Querying usage...');
const records = await listUsageRecords({
filter: {
subscriptionItemId: params.id,
},
page: params.filter,
});
if (records.error) {
logger.error(
{
...ctx,
error: records.error,
},
'Failed to query usage',
);
throw new Error('Failed to query usage');
}
if (!records.data) {
return {
value: 0,
};
}
const value = records.data.data.reduce(
(acc, record) => acc + record.attributes.quantity,
0,
);
logger.info(
{
...ctx,
value,
},
'Usage queried successfully',
);
return { value };
}
/**
* @name queryUsage
* @description Queries the usage of the metered billing
* @param params
*/
async updateSubscriptionItem(
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
...params,
};
logger.info(ctx, 'Updating subscription...');
const { error } = await updateSubscriptionItem(params.subscriptionItemId, {
quantity: params.quantity,
});
if (error) {
logger.error(
{
...ctx,
error,
},
'Failed to update subscription',
);
throw new Error('Failed to update subscription');
}
logger.info(ctx, 'Subscription updated successfully');
return { success: true };
}
async getSubscription(subscriptionId: string) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionId,
};
logger.info(ctx, 'Retrieving subscription...');
const { error, data } = await getSubscription(subscriptionId);
if (error) {
logger.error(
{
...ctx,
error,
},
'Failed to retrieve subscription',
);
throw new Error('Failed to retrieve subscription');
}
if (!data) {
logger.error(
{
...ctx,
},
'Subscription not found',
);
throw new Error('Subscription not found');
}
logger.info(ctx, 'Subscription retrieved successfully');
const payloadBuilderService =
createLemonSqueezySubscriptionPayloadBuilderService();
const subscription = data.data.attributes;
const customerId = subscription.customer_id.toString();
const status = subscription.status;
const variantId = subscription.variant_id;
const productId = subscription.product_id;
const createdAt = subscription.created_at;
const endsAt = subscription.ends_at;
const renewsAt = subscription.renews_at;
const trialEndsAt = subscription.trial_ends_at;
const intervalCount = subscription.billing_anchor;
const interval = intervalCount === 1 ? 'month' : 'year';
const subscriptionItemId =
data.data.attributes.first_subscription_item?.id.toString() as string;
const lineItems = [
{
id: subscriptionItemId.toString(),
product: productId.toString(),
variant: variantId.toString(),
quantity: subscription.first_subscription_item?.quantity ?? 1,
// not anywhere in the API
priceAmount: 0,
// we cannot retrieve this from the API, user should retrieve from the billing configuration if needed
type: '' as never,
},
];
return payloadBuilderService.build({
customerId,
id: subscriptionId,
// not in the API
accountId: '',
lineItems,
status,
interval,
intervalCount,
// not in the API
currency: '',
periodStartsAt: new Date(createdAt).getTime(),
periodEndsAt: new Date(renewsAt ?? endsAt).getTime(),
cancelAtPeriodEnd: subscription.cancelled,
trialStartsAt: trialEndsAt ? new Date(createdAt).getTime() : null,
trialEndsAt: trialEndsAt ? new Date(trialEndsAt).getTime() : null,
});
}
/**
* @name queryUsage
* @description Queries the usage of the metered billing
* @param planId
*/
async getPlanById(planId: string) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
planId,
};
logger.info(ctx, 'Retrieving plan by ID...');
const { error, data } = await getVariant(planId);
if (error) {
logger.error(
{
...ctx,
error,
},
'Failed to retrieve plan by ID',
);
throw new Error('Failed to retrieve plan by ID');
}
if (!data) {
logger.error(
{
...ctx,
},
'Plan not found',
);
throw new Error('Plan not found');
}
logger.info(ctx, 'Plan retrieved successfully');
const attrs = data.data.attributes;
return {
id: data.data.id,
name: attrs.name,
interval: attrs.interval ?? '',
amount: attrs.price,
};
}
}

View File

@@ -0,0 +1,27 @@
import 'server-only';
import { getLogger } 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();
const logger = await getLogger();
lemonSqueezySetup({
apiKey: env.secretKey,
onError(error) {
logger.error(
{
name: `billing.lemon-squeezy`,
error: error.message,
},
'Encountered an error using the Lemon Squeezy SDK',
);
},
});
}

View File

@@ -0,0 +1,129 @@
import { BillingConfig } from '@kit/billing';
import { UpsertSubscriptionParams } from '@kit/billing/types';
type SubscriptionStatus =
| 'on_trial'
| 'active'
| 'cancelled'
| 'paused'
| 'expired'
| 'unpaid'
| 'past_due';
/**
* @name createLemonSqueezySubscriptionPayloadBuilderService
* @description Create a new instance of the `LemonSqueezySubscriptionPayloadBuilderService` class
*/
export function createLemonSqueezySubscriptionPayloadBuilderService() {
return new LemonSqueezySubscriptionPayloadBuilderService();
}
/**
* @name LemonSqueezySubscriptionPayloadBuilderService
* @description This class is used to build the subscription payload for Lemon Squeezy
*/
class LemonSqueezySubscriptionPayloadBuilderService {
private config?: BillingConfig;
/**
* @name build
* @description Build the subscription payload for Lemon Squeezy
* @param params
*/
build<
LineItem extends {
id: string;
quantity: number;
product: string;
variant: string;
priceAmount: number;
type: 'flat' | 'per_seat' | 'metered';
},
>(params: {
id: string;
accountId: string;
customerId: string;
lineItems: LineItem[];
interval: string;
intervalCount: number;
status: string;
currency: string;
cancelAtPeriodEnd: boolean;
periodStartsAt: number;
periodEndsAt: number;
trialStartsAt: number | null;
trialEndsAt: number | null;
}): UpsertSubscriptionParams {
const canceledAtPeriodEnd =
params.status === 'cancelled' && params.cancelAtPeriodEnd;
const active =
params.status === 'active' ||
params.status === 'trialing' ||
canceledAtPeriodEnd;
const lineItems = params.lineItems.map((item) => {
const quantity = item.quantity ?? 1;
return {
id: item.id,
subscription_item_id: item.id,
quantity,
interval: params.interval,
interval_count: params.intervalCount,
subscription_id: params.id,
product_id: item.product,
variant_id: item.variant,
price_amount: item.priceAmount,
type: item.type,
};
});
// otherwise we are updating a subscription
// and we only need to return the update payload
return {
target_subscription_id: params.id,
target_account_id: params.accountId,
target_customer_id: params.customerId,
billing_provider: 'lemon-squeezy',
status: this.getSubscriptionStatus(params.status as SubscriptionStatus),
line_items: lineItems,
active,
currency: params.currency,
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: params.trialStartsAt
? getISOString(params.trialStartsAt)
: undefined,
trial_ends_at: params.trialEndsAt
? getISOString(params.trialEndsAt)
: undefined,
};
}
private getSubscriptionStatus(status: SubscriptionStatus) {
switch (status) {
case 'active':
return 'active';
case 'cancelled':
return 'canceled';
case 'paused':
return 'paused';
case 'on_trial':
return 'trialing';
case 'past_due':
return 'past_due';
case 'unpaid':
return 'unpaid';
case 'expired':
return 'past_due';
default:
return 'active';
}
}
}
function getISOString(date: number | null) {
return date ? new Date(date).toISOString() : undefined;
}

View File

@@ -0,0 +1,504 @@
import {
getOrder,
getSubscription,
getVariant,
} from '@lemonsqueezy/lemonsqueezy.js';
import { BillingWebhookHandlerService, type PlanTypeMap } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { Database, Enums } from '@kit/supabase/database';
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
import { OrderWebhook } from '../types/order-webhook';
import { SubscriptionInvoiceWebhook } from '../types/subscription-invoice-webhook';
import { SubscriptionWebhook } from '../types/subscription-webhook';
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service';
import { createHmac } from './verify-hmac';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'] & {
line_items: Array<LineItem>;
};
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
type BillingProvider = Enums<'billing_provider'>;
interface LineItem {
id: string;
quantity: number;
subscription_id: string;
subscription_item_id: string;
product_id: string;
variant_id: string;
price_amount: number | null | undefined;
interval: string;
interval_count: number;
type: 'flat' | 'metered' | 'per_seat' | undefined;
}
type OrderStatus = 'pending' | 'failed' | 'paid' | 'refunded';
export class LemonSqueezyWebhookHandlerService
implements BillingWebhookHandlerService
{
private readonly provider: BillingProvider = 'lemon-squeezy';
private readonly namespace = 'billing.lemon-squeezy';
constructor(private readonly planTypesMap: PlanTypeMap) {}
/**
* @description Verifies the webhook signature - should throw an error if the signature is invalid
*/
async verifyWebhookSignature(request: Request) {
const logger = await getLogger();
// get the event name and signature from the headers
const eventName = request.headers.get('x-event-name');
const signature = request.headers.get('x-signature') as string;
// clone the request so we can read the body twice
const reqClone = request.clone();
const body = (await request.json()) as SubscriptionWebhook | OrderWebhook;
const rawBody = await reqClone.text();
// if no signature is found, throw an error
if (!signature) {
logger.error(
{
eventName,
},
`Signature header not found`,
);
throw new Error('Signature header not found');
}
const isValid = await isSigningSecretValid(rawBody, signature);
// if the signature is invalid, throw an error
if (!isValid) {
logger.error(
{
eventName,
},
`Signing secret is invalid`,
);
throw new Error('Signing secret is invalid');
}
return body;
}
async handleWebhookEvent(
event: OrderWebhook | SubscriptionWebhook | SubscriptionInvoiceWebhook,
params: {
onCheckoutSessionCompleted: (
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>;
onSubscriptionUpdated: (
data: UpsertSubscriptionParams,
) => Promise<unknown>;
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
onPaymentFailed: (sessionId: string) => Promise<unknown>;
onInvoicePaid: (
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>;
onEvent?: (event: unknown) => Promise<unknown>;
},
) {
const eventName = event.meta.event_name;
switch (eventName) {
case 'order_created': {
const result = await this.handleOrderCompleted(
event as OrderWebhook,
params.onCheckoutSessionCompleted,
);
// handle user-supplied handler
if (params.onEvent) {
await params.onEvent(event);
}
return result;
}
case 'subscription_created': {
const result = await this.handleSubscriptionCreatedEvent(
event as SubscriptionWebhook,
params.onSubscriptionUpdated,
);
// handle user-supplied handler
if (params.onEvent) {
await params.onEvent(event);
}
return result;
}
case 'subscription_updated': {
const result = await this.handleSubscriptionUpdatedEvent(
event as SubscriptionWebhook,
params.onSubscriptionUpdated,
);
// handle user-supplied handler
if (params.onEvent) {
await params.onEvent(event);
}
return result;
}
case 'subscription_expired': {
const result = await this.handleSubscriptionDeletedEvent(
event as SubscriptionWebhook,
params.onSubscriptionDeleted,
);
// handle user-supplied handler
if (params.onEvent) {
await params.onEvent(event);
}
return result;
}
case 'subscription_payment_success': {
const result = await this.handleInvoicePaid(
event as SubscriptionInvoiceWebhook,
params.onInvoicePaid,
);
// handle user-supplied handler
if (params.onEvent) {
await params.onEvent(event);
}
return result;
}
default: {
// handle user-supplied handler
if (params.onEvent) {
return params.onEvent(event);
}
const logger = await getLogger();
logger.debug(
{
eventType: eventName,
name: this.namespace,
},
`Unhandled Lemon Squeezy event type`,
);
return;
}
}
}
private async handleOrderCompleted(
event: OrderWebhook,
onCheckoutCompletedCallback: (
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>,
) {
await initializeLemonSqueezyClient();
// we fetch the variant to check if the order is a subscription
// if Lemon Squeezy was able to discriminate between orders and subscriptions
// it would be better to use that information. But for now, we need to fetch the variant
const variantId = event.data.attributes.first_order_item.variant_id;
const { data } = await getVariant(variantId);
// if the order is a subscription
// we handle it in the subscription created event
if (data?.data.attributes.is_subscription) {
return;
}
const attrs = event.data.attributes;
const orderId = attrs.first_order_item.order_id;
const accountId = event.meta.custom_data.account_id.toString();
const customerId = attrs.customer_id.toString();
const status = this.getOrderStatus(attrs.status as OrderStatus);
const payload: UpsertOrderParams = {
target_account_id: accountId,
target_customer_id: customerId,
target_order_id: orderId.toString(),
billing_provider: this.provider,
status,
currency: attrs.currency,
total_amount: attrs.first_order_item.price,
line_items: [
{
id: attrs.first_order_item.id.toString(),
product_id: attrs.first_order_item.product_id.toString(),
variant_id: attrs.first_order_item.variant_id.toString(),
price_amount: attrs.first_order_item.price,
quantity: 1,
type: this.getLineItemType(attrs.first_order_item.variant_id),
},
],
};
return onCheckoutCompletedCallback(payload);
}
private async handleSubscriptionCreatedEvent(
event: SubscriptionWebhook,
onSubscriptionCreatedEvent: (
data: UpsertSubscriptionParams,
) => Promise<unknown>,
) {
await initializeLemonSqueezyClient();
const subscription = event.data.attributes;
const orderId = subscription.order_id;
const subscriptionId = event.data.id;
const accountId = event.meta.custom_data.account_id;
const customerId = subscription.customer_id.toString();
const status = subscription.status;
const variantId = subscription.variant_id;
const productId = subscription.product_id;
const createdAt = subscription.created_at;
const endsAt = subscription.ends_at;
const renewsAt = subscription.renews_at;
const trialEndsAt = subscription.trial_ends_at;
const { data: order, error } = await getOrder(orderId);
if (error ?? !order) {
const logger = await getLogger();
logger.warn(
{
orderId,
subscriptionId,
error,
name: this.namespace,
},
'Failed to fetch order',
);
throw new Error('Failed to fetch order');
}
const priceAmount = order?.data.attributes.first_order_item.price ?? 0;
const firstSubscriptionItem = subscription.first_subscription_item;
const lineItems = [
{
id: firstSubscriptionItem.id.toString(),
product: productId.toString(),
variant: variantId.toString(),
quantity: firstSubscriptionItem.quantity,
priceAmount,
type: this.getLineItemType(variantId),
},
];
const { interval, intervalCount } = getSubscriptionIntervalType(renewsAt);
const payloadBuilderService =
createLemonSqueezySubscriptionPayloadBuilderService();
const payload = payloadBuilderService.build({
customerId,
id: subscriptionId,
accountId,
lineItems,
status,
interval,
intervalCount,
currency: order.data.attributes.currency,
periodStartsAt: new Date(createdAt).getTime(),
periodEndsAt: new Date(renewsAt ?? endsAt).getTime(),
cancelAtPeriodEnd: subscription.cancelled,
trialStartsAt: trialEndsAt ? new Date(createdAt).getTime() : null,
trialEndsAt: trialEndsAt ? new Date(trialEndsAt).getTime() : null,
});
return onSubscriptionCreatedEvent(payload);
}
private handleSubscriptionUpdatedEvent(
event: SubscriptionWebhook,
onSubscriptionUpdatedCallback: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>,
) {
return this.handleSubscriptionCreatedEvent(
event,
onSubscriptionUpdatedCallback,
);
}
private handleSubscriptionDeletedEvent(
subscription: SubscriptionWebhook,
onSubscriptionDeletedCallback: (subscriptionId: string) => Promise<unknown>,
) {
// Here we don't need to do anything, so we just return the callback
return onSubscriptionDeletedCallback(subscription.data.id);
}
private async handleInvoicePaid(
event: SubscriptionInvoiceWebhook,
onInvoicePaidCallback: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>,
) {
await initializeLemonSqueezyClient();
const attrs = event.data.attributes;
const subscriptionId = event.data.id;
const accountId = event.meta.custom_data.account_id;
const customerId = attrs.customer_id.toString();
const status = attrs.status;
const createdAt = attrs.created_at;
const { data: subscriptionResponse } =
await getSubscription(subscriptionId);
const subscription = subscriptionResponse?.data.attributes;
if (!subscription) {
const logger = await getLogger();
logger.error(
{
subscriptionId,
accountId,
name: this.namespace,
},
'Failed to fetch subscription',
);
return;
}
const variantId = subscription.variant_id;
const productId = subscription.product_id;
const endsAt = subscription.ends_at;
const renewsAt = subscription.renews_at;
const trialEndsAt = subscription.trial_ends_at;
const intervalCount = subscription.billing_anchor;
const interval = intervalCount === 1 ? 'month' : 'year';
const payloadBuilderService =
createLemonSqueezySubscriptionPayloadBuilderService();
const lineItemType = this.getLineItemType(variantId);
const lineItems = [
{
id: subscription.order_item_id.toString(),
product: productId.toString(),
variant: variantId.toString(),
quantity: subscription.first_subscription_item?.quantity ?? 1,
priceAmount: attrs.total,
type: lineItemType,
},
];
const payload = payloadBuilderService.build({
customerId,
id: subscriptionId,
accountId,
lineItems,
status,
interval,
intervalCount,
currency: attrs.currency,
periodStartsAt: new Date(createdAt).getTime(),
periodEndsAt: new Date(renewsAt ?? endsAt).getTime(),
cancelAtPeriodEnd: subscription.cancelled,
trialStartsAt: trialEndsAt ? new Date(createdAt).getTime() : null,
trialEndsAt: trialEndsAt ? new Date(trialEndsAt).getTime() : null,
});
return onInvoicePaidCallback(payload);
}
private getLineItemType(variantId: number) {
const type = this.planTypesMap.get(variantId.toString());
if (!type) {
console.warn(
{
variantId,
},
'Line item type not found. Will be defaulted to "flat"',
);
return 'flat' as const;
}
return type;
}
private getOrderStatus(status: OrderStatus) {
switch (status) {
case 'paid':
return 'succeeded';
case 'pending':
return 'pending';
case 'failed':
return 'failed';
case 'refunded':
return 'failed';
default:
return 'pending';
}
}
}
async function isSigningSecretValid(rawBody: string, signatureHeader: string) {
const { webhooksSecret } = getLemonSqueezyEnv();
const { hex: digest } = await createHmac({
key: webhooksSecret,
data: rawBody,
});
const signature = Buffer.from(signatureHeader, 'utf8');
return timingSafeEqual(digest, signature);
}
function timingSafeEqual(digest: string, signature: Buffer) {
return digest.toString() === signature.toString();
}
function getSubscriptionIntervalType(renewsAt: string) {
const renewalDate = new Date(renewsAt);
const currentDate = new Date();
// Calculate the difference in milliseconds
const timeDifference = renewalDate.getTime() - currentDate.getTime();
// Convert milliseconds to days
const daysDifference = timeDifference / (1000 * 3600 * 24);
if (daysDifference <= 32) {
return {
interval: 'month',
intervalCount: 1,
};
}
return {
interval: 'year',
intervalCount: 1,
};
}

View File

@@ -0,0 +1,28 @@
function bufferToHex(buffer: ArrayBuffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export async function createHmac({ key, data }: { key: string; data: string }) {
const encoder = new TextEncoder();
const encodedKey = encoder.encode(key);
const encodedData = encoder.encode(data);
const hmacKey = await crypto.subtle.importKey(
'raw',
encodedKey,
{
name: 'HMAC',
hash: 'SHA-256',
},
true,
['sign', 'verify'],
);
const signature = await crypto.subtle.sign('HMAC', hmacKey, encodedData);
const hex = bufferToHex(signature);
return { hex };
}

View File

@@ -0,0 +1,104 @@
export type OrderWebhook = {
meta: {
event_name: string;
custom_data: {
account_id: number;
};
};
data: {
type: string;
id: string;
attributes: {
store_id: number;
customer_id: number;
identifier: string;
order_number: number;
user_name: string;
user_email: string;
currency: string;
currency_rate: string;
subtotal: number;
discount_total: number;
tax: number;
total: number;
subtotal_usd: number;
discount_total_usd: number;
tax_usd: number;
total_usd: number;
tax_name: string;
tax_rate: string;
status: string;
status_formatted: string;
refunded: boolean;
refunded_at: string | null;
subtotal_formatted: string;
discount_total_formatted: string;
tax_formatted: string;
total_formatted: string;
first_order_item: {
id: number;
order_id: number;
product_id: number;
variant_id: number;
product_name: string;
variant_name: string;
price: number;
created_at: string;
updated_at: string;
deleted_at: string | null;
test_mode: boolean;
};
urls: {
receipt: string;
};
created_at: string;
updated_at: string;
};
relationships: {
store: {
links: {
related: string;
self: string;
};
};
customer: {
links: {
related: string;
self: string;
};
};
'order-items': {
links: {
related: string;
self: string;
};
};
subscriptions: {
links: {
related: string;
self: string;
};
};
'license-keys': {
links: {
related: string;
self: string;
};
};
'discount-redemptions': {
links: {
related: string;
self: string;
};
};
};
links: {
self: string;
};
};
};

View File

@@ -0,0 +1,53 @@
export interface SubscriptionInvoiceWebhook {
meta: Meta;
data: Data;
}
interface Data {
type: string;
id: string;
attributes: Attributes;
}
interface Meta {
event_name: string;
custom_data: {
account_id: string;
};
}
interface Attributes {
store_id: number;
subscription_id: number;
customer_id: number;
user_name: string;
user_email: string;
billing_reason: string;
card_brand: string;
card_last_four: string;
currency: string;
currency_rate: string;
status: string;
status_formatted: string;
refunded: boolean;
refunded_at: string | null;
subtotal: number;
discount_total: number;
tax: number;
tax_inclusive: boolean;
total: number;
subtotal_usd: number;
discount_total_usd: number;
tax_usd: number;
total_usd: number;
subtotal_formatted: string;
discount_total_formatted: string;
tax_formatted: string;
total_formatted: string;
urls: {
invoice_url: string;
};
created_at: string;
updated_at: string;
test_mode: boolean;
}

View File

@@ -0,0 +1,90 @@
export interface SubscriptionWebhook {
meta: Meta;
data: Data;
}
interface Data {
type: string;
id: string;
attributes: Attributes;
relationships: Relationships;
links: DataLinks;
}
interface Attributes {
store_id: number;
customer_id: number;
order_id: number;
order_item_id: number;
product_id: number;
variant_id: number;
product_name: string;
variant_name: string;
user_name: string;
user_email: string;
status:
| 'active'
| 'cancelled'
| 'paused'
| 'on_trial'
| 'past_due'
| 'unpaid'
| 'incomplete';
status_formatted: string;
card_brand: string;
card_last_four: string;
pause: null;
cancelled: boolean;
trial_ends_at: string;
billing_anchor: number;
urls: Urls;
renews_at: string;
ends_at: string | null;
created_at: string;
updated_at: string;
test_mode: boolean;
first_subscription_item: {
id: number;
subscription_id: number;
price_id: number;
quantity: number;
created_at: string;
updated_at: string;
};
}
interface Urls {
update_payment_method: string;
customer_portal: string;
}
interface DataLinks {
self: string;
}
interface Relationships {
store: Customer;
customer: Customer;
order: Customer;
'order-item': Customer;
product: Customer;
variant: Customer;
'subscription-invoices': Customer;
}
interface Customer {
links: CustomerLinks;
}
interface CustomerLinks {
related: string;
self: string;
}
interface Meta {
event_name: string;
custom_data: {
account_id: string;
};
}

View File

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