Replace 'base' line item type with 'flat'

This commit updates all instances of 'base' line item type to 'flat'. It modifies the BillingIntervalSchema, the validation rules for one-time plans, and the function to get the primary line item for a plan. Furthermore, it adjusts the display and filtering of line items in the pricing table component and the plan picker component. The SQL migration script and the sample billing configuration are also updated to reflect this change.
This commit is contained in:
giancarlo
2024-04-07 17:34:47 +08:00
parent b049dfca80
commit 1e23ee2783
6 changed files with 87 additions and 51 deletions

View File

@@ -32,7 +32,7 @@ export default createBillingSchema({
id: '324643', id: '324643',
name: 'Base', name: 'Base',
cost: 999.99, cost: 999.99,
type: 'base', type: 'flat',
}, },
], ],
}, },
@@ -53,33 +53,23 @@ export default createBillingSchema({
interval: 'month', interval: 'month',
lineItems: [ lineItems: [
{ {
id: '55476', id: '324646',
name: 'Base', name: 'Addon 2',
cost: 9.99, cost: 9.99,
type: 'base',
},
{
id: '324644',
name: 'Addon 1',
cost: 99.99,
type: 'metered', type: 'metered',
unit: 'GB', unit: 'GBs',
tiers: [ tiers: [
{
upTo: 5,
cost: 0,
},
{ {
upTo: 10, upTo: 10,
cost: 0.99, cost: 6.99,
},
{
upTo: 100,
cost: 0.49,
},
{
upTo: 1000,
cost: 0.29,
}, },
{ {
upTo: 'unlimited', upTo: 'unlimited',
cost: 0.19, cost: 0.49,
}, },
], ],
}, },
@@ -115,7 +105,7 @@ export default createBillingSchema({
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1', id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1',
name: 'Base', name: 'Base',
cost: 99.99, cost: 99.99,
type: 'base', type: 'flat',
}, },
], ],
}, },
@@ -140,7 +130,7 @@ export default createBillingSchema({
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2', id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2',
name: 'Base', name: 'Base',
cost: 19.99, cost: 19.99,
type: 'base', type: 'flat',
}, },
], ],
}, },
@@ -154,7 +144,7 @@ export default createBillingSchema({
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3', id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3',
name: 'Base', name: 'Base',
cost: 199.99, cost: 199.99,
type: 'base', type: 'flat',
}, },
], ],
}, },
@@ -183,7 +173,7 @@ export default createBillingSchema({
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4', id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4',
name: 'Base', name: 'Base',
cost: 29.99, cost: 29.99,
type: 'base', type: 'flat',
}, },
], ],
}, },
@@ -197,7 +187,7 @@ export default createBillingSchema({
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5', id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5',
name: 'Base', name: 'Base',
cost: 299.99, cost: 299.99,
type: 'base', type: 'flat',
}, },
], ],
}, },

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
const BillingIntervalSchema = z.enum(['month', 'year']); const BillingIntervalSchema = z.enum(['month', 'year']);
const LineItemTypeSchema = z.enum(['base', 'per-seat', 'metered']); const LineItemTypeSchema = z.enum(['flat', 'per-seat', 'metered']);
export const BillingProviderSchema = z.enum([ export const BillingProviderSchema = z.enum([
'stripe', 'stripe',
@@ -122,15 +122,17 @@ export const PlanSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.paymentType === 'one-time') { if (data.paymentType === 'one-time') {
const baseItems = data.lineItems.filter((item) => item.type !== 'base'); const nonFlatLineItems = data.lineItems.filter(
(item) => item.type !== 'flat',
);
return baseItems.length === 0; return nonFlatLineItems.length === 0;
} }
return true; return true;
}, },
{ {
message: 'One-time plans must not have non-base line items', message: 'One-time plans must not have non-flat line items',
path: ['paymentType', 'lineItems'], path: ['paymentType', 'lineItems'],
}, },
); );
@@ -266,7 +268,17 @@ export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
return Array.from(new Set(intervals)); return Array.from(new Set(intervals));
} }
export function getBaseLineItem( /**
* @name getPrimaryLineItem
* @description Get the primary line item for a plan
* By default, the primary line item is the first line item in the plan for Lemon Squeezy
* For other providers, the primary line item is the first flat line item in the plan. If there are no flat line items,
* the first line item is returned.
*
* @param config
* @param planId
*/
export function getPrimaryLineItem(
config: z.infer<typeof BillingSchema>, config: z.infer<typeof BillingSchema>,
planId: string, planId: string,
) { ) {
@@ -278,11 +290,15 @@ export function getBaseLineItem(
return plan.lineItems[0]; return plan.lineItems[0];
} }
const item = plan.lineItems.find((item) => item.type === 'base'); const flatLineItem = plan.lineItems.find(
(item) => item.type === 'flat',
);
if (item) { if (flatLineItem) {
return item; return flatLineItem;
} }
return plan.lineItems[0];
} }
} }
} }

