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:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user