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:
@@ -54,12 +54,13 @@ export function PersonalAccountCheckoutForm() {
|
||||
<PlanPicker
|
||||
pending={pending}
|
||||
config={billingConfig}
|
||||
onSubmit={({ planId }) => {
|
||||
onSubmit={({ planId, productId }) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const { checkoutToken } =
|
||||
await createPersonalAccountCheckoutSession({
|
||||
planId,
|
||||
productId,
|
||||
});
|
||||
|
||||
setCheckoutToken(checkoutToken);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getProductPlanPairFromId } from '@kit/billing';
|
||||
import { getLineItemsFromPlanId } from '@kit/billing';
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { requireAuth } from '@kit/supabase/require-auth';
|
||||
@@ -22,6 +22,7 @@ import pathsConfig from '~/config/paths.config';
|
||||
*/
|
||||
export async function createPersonalAccountCheckoutSession(params: {
|
||||
planId: string;
|
||||
productId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const { data, error } = await requireAuth(client);
|
||||
@@ -30,21 +31,22 @@ export async function createPersonalAccountCheckoutSession(params: {
|
||||
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(
|
||||
{
|
||||
planId,
|
||||
productId,
|
||||
},
|
||||
`Creating checkout session for plan ID`,
|
||||
);
|
||||
|
||||
const service = await getBillingGatewayProvider(client);
|
||||
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
|
||||
|
||||
if (!productPlanPairFromId) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
// in the case of personal accounts
|
||||
// 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)
|
||||
const customerId = await getCustomerIdFromAccountId(accountId);
|
||||
|
||||
// retrieve the product and plan from the billing configuration
|
||||
const { product, plan } = productPlanPairFromId;
|
||||
const product = billingConfig.products.find((item) => item.id === productId);
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId);
|
||||
|
||||
// call the payment gateway to create the checkout session
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
paymentType: product.paymentType,
|
||||
lineItems,
|
||||
returnUrl,
|
||||
accountId,
|
||||
planId,
|
||||
trialPeriodDays: plan.trialPeriodDays,
|
||||
trialDays,
|
||||
paymentType: product.paymentType,
|
||||
customerEmail: data.user.email,
|
||||
customerId,
|
||||
});
|
||||
|
||||
@@ -45,14 +45,15 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) {
|
||||
<PlanPicker
|
||||
pending={pending}
|
||||
config={billingConfig}
|
||||
onSubmit={({ planId }) => {
|
||||
onSubmit={({ planId, productId }) => {
|
||||
startTransition(async () => {
|
||||
const slug = routeParams.account as string;
|
||||
|
||||
const { checkoutToken } = await createTeamAccountCheckoutSession({
|
||||
planId,
|
||||
accountId: params.accountId,
|
||||
productId,
|
||||
slug,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
setCheckoutToken(checkoutToken);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getProductPlanPairFromId } from '@kit/billing';
|
||||
import { getLineItemsFromPlanId } from '@kit/billing';
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { requireAuth } from '@kit/supabase/require-auth';
|
||||
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.
|
||||
*/
|
||||
export async function createTeamAccountCheckoutSession(params: {
|
||||
productId: string;
|
||||
planId: string;
|
||||
accountId: string;
|
||||
slug: string;
|
||||
@@ -29,6 +30,7 @@ export async function createTeamAccountCheckoutSession(params: {
|
||||
// we parse the plan ID from the parameters
|
||||
// no need in continuing if the plan ID is not valid
|
||||
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
|
||||
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
|
||||
// so we go on and create a checkout session
|
||||
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');
|
||||
}
|
||||
|
||||
// the return URL for the checkout session
|
||||
const returnUrl = getCheckoutSessionReturnUrl(params.slug);
|
||||
const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId);
|
||||
|
||||
// find the customer ID for the account if it exists
|
||||
// (eg. if the account has been billed before)
|
||||
const customerId = await getCustomerIdFromAccountId(client, accountId);
|
||||
const customerEmail = session.user.email;
|
||||
|
||||
// retrieve the product and plan from the billing configuration
|
||||
const { product, plan } = productPlanPairFromId;
|
||||
// the return URL for the checkout session
|
||||
const returnUrl = getCheckoutSessionReturnUrl(params.slug);
|
||||
|
||||
// call the payment gateway to create the checkout session
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
accountId,
|
||||
lineItems,
|
||||
returnUrl,
|
||||
planId,
|
||||
customerEmail,
|
||||
customerId,
|
||||
trialDays,
|
||||
paymentType: product.paymentType,
|
||||
trialPeriodDays: plan.trialPeriodDays,
|
||||
});
|
||||
|
||||
// return the checkout token to the client
|
||||
|
||||
@@ -8,22 +8,24 @@ export default createBillingSchema({
|
||||
name: 'Starter',
|
||||
description: 'The perfect plan to get started',
|
||||
currency: 'USD',
|
||||
paymentType: 'recurring',
|
||||
badge: `Value`,
|
||||
paymentType: 'recurring',
|
||||
plans: [
|
||||
{
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
|
||||
name: 'Starter Monthly',
|
||||
price: '9.99',
|
||||
interval: 'month',
|
||||
perSeat: false,
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
|
||||
price: 9.99,
|
||||
recurring: {
|
||||
interval: 'month',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'starter-yearly',
|
||||
name: 'Starter Yearly',
|
||||
price: '99.99',
|
||||
interval: 'year',
|
||||
perSeat: false,
|
||||
id: 'starter-yearly',
|
||||
price: 99.99,
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
},
|
||||
},
|
||||
],
|
||||
features: ['Feature 1', 'Feature 2', 'Feature 3'],
|
||||
@@ -34,22 +36,24 @@ export default createBillingSchema({
|
||||
badge: `Popular`,
|
||||
highlighted: true,
|
||||
description: 'The perfect plan for professionals',
|
||||
paymentType: 'recurring',
|
||||
currency: 'USD',
|
||||
paymentType: 'recurring',
|
||||
plans: [
|
||||
{
|
||||
id: 'pro-monthly',
|
||||
name: 'Pro Monthly',
|
||||
price: '19.99',
|
||||
interval: 'month',
|
||||
perSeat: false,
|
||||
id: 'pro-monthly',
|
||||
price: 19.99,
|
||||
recurring: {
|
||||
interval: 'month',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pro-yearly',
|
||||
name: 'Pro Yearly',
|
||||
price: '199.99',
|
||||
interval: 'year',
|
||||
perSeat: false,
|
||||
id: 'pro-yearly',
|
||||
price: 199.99,
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
},
|
||||
},
|
||||
],
|
||||
features: [
|
||||
@@ -64,22 +68,24 @@ export default createBillingSchema({
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'The perfect plan for enterprises',
|
||||
paymentType: 'recurring',
|
||||
currency: 'USD',
|
||||
paymentType: 'recurring',
|
||||
plans: [
|
||||
{
|
||||
id: 'enterprise-monthly',
|
||||
name: 'Enterprise Monthly',
|
||||
price: '99.99',
|
||||
interval: 'month',
|
||||
perSeat: false,
|
||||
id: 'enterprise-monthly',
|
||||
price: 99.99,
|
||||
recurring: {
|
||||
interval: 'month',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'enterprise-yearly',
|
||||
name: 'Enterprise Yearly',
|
||||
price: '999.99',
|
||||
interval: 'year',
|
||||
perSeat: false,
|
||||
id: 'enterprise-yearly',
|
||||
price: 999.99,
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
},
|
||||
},
|
||||
],
|
||||
features: [
|
||||
|
||||
@@ -78,7 +78,7 @@ function buildLazyComponent<
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
{/* @ts-ignore */}
|
||||
{/* @ts-expect-error */}
|
||||
<LoadedComponent
|
||||
onClose={props.onClose}
|
||||
checkoutToken={props.checkoutToken}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingSchema } from '@kit/billing';
|
||||
import {
|
||||
BillingSchema,
|
||||
RecurringPlanSchema,
|
||||
getPlanIntervals,
|
||||
getProductPlanPairFromId,
|
||||
} from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -28,15 +35,14 @@ import { cn } from '@kit/ui/utils';
|
||||
export function PlanPicker(
|
||||
props: React.PropsWithChildren<{
|
||||
config: z.infer<typeof BillingSchema>;
|
||||
onSubmit: (data: { planId: string }) => void;
|
||||
onSubmit: (data: { planId: string; productId: string }) => void;
|
||||
pending?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const intervals = props.config.products.reduce<string[]>((acc, item) => {
|
||||
return Array.from(
|
||||
new Set([...acc, ...item.plans.map((plan) => plan.interval)]),
|
||||
);
|
||||
}, []);
|
||||
const intervals = useMemo(
|
||||
() => getPlanIntervals(props.config),
|
||||
[props.config],
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
reValidateMode: 'onChange',
|
||||
@@ -44,20 +50,17 @@ export function PlanPicker(
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
planId: z.string(),
|
||||
interval: z.string(),
|
||||
planId: z.string().min(1),
|
||||
interval: z.string().min(1),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const planFound = props.config.products
|
||||
.flatMap((item) => item.plans)
|
||||
.some((plan) => plan.id === data.planId);
|
||||
const { product, plan } = getProductPlanPairFromId(
|
||||
props.config,
|
||||
data.planId,
|
||||
);
|
||||
|
||||
if (!planFound) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return intervals.includes(data.interval);
|
||||
return product && plan;
|
||||
},
|
||||
{ message: `Please pick a plan to continue`, path: ['planId'] },
|
||||
),
|
||||
@@ -65,6 +68,7 @@ export function PlanPicker(
|
||||
defaultValues: {
|
||||
interval: intervals[0],
|
||||
planId: '',
|
||||
productId: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,9 +85,11 @@ export function PlanPicker(
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<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}>
|
||||
<div className={'flex space-x-2.5'}>
|
||||
{intervals.map((interval) => {
|
||||
@@ -91,6 +97,7 @@ export function PlanPicker(
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={interval}
|
||||
key={interval}
|
||||
className={cn(
|
||||
'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', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('interval', interval, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
@@ -138,25 +146,38 @@ export function PlanPicker(
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name}>
|
||||
{props.config.products.map((item) => {
|
||||
const variant = item.plans.find(
|
||||
(plan) => plan.interval === selectedInterval,
|
||||
);
|
||||
{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;
|
||||
}
|
||||
});
|
||||
|
||||
if (!variant) {
|
||||
throw new Error('No plan found');
|
||||
if (!plan) {
|
||||
throw new Error('Plan not found');
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
selected={field.value === variant.id}
|
||||
key={variant.id}
|
||||
selected={field.value === plan.id}
|
||||
key={plan.id}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={variant.id}
|
||||
value={variant.id}
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
form.setValue('planId', variant.id, {
|
||||
form.setValue('planId', plan.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
@@ -166,23 +187,23 @@ export function PlanPicker(
|
||||
className={'flex w-full items-center justify-between'}
|
||||
>
|
||||
<Label
|
||||
htmlFor={variant.id}
|
||||
htmlFor={plan.id}
|
||||
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'}>
|
||||
{item.description}
|
||||
{product.description}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div className={'text-right'}>
|
||||
<div>
|
||||
<Price key={variant.id}>
|
||||
<Price key={plan.id}>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
item.currency.toLowerCase(),
|
||||
variant.price,
|
||||
product.currency.toLowerCase(),
|
||||
plan.price,
|
||||
)}
|
||||
</span>
|
||||
</Price>
|
||||
@@ -190,7 +211,7 @@ export function PlanPicker(
|
||||
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
per {variant.interval}
|
||||
per {selectedInterval}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +228,7 @@ export function PlanPicker(
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={props.pending || !form.formState.isValid}>
|
||||
<Button disabled={props.pending ?? !form.formState.isValid}>
|
||||
{props.pending ? (
|
||||
'Processing...'
|
||||
) : (
|
||||
|
||||
@@ -7,7 +7,13 @@ import Link from 'next/link';
|
||||
import { CheckCircle, Sparkles } from 'lucide-react';
|
||||
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 { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
@@ -15,6 +21,7 @@ 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;
|
||||
@@ -33,20 +40,20 @@ export function PricingTable({
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
}) {
|
||||
const intervals = getPlanIntervals(config);
|
||||
const intervals = getPlanIntervals(config).filter(Boolean);
|
||||
|
||||
const [planVariant, setPlanVariant] = useState<string>(
|
||||
intervals[0] as string,
|
||||
);
|
||||
const [interval, setInterval] = useState<Interval>(intervals[0]!);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-12'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<PlanIntervalSwitcher
|
||||
intervals={intervals}
|
||||
interval={planVariant}
|
||||
setInterval={setPlanVariant}
|
||||
/>
|
||||
{intervals.length ? (
|
||||
<PlanIntervalSwitcher
|
||||
intervals={intervals}
|
||||
interval={interval}
|
||||
setInterval={setInterval}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -56,21 +63,28 @@ export function PricingTable({
|
||||
}
|
||||
>
|
||||
{config.products.map((product) => {
|
||||
const plan = product.plans.find(
|
||||
(item) => item.interval === planVariant,
|
||||
const plan = product.plans.find((item) =>
|
||||
'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}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (product.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
selectable
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
plan={{ ...plan, interval }}
|
||||
product={product}
|
||||
paths={paths}
|
||||
CheckoutButton={CheckoutButtonRenderer}
|
||||
@@ -92,7 +106,7 @@ function PricingItem(
|
||||
|
||||
plan: {
|
||||
id: string;
|
||||
price: string;
|
||||
price: number;
|
||||
interval: string;
|
||||
name?: string;
|
||||
href?: string;
|
||||
@@ -158,8 +172,7 @@ function PricingItem(
|
||||
|
||||
<div className={'flex items-center space-x-1'}>
|
||||
<Price>
|
||||
<span className={'text-base'}>{props.product.currency}</span>
|
||||
{props.plan.price}
|
||||
{formatCurrency(props.product.currency, props.plan.price)}
|
||||
</Price>
|
||||
|
||||
<If condition={props.plan.name}>
|
||||
@@ -249,9 +262,9 @@ function ListItem({ children }: React.PropsWithChildren) {
|
||||
|
||||
function PlanIntervalSwitcher(
|
||||
props: React.PropsWithChildren<{
|
||||
intervals: string[];
|
||||
interval: string;
|
||||
setInterval: (interval: string) => void;
|
||||
intervals: Interval[];
|
||||
interval: Interval;
|
||||
setInterval: (interval: Interval) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
|
||||
@@ -95,7 +95,7 @@ export class BillingEventHandlerService {
|
||||
|
||||
Logger.info(ctx, 'Processing checkout session completed event...');
|
||||
|
||||
const { id, ...data } = subscription;
|
||||
const { id: _, ...data } = subscription;
|
||||
|
||||
const { error } = await client.rpc('add_subscription', {
|
||||
...data,
|
||||
|
||||
@@ -1,96 +1,221 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const Interval = z.enum(['month', 'year']);
|
||||
const PaymentType = z.enum(['recurring', 'one-time']);
|
||||
export const RecurringPlanInterval = z.enum(['month', 'year']);
|
||||
|
||||
export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']);
|
||||
|
||||
const PlanSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
export const PaymentType = z.enum(['recurring', 'one-time']);
|
||||
|
||||
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),
|
||||
price: z.string().min(1).max(100),
|
||||
trialPeriodDays: z.number().optional(),
|
||||
interval: Interval,
|
||||
perSeat: z.boolean().optional().default(false),
|
||||
id: z.string().min(1),
|
||||
price: z.number().positive(),
|
||||
trialDays: z.number().positive().optional(),
|
||||
recurring: RecurringSchema,
|
||||
});
|
||||
|
||||
const ProductSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
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 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: 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
|
||||
.object({
|
||||
products: z.array(ProductSchema),
|
||||
products: z.array(ProductSchema).nonempty(),
|
||||
provider: BillingProvider,
|
||||
})
|
||||
.refine((schema) => {
|
||||
// verify dupe product ids
|
||||
const ids = schema.products.map((product) => product.id);
|
||||
.refine(
|
||||
(schema) => {
|
||||
const ids = schema.products.map((product) => product.id);
|
||||
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
return {
|
||||
message: 'Duplicate product IDs',
|
||||
path: ['products'],
|
||||
};
|
||||
}
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{
|
||||
message: 'Duplicate product IDs',
|
||||
path: ['products'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
const planIds = getAllPlanIds(schema);
|
||||
|
||||
return true;
|
||||
})
|
||||
.refine((schema) => {
|
||||
// verify dupe plan ids
|
||||
const planIds = schema.products.flatMap((product) =>
|
||||
product.plans.map((plan) => plan.id),
|
||||
);
|
||||
|
||||
if (new Set(planIds).size !== planIds.length) {
|
||||
return {
|
||||
message: 'Duplicate plan IDs',
|
||||
path: ['products'],
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
return new Set(planIds).size === planIds.length;
|
||||
},
|
||||
{
|
||||
message: 'Duplicate plan IDs',
|
||||
path: ['products'],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create and validate the billing schema
|
||||
* @param config
|
||||
* @param config The billing configuration
|
||||
*/
|
||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
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.
|
||||
*
|
||||
* @param {Object} config - The billing configuration containing products and plans.
|
||||
* @param config The billing configuration containing products and plans.
|
||||
*/
|
||||
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
config.products.flatMap((product) =>
|
||||
product.plans.map((plan) => plan.interval),
|
||||
),
|
||||
config.products.flatMap((product) => {
|
||||
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(
|
||||
@@ -98,12 +223,30 @@ export function getProductPlanPairFromId(
|
||||
planId: string,
|
||||
) {
|
||||
for (const product of config.products) {
|
||||
const plan = product.plans.find((plan) => plan.id === planId);
|
||||
|
||||
if (plan) {
|
||||
return { product, plan };
|
||||
for (const plan of product.plans) {
|
||||
if (plan.id === planId) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './create-billing-schema';
|
||||
export * from './services/billing-strategy-provider.service';
|
||||
export * from './services/billing-webhook-handler.service';
|
||||
export * from './line-items-mapper';
|
||||
|
||||
51
packages/billing/src/line-items-mapper.ts
Normal file
51
packages/billing/src/line-items-mapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateBillingCheckoutSchema = z.object({
|
||||
returnUrl: z.string().url(),
|
||||
accountId: z.string(),
|
||||
planId: z.string(),
|
||||
paymentType: z.enum(['recurring', 'one-time']),
|
||||
import { LineItemUsageType, PaymentType } from '../create-billing-schema';
|
||||
|
||||
trialPeriodDays: z.number().optional(),
|
||||
|
||||
customerId: z.string().optional(),
|
||||
customerEmail: z.string().optional(),
|
||||
});
|
||||
export const CreateBillingCheckoutSchema = z
|
||||
.object({
|
||||
returnUrl: z.string().url(),
|
||||
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'],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"./hooks/*": "./src/hooks/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/billing-gateway": "*",
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/shared": "*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { RedirectType, redirect } from 'next/navigation';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { requireAuth } from '@kit/supabase/require-auth';
|
||||
@@ -33,28 +33,13 @@ export async function deletePersonalAccountAction(formData: FormData) {
|
||||
`Deleting personal account...`,
|
||||
);
|
||||
|
||||
const deleteAccountResponse = await service.deletePersonalAccount(
|
||||
await service.deletePersonalAccount(
|
||||
getSupabaseServerActionClient({ admin: true }),
|
||||
{
|
||||
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(
|
||||
{
|
||||
userId,
|
||||
@@ -65,5 +50,5 @@ export async function deletePersonalAccountAction(formData: FormData) {
|
||||
|
||||
await client.auth.signOut();
|
||||
|
||||
redirect('/');
|
||||
redirect('/', RedirectType.replace);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { BillingGatewayService } from '@kit/billing-gateway';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
@@ -11,12 +13,95 @@ import { Database } from '@kit/supabase/database';
|
||||
* const accountsService = new AccountsService(client);
|
||||
*/
|
||||
export class PersonalAccountsService {
|
||||
private namespace = 'account';
|
||||
|
||||
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(
|
||||
adminClient: SupabaseClient<Database>,
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
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) {
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
const params = DeleteTeamAccountSchema.parse(body);
|
||||
const client = getSupabaseServerActionClient();
|
||||
const service = new DeleteAccountService(client);
|
||||
const service = new DeleteTeamAccountService(client);
|
||||
|
||||
await service.deleteTeamAccount(params);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'server-only';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export class DeleteAccountService {
|
||||
export class DeleteTeamAccountService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async deleteTeamAccount(params: { accountId: string }) {
|
||||
@@ -12,6 +12,7 @@ type Config = z.infer<typeof MailerSchema>;
|
||||
*/
|
||||
export class CloudflareMailer implements Mailer {
|
||||
async sendEmail(config: Config) {
|
||||
console.log('Sending email with Cloudflare Workers', config);
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,19 +26,18 @@ export async function createStripeCheckout(
|
||||
const mode: Stripe.Checkout.SessionCreateParams.Mode =
|
||||
params.paymentType === 'recurring' ? 'subscription' : 'payment';
|
||||
|
||||
// TODO: support multiple line items and per-seat pricing
|
||||
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
|
||||
quantity: 1,
|
||||
price: params.planId,
|
||||
};
|
||||
|
||||
const subscriptionData: Stripe.Checkout.SessionCreateParams.SubscriptionData =
|
||||
{
|
||||
trial_period_days: params.trialPeriodDays,
|
||||
metadata: {
|
||||
accountId: params.accountId,
|
||||
},
|
||||
};
|
||||
// this should only be set if the mode is 'subscription'
|
||||
const subscriptionData:
|
||||
| Stripe.Checkout.SessionCreateParams.SubscriptionData
|
||||
| undefined =
|
||||
mode === 'subscription'
|
||||
? {
|
||||
trial_period_days: params.trialDays,
|
||||
metadata: {
|
||||
accountId: params.accountId,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const urls = getUrls({
|
||||
returnUrl: params.returnUrl,
|
||||
@@ -55,10 +54,23 @@ export async function createStripeCheckout(
|
||||
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({
|
||||
mode,
|
||||
ui_mode: uiMode,
|
||||
line_items: [lineItem],
|
||||
line_items: lineItems,
|
||||
client_reference_id: clientReferenceId,
|
||||
subscription_data: subscriptionData,
|
||||
...customerData,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MDXComponents } from 'mdx/types';
|
||||
import { getMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
import Components from './mdx-components';
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
import styles from './mdx-renderer.module.css';
|
||||
|
||||
export function Mdx({
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -283,6 +283,9 @@ importers:
|
||||
|
||||
packages/features/accounts:
|
||||
devDependencies:
|
||||
'@kit/billing-gateway':
|
||||
specifier: '*'
|
||||
version: link:../../billing-gateway
|
||||
'@kit/eslint-config':
|
||||
specifier: 0.2.0
|
||||
version: link:../../../tooling/eslint
|
||||
|
||||
Reference in New Issue
Block a user