From ebb8fc08fe994b9546724210e820ad3fe420ad2a Mon Sep 17 00:00:00 2001 From: giancarlo Date: Tue, 16 Apr 2024 11:55:43 +0800 Subject: [PATCH] Add custom handlers for billing events The code introduces custom handlers for different billing events like subscription deletion, subscription update, checkout session completion, payment successes and failures, invoice payment, and a generic event handler. These customer handlers allow consumers to add their own custom behaviors when certain billing events occur. This flexibility can be utilized to better adapt the system to various business requirements and rules. Also, the invoice payment event and a generic event handler were added. --- .../billing-webhook-handler.service.ts | 6 ++ .../billing-event-handler.service.ts | 100 +++++++++++++++--- .../services/create-lemon-squeezy-checkout.ts | 4 - .../lemon-squeezy-webhook-handler.service.ts | 21 ++++ .../stripe-webhook-handler.service.ts | 38 +++++++ 5 files changed, 150 insertions(+), 19 deletions(-) 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) {