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:
@@ -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}}",
|
||||||
|
|||||||
@@ -26,5 +26,5 @@
|
|||||||
"lib/**/*.ts",
|
"lib/**/*.ts",
|
||||||
"app"
|
"app"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", ".next"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user