Added tiers to billing config and related UI

This commit is contained in:
giancarlo
2024-04-06 11:41:50 +08:00
parent 9492b035a0
commit a1d86d2b7d
7 changed files with 1196 additions and 1039 deletions

View File

@@ -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,
},
],
},
],
},

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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>;
}

View File

@@ -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}

View File

@@ -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