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,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>

View File

@@ -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'} />
</>
)}

View File

@@ -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 (

View File

@@ -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': {

View File

@@ -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.