Refactor billing schema for increased flexibility

The billing schema has been revamped to allow more flexible billing setups, supporting multiple line items per plan. Changes extend to related app and UI components for a seamless experience. As a result, the previously used 'line-items-mapper.ts' is no longer needed and has been removed.
This commit is contained in:
giancarlo
2024-03-30 00:59:06 +08:00
parent 163eff6583
commit f93af31009
18 changed files with 1120 additions and 1213 deletions

View File

@@ -1,5 +1,18 @@
# SITE
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_PRODUCT_NAME=Makerkit NEXT_PUBLIC_PRODUCT_NAME=Makerkit
NEXT_PUBLIC_SITE_TITLE="Makerkit - The easiest way to build and manage your SaaS"
NEXT_PUBLIC_SITE_DESCRIPTION="Makerkit is the easiest way to build and manage your SaaS. It provides you with the tools you need to build your SaaS, without the hassle of building it from scratch."
NEXT_PUBLIC_DEFAULT_THEME_MODE=light
NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
# AUTH
NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
# BILLING
NEXT_PUBLIC_BILLING_PROVIDER=stripe
# SUPABASE # SUPABASE
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321

View File

@@ -1 +1,4 @@
## DO NOT ADD VARS HERE UNLESS THEY ARE PUBLIC ## DO NOT ADD VARS HERE UNLESS THEY ARE PUBLIC (eg. prefixed with NEXT_PUBLIC_)
NEXT_PUBLIC_PRODUCT_NAME=Makerkit
NEXT_PUBLIC_BILLING_PROVIDER=stripe

View File

@@ -62,7 +62,11 @@ export async function createTeamAccountCheckoutSession(params: {
throw new Error('Product not found'); throw new Error('Product not found');
} }
const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId); const plan = product?.plans.find((plan) => plan.id === planId);
if (!plan) {
throw new Error('Plan not found');
}
// 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)
@@ -75,12 +79,10 @@ export async function createTeamAccountCheckoutSession(params: {
// 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, plan,
returnUrl, returnUrl,
customerEmail, customerEmail,
customerId, customerId,
trialDays,
paymentType: product.paymentType,
}); });
// return the checkout token to the client // return the checkout token to the client

View File

