Add support for custom billing plans

This commit includes updates to the billing schema to allow for the creation of custom billing plans. These types of plans have no line items associated with them. Changes have also been made to properly handle the absence of primary line items in such plans.
This commit is contained in:
giancarlo
2024-05-01 16:26:35 +07:00
parent 9951c82309
commit 898e1c3dbb
4 changed files with 48 additions and 14 deletions

View File

@@ -26,6 +26,7 @@
"month": "Billed monthly",
"year": "Billed yearly"
},
"custom": "Custom Plan",
"lifetime": "Lifetime",
"trialPeriod": "{{period}} day trial",
"perPeriod": "per {{period}}",

View File

@@ -26,5 +26,5 @@
"lib/**/*.ts",
"app"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".next"]
}

View File

@@ -87,12 +87,17 @@ export const PlanSchema = z
})
.min(1),
interval: BillingIntervalSchema.optional(),
custom: z.boolean().default(false).optional(),
label: z.string().min(1).optional(),
href: z.string().min(1).optional(),
lineItems: z.array(LineItemSchema).refine(
(schema) => {
const types = schema.map((item) => item.type);
const perSeat = types.filter(
(type) => type === LineItemType.PerSeat,
).length;
const flat = types.filter((type) => type === LineItemType.Flat).length;
return perSeat <= 1 && flat <= 1;
@@ -111,10 +116,32 @@ export const PlanSchema = z
.optional(),
paymentType: PaymentTypeSchema,
})
.refine((data) => data.lineItems.length > 0, {
message: 'Plans must have at least one line item',
path: ['lineItems'],
})
.refine(
(data) => {
if (data.custom) {
return data.lineItems.length === 0;
}
return data.lineItems.length > 0;
},
{
message: 'Non-Custom Plans must have at least one line item',
path: ['lineItems'],
},
)
.refine(
(data) => {
if (data.custom) {
return data.lineItems.length === 0;
}
return data.lineItems.length > 0;
},
{
message: 'Custom Plans must have 0 line items',
path: ['lineItems'],
},
)
.refine(
(data) => data.paymentType !== 'one-time' || data.interval === undefined,
{

View File

@@ -83,8 +83,8 @@ export function PricingTable({
const primaryLineItem = getPrimaryLineItem(config, plan.id);
if (!primaryLineItem) {
throw new Error(`Base line item was not found`);
if (!plan.custom && !primaryLineItem) {
throw new Error(`Primary line item not found for plan ${plan.id}`);
}
return (
@@ -115,7 +115,7 @@ function PricingItem(
selectable: boolean;
primaryLineItem: z.infer<typeof LineItemSchema>;
primaryLineItem: z.infer<typeof LineItemSchema> | undefined;
redirectToCheckout?: boolean;
@@ -145,10 +145,12 @@ function PricingItem(
) {
const highlighted = props.product.highlighted ?? false;
const lineItem = props.primaryLineItem;
// we want to exclude the primary plan from the list of line items
// since we are displaying the primary line item separately as the main price
const lineItemsToDisplay = props.plan.lineItems.filter((item) => {
return item.id !== props.primaryLineItem.id;
return item.id !== lineItem?.id;
});
return (
@@ -205,7 +207,11 @@ function PricingItem(
<div className={'flex flex-col space-y-1'}>
<Price>
{formatCurrency(props.product.currency, props.primaryLineItem.cost)}
{lineItem ? (
formatCurrency(props.product.currency, lineItem.cost)
) : (
<Trans i18nKey={'billing:custom'} />
)}
</Price>
<If condition={props.plan.name}>
@@ -225,7 +231,7 @@ function PricingItem(
</If>
</span>
<If condition={props.primaryLineItem.type !== 'flat'}>
<If condition={lineItem && lineItem?.type !== 'flat'}>
<span>/</span>
<span
@@ -233,15 +239,15 @@ function PricingItem(
`animate-in slide-in-from-left-4 fade-in text-sm capitalize`,
)}
>
<If condition={props.primaryLineItem.type === 'per_seat'}>
<If condition={lineItem?.type === 'per_seat'}>
<Trans i18nKey={'billing:perTeamMember'} />
</If>
<If condition={props.primaryLineItem.unit}>
<If condition={lineItem?.unit}>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: props.primaryLineItem.unit,
unit: lineItem?.unit,
}}
/>
</If>