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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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>) {
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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...');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -63,10 +63,6 @@ function EmbeddedCheckoutPopup({
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Complete your purchase</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent
|
||||
className={className}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
|
||||
@@ -73,6 +73,7 @@ export async function createStripeCheckout(
|
||||
line_items: lineItems,
|
||||
client_reference_id: clientReferenceId,
|
||||
subscription_data: subscriptionData,
|
||||
customer_creation: 'always',
|
||||
...customerData,
|
||||
...urls,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user