@@ -2,11 +2,6 @@ import { z } from 'zod';
const production = process.env.NODE_ENV === 'production'; const production = process.env.NODE_ENV === 'production';
enum Themes {
Light = 'light',
Dark = 'dark',
}
const AppConfigSchema = z.object({ const AppConfigSchema = z.object({
name: z name: z
.string({ .string({
@@ -29,7 +24,7 @@ const AppConfigSchema = z.object({
description: `This is the default locale of your SaaS.`, description: `This is the default locale of your SaaS.`,
}) })
.default('en'), .default('en'),
theme: z.nativeEnum(Themes), theme: z.enum(['light', 'dark', 'system']),
production: z.boolean(), production: z.boolean(),
themeColor: z.string(), themeColor: z.string(),
themeColorDark: z.string(), themeColorDark: z.string(),
@@ -37,14 +32,14 @@ const AppConfigSchema = z.object({
const appConfig = AppConfigSchema.parse({ const appConfig = AppConfigSchema.parse({
name: process.env.NEXT_PUBLIC_PRODUCT_NAME, name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
title: 'Awesomely - Your SaaS Title', title: process.env.NEXT_PUBLIC_SITE_TITLE,
description: 'Your SaaS Description', description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION,
url: process.env.NEXT_PUBLIC_SITE_URL, url: process.env.NEXT_PUBLIC_SITE_URL,
locale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE, locale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE,
theme: Themes.Light, theme: process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE,
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR,
themeColorDark: process.env.NEXT_PUBLIC_THEME_COLOR_DARK,
production, production,
themeColor: '#ffffff',
themeColorDark: '#0a0a0a',
}); });
export default appConfig; export default appConfig;

View File

@@ -20,8 +20,8 @@ const authConfig = AuthConfigSchema.parse({
// NB: Enable the providers below in the Supabase Console // NB: Enable the providers below in the Supabase Console
// in your production project // in your production project
providers: { providers: {
password: true, password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: false, magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
oAuth: ['google'], oAuth: ['google'],
}, },
} satisfies z.infer<typeof AuthConfigSchema>); } satisfies z.infer<typeof AuthConfigSchema>);

View File

@@ -1,7 +1,11 @@
import { createBillingSchema } from '@kit/billing'; import { BillingProviderSchema, createBillingSchema } from '@kit/billing';
const provider = BillingProviderSchema.parse(
process.env.NEXT_PUBLIC_BILLING_PROVIDER,
);
export default createBillingSchema({ export default createBillingSchema({
provider: 'stripe', provider,
products: [ products: [
{ {
id: 'starter', id: 'starter',
@@ -9,23 +13,37 @@ export default createBillingSchema({
description: 'The perfect plan to get started', description: 'The perfect plan to get started',
currency: 'USD', currency: 'USD',
badge: `Value`, badge: `Value`,
paymentType: 'recurring',
plans: [ plans: [
{ {
name: 'Starter Monthly', name: 'Starter Monthly',
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', id: 'starter-monthly',
price: 9.99, trialPeriod: 7,
recurring: { paymentType: 'recurring',
interval: 'month', interval: 'month',
}, lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Base',
description: 'Base plan',
cost: 9.99,
type: 'base',
},
],
}, },
{ {
name: 'Starter Yearly', name: 'Starter Yearly',
id: 'starter-yearly', id: 'starter-yearly',
price: 99.99, paymentType: 'recurring',
recurring: { interval: 'year',
interval: 'year', lineItems: [
}, {
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1',
name: 'Base',
description: 'Base plan',
cost: 99.99,
type: 'base',
},
],
}, },
], ],
features: ['Feature 1', 'Feature 2', 'Feature 3'], features: ['Feature 1', 'Feature 2', 'Feature 3'],
@@ -37,23 +55,36 @@ export default createBillingSchema({
highlighted: true, highlighted: true,
description: 'The perfect plan for professionals', description: 'The perfect plan for professionals',
currency: 'USD', currency: 'USD',
paymentType: 'recurring',
plans: [ plans: [
{ {
name: 'Pro Monthly', name: 'Pro Monthly',
id: 'pro-monthly', id: 'pro-monthly',
price: 19.99, paymentType: 'recurring',
recurring: { interval: 'month',
interval: 'month', lineItems: [
}, {
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2',
name: 'Base',
description: 'Base plan',
cost: 19.99,
type: 'base',
},
],
}, },
{ {
name: 'Pro Yearly', name: 'Pro Yearly',
id: 'pro-yearly', id: 'pro-yearly',
price: 199.99, paymentType: 'recurring',
recurring: { interval: 'year',
interval: 'year', lineItems: [
}, {
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3',
name: 'Base',
description: 'Base plan',
cost: 199.99,
type: 'base',
},
],
}, },
], ],
features: [ features: [
@@ -69,23 +100,36 @@ export default createBillingSchema({
name: 'Enterprise', name: 'Enterprise',
description: 'The perfect plan for enterprises', description: 'The perfect plan for enterprises',
currency: 'USD', currency: 'USD',
paymentType: 'recurring',
plans: [ plans: [
{ {
name: 'Enterprise Monthly', name: 'Enterprise Monthly',
id: 'enterprise-monthly', id: 'enterprise-monthly',
price: 99.99, paymentType: 'recurring',
recurring: { interval: 'month',
interval: 'month', lineItems: [
}, {
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4',
name: 'Base',
description: 'Base plan',
cost: 29.99,
type: 'base',
},
],
}, },
{ {
name: 'Enterprise Yearly', name: 'Enterprise Yearly',
id: 'enterprise-yearly', id: 'enterprise-yearly',
price: 999.99, paymentType: 'recurring',
recurring: { interval: 'year',
interval: 'year', lineItems: [
}, {
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5',
name: 'Base',
description: 'Base plan',
cost: 299.99,
type: 'base',
},
],
}, },
], ],
features: [ features: [

View File

@@ -1,8 +1,7 @@
import { formatDate } from 'date-fns'; import { formatDate } from 'date-fns';
import { BadgeCheck, CheckCircle2 } from 'lucide-react'; import { BadgeCheck, CheckCircle2 } from 'lucide-react';
import { z } from 'zod';
import { BillingSchema, getProductPlanPairFromId } from '@kit/billing'; import { BillingConfig, getProductPlanPair } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils'; import { formatCurrency } from '@kit/shared/utils';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { import {
@@ -29,12 +28,9 @@ export function CurrentPlanCard({
config, config,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
subscription: Database['public']['Tables']['subscriptions']['Row']; subscription: Database['public']['Tables']['subscriptions']['Row'];
config: z.infer<typeof BillingSchema>; config: BillingConfig;
}>) { }>) {
const { plan, product } = getProductPlanPairFromId( const { plan, product } = getProductPlanPair(config, subscription.variant_id);
config,
subscription.variant_id,
);
return ( return (
<Card> <Card>

View File

@@ -8,12 +8,13 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { import {
BillingSchema, BillingConfig,
RecurringPlanSchema, getBaseLineItem,
getPlanIntervals, getPlanIntervals,
getProductPlanPairFromId, getProductPlanPair,
} from '@kit/billing'; } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils'; import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
Form, Form,
@@ -23,6 +24,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import { import {
RadioGroup, RadioGroup,
@@ -34,7 +36,7 @@ import { cn } from '@kit/ui/utils';
export function PlanPicker( export function PlanPicker(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
config: z.infer<typeof BillingSchema>; config: BillingConfig;
onSubmit: (data: { planId: string; productId: string }) => void; onSubmit: (data: { planId: string; productId: string }) => void;
pending?: boolean; pending?: boolean;
}>, }>,
@@ -42,7 +44,7 @@ export function PlanPicker(
const intervals = useMemo( const intervals = useMemo(
() => getPlanIntervals(props.config), () => getPlanIntervals(props.config),
[props.config], [props.config],
); ) as string[];
const form = useForm({ const form = useForm({
reValidateMode: 'onChange', reValidateMode: 'onChange',
@@ -50,17 +52,21 @@ export function PlanPicker(
resolver: zodResolver( resolver: zodResolver(
z z
.object({ .object({
planId: z.string().min(1), planId: z.string(),
interval: z.string().min(1), interval: z.string().min(1),
}) })
.refine( .refine(
(data) => { (data) => {
const { product, plan } = getProductPlanPairFromId( try {
props.config, const { product, plan } = getProductPlanPair(
data.planId, props.config,
); data.planId,
);
return product && plan; return product && plan;
} catch {
return false;
}
}, },
{ message: `Please pick a plan to continue`, path: ['planId'] }, { message: `Please pick a plan to continue`, path: ['planId'] },
), ),
@@ -73,6 +79,15 @@ export function PlanPicker(
}); });
const { interval: selectedInterval } = form.watch(); const { interval: selectedInterval } = form.watch();
const planId = form.getValues('planId');
const selectedPlan = useMemo(() => {
try {
return getProductPlanPair(props.config, planId).plan;
} catch {
return;
}
}, [form, props.config, planId]);
return ( return (
<Form {...form}> <Form {...form}>
@@ -147,23 +162,16 @@ export function PlanPicker(
<FormControl> <FormControl>
<RadioGroup name={field.name}> <RadioGroup name={field.name}>
{props.config.products.map((product) => { {props.config.products.map((product) => {
const plan = const plan = product.plans.find(
product.paymentType === 'one-time' (item) => item.interval === selectedInterval,
? product.plans[0] );
: product.plans.find((item) => {
if (
'recurring' in item &&
(item as z.infer<typeof RecurringPlanSchema>)
.recurring.interval === selectedInterval
) {
return item;
}
});
if (!plan) { if (!plan) {
throw new Error('Plan not found'); return null;
} }
const baseLineItem = getBaseLineItem(props.config, plan.id);
return ( return (
<RadioGroupItemLabel <RadioGroupItemLabel
selected={field.value === plan.id} selected={field.value === plan.id}
@@ -197,22 +205,32 @@ export function PlanPicker(
</span> </span>
</Label> </Label>
<div className={'text-right'}> <div
className={'flex items-center space-x-4 text-right'}
>
<If condition={plan.trialPeriod}>
<div>
<Badge variant={'success'}>
{plan.trialPeriod} day trial
</Badge>
</div>
</If>
<div> <div>
<Price key={plan.id}> <Price key={plan.id}>
<span> <span>
{formatCurrency( {formatCurrency(
product.currency.toLowerCase(), product.currency.toLowerCase(),
plan.price, baseLineItem.cost,
)} )}
</span> </span>
</Price> </Price>
</div>
<div> <div>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>
per {selectedInterval} per {selectedInterval}
</span> </span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -233,7 +251,13 @@ export function PlanPicker(
'Processing...' 'Processing...'
) : ( ) : (
<> <>
<span>Proceed to payment</span> <If
condition={selectedPlan?.trialPeriod}
fallback={'Proceed to payment'}
>
<span>Start {selectedPlan?.trialPeriod} day trial</span>
</If>
<ArrowRight className={'ml-2 h-4 w-4'} /> <ArrowRight className={'ml-2 h-4 w-4'} />
</> </>
)} )}

View File

@@ -5,14 +5,8 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { CheckCircle, Sparkles } from 'lucide-react'; import { CheckCircle, Sparkles } from 'lucide-react';
import { z } from 'zod';
import { import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing';
BillingSchema,
RecurringPlanInterval,
RecurringPlanSchema,
getPlanIntervals,
} 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 { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
@@ -20,9 +14,6 @@ import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; 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 Interval = z.infer<typeof RecurringPlanInterval>;
interface Paths { interface Paths {
signUp: string; signUp: string;
} }
@@ -32,7 +23,7 @@ export function PricingTable({
paths, paths,
CheckoutButtonRenderer, CheckoutButtonRenderer,
}: { }: {
config: Config; config: BillingConfig;
paths: Paths; paths: Paths;
CheckoutButtonRenderer?: React.ComponentType<{ CheckoutButtonRenderer?: React.ComponentType<{
@@ -40,9 +31,8 @@ export function PricingTable({
highlighted?: boolean; highlighted?: boolean;
}>; }>;
}) { }) {
const intervals = getPlanIntervals(config).filter(Boolean); const intervals = getPlanIntervals(config).filter(Boolean) as string[];
const [interval, setInterval] = useState(intervals[0]!);
const [interval, setInterval] = useState<Interval>(intervals[0]!);
return ( return (
<div className={'flex flex-col space-y-12'}> <div className={'flex flex-col space-y-12'}>
@@ -63,12 +53,7 @@ export function PricingTable({
} }
> >
{config.products.map((product) => { {config.products.map((product) => {
const plan = product.plans.find((item) => const plan = product.plans.find((plan) => plan.interval === interval);
'recurring' in item
? (item as z.infer<typeof RecurringPlanSchema>).recurring
.interval === interval
: true,
);
if (!plan) { if (!plan) {
console.warn(`No plan found for ${product.name}`); console.warn(`No plan found for ${product.name}`);
@@ -76,15 +61,14 @@ export function PricingTable({
return; return;
} }
if (product.hidden) { const basePlan = getBaseLineItem(config, plan.id);
return null;
}
return ( return (
<PricingItem <PricingItem
selectable selectable
key={plan.id} key={plan.id}
plan={{ ...plan, interval }} plan={{ ...plan, interval }}
baseLineItem={basePlan}
product={product} product={product}
paths={paths} paths={paths}
CheckoutButton={CheckoutButtonRenderer} CheckoutButton={CheckoutButtonRenderer}
@@ -104,9 +88,13 @@ function PricingItem(
selectable: boolean; selectable: boolean;
baseLineItem: {
id: string;
cost: number;
};
plan: { plan: {
id: string; id: string;
price: number;
interval: string; interval: string;
name?: string; name?: string;
href?: string; href?: string;
@@ -172,7 +160,7 @@ function PricingItem(
<div className={'flex items-center space-x-1'}> <div className={'flex items-center space-x-1'}>
<Price> <Price>
{formatCurrency(props.product.currency, props.plan.price)} {formatCurrency(props.product.currency, props.baseLineItem.cost)}
</Price> </Price>
<If condition={props.plan.name}> <If condition={props.plan.name}>
@@ -262,9 +250,9 @@ function ListItem({ children }: React.PropsWithChildren) {
function PlanIntervalSwitcher( function PlanIntervalSwitcher(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
intervals: Interval[]; intervals: string[];
interval: Interval; interval: string;
setInterval: (interval: Interval) => void; setInterval: (interval: string) => void;
}>, }>,
) { ) {
return ( return (

View File

@@ -1,10 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { BillingProvider, BillingStrategyProviderService } from '@kit/billing'; import {
BillingProviderSchema,
BillingStrategyProviderService,
} from '@kit/billing';
export class BillingGatewayFactoryService { export class BillingGatewayFactoryService {
static async GetProviderStrategy( static async GetProviderStrategy(
provider: z.infer<typeof BillingProvider>, provider: z.infer<typeof BillingProviderSchema>,
): Promise<BillingStrategyProviderService> { ): Promise<BillingStrategyProviderService> {
switch (provider) { switch (provider) {
case 'stripe': { case 'stripe': {

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { BillingProvider } from '@kit/billing'; import { BillingProviderSchema } from '@kit/billing';
import { import {
CancelSubscriptionParamsSchema, CancelSubscriptionParamsSchema,
CreateBillingCheckoutSchema, CreateBillingCheckoutSchema,
@@ -20,7 +20,9 @@ import { BillingGatewayFactoryService } from './billing-gateway-factory.service'
* const billingGatewayService = new BillingGatewayService(provider); * const billingGatewayService = new BillingGatewayService(provider);
*/ */
export class BillingGatewayService { export class BillingGatewayService {
constructor(private readonly provider: z.infer<typeof BillingProvider>) {} constructor(
private readonly provider: z.infer<typeof BillingProviderSchema>,
) {}
/** /**
* Creates a checkout session for billing. * Creates a checkout session for billing.

View File

@@ -1,229 +1,156 @@
import { z } from 'zod'; import { z } from 'zod';
export const RecurringPlanInterval = z.enum(['month', 'year']); const BillingIntervalSchema = z.enum(['month', 'year']);
const LineItemTypeSchema = z.enum(['base', 'per-seat', 'metered']);
export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']); export const BillingProviderSchema = z.enum([
'stripe',
'paddle',
'lemon-squeezy',
]);
export const PaymentType = z.enum(['recurring', 'one-time']); export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
export const LineItemUsageType = z.enum(['licensed', 'metered']); export const LineItemSchema = z
const RecurringLineItemSchema = z
.object({ .object({
id: z.string().min(1), id: z.string().min(1),
interval: RecurringPlanInterval, name: z.string().min(1),
metered: z.boolean().optional().default(false), description: z.string().optional(),
costPerUnit: z.number().positive().optional(), cost: z.number().positive(),
perSeat: z.boolean().default(false).optional().default(false), type: LineItemTypeSchema,
usageType: LineItemUsageType.optional().default('licensed'), unit: z.string().optional(),
included: z.number().optional(),
})
.refine((data) => data.type !== 'metered' || (data.unit && data.included), {
message: 'Metered line items must have a unit and included amount',
path: ['type', 'unit', 'included'],
});
export const PlanSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
interval: BillingIntervalSchema.optional(),
lineItems: z.array(LineItemSchema),
trialPeriod: z.number().optional(),
paymentType: PaymentTypeSchema,
})
.refine((data) => data.lineItems.length > 0, {
message: 'Plans must have at least one line item',
path: ['lineItems'],
})
.refine((data) => data.lineItems.some((item) => item.type === 'base'), {
message: 'Plans must include a base line item',
path: ['lineItems'],
}) })
.refine( .refine(
(schema) => { (data) => data.paymentType !== 'one-time' || data.interval === undefined,
if (!schema.metered && schema.perSeat) {
return false;
}
return true;
},
{ {
message: 'Line item must be either metered or a member seat', message: 'One-time plans must not have an interval',
path: ['metered', 'perSeat'], path: ['paymentType', 'interval'],
}, },
) )
.refine( .refine(
(schema) => { (data) => data.paymentType !== 'recurring' || data.interval !== undefined,
if (schema.metered && !schema.usageType) { {
return false; message: 'Recurring plans must have an interval',
} path: ['paymentType', 'interval'],
},
)
.refine(
(item) => {
const ids = item.lineItems.map((item) => item.id);
return true; return ids.length === new Set(ids).size;
}, },
{ {
message: 'Line item must have a usage type', message: 'Line item IDs must be unique',
path: ['usageType'], path: ['lineItems'],
}, },
); );
const RecurringSchema = z const ProductSchema = z
.object({ .object({
interval: RecurringPlanInterval, id: z.string().min(1),
metered: z.boolean().optional(), name: z.string().min(1),
costPerUnit: z.number().positive().optional(), description: z.string().min(1),
perSeat: z.boolean().optional(), currency: z.string().min(1),
usageType: LineItemUsageType.optional(), badge: z.string().optional(),
addOns: z.array(RecurringLineItemSchema).optional(), features: z.array(z.string()).nonempty(),
highlighted: z.boolean().optional(),
plans: z.array(PlanSchema),
})
.refine((data) => data.plans.length > 0, {
message: 'Products must have at least one plan',
path: ['plans'],
}) })
.refine( .refine(
(schema) => { (item) => {
if (schema.metered) { const planIds = item.plans.map((plan) => plan.id);
return schema.costPerUnit;
}
return true; return planIds.length === new Set(planIds).size;
}, },
{ {
message: 'Metered plans must have a cost per unit', message: 'Plan IDs must be unique',
path: ['costPerUnit'],
},
)
.refine(
(schema) => {
if (schema.perSeat && !schema.metered) {
return false;
}
return true;
},
{
message: 'Per seat plans must be metered',
path: ['perSeat'],
},
)
.refine(
(schema) => {
if (schema.metered) {
return !!schema.usageType;
}
return true;
},
{
message: 'Metered plans must have a usage type',
path: ['usageType'],
},
);
export const RecurringPlanSchema = z.object({
name: z.string().min(1).max(100),
id: z.string().min(1),
price: z.number().positive(),
recurring: RecurringSchema,
trialDays: z.number().positive().optional(),
});
export const OneTimePlanSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(100),
price: z.number().positive(),
});
export const ProductSchema = z
.object({
id: z.string(),
name: z.string(),
description: z.string(),
currency: z.string().optional().default('USD'),
plans: RecurringPlanSchema.strict()
.array()
.nonempty()
.or(OneTimePlanSchema.strict().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) => {
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'], path: ['plans'],
}, },
); );
export const BillingSchema = z const BillingSchema = z
.object({ .object({
provider: BillingProviderSchema,
products: z.array(ProductSchema).nonempty(), products: z.array(ProductSchema).nonempty(),
provider: BillingProvider,
}) })
.refine( .refine(
(schema) => { (data) => {
const ids = schema.products.map((product) => product.id); const ids = data.products.flatMap((product) =>
product.plans.flatMap((plan) => plan.lineItems.map((item) => item.id)),
);
return new Set(ids).size === ids.length; return ids.length === new Set(ids).size;
}, },
{ {
message: 'Duplicate product IDs', message: 'Line item IDs must be unique',
path: ['products'],
},
)
.refine(
(schema) => {
const planIds = getAllPlanIds(schema);
return new Set(planIds).size === planIds.length;
},
{
message: 'Duplicate plan IDs',
path: ['products'], path: ['products'],
}, },
); );
/**
* Create and validate the billing schema
* @param config The billing configuration
*/
export function createBillingSchema(config: z.infer<typeof BillingSchema>) { export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
console.log(JSON.stringify(config));
return BillingSchema.parse(config); return BillingSchema.parse(config);
} }
/** export type BillingConfig = z.infer<typeof BillingSchema>;
* Retrieves the intervals of all plans specified in the given configuration. export type ProductSchema = z.infer<typeof ProductSchema>;
* @param 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( const intervals = config.products.flatMap((product) =>
new Set( product.plans.map((plan) => plan.interval),
config.products.flatMap((product) => { );
const isRecurring = product.paymentType === 'recurring';
if (isRecurring) { return Array.from(new Set(intervals));
const plans = product.plans as z.infer<typeof RecurringPlanSchema>[];
return plans.map((plan) => plan.recurring.interval);
}
return [];
}),
),
).filter(Boolean);
} }
export function getProductPlanPairFromId( export function getBaseLineItem(
config: z.infer<typeof BillingSchema>,
planId: string,
) {
for (const product of config.products) {
for (const plan of product.plans) {
if (plan.id === planId) {
const item = plan.lineItems.find((item) => item.type === 'base');
if (item) {
return item;
}
}
}
}
throw new Error('Base line item not found');
}
export function getProductPlanPair(
config: z.infer<typeof BillingSchema>, config: z.infer<typeof BillingSchema>,
planId: string, planId: string,
) { ) {
@@ -237,21 +164,3 @@ export function getProductPlanPairFromId(
throw new Error('Plan 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,4 +1,3 @@
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

@@ -1,51 +0,0 @@
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,31 +1,12 @@
import { z } from 'zod'; import { z } from 'zod';
import { LineItemUsageType, PaymentType } from '../create-billing-schema'; import { PlanSchema } from '../create-billing-schema';
export const CreateBillingCheckoutSchema = z export const CreateBillingCheckoutSchema = z.object({
.object({ returnUrl: z.string().url(),
returnUrl: z.string().url(), accountId: z.string().uuid(),
accountId: z.string().uuid(), plan: PlanSchema,
paymentType: PaymentType, trialDays: z.number().optional(),
lineItems: z.array( customerId: z.string().optional(),
z.object({ customerEmail: z.string().email().optional(),
id: z.string(), });
quantity: z.number().int().positive(),
usageType: LineItemUsageType.optional(),
}),
),
trialDays: z.number().optional(),
customerId: z.string().optional(),
customerEmail: z.string().email().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

@@ -6,7 +6,7 @@ import {
CreateBillingPortalSessionSchema, CreateBillingPortalSessionSchema,
RetrieveCheckoutSessionSchema, RetrieveCheckoutSessionSchema,
} from '../schema'; } from '../schema';
import { ReportBillingUsageSchema } from '../schema/report-billing-usage.schema'; import { ReportBillingUsageSchema } from '../schema';
export abstract class BillingStrategyProviderService { export abstract class BillingStrategyProviderService {
abstract createBillingPortalSession( abstract createBillingPortalSession(

View File

@@ -24,7 +24,7 @@ export async function createStripeCheckout(
// docs: https://stripe.com/docs/billing/subscriptions/build-subscription // docs: https://stripe.com/docs/billing/subscriptions/build-subscription
const mode: Stripe.Checkout.SessionCreateParams.Mode = const mode: Stripe.Checkout.SessionCreateParams.Mode =
params.paymentType === 'recurring' ? 'subscription' : 'payment'; params.plan.paymentType === 'recurring' ? 'subscription' : 'payment';
// this should only be set if the mode is 'subscription' // this should only be set if the mode is 'subscription'
const subscriptionData: const subscriptionData:
@@ -54,8 +54,8 @@ export async function createStripeCheckout(
customer_email: params.customerEmail, customer_email: params.customerEmail,
}; };
const lineItems = params.lineItems.map((item) => { const lineItems = params.plan.lineItems.map((item) => {
if (item.usageType === 'metered') { if (item.type === 'metered') {
return { return {
price: item.id, price: item.id,
}; };
@@ -63,7 +63,7 @@ export async function createStripeCheckout(
return { return {
price: item.id, price: item.id,
quantity: item.quantity, quantity: 1,
}; };
}); });

File diff suppressed because it is too large Load Diff