Added tiers to billing config and related UI
This commit is contained in:
@@ -64,14 +64,44 @@ export default createBillingSchema({
|
||||
cost: 99.99,
|
||||
type: 'metered',
|
||||
unit: 'GB',
|
||||
included: 10,
|
||||
tiers: [
|
||||
{
|
||||
upTo: 10,
|
||||
cost: 0.99,
|
||||
},
|
||||
{
|
||||
upTo: 100,
|
||||
cost: 0.49,
|
||||
},
|
||||
{
|
||||
upTo: 1000,
|
||||
cost: 0.29,
|
||||
},
|
||||
{
|
||||
upTo: 'unlimited',
|
||||
cost: 0.19,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '324645',
|
||||
name: 'Addon 2',
|
||||
cost: 9.99,
|
||||
type: 'per-seat',
|
||||
included: 5,
|
||||
tiers: [
|
||||
{
|
||||
upTo: 5,
|
||||
cost: 0,
|
||||
},
|
||||
{
|
||||
upTo: 10,
|
||||
cost: 6.99,
|
||||
},
|
||||
{
|
||||
upTo: 'unlimited',
|
||||
cost: 0.49,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -33,7 +33,12 @@
|
||||
"proceedToPayment": "Proceed to Payment",
|
||||
"startTrial": "Start Trial",
|
||||
"perTeamMember": "Per team member",
|
||||
"perUnit": "For each {{unit}}",
|
||||
"perUnit": "Per {{unit}} usage",
|
||||
"teamMembers": "Team Members",
|
||||
"includedUpTo": "Up to {{upTo}} {{unit}} included in the plan",
|
||||
"fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}",
|
||||
"andAbove": "above {{ previousTier }} {{ unit }}",
|
||||
"setupFee": "plus a {{ setupFee }} setup fee",
|
||||
"perUnitIncluded": "({{included}} included)",
|
||||
"featuresLabel": "Features",
|
||||
"detailsLabel": "Details",
|
||||
|
||||
@@ -43,36 +43,27 @@ export const LineItemSchema = z
|
||||
'Unit of the line item. Displayed to the user. Example "seat" or "GB"',
|
||||
})
|
||||
.optional(),
|
||||
included: z
|
||||
setupFee: z
|
||||
.number({
|
||||
description: 'Included amount of the line item. Displayed to the user.',
|
||||
description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`,
|
||||
})
|
||||
.positive()
|
||||
.optional(),
|
||||
tiers: z
|
||||
.array(
|
||||
z.object({
|
||||
upTo: z
|
||||
.number({
|
||||
description:
|
||||
'Up to this amount the cost is the base cost. Displayed to the user.',
|
||||
})
|
||||
.min(0),
|
||||
cost: z
|
||||
.number({
|
||||
description:
|
||||
'Cost of the line item after the upTo amount. Displayed to the user.',
|
||||
})
|
||||
.min(0),
|
||||
cost: z.number().min(0),
|
||||
upTo: z.union([z.number().min(0), z.literal('unlimited')]),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.type !== 'metered' || (data.unit && data.included !== undefined),
|
||||
data.type !== 'metered' || (data.unit && data.tiers !== undefined),
|
||||
{
|
||||
message: 'Metered line items must have a unit and included amount',
|
||||
path: ['type', 'unit', 'included'],
|
||||
message: 'Metered line items must have a unit and tiers',
|
||||
path: ['type', 'unit', 'tiers'],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -216,22 +207,49 @@ const BillingSchema = z
|
||||
path: ['products'],
|
||||
},
|
||||
)
|
||||
.refine((schema) => {
|
||||
if (schema.provider === 'lemon-squeezy') {
|
||||
for (const product of schema.products) {
|
||||
for (const plan of product.plans) {
|
||||
if (plan.lineItems.length > 1) {
|
||||
return {
|
||||
message: 'Only one line item is allowed for Lemon Squeezy',
|
||||
path: ['products', 'plans'],
|
||||
};
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (schema.provider === 'lemon-squeezy') {
|
||||
for (const product of schema.products) {
|
||||
for (const plan of product.plans) {
|
||||
if (plan.lineItems.length > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'Lemon Squeezy only supports one line item per plan',
|
||||
path: ['provider', 'products'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (schema.provider !== 'lemon-squeezy') {
|
||||
// Check if there are any flat fee metered items
|
||||
const setupFeeItems = schema.products.flatMap((product) =>
|
||||
product.plans.flatMap((plan) =>
|
||||
plan.lineItems.filter((item) => item.setupFee),
|
||||
),
|
||||
);
|
||||
|
||||
// If there are any flat fee metered items, return an error
|
||||
if (setupFeeItems.length > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Setup fee metered items are only supported by Lemon Squeezy. For Stripe and Paddle, please use a separate line item for the setup fee.',
|
||||
path: ['products', 'plans', 'lineItems'],
|
||||
},
|
||||
);
|
||||
|
||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
return BillingSchema.parse(config);
|
||||
@@ -255,6 +273,11 @@ export function getBaseLineItem(
|
||||
for (const product of config.products) {
|
||||
for (const plan of product.plans) {
|
||||
if (plan.id === planId) {
|
||||
// Lemon Squeezy only supports one line item per plan
|
||||
if (config.provider === 'lemon-squeezy') {
|
||||
return plan.lineItems[0];
|
||||
}
|
||||
|
||||
const item = plan.lineItems.find((item) => item.type === 'base');
|
||||
|
||||
if (item) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, PlusCircle } from 'lucide-react';
|
||||
import { PlusSquare } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LineItemSchema } from '@kit/billing';
|
||||
@@ -26,7 +26,7 @@ export function LineItemDetails(
|
||||
return (
|
||||
<div key={item.id} className={className}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<Plus className={'w-4'} />
|
||||
<PlusSquare className={'w-4'} />
|
||||
|
||||
<Trans
|
||||
i18nKey={item.description}
|
||||
@@ -38,94 +38,186 @@ export function LineItemDetails(
|
||||
);
|
||||
}
|
||||
|
||||
const BaseFee = () => (
|
||||
<div key={item.id} className={className}>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusSquare className={'w-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'billing:basePlan'} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span>-</span>
|
||||
|
||||
<span>
|
||||
<If
|
||||
condition={props.selectedInterval}
|
||||
fallback={<Trans i18nKey={'billing:lifetime'} />}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
|
||||
/>
|
||||
</If>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className={'font-semibold'}>
|
||||
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
switch (item.type) {
|
||||
case 'base':
|
||||
return (
|
||||
<div key={item.id} className={className}>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusCircle className={'w-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'billing:basePlan'} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span>-</span>
|
||||
|
||||
<span>
|
||||
<If
|
||||
condition={props.selectedInterval}
|
||||
fallback={<Trans i18nKey={'billing:lifetime'} />}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
|
||||
/>
|
||||
</If>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className={'font-semibold'}>
|
||||
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return <BaseFee />;
|
||||
|
||||
case 'per-seat':
|
||||
return (
|
||||
<div key={item.id} className={className}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusCircle className={'w-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'billing:perTeamMember'} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className={'font-semibold'}>
|
||||
{formatCurrency(props.currency.toLowerCase(), item.cost)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'metered':
|
||||
return (
|
||||
<div key={item.id} className={className}>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<div className={'flex flex-col'}>
|
||||
<div key={item.id} className={className}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusCircle className={'w-4'} />
|
||||
<PlusSquare className={'w-4'} />
|
||||
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:perUnit'}
|
||||
values={{
|
||||
unit: item.unit,
|
||||
}}
|
||||
/>
|
||||
<Trans i18nKey={'billing:perTeamMember'} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{item.included ? (
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:perUnitIncluded'}
|
||||
values={{
|
||||
included: item.included,
|
||||
}}
|
||||
/>
|
||||
<If condition={!item.tiers?.length}>
|
||||
<span className={'font-semibold'}>
|
||||
{formatCurrency(props.currency.toLowerCase(), item.cost)}
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<span className={'font-semibold'}>
|
||||
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
|
||||
</span>
|
||||
<If condition={item.tiers?.length}>
|
||||
<Tiers item={item} currency={props.currency} />
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'metered': {
|
||||
return (
|
||||
<div className={'flex flex-col'}>
|
||||
<div key={item.id} className={className}>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusSquare className={'w-4'} />
|
||||
|
||||
<span className={'flex space-x-1'}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:perUnit'}
|
||||
values={{
|
||||
unit: item.unit,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<If condition={item.setupFee}>
|
||||
{(fee) => (
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:setupFee'}
|
||||
values={{
|
||||
setupFee: formatCurrency(props.currency, fee),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</If>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* If there are no tiers, there is a flat cost for usage */}
|
||||
<If condition={!item.tiers?.length}>
|
||||
<span className={'font-semibold'}>
|
||||
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
{/* If there are tiers, we render them as a list */}
|
||||
<If condition={item.tiers?.length}>
|
||||
<Tiers item={item} currency={props.currency} />
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tiers({
|
||||
currency,
|
||||
item,
|
||||
}: {
|
||||
currency: string;
|
||||
item: z.infer<typeof LineItemSchema>;
|
||||
}) {
|
||||
const tiers = item.tiers?.map((tier, index) => {
|
||||
const previousTier = item.tiers?.[index - 1];
|
||||
const isNoLimit = tier.upTo === 'unlimited';
|
||||
|
||||
const previousTierFrom =
|
||||
tier.upTo === 'unlimited'
|
||||
? 'unlimited'
|
||||
: previousTier === undefined
|
||||
? 0
|
||||
: (previousTier?.upTo as number) + 1 || 0;
|
||||
|
||||
const upTo = tier.upTo;
|
||||
const isIncluded = tier.cost === 0;
|
||||
const unit = item.unit;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={'text-secondary-foreground flex space-x-1 text-xs'}
|
||||
key={tier.upTo}
|
||||
>
|
||||
<span>-</span>
|
||||
|
||||
<If condition={isNoLimit}>
|
||||
<span className={'font-bold'}>
|
||||
{formatCurrency(currency.toLowerCase(), tier.cost)}
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:andAbove'}
|
||||
values={{ unit, previousTier: (previousTierFrom as number) - 1 }}
|
||||
/>
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={!isNoLimit}>
|
||||
<If condition={isIncluded}>
|
||||
<span>
|
||||
<Trans i18nKey={'billing:includedUpTo'} values={{ unit, upTo }} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={!isIncluded}>
|
||||
<span className={'font-bold'}>
|
||||
{formatCurrency(currency.toLowerCase(), tier.cost)}
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:fromPreviousTierUpTo'}
|
||||
values={{ previousTierFrom, unit, upTo }}
|
||||
/>
|
||||
</span>
|
||||
</If>
|
||||
</If>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className={'my-2.5 flex flex-col space-y-1.5'}>{tiers}</div>;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,10 @@ export function PlanPicker(
|
||||
plan.id,
|
||||
);
|
||||
|
||||
if (!baseLineItem) {
|
||||
throw new Error(`Base line item was not found`);
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
selected={field.value === plan.id}
|
||||
|
||||
@@ -79,7 +79,11 @@ export function PricingTable({
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePlan = getBaseLineItem(config, plan.id);
|
||||
const baseLineItem = getBaseLineItem(config, plan.id);
|
||||
|
||||
if (!baseLineItem) {
|
||||
throw new Error(`Base line item was not found`);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
@@ -90,7 +94,7 @@ export function PricingTable({
|
||||
selectable
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
baseLineItem={basePlan}
|
||||
baseLineItem={baseLineItem}
|
||||
product={product}
|
||||
paths={paths}
|
||||
displayPlanDetails={displayPlanDetails}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user