B2B-88: add starter kit structure and elements
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
28
packages/billing/lemon-squeezy/src/services/verify-hmac.ts
Normal file
28
packages/billing/lemon-squeezy/src/services/verify-hmac.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user