Improve and update billing flow
This commit updates various components in the billing flow due to a new schema that supports multiple line items per plan. The added flexibility rendered 'line-items-mapper.ts' redundant, which has been removed. Additionally, webhooks have been created for handling account membership insertions and deletions, as well as handling subscription deletions when an account is deleted. This message also introduces a new service to handle sending out invitation emails. Lastly, the validation of the billing provider has been improved for increased security and stability.
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { formatDate } from 'date-fns';
|
||||
import { BadgeCheck, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import { BillingConfig, getProductPlanPair } from '@kit/billing';
|
||||
import {
|
||||
BillingConfig,
|
||||
getBaseLineItem,
|
||||
getProductPlanPair,
|
||||
} from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import {
|
||||
@@ -31,6 +35,7 @@ export function CurrentPlanCard({
|
||||
config: BillingConfig;
|
||||
}>) {
|
||||
const { plan, product } = getProductPlanPair(config, subscription.variant_id);
|
||||
const baseLineItem = getBaseLineItem(config, plan.id);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -62,7 +67,7 @@ export function CurrentPlanCard({
|
||||
i18nKey="billing:planRenewal"
|
||||
values={{
|
||||
interval: subscription.interval,
|
||||
price: formatCurrency(product.currency, plan.price),
|
||||
price: formatCurrency(product.currency, baseLineItem.price),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -111,19 +116,6 @@ export function CurrentPlanCard({
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={!subscription.cancel_at_period_end}>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="font-medium">Your next bill</span>
|
||||
|
||||
<div className={'text-muted-foreground'}>
|
||||
Your next bill is for {product.currency} {plan.price} on{' '}
|
||||
<span>
|
||||
{formatDate(subscription.period_ends_at ?? '', 'P')}
|
||||
</span>{' '}
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="font-medium">Features</span>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
RadioGroupItem,
|
||||
RadioGroupItemLabel,
|
||||
} from '@kit/ui/radio-group';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
@@ -81,189 +83,240 @@ export function PlanPicker(
|
||||
const { interval: selectedInterval } = form.watch();
|
||||
const planId = form.getValues('planId');
|
||||
|
||||
const selectedPlan = useMemo(() => {
|
||||
const { plan: selectedPlan, product: selectedProduct } = useMemo(() => {
|
||||
try {
|
||||
return getProductPlanPair(props.config, planId).plan;
|
||||
return getProductPlanPair(props.config, planId);
|
||||
} catch {
|
||||
return;
|
||||
return {
|
||||
plan: null,
|
||||
product: null,
|
||||
};
|
||||
}
|
||||
}, [form, props.config, planId]);
|
||||
}, [props.config, planId]);
|
||||
|
||||
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 htmlFor={'plan-picker-id'}>
|
||||
Choose your billing interval
|
||||
</FormLabel>
|
||||
<div className={'flex space-x-4'}>
|
||||
<form
|
||||
className={'flex w-full max-w-xl flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit(props.onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name={'interval'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className={'rounded-md border p-4'}>
|
||||
<FormLabel htmlFor={'plan-picker-id'}>
|
||||
Choose your billing interval
|
||||
</FormLabel>
|
||||
|
||||
<FormControl id={'plan-picker-id'}>
|
||||
<RadioGroup name={field.name} value={field.value}>
|
||||
<div className={'flex space-x-2.5'}>
|
||||
{intervals.map((interval) => {
|
||||
const selected = field.value === interval;
|
||||
<FormControl id={'plan-picker-id'}>
|
||||
<RadioGroup name={field.name} value={field.value}>
|
||||
<div className={'flex space-x-2.5'}>
|
||||
{intervals.map((interval) => {
|
||||
const selected = field.value === interval;
|
||||
|
||||
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',
|
||||
{
|
||||
['border-border']: selected,
|
||||
['hover:bg-muted']: !selected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={interval}
|
||||
value={interval}
|
||||
onClick={() => {
|
||||
form.setValue('planId', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
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',
|
||||
{
|
||||
['border-border']: selected,
|
||||
['hover:bg-muted']: !selected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={interval}
|
||||
value={interval}
|
||||
onClick={() => {
|
||||
form.setValue('planId', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('interval', interval, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className={'text-sm font-bold'}>
|
||||
<Trans
|
||||
i18nKey={`common:billingInterval.${interval}`}
|
||||
form.setValue('interval', interval, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'planId'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pick your preferred plan</FormLabel>
|
||||
<span className={'text-sm font-bold'}>
|
||||
<Trans
|
||||
i18nKey={`common:billingInterval.${interval}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name}>
|
||||
{props.config.products.map((product) => {
|
||||
const plan = product.plans.find(
|
||||
(item) => item.interval === selectedInterval,
|
||||
);
|
||||
<FormField
|
||||
name={'planId'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pick your preferred plan</FormLabel>
|
||||
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name}>
|
||||
{props.config.products.map((product) => {
|
||||
const plan = product.plans.find(
|
||||
(item) => item.interval === selectedInterval,
|
||||
);
|
||||
|
||||
const baseLineItem = getBaseLineItem(props.config, plan.id);
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
selected={field.value === plan.id}
|
||||
key={plan.id}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
form.setValue('planId', plan.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
const baseLineItem = getBaseLineItem(
|
||||
props.config,
|
||||
plan.id,
|
||||
);
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={'flex w-full items-center justify-between'}
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
selected={field.value === plan.id}
|
||||
key={plan.id}
|
||||
>
|
||||
<Label
|
||||
htmlFor={plan.id}
|
||||
className={'flex flex-col justify-center space-y-2'}
|
||||
>
|
||||
<span className="font-bold">{product.name}</span>
|
||||
<RadioGroupItem
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
form.setValue('planId', plan.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
<span className={'text-muted-foreground'}>
|
||||
{product.description}
|
||||
</span>
|
||||
</Label>
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={'flex items-center space-x-4 text-right'}
|
||||
className={
|
||||
'flex w-full items-center justify-between'
|
||||
}
|
||||
>
|
||||
<If condition={plan.trialPeriod}>
|
||||
<div>
|
||||
<Badge variant={'success'}>
|
||||
{plan.trialPeriod} day trial
|
||||
</Badge>
|
||||
</div>
|
||||
</If>
|
||||
<Label
|
||||
htmlFor={plan.id}
|
||||
className={
|
||||
'flex flex-col justify-center space-y-2'
|
||||
}
|
||||
>
|
||||
<span className="font-bold">{product.name}</span>
|
||||
|
||||
<div>
|
||||
<Price key={plan.id}>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
product.currency.toLowerCase(),
|
||||
baseLineItem.cost,
|
||||
)}
|
||||
</span>
|
||||
</Price>
|
||||
<span className={'text-muted-foreground'}>
|
||||
{product.description}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<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>
|
||||
<span className={'text-muted-foreground'}>
|
||||
per {selectedInterval}
|
||||
</span>
|
||||
<Price key={plan.id}>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
product.currency.toLowerCase(),
|
||||
baseLineItem.cost,
|
||||
)}
|
||||
</span>
|
||||
</Price>
|
||||
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
per {selectedInterval}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupItemLabel>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</RadioGroupItemLabel>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={props.pending ?? !form.formState.isValid}>
|
||||
{props.pending ? (
|
||||
'Processing...'
|
||||
) : (
|
||||
<>
|
||||
<If
|
||||
condition={selectedPlan?.trialPeriod}
|
||||
fallback={'Proceed to payment'}
|
||||
>
|
||||
<span>Start {selectedPlan?.trialPeriod} day trial</span>
|
||||
</If>
|
||||
|
||||
<ArrowRight className={'ml-2 h-4 w-4'} />
|
||||
</>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={props.pending ?? !form.formState.isValid}>
|
||||
{props.pending ? (
|
||||
'Processing...'
|
||||
) : (
|
||||
<>
|
||||
<If
|
||||
condition={selectedPlan?.trialPeriod}
|
||||
fallback={'Proceed to payment'}
|
||||
>
|
||||
<span>Start {selectedPlan?.trialPeriod} day trial</span>
|
||||
</If>
|
||||
|
||||
<ArrowRight className={'ml-2 h-4 w-4'} />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<If condition={selectedPlan && selectedProduct}>
|
||||
<div
|
||||
className={
|
||||
'fade-in animate-in zoom-in-90 flex w-full flex-col space-y-4 rounded-lg border p-4'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-0.5'}>
|
||||
<Heading level={5}>
|
||||
<b>{selectedProduct?.name}</b>
|
||||
</Heading>
|
||||
|
||||
<p>
|
||||
<span className={'text-muted-foreground'}>
|
||||
{selectedProduct?.description}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col'}>
|
||||
{selectedProduct?.features.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item}
|
||||
className={'flex items-center space-x-2 text-sm'}
|
||||
>
|
||||
<CheckCircle className={'h-4 text-green-500'} />
|
||||
|
||||
<span className={'text-muted-foreground'}>{item}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './server/services/billing-gateway/billing-gateway.service';
|
||||
export * from './server/services/billing-gateway/billing-gateway-provider-factory';
|
||||
export * from './server/services/billing-event-handler/billing-gateway-provider-factory';
|
||||
export * from './server/services/account-billing.service';
|
||||
export * from './server/services/billing-webhooks/billing-webhooks.service';
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { BillingGatewayService } from './billing-gateway/billing-gateway.service';
|
||||
|
||||
export class AccountBillingService {
|
||||
private readonly namespace = 'accounts.billing';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async cancelAllAccountSubscriptions({
|
||||
accountId,
|
||||
userId,
|
||||
}: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
Logger.info(
|
||||
{
|
||||
userId,
|
||||
accountId,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Cancelling all subscriptions for account...',
|
||||
);
|
||||
|
||||
const { data: subscriptions } = await this.client
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('account_id', accountId);
|
||||
|
||||
const cancellationRequests = [];
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
userId,
|
||||
subscriptions: subscriptions?.length ?? 0,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Cancelling all account subscriptions...',
|
||||
);
|
||||
|
||||
for (const subscription of subscriptions ?? []) {
|
||||
const gateway = new BillingGatewayService(subscription.billing_provider);
|
||||
|
||||
cancellationRequests.push(
|
||||
gateway.cancelSubscription({
|
||||
subscriptionId: subscription.id,
|
||||
invoiceNow: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// execute all cancellation requests
|
||||
await Promise.all(cancellationRequests);
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
userId,
|
||||
subscriptions: subscriptions?.length ?? 0,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Subscriptions cancelled successfully',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingProvider, BillingWebhookHandlerService } from '@kit/billing';
|
||||
import {
|
||||
BillingProviderSchema,
|
||||
BillingWebhookHandlerService,
|
||||
} from '@kit/billing';
|
||||
|
||||
export class BillingEventHandlerFactoryService {
|
||||
static async GetProviderStrategy(
|
||||
provider: z.infer<typeof BillingProvider>,
|
||||
provider: z.infer<typeof BillingProviderSchema>,
|
||||
): Promise<BillingWebhookHandlerService> {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { BillingGatewayService } from '../billing-gateway/billing-gateway.service';
|
||||
|
||||
type Subscription = Database['public']['Tables']['subscriptions']['Row'];
|
||||
|
||||
export class BillingWebhooksService {
|
||||
async handleSubscriptionDeletedWebhook(subscription: Subscription) {
|
||||
const gateway = new BillingGatewayService(subscription.billing_provider);
|
||||
|
||||
await gateway.cancelSubscription({
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user