import 'server-only'; import type { Stripe } from 'stripe'; 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 { createStripeBillingPortalSession } from './create-stripe-billing-portal-session'; import { createStripeCheckout } from './create-stripe-checkout'; import { createStripeClient } from './stripe-sdk'; import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service'; /** * @name StripeBillingStrategyService * @description The Stripe billing strategy service * @class StripeBillingStrategyService * @implements {BillingStrategyProviderService} */ export class StripeBillingStrategyService implements BillingStrategyProviderService { private readonly namespace = 'billing.stripe'; /** * @name createCheckoutSession * @description Creates a checkout session for a customer * @param params */ async createCheckoutSession( params: z.infer, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); const ctx = { name: this.namespace, customerId: params.customerId, accountId: params.accountId, }; logger.info(ctx, 'Creating checkout session...'); const { client_secret } = await createStripeCheckout(stripe, params); if (!client_secret) { logger.error(ctx, 'Failed to create checkout session'); throw new Error('Failed to create checkout session'); } logger.info(ctx, 'Checkout session created successfully'); return { checkoutToken: client_secret }; } /** * @name createBillingPortalSession * @description Creates a billing portal session for a customer * @param params */ async createBillingPortalSession( params: z.infer, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); const ctx = { name: this.namespace, customerId: params.customerId, }; logger.info(ctx, 'Creating billing portal session...'); const session = await createStripeBillingPortalSession(stripe, params); if (!session?.url) { logger.error(ctx, 'Failed to create billing portal session'); } else { logger.info(ctx, 'Billing portal session created successfully'); } return session; } /** * @name cancelSubscription * @description Cancels a subscription * @param params */ async cancelSubscription( params: z.infer, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); const ctx = { name: this.namespace, subscriptionId: params.subscriptionId, }; logger.info(ctx, 'Cancelling subscription...'); try { await stripe.subscriptions.cancel(params.subscriptionId, { invoice_now: params.invoiceNow ?? true, }); logger.info(ctx, 'Subscription cancelled successfully'); return { success: true, }; } catch (error) { logger.info( { ...ctx, error, }, `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 stripe = await this.stripeProvider(); const logger = await getLogger(); const ctx = { name: this.namespace, sessionId: params.sessionId, }; logger.info(ctx, 'Retrieving checkout session...'); try { const session = await stripe.checkout.sessions.retrieve(params.sessionId); const isSessionOpen = session.status === 'open'; logger.info(ctx, 'Checkout session retrieved successfully'); return { checkoutToken: session.client_secret, isSessionOpen, status: session.status ?? 'complete', customer: { email: session.customer_details?.email ?? null, }, }; } catch (error) { logger.error( { ...ctx, error, }, 'Failed to retrieve checkout session', ); throw new Error('Failed to retrieve checkout session'); } } /** * @name reportUsage * @description Reports usage for a subscription with the Metrics API * @param params */ async reportUsage(params: z.infer) { const stripe = await this.stripeProvider(); const logger = await getLogger(); const ctx = { name: this.namespace, subscriptionItemId: params.id, usage: params.usage, }; logger.info(ctx, 'Reporting usage...'); if (!params.eventName) { logger.error(ctx, 'Event name is required'); throw new Error('Event name is required when reporting Metrics'); } try { await stripe.billing.meterEvents.create({ event_name: params.eventName, payload: { value: params.usage.quantity.toString(), stripe_customer_id: params.id, }, }); } catch (error) { logger.error( { ...ctx, error, }, 'Failed to report usage', ); throw new Error('Failed to report usage'); } return { success: true, }; } /** * @name queryUsage * @description Reports the total usage for a subscription with the Metrics API */ async queryUsage(params: z.infer) { const stripe = await this.stripeProvider(); const logger = await getLogger(); const ctx = { name: this.namespace, id: params.id, customerId: params.customerId, }; // validate shape of filters for Stripe if (!('startTime' in params.filter)) { logger.error(ctx, 'Start and end time are required for Stripe'); throw new Error('Start and end time are required when querying usage'); } logger.info(ctx, 'Querying billing usage...'); try { const summaries = await stripe.billing.meters.listEventSummaries( params.id, { customer: params.customerId, start_time: params.filter.startTime, end_time: params.filter.endTime, }, ); logger.info(ctx, 'Billing usage queried successfully'); const value = summaries.data.reduce((acc, summary) => { return acc + Number(summary.aggregated_value); }, 0); return { value, }; } catch (error) { logger.error( { ...ctx, error, }, 'Failed to report usage', ); throw new Error('Failed to report usage'); } } /** * @name updateSubscriptionItem * @description Updates a subscription * @param params */ async updateSubscriptionItem( params: z.infer, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); const ctx = { name: this.namespace, subscriptionId: params.subscriptionId, subscriptionItemId: params.subscriptionItemId, quantity: params.quantity, }; logger.info(ctx, 'Updating subscription...'); try { await stripe.subscriptions.update(params.subscriptionId, { items: [ { id: params.subscriptionItemId, quantity: params.quantity, }, ], }); logger.info(ctx, 'Subscription updated successfully'); return { success: true }; } catch (error) { logger.error({ ...ctx, error }, 'Failed to update subscription'); throw new Error('Failed to update subscription'); } } /** * @name getPlanById * @description Retrieves a plan by id * @param planId */ async getPlanById(planId: string) { const logger = await getLogger(); const ctx = { name: this.namespace, planId, }; logger.info(ctx, 'Retrieving plan by id...'); const stripe = await this.stripeProvider(); try { const plan = await stripe.plans.retrieve(planId); logger.info(ctx, 'Plan retrieved successfully'); return { id: plan.id, name: plan.nickname ?? '', amount: plan.amount ?? 0, interval: plan.interval, }; } catch (error) { logger.error({ ...ctx, error }, 'Failed to retrieve plan'); throw new Error('Failed to retrieve plan'); } } async getSubscription(subscriptionId: string) { const stripe = await this.stripeProvider(); const logger = await getLogger(); const ctx = { name: this.namespace, subscriptionId, }; logger.info(ctx, 'Retrieving subscription...'); const subscriptionPayloadBuilder = createStripeSubscriptionPayloadBuilderService(); try { const subscription = await stripe.subscriptions.retrieve(subscriptionId); logger.info(ctx, 'Subscription retrieved successfully'); const customer = subscription.customer as string; const accountId = subscription.metadata?.accountId as string; const periodStartsAt = subscriptionPayloadBuilder.getPeriodStartsAt(subscription); const periodEndsAt = subscriptionPayloadBuilder.getPeriodEndsAt(subscription); const lineItems = subscription.items.data.map((item) => { return { ...item, // we cannot retrieve this from the API, user should retrieve from the billing configuration if needed type: '' as never, }; }); return subscriptionPayloadBuilder.build({ customerId: customer, accountId, id: subscription.id, lineItems, status: subscription.status, currency: subscription.currency, cancelAtPeriodEnd: subscription.cancel_at_period_end, periodStartsAt, periodEndsAt, trialStartsAt: subscription.trial_start, trialEndsAt: subscription.trial_end, }); } catch (error) { logger.error({ ...ctx, error }, 'Failed to retrieve subscription'); throw new Error('Failed to retrieve subscription'); } } private async stripeProvider(): Promise { return createStripeClient(); } }