Files
myeasycms-v2/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts
giancarlo ebb8fc08fe 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.
2024-04-16 11:55:43 +08:00

361 lines
11 KiB
TypeScript

import Stripe from 'stripe';
import {
BillingConfig,
BillingWebhookHandlerService,
getLineItemTypeById,
} from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
import { createStripeClient } from './stripe-sdk';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
export class StripeWebhookHandlerService
implements BillingWebhookHandlerService
{
private stripe: Stripe | undefined;
constructor(private readonly config: BillingConfig) {}
private readonly provider: Database['public']['Enums']['billing_provider'] =
'stripe';
private readonly namespace = 'billing.stripe';
/**
* @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 = stripe.webhooks.constructEvent(
body,
signature,
webhooksSecret,
);
if (!event) {
throw new Error('Invalid signature');
}
return event;
}
private async loadStripe() {
if (!this.stripe) {
this.stripe = await createStripeClient();
}
return this.stripe;
}
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: (eventType: string) => Promise<unknown>;
},
) {
switch (event.type) {
case 'checkout.session.completed': {
return this.handleCheckoutSessionCompleted(
event,
params.onCheckoutSessionCompleted,
);
}
case 'customer.subscription.updated': {
return this.handleSubscriptionUpdatedEvent(
event,
params.onSubscriptionUpdated,
);
}
case 'customer.subscription.deleted': {
return this.handleSubscriptionDeletedEvent(
event,
params.onSubscriptionDeleted,
);
}
case 'checkout.session.async_payment_failed': {
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,
params.onPaymentSucceeded,
);
}
default: {
if (params.onEvent) {
return params.onEvent(event.type);
}
const Logger = await getLogger();
Logger.info(
{
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 subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const payload = this.buildSubscriptionPayload({
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,
});
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 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 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 onSubscriptionUpdatedCallback(payload);
}
private handleSubscriptionDeletedEvent(
subscription: Stripe.CustomerSubscriptionDeletedEvent,
onSubscriptionDeletedCallback: (subscriptionId: string) => Promise<unknown>,
) {
// Here we don't need to do anything, so we just return the callback
return onSubscriptionDeletedCallback(subscription.id);
}
private buildSubscriptionPayload<
LineItem extends {
id: string;
quantity?: number;
price?: Stripe.Price;
},
>(params: {
id: string;
accountId: string;
customerId: string;
lineItems: LineItem[];
status: Stripe.Subscription.Status;
currency: string;
cancelAtPeriodEnd: boolean;
periodStartsAt: number;
periodEndsAt: number;
trialStartsAt: number | null;
trialEndsAt: number | null;
}): UpsertSubscriptionParams {
const active = params.status === 'active' || params.status === 'trialing';
const lineItems = params.lineItems.map((item) => {
const quantity = item.quantity ?? 1;
const variantId = item.price?.id as string;
return {
id: item.id,
quantity,
subscription_id: params.id,
product_id: item.price?.product as string,
variant_id: variantId,
price_amount: item.price?.unit_amount,
interval: item.price?.recurring?.interval as string,
interval_count: item.price?.recurring?.interval_count as number,
type: getLineItemTypeById(this.config, variantId),
};
});
// otherwise we are updating a subscription
// and we only need to return the update payload
return {
target_subscription_id: params.id,
target_account_id: params.accountId,
target_customer_id: params.customerId,
billing_provider: this.provider,
status: params.status,
line_items: lineItems,
active,
currency: params.currency,
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: getISOString(params.trialStartsAt),
trial_ends_at: getISOString(params.trialEndsAt),
};
}
private async handleInvoicePaid(
event: Stripe.InvoicePaidEvent,
onInvoicePaid: (params: UpsertSubscriptionParams) => Promise<unknown>,
) {
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) {
return date ? new Date(date * 1000).toISOString() : undefined;
}