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 60ffc0ea1..5e4a88f6f 100644 --- a/packages/billing/core/src/services/billing-webhook-handler.service.ts +++ b/packages/billing/core/src/services/billing-webhook-handler.service.ts @@ -42,6 +42,12 @@ export abstract class BillingWebhookHandlerService { // one-time payments onPaymentFailed: (sessionId: string) => Promise; + // this method is called when an invoice is paid. We don't have a specific use case for this + // but it's extremely common for credit-based systems + onInvoicePaid: ( + subscription: UpsertSubscriptionParams, + ) => Promise; + // generic handler for any event onEvent?: (data: unknown) => 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 7da20387e..8caef6c75 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 @@ -26,6 +26,7 @@ interface CustomHandlersParams { ) => Promise; onPaymentSucceeded: (sessionId: string) => Promise; onPaymentFailed: (sessionId: string) => Promise; + onInvoicePaid: (subscription: UpsertSubscriptionParams) => Promise; onEvent(event: unknown): Promise; } @@ -62,7 +63,7 @@ class BillingEventHandlerService { */ async handleWebhookEvent( request: Request, - params: Partial = {} + params: Partial = {}, ) { const event = await this.strategy.verifyWebhookSignature(request); @@ -273,6 +274,11 @@ class BillingEventHandlerService { logger.info(ctx, 'Successfully updated payment status'); }, + onInvoicePaid: async (payload) => { + if (params.onInvoicePaid) { + return params.onInvoicePaid(payload); + } + }, onEvent: params.onEvent, }); } 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 5989a8740..0e8d55d96 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 @@ -1,4 +1,8 @@ -import { getOrder, getVariant } from '@lemonsqueezy/lemonsqueezy.js'; +import { + getOrder, + getSubscription, + getVariant, +} from '@lemonsqueezy/lemonsqueezy.js'; import { BillingConfig, BillingWebhookHandlerService } from '@kit/billing'; import { getLogger } from '@kit/shared/logger'; @@ -6,6 +10,7 @@ import { Database } from '@kit/supabase/database'; import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema'; import { OrderWebhook } from '../types/order-webhook'; +import { SubscriptionInvoiceWebhook } from '../types/subscription-invoice-webhook'; import { SubscriptionWebhook } from '../types/subscription-webhook'; import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk'; import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service'; @@ -89,7 +94,7 @@ export class LemonSqueezyWebhookHandlerService } async handleWebhookEvent( - event: OrderWebhook | SubscriptionWebhook, + event: OrderWebhook | SubscriptionWebhook | SubscriptionInvoiceWebhook, params: { onCheckoutSessionCompleted: ( data: UpsertSubscriptionParams | UpsertOrderParams, @@ -100,7 +105,10 @@ export class LemonSqueezyWebhookHandlerService onSubscriptionDeleted: (subscriptionId: string) => Promise; onPaymentSucceeded: (sessionId: string) => Promise; onPaymentFailed: (sessionId: string) => Promise; - onEvent?: (event: OrderWebhook | SubscriptionWebhook) => Promise; + onInvoicePaid: ( + data: UpsertSubscriptionParams | UpsertOrderParams, + ) => Promise; + onEvent?: (event: unknown) => Promise; }, ) { const eventName = event.meta.event_name; @@ -134,6 +142,13 @@ export class LemonSqueezyWebhookHandlerService ); } + case 'subscription_payment_success': { + return this.handleInvoicePaid( + event as SubscriptionInvoiceWebhook, + params.onInvoicePaid, + ); + } + default: { if (params.onEvent) { return params.onEvent(event); @@ -298,6 +313,80 @@ export class LemonSqueezyWebhookHandlerService return onSubscriptionDeletedCallback(subscription.data.id); } + private async handleInvoicePaid( + event: SubscriptionInvoiceWebhook, + onInvoicePaidCallback: ( + subscription: UpsertSubscriptionParams, + ) => Promise, + ) { + await initializeLemonSqueezyClient(); + + const attrs = event.data.attributes; + const subscriptionId = event.data.id; + const accountId = event.meta.custom_data.account_id; + const customerId = attrs.customer_id.toString(); + const status = attrs.status; + const createdAt = attrs.created_at; + + const { data: subscriptionResponse } = + await getSubscription(subscriptionId); + const subscription = subscriptionResponse?.data.attributes; + + if (!subscription) { + const logger = await getLogger(); + + logger.error( + { + subscriptionId, + accountId, + name: this.namespace, + }, + 'Failed to fetch subscription', + ); + + return; + } + + const variantId = subscription.variant_id; + const productId = subscription.product_id; + const endsAt = subscription.ends_at; + const renewsAt = subscription.renews_at; + const trialEndsAt = subscription.trial_ends_at; + const intervalCount = subscription.billing_anchor; + const interval = intervalCount === 1 ? 'month' : 'year'; + + const payloadBuilderService = + createLemonSqueezySubscriptionPayloadBuilderService(); + + const lineItems = [ + { + id: subscription.order_item_id.toString(), + product: productId.toString(), + variant: variantId.toString(), + quantity: subscription.first_subscription_item?.quantity ?? 1, + priceAmount: attrs.total, + }, + ]; + + const payload = payloadBuilderService.withBillingConfig(this.config).build({ + customerId, + id: subscriptionId, + accountId, + lineItems, + status, + interval, + intervalCount, + currency: attrs.currency, + periodStartsAt: new Date(createdAt).getTime(), + periodEndsAt: new Date(renewsAt ?? endsAt).getTime(), + cancelAtPeriodEnd: subscription.cancelled, + trialStartsAt: trialEndsAt ? new Date(createdAt).getTime() : null, + trialEndsAt: trialEndsAt ? new Date(trialEndsAt).getTime() : null, + }); + + return onInvoicePaidCallback(payload); + } + private getOrderStatus(status: OrderStatus) { switch (status) { case 'paid': diff --git a/packages/billing/lemon-squeezy/src/types/subscription-invoice-webhook.ts b/packages/billing/lemon-squeezy/src/types/subscription-invoice-webhook.ts new file mode 100644 index 000000000..a50b71a07 --- /dev/null +++ b/packages/billing/lemon-squeezy/src/types/subscription-invoice-webhook.ts @@ -0,0 +1,53 @@ +export interface SubscriptionInvoiceWebhook { + meta: Meta; + data: Data; +} + +interface Data { + type: string; + id: string; + attributes: Attributes; +} + +interface Meta { + event_name: string; + custom_data: { + account_id: string; + }; +} + +interface Attributes { + store_id: number; + subscription_id: number; + customer_id: number; + user_name: string; + user_email: string; + billing_reason: string; + card_brand: string; + card_last_four: string; + currency: string; + currency_rate: string; + status: string; + status_formatted: string; + refunded: boolean; + refunded_at: string | null; + subtotal: number; + discount_total: number; + tax: number; + tax_inclusive: boolean; + total: number; + subtotal_usd: number; + discount_total_usd: number; + tax_usd: number; + total_usd: number; + subtotal_formatted: string; + discount_total_formatted: string; + tax_formatted: string; + total_formatted: string; + urls: { + invoice_url: string; + }; + created_at: string; + updated_at: string; + test_mode: boolean; +} 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 0f8ff66e9..e414ffd39 100644 --- a/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts +++ b/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts @@ -88,7 +88,10 @@ export class StripeWebhookHandlerService onSubscriptionDeleted: (subscriptionId: string) => Promise; onPaymentSucceeded: (sessionId: string) => Promise; onPaymentFailed: (sessionId: string) => Promise; - onEvent?: (event: Stripe.Event) => Promise; + onInvoicePaid: ( + data: UpsertSubscriptionParams, + ) => Promise; + onEvent?(event: Stripe.Event): Promise; }, ) { switch (event.type) { @@ -124,6 +127,10 @@ export class StripeWebhookHandlerService ); } + case 'invoice.paid': { + return this.handleInvoicePaid(event, params.onInvoicePaid); + } + default: { if (params.onEvent) { return params.onEvent(event); @@ -285,6 +292,45 @@ export class StripeWebhookHandlerService return onSubscriptionDeletedCallback(event.data.object.id); } + private async handleInvoicePaid( + event: Stripe.InvoicePaidEvent, + onInvoicePaid: ( + data: UpsertSubscriptionParams, + ) => Promise, + ) { + const stripe = await this.loadStripe(); + + const invoice = event.data.object; + const subscriptionId = invoice.subscription as string; + + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['line_items'], + }); + + const accountId = subscription.metadata.accountId as string; + + 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, + }); + + return onInvoicePaid(payload); + } + private async loadStripe() { if (!this.stripe) { this.stripe = await createStripeClient();