B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,18 @@
import type { Stripe } from 'stripe';
import { z } from 'zod';
import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
/**
* @name createStripeBillingPortalSession
* @description Create a Stripe billing portal session for a user
*/
export async function createStripeBillingPortalSession(
stripe: Stripe,
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
return stripe.billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
});
}

View File

@@ -0,0 +1,136 @@
import type { Stripe } from 'stripe';
import { z } from 'zod';
import type { CreateBillingCheckoutSchema } from '@kit/billing/schema';
/**
* @description If set to true, users can start a trial without entering their credit card details
*/
const enableTrialWithoutCreditCard =
process.env.STRIPE_ENABLE_TRIAL_WITHOUT_CC === 'true';
/**
* @name createStripeCheckout
* @description Creates a Stripe Checkout session, and returns an Object
* containing the session, which you can use to redirect the user to the
* checkout page
*/
export async function createStripeCheckout(
stripe: Stripe,
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
// in MakerKit, a subscription belongs to an organization,
// rather than to a user
// if you wish to change it, use the current user ID instead
const clientReferenceId = params.accountId;
// we pass an optional customer ID, so we do not duplicate the Stripe
// customers if an organization subscribes multiple times
const customer = params.customerId ?? undefined;
// docs: https://stripe.com/docs/billing/subscriptions/build-subscription
const mode: Stripe.Checkout.SessionCreateParams.Mode =
params.plan.paymentType === 'recurring' ? 'subscription' : 'payment';
const isSubscription = mode === 'subscription';
let trialDays: number | null | undefined = params.plan.trialDays;
// if the customer already exists, we do not set a trial period
if (customer) {
trialDays = undefined;
}
const trialSettings =
trialDays && enableTrialWithoutCreditCard
? {
trial_settings: {
end_behavior: {
missing_payment_method: 'cancel' as const,
},
},
}
: {};
// this should only be set if the mode is 'subscription'
const subscriptionData:
| Stripe.Checkout.SessionCreateParams.SubscriptionData
| undefined = isSubscription
? {
trial_period_days: trialDays,
metadata: {
accountId: params.accountId,
...(params.metadata ?? {}),
},
...trialSettings,
}
: {};
const urls = getUrls({
returnUrl: params.returnUrl,
});
// we use the embedded mode, so the user does not leave the page
const uiMode = 'embedded';
const customerData = customer
? {
customer,
}
: {
customer_email: params.customerEmail,
};
const customerCreation =
isSubscription || customer
? ({} as Record<string, string>)
: { customer_creation: 'always' };
const lineItems = params.plan.lineItems.map((item) => {
if (item.type === 'metered') {
return {
price: item.id,
};
}
// if we pass a custom quantity for the item ID
// we use that - otherwise we set it to 1 by default
const quantity =
params.variantQuantities.find((variant) => {
return variant.variantId === item.id;
})?.quantity ?? 1;
return {
price: item.id,
quantity,
};
});
const paymentCollectionMethod =
enableTrialWithoutCreditCard && params.plan.trialDays
? {
payment_method_collection: 'if_required' as const,
}
: {};
return stripe.checkout.sessions.create({
mode,
allow_promotion_codes: params.enableDiscountField,
ui_mode: uiMode,
line_items: lineItems,
client_reference_id: clientReferenceId,
subscription_data: subscriptionData,
...customerCreation,
...customerData,
...urls,
...paymentCollectionMethod,
});
}
function getUrls(params: { returnUrl: string }) {
const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
return {
return_url: returnUrl,
};
}

View File

