Refactor and improve billing module

The billing module has been refined and enhanced to include deeper validation and detailing of billing plans and products. The checkout session creation process was revised to handle more complex scenarios, incorporating better parsing and validation. Additional validations were added for the plan and product schemas, improving product details extraction, and rearranging of module exports was made for better organization. The code refactor allows easier future modifications and upgrades for recurring and one-time payments with nuanced product configurations.
This commit is contained in:
giancarlo
2024-03-27 21:06:34 +08:00
parent 7579ee9a2c
commit c3a4a05b22
22 changed files with 578 additions and 225 deletions

View File

@@ -54,12 +54,13 @@ export function PersonalAccountCheckoutForm() {
<PlanPicker <PlanPicker
pending={pending} pending={pending}
config={billingConfig} config={billingConfig}
onSubmit={({ planId }) => { onSubmit={({ planId, productId }) => {
startTransition(async () => { startTransition(async () => {
try { try {
const { checkoutToken } = const { checkoutToken } =
await createPersonalAccountCheckoutSession({ await createPersonalAccountCheckoutSession({
planId, planId,
productId,
}); });
setCheckoutToken(checkoutToken); setCheckoutToken(checkoutToken);

View File

@@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
import { z } from 'zod'; import { z } from 'zod';
import { getProductPlanPairFromId } from '@kit/billing'; import { getLineItemsFromPlanId } from '@kit/billing';
import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { requireAuth } from '@kit/supabase/require-auth'; import { requireAuth } from '@kit/supabase/require-auth';
@@ -22,6 +22,7 @@ import pathsConfig from '~/config/paths.config';
*/ */
export async function createPersonalAccountCheckoutSession(params: { export async function createPersonalAccountCheckoutSession(params: {
planId: string; planId: string;
productId: string;
}) { }) {
const client = getSupabaseServerActionClient(); const client = getSupabaseServerActionClient();
const { data, error } = await requireAuth(client); const { data, error } = await requireAuth(client);
@@ -30,21 +31,22 @@ export async function createPersonalAccountCheckoutSession(params: {
throw new Error('Authentication required'); throw new Error('Authentication required');
} }
const planId = z.string().min(1).parse(params.planId); const { planId, productId } = z
.object({
planId: z.string().min(1),
productId: z.string().min(1),
})
.parse(params);
Logger.info( Logger.info(
{ {
planId, planId,
productId,
}, },
`Creating checkout session for plan ID`, `Creating checkout session for plan ID`,
); );
const service = await getBillingGatewayProvider(client); const service = await getBillingGatewayProvider(client);
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
if (!productPlanPairFromId) {
throw new Error('Product not found');
}
// in the case of personal accounts // in the case of personal accounts
// the account ID is the same as the user ID // the account ID is the same as the user ID
@@ -57,16 +59,21 @@ export async function createPersonalAccountCheckoutSession(params: {
// (eg. if the account has been billed before) // (eg. if the account has been billed before)
const customerId = await getCustomerIdFromAccountId(accountId); const customerId = await getCustomerIdFromAccountId(accountId);
// retrieve the product and plan from the billing configuration const product = billingConfig.products.find((item) => item.id === productId);
const { product, plan } = productPlanPairFromId;
if (!product) {
throw new Error('Product not found');
}
const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId);
// call the payment gateway to create the checkout session // call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({ const { checkoutToken } = await service.createCheckoutSession({
paymentType: product.paymentType, lineItems,
returnUrl, returnUrl,
accountId, accountId,
planId, trialDays,
trialPeriodDays: plan.trialPeriodDays, paymentType: product.paymentType,
customerEmail: data.user.email, customerEmail: data.user.email,
customerId, customerId,
}); });

View File

@@ -45,14 +45,15 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) {
<PlanPicker <PlanPicker
pending={pending} pending={pending}
config={billingConfig} config={billingConfig}
onSubmit={({ planId }) => { onSubmit={({ planId, productId }) => {
startTransition(async () => { startTransition(async () => {
const slug = routeParams.account as string; const slug = routeParams.account as string;
const { checkoutToken } = await createTeamAccountCheckoutSession({ const { checkoutToken } = await createTeamAccountCheckoutSession({
planId, planId,
accountId: params.accountId, productId,
slug, slug,
accountId: params.accountId,
}); });
setCheckoutToken(checkoutToken); setCheckoutToken(checkoutToken);

View File

@@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
import { z } from 'zod'; import { z } from 'zod';
import { getProductPlanPairFromId } from '@kit/billing'; import { getLineItemsFromPlanId } from '@kit/billing';
import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { requireAuth } from '@kit/supabase/require-auth'; import { requireAuth } from '@kit/supabase/require-auth';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
@@ -20,6 +20,7 @@ import pathsConfig from '~/config/paths.config';
* @param {string} params.planId - The ID of the plan to be associated with the account. * @param {string} params.planId - The ID of the plan to be associated with the account.
*/ */
export async function createTeamAccountCheckoutSession(params: { export async function createTeamAccountCheckoutSession(params: {
productId: string;
planId: string; planId: string;
accountId: string; accountId: string;
slug: string; slug: string;
@@ -29,6 +30,7 @@ export async function createTeamAccountCheckoutSession(params: {
// we parse the plan ID from the parameters // we parse the plan ID from the parameters
// no need in continuing if the plan ID is not valid // no need in continuing if the plan ID is not valid
const planId = z.string().min(1).parse(params.planId); const planId = z.string().min(1).parse(params.planId);
const productId = z.string().min(1).parse(params.productId);
// we require the user to be authenticated // we require the user to be authenticated
const { data: session } = await requireAuth(client); const { data: session } = await requireAuth(client);
@@ -51,32 +53,34 @@ export async function createTeamAccountCheckoutSession(params: {
// here we have confirmed that the user has permission to manage billing for the account // here we have confirmed that the user has permission to manage billing for the account
// so we go on and create a checkout session // so we go on and create a checkout session
const service = await getBillingGatewayProvider(client); const service = await getBillingGatewayProvider(client);
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
if (!productPlanPairFromId) { const product = billingConfig.products.find(
(product) => product.id === productId,
);
if (!product) {
throw new Error('Product not found'); throw new Error('Product not found');
} }
// the return URL for the checkout session const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId);
const returnUrl = getCheckoutSessionReturnUrl(params.slug);
// find the customer ID for the account if it exists // find the customer ID for the account if it exists
// (eg. if the account has been billed before) // (eg. if the account has been billed before)
const customerId = await getCustomerIdFromAccountId(client, accountId); const customerId = await getCustomerIdFromAccountId(client, accountId);
const customerEmail = session.user.email; const customerEmail = session.user.email;
// retrieve the product and plan from the billing configuration // the return URL for the checkout session
const { product, plan } = productPlanPairFromId; const returnUrl = getCheckoutSessionReturnUrl(params.slug);
// call the payment gateway to create the checkout session // call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({ const { checkoutToken } = await service.createCheckoutSession({
accountId, accountId,
lineItems,
returnUrl, returnUrl,
planId,
customerEmail, customerEmail,
customerId, customerId,
trialDays,
paymentType: product.paymentType, paymentType: product.paymentType,
trialPeriodDays: plan.trialPeriodDays,
}); });
// return the checkout token to the client // return the checkout token to the client

View File

@@ -8,22 +8,24 @@ export default createBillingSchema({
name: 'Starter', name: 'Starter',
description: 'The perfect plan to get started', description: 'The perfect plan to get started',
currency: 'USD', currency: 'USD',
paymentType: 'recurring',
badge: `Value`, badge: `Value`,
paymentType: 'recurring',
plans: [ plans: [
{ {
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Starter Monthly', name: 'Starter Monthly',
price: '9.99', id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
interval: 'month', price: 9.99,
perSeat: false, recurring: {
interval: 'month',
},
}, },
{ {
id: 'starter-yearly',
name: 'Starter Yearly', name: 'Starter Yearly',
price: '99.99', id: 'starter-yearly',
interval: 'year', price: 99.99,
perSeat: false, recurring: {
interval: 'year',
},
}, },
], ],
features: ['Feature 1', 'Feature 2', 'Feature 3'], features: ['Feature 1', 'Feature 2', 'Feature 3'],
@@ -34,22 +36,24 @@ export default createBillingSchema({
badge: `Popular`, badge: `Popular`,
highlighted: true, highlighted: true,
description: 'The perfect plan for professionals', description: 'The perfect plan for professionals',
paymentType: 'recurring',
currency: 'USD', currency: 'USD',
paymentType: 'recurring',
plans: [ plans: [
{ {
id: 'pro-monthly',
name: 'Pro Monthly', name: 'Pro Monthly',
price: '19.99', id: 'pro-monthly',
interval: 'month', price: 19.99,
perSeat: false, recurring: {
interval: 'month',
},
}, },
{ {
id: 'pro-yearly',
name: 'Pro Yearly', name: 'Pro Yearly',
price: '199.99', id: 'pro-yearly',
interval: 'year', price: 199.99,
perSeat: false, recurring: {
interval: 'year',
},
}, },
], ],
features: [ features: [
@@ -64,22 +68,24 @@ export default createBillingSchema({
id: 'enterprise', id: 'enterprise',
name: 'Enterprise', name: 'Enterprise',
description: 'The perfect plan for enterprises', description: 'The perfect plan for enterprises',
paymentType: 'recurring',
currency: 'USD', currency: 'USD',
paymentType: 'recurring',
plans: [ plans: [
{ {
id: 'enterprise-monthly',
name: 'Enterprise Monthly', name: 'Enterprise Monthly',
price: '99.99', id: 'enterprise-monthly',
interval: 'month', price: 99.99,
perSeat: false, recurring: {
interval: 'month',
},
}, },
{ {
id: 'enterprise-yearly',
name: 'Enterprise Yearly', name: 'Enterprise Yearly',
price: '999.99', id: 'enterprise-yearly',
interval: 'year', price: 999.99,
perSeat: false, recurring: {
interval: 'year',
},
}, },
], ],
features: [ features: [

View File

@@ -78,7 +78,7 @@ function buildLazyComponent<
return ( return (
<Suspense fallback={fallback}> <Suspense fallback={fallback}>
{/* @ts-ignore */} {/* @ts-expect-error */}
<LoadedComponent <LoadedComponent
onClose={props.onClose} onClose={props.onClose}
checkoutToken={props.checkoutToken} checkoutToken={props.checkoutToken}

View File

@@ -1,11 +1,18 @@
'use client'; 'use client';
import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { BillingSchema } from '@kit/billing'; import {
BillingSchema,
RecurringPlanSchema,
getPlanIntervals,
getProductPlanPairFromId,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils'; import { formatCurrency } from '@kit/shared/utils';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
@@ -28,15 +35,14 @@ import { cn } from '@kit/ui/utils';
export function PlanPicker( export function PlanPicker(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
config: z.infer<typeof BillingSchema>; config: z.infer<typeof BillingSchema>;
onSubmit: (data: { planId: string }) => void; onSubmit: (data: { planId: string; productId: string }) => void;
pending?: boolean; pending?: boolean;
}>, }>,
) { ) {
const intervals = props.config.products.reduce<string[]>((acc, item) => { const intervals = useMemo(
return Array.from( () => getPlanIntervals(props.config),
new Set([...acc, ...item.plans.map((plan) => plan.interval)]), [props.config],
); );
}, []);
const form = useForm({ const form = useForm({
reValidateMode: 'onChange', reValidateMode: 'onChange',
@@ -44,20 +50,17 @@ export function PlanPicker(
resolver: zodResolver( resolver: zodResolver(
z z
.object({ .object({
planId: z.string(), planId: z.string().min(1),
interval: z.string(), interval: z.string().min(1),
}) })
.refine( .refine(
(data) => { (data) => {
const planFound = props.config.products const { product, plan } = getProductPlanPairFromId(
.flatMap((item) => item.plans) props.config,
.some((plan) => plan.id === data.planId); data.planId,
);
if (!planFound) { return product && plan;
return false;
}
return intervals.includes(data.interval);
}, },
{ message: `Please pick a plan to continue`, path: ['planId'] }, { message: `Please pick a plan to continue`, path: ['planId'] },
), ),
@@ -65,6 +68,7 @@ export function PlanPicker(
defaultValues: { defaultValues: {
interval: intervals[0], interval: intervals[0],
planId: '', planId: '',
productId: '',
}, },
}); });
@@ -81,9 +85,11 @@ export function PlanPicker(
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem className={'rounded-md border p-4'}> <FormItem className={'rounded-md border p-4'}>
<FormLabel>Choose your billing interval</FormLabel> <FormLabel htmlFor={'plan-picker-id'}>
Choose your billing interval
</FormLabel>
<FormControl> <FormControl id={'plan-picker-id'}>
<RadioGroup name={field.name} value={field.value}> <RadioGroup name={field.name} value={field.value}>
<div className={'flex space-x-2.5'}> <div className={'flex space-x-2.5'}>
{intervals.map((interval) => { {intervals.map((interval) => {
@@ -91,6 +97,7 @@ export function PlanPicker(
return ( return (
<label <label
htmlFor={interval}
key={interval} key={interval}
className={cn( className={cn(
'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2', 'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
@@ -107,6 +114,7 @@ export function PlanPicker(
form.setValue('planId', '', { form.setValue('planId', '', {
shouldValidate: true, shouldValidate: true,
}); });
form.setValue('interval', interval, { form.setValue('interval', interval, {
shouldValidate: true, shouldValidate: true,
}); });
@@ -138,25 +146,38 @@ export function PlanPicker(
<FormControl> <FormControl>
<RadioGroup name={field.name}> <RadioGroup name={field.name}>
{props.config.products.map((item) => { {props.config.products.map((product) => {
const variant = item.plans.find( const plan =
(plan) => plan.interval === selectedInterval, product.paymentType === 'one-time'
); ? product.plans[0]
: product.plans.find((item) => {
if (
'recurring' in item &&
(item as z.infer<typeof RecurringPlanSchema>)
.recurring.interval === selectedInterval
) {
return item;
}
});
if (!variant) { if (!plan) {
throw new Error('No plan found'); throw new Error('Plan not found');
} }
return ( return (
<RadioGroupItemLabel <RadioGroupItemLabel
selected={field.value === variant.id} selected={field.value === plan.id}
key={variant.id} key={plan.id}
> >
<RadioGroupItem <RadioGroupItem
id={variant.id} id={plan.id}
value={variant.id} value={plan.id}
onClick={() => { onClick={() => {
form.setValue('planId', variant.id, { form.setValue('planId', plan.id, {
shouldValidate: true,
});
form.setValue('productId', product.id, {
shouldValidate: true, shouldValidate: true,
}); });
}} }}
@@ -166,23 +187,23 @@ export function PlanPicker(
className={'flex w-full items-center justify-between'} className={'flex w-full items-center justify-between'}
> >
<Label <Label
htmlFor={variant.id} htmlFor={plan.id}
className={'flex flex-col justify-center space-y-2'} className={'flex flex-col justify-center space-y-2'}
> >
<span className="font-bold">{item.name}</span> <span className="font-bold">{product.name}</span>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>
{item.description} {product.description}
</span> </span>
</Label> </Label>
<div className={'text-right'}> <div className={'text-right'}>
<div> <div>
<Price key={variant.id}> <Price key={plan.id}>
<span> <span>
{formatCurrency( {formatCurrency(
item.currency.toLowerCase(), product.currency.toLowerCase(),
variant.price, plan.price,
)} )}
</span> </span>
</Price> </Price>
@@ -190,7 +211,7 @@ export function PlanPicker(
<div> <div>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>
per {variant.interval} per {selectedInterval}
</span> </span>
</div> </div>
</div> </div>
@@ -207,7 +228,7 @@ export function PlanPicker(
/> />
<div> <div>
<Button disabled={props.pending || !form.formState.isValid}> <Button disabled={props.pending ?? !form.formState.isValid}>
{props.pending ? ( {props.pending ? (
'Processing...' 'Processing...'
) : ( ) : (

View File

@@ -7,7 +7,13 @@ import Link from 'next/link';
import { CheckCircle, Sparkles } from 'lucide-react'; import { CheckCircle, Sparkles } from 'lucide-react';
import { z } from 'zod'; import { z } from 'zod';
import { BillingSchema, getPlanIntervals } from '@kit/billing'; import {
BillingSchema,
RecurringPlanInterval,
RecurringPlanSchema,
getPlanIntervals,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
@@ -15,6 +21,7 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
type Config = z.infer<typeof BillingSchema>; type Config = z.infer<typeof BillingSchema>;
type Interval = z.infer<typeof RecurringPlanInterval>;
interface Paths { interface Paths {
signUp: string; signUp: string;
@@ -33,20 +40,20 @@ export function PricingTable({
highlighted?: boolean; highlighted?: boolean;
}>; }>;
}) { }) {
const intervals = getPlanIntervals(config); const intervals = getPlanIntervals(config).filter(Boolean);
const [planVariant, setPlanVariant] = useState<string>( const [interval, setInterval] = useState<Interval>(intervals[0]!);
intervals[0] as string,
);
return ( return (
<div className={'flex flex-col space-y-12'}> <div className={'flex flex-col space-y-12'}>
<div className={'flex justify-center'}> <div className={'flex justify-center'}>
<PlanIntervalSwitcher {intervals.length ? (
intervals={intervals} <PlanIntervalSwitcher
interval={planVariant} intervals={intervals}
setInterval={setPlanVariant} interval={interval}
/> setInterval={setInterval}
/>
) : null}
</div> </div>
<div <div
@@ -56,21 +63,28 @@ export function PricingTable({
} }
> >
{config.products.map((product) => { {config.products.map((product) => {
const plan = product.plans.find( const plan = product.plans.find((item) =>
(item) => item.interval === planVariant, 'recurring' in item
? (item as z.infer<typeof RecurringPlanSchema>).recurring
.interval === interval
: true,
); );
if (!plan || product.hidden) { if (!plan) {
console.warn(`No plan found for ${product.name}`); console.warn(`No plan found for ${product.name}`);
return; return;
} }
if (product.hidden) {
return null;
}
return ( return (
<PricingItem <PricingItem
selectable selectable
key={plan.id} key={plan.id}
plan={plan} plan={{ ...plan, interval }}
product={product} product={product}
paths={paths} paths={paths}
CheckoutButton={CheckoutButtonRenderer} CheckoutButton={CheckoutButtonRenderer}
@@ -92,7 +106,7 @@ function PricingItem(
plan: { plan: {
id: string; id: string;
price: string; price: number;
interval: string; interval: string;
name?: string; name?: string;
href?: string; href?: string;
@@ -158,8 +172,7 @@ function PricingItem(
<div className={'flex items-center space-x-1'}> <div className={'flex items-center space-x-1'}>
<Price> <Price>
<span className={'text-base'}>{props.product.currency}</span> {formatCurrency(props.product.currency, props.plan.price)}
{props.plan.price}
</Price> </Price>
<If condition={props.plan.name}> <If condition={props.plan.name}>
@@ -249,9 +262,9 @@ function ListItem({ children }: React.PropsWithChildren) {
function PlanIntervalSwitcher( function PlanIntervalSwitcher(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
intervals: string[]; intervals: Interval[];
interval: string; interval: Interval;
setInterval: (interval: string) => void; setInterval: (interval: Interval) => void;
}>, }>,
) { ) {
return ( return (

View File

@@ -95,7 +95,7 @@ export class BillingEventHandlerService {
Logger.info(ctx, 'Processing checkout session completed event...'); Logger.info(ctx, 'Processing checkout session completed event...');
const { id, ...data } = subscription; const { id: _, ...data } = subscription;
const { error } = await client.rpc('add_subscription', { const { error } = await client.rpc('add_subscription', {
...data, ...data,

View File

@@ -1,96 +1,221 @@
import { z } from 'zod'; import { z } from 'zod';
const Interval = z.enum(['month', 'year']); export const RecurringPlanInterval = z.enum(['month', 'year']);
const PaymentType = z.enum(['recurring', 'one-time']);
export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']); export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']);
const PlanSchema = z.object({ export const PaymentType = z.enum(['recurring', 'one-time']);
id: z.string().min(1),
export const LineItemUsageType = z.enum(['licensed', 'metered']);
const RecurringLineItemSchema = z
.object({
id: z.string().min(1),
interval: RecurringPlanInterval,
metered: z.boolean().optional().default(false),
costPerUnit: z.number().positive().optional(),
perSeat: z.boolean().default(false).optional().default(false),
usageType: LineItemUsageType.optional().default('licensed'),
})
.refine(
(schema) => {
if (!schema.metered && schema.perSeat) {
return false;
}
return true;
},
{
message: 'Line item must be either metered or a member seat',
path: ['metered', 'perSeat'],
},
)
.refine(
(schema) => {
if (!schema.metered && !schema.usageType) {
return false;
}
return true;
},
{
message: 'Line item must have a usage type',
path: ['usageType'],
},
);
const RecurringSchema = z
.object({
interval: RecurringPlanInterval,
metered: z.boolean().optional(),
costPerUnit: z.number().positive().optional(),
perSeat: z.boolean().optional(),
usageType: LineItemUsageType.optional(),
addOns: z.array(RecurringLineItemSchema).default([]).optional(),
})
.refine(
(schema) => {
if (schema.metered) {
return schema.costPerUnit !== undefined;
}
},
{
message: 'Metered plans must have a cost per unit',
path: ['costPerUnit'],
},
)
.refine(
(schema) => {
if (schema.perSeat && !schema.metered) {
return false;
}
return true;
},
{
message: 'Per seat plans must be metered',
path: ['perSeat'],
},
)
.refine(
(schema) => {
return schema.metered && schema.usageType;
},
{
message: 'Metered plans must have a usage type',
path: ['usageType'],
},
);
export const RecurringPlanSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
price: z.string().min(1).max(100), id: z.string().min(1),
trialPeriodDays: z.number().optional(), price: z.number().positive(),
interval: Interval, trialDays: z.number().positive().optional(),
perSeat: z.boolean().optional().default(false), recurring: RecurringSchema,
}); });
const ProductSchema = z.object({ export const OneTimePlanSchema = z.object({
id: z.string(), id: z.string().min(1),
name: z.string(), name: z.string().min(1).max(100),
description: z.string(), price: z.number().positive(),
currency: z.string().optional().default('USD'),
plans: z.array(PlanSchema),
features: z.array(z.string()),
badge: z.string().optional(),
highlighted: z.boolean().optional(),
hidden: z.boolean().optional(),
paymentType: PaymentType.optional().default('recurring'),
}); });
export const ProductSchema = z
.object({
id: z.string(),
name: z.string(),
description: z.string(),
currency: z.string().optional().default('USD'),
plans: z.union([
RecurringPlanSchema.array().nonempty(),
OneTimePlanSchema.array().nonempty(),
]),
paymentType: PaymentType,
features: z.array(z.string()),
badge: z.string().min(1).optional(),
highlighted: z.boolean().default(false).optional(),
hidden: z.boolean().default(false).optional(),
})
.refine(
(schema) => {
console.log(schema);
const recurringPlans = schema.plans.filter((plan) => 'recurring' in plan);
if (recurringPlans.length && schema.paymentType === 'one-time') {
return false;
}
return true;
},
{
message: 'One-time products cannot have recurring plans',
path: ['paymentType'],
},
)
.refine(
(schema) => {
const recurringPlans = schema.plans.filter((plan) => 'recurring' in plan);
if (recurringPlans.length === 0 && schema.paymentType === 'recurring') {
return false;
}
return true;
},
{
message:
'The product must have at least one recurring plan if the payment type is recurring',
path: ['paymentType'],
},
)
.refine(
(schema) => {
return !(schema.paymentType === 'one-time' && schema.plans.length > 1);
},
{
message: 'One-time products can only have one plan',
path: ['plans'],
},
);
export const BillingSchema = z export const BillingSchema = z
.object({ .object({
products: z.array(ProductSchema), products: z.array(ProductSchema).nonempty(),
provider: BillingProvider, provider: BillingProvider,
}) })
.refine((schema) => { .refine(
// verify dupe product ids (schema) => {
const ids = schema.products.map((product) => product.id); const ids = schema.products.map((product) => product.id);
if (new Set(ids).size !== ids.length) { return new Set(ids).size === ids.length;
return { },
message: 'Duplicate product IDs', {
path: ['products'], message: 'Duplicate product IDs',
}; path: ['products'],
} },
)
.refine(
(schema) => {
const planIds = getAllPlanIds(schema);
return true; return new Set(planIds).size === planIds.length;
}) },
.refine((schema) => { {
// verify dupe plan ids message: 'Duplicate plan IDs',
const planIds = schema.products.flatMap((product) => path: ['products'],
product.plans.map((plan) => plan.id), },
); );
if (new Set(planIds).size !== planIds.length) {
return {
message: 'Duplicate plan IDs',
path: ['products'],
};
}
return true;
});
/** /**
* Create and validate the billing schema * Create and validate the billing schema
* @param config * @param config The billing configuration
*/ */
export function createBillingSchema(config: z.infer<typeof BillingSchema>) { export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
return BillingSchema.parse(config); return BillingSchema.parse(config);
} }
/**
* Returns an array of billing plans based on the provided configuration.
*
* @param {Object} config - The configuration object containing product and plan information.
*/
export function getBillingPlans(config: z.infer<typeof BillingSchema>) {
return config.products.flatMap((product) => product.plans);
}
/** /**
* Retrieves the intervals of all plans specified in the given configuration. * Retrieves the intervals of all plans specified in the given configuration.
* * @param config The billing configuration containing products and plans.
* @param {Object} config - The billing configuration containing products and plans.
*/ */
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) { export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
return Array.from( return Array.from(
new Set( new Set(
config.products.flatMap((product) => config.products.flatMap((product) => {
product.plans.map((plan) => plan.interval), const isRecurring = product.paymentType === 'recurring';
),
if (isRecurring) {
const plans = product.plans as z.infer<typeof RecurringPlanSchema>[];
return plans.map((plan) => plan.recurring.interval);
}
return [];
}),
), ),
); ).filter(Boolean);
} }
export function getProductPlanPairFromId( export function getProductPlanPairFromId(
@@ -98,12 +223,30 @@ export function getProductPlanPairFromId(
planId: string, planId: string,
) { ) {
for (const product of config.products) { for (const product of config.products) {
const plan = product.plans.find((plan) => plan.id === planId); for (const plan of product.plans) {
if (plan.id === planId) {
if (plan) { return { product, plan };
return { product, plan }; }
} }
} }
throw new Error(`Plan with ID ${planId} not found`); throw new Error('Plan not found');
}
export function getAllPlanIds(config: z.infer<typeof BillingSchema>) {
const ids: string[] = [];
for (const product of config.products) {
for (const plan of product.plans) {
ids.push(plan.id);
}
}
return ids;
}
export function isRecurringPlan(
plan: z.infer<typeof RecurringPlanSchema | typeof OneTimePlanSchema>,
): plan is z.infer<typeof RecurringPlanSchema> {
return 'recurring' in plan;
} }

View File

@@ -1,3 +1,4 @@
export * from './create-billing-schema'; export * from './create-billing-schema';
export * from './services/billing-strategy-provider.service'; export * from './services/billing-strategy-provider.service';
export * from './services/billing-webhook-handler.service'; export * from './services/billing-webhook-handler.service';
export * from './line-items-mapper';

View File

@@ -0,0 +1,51 @@
import { z } from 'zod';
import { ProductSchema, isRecurringPlan } from './create-billing-schema';
export function getLineItemsFromPlanId(
product: z.infer<typeof ProductSchema>,
planId: string,
) {
const plan = product.plans.find((plan) => plan.id === planId);
if (!plan) {
throw new Error('Plan not found');
}
const lineItems = [];
let trialDays = undefined;
if (isRecurringPlan(plan)) {
const lineItem: {
id: string;
quantity: number;
usageType?: 'metered' | 'licensed';
} = {
id: plan.id,
quantity: 1,
};
trialDays = plan.trialDays;
if (plan.recurring.usageType) {
lineItem.usageType = plan.recurring.usageType;
}
lineItems.push(lineItem);
if (plan.recurring.addOns) {
for (const addOn of plan.recurring.addOns) {
lineItems.push({
id: addOn.id,
quantity: 1,
});
}
}
}
return {
lineItems,
trialDays,
};
}

View File

@@ -1,13 +1,31 @@
import { z } from 'zod'; import { z } from 'zod';
export const CreateBillingCheckoutSchema = z.object({ import { LineItemUsageType, PaymentType } from '../create-billing-schema';
returnUrl: z.string().url(),
accountId: z.string(),
planId: z.string(),
paymentType: z.enum(['recurring', 'one-time']),
trialPeriodDays: z.number().optional(), export const CreateBillingCheckoutSchema = z
.object({
customerId: z.string().optional(), returnUrl: z.string().url(),
customerEmail: z.string().optional(), accountId: z.string(),
}); paymentType: PaymentType,
lineItems: z.array(
z.object({
id: z.string(),
quantity: z.number().int().positive(),
usageType: LineItemUsageType.optional(),
}),
),
trialDays: z.number().optional(),
customerId: z.string().optional(),
customerEmail: z.string().optional(),
})
.refine(
(schema) => {
if (schema.paymentType === 'one-time' && schema.trialDays) {
return false;
}
},
{
message: 'Trial days are only allowed for recurring payments',
path: ['trialDays'],
},
);

View File

@@ -15,6 +15,7 @@
"./hooks/*": "./src/hooks/*.ts" "./hooks/*": "./src/hooks/*.ts"
}, },
"devDependencies": { "devDependencies": {
"@kit/billing-gateway": "*",
"@kit/eslint-config": "0.2.0", "@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0", "@kit/prettier-config": "0.1.0",
"@kit/shared": "*", "@kit/shared": "*",

View File

@@ -1,6 +1,6 @@
'use server'; 'use server';
import { redirect } from 'next/navigation'; import { RedirectType, redirect } from 'next/navigation';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { requireAuth } from '@kit/supabase/require-auth'; import { requireAuth } from '@kit/supabase/require-auth';
@@ -33,28 +33,13 @@ export async function deletePersonalAccountAction(formData: FormData) {
`Deleting personal account...`, `Deleting personal account...`,
); );
const deleteAccountResponse = await service.deletePersonalAccount( await service.deletePersonalAccount(
getSupabaseServerActionClient({ admin: true }), getSupabaseServerActionClient({ admin: true }),
{ {
userId, userId,
}, },
); );
//
// also delete any associated data and subscriptions
if (deleteAccountResponse.error) {
Logger.error(
{
error: deleteAccountResponse.error,
name: 'accounts',
},
`Error deleting personal account`,
);
throw new Error('Error deleting personal account');
}
Logger.info( Logger.info(
{ {
userId, userId,
@@ -65,5 +50,5 @@ export async function deletePersonalAccountAction(formData: FormData) {
await client.auth.signOut(); await client.auth.signOut();
redirect('/'); redirect('/', RedirectType.replace);
} }

View File

@@ -1,5 +1,7 @@
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { BillingGatewayService } from '@kit/billing-gateway';
import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
/** /**
@@ -11,12 +13,95 @@ import { Database } from '@kit/supabase/database';
* const accountsService = new AccountsService(client); * const accountsService = new AccountsService(client);
*/ */
export class PersonalAccountsService { export class PersonalAccountsService {
private namespace = 'account';
constructor(private readonly client: SupabaseClient<Database>) {} constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name deletePersonalAccount
* Delete personal account of a user.
* This will delete the user from the authentication provider and cancel all subscriptions.
*/
async deletePersonalAccount( async deletePersonalAccount(
adminClient: SupabaseClient<Database>, adminClient: SupabaseClient<Database>,
params: { userId: string }, params: { userId: string },
) { ) {
return adminClient.auth.admin.deleteUser(params.userId); Logger.info(
{ userId: params.userId, name: this.namespace },
'User requested deletion. Processing...',
);
try {
await adminClient.auth.admin.deleteUser(params.userId);
} catch (error) {
Logger.error(
{
userId: params.userId,
error,
name: this.namespace,
},
'Error deleting user',
);
throw new Error('Error deleting user');
}
try {
await this.cancelAllUserSubscriptions(params.userId);
} catch (error) {
Logger.error({
userId: params.userId,
error,
name: this.namespace,
});
}
}
private async cancelAllUserSubscriptions(userId: string) {
Logger.info(
{
userId,
name: this.namespace,
},
'Cancelling all subscriptions for user...',
);
const { data: subscriptions } = await this.client
.from('subscriptions')
.select('*')
.eq('account_id', userId);
const cancellationRequests = [];
Logger.info(
{
userId,
subscriptions: subscriptions?.length ?? 0,
name: this.namespace,
},
'Cancelling subscriptions...',
);
for (const subscription of subscriptions ?? []) {
const gateway = new BillingGatewayService(subscription.billing_provider);
cancellationRequests.push(
gateway.cancelSubscription({
subscriptionId: subscription.id,
invoiceNow: true,
}),
);
}
await Promise.all(cancellationRequests);
Logger.info(
{
userId,
subscriptions: subscriptions?.length ?? 0,
name: this.namespace,
},
'Subscriptions cancelled successfully',
);
} }
} }

View File

@@ -3,13 +3,13 @@
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { DeleteTeamAccountSchema } from '../schema/delete-team-account.schema'; import { DeleteTeamAccountSchema } from '../schema/delete-team-account.schema';
import { DeleteAccountService } from '../services/delete-account.service'; import { DeleteTeamAccountService } from '../services/delete-team-account.service';
export async function deleteTeamAccountAction(formData: FormData) { export async function deleteTeamAccountAction(formData: FormData) {
const body = Object.fromEntries(formData.entries()); const body = Object.fromEntries(formData.entries());
const params = DeleteTeamAccountSchema.parse(body); const params = DeleteTeamAccountSchema.parse(body);
const client = getSupabaseServerActionClient(); const client = getSupabaseServerActionClient();
const service = new DeleteAccountService(client); const service = new DeleteTeamAccountService(client);
await service.deleteTeamAccount(params); await service.deleteTeamAccount(params);

View File

@@ -4,7 +4,7 @@ import 'server-only';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
export class DeleteAccountService { export class DeleteTeamAccountService {
constructor(private readonly client: SupabaseClient<Database>) {} constructor(private readonly client: SupabaseClient<Database>) {}
async deleteTeamAccount(params: { accountId: string }) { async deleteTeamAccount(params: { accountId: string }) {

View File

@@ -12,6 +12,7 @@ type Config = z.infer<typeof MailerSchema>;
*/ */
export class CloudflareMailer implements Mailer { export class CloudflareMailer implements Mailer {
async sendEmail(config: Config) { async sendEmail(config: Config) {
console.log('Sending email with Cloudflare Workers', config);
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
} }

View File

@@ -26,19 +26,18 @@ export async function createStripeCheckout(
const mode: Stripe.Checkout.SessionCreateParams.Mode = const mode: Stripe.Checkout.SessionCreateParams.Mode =
params.paymentType === 'recurring' ? 'subscription' : 'payment'; params.paymentType === 'recurring' ? 'subscription' : 'payment';
// TODO: support multiple line items and per-seat pricing // this should only be set if the mode is 'subscription'
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = { const subscriptionData:
quantity: 1, | Stripe.Checkout.SessionCreateParams.SubscriptionData
price: params.planId, | undefined =
}; mode === 'subscription'
? {
const subscriptionData: Stripe.Checkout.SessionCreateParams.SubscriptionData = trial_period_days: params.trialDays,
{ metadata: {
trial_period_days: params.trialPeriodDays, accountId: params.accountId,
metadata: { },
accountId: params.accountId, }
}, : undefined;
};
const urls = getUrls({ const urls = getUrls({
returnUrl: params.returnUrl, returnUrl: params.returnUrl,
@@ -55,10 +54,23 @@ export async function createStripeCheckout(
customer_email: params.customerEmail, customer_email: params.customerEmail,
}; };
const lineItems = params.lineItems.map((item) => {
if (item.usageType === 'metered') {
return {
price: item.id,
};
}
return {
price: item.id,
quantity: item.quantity,
};
});
return stripe.checkout.sessions.create({ return stripe.checkout.sessions.create({
mode, mode,
ui_mode: uiMode, ui_mode: uiMode,
line_items: [lineItem], line_items: lineItems,
client_reference_id: clientReferenceId, client_reference_id: clientReferenceId,
subscription_data: subscriptionData, subscription_data: subscriptionData,
...customerData, ...customerData,

View File

@@ -2,7 +2,7 @@ import type { MDXComponents } from 'mdx/types';
import { getMDXComponent } from 'next-contentlayer/hooks'; import { getMDXComponent } from 'next-contentlayer/hooks';
import Components from './mdx-components'; import Components from './mdx-components';
// @ts-ignore // @ts-expect-error
import styles from './mdx-renderer.module.css'; import styles from './mdx-renderer.module.css';
export function Mdx({ export function Mdx({

3
pnpm-lock.yaml generated
View File

@@ -283,6 +283,9 @@ importers:
packages/features/accounts: packages/features/accounts:
devDependencies: devDependencies:
'@kit/billing-gateway':
specifier: '*'
version: link:../../billing-gateway
'@kit/eslint-config': '@kit/eslint-config':
specifier: 0.2.0 specifier: 0.2.0
version: link:../../../tooling/eslint version: link:../../../tooling/eslint