Refactor billing gateway and enhance localization

Refactored the 'plan-picker' component in the billing gateway to remove unwanted line items and improve checkout session and subscription handling. Enhanced the localization support by adding translations in the plan picker and introduced a new function to check trial eligibility so that existing customers can't start a new trial period. These changes enhance the usability of the application across different regions and provide accurate trial period conditions.
This commit is contained in:
giancarlo
2024-04-01 11:52:35 +08:00
parent 248ab7ef72
commit d6004f2f7e
15 changed files with 877 additions and 346 deletions

View File

@@ -1,12 +1,7 @@
import { formatDate } from 'date-fns';
import { BadgeCheck, CheckCircle2 } from 'lucide-react';
import {
BillingConfig,
getBaseLineItem,
getProductPlanPair,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { Database } from '@kit/supabase/database';
import {
Accordion,
@@ -26,6 +21,7 @@ import { Trans } from '@kit/ui/trans';
import { CurrentPlanAlert } from './current-plan-alert';
import { CurrentPlanBadge } from './current-plan-badge';
import { LineItemDetails } from './line-item-details';
type Subscription = Database['public']['Tables']['subscriptions']['Row'];
type LineItem = Database['public']['Tables']['subscription_items']['Row'];
@@ -42,19 +38,26 @@ export function CurrentPlanCard({
subscription,
config,
}: React.PropsWithChildren<Props>) {
// line items have the same product id
const lineItem = subscription.items[0] as LineItem;
const lineItems = subscription.items;
const firstLineItem = lineItems[0];
const product = config.products.find(
(product) => product.id === lineItem.product_id,
if (!firstLineItem) {
throw new Error('No line items found in subscription');
}
const { product, plan } = getProductPlanPairByVariantId(
config,
firstLineItem.variant_id,
);
if (!product) {
if (!product || !plan) {
throw new Error(
'Product not found. Make sure the product exists in the billing config.',
'Product or plan not found. Did you forget to add it to the billing config?',
);
}
const productLineItems = plan.lineItems;
return (
<Card>
<CardHeader>
@@ -113,8 +116,7 @@ export function CurrentPlanCard({
<If condition={subscription.cancel_at_period_end}>
<div className="flex flex-col">
<span className="font-medium">
Your subscription will be cancelled at the end of the
period
<Trans i18nKey="billing:cancelSubscriptionDate" />
</span>
<div className={'text-muted-foreground'}>
@@ -126,7 +128,21 @@ export function CurrentPlanCard({
</If>
<div className="flex flex-col space-y-1">
<span className="font-medium">Features</span>
<span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" />
</span>
<LineItemDetails
lineItems={productLineItems}
currency={subscription.currency}
selectedInterval={firstLineItem.interval}
/>
</div>
<div className="flex flex-col space-y-1">
<span className="font-semibold">
<Trans i18nKey="billing:featuresLabel" />
</span>
<ul className={'flex flex-col space-y-0.5'}>
{product.features.map((item) => {

View File

@@ -0,0 +1,95 @@
import { z } from 'zod';
import { LineItemSchema } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Trans } from '@kit/ui/trans';
export function LineItemDetails(
props: React.PropsWithChildren<{
lineItems: z.infer<typeof LineItemSchema>[];
currency: string;
selectedInterval: string;
}>,
) {
return (
<div className={'flex flex-col divide-y'}>
{props.lineItems.map((item) => {
switch (item.type) {
case 'base':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span className={'flex space-x-2'}>
<span>
<Trans i18nKey={'billing:flatSubscription'} />
</span>
<span>/</span>
<span>
<Trans
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
/>
</span>
</span>
<span className={'font-semibold'}>
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
</span>
</div>
);
case 'per-seat':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span>
<Trans i18nKey={'billing:perTeamMember'} />
</span>
<span className={'font-semibold'}>
{formatCurrency(props.currency.toLowerCase(), item.cost)}
</span>
</div>
);
case 'metered':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: item.unit,
}}
/>
{item.included ? (
<Trans
i18nKey={'billing:perUnitIncluded'}
values={{
included: item.included,
}}
/>
) : (
''
)}
</span>
<span className={'font-semibold'}>
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
</span>
</div>
);
}
})}
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { z } from 'zod';
import {
BillingConfig,
LineItemSchema,
getBaseLineItem,
getPlanIntervals,
getProductPlanPair,
@@ -36,6 +37,8 @@ import {
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
export function PlanPicker(
props: React.PropsWithChildren<{
config: BillingConfig;
@@ -55,7 +58,8 @@ export function PlanPicker(
resolver: zodResolver(
z
.object({
planId: z.string(),
planId: z.string().min(1),
productId: z.string().min(1),
interval: z.string().min(1),
})
.refine(
@@ -143,6 +147,10 @@ export function PlanPicker(
shouldValidate: true,
});
form.setValue('productId', '', {
shouldValidate: true,
});
form.setValue('interval', interval, {
shouldValidate: true,
});
@@ -311,167 +319,97 @@ export function PlanPicker(
</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>
<Trans
i18nKey={`billing:products.${selectedProduct?.id}.name`}
defaults={selectedProduct?.name}
/>
</b>{' '}
/{' '}
<Trans
i18nKey={`billing:billingInterval.${selectedInterval}`}
/>
</Heading>
<p>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:products.${selectedProduct?.id}.description`}
defaults={selectedProduct?.description}
/>
</span>
</p>
</div>
<div className={'flex flex-col space-y-1'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</span>
<div className={'flex flex-col divide-y'}>
{selectedPlan?.lineItems.map((item) => {
switch (item.type) {
case 'base':
return (
<div
key={item.id}
className={
'flex items-center justify-between py-1.5 text-sm'
}
>
<span className={'flex space-x-2'}>
<span>
<Trans i18nKey={'billing:flatSubscription'} />
</span>
<span>/</span>
<span>
<Trans
i18nKey={`billing:billingInterval.${selectedInterval}`}
/>
</span>
</span>
<span className={'font-semibold'}>
{formatCurrency(
selectedProduct?.currency.toLowerCase(),
item.cost,
)}
</span>
</div>
);
case 'per-seat':
return (
<div
key={item.id}
className={
'flex items-center justify-between py-1.5 text-sm'
}
>
<span>
<Trans i18nKey={'billing:perTeamMember'} />
</span>
<span className={'font-semibold'}>
{formatCurrency(
selectedProduct?.currency.toLowerCase(),
item.cost,
)}
</span>
</div>
);
case 'metered':
return (
<div
key={item.id}
className={
'flex items-center justify-between py-1.5 text-sm'
}
>
<span>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: item.unit,
}}
/>
{item.included ? (
<Trans
i18nKey={'billing:perUnitIncluded'}
values={{
included: item.included,
}}
/>
) : (
''
)}
</span>
<span className={'font-semibold'}>
{formatCurrency(
selectedProduct?.currency.toLowerCase(),
item.cost,
)}
</span>
</div>
);
}
})}
</div>
</div>
<div className={'flex flex-col space-y-2'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:featuresLabel'} />
</span>
{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'}>
<Trans
i18nKey={`billing:features.${item}`}
defaults={item}
/>
</span>
</div>
);
})}
</div>
</div>
</If>
{selectedPlan && selectedInterval && selectedProduct ? (
<PlanDetails
selectedInterval={selectedInterval}
selectedPlan={selectedPlan}
selectedProduct={selectedProduct}
/>
) : null}
</div>
</Form>
);
}
function PlanDetails({
selectedProduct,
selectedInterval,
selectedPlan,
}: {
selectedProduct: {
id: string;
name: string;
description: string;
currency: string;
features: string[];
};
selectedInterval: string;
selectedPlan: {
lineItems: z.infer<typeof LineItemSchema>[];
};
}) {
return (
<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>
<Trans
i18nKey={`billing:products.${selectedProduct.id}.name`}
defaults={selectedProduct.name}
/>
</b>{' '}
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
</Heading>
<p>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:products.${selectedProduct.id}.description`}
defaults={selectedProduct.description}
/>
</span>
</p>
</div>
<div className={'flex flex-col space-y-1'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</span>
<LineItemDetails
lineItems={selectedPlan.lineItems ?? []}
selectedInterval={selectedInterval}
currency={selectedProduct.currency}
/>
</div>
<div className={'flex flex-col space-y-2'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:featuresLabel'} />
</span>
{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'}>
<Trans i18nKey={`billing:features.${item}`} defaults={item} />
</span>
</div>
);
})}
</div>
</div>
);
}
function Price(props: React.PropsWithChildren) {
return (
<span

View File

@@ -5,6 +5,8 @@ import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
export class BillingEventHandlerService {
private readonly namespace = 'billing';
constructor(
private readonly clientProvider: () => SupabaseClient<Database>,
private readonly strategy: BillingWebhookHandlerService,
@@ -25,7 +27,7 @@ export class BillingEventHandlerService {
// here we delete the subscription from the database
Logger.info(
{
namespace: 'billing',
namespace: this.namespace,
subscriptionId,
},
'Processing subscription deleted event',
@@ -42,7 +44,7 @@ export class BillingEventHandlerService {
Logger.info(
{
namespace: 'billing',
namespace: this.namespace,
subscriptionId,
},
'Successfully deleted subscription',
@@ -52,22 +54,18 @@ export class BillingEventHandlerService {
const client = this.clientProvider();
const ctx = {
namespace: 'billing',
subscriptionId: subscription.subscription_id,
namespace: this.namespace,
subscriptionId: subscription.target_subscription_id,
provider: subscription.billing_provider,
accountId: subscription.account_id,
customerId: subscription.customer_id,
accountId: subscription.target_account_id,
customerId: subscription.target_customer_id,
};
Logger.info(ctx, 'Processing subscription updated event');
// Handle the subscription updated event
// here we update the subscription in the database
const { error } = await client.rpc('upsert_subscription', {
...subscription,
customer_id: subscription.customer_id,
account_id: subscription.account_id,
});
const { error } = await client.rpc('upsert_subscription', subscription);
if (error) {
Logger.error(
@@ -83,32 +81,114 @@ export class BillingEventHandlerService {
Logger.info(ctx, 'Successfully updated subscription');
},
onCheckoutSessionCompleted: async (subscription, customerId) => {
onCheckoutSessionCompleted: async (payload, customerId) => {
// Handle the checkout session completed event
// here we add the subscription to the database
const client = this.clientProvider();
const ctx = {
namespace: 'billing',
subscriptionId: subscription.subscription_id,
provider: subscription.billing_provider,
accountId: subscription.account_id,
};
// 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) {
const ctx = {
namespace: this.namespace,
orderId: payload.order_id,
provider: payload.billing_provider,
accountId: payload.target_account_id,
customerId,
};
Logger.info(ctx, 'Processing checkout session completed event...');
Logger.info(ctx, 'Processing order completed event...');
const { error } = await client.rpc('upsert_subscription', {
...subscription,
customer_id: customerId,
});
const { error } = await client.rpc('upsert_order', payload);
if (error) {
Logger.error({ ...ctx, error }, 'Failed to add order');
throw new Error('Failed to add order');
}
Logger.info(ctx, 'Successfully added order');
} else {
const ctx = {
namespace: this.namespace,
subscriptionId: payload.target_subscription_id,
provider: payload.billing_provider,
accountId: payload.target_account_id,
customerId,
};
Logger.info(ctx, 'Processing checkout session completed event...');
const { error } = await client.rpc('upsert_subscription', payload);
if (error) {
Logger.error({ ...ctx, error }, 'Failed to add subscription');
throw new Error('Failed to add subscription');
}
Logger.info(ctx, 'Successfully added subscription');
}
},
onPaymentSucceeded: async (sessionId: string) => {
const client = this.clientProvider();
// Handle the payment succeeded event
// here we update the payment status in the database
Logger.info(
{
namespace: this.namespace,
sessionId,
},
'Processing payment succeeded event',
);
const { error } = await client
.from('orders')
.update({ status: 'succeeded' })
.match({ session_id: sessionId });
if (error) {
Logger.error({ ...ctx, error }, 'Failed to add subscription');
throw new Error('Failed to add subscription');
throw new Error('Failed to update payment status');
}
Logger.info(ctx, 'Successfully added subscription');
Logger.info(
{
namespace: 'billing',
sessionId,
},
'Successfully updated payment status',
);
},
onPaymentFailed: async (sessionId: string) => {
const client = this.clientProvider();
// Handle the payment failed event
// here we update the payment status in the database
Logger.info(
{
namespace: this.namespace,
sessionId,
},
'Processing payment failed event',
);
const { error } = await client
.from('orders')
.update({ status: 'failed' })
.match({ session_id: sessionId });
if (error) {
throw new Error('Failed to update payment status');
}
Logger.info(
{
namespace: this.namespace,
sessionId,
},
'Successfully updated payment status',
);
},
});
}