@@ -0,0 +1,420 @@
import 'server-only';
import type { Stripe } from 'stripe';
import { z } from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import type {
CancelSubscriptionParamsSchema,
CreateBillingCheckoutSchema,
CreateBillingPortalSessionSchema,
QueryBillingUsageSchema,
ReportBillingUsageSchema,
RetrieveCheckoutSessionSchema,
UpdateSubscriptionParamsSchema,
} from '@kit/billing/schema';
import { getLogger } from '@kit/shared/logger';
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
import { createStripeCheckout } from './create-stripe-checkout';
import { createStripeClient } from './stripe-sdk';
import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service';
/**
* @name StripeBillingStrategyService
* @description The Stripe billing strategy service
* @class StripeBillingStrategyService
* @implements {BillingStrategyProviderService}
*/
export class StripeBillingStrategyService
implements BillingStrategyProviderService
{
private readonly namespace = 'billing.stripe';
/**
* @name createCheckoutSession
* @description Creates a checkout session for a customer
* @param params
*/
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
customerId: params.customerId,
accountId: params.accountId,
};
logger.info(ctx, 'Creating checkout session...');
const { client_secret } = await createStripeCheckout(stripe, params);
if (!client_secret) {
logger.error(ctx, 'Failed to create checkout session');
throw new Error('Failed to create checkout session');
}
logger.info(ctx, 'Checkout session created successfully');
return { checkoutToken: client_secret };
}
/**
* @name createBillingPortalSession
* @description Creates a billing portal session for a customer
* @param params
*/
async createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
customerId: params.customerId,
};
logger.info(ctx, 'Creating billing portal session...');
const session = await createStripeBillingPortalSession(stripe, params);
if (!session?.url) {
logger.error(ctx, 'Failed to create billing portal session');
} else {
logger.info(ctx, 'Billing portal session created successfully');
}
return session;
}
/**
* @name cancelSubscription
* @description Cancels a subscription
* @param params
*/
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionId: params.subscriptionId,
};
logger.info(ctx, 'Cancelling subscription...');
try {
await stripe.subscriptions.cancel(params.subscriptionId, {
invoice_now: params.invoiceNow ?? true,
});
logger.info(ctx, 'Subscription cancelled successfully');
return {
success: true,
};
} catch (error) {
logger.info(
{
...ctx,
error,
},
`Failed to cancel subscription. It may have already been cancelled on the user's end.`,
);
return {
success: false,
};
}
}
/**
* @name retrieveCheckoutSession
* @description Retrieves a checkout session
* @param params
*/
async retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
sessionId: params.sessionId,
};
logger.info(ctx, 'Retrieving checkout session...');
try {
const session = await stripe.checkout.sessions.retrieve(params.sessionId);
const isSessionOpen = session.status === 'open';
logger.info(ctx, 'Checkout session retrieved successfully');
return {
checkoutToken: session.client_secret,
isSessionOpen,
status: session.status ?? 'complete',
customer: {
email: session.customer_details?.email ?? null,
},
};
} catch (error) {
logger.error(
{
...ctx,
error,
},
'Failed to retrieve checkout session',
);
throw new Error('Failed to retrieve checkout session');
}
}
/**
* @name reportUsage
* @description Reports usage for a subscription with the Metrics API
* @param params
*/
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionItemId: params.id,
usage: params.usage,
};
logger.info(ctx, 'Reporting usage...');
if (!params.eventName) {
logger.error(ctx, 'Event name is required');
throw new Error('Event name is required when reporting Metrics');
}
try {
await stripe.billing.meterEvents.create({
event_name: params.eventName,
payload: {
value: params.usage.quantity.toString(),
stripe_customer_id: params.id,
},
});
} catch (error) {
logger.error(
{
...ctx,
error,
},
'Failed to report usage',
);
throw new Error('Failed to report usage');
}
return {
success: true,
};
}
/**
* @name queryUsage
* @description Reports the total usage for a subscription with the Metrics API
*/
async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
id: params.id,
customerId: params.customerId,
};
// validate shape of filters for Stripe
if (!('startTime' in params.filter)) {
logger.error(ctx, 'Start and end time are required for Stripe');
throw new Error('Start and end time are required when querying usage');
}
logger.info(ctx, 'Querying billing usage...');
try {
const summaries = await stripe.billing.meters.listEventSummaries(
params.id,
{
customer: params.customerId,
start_time: params.filter.startTime,
end_time: params.filter.endTime,
},
);
logger.info(ctx, 'Billing usage queried successfully');
const value = summaries.data.reduce((acc, summary) => {
return acc + Number(summary.aggregated_value);
}, 0);
return {
value,
};
} catch (error) {
logger.error(
{
...ctx,
error,
},
'Failed to report usage',
);
throw new Error('Failed to report usage');
}
}
/**
* @name updateSubscriptionItem
* @description Updates a subscription
* @param params
*/
async updateSubscriptionItem(
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionId: params.subscriptionId,
subscriptionItemId: params.subscriptionItemId,
quantity: params.quantity,
};
logger.info(ctx, 'Updating subscription...');
try {
await stripe.subscriptions.update(params.subscriptionId, {
items: [
{
id: params.subscriptionItemId,
quantity: params.quantity,
},
],
});
logger.info(ctx, 'Subscription updated successfully');
return { success: true };
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to update subscription');
throw new Error('Failed to update subscription');
}
}
/**
* @name getPlanById
* @description Retrieves a plan by id
* @param planId
*/
async getPlanById(planId: string) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
planId,
};
logger.info(ctx, 'Retrieving plan by id...');
const stripe = await this.stripeProvider();
try {
const plan = await stripe.plans.retrieve(planId);
logger.info(ctx, 'Plan retrieved successfully');
return {
id: plan.id,
name: plan.nickname ?? '',
amount: plan.amount ?? 0,
interval: plan.interval,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to retrieve plan');
throw new Error('Failed to retrieve plan');
}
}
async getSubscription(subscriptionId: string) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionId,
};
logger.info(ctx, 'Retrieving subscription...');
const subscriptionPayloadBuilder =
createStripeSubscriptionPayloadBuilderService();
try {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
logger.info(ctx, 'Subscription retrieved successfully');
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,
status: subscription.status,
currency: subscription.currency,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
periodStartsAt,
periodEndsAt,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to retrieve subscription');
throw new Error('Failed to retrieve subscription');
}
}
private async stripeProvider(): Promise<Stripe> {
return createStripeClient();
}
}

