B2B-88: add starter kit structure and elements
This commit is contained in:
1
packages/billing/stripe/src/components/index.ts
Normal file
1
packages/billing/stripe/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './stripe-embedded-checkout';
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
EmbeddedCheckout,
|
||||
EmbeddedCheckoutProvider,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from '@kit/ui/dialog';
|
||||
|
||||
import { StripeClientEnvSchema } from '../schema/stripe-client-env.schema';
|
||||
|
||||
const { publishableKey } = StripeClientEnvSchema.parse({
|
||||
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
||||
});
|
||||
|
||||
const stripePromise = loadStripe(publishableKey);
|
||||
|
||||
export function StripeCheckout({
|
||||
checkoutToken,
|
||||
onClose,
|
||||
}: React.PropsWithChildren<{
|
||||
checkoutToken: string;
|
||||
onClose?: () => void;
|
||||
}>) {
|
||||
return (
|
||||
<EmbeddedCheckoutPopup key={checkoutToken} onClose={onClose}>
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{ clientSecret: checkoutToken }}
|
||||
>
|
||||
<EmbeddedCheckout className={'EmbeddedCheckoutClassName'} />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</EmbeddedCheckoutPopup>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedCheckoutPopup({
|
||||
onClose,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
onClose?: () => void;
|
||||
}>) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const className = `bg-white p-4 overflow-y-auto shadow-transparent border`;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
defaultOpen
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
style={{
|
||||
maxHeight: '98vh',
|
||||
}}
|
||||
className={className}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className={'hidden'}>Checkout</DialogTitle>
|
||||
<div>{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
2
packages/billing/stripe/src/index.ts
Normal file
2
packages/billing/stripe/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { StripeBillingStrategyService } from './services/stripe-billing-strategy.service';
|
||||
export { StripeWebhookHandlerService } from './services/stripe-webhook-handler.service';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const StripeClientEnvSchema = z
|
||||
.object({
|
||||
publishableKey: z.string().min(1),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
return schema.publishableKey.startsWith('pk_');
|
||||
},
|
||||
{
|
||||
path: ['publishableKey'],
|
||||
message: `Stripe publishable key must start with 'pk_'`,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const StripeServerEnvSchema = z
|
||||
.object({
|
||||
secretKey: z
|
||||
.string({
|
||||
required_error: `Please provide the variable STRIPE_SECRET_KEY`,
|
||||
})
|
||||
.min(1),
|
||||
webhooksSecret: z
|
||||
.string({
|
||||
required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const key = schema.secretKey;
|
||||
const secretKeyPrefix = 'sk_';
|
||||
const restrictKeyPrefix = 'rk_';
|
||||
|
||||
return (
|
||||
key.startsWith(secretKeyPrefix) || key.startsWith(restrictKeyPrefix)
|
||||
);
|
||||
},
|
||||
{
|
||||
path: ['STRIPE_SECRET_KEY'],
|
||||
message: `Stripe secret key must start with 'sk_' or 'rk_'`,
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
return schema.webhooksSecret.startsWith('whsec_');
|
||||
},
|
||||
{
|
||||
path: ['STRIPE_WEBHOOK_SECRET'],
|
||||
message: `Stripe webhook secret must start with 'whsec_'`,
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
136
packages/billing/stripe/src/services/create-stripe-checkout.ts
Normal file
136
packages/billing/stripe/src/services/create-stripe-checkout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
22
packages/billing/stripe/src/services/stripe-sdk.ts
Normal file
22
packages/billing/stripe/src/services/stripe-sdk.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user