View File

@@ -11,8 +11,8 @@ import { z } from 'zod';
import { import {
BillingConfig, BillingConfig,
LineItemSchema, LineItemSchema,
getBaseLineItem,
getPlanIntervals, getPlanIntervals,
getPrimaryLineItem,
getProductPlanPair, getProductPlanPair,
} from '@kit/billing'; } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils'; import { formatCurrency } from '@kit/shared/utils';
@@ -214,7 +214,7 @@ export function PlanPicker(
return null; return null;
} }
const baseLineItem = getBaseLineItem( const baseLineItem = getPrimaryLineItem(
props.config, props.config,
plan.id, plan.id,
); );

View File

@@ -10,8 +10,8 @@ import { z } from 'zod';
import { import {
BillingConfig, BillingConfig,
LineItemSchema, LineItemSchema,
getBaseLineItem,
getPlanIntervals, getPlanIntervals,
getPrimaryLineItem,
} from '@kit/billing'; } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils'; import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -79,9 +79,9 @@ export function PricingTable({
return null; return null;
} }
const baseLineItem = getBaseLineItem(config, plan.id); const primaryLineItem = getPrimaryLineItem(config, plan.id);
if (!baseLineItem) { if (!primaryLineItem) {
throw new Error(`Base line item was not found`); throw new Error(`Base line item was not found`);
} }
@@ -94,7 +94,7 @@ export function PricingTable({
selectable selectable
key={plan.id} key={plan.id}
plan={plan} plan={plan}
baseLineItem={baseLineItem} primaryLineItem={primaryLineItem}
product={product} product={product}
paths={paths} paths={paths}
displayPlanDetails={displayPlanDetails} displayPlanDetails={displayPlanDetails}
@@ -118,10 +118,7 @@ function PricingItem(
selectable: boolean; selectable: boolean;
baseLineItem: { primaryLineItem: z.infer<typeof LineItemSchema>;
id: string;
cost: number;
};
plan: { plan: {
id: string; id: string;
@@ -149,10 +146,10 @@ function PricingItem(
) { ) {
const highlighted = props.product.highlighted ?? false; const highlighted = props.product.highlighted ?? false;
// we want to exclude the base 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 base plan 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.type !== 'base'; return item.id !== props.primaryLineItem.id;
}); });
return ( return (
@@ -171,7 +168,12 @@ function PricingItem(
<div className={'flex flex-col space-y-2'}> <div className={'flex flex-col space-y-2'}>
<div className={'flex items-center space-x-4'}> <div className={'flex items-center space-x-4'}>
<Heading level={4}> <Heading level={4}>
<b className={'font-bold'}>{props.product.name}</b> <b className={'font-bold'}>
<Trans
i18nKey={props.product.name}
defaults={props.product.name}
/>
</b>
</Heading> </Heading>
<If condition={props.product.badge}> <If condition={props.product.badge}>
@@ -209,13 +211,13 @@ 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.baseLineItem.cost)} {formatCurrency(props.product.currency, props.primaryLineItem.cost)}
</Price> </Price>
<If condition={props.plan.name}> <If condition={props.plan.name}>
<span <span
className={cn( className={cn(
`animate-in slide-in-from-left-4 fade-in capitalize`, `animate-in slide-in-from-left-4 fade-in flex items-center space-x-0.5 capitalize`,
{ {
['text-muted-foreground']: !highlighted, ['text-muted-foreground']: !highlighted,
}, },
@@ -231,6 +233,32 @@ function PricingItem(
)} )}
</If> </If>
</span> </span>
<If condition={props.primaryLineItem.type !== 'flat'}>
<span>/</span>
<span
className={cn(
`animate-in slide-in-from-left-4 fade-in text-sm capitalize`,
{
['text-muted-foreground']: !highlighted,
},
)}
>
<If condition={props.primaryLineItem.type === 'per-seat'}>
<Trans i18nKey={'billing:perTeamMember'} />
</If>
<If condition={props.primaryLineItem.unit}>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: props.primaryLineItem.unit,
}}
/>
</If>
</span>
</If>
</span> </span>
</If> </If>
</div> </div>

View File

@@ -78,4 +78,6 @@ export const POST = enhanceRouteHandler(({ request, body, user }) => {
id: z.number() id: z.number()
}), }),
}); });
``` ```
When using a Captcha, the consumer will pass an header `x-captcha-token` with the captcha token.

View File

@@ -123,7 +123,7 @@ create type public.billing_provider as ENUM(
- You can add more types as needed. - You can add more types as needed.
*/ */
create type public.subscription_item_type as ENUM( create type public.subscription_item_type as ENUM(
'base', 'flat',
'per_seat', 'per_seat',
'metered' 'metered'
); );