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:
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingProvider, BillingStrategyProviderService } from '@kit/billing';
|
||||
|
||||
export class BillingGatewayFactoryService {
|
||||
static async GetProviderStrategy(
|
||||
provider: z.infer<typeof BillingProvider>,
|
||||
): Promise<BillingStrategyProviderService> {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
const { StripeBillingStrategyService } = await import('@kit/stripe');
|
||||
|
||||
return new StripeBillingStrategyService();
|
||||
}
|
||||
|
||||
case 'paddle': {
|
||||
throw new Error('Paddle is not supported yet');
|
||||
}
|
||||
|
||||
case 'lemon-squeezy': {
|
||||
throw new Error('Lemon Squeezy is not supported yet');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported billing provider: ${provider as string}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
packages/billing-gateway/src/billing-gateway-service.ts
Normal file
93
packages/billing-gateway/src/billing-gateway-service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingProvider } from '@kit/billing';
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
CreateBillingPortalSessionSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '@kit/billing/schema';
|
||||
|
||||
import { BillingGatewayFactoryService } from './billing-gateway-factory.service';
|
||||
|
||||
/**
|
||||
* @description The billing gateway service to interact with the billing provider of choice (e.g. Stripe)
|
||||
* @class BillingGatewayService
|
||||
* @param {BillingProvider} provider - The billing provider to use
|
||||
* @example
|
||||
*
|
||||
* const provider = 'stripe';
|
||||
* const billingGatewayService = new BillingGatewayService(provider);
|
||||
*/
|
||||
export class BillingGatewayService {
|
||||
constructor(private readonly provider: z.infer<typeof BillingProvider>) {}
|
||||
|
||||
/**
|
||||
* Creates a checkout session for billing.
|
||||
*
|
||||
* @param {CreateBillingCheckoutSchema} params - The parameters for creating the checkout session.
|
||||
*
|
||||
*/
|
||||
async createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
|
||||
this.provider,
|
||||
);
|
||||
|
||||
const payload = CreateBillingCheckoutSchema.parse(params);
|
||||
|
||||
return strategy.createCheckoutSession(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the checkout session from the specified provider.
|
||||
*
|
||||
* @param {RetrieveCheckoutSessionSchema} params - The parameters to retrieve the checkout session.
|
||||
*/
|
||||
async retrieveCheckoutSession(
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
) {
|
||||
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
|
||||
this.provider,
|
||||
);
|
||||
|
||||
const payload = RetrieveCheckoutSessionSchema.parse(params);
|
||||
|
||||
return strategy.retrieveCheckoutSession(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a billing portal session for the specified parameters.
|
||||
*
|
||||
* @param {CreateBillingPortalSessionSchema} params - The parameters to create the billing portal session.
|
||||
*/
|
||||
async createBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
|
||||
this.provider,
|
||||
);
|
||||
|
||||
const payload = CreateBillingPortalSessionSchema.parse(params);
|
||||
|
||||
return strategy.createBillingPortalSession(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a subscription.
|
||||
*
|
||||
* @param {CancelSubscriptionParamsSchema} params - The parameters for cancelling the subscription.
|
||||
*/
|
||||
async cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
|
||||
this.provider,
|
||||
);
|
||||
|
||||
const payload = CancelSubscriptionParamsSchema.parse(params);
|
||||
|
||||
return strategy.cancelSubscription(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export function CurrentPlanCard(props: React.PropsWithChildren<{}>) {}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
3
packages/billing-gateway/src/components/index.ts
Normal file
3
packages/billing-gateway/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './plan-picker';
|
||||
export * from './current-plan-card';
|
||||
export * from './embedded-checkout';
|
||||
222
packages/billing-gateway/src/components/plan-picker.tsx
Normal file
222
packages/billing-gateway/src/components/plan-picker.tsx
Normal 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);
|
||||
}
|
||||
33
packages/billing-gateway/src/gateway-provider-factory.ts
Normal file
33
packages/billing-gateway/src/gateway-provider-factory.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { BillingGatewayService } from './billing-gateway-service';
|
||||
|
||||
/**
|
||||
* @description This function retrieves the billing provider from the database and returns a
|
||||
* new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
|
||||
* defined in the host application.
|
||||
* @param {ReturnType<typeof getSupabaseServerActionClient>} client - The Supabase server action client.
|
||||
*
|
||||
*/
|
||||
export async function getGatewayProvider(
|
||||
client: ReturnType<typeof getSupabaseServerActionClient>,
|
||||
) {
|
||||
const provider = await getBillingProvider(client);
|
||||
|
||||
return new BillingGatewayService(provider);
|
||||
}
|
||||
|
||||
async function getBillingProvider(
|
||||
client: ReturnType<typeof getSupabaseServerActionClient>,
|
||||
) {
|
||||
const { data, error } = await client
|
||||
.from('config')
|
||||
.select('billing_provider')
|
||||
.single();
|
||||
|
||||
if (error ?? !data.billing_provider) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.billing_provider;
|
||||
}
|
||||
2
packages/billing-gateway/src/index.ts
Normal file
2
packages/billing-gateway/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './billing-gateway-service';
|
||||
export * from './gateway-provider-factory';
|
||||
Reference in New Issue
Block a user