Update billing system to support single and recurring payments

This update modifies the billing system to properly handle both single and recurring payment plans. Logic is introduced to determine whether the selected plan is recurring or a one-time payment and adjust the interface accordingly. The naming of some components and variables has been changed to more accurately reflect their purpose. Additionally, a
This commit is contained in:
giancarlo
2024-04-01 20:58:26 +08:00
parent 6b72206b00
commit 84a4b45bcd
22 changed files with 291 additions and 119 deletions

View File

@@ -0,0 +1,94 @@
import { BadgeCheck } from 'lucide-react';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { Database } from '@kit/supabase/database';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { CurrentPlanBadge } from './current-plan-badge';
import { LineItemDetails } from './line-item-details';
type Order = Database['public']['Tables']['orders']['Row'];
type LineItem = Database['public']['Tables']['order_items']['Row'];
interface Props {
order: Order & {
items: LineItem[];
};
config: BillingConfig;
}
export function CurrentLifetimeOrderCard({
order,
config,
}: React.PropsWithChildren<Props>) {
const lineItems = order.items;
const firstLineItem = lineItems[0];
if (!firstLineItem) {
throw new Error('No line items found in subscription');
}
const { product, plan } = getProductPlanPairByVariantId(
config,
firstLineItem.variant_id,
);
if (!product || !plan) {
throw new Error(
'Product or plan not found. Did you forget to add it to the billing config?',
);
}
const productLineItems = plan.lineItems;
return (
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="billing:planCardTitle" />
</CardTitle>
<CardDescription>
<Trans i18nKey="billing:planCardDescription" />
</CardDescription>
</CardHeader>
<CardContent className={'space-y-3 text-sm'}>
<div className={'flex flex-col space-y-1'}>
<div className={'flex items-center space-x-2 text-lg font-semibold'}>
<BadgeCheck
className={
's-6 fill-green-500 text-white dark:fill-white dark:text-black'
}
/>
<span>{product.name}</span>
<CurrentPlanBadge status={order.status} />
</div>
</div>
<div>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" />
</span>
<LineItemDetails
lineItems={productLineItems}
currency={order.currency}
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -2,9 +2,13 @@ import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
import { Trans } from '@kit/ui/trans';
type Status =
| Database['public']['Enums']['subscription_status']
| Database['public']['Enums']['payment_status'];
export function CurrentPlanBadge(
props: React.PropsWithoutRef<{
status: Database['public']['Enums']['subscription_status'];
status: Status;
}>,
) {
let variant: 'success' | 'warning' | 'destructive';
@@ -12,12 +16,14 @@ export function CurrentPlanBadge(
switch (props.status) {
case 'active':
case 'succeeded':
variant = 'success';
break;
case 'trialing':
variant = 'success';
break;
case 'past_due':
case 'failed':
variant = 'destructive';
break;
case 'canceled':
@@ -27,6 +33,7 @@ export function CurrentPlanBadge(
variant = 'destructive';
break;
case 'incomplete':
case 'pending':
variant = 'warning';
break;
case 'incomplete_expired':

View File

@@ -1,14 +1,10 @@
import { formatDate } from 'date-fns';
import { BadgeCheck, CheckCircle2 } from 'lucide-react';
import { BadgeCheck } from 'lucide-react';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { Database } from '@kit/supabase/database';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@kit/ui/accordion';
import {
Card,
CardContent,
@@ -34,7 +30,7 @@ interface Props {
config: BillingConfig;
}
export function CurrentPlanCard({
export function CurrentSubscriptionCard({
subscription,
config,
}: React.PropsWithChildren<Props>) {

View File

@@ -1,5 +1,6 @@
export * from './plan-picker';
export * from './current-plan-card';
export * from './current-subscription-card';
export * from './current-lifetime-order-card';
export * from './embedded-checkout';
export * from './billing-session-status';
export * from './billing-portal-card';

View File

@@ -2,13 +2,14 @@ import { z } from 'zod';
import { LineItemSchema } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
export function LineItemDetails(
props: React.PropsWithChildren<{
lineItems: z.infer<typeof LineItemSchema>[];
currency: string;
selectedInterval: string;
selectedInterval?: string | undefined;
}>,
) {
return (
@@ -23,15 +24,20 @@ export function LineItemDetails(
>
<span className={'flex space-x-2'}>
<span>
<Trans i18nKey={'billing:flatSubscription'} />
<Trans i18nKey={'billing:basePlan'} />
</span>
<span>/</span>
<span>
<Trans
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
/>
<If
condition={props.selectedInterval}
fallback={<Trans i18nKey={'billing:lifetime'} />}
>
<Trans
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
/>
</If>
</span>
</span>

View File

@@ -101,6 +101,10 @@ export function PlanPicker(
const { t } = useTranslation(`billing`);
// display the period picker if the selected plan is recurring or if no plan is selected
const isRecurringPlan =
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
return (
<Form {...form}>
<div
@@ -112,67 +116,74 @@ export function PlanPicker(
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'}>
<Trans i18nKey={'common:billingInterval.label'} />
</FormLabel>
<div
className={cn('transition-all', {
['pointer-events-none opacity-50']: !isRecurringPlan,
})}
>
<FormField
name={'interval'}
render={({ field }) => {
return (
<FormItem className={'rounded-md border p-4'}>
<FormLabel htmlFor={'plan-picker-id'}>
<Trans i18nKey={'common:billingInterval.label'} />
</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('productId', '', {
shouldValidate: true,
});
form.setValue('productId', '', {
shouldValidate: true,
});
form.setValue('interval', interval, {
shouldValidate: true,
});
}}
/>
<span className={'text-sm font-bold'}>
<Trans
i18nKey={`billing:billingInterval.${interval}`}
form.setValue('interval', interval, {
shouldValidate: true,
});
}}
/>
</span>
</label>
);
})}
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<span className={'text-sm font-bold'}>
<Trans
i18nKey={`billing:billingInterval.${interval}`}
/>
</span>
</label>
);
})}
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<FormField
name={'planId'}
@@ -185,9 +196,13 @@ export function PlanPicker(
<FormControl>
<RadioGroup name={field.name}>
{props.config.products.map((product) => {
const plan = product.plans.find(
(item) => item.interval === selectedInterval,
);
const plan = product.plans.find((item) => {
if (item.paymentType === 'one-time') {
return true;
}
return item.interval === selectedInterval;
});
if (!plan) {
return null;
@@ -277,12 +292,21 @@ export function PlanPicker(
<div>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:perPeriod`}
values={{
period: selectedInterval,
}}
/>
<If
condition={
plan.paymentType === 'recurring'
}
fallback={
<Trans i18nKey={`billing:lifetime`} />
}
>
<Trans
i18nKey={`billing:perPeriod`}
values={{
period: selectedInterval,
}}
/>
</If>
</span>
</div>
</div>
@@ -348,8 +372,11 @@ function PlanDetails({
selectedPlan: {
lineItems: z.infer<typeof LineItemSchema>[];
paymentType: string;
};
}) {
const isRecurring = selectedPlan.paymentType === 'recurring';
return (
<div
className={
@@ -364,7 +391,9 @@ function PlanDetails({
defaults={selectedProduct.name}
/>
</b>{' '}
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
<If condition={isRecurring}>
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
</If>
</Heading>
<p>
@@ -384,7 +413,7 @@ function PlanDetails({
<LineItemDetails
lineItems={selectedPlan.lineItems ?? []}
selectedInterval={selectedInterval}
selectedInterval={isRecurring ? selectedInterval : undefined}
currency={selectedProduct.currency}
/>
</div>

View File

@@ -81,20 +81,20 @@ export class BillingEventHandlerService {
Logger.info(ctx, 'Successfully updated subscription');
},
onCheckoutSessionCompleted: async (payload, customerId) => {
onCheckoutSessionCompleted: async (payload) => {
// Handle the checkout session completed event
// here we add the subscription to the database
const client = this.clientProvider();
// Check if the payload contains an order_id
// if it does, we add an order, otherwise we add a subscription
if ('order_id' in payload) {
if ('target_order_id' in payload) {
const ctx = {
namespace: this.namespace,
orderId: payload.order_id,
orderId: payload.target_order_id,
provider: payload.billing_provider,
accountId: payload.target_account_id,
customerId,
customerId: payload.target_customer_id,
};
Logger.info(ctx, 'Processing order completed event...');
@@ -114,7 +114,7 @@ export class BillingEventHandlerService {
subscriptionId: payload.target_subscription_id,
provider: payload.billing_provider,
accountId: payload.target_account_id,
customerId,
customerId: payload.target_customer_id,
};
Logger.info(ctx, 'Processing checkout session completed event...');

View File

@@ -186,9 +186,9 @@ export type BillingConfig = z.infer<typeof BillingSchema>;
export type ProductSchema = z.infer<typeof ProductSchema>;
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
const intervals = config.products.flatMap((product) =>
product.plans.map((plan) => plan.interval),
);
const intervals = config.products
.flatMap((product) => product.plans.map((plan) => plan.interval))
.filter(Boolean);
return Array.from(new Set(intervals));
}

View File

@@ -29,7 +29,7 @@ export function PersonalAccountSettingsContainer(
}>,
) {
return (
<div className={'flex w-full flex-col space-y-8 pb-32'}>
<div className={'flex w-full flex-col space-y-6 pb-32'}>
<Card>
<CardHeader>
<CardTitle>

View File

@@ -27,7 +27,7 @@ export function TeamAccountSettingsContainer(props: {
};
}) {
return (
<div className={'flex w-full flex-col space-y-8'}>
<div className={'flex w-full flex-col space-y-6'}>
<Card>
<CardHeader>
<CardTitle>

View File

@@ -63,10 +63,6 @@ function EmbeddedCheckoutPopup({
setOpen(open);
}}
>
<DialogHeader>
<DialogTitle>Complete your purchase</DialogTitle>
</DialogHeader>
<DialogContent
className={className}
onOpenAutoFocus={(e) => e.preventDefault()}

View File

@@ -73,6 +73,7 @@ export async function createStripeCheckout(
line_items: lineItems,
client_reference_id: clientReferenceId,
subscription_data: subscriptionData,
customer_creation: 'always',
...customerData,
...urls,
});

View File

@@ -171,7 +171,7 @@ export class StripeWebhookHandlerService
const payload: UpsertOrderParams = {
target_account_id: accountId,
target_customer_id: customerId,
order_id: sessionId,
target_order_id: sessionId,
billing_provider: this.provider,
status: status,
currency: currency,

View File

@@ -410,11 +410,9 @@ export type Database = {
created_at: string
currency: string
id: string
product_id: string
status: Database["public"]["Enums"]["payment_status"]
total_amount: number
updated_at: string
variant_id: string
}
Insert: {
account_id: string
@@ -423,11 +421,9 @@ export type Database = {
created_at?: string
currency: string
id: string
product_id: string
status: Database["public"]["Enums"]["payment_status"]
total_amount: number
updated_at?: string
variant_id: string
}
Update: {
account_id?: string
@@ -436,11 +432,9 @@ export type Database = {
created_at?: string
currency?: string
id?: string
product_id?: string
status?: Database["public"]["Enums"]["payment_status"]
total_amount?: number
updated_at?: string
variant_id?: string
}
Relationships: [
{
@@ -891,7 +885,7 @@ export type Database = {
Args: {
target_account_id: string
target_customer_id: string
order_id: string
target_order_id: string
status: Database["public"]["Enums"]["payment_status"]
billing_provider: Database["public"]["Enums"]["billing_provider"]
total_amount: number
@@ -905,11 +899,9 @@ export type Database = {
created_at: string
currency: string
id: string
product_id: string
status: Database["public"]["Enums"]["payment_status"]
total_amount: number
updated_at: string
variant_id: string
}
}
upsert_subscription: {