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