import type Stripe from 'stripe'; import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing'; import { getLogger } from '@kit/shared/logger'; import { Database, Enums } from '@kit/supabase/database'; import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema'; import { createStripeClient } from './stripe-sdk'; import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service'; type UpsertSubscriptionParams = Database['medreport']['Functions']['upsert_subscription']['Args'] & { line_items: Array; }; 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 UpsertOrderParams = Database['medreport']['Functions']['upsert_order']['Args']; type BillingProvider = Enums<{ schema: 'medreport' }, 'billing_provider'>; export class StripeWebhookHandlerService implements BillingWebhookHandlerService { private stripe: Stripe | undefined; constructor(private readonly planTypesMap: PlanTypeMap) {} private readonly provider: BillingProvider = 'stripe'; private readonly namespace = 'billing.stripe'; /** * @name verifyWebhookSignature * @description Verifies the webhook signature - should throw an error if the signature is invalid */ async verifyWebhookSignature(request: Request) { const body = await request.clone().text(); const signatureKey = `stripe-signature`; const signature = request.headers.get(signatureKey)!; const { webhooksSecret } = StripeServerEnvSchema.parse({ secretKey: process.env.STRIPE_SECRET_KEY, webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET, }); const stripe = await this.loadStripe(); const event = await stripe.webhooks.constructEventAsync( body, signature, webhooksSecret, ); if (!event) { throw new Error('Invalid signature'); } return event; } /** * @name handleWebhookEvent * @description Handle the webhook event from the billing provider * @param event * @param params */ async handleWebhookEvent( event: Stripe.Event, params: { onCheckoutSessionCompleted: ( data: UpsertSubscriptionParams | UpsertOrderParams, ) => Promise; onSubscriptionUpdated: ( data: UpsertSubscriptionParams, ) => Promise; onSubscriptionDeleted: (subscriptionId: string) => Promise; onPaymentSucceeded: (sessionId: string) => Promise; onPaymentFailed: (sessionId: string) => Promise; onInvoicePaid: (data: UpsertSubscriptionParams) => Promise; onEvent?(event: Stripe.Event): Promise; }, ) { switch (event.type) { case 'checkout.session.completed': { const result = await this.handleCheckoutSessionCompleted( event, params.onCheckoutSessionCompleted, ); // handle user-supplied handler if (params.onEvent) { await params.onEvent(event); } return result; } case 'customer.subscription.updated': { const result = await this.handleSubscriptionUpdatedEvent( event, params.onSubscriptionUpdated, ); // handle user-supplied handler if (params.onEvent) { await params.onEvent(event); } return result; } case 'customer.subscription.deleted': { const result = await this.handleSubscriptionDeletedEvent( event, params.onSubscriptionDeleted, ); // handle user-supplied handler if (params.onEvent) { await params.onEvent(event); } return result; } case 'checkout.session.async_payment_failed': { const result = await this.handleAsyncPaymentFailed( event, params.onPaymentFailed, ); // handle user-supplied handler if (params.onEvent) { await params.onEvent(event); } return result; } case 'checkout.session.async_payment_succeeded': { const result = await this.handleAsyncPaymentSucceeded( event, params.onPaymentSucceeded, ); // handle user-supplied handler if (params.onEvent) { await params.onEvent(event); } return result; } case 'invoice.paid': { const result = await this.handleInvoicePaid( event, params.onInvoicePaid, ); // handle user-supplied handler (ex. user wanting to handle one-off payments) if (params.onEvent) { await params.onEvent(event); } return result; } default: { // when none of the events were matched, attempt to call // the user-supplied handler if (params.onEvent) { return params.onEvent(event); } const logger = await getLogger(); logger.debug( { eventType: event.type, name: this.namespace, }, `Unhandled Stripe event type: ${event.type}`, ); return; } } } private async handleCheckoutSessionCompleted( event: Stripe.CheckoutSessionCompletedEvent, onCheckoutCompletedCallback: ( data: UpsertSubscriptionParams | UpsertOrderParams, ) => Promise, ) { const stripe = await this.loadStripe(); const session = event.data.object; const isSubscription = session.mode === 'subscription'; const accountId = session.client_reference_id!; const customerId = session.customer as string; // if it's a subscription, we need to retrieve the subscription // and build the payload for the subscription if (isSubscription) { const subscriptionPayloadBuilderService = createStripeSubscriptionPayloadBuilderService(); const subscriptionId = session.subscription as string; const subscription = await stripe.subscriptions.retrieve(subscriptionId); const periodStartsAt = subscriptionPayloadBuilderService.getPeriodStartsAt(subscription); const periodEndsAt = subscriptionPayloadBuilderService.getPeriodEndsAt(subscription); const lineItems = this.getLineItems(subscription); const payload = subscriptionPayloadBuilderService.build({ accountId, customerId, id: subscription.id, lineItems, status: subscription.status, currency: subscription.currency, periodStartsAt, periodEndsAt, cancelAtPeriodEnd: subscription.cancel_at_period_end, trialStartsAt: subscription.trial_start, trialEndsAt: subscription.trial_end, }); return onCheckoutCompletedCallback(payload); } else { // if it's a one-time payment, we need to retrieve the session const sessionId = event.data.object.id; // from the session, we need to retrieve the line items const sessionWithLineItems = await stripe.checkout.sessions.retrieve( event.data.object.id, { expand: ['line_items'], }, ); const lineItems = sessionWithLineItems.line_items?.data ?? []; const paymentStatus = sessionWithLineItems.payment_status; const status = paymentStatus === 'unpaid' ? 'pending' : 'succeeded'; const currency = event.data.object.currency as string; const payload: UpsertOrderParams = { target_account_id: accountId, target_customer_id: customerId, target_order_id: sessionId, billing_provider: this.provider, status: status, currency: currency, total_amount: sessionWithLineItems.amount_total ?? 0, line_items: lineItems.map((item) => { const price = item.price as Stripe.Price; return { id: item.id, product_id: price.product as string, variant_id: price.id, price_amount: price.unit_amount, quantity: item.quantity, }; }), }; return onCheckoutCompletedCallback(payload); } } private handleAsyncPaymentFailed( event: Stripe.CheckoutSessionAsyncPaymentFailedEvent, onPaymentFailed: (sessionId: string) => Promise, ) { const sessionId = event.data.object.id; return onPaymentFailed(sessionId); } private handleAsyncPaymentSucceeded( event: Stripe.CheckoutSessionAsyncPaymentSucceededEvent, onPaymentSucceeded: (sessionId: string) => Promise, ) { const sessionId = event.data.object.id; return onPaymentSucceeded(sessionId); } private async handleSubscriptionUpdatedEvent( event: Stripe.CustomerSubscriptionUpdatedEvent, onSubscriptionUpdatedCallback: ( subscription: UpsertSubscriptionParams, ) => Promise, ) { const subscription = event.data.object; const subscriptionId = subscription.id; const accountId = subscription.metadata.accountId as string; const subscriptionPayloadBuilderService = createStripeSubscriptionPayloadBuilderService(); const periodStartsAt = subscriptionPayloadBuilderService.getPeriodStartsAt(subscription); const periodEndsAt = subscriptionPayloadBuilderService.getPeriodEndsAt(subscription); const lineItems = this.getLineItems(subscription); const payload = subscriptionPayloadBuilderService.build({ customerId: subscription.customer as string, id: subscriptionId, accountId, lineItems, status: subscription.status, currency: subscription.currency, periodStartsAt, periodEndsAt, cancelAtPeriodEnd: subscription.cancel_at_period_end, trialStartsAt: subscription.trial_start, trialEndsAt: subscription.trial_end, }); return onSubscriptionUpdatedCallback(payload); } private handleSubscriptionDeletedEvent( event: Stripe.CustomerSubscriptionDeletedEvent, onSubscriptionDeletedCallback: (subscriptionId: string) => Promise, ) { // Here we don't need to do anything, so we just return the callback return onSubscriptionDeletedCallback(event.data.object.id); } private async handleInvoicePaid( event: Stripe.InvoicePaidEvent, onInvoicePaid: (data: UpsertSubscriptionParams) => Promise, ) { const stripe = await this.loadStripe(); const logger = await getLogger(); const subscriptionPayloadBuilderService = createStripeSubscriptionPayloadBuilderService(); const invoice = event.data.object; const invoiceId = invoice.id; if (!invoiceId) { logger.warn( { invoiceId, }, `Invoice not found. Will not handle invoice.paid event.`, ); return; } const customerId = invoice.customer as string; let subscriptionId: string | undefined; // for retro-compatibility with Stripe < 18 // we check if the invoice object has a "subscription" property if ('subscription' in invoice && invoice.subscription) { subscriptionId = invoice.subscription as string; } else { // for Stripe 18+ we retrieve the subscription ID from the parent object subscriptionId = invoice.parent?.subscription_details ?.subscription as string; } // handle when a subscription ID is not found if (!subscriptionId) { logger.warn( { subscriptionId, customerId, }, `Subscription ID not found for invoice. Will not handle invoice.paid event.`, ); return; } const subscription = await stripe.subscriptions.retrieve(subscriptionId); // // handle when a subscription is not found if (!subscription) { logger.warn( { subscriptionId, customerId, }, `Subscription not found for invoice. Will not handle invoice.paid event.`, ); return; } // retrieve account ID from the metadata const accountId = subscription.metadata?.accountId as string; const periodStartsAt = subscriptionPayloadBuilderService.getPeriodStartsAt(subscription); const periodEndsAt = subscriptionPayloadBuilderService.getPeriodEndsAt(subscription); const lineItems = this.getLineItems(subscription); const payload = subscriptionPayloadBuilderService.build({ customerId, id: subscriptionId, accountId, lineItems, status: subscription.status, currency: subscription.currency, periodStartsAt, periodEndsAt, cancelAtPeriodEnd: subscription.cancel_at_period_end, trialStartsAt: subscription.trial_start, trialEndsAt: subscription.trial_end, }); return onInvoicePaid(payload); } private getLineItems(subscription: Stripe.Subscription) { return subscription.items.data.map((item) => { let type = this.planTypesMap.get(item.price.id); if (!type) { console.warn( { lineItemId: item.id, }, `Line item is not in the billing configuration, please add it. Defaulting to "flat" type.`, ); type = 'flat' as const; } return { ...item, type }; }); } private async loadStripe() { if (!this.stripe) { this.stripe = await createStripeClient(); } return this.stripe; } }