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,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.
|
||||
|
||||
Reference in New Issue
Block a user