Retrieve plan from Stripe/LS if not found in billing configuration. Useful for legacy plans.
493 lines
11 KiB
TypeScript
493 lines
11 KiB
TypeScript
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 ?? '',
|
|
description: attrs.description ?? '',
|
|
amount: attrs.price,
|
|
type: attrs.is_subscription
|
|
? ('recurring' as const)
|
|
: ('one_time' as const),
|
|
intervalCount: attrs.interval_count ?? undefined,
|
|
};
|
|
}
|
|
}
|