diff --git a/packages/billing/core/src/services/billing-webhook-handler.service.ts b/packages/billing/core/src/services/billing-webhook-handler.service.ts index 182592180..c69869338 100644 --- a/packages/billing/core/src/services/billing-webhook-handler.service.ts +++ b/packages/billing/core/src/services/billing-webhook-handler.service.ts @@ -36,9 +36,15 @@ export abstract class BillingWebhookHandlerService { // one-time payments onPaymentSucceeded: (sessionId: string) => Promise; + // this method is called when an invoice is paid. This is used for + onInvoicePaid: (data: UpsertSubscriptionParams) => Promise; + // this method is called when a payment is failed. This is used for // one-time payments onPaymentFailed: (sessionId: string) => Promise; + + // generic handler for any event + onEvent?: (event: string, data: unknown) => Promise; }, ): Promise; } diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts index 4e296e7ea..06251a007 100644 --- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts +++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts @@ -6,6 +6,30 @@ import { BillingWebhookHandlerService } from '@kit/billing'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; +/** + * @name CustomHandlersParams + * @description Allow consumers to provide custom handlers for the billing events + * that are triggered by the webhook events. + */ +interface CustomHandlersParams { + onSubscriptionDeleted: (subscriptionId: string) => Promise; + onSubscriptionUpdated: ( + subscription: Database['public']['Functions']['upsert_subscription']['Args'], + ) => Promise; + onCheckoutSessionCompleted: ( + subscription: + | Database['public']['Functions']['upsert_subscription']['Args'] + | Database['public']['Functions']['upsert_order']['Args'], + customerId: string, + ) => Promise; + onPaymentSucceeded: (sessionId: string) => Promise; + onPaymentFailed: (sessionId: string) => Promise; + onInvoicePaid: ( + data: Database['public']['Functions']['upsert_subscription']['Args'], + ) => Promise; + onEvent?: (event: string, data: unknown) => Promise; +} + export class BillingEventHandlerService { private readonly namespace = 'billing'; @@ -14,7 +38,10 @@ export class BillingEventHandlerService { private readonly strategy: BillingWebhookHandlerService, ) {} - async handleWebhookEvent(request: Request) { + async handleWebhookEvent( + request: Request, + params: Partial = {}, + ) { const event = await this.strategy.verifyWebhookSignature(request); if (!event) { @@ -52,6 +79,10 @@ export class BillingEventHandlerService { throw new Error('Failed to delete subscription'); } + if (params.onSubscriptionDeleted) { + await params.onSubscriptionDeleted(subscriptionId); + } + logger.info(ctx, 'Successfully deleted subscription'); }, onSubscriptionUpdated: async (subscription) => { @@ -84,6 +115,10 @@ export class BillingEventHandlerService { throw new Error('Failed to update subscription'); } + if (params.onSubscriptionUpdated) { + await params.onSubscriptionUpdated(subscription); + } + logger.info(ctx, 'Successfully updated subscription'); }, onCheckoutSessionCompleted: async (payload) => { @@ -113,6 +148,13 @@ export class BillingEventHandlerService { throw new Error('Failed to add order'); } + if (params.onCheckoutSessionCompleted) { + await params.onCheckoutSessionCompleted( + payload, + payload.target_customer_id, + ); + } + logger.info(ctx, 'Successfully added order'); } else { const ctx = { @@ -127,12 +169,22 @@ export class BillingEventHandlerService { const { error } = await client.rpc('upsert_subscription', payload); + // handle the error if (error) { logger.error({ ...ctx, error }, 'Failed to add subscription'); throw new Error('Failed to add subscription'); } + // allow consumers to provide custom handlers for the event + if (params.onCheckoutSessionCompleted) { + await params.onCheckoutSessionCompleted( + payload, + payload.target_customer_id, + ); + } + + // all good logger.info(ctx, 'Successfully added subscription'); } }, @@ -154,18 +206,18 @@ export class BillingEventHandlerService { .update({ status: 'succeeded' }) .match({ session_id: sessionId }); + // handle the error if (error) { - logger.error( - { - error, - ...ctx, - }, - 'Failed to update payment status', - ); + logger.error({ error, ...ctx }, 'Failed to update payment status'); throw new Error('Failed to update payment status'); } + // allow consumers to provide custom handlers for the event + if (params.onPaymentSucceeded) { + await params.onPaymentSucceeded(sessionId); + } + logger.info(ctx, 'Successfully updated payment status'); }, onPaymentFailed: async (sessionId: string) => { @@ -187,19 +239,37 @@ export class BillingEventHandlerService { .match({ session_id: sessionId }); if (error) { - logger.error( - { - error, - ...ctx, - }, - 'Failed to update payment status', - ); + logger.error({ error, ...ctx }, 'Failed to update payment status'); throw new Error('Failed to update payment status'); } + // allow consumers to provide custom handlers for the event + if (params.onPaymentFailed) { + await params.onPaymentFailed(sessionId); + } + logger.info(ctx, 'Successfully updated payment status'); }, + onInvoicePaid: async (data) => { + const logger = await getLogger(); + + const ctx = { + namespace: this.namespace, + subscriptionId: data.target_subscription_id, + }; + + logger.info(ctx, 'Processing invoice paid event...'); + + // by default we don't need to do anything here + // but we allow consumers to provide custom handlers for the event + if (params.onInvoicePaid) { + await params.onInvoicePaid(data); + } + + logger.info(ctx, 'Invoice paid event processed successfully'); + }, + onEvent: params.onEvent, }); } } diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts index 52616986e..3a80bde55 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts @@ -12,10 +12,6 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk'; /** * Creates a checkout for a Lemon Squeezy product. - * - * @param {object} params - The parameters for creating the checkout. - * @return {Promise} - A promise that resolves to the created Lemon Squeezy checkout. - * @throws {Error} - If no line items are found in the subscription. */ export async function createLemonSqueezyCheckout( params: z.infer, diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts index 205ae1453..b30c3aed3 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts @@ -95,6 +95,8 @@ export class LemonSqueezyWebhookHandlerService onSubscriptionDeleted: (subscriptionId: string) => Promise; onPaymentSucceeded: (sessionId: string) => Promise; onPaymentFailed: (sessionId: string) => Promise; + onInvoicePaid: (data: UpsertSubscriptionParams) => Promise; + onEvent?: (event: string) => Promise; }, ) { const eventName = event.meta.event_name; @@ -128,6 +130,13 @@ export class LemonSqueezyWebhookHandlerService ); } + case 'subscription_payment_success': { + return this.handleInvoicePaid( + event as SubscriptionWebhook, + params.onInvoicePaid, + ); + } + default: { const logger = await getLogger(); @@ -276,6 +285,18 @@ export class LemonSqueezyWebhookHandlerService ); } + private handleInvoicePaid( + subscription: SubscriptionWebhook, + onInvoicePaidCallback: ( + subscription: UpsertSubscriptionParams, + ) => Promise, + ) { + return this.handleSubscriptionCreatedEvent( + subscription, + onInvoicePaidCallback, + ); + } + private handleSubscriptionDeletedEvent( subscription: SubscriptionWebhook, onSubscriptionDeletedCallback: (subscriptionId: string) => Promise, diff --git a/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts b/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts index d02d5377f..3827c575b 100644 --- a/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts +++ b/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts @@ -77,6 +77,8 @@ export class StripeWebhookHandlerService onSubscriptionDeleted: (subscriptionId: string) => Promise; onPaymentSucceeded: (sessionId: string) => Promise; onPaymentFailed: (sessionId: string) => Promise; + onInvoicePaid: (data: UpsertSubscriptionParams) => Promise; + onEvent: (eventType: string) => Promise; }, ) { switch (event.type) { @@ -105,6 +107,10 @@ export class StripeWebhookHandlerService return this.handleAsyncPaymentFailed(event, params.onPaymentFailed); } + case 'invoice.paid': { + return this.handleInvoicePaid(event, params.onInvoicePaid); + } + case 'checkout.session.async_payment_succeeded': { return this.handleAsyncPaymentSucceeded( event, @@ -113,6 +119,10 @@ export class StripeWebhookHandlerService } default: { + if (params.onEvent) { + return params.onEvent(event.type); + } + const Logger = await getLogger(); Logger.info( @@ -315,6 +325,34 @@ export class StripeWebhookHandlerService trial_ends_at: getISOString(params.trialEndsAt), }; } + + private async handleInvoicePaid( + event: Stripe.InvoicePaidEvent, + onInvoicePaid: (params: UpsertSubscriptionParams) => Promise, + ) { + const stripe = await this.loadStripe(); + + const subscriptionId = event.data.object.subscription as string; + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + + const accountId = subscription.metadata.accountId as string; + + const payload = this.buildSubscriptionPayload({ + customerId: subscription.customer as string, + id: subscriptionId, + accountId, + lineItems: subscription.items.data, + status: subscription.status, + currency: subscription.currency, + periodStartsAt: subscription.current_period_start, + periodEndsAt: subscription.current_period_end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + trialStartsAt: subscription.trial_start, + trialEndsAt: subscription.trial_end, + }); + + return onInvoicePaid(payload); + } } function getISOString(date: number | null) {