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:
@@ -1,5 +1,18 @@
|
||||
# SITE
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
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
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
|
||||
@@ -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
|
||||
@@ -62,7 +62,11 @@ export async function createTeamAccountCheckoutSession(params: {
|
||||
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
|
||||
// (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
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
accountId,
|
||||
lineItems,
|
||||
plan,
|
||||
returnUrl,
|
||||
customerEmail,
|
||||
customerId,
|
||||
trialDays,
|
||||
paymentType: product.paymentType,
|
||||
});
|
||||
|
||||
// return the checkout token to the client
|
||||
|
||||
@@ -2,11 +2,6 @@ import { z } from 'zod';
|
||||
|
||||
const production = process.env.NODE_ENV === 'production';
|
||||
|
||||
enum Themes {
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
}
|
||||
|
||||
const AppConfigSchema = z.object({
|
||||
name: z
|
||||
.string({
|
||||
@@ -29,7 +24,7 @@ const AppConfigSchema = z.object({
|
||||
description: `This is the default locale of your SaaS.`,
|
||||
})
|
||||
.default('en'),
|
||||
theme: z.nativeEnum(Themes),
|
||||
theme: z.enum(['light', 'dark', 'system']),
|
||||
production: z.boolean(),
|
||||
themeColor: z.string(),
|
||||
themeColorDark: z.string(),
|
||||
@@ -37,14 +32,14 @@ const AppConfigSchema = z.object({
|
||||
|
||||
const appConfig = AppConfigSchema.parse({
|
||||
name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
|
||||
title: 'Awesomely - Your SaaS Title',
|
||||
description: 'Your SaaS Description',
|
||||
title: process.env.NEXT_PUBLIC_SITE_TITLE,
|
||||
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION,
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL,
|
||||
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,
|
||||
themeColor: '#ffffff',
|
||||
themeColorDark: '#0a0a0a',
|
||||
});
|
||||
|
||||
export default appConfig;
|
||||
|
||||
@@ -20,8 +20,8 @@ const authConfig = AuthConfigSchema.parse({
|
||||
// NB: Enable the providers below in the Supabase Console
|
||||
// in your production project
|
||||
providers: {
|
||||
password: true,
|
||||
magicLink: false,
|
||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||
oAuth: ['google'],
|
||||
},
|
||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||
|
||||
@@ -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({
|
||||
provider: 'stripe',
|
||||
provider,
|
||||
products: [
|
||||
{
|
||||
id: 'starter',
|
||||
@@ -9,23 +13,37 @@ export default createBillingSchema({
|
||||
description: 'The perfect plan to get started',
|
||||
currency: 'USD',
|
||||
badge: `Value`,
|
||||
paymentType: 'recurring',
|
||||
plans: [
|
||||
{
|
||||
name: 'Starter Monthly',
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
|
||||
price: 9.99,
|
||||
recurring: {
|
||||
interval: 'month',
|
||||
},
|
||||
id: 'starter-monthly',
|
||||
trialPeriod: 7,
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
|
||||
name: 'Base',
|
||||
description: 'Base plan',
|
||||
cost: 9.99,
|
||||
type: 'base',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Starter Yearly',
|
||||
id: 'starter-yearly',
|
||||
price: 99.99,
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
},
|
||||
paymentType: 'recurring',
|
||||
interval: 'year',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1',
|
||||
name: 'Base',
|
||||
description: 'Base plan',
|
||||
cost: 99.99,
|
||||
type: 'base',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
features: ['Feature 1', 'Feature 2', 'Feature 3'],
|
||||
@@ -37,23 +55,36 @@ export default createBillingSchema({
|
||||
highlighted: true,
|
||||
description: 'The perfect plan for professionals',
|
||||
currency: 'USD',
|
||||
paymentType: 'recurring',
|
||||
plans: [
|
||||
{
|
||||
name: 'Pro Monthly',
|
||||
id: 'pro-monthly',
|
||||
price: 19.99,
|
||||
recurring: {
|
||||
interval: 'month',
|
||||
},
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2',
|
||||
name: 'Base',
|
||||
description: 'Base plan',
|
||||
cost: 19.99,
|
||||
type: 'base',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Pro Yearly',
|
||||
id: 'pro-yearly',
|
||||
price: 199.99,
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
},
|
||||
paymentType: 'recurring',
|
||||
interval: 'year',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3',
|
||||
name: 'Base',
|
||||
description: 'Base plan',
|
||||
cost: 199.99,
|
||||
type: 'base',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
features: [
|
||||
@@ -69,23 +100,36 @@ export default createBillingSchema({
|
||||
name: 'Enterprise',
|
||||
description: 'The perfect plan for enterprises',
|
||||
currency: 'USD',
|
||||
paymentType: 'recurring',
|
||||
plans: [
|
||||
{
|
||||
name: 'Enterprise Monthly',
|
||||
id: 'enterprise-monthly',
|
||||
price: 99.99,
|
||||
recurring: {
|
||||
interval: 'month',
|
||||
},
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4',
|
||||
name: 'Base',
|
||||
description: 'Base plan',
|
||||
cost: 29.99,
|
||||
type: 'base',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Enterprise Yearly',
|
||||
id: 'enterprise-yearly',
|
||||
price: 999.99,
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
},
|
||||
paymentType: 'recurring',
|
||||
interval: 'year',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5',
|
||||
name: 'Base',
|
||||
description: 'Base plan',
|
||||
cost: 299.99,
|
||||
type: 'base',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
features: [
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { formatDate } from 'date-fns';
|
||||
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 { Database } from '@kit/supabase/database';
|
||||
import {
|
||||
@@ -29,12 +28,9 @@ export function CurrentPlanCard({
|
||||
config,
|
||||
}: React.PropsWithChildren<{
|
||||
subscription: Database['public']['Tables']['subscriptions']['Row'];
|
||||
config: z.infer<typeof BillingSchema>;
|
||||
config: BillingConfig;
|
||||
}>) {
|
||||
const { plan, product } = getProductPlanPairFromId(
|
||||
config,
|
||||
subscription.variant_id,
|
||||
);
|
||||
const { plan, product } = getProductPlanPair(config, subscription.variant_id);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -8,12 +8,13 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingSchema,
|
||||
RecurringPlanSchema,
|
||||
BillingConfig,
|
||||
getBaseLineItem,
|
||||
getPlanIntervals,
|
||||
getProductPlanPairFromId,
|
||||
getProductPlanPair,
|
||||
} from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
RadioGroup,
|
||||
@@ -34,7 +36,7 @@ import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function PlanPicker(
|
||||
props: React.PropsWithChildren<{
|
||||
config: z.infer<typeof BillingSchema>;
|
||||
config: BillingConfig;
|
||||
onSubmit: (data: { planId: string; productId: string }) => void;
|
||||
pending?: boolean;
|
||||
}>,
|
||||
@@ -42,7 +44,7 @@ export function PlanPicker(
|
||||
const intervals = useMemo(
|
||||
() => getPlanIntervals(props.config),
|
||||
[props.config],
|
||||
);
|
||||
) as string[];
|
||||
|
||||
const form = useForm({
|
||||
reValidateMode: 'onChange',
|
||||
@@ -50,17 +52,21 @@ export function PlanPicker(
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
planId: z.string().min(1),
|
||||
planId: z.string(),
|
||||
interval: z.string().min(1),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const { product, plan } = getProductPlanPairFromId(
|
||||
props.config,
|
||||
data.planId,
|
||||
);
|
||||
try {
|
||||
const { product, plan } = getProductPlanPair(
|
||||
props.config,
|
||||
data.planId,
|
||||
);
|
||||
|
||||
return product && plan;
|
||||
return product && plan;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: `Please pick a plan to continue`, path: ['planId'] },
|
||||
),
|
||||
@@ -73,6 +79,15 @@ export function PlanPicker(
|
||||
});
|
||||
|
||||
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 (
|
||||
<Form {...form}>
|
||||
@@ -147,23 +162,16 @@ export function PlanPicker(
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name}>
|
||||
{props.config.products.map((product) => {
|
||||
const plan =
|
||||
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;
|
||||
}
|
||||
});
|
||||
const plan = product.plans.find(
|
||||
(item) => item.interval === selectedInterval,
|
||||
);
|
||||
|
||||
if (!plan) {
|
||||
throw new Error('Plan not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseLineItem = getBaseLineItem(props.config, plan.id);
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
selected={field.value === plan.id}
|
||||
@@ -197,22 +205,32 @@ export function PlanPicker(
|
||||
</span>
|
||||
</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>
|
||||
<Price key={plan.id}>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
product.currency.toLowerCase(),
|
||||
plan.price,
|
||||
baseLineItem.cost,
|
||||
)}
|
||||
</span>
|
||||
</Price>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
per {selectedInterval}
|
||||
</span>
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
per {selectedInterval}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +251,13 @@ export function PlanPicker(
|
||||
'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'} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,14 +5,8 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckCircle, Sparkles } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingSchema,
|
||||
RecurringPlanInterval,
|
||||
RecurringPlanSchema,
|
||||
getPlanIntervals,
|
||||
} from '@kit/billing';
|
||||
import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
@@ -20,9 +14,6 @@ import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
type Config = z.infer<typeof BillingSchema>;
|
||||
type Interval = z.infer<typeof RecurringPlanInterval>;
|
||||
|
||||
interface Paths {
|
||||
signUp: string;
|
||||
}
|
||||
@@ -32,7 +23,7 @@ export function PricingTable({
|
||||
paths,
|
||||
CheckoutButtonRenderer,
|
||||
}: {
|
||||
config: Config;
|
||||
config: BillingConfig;
|
||||
paths: Paths;
|
||||
|
||||
CheckoutButtonRenderer?: React.ComponentType<{
|
||||
@@ -40,9 +31,8 @@ export function PricingTable({
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
}) {
|
||||
const intervals = getPlanIntervals(config).filter(Boolean);
|
||||
|
||||
const [interval, setInterval] = useState<Interval>(intervals[0]!);
|
||||
const intervals = getPlanIntervals(config).filter(Boolean) as string[];
|
||||
const [interval, setInterval] = useState(intervals[0]!);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-12'}>
|
||||
@@ -63,12 +53,7 @@ export function PricingTable({
|
||||
}
|
||||
>
|
||||
{config.products.map((product) => {
|
||||
const plan = product.plans.find((item) =>
|
||||
'recurring' in item
|
||||
? (item as z.infer<typeof RecurringPlanSchema>).recurring
|
||||
.interval === interval
|
||||
: true,
|
||||
);
|
||||
const plan = product.plans.find((plan) => plan.interval === interval);
|
||||
|
||||
if (!plan) {
|
||||
console.warn(`No plan found for ${product.name}`);
|
||||
@@ -76,15 +61,14 @@ export function PricingTable({
|
||||
return;
|
||||
}
|
||||
|
||||
if (product.hidden) {
|
||||
return null;
|
||||
}
|
||||
const basePlan = getBaseLineItem(config, plan.id);
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
selectable
|
||||
key={plan.id}
|
||||
plan={{ ...plan, interval }}
|
||||
baseLineItem={basePlan}
|
||||
product={product}
|
||||
paths={paths}
|
||||
CheckoutButton={CheckoutButtonRenderer}
|
||||
@@ -104,9 +88,13 @@ function PricingItem(
|
||||
|
||||
selectable: boolean;
|
||||
|
||||
baseLineItem: {
|
||||
id: string;
|
||||
cost: number;
|
||||
};
|
||||
|
||||
plan: {
|
||||
id: string;
|
||||
price: number;
|
||||
interval: string;
|
||||
name?: string;
|
||||
href?: string;
|
||||
@@ -172,7 +160,7 @@ function PricingItem(
|
||||
|
||||
<div className={'flex items-center space-x-1'}>
|
||||
<Price>
|
||||
{formatCurrency(props.product.currency, props.plan.price)}
|
||||
{formatCurrency(props.product.currency, props.baseLineItem.cost)}
|
||||
</Price>
|
||||
|
||||
<If condition={props.plan.name}>
|
||||
@@ -262,9 +250,9 @@ function ListItem({ children }: React.PropsWithChildren) {
|
||||
|
||||
function PlanIntervalSwitcher(
|
||||
props: React.PropsWithChildren<{
|
||||
intervals: Interval[];
|
||||
interval: Interval;
|
||||
setInterval: (interval: Interval) => void;
|
||||
intervals: string[];
|
||||
interval: string;
|
||||
setInterval: (interval: string) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingProvider, BillingStrategyProviderService } from '@kit/billing';
|
||||
import {
|
||||
BillingProviderSchema,
|
||||
BillingStrategyProviderService,
|
||||
} from '@kit/billing';
|
||||
|
||||
export class BillingGatewayFactoryService {
|
||||
static async GetProviderStrategy(
|
||||
provider: z.infer<typeof BillingProvider>,
|
||||
provider: z.infer<typeof BillingProviderSchema>,
|
||||
): Promise<BillingStrategyProviderService> {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingProvider } from '@kit/billing';
|
||||
import { BillingProviderSchema } from '@kit/billing';
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
@@ -20,7 +20,9 @@ import { BillingGatewayFactoryService } from './billing-gateway-factory.service'
|
||||
* const billingGatewayService = new BillingGatewayService(provider);
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -1,229 +1,156 @@
|
||||
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']);
|
||||
|
||||
const RecurringLineItemSchema = z
|
||||
export const LineItemSchema = 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'),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
cost: z.number().positive(),
|
||||
type: LineItemTypeSchema,
|
||||
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(
|
||||
(schema) => {
|
||||
if (!schema.metered && schema.perSeat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
(data) => data.paymentType !== 'one-time' || data.interval === undefined,
|
||||
{
|
||||
message: 'Line item must be either metered or a member seat',
|
||||
path: ['metered', 'perSeat'],
|
||||
message: 'One-time plans must not have an interval',
|
||||
path: ['paymentType', 'interval'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (schema.metered && !schema.usageType) {
|
||||
return false;
|
||||
}
|
||||
(data) => data.paymentType !== 'recurring' || data.interval !== undefined,
|
||||
{
|
||||
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',
|
||||
path: ['usageType'],
|
||||
message: 'Line item IDs must be unique',
|
||||
path: ['lineItems'],
|
||||
},
|
||||
);
|
||||
|
||||
const RecurringSchema = z
|
||||
const ProductSchema = z
|
||||
.object({
|
||||
interval: RecurringPlanInterval,
|
||||
metered: z.boolean().optional(),
|
||||
costPerUnit: z.number().positive().optional(),
|
||||
perSeat: z.boolean().optional(),
|
||||
usageType: LineItemUsageType.optional(),
|
||||
addOns: z.array(RecurringLineItemSchema).optional(),
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
currency: z.string().min(1),
|
||||
badge: z.string().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(
|
||||
(schema) => {
|
||||
if (schema.metered) {
|
||||
return schema.costPerUnit;
|
||||
}
|
||||
(item) => {
|
||||
const planIds = item.plans.map((plan) => plan.id);
|
||||
|
||||
return true;
|
||||
return planIds.length === new Set(planIds).size;
|
||||
},
|
||||
{
|
||||
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) => {
|
||||
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',
|
||||
message: 'Plan IDs must be unique',
|
||||
path: ['plans'],
|
||||
},
|
||||
);
|
||||
|
||||
export const BillingSchema = z
|
||||
const BillingSchema = z
|
||||
.object({
|
||||
provider: BillingProviderSchema,
|
||||
products: z.array(ProductSchema).nonempty(),
|
||||
provider: BillingProvider,
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const ids = schema.products.map((product) => product.id);
|
||||
(data) => {
|
||||
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',
|
||||
path: ['products'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
const planIds = getAllPlanIds(schema);
|
||||
|
||||
return new Set(planIds).size === planIds.length;
|
||||
},
|
||||
{
|
||||
message: 'Duplicate plan IDs',
|
||||
message: 'Line item IDs must be unique',
|
||||
path: ['products'],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create and validate the billing schema
|
||||
* @param config The billing configuration
|
||||
*/
|
||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
console.log(JSON.stringify(config));
|
||||
return BillingSchema.parse(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the intervals of all plans specified in the given configuration.
|
||||
* @param config The billing configuration containing products and plans.
|
||||
*/
|
||||
export type BillingConfig = z.infer<typeof BillingSchema>;
|
||||
export type ProductSchema = z.infer<typeof ProductSchema>;
|
||||
|
||||
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
config.products.flatMap((product) => {
|
||||
const isRecurring = product.paymentType === 'recurring';
|
||||
const intervals = config.products.flatMap((product) =>
|
||||
product.plans.map((plan) => plan.interval),
|
||||
);
|
||||
|
||||
if (isRecurring) {
|
||||
const plans = product.plans as z.infer<typeof RecurringPlanSchema>[];
|
||||
|
||||
return plans.map((plan) => plan.recurring.interval);
|
||||
}
|
||||
|
||||
return [];
|
||||
}),
|
||||
),
|
||||
).filter(Boolean);
|
||||
return Array.from(new Set(intervals));
|
||||
}
|
||||
|
||||
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>,
|
||||
planId: string,
|
||||
) {
|
||||
@@ -237,21 +164,3 @@ export function getProductPlanPairFromId(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './create-billing-schema';
|
||||
export * from './services/billing-strategy-provider.service';
|
||||
export * from './services/billing-webhook-handler.service';
|
||||
export * from './line-items-mapper';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,31 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LineItemUsageType, PaymentType } from '../create-billing-schema';
|
||||
import { PlanSchema } from '../create-billing-schema';
|
||||
|
||||
export const CreateBillingCheckoutSchema = z
|
||||
.object({
|
||||
returnUrl: z.string().url(),
|
||||
accountId: z.string().uuid(),
|
||||
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().email().optional(),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (schema.paymentType === 'one-time' && schema.trialDays) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'Trial days are only allowed for recurring payments',
|
||||
path: ['trialDays'],
|
||||
},
|
||||
);
|
||||
export const CreateBillingCheckoutSchema = z.object({
|
||||
returnUrl: z.string().url(),
|
||||
accountId: z.string().uuid(),
|
||||
plan: PlanSchema,
|
||||
trialDays: z.number().optional(),
|
||||
customerId: z.string().optional(),
|
||||
customerEmail: z.string().email().optional(),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
CreateBillingPortalSessionSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '../schema';
|
||||
import { ReportBillingUsageSchema } from '../schema/report-billing-usage.schema';
|
||||
import { ReportBillingUsageSchema } from '../schema';
|
||||
|
||||
export abstract class BillingStrategyProviderService {
|
||||
abstract createBillingPortalSession(
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function createStripeCheckout(
|
||||
|
||||
// docs: https://stripe.com/docs/billing/subscriptions/build-subscription
|
||||
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'
|
||||
const subscriptionData:
|
||||
@@ -54,8 +54,8 @@ export async function createStripeCheckout(
|
||||
customer_email: params.customerEmail,
|
||||
};
|
||||
|
||||
const lineItems = params.lineItems.map((item) => {
|
||||
if (item.usageType === 'metered') {
|
||||
const lineItems = params.plan.lineItems.map((item) => {
|
||||
if (item.type === 'metered') {
|
||||
return {
|
||||
price: item.id,
|
||||
};
|
||||
@@ -63,7 +63,7 @@ export async function createStripeCheckout(
|
||||
|
||||
return {
|
||||
price: item.id,
|
||||
quantity: item.quantity,
|
||||
quantity: 1,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user