diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts
index e065d94f0..ae9a9fb58 100644
--- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts
+++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts
@@ -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:
diff --git a/packages/billing/lemon-squeezy/src/components/index.ts b/packages/billing/lemon-squeezy/src/components/index.ts
new file mode 100644
index 000000000..0033b39a7
--- /dev/null
+++ b/packages/billing/lemon-squeezy/src/components/index.ts
@@ -0,0 +1 @@
+export * from './lemon-squeezy-embedded-checkout';
diff --git a/packages/billing/lemon-squeezy/src/components/lemon-squeezy-embedded-checkout.tsx b/packages/billing/lemon-squeezy/src/components/lemon-squeezy-embedded-checkout.tsx
new file mode 100644
index 000000000..877bd46eb
--- /dev/null
+++ b/packages/billing/lemon-squeezy/src/components/lemon-squeezy-embedded-checkout.tsx
@@ -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 (
+
+ );
+}
diff --git a/packages/billing/lemon-squeezy/src/index.ts b/packages/billing/lemon-squeezy/src/index.ts
index ec5736c3a..82c21b93b 100644
--- a/packages/billing/lemon-squeezy/src/index.ts
+++ b/packages/billing/lemon-squeezy/src/index.ts
@@ -1 +1,2 @@
export * from './services/lemon-squeezy-billing-strategy.service';
+export * from './services/lemon-squeezy-webhook-handler.service';
diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts
new file mode 100644
index 000000000..5efe8443f
--- /dev/null
+++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts
@@ -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;
+ onSubscriptionUpdated: (
+ data: UpsertSubscriptionParams,
+ ) => Promise;
+ onSubscriptionDeleted: (subscriptionId: string) => Promise;
+ onPaymentSucceeded: (sessionId: string) => Promise;
+ onPaymentFailed: (sessionId: string) => Promise;
+ },
+ ) {
+ 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,
+ ) {
+ 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,
+ ) {
+ 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,
+ ) {
+ return this.handleSubscriptionCreatedEvent(
+ event,
+ onSubscriptionUpdatedCallback,
+ );
+ }
+
+ private handleSubscriptionDeletedEvent(
+ subscription: SubscriptionWebhook,
+ onSubscriptionDeletedCallback: (subscriptionId: string) => Promise,
+ ) {
+ // 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);
+}
diff --git a/packages/billing/lemon-squeezy/src/types/order-webhook.ts b/packages/billing/lemon-squeezy/src/types/order-webhook.ts
new file mode 100644
index 000000000..c9e57a078
--- /dev/null
+++ b/packages/billing/lemon-squeezy/src/types/order-webhook.ts
@@ -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;
+ };
+ };
+};
diff --git a/packages/billing/lemon-squeezy/src/types/subscription-webhook.ts b/packages/billing/lemon-squeezy/src/types/subscription-webhook.ts
new file mode 100644
index 000000000..d468e8cc1
--- /dev/null
+++ b/packages/billing/lemon-squeezy/src/types/subscription-webhook.ts
@@ -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;
+ };
+}