feat: tiered billing (cloud) (#1294)

* feat: tiered billing (cloud)

* custom tier
This commit is contained in:
Philip Okugbe
2025-06-24 13:22:38 +01:00
committed by GitHub
parent d5b84ae0b8
commit f5a36c60e8
6 changed files with 248 additions and 93 deletions
@@ -112,8 +112,21 @@ export default function BillingDetails() {
fz="xs" fz="xs"
className={classes.label} className={classes.label}
> >
Total Cost
</Text> </Text>
{billing.billingScheme === "tiered" && (
<>
<Text fw={700} fz="lg">
${billing.amount / 100} {billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
per {billing.interval}
</Text>
</>
)}
{billing.billingScheme !== "tiered" && (
<>
<Text fw={700} fz="lg"> <Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "} {(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()} {billing.currency.toUpperCase()}
@@ -121,9 +134,36 @@ export default function BillingDetails() {
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval} ${billing.amount / 100} /user/{billing.interval}
</Text> </Text>
</>
)}
</div> </div>
</Group> </Group>
</Paper> </Paper>
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Current Tier
</Text>
<Text fw={700} fz="lg">
For up to {billing.tieredUpTo} users
</Text>
{/*billing.tieredFlatAmount && (
<Text c="dimmed" fz="sm">
</Text>
)*/}
</div>
</Group>
</Paper>
)}
</SimpleGrid> </SimpleGrid>
</div> </div>
); );
@@ -2,24 +2,28 @@ import {
Button, Button,
Card, Card,
List, List,
SegmentedControl,
ThemeIcon, ThemeIcon,
Title, Title,
Text, Text,
Group, Group,
Select,
Container,
Stack,
Badge,
Flex,
Switch,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts"; import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
export default function BillingPlans() { export default function BillingPlans() {
const { data: plans } = useBillingPlans(); const { data: plans } = useBillingPlans();
const [interval, setInterval] = useState("yearly"); const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
if (!plans) { null,
return null; );
}
const handleCheckout = async (priceId: string) => { const handleCheckout = async (priceId: string) => {
try { try {
@@ -32,84 +36,153 @@ export default function BillingPlans() {
} }
}; };
if (!plans || plans.length === 0) {
return null;
}
const firstPlan = plans[0];
// Set initial tier value if not set
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
return null;
}
if (!selectedTierValue) {
return null;
}
const selectData = firstPlan.pricingTiers
.filter((tier) => !tier.custom)
.map((tier, index) => {
const prevMaxUsers =
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
return {
value: tier.upTo.toString(),
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
};
});
return ( return (
<Group justify="center" p="xl"> <Container size="xl" py="xl">
{plans.map((plan) => { {/* Controls Section */}
const price = <Stack gap="xl" mb="md">
interval === "monthly" ? plan.price.monthly : plan.price.yearly; {/* Team Size and Billing Controls */}
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId; <Group justify="center" align="center" gap="sm">
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12; <Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
<Group justify="center" align="start">
<Flex justify="center" gap="md" align="center">
<Text size="md">Monthly</Text>
<Switch
defaultChecked={isAnnual}
onChange={(event) => setIsAnnual(event.target.checked)}
size="sm"
/>
<Text size="md">
Annually
<Badge component="span" variant="light" color="blue">
15% OFF
</Badge>
</Text>
</Flex>
</Group>
</Group>
</Stack>
{/* Plans Grid */}
<Group justify="center" gap="lg" align="stretch">
{plans.map((plan, index) => {
const tieredPlan = plan;
const planSelectedTier =
tieredPlan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || tieredPlan.pricingTiers[0];
const price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
return ( return (
<Card <Card
key={plan.name} key={plan.name}
withBorder withBorder
radius="md" radius="lg"
shadow="sm" shadow="sm"
p="xl" p="xl"
w={300} w={350}
miw={300}
style={{
position: "relative",
}}
> >
<SegmentedControl <Stack gap="lg">
value={interval} {/* Plan Header */}
onChange={setInterval} <Stack gap="xs">
fullWidth <Title order={3} size="h4">
data={[
{ label: "Monthly", value: "monthly" },
{ label: "Yearly (25% OFF)", value: "yearly" },
]}
/>
<Title order={3} ta="center" mt="sm" mb="xs">
{plan.name} {plan.name}
</Title> </Title>
<Text ta="center" size="lg" fw={700}> {plan.description && (
{interval === "monthly" && ( <Text size="sm" c="dimmed">
<> {plan.description}
${price}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text> </Text>
</>
)} )}
{interval === "yearly" && ( </Stack>
<>
${yearlyMonthPrice}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
<br/>
<Text span ta="center" size="md" fw={500} c="dimmed">
billed {interval}
</Text>
</Text>
<Card.Section mt="lg"> {/* Pricing */}
<Stack gap="xs">
<Group align="baseline" gap="xs">
<Title order={1} size="h1">
${isAnnual ? (price / 12).toFixed(0) : price}
</Title>
<Text size="lg" c="dimmed">
per {isAnnual ? "month" : "month"}
</Text>
</Group>
{isAnnual && (
<Text size="sm" c="dimmed">
Billed annually
</Text>
)}
<Text size="md" fw={500}>
for up to {planSelectedTier.upTo} users
</Text>
</Stack>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth> <Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe Upgrade
</Button> </Button>
</Card.Section>
<Card.Section mt="md"> {/* Features */}
<List <List
spacing="xs" spacing="xs"
size="sm" size="sm"
center
icon={ icon={
<ThemeIcon variant="light" size={24} radius="xl"> <ThemeIcon size={20} radius="xl">
<IconCheck size={16} /> <IconCheck size={14} />
</ThemeIcon> </ThemeIcon>
} }
> >
{plan.features.map((feature, index) => ( {plan.features.map((feature, featureIndex) => (
<List.Item key={index}>{feature}</List.Item> <List.Item key={featureIndex}>{feature}</List.Item>
))} ))}
</List> </List>
</Card.Section> </Stack>
</Card> </Card>
); );
})} })}
</Group> </Group>
</Container>
); );
} }
@@ -25,6 +25,11 @@ export interface IBilling {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
deletedAt: Date; deletedAt: Date;
billingScheme: string | null;
tieredUpTo: string | null;
tieredFlatAmount: number | null;
tieredUnitAmount: number | null;
planName: string | null;
} }
export interface ICheckoutLink { export interface ICheckoutLink {
@@ -42,9 +47,18 @@ export interface IBillingPlan {
monthlyId: string; monthlyId: string;
yearlyId: string; yearlyId: string;
currency: string; currency: string;
price: { price?: {
monthly: string; monthly: string;
yearly: string; yearly: string;
}; };
features: string[]; features: string[];
billingScheme: string | null;
pricingTiers: PricingTier[];
}
interface PricingTier {
upTo: number;
monthly?: number;
yearly?: number;
custom?: boolean;
} }
@@ -0,0 +1,23 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('billing')
.addColumn('billing_scheme', 'varchar', (col) => col)
.addColumn('tiered_up_to', 'varchar', (col) => col)
.addColumn('tiered_flat_amount', 'int8', (col) => col)
.addColumn('tiered_unit_amount', 'int8', (col) => col)
.addColumn('plan_name', 'varchar', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('billing')
.dropColumn('billing_scheme')
.dropColumn('tiered_up_to')
.dropColumn('tiered_flat_amount')
.dropColumn('tiered_unit_amount')
.dropColumn('plan_name')
.execute();
}
+5
View File
@@ -84,6 +84,7 @@ export interface Backlinks {
export interface Billing { export interface Billing {
amount: Int8 | null; amount: Int8 | null;
billingScheme: string | null;
cancelAt: Timestamp | null; cancelAt: Timestamp | null;
cancelAtPeriodEnd: boolean | null; cancelAtPeriodEnd: boolean | null;
canceledAt: Timestamp | null; canceledAt: Timestamp | null;
@@ -96,6 +97,7 @@ export interface Billing {
metadata: Json | null; metadata: Json | null;
periodEndAt: Timestamp | null; periodEndAt: Timestamp | null;
periodStartAt: Timestamp; periodStartAt: Timestamp;
planName: string | null;
quantity: Int8 | null; quantity: Int8 | null;
status: string; status: string;
stripeCustomerId: string | null; stripeCustomerId: string | null;
@@ -103,6 +105,9 @@ export interface Billing {
stripePriceId: string | null; stripePriceId: string | null;
stripeProductId: string | null; stripeProductId: string | null;
stripeSubscriptionId: string; stripeSubscriptionId: string;
tieredFlatAmount: Int8 | null;
tieredUnitAmount: Int8 | null;
tieredUpTo: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }