Add handling for paid invoices in billing services
In response to paid invoices, a new callback method has been introduced in the billing services. This feature includes changes in billing-webhook-handler.service.ts, lemon-squeezy-webhook-handler.service.ts, and other related files. The new method fetches and handles paid invoice information from Stripe and LemonSqueezy subscriptions.
This commit is contained in:
@@ -42,6 +42,12 @@ export abstract class BillingWebhookHandlerService {
|
|||||||
// one-time payments
|
// one-time payments
|
||||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||||
|
|
||||||
|
// 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<unknown>;
|
||||||
|
|
||||||
// generic handler for any event
|
// generic handler for any event
|
||||||
onEvent?: (data: unknown) => Promise<unknown>;
|
onEvent?: (data: unknown) => Promise<unknown>;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface CustomHandlersParams {
|
|||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||||
|
onInvoicePaid: (subscription: UpsertSubscriptionParams) => Promise<unknown>;
|
||||||
onEvent(event: unknown): Promise<unknown>;
|
onEvent(event: unknown): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ class BillingEventHandlerService {
|
|||||||
*/
|
*/
|
||||||
async handleWebhookEvent(
|
async handleWebhookEvent(
|
||||||
request: Request,
|
request: Request,
|
||||||
params: Partial<CustomHandlersParams> = {}
|
params: Partial<CustomHandlersParams> = {},
|
||||||
) {
|
) {
|
||||||
const event = await this.strategy.verifyWebhookSignature(request);
|
const event = await this.strategy.verifyWebhookSignature(request);
|
||||||
|
|
||||||
@@ -273,6 +274,11 @@ class BillingEventHandlerService {
|
|||||||
|
|
||||||
logger.info(ctx, 'Successfully updated payment status');
|
logger.info(ctx, 'Successfully updated payment status');
|
||||||
},
|
},
|
||||||
|
onInvoicePaid: async (payload) => {
|
||||||
|
if (params.onInvoicePaid) {
|
||||||
|
return params.onInvoicePaid(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
onEvent: params.onEvent,
|
onEvent: params.onEvent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { BillingConfig, BillingWebhookHandlerService } from '@kit/billing';
|
||||||
import { getLogger } from '@kit/shared/logger';
|
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 { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
|
||||||
import { OrderWebhook } from '../types/order-webhook';
|
import { OrderWebhook } from '../types/order-webhook';
|
||||||
|
import { SubscriptionInvoiceWebhook } from '../types/subscription-invoice-webhook';
|
||||||
import { SubscriptionWebhook } from '../types/subscription-webhook';
|
import { SubscriptionWebhook } from '../types/subscription-webhook';
|
||||||
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||||
import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service';
|
import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service';
|
||||||
@@ -89,7 +94,7 @@ export class LemonSqueezyWebhookHandlerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleWebhookEvent(
|
async handleWebhookEvent(
|
||||||
event: OrderWebhook | SubscriptionWebhook,
|
event: OrderWebhook | SubscriptionWebhook | SubscriptionInvoiceWebhook,
|
||||||
params: {
|
params: {
|
||||||
onCheckoutSessionCompleted: (
|
onCheckoutSessionCompleted: (
|
||||||
data: UpsertSubscriptionParams | UpsertOrderParams,
|
data: UpsertSubscriptionParams | UpsertOrderParams,
|
||||||
@@ -100,7 +105,10 @@ export class LemonSqueezyWebhookHandlerService
|
|||||||
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
||||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||||
onEvent?: (event: OrderWebhook | SubscriptionWebhook) => Promise<unknown>;
|
onInvoicePaid: (
|
||||||
|
data: UpsertSubscriptionParams | UpsertOrderParams,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
onEvent?: (event: unknown) => Promise<unknown>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const eventName = event.meta.event_name;
|
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: {
|
default: {
|
||||||
if (params.onEvent) {
|
if (params.onEvent) {
|
||||||
return params.onEvent(event);
|
return params.onEvent(event);
|
||||||
@@ -298,6 +313,80 @@ export class LemonSqueezyWebhookHandlerService
|
|||||||
return onSubscriptionDeletedCallback(subscription.data.id);
|
return onSubscriptionDeletedCallback(subscription.data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleInvoicePaid(
|
||||||
|
event: SubscriptionInvoiceWebhook,
|
||||||
|
onInvoicePaidCallback: (
|
||||||
|
subscription: UpsertSubscriptionParams,
|
||||||
|
) => Promise<unknown>,
|
||||||
|
) {
|
||||||
|
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) {
|
private getOrderStatus(status: OrderStatus) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'paid':
|
case 'paid':
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -88,7 +88,10 @@ export class StripeWebhookHandlerService
|
|||||||
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
||||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||||
onEvent?: (event: Stripe.Event) => Promise<unknown>;
|
onInvoicePaid: (
|
||||||
|
data: UpsertSubscriptionParams,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
onEvent?(event: Stripe.Event): Promise<unknown>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -124,6 +127,10 @@ export class StripeWebhookHandlerService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'invoice.paid': {
|
||||||
|
return this.handleInvoicePaid(event, params.onInvoicePaid);
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
if (params.onEvent) {
|
if (params.onEvent) {
|
||||||
return params.onEvent(event);
|
return params.onEvent(event);
|
||||||
@@ -285,6 +292,45 @@ export class StripeWebhookHandlerService
|
|||||||
return onSubscriptionDeletedCallback(event.data.object.id);
|
return onSubscriptionDeletedCallback(event.data.object.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleInvoicePaid(
|
||||||
|
event: Stripe.InvoicePaidEvent,
|
||||||
|
onInvoicePaid: (
|
||||||
|
data: UpsertSubscriptionParams,
|
||||||
|
) => Promise<unknown>,
|
||||||
|
) {
|
||||||
|
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() {
|
private async loadStripe() {
|
||||||
if (!this.stripe) {
|
if (!this.stripe) {
|
||||||
this.stripe = await createStripeClient();
|
this.stripe = await createStripeClient();
|
||||||
|
|||||||
Reference in New Issue
Block a user