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, ) { 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, ) { 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, ) { 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, ) { 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) { 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, ): 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, ) { 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, }; } }