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

@@ -25,7 +25,10 @@ export function PersonalAccountCheckoutForm(props: {
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState(false);
const [checkoutToken, setCheckoutToken] = useState<string>();
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
undefined,
);
// only allow trial if the user is not already a customer
const canStartTrial = !props.customerId;
@@ -36,6 +39,7 @@ export function PersonalAccountCheckoutForm(props: {
<EmbeddedCheckout
checkoutToken={checkoutToken}
provider={billingConfig.provider}
onClose={() => setCheckoutToken(undefined)}
/>
);
}

View File

@@ -2,7 +2,7 @@ import { SupabaseClient } from '@supabase/supabase-js';
import {
BillingPortalCard,
CurrentPlanCard,
CurrentSubscriptionCard,
} from '@kit/billing-gateway/components';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
@@ -51,9 +51,9 @@ async function PersonalAccountBillingPage() {
<If condition={subscription}>
{(subscription) => (
<div
className={'mx-auto flex w-full max-w-2xl flex-col space-y-4'}
className={'mx-auto flex w-full max-w-2xl flex-col space-y-6'}
>
<CurrentPlanCard
<CurrentSubscriptionCard
subscription={subscription}
config={billingConfig}
/>

View File

@@ -24,7 +24,10 @@ export function TeamAccountCheckoutForm(params: {
}) {
const routeParams = useParams();
const [pending, startTransition] = useTransition();
const [checkoutToken, setCheckoutToken] = useState<string | null>(null);
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
undefined,
);
// If the checkout token is set, render the embedded checkout component
if (checkoutToken) {
@@ -32,6 +35,7 @@ export function TeamAccountCheckoutForm(params: {
<EmbeddedCheckout
checkoutToken={checkoutToken}
provider={billingConfig.provider}
onClose={() => setCheckoutToken(undefined)}
/>
);
}

View File

@@ -1,6 +1,6 @@
import {
BillingPortalCard,
CurrentPlanCard,
CurrentSubscriptionCard,
} from '@kit/billing-gateway/components';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -53,7 +53,7 @@ async function TeamAccountBillingPage({ params }: Params) {
</If>
<div>
<div className={'flex flex-col space-y-2'}>
<div className={'flex flex-col space-y-6'}>
<If
condition={subscription}
fallback={
@@ -66,7 +66,10 @@ async function TeamAccountBillingPage({ params }: Params) {
}
>
{(data) => (
<CurrentPlanCard subscription={data} config={billingConfig} />
<CurrentSubscriptionCard
subscription={data}
config={billingConfig}
/>
)}
</If>

View File

@@ -114,7 +114,7 @@ async function TeamAccountMembersPage({ params }: Params) {
<PageBody>
<div
className={'mx-auto flex w-full max-w-3xl flex-col space-y-4 pb-32'}
className={'mx-auto flex w-full max-w-3xl flex-col space-y-6 pb-32'}
>
<Card>
<CardHeader className={'flex flex-row justify-between'}>

View File

@@ -7,6 +7,29 @@ const provider = BillingProviderSchema.parse(
export default createBillingSchema({
provider,
products: [
{
id: 'lifetime',
name: 'Lifetime',
description: 'The perfect plan for a lifetime',
currency: 'USD',
features: ['Feature 1', 'Feature 2', 'Feature 3'],
plans: [
{
name: 'Lifetime',
id: 'lifetime',
paymentType: 'one-time',
lineItems: [
{
id: 'price_1P0jgcI1i3VnbZTqXVXaZkMP',
name: 'Base',
description: 'Base plan',
cost: 999.99,
type: 'base',
},
],
},
],
},
{
id: 'starter',
name: 'Starter',

View File

@@ -20,12 +20,13 @@
"cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your organization owner.",
"manageTeamPlan": "Manage your Team Plan",
"manageTeamPlanDescription": "Choose a plan that fits your team's needs. You can upgrade or downgrade your plan at any time.",
"flatSubscription": "Flat Subscription",
"basePlan": "Base Plan",
"billingInterval": {
"label": "Choose your billing interval",
"month": "Billed monthly",
"year": "Billed yearly"
},
"lifetime": "Lifetime",
"trialPeriod": "{{period}} day trial",
"perPeriod": "per {{period}}",
"processing": "Processing...",
@@ -85,6 +86,21 @@
"badge": "Paused",
"heading": "Your subscription is paused",
"description": "Your subscription is paused. You can resume it at any time."
},
"succeeded": {
"badge": "Succeeded",
"heading": "Your payment was successful",
"description": "Your payment was successful. Thank you for subscribing!"
},
"pending": {
"badge": "Pending",
"heading": "Your payment is pending",
"description": "Your payment is pending. Please bear with us."
},
"failed": {
"badge": "Failed",
"heading": "Your payment failed",
"description": "Your payment failed. Please update your payment method."
}
}
}

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: {

View File

@@ -1249,7 +1249,7 @@ select
create or replace function public.upsert_order(
target_account_id uuid,
target_customer_id varchar(255),
order_id text,
target_order_id text,
status public.payment_status,
billing_provider public.billing_provider,
total_amount numeric,
@@ -1261,7 +1261,7 @@ declare
new_billing_customer_id int;
begin
insert into public.billing_customers(account_id, provider, customer_id)
values (target_account_id, target_billing_provider, target_customer_id)
values (target_account_id, billing_provider, target_customer_id)
on conflict (account_id, provider, customer_id) do update
set provider = excluded.provider
returning id into new_billing_customer_id;
@@ -1277,7 +1277,7 @@ begin
values (
target_account_id,
new_billing_customer_id,
order_id,
target_order_id,
status,
billing_provider,
total_amount,