From 903ef6dc0832101b1ad3d851e644e5e0f5e00524 Mon Sep 17 00:00:00 2001 From: Giancarlo Buomprisco Date: Tue, 22 Apr 2025 09:42:12 +0700 Subject: [PATCH] Update Stripe SDK to v18 and dependencies (#236) * Update Stripe SDK and dependencies 1. Upgrade `stripe` package from version 17.7.0 to 18.0.0 in `package.json`. 2. Update `STRIPE_API_VERSION` in `stripe-sdk.ts` to '2025-03-31.basil'. 3. Refactor `StripeWebhookHandlerService` to retrieve subscription details using Supabase client, ensuring compatibility with the new Stripe version. 4. Introduce helper methods `getPeriodStartsAt` and `getPeriodEndsAt` for better handling of subscription periods based on the Stripe API changes. These changes enhance the integration with the latest Stripe API and improve the overall reliability of the billing service. * Refactor billing payload builders to remove config dependency Removed direct dependency on `BillingConfig` in subscription payload builders. Introduced `PlanTypeMap` to streamline plan type resolutions. Updated webhook handlers and event processing functions to handle plan types more efficiently and improve extensibility. * Refactor Stripe subscription handling for improved accuracy --- apps/web/app/api/billing/webhook/route.ts | 3 +- .../billing/core/src/create-billing-schema.ts | 20 +- .../billing-event-handler-factory.service.ts | 12 +- .../billing-event-handler-provider.ts | 8 +- .../lemon-squeezy-billing-strategy.service.ts | 6 + ...zy-subscription-payload-builder.service.ts | 18 +- .../lemon-squeezy-webhook-handler.service.ts | 77 ++++- packages/billing/stripe/package.json | 2 +- .../stripe-billing-strategy.service.ts | 20 +- .../billing/stripe/src/services/stripe-sdk.ts | 2 +- ...pe-subscription-payload-builder.service.ts | 57 ++-- .../stripe-webhook-handler.service.ts | 270 +++++++++++++----- pnpm-lock.yaml | 16 +- 13 files changed, 372 insertions(+), 139 deletions(-) diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts index c8a680f48..a38754a4b 100644 --- a/apps/web/app/api/billing/webhook/route.ts +++ b/apps/web/app/api/billing/webhook/route.ts @@ -1,3 +1,4 @@ +import { getPlanTypesMap } from '@kit/billing'; import { getBillingEventHandlerService } from '@kit/billing-gateway'; import { enhanceRouteHandler } from '@kit/next/routes'; import { getLogger } from '@kit/shared/logger'; @@ -25,7 +26,7 @@ export const POST = enhanceRouteHandler( const service = await getBillingEventHandlerService( supabaseClientProvider, provider, - billingConfig, + getPlanTypesMap(billingConfig), ); try { diff --git a/packages/billing/core/src/create-billing-schema.ts b/packages/billing/core/src/create-billing-schema.ts index c0d79ad39..8f33c1fe9 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -422,19 +422,25 @@ export function getProductPlanPairByVariantId( throw new Error('Plan not found'); } -export function getLineItemTypeById( +export type PlanTypeMap = Map>; + +/** + * @name getPlanTypesMap + * @description Get all line item types for all plans in the config + * @param config + */ +export function getPlanTypesMap( config: z.infer, - id: string, -) { +): PlanTypeMap { + const planTypes: PlanTypeMap = new Map(); + for (const product of config.products) { for (const plan of product.plans) { for (const lineItem of plan.lineItems) { - if (lineItem.id === id) { - return lineItem.type; - } + planTypes.set(lineItem.id, lineItem.type); } } } - throw new Error(`Line Item with ID ${id} not found`); + return planTypes; } diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts index 6010ce76c..213c53f5d 100644 --- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts +++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts @@ -3,18 +3,20 @@ import 'server-only'; import { z } from 'zod'; import { - type BillingConfig, type BillingProviderSchema, BillingWebhookHandlerService, + type PlanTypeMap, } from '@kit/billing'; import { createRegistry } from '@kit/shared/registry'; /** * @description Creates a registry for billing webhook handlers - * @param config - The billing config + * @param planTypesMap - A map of plan types as setup by the user in the billing config * @returns The billing webhook handler registry */ -export function createBillingEventHandlerFactoryService(config: BillingConfig) { +export function createBillingEventHandlerFactoryService( + planTypesMap: PlanTypeMap, +) { // Create a registry for billing webhook handlers const billingWebhookHandlerRegistry = createRegistry< BillingWebhookHandlerService, @@ -25,7 +27,7 @@ export function createBillingEventHandlerFactoryService(config: BillingConfig) { billingWebhookHandlerRegistry.register('stripe', async () => { const { StripeWebhookHandlerService } = await import('@kit/stripe'); - return new StripeWebhookHandlerService(config); + return new StripeWebhookHandlerService(planTypesMap); }); // Register the Lemon Squeezy webhook handler @@ -34,7 +36,7 @@ export function createBillingEventHandlerFactoryService(config: BillingConfig) { '@kit/lemon-squeezy' ); - return new LemonSqueezyWebhookHandlerService(config); + return new LemonSqueezyWebhookHandlerService(planTypesMap); }); // Register Paddle webhook handler (not implemented yet) diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-provider.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-provider.ts index 43c2d9d83..47cb1d115 100644 --- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-provider.ts +++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-provider.ts @@ -1,8 +1,8 @@ import 'server-only'; -import { SupabaseClient } from '@supabase/supabase-js'; +import type { SupabaseClient } from '@supabase/supabase-js'; -import { BillingConfig } from '@kit/billing'; +import type { PlanTypeMap } from '@kit/billing'; import { Database, Enums } from '@kit/supabase/database'; import { createBillingEventHandlerFactoryService } from './billing-event-handler-factory.service'; @@ -23,10 +23,10 @@ type BillingProvider = Enums<'billing_provider'>; export async function getBillingEventHandlerService( clientProvider: ClientProvider, provider: BillingProvider, - config: BillingConfig, + planTypesMap: PlanTypeMap, ) { const strategy = - await createBillingEventHandlerFactoryService(config).get(provider); + await createBillingEventHandlerFactoryService(planTypesMap).get(provider); return createBillingEventHandlerService(clientProvider, strategy); } diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts index 37a3c9090..b81b7c08a 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts @@ -27,6 +27,10 @@ import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-b 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 { @@ -405,6 +409,8 @@ export class LemonSqueezyBillingStrategyService 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, }, ]; diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-subscription-payload-builder.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-subscription-payload-builder.service.ts index cf10823f1..4bde68cad 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-subscription-payload-builder.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-subscription-payload-builder.service.ts @@ -1,4 +1,4 @@ -import { BillingConfig, getLineItemTypeById } from '@kit/billing'; +import { BillingConfig } from '@kit/billing'; import { UpsertSubscriptionParams } from '@kit/billing/types'; type SubscriptionStatus = @@ -25,17 +25,6 @@ export function createLemonSqueezySubscriptionPayloadBuilderService() { class LemonSqueezySubscriptionPayloadBuilderService { private config?: BillingConfig; - /** - * @name withBillingConfig - * @description Set the billing config for the subscription payload - * @param config - */ - withBillingConfig(config: BillingConfig) { - this.config = config; - - return this; - } - /** * @name build * @description Build the subscription payload for Lemon Squeezy @@ -48,6 +37,7 @@ class LemonSqueezySubscriptionPayloadBuilderService { product: string; variant: string; priceAmount: number; + type: 'flat' | 'per_seat' | 'metered'; }, >(params: { id: string; @@ -85,9 +75,7 @@ class LemonSqueezySubscriptionPayloadBuilderService { product_id: item.product, variant_id: item.variant, price_amount: item.priceAmount, - type: this.config - ? getLineItemTypeById(this.config, item.variant) - : undefined, + type: item.type, }; }); 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 48100fff9..83e0e2e77 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 @@ -4,7 +4,7 @@ import { getVariant, } from '@lemonsqueezy/lemonsqueezy.js'; -import { BillingConfig, BillingWebhookHandlerService } from '@kit/billing'; +import { BillingWebhookHandlerService, type PlanTypeMap } from '@kit/billing'; import { getLogger } from '@kit/shared/logger'; import { Database, Enums } from '@kit/supabase/database'; @@ -48,7 +48,7 @@ export class LemonSqueezyWebhookHandlerService private readonly namespace = 'billing.lemon-squeezy'; - constructor(private readonly config: BillingConfig) {} + constructor(private readonly planTypesMap: PlanTypeMap) {} /** * @description Verifies the webhook signature - should throw an error if the signature is invalid @@ -116,48 +116,84 @@ export class LemonSqueezyWebhookHandlerService switch (eventName) { case 'order_created': { - return this.handleOrderCompleted( + const result = await this.handleOrderCompleted( event as OrderWebhook, params.onCheckoutSessionCompleted, ); + + // handle user-supplied handler + if (params.onEvent) { + await params.onEvent(event); + } + + return result; } case 'subscription_created': { - return this.handleSubscriptionCreatedEvent( + const result = await this.handleSubscriptionCreatedEvent( event as SubscriptionWebhook, params.onSubscriptionUpdated, ); + + // handle user-supplied handler + if (params.onEvent) { + await params.onEvent(event); + } + + return result; } case 'subscription_updated': { - return this.handleSubscriptionUpdatedEvent( + const result = await this.handleSubscriptionUpdatedEvent( event as SubscriptionWebhook, params.onSubscriptionUpdated, ); + + // handle user-supplied handler + if (params.onEvent) { + await params.onEvent(event); + } + + return result; } case 'subscription_expired': { - return this.handleSubscriptionDeletedEvent( + const result = await this.handleSubscriptionDeletedEvent( event as SubscriptionWebhook, params.onSubscriptionDeleted, ); + + // handle user-supplied handler + if (params.onEvent) { + await params.onEvent(event); + } + + return result; } case 'subscription_payment_success': { - return this.handleInvoicePaid( + const result = await this.handleInvoicePaid( event as SubscriptionInvoiceWebhook, params.onInvoicePaid, ); + + // handle user-supplied handler + if (params.onEvent) { + await params.onEvent(event); + } + + return result; } default: { + // handle user-supplied handler if (params.onEvent) { return params.onEvent(event); } const logger = await getLogger(); - logger.info( + logger.debug( { eventType: eventName, name: this.namespace, @@ -212,6 +248,7 @@ export class LemonSqueezyWebhookHandlerService variant_id: attrs.first_order_item.variant_id.toString(), price_amount: attrs.first_order_item.price, quantity: 1, + type: this.getLineItemType(attrs.first_order_item.variant_id), }, ], }; @@ -268,6 +305,7 @@ export class LemonSqueezyWebhookHandlerService variant: variantId.toString(), quantity: firstSubscriptionItem.quantity, priceAmount, + type: this.getLineItemType(variantId), }, ]; @@ -276,7 +314,7 @@ export class LemonSqueezyWebhookHandlerService const payloadBuilderService = createLemonSqueezySubscriptionPayloadBuilderService(); - const payload = payloadBuilderService.withBillingConfig(this.config).build({ + const payload = payloadBuilderService.build({ customerId, id: subscriptionId, accountId, @@ -361,6 +399,8 @@ export class LemonSqueezyWebhookHandlerService const payloadBuilderService = createLemonSqueezySubscriptionPayloadBuilderService(); + const lineItemType = this.getLineItemType(variantId); + const lineItems = [ { id: subscription.order_item_id.toString(), @@ -368,10 +408,11 @@ export class LemonSqueezyWebhookHandlerService variant: variantId.toString(), quantity: subscription.first_subscription_item?.quantity ?? 1, priceAmount: attrs.total, + type: lineItemType, }, ]; - const payload = payloadBuilderService.withBillingConfig(this.config).build({ + const payload = payloadBuilderService.build({ customerId, id: subscriptionId, accountId, @@ -390,6 +431,22 @@ export class LemonSqueezyWebhookHandlerService return onInvoicePaidCallback(payload); } + private getLineItemType(variantId: number) { + const type = this.planTypesMap.get(variantId.toString()); + + if (!type) { + console.warn( + { + variantId, + }, + 'Line item type not found. Will be defaulted to "flat"', + ); + + return 'flat' as const; + } + + return type; + } private getOrderStatus(status: OrderStatus) { switch (status) { case 'paid': diff --git a/packages/billing/stripe/package.json b/packages/billing/stripe/package.json index e9f1ff344..04a21d7ee 100644 --- a/packages/billing/stripe/package.json +++ b/packages/billing/stripe/package.json @@ -17,7 +17,7 @@ "dependencies": { "@stripe/react-stripe-js": "^3.6.0", "@stripe/stripe-js": "^7.1.0", - "stripe": "^17.7.0" + "stripe": "^18.0.0" }, "devDependencies": { "@kit/billing": "workspace:*", diff --git a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts index a6bd34da6..54374e984 100644 --- a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts +++ b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts @@ -380,16 +380,30 @@ export class StripeBillingStrategyService 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: subscription.items.data, + lineItems, status: subscription.status, currency: subscription.currency, cancelAtPeriodEnd: subscription.cancel_at_period_end, - periodStartsAt: subscription.current_period_start, - periodEndsAt: subscription.current_period_end, + periodStartsAt, + periodEndsAt, trialStartsAt: subscription.trial_start, trialEndsAt: subscription.trial_end, }); diff --git a/packages/billing/stripe/src/services/stripe-sdk.ts b/packages/billing/stripe/src/services/stripe-sdk.ts index 031e94ff3..c36a3fac2 100644 --- a/packages/billing/stripe/src/services/stripe-sdk.ts +++ b/packages/billing/stripe/src/services/stripe-sdk.ts @@ -2,7 +2,7 @@ import 'server-only'; import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema'; -const STRIPE_API_VERSION = '2025-02-24.acacia'; +const STRIPE_API_VERSION = '2025-03-31.basil'; /** * @description returns a Stripe instance diff --git a/packages/billing/stripe/src/services/stripe-subscription-payload-builder.service.ts b/packages/billing/stripe/src/services/stripe-subscription-payload-builder.service.ts index 5d68789cc..966ba56c3 100644 --- a/packages/billing/stripe/src/services/stripe-subscription-payload-builder.service.ts +++ b/packages/billing/stripe/src/services/stripe-subscription-payload-builder.service.ts @@ -1,6 +1,5 @@ -import Stripe from 'stripe'; +import type Stripe from 'stripe'; -import { BillingConfig, getLineItemTypeById } from '@kit/billing'; import { UpsertSubscriptionParams } from '@kit/billing/types'; /** @@ -16,19 +15,6 @@ export function createStripeSubscriptionPayloadBuilderService() { * @description This class is used to build the subscription payload for Stripe */ class StripeSubscriptionPayloadBuilderService { - private config?: BillingConfig; - - /** - * @name withBillingConfig - * @description Set the billing config for the subscription payload - * @param config - */ - withBillingConfig(config: BillingConfig) { - this.config = config; - - return this; - } - /** * @name build * @description Build the subscription payload for Stripe @@ -39,6 +25,7 @@ class StripeSubscriptionPayloadBuilderService { id: string; quantity?: number; price?: Stripe.Price; + type: 'flat' | 'per_seat' | 'metered'; }, >(params: { id: string; @@ -69,9 +56,7 @@ class StripeSubscriptionPayloadBuilderService { price_amount: item.price?.unit_amount, interval: item.price?.recurring?.interval as string, interval_count: item.price?.recurring?.interval_count as number, - type: this.config - ? getLineItemTypeById(this.config, variantId) - : undefined, + type: item.type, }; }); @@ -93,6 +78,42 @@ class StripeSubscriptionPayloadBuilderService { trial_ends_at: getISOString(params.trialEndsAt), }; } + + /** + * @name getPeriodStartsAt + * @description Get the period starts at for the subscription + * @param subscription + */ + getPeriodStartsAt(subscription: Stripe.Subscription) { + // for retro-compatibility, we need to check if the subscription has a period + + // if it does, we use the period start, otherwise we use the subscription start + // (Stripe 17 and below) + if ('current_period_start' in subscription) { + return subscription.current_period_start as number; + } + + // if it doesn't, we use the subscription item start (Stripe 18+) + return subscription.items.data[0]!.current_period_start; + } + + /** + * @name getPeriodEndsAt + * @description Get the period ends at for the subscription + * @param subscription + */ + getPeriodEndsAt(subscription: Stripe.Subscription) { + // for retro-compatibility, we need to check if the subscription has a period + + // if it does, we use the period end, otherwise we use the subscription end + // (Stripe 17 and below) + if ('current_period_end' in subscription) { + return subscription.current_period_end as number; + } + + // if it doesn't, we use the subscription item end (Stripe 18+) + return subscription.items.data[0]!.current_period_end; + } } function getISOString(date: number | null) { 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 10529bb72..3227bd715 100644 --- a/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts +++ b/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts @@ -1,6 +1,6 @@ -import Stripe from 'stripe'; +import type Stripe from 'stripe'; -import { BillingConfig, BillingWebhookHandlerService } from '@kit/billing'; +import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing'; import { getLogger } from '@kit/shared/logger'; import { Database, Enums } from '@kit/supabase/database'; @@ -36,7 +36,7 @@ export class StripeWebhookHandlerService { private stripe: Stripe | undefined; - constructor(private readonly config: BillingConfig) {} + constructor(private readonly planTypesMap: PlanTypeMap) {} private readonly provider: BillingProvider = 'stripe'; @@ -95,49 +95,99 @@ export class StripeWebhookHandlerService ) { switch (event.type) { case 'checkout.session.completed': { - return this.handleCheckoutSessionCompleted( + 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': { - return this.handleSubscriptionUpdatedEvent( + 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': { - return this.handleSubscriptionDeletedEvent( + 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': { - return this.handleAsyncPaymentFailed(event, params.onPaymentFailed); + 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': { - return this.handleAsyncPaymentSucceeded( + const result = await this.handleAsyncPaymentSucceeded( event, params.onPaymentSucceeded, ); + + // handle user-supplied handler + if (params.onEvent) { + await params.onEvent(event); + } + + return result; } case 'invoice.paid': { - return this.handleInvoicePaid(event, params.onInvoicePaid); + 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(); + const logger = await getLogger(); - Logger.info( + logger.debug( { eventType: event.type, name: this.namespace, @@ -173,21 +223,27 @@ export class StripeWebhookHandlerService const subscriptionId = session.subscription as string; const subscription = await stripe.subscriptions.retrieve(subscriptionId); - const payload = subscriptionPayloadBuilderService - .withBillingConfig(this.config) - .build({ - accountId, - customerId, - id: subscription.id, - 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, - }); + 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 { @@ -250,7 +306,7 @@ export class StripeWebhookHandlerService return onPaymentSucceeded(sessionId); } - private handleSubscriptionUpdatedEvent( + private async handleSubscriptionUpdatedEvent( event: Stripe.CustomerSubscriptionUpdatedEvent, onSubscriptionUpdatedCallback: ( subscription: UpsertSubscriptionParams, @@ -263,21 +319,27 @@ export class StripeWebhookHandlerService const subscriptionPayloadBuilderService = createStripeSubscriptionPayloadBuilderService(); - const payload = subscriptionPayloadBuilderService - .withBillingConfig(this.config) - .build({ - 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, - }); + 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); } @@ -296,38 +358,114 @@ export class StripeWebhookHandlerService onInvoicePaid: (data: UpsertSubscriptionParams) => Promise, ) { const stripe = await this.loadStripe(); - - const invoice = event.data.object; - const subscriptionId = invoice.subscription as string; - - // Retrieve the subscription - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - - // Here we need to retrieve the subscription and build the payload - const accountId = subscription.metadata.accountId as string; + const logger = await getLogger(); const subscriptionPayloadBuilderService = createStripeSubscriptionPayloadBuilderService(); - const payload = subscriptionPayloadBuilderService - .withBillingConfig(this.config) - .build({ - 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, - }); + 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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b360ba933..388f62667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -442,8 +442,8 @@ importers: specifier: ^7.1.0 version: 7.1.0 stripe: - specifier: ^17.7.0 - version: 17.7.0 + specifier: ^18.0.0 + version: 18.0.0 devDependencies: '@kit/billing': specifier: workspace:* @@ -7742,8 +7742,8 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - stripe@17.7.0: - resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} + stripe@18.0.0: + resolution: {integrity: sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA==} engines: {node: '>=12.*'} styled-jsx@5.1.6: @@ -11804,7 +11804,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 22.14.0 + '@types/node': 22.14.1 '@types/hast@3.0.4': dependencies: @@ -11889,7 +11889,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 22.14.0 + '@types/node': 22.14.1 '@types/tinycolor2@1.4.6': {} @@ -15164,7 +15164,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.14.0 + '@types/node': 22.14.1 long: 5.3.1 proxy-agent@6.5.0: @@ -15776,7 +15776,7 @@ snapshots: strip-json-comments@3.1.1: {} - stripe@17.7.0: + stripe@18.0.0: dependencies: '@types/node': 22.14.1 qs: 6.14.0