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,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 };
}