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", "month": "Billed monthly",
"year": "Billed yearly" "year": "Billed yearly"
}, },
"custom": "Custom Plan",
"lifetime": "Lifetime", "lifetime": "Lifetime",
"trialPeriod": "{{period}} day trial", "trialPeriod": "{{period}} day trial",
"perPeriod": "per {{period}}", "perPeriod": "per {{period}}",

View File

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

View File

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

View File

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