Implement Lemon Squeezy billing services
Added implementation for various billing services with Lemon Squeezy. This includes processing of webhooks for order and subscription handling, verification of webhook signatures, and validating subscription statuses and order states. Additionally, types for order and subscription webhooks have been created.
This commit is contained in:
@@ -16,12 +16,16 @@ export class BillingEventHandlerFactoryService {
|
||||
return new StripeWebhookHandlerService();
|
||||
}
|
||||
|
||||
case 'paddle': {
|
||||
throw new Error('Paddle is not supported yet');
|
||||
case 'lemon-squeezy': {
|
||||
const { LemonSqueezyWebhookHandlerService } = await import(
|
||||
'@kit/lemon-squeezy'
|
||||
);
|
||||
|
||||
return new LemonSqueezyWebhookHandlerService();
|
||||
}
|
||||
|
||||
case 'lemon-squeezy': {
|
||||
throw new Error('Lemon Squeezy is not supported yet');
|
||||
case 'paddle': {
|
||||
throw new Error('Paddle is not supported yet');
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
1
packages/billing/lemon-squeezy/src/components/index.ts
Normal file
1
packages/billing/lemon-squeezy/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lemon-squeezy-embedded-checkout';
|
||||
@@ -0,0 +1,29 @@
|
||||
interface LemonSqueezyWindow extends Window {
|
||||
createLemonSqueezy: () => void;
|
||||
LemonSqueezy: {
|
||||
Setup: (options: {
|
||||
eventHandler: (event: { event: string }) => void;
|
||||
}) => void;
|
||||
Refresh: () => void;
|
||||
|
||||
Url: {
|
||||
Open: (url: string) => void;
|
||||
Close: () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function LemonSqueezyEmbeddedCheckout(props: { checkoutToken: string }) {
|
||||
return (
|
||||
<script
|
||||
src="https://app.lemonsqueezy.com/js/lemon.js"
|
||||
defer
|
||||
onLoad={() => {
|
||||
const win = window as unknown as LemonSqueezyWindow;
|
||||
|
||||
win.createLemonSqueezy();
|
||||
win.LemonSqueezy.Url.Open(props.checkoutToken);
|
||||
}}
|
||||
></script>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './services/lemon-squeezy-billing-strategy.service';
|
||||
export * from './services/lemon-squeezy-webhook-handler.service';
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { getOrder } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
import { BillingWebhookHandlerService } from '@kit/billing';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
|
||||
import { OrderWebhook } from '../types/order-webhook';
|
||||
import SubscriptionWebhook from '../types/subscription-webhook';
|
||||
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||
|
||||
type UpsertSubscriptionParams =
|
||||
Database['public']['Functions']['upsert_subscription']['Args'];
|
||||
|
||||
type UpsertOrderParams =
|
||||
Database['public']['Functions']['upsert_order']['Args'];
|
||||
|
||||
type OrderStatus = 'pending' | 'failed' | 'paid' | 'refunded';
|
||||
|
||||
type SubscriptionStatus =
|
||||
| 'on_trial'
|
||||
| 'active'
|
||||
| 'cancelled'
|
||||
| 'paused'
|
||||
| 'expired'
|
||||
| 'unpaid'
|
||||
| 'past_due';
|
||||
|
||||
export class LemonSqueezyWebhookHandlerService
|
||||
implements BillingWebhookHandlerService
|
||||
{
|
||||
private readonly provider: Database['public']['Enums']['billing_provider'] =
|
||||
'lemon-squeezy';
|
||||
|
||||
private readonly namespace = 'billing.lemon-squeezy';
|
||||
|
||||
/**
|
||||
* @description Verifies the webhook signature - should throw an error if the signature is invalid
|
||||
*/
|
||||
async verifyWebhookSignature(request: Request) {
|
||||
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();
|
||||
const rawBody = await reqClone.text();
|
||||
|
||||
if (!signature) {
|
||||
Logger.error(
|
||||
{
|
||||
eventName,
|
||||
},
|
||||
`Signature header not found`,
|
||||
);
|
||||
|
||||
throw new Error('Signature header not found');
|
||||
}
|
||||
|
||||
if (!isSigningSecretValid(Buffer.from(rawBody), signature)) {
|
||||
Logger.error(
|
||||
{
|
||||
eventName,
|
||||
},
|
||||
`Signing secret is invalid`,
|
||||
);
|
||||
|
||||
throw new Error('Signing secret is invalid');
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async handleWebhookEvent(
|
||||
event: OrderWebhook | SubscriptionWebhook,
|
||||
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>;
|
||||
},
|
||||
) {
|
||||
const eventName = event.meta.event_name;
|
||||
|
||||
switch (eventName) {
|
||||
case 'order_created': {
|
||||
return this.handleOrderCompleted(
|
||||
event as OrderWebhook,
|
||||
params.onCheckoutSessionCompleted,
|
||||
);
|
||||
}
|
||||
|
||||
case 'subscription_created': {
|
||||
return this.handleSubscriptionCreatedEvent(
|
||||
event as SubscriptionWebhook,
|
||||
params.onSubscriptionUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
case 'subscription_updated': {
|
||||
return this.handleSubscriptionUpdatedEvent(
|
||||
event as SubscriptionWebhook,
|
||||
params.onSubscriptionUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
case 'subscription_expired': {
|
||||
return this.handleSubscriptionDeletedEvent(
|
||||
event as SubscriptionWebhook,
|
||||
params.onSubscriptionDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
Logger.info(
|
||||
{
|
||||
eventType: eventName,
|
||||
name: this.namespace,
|
||||
},
|
||||
`Unhandle Lemon Squeezy event type`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOrderCompleted(
|
||||
event: OrderWebhook,
|
||||
onCheckoutCompletedCallback: (
|
||||
data: UpsertSubscriptionParams | UpsertOrderParams,
|
||||
) => Promise<unknown>,
|
||||
) {
|
||||
await initializeLemonSqueezyClient();
|
||||
|
||||
const subscription = event.data.relationships.subscriptions.links.self;
|
||||
|
||||
if (subscription) {
|
||||
// we handle the subscription created event instead
|
||||
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,
|
||||
product_id: attrs.first_order_item.product_id,
|
||||
variant_id: attrs.first_order_item.variant_id,
|
||||
price_amount: attrs.first_order_item.price,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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 intervalCount = subscription.billing_anchor;
|
||||
|
||||
const { data: order, error } = await getOrder(orderId);
|
||||
|
||||
if (error ?? !order) {
|
||||
Logger.error(
|
||||
{
|
||||
orderId,
|
||||
subscriptionId,
|
||||
error,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Failed to fetch order',
|
||||
);
|
||||
|
||||
throw new Error('Failed to fetch order');
|
||||
}
|
||||
|
||||
const lineItems = [
|
||||
{
|
||||
id: subscription.order_item_id.toString(),
|
||||
product: productId.toString(),
|
||||
variant: variantId.toString(),
|
||||
quantity: order.data.attributes.first_order_item.quantity,
|
||||
unitAmount: order.data.attributes.first_order_item.price,
|
||||
},
|
||||
];
|
||||
|
||||
const interval = intervalCount === 1 ? 'month' : 'year';
|
||||
|
||||
const payload = this.buildSubscriptionPayload({
|
||||
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 buildSubscriptionPayload<
|
||||
LineItem extends {
|
||||
id: string;
|
||||
quantity: number;
|
||||
product: string;
|
||||
variant: string;
|
||||
unitAmount: number;
|
||||
},
|
||||
>(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 active = params.status === 'active' || params.status === 'trialing';
|
||||
|
||||
const lineItems = params.lineItems.map((item) => {
|
||||
const quantity = item.quantity ?? 1;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
quantity,
|
||||
subscription_id: params.id,
|
||||
product_id: item.product,
|
||||
variant_id: item.variant,
|
||||
price_amount: item.unitAmount,
|
||||
};
|
||||
});
|
||||
|
||||
// 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: this.provider,
|
||||
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: getISOString(params.trialStartsAt),
|
||||
trial_ends_at: getISOString(params.trialEndsAt),
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
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 * 1000).toISOString() : undefined;
|
||||
}
|
||||
|
||||
function isSigningSecretValid(rawBody: Buffer, signatureHeader: string) {
|
||||
const { webhooksSecret } = getLemonSqueezyEnv();
|
||||
const hmac = createHmac('sha256', webhooksSecret);
|
||||
|
||||
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');
|
||||
const signature = Buffer.from(signatureHeader, 'utf8');
|
||||
|
||||
return timingSafeEqual(digest, signature);
|
||||
}
|
||||
99
packages/billing/lemon-squeezy/src/types/order-webhook.ts
Normal file
99
packages/billing/lemon-squeezy/src/types/order-webhook.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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: any;
|
||||
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: any;
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
interface SubscriptionWebhookResponse {
|
||||
meta: Meta;
|
||||
data: Data;
|
||||
}
|
||||
|
||||
export default SubscriptionWebhookResponse;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user