View File

@@ -0,0 +1,22 @@
import 'server-only';
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
const STRIPE_API_VERSION = '2025-04-30.basil';
/**
* @description returns a Stripe instance
*/
export async function createStripeClient() {
const { default: Stripe } = await import('stripe');
// Parse the environment variables and validate them
const stripeServerEnv = StripeServerEnvSchema.parse({
secretKey: process.env.STRIPE_SECRET_KEY,
webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET,
});
return new Stripe(stripeServerEnv.secretKey, {
apiVersion: STRIPE_API_VERSION,
});
}

View File

@@ -0,0 +1,121 @@
import type Stripe from 'stripe';
import { UpsertSubscriptionParams } from '@kit/billing/types';
/**
* @name createStripeSubscriptionPayloadBuilderService
* @description Create a new instance of the `StripeSubscriptionPayloadBuilderService` class
*/
export function createStripeSubscriptionPayloadBuilderService() {
return new StripeSubscriptionPayloadBuilderService();
}
/**
* @name StripeSubscriptionPayloadBuilderService
* @description This class is used to build the subscription payload for Stripe
*/
class StripeSubscriptionPayloadBuilderService {
/**
* @name build
* @description Build the subscription payload for Stripe
* @param params
*/
build<
LineItem extends {
id: string;
quantity?: number;
price?: Stripe.Price;
type: 'flat' | 'per_seat' | 'metered';
},
>(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,
subscription_item_id: item.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: item.type,
};
});
// 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: 'stripe',
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),
};
}
/**
* @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) {
return date ? new Date(date * 1000).toISOString() : undefined;
}

View File

@@ -0,0 +1,476 @@
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['public']['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['public']['Functions']['upsert_order']['Args'];
type BillingProvider = Enums<'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;
}
}