Files
medreport_mrb2b/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts
2025-07-09 13:31:37 +03:00

477 lines
13 KiB
TypeScript

import type Stripe from 'stripe';
import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { Database, Enums } from '@kit/supabase/database';
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
import { createStripeClient } from './stripe-sdk';
import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service';
type UpsertSubscriptionParams =
Database['medreport']['Functions']['upsert_subscription']['Args'] & {
line_items: Array<LineItem>;
};
interface LineItem {
id: string;
quantity: number;
subscription_id: string;
subscription_item_id: string;
product_id: string;
variant_id: string;
price_amount: number | null | undefined;
interval: string;
interval_count: number;
type: 'flat' | 'metered' | 'per_seat' | undefined;
}
type UpsertOrderParams =
Database['medreport']['Functions']['upsert_order']['Args'];
type BillingProvider = Enums<{ schema: 'medreport' }, 'billing_provider'>;
export class StripeWebhookHandlerService
implements BillingWebhookHandlerService
{
private stripe: Stripe | undefined;
constructor(private readonly planTypesMap: PlanTypeMap) {}
private readonly provider: BillingProvider = 'stripe';
private readonly namespace = 'billing.stripe';
/**
* @name verifyWebhookSignature
* @description Verifies the webhook signature - should throw an error if the signature is invalid
*/
async verifyWebhookSignature(request: Request) {
const body = await request.clone().text();
const signatureKey = `stripe-signature`;
const signature = request.headers.get(signatureKey)!;
const { webhooksSecret } = StripeServerEnvSchema.parse({
secretKey: process.env.STRIPE_SECRET_KEY,
webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET,
});
const stripe = await this.loadStripe();
const event = await stripe.webhooks.constructEventAsync(
body,
signature,
webhooksSecret,
);
if (!event) {
throw new Error('Invalid signature');
}
return event;
}
/**
* @name handleWebhookEvent
* @description Handle the webhook event from the billing provider
* @param event
* @param params
*/
async handleWebhookEvent(
event: Stripe.Event,
params: {
onCheckoutSessionCompleted: (
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>;
onSubscriptionUpdated: (
data: UpsertSubscriptionParams,
) => Promise<unknown>;
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
onPaymentFailed: (sessionId: string) => Promise<unknown>;
onInvoicePaid: (data: UpsertSubscriptionParams) => Promise<unknown>;
onEvent?(event: Stripe.Event): Promise<unknown>;
},
) {
switch (event.type) {
case 'checkout.session.completed': {
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': {
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': {
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': {
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': {
const result = await this.handleAsyncPaymentSucceeded(
event,
params.onPaymentSucceeded,
);
// handle user-supplied handler
if (params.onEvent) {
await params.onEvent(event);
}
return result;
}
case 'invoice.paid': {
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();
logger.debug(
{
eventType: event.type,
name: this.namespace,
},
`Unhandled Stripe event type: ${event.type}`,
);
return;
}
}
}
private async handleCheckoutSessionCompleted(
event: Stripe.CheckoutSessionCompletedEvent,
onCheckoutCompletedCallback: (
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>,
) {
const stripe = await this.loadStripe();
const session = event.data.object;
const isSubscription = session.mode === 'subscription';
const accountId = session.client_reference_id!;
const customerId = session.customer as string;
// if it's a subscription, we need to retrieve the subscription
// and build the payload for the subscription
if (isSubscription) {
const subscriptionPayloadBuilderService =
createStripeSubscriptionPayloadBuilderService();
const subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
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 {
// if it's a one-time payment, we need to retrieve the session
const sessionId = event.data.object.id;
// from the session, we need to retrieve the line items
const sessionWithLineItems = await stripe.checkout.sessions.retrieve(
event.data.object.id,
{
expand: ['line_items'],
},
);
const lineItems = sessionWithLineItems.line_items?.data ?? [];
const paymentStatus = sessionWithLineItems.payment_status;
const status = paymentStatus === 'unpaid' ? 'pending' : 'succeeded';
const currency = event.data.object.currency as string;
const payload: UpsertOrderParams = {
target_account_id: accountId,
target_customer_id: customerId,
target_order_id: sessionId,
billing_provider: this.provider,
status: status,
currency: currency,
total_amount: sessionWithLineItems.amount_total ?? 0,
line_items: lineItems.map((item) => {
const price = item.price as Stripe.Price;
return {
id: item.id,
product_id: price.product as string,
variant_id: price.id,
price_amount: price.unit_amount,
quantity: item.quantity,
};
}),
};
return onCheckoutCompletedCallback(payload);
}
}
private handleAsyncPaymentFailed(
event: Stripe.CheckoutSessionAsyncPaymentFailedEvent,
onPaymentFailed: (sessionId: string) => Promise<unknown>,
) {
const sessionId = event.data.object.id;
return onPaymentFailed(sessionId);
}
private handleAsyncPaymentSucceeded(
event: Stripe.CheckoutSessionAsyncPaymentSucceededEvent,
onPaymentSucceeded: (sessionId: string) => Promise<unknown>,
) {
const sessionId = event.data.object.id;
return onPaymentSucceeded(sessionId);
}
private async handleSubscriptionUpdatedEvent(
event: Stripe.CustomerSubscriptionUpdatedEvent,
onSubscriptionUpdatedCallback: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>,
) {
const subscription = event.data.object;
const subscriptionId = subscription.id;
const accountId = subscription.metadata.accountId as string;
const subscriptionPayloadBuilderService =
createStripeSubscriptionPayloadBuilderService();
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);
}
private handleSubscriptionDeletedEvent(
event: Stripe.CustomerSubscriptionDeletedEvent,
onSubscriptionDeletedCallback: (subscriptionId: string) => Promise<unknown>,
) {
// Here we don't need to do anything, so we just return the callback
return onSubscriptionDeletedCallback(event.data.object.id);
}
private async handleInvoicePaid(
event: Stripe.InvoicePaidEvent,
onInvoicePaid: (data: UpsertSubscriptionParams) => Promise<unknown>,
) {
const stripe = await this.loadStripe();
const logger = await getLogger();
const subscriptionPayloadBuilderService =
createStripeSubscriptionPayloadBuilderService();
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();
}
return this.stripe;
}
}