Implement new billing-gateway and update related services

Created a new package named billing-gateway which implements interfaces for different billing providers and provides a centralized service for payments. This will potentially help to maintain cleaner code by reducing direct dependencies on specific payment providers in the core application code. Additionally, made adjustments in existing services, like Stripe, to comply with this change. The relevant interfaces and types have been exported and imported accordingly.
This commit is contained in:
giancarlo
2024-03-24 20:54:12 +08:00
parent 06156e980d
commit 78c704e54d
44 changed files with 1251 additions and 108 deletions

View File

@@ -0,0 +1 @@
export function CurrentPlanCard(props: React.PropsWithChildren<{}>) {}

View File

@@ -0,0 +1,39 @@
import { lazy } from 'react';
import { Database } from '@kit/supabase/database';
type BillingProvider = Database['public']['Enums']['billing_provider'];
export function EmbeddedCheckout(
props: React.PropsWithChildren<{
checkoutToken: string;
provider: BillingProvider;
}>,
) {
const CheckoutComponent = loadCheckoutComponent(props.provider);
return <CheckoutComponent checkoutToken={props.checkoutToken} />;
}
function loadCheckoutComponent(provider: BillingProvider) {
switch (provider) {
case 'stripe': {
return lazy(() => {
return import('@kit/stripe/components').then((c) => ({
default: c.StripeCheckout,
}));
});
}
case 'lemon-squeezy': {
throw new Error('Lemon Squeezy is not yet supported');
}
case 'paddle': {
throw new Error('Paddle is not yet supported');
}
default:
throw new Error(`Unsupported provider: ${provider as string}`);
}
}

View File

@@ -0,0 +1,3 @@
export * from './plan-picker';
export * from './current-plan-card';
export * from './embedded-checkout';

View File

@@ -0,0 +1,222 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRightIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { BillingSchema } from '@kit/billing';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Label } from '@kit/ui/label';
import {
RadioGroup,
RadioGroupItem,
RadioGroupItemLabel,
} from '@kit/ui/radio-group';
import { Trans } from '@kit/ui/trans';
export function PlanPicker(
props: React.PropsWithChildren<{
config: z.infer<typeof BillingSchema>;
onSubmit: (data: { planId: 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 form = useForm({
resolver: zodResolver(
z
.object({
planId: z.string(),
interval: z.string(),
})
.refine(
(data) => {
const planFound = props.config.products
.flatMap((item) => item.plans)
.some((plan) => plan.id === data.planId);
if (!planFound) {
return false;
}
return intervals.includes(data.interval);
},
{ message: 'Invalid plan', path: ['planId'] },
),
),
defaultValues: {
interval: intervals[0],
planId: '',
},
});
const selectedInterval = form.watch('interval');
return (
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(props.onSubmit)}
>
<FormField
name={'interval'}
render={({ field }) => {
return (
<FormItem className={'rounded-md border p-4'}>
<FormLabel>Choose your billing interval</FormLabel>
<FormControl>
<RadioGroup name={field.name} value={field.value}>
{intervals.map((interval) => {
return (
<div
key={interval}
className={'flex items-center space-x-2'}
>
<RadioGroupItem
id={interval}
value={interval}
onClick={() => {
form.setValue('interval', interval);
}}
/>
<span className={'text-sm font-bold'}>
<Trans
i18nKey={`common.billingInterval.${interval}`}
defaults={interval}
/>
</span>
</div>
);
})}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'planId'}
render={({ field }) => (
<FormItem>
<FormLabel>Pick your preferred plan</FormLabel>
<FormControl>
<RadioGroup name={field.name}>
{props.config.products.map((item) => {
const variant = item.plans.find(
(plan) => plan.interval === selectedInterval,
);
if (!variant) {
throw new Error('No plan found');
}
return (
<RadioGroupItemLabel key={variant.id}>
<RadioGroupItem
id={variant.id}
value={variant.id}
onClick={() => {
form.setValue('planId', variant.id);
}}
/>
<div
className={'flex w-full items-center justify-between'}
>
<Label
htmlFor={variant.id}
className={
'flex flex-col justify-center space-y-1.5'
}
>
<span className="font-bold">{item.name}</span>
<span className={'text-muted-foreground'}>
{item.description}
</span>
</Label>
<div className={'text-right'}>
<div>
<Price key={variant.id}>
<span>
{formatCurrency(
item.currency.toLowerCase(),
variant.price,
)}
</span>
</Price>
</div>
<div>
<span className={'text-muted-foreground'}>
per {variant.interval}
</span>
</div>
</div>
</div>
</RadioGroupItemLabel>
);
})}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button disabled={props.pending}>
{props.pending ? (
'Processing...'
) : (
<>
<span>Proceed to payment</span>
<ArrowRightIcon className={'ml-2 h-4 w-4'} />
</>
)}
</Button>
</div>
</form>
</Form>
);
}
function Price(props: React.PropsWithChildren) {
return (
<span
className={
'animate-in slide-in-from-left-4 fade-in text-xl font-bold duration-500'
}
>
{props.children}
</span>
);
}
function formatCurrency(currencyCode: string, value: string) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(value);
}