mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 09:14:07 +08:00
feat: tiered billing (cloud) (#1294)
* feat: tiered billing (cloud) * custom tier
This commit is contained in:
@@ -112,18 +112,58 @@ export default function BillingDetails() {
|
|||||||
fz="xs"
|
fz="xs"
|
||||||
className={classes.label}
|
className={classes.label}
|
||||||
>
|
>
|
||||||
Total
|
Cost
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
{(billing.amount / 100) * billing.quantity}{" "}
|
|
||||||
{billing.currency.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
${billing.amount / 100} /user/{billing.interval}
|
|
||||||
</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">
|
||||||
|
{(billing.amount / 100) * billing.quantity}{" "}
|
||||||
|
{billing.currency.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" fz="sm">
|
||||||
|
${billing.amount / 100} /user/{billing.interval}
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
|
||||||
return (
|
<Group justify="center" align="start">
|
||||||
<Card
|
<Flex justify="center" gap="md" align="center">
|
||||||
key={plan.name}
|
<Text size="md">Monthly</Text>
|
||||||
withBorder
|
<Switch
|
||||||
radius="md"
|
defaultChecked={isAnnual}
|
||||||
shadow="sm"
|
onChange={(event) => setIsAnnual(event.target.checked)}
|
||||||
p="xl"
|
|
||||||
w={300}
|
|
||||||
>
|
|
||||||
<SegmentedControl
|
|
||||||
value={interval}
|
|
||||||
onChange={setInterval}
|
|
||||||
fullWidth
|
|
||||||
data={[
|
|
||||||
{ label: "Monthly", value: "monthly" },
|
|
||||||
{ label: "Yearly (25% OFF)", value: "yearly" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Title order={3} ta="center" mt="sm" mb="xs">
|
|
||||||
{plan.name}
|
|
||||||
</Title>
|
|
||||||
<Text ta="center" size="lg" fw={700}>
|
|
||||||
{interval === "monthly" && (
|
|
||||||
<>
|
|
||||||
${price}{" "}
|
|
||||||
<Text span size="sm" fw={500} c="dimmed">
|
|
||||||
/user/month
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{interval === "yearly" && (
|
|
||||||
<>
|
|
||||||
${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">
|
|
||||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
|
||||||
Subscribe
|
|
||||||
</Button>
|
|
||||||
</Card.Section>
|
|
||||||
|
|
||||||
<Card.Section mt="md">
|
|
||||||
<List
|
|
||||||
spacing="xs"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
center
|
/>
|
||||||
icon={
|
<Text size="md">
|
||||||
<ThemeIcon variant="light" size={24} radius="xl">
|
Annually
|
||||||
<IconCheck size={16} />
|
<Badge component="span" variant="light" color="blue">
|
||||||
</ThemeIcon>
|
15% OFF
|
||||||
}
|
</Badge>
|
||||||
>
|
</Text>
|
||||||
{plan.features.map((feature, index) => (
|
</Flex>
|
||||||
<List.Item key={index}>{feature}</List.Item>
|
</Group>
|
||||||
))}
|
</Group>
|
||||||
</List>
|
</Stack>
|
||||||
</Card.Section>
|
|
||||||
</Card>
|
{/* Plans Grid */}
|
||||||
);
|
<Group justify="center" gap="lg" align="stretch">
|
||||||
})}
|
{plans.map((plan, index) => {
|
||||||
</Group>
|
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 (
|
||||||
|
<Card
|
||||||
|
key={plan.name}
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
shadow="sm"
|
||||||
|
p="xl"
|
||||||
|
w={350}
|
||||||
|
miw={300}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* Plan Header */}
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={3} size="h4">
|
||||||
|
{plan.name}
|
||||||
|
</Title>
|
||||||
|
{plan.description && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{plan.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<List
|
||||||
|
spacing="xs"
|
||||||
|
size="sm"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon size={20} radius="xl">
|
||||||
|
<IconCheck size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{plan.features.map((feature, featureIndex) => (
|
||||||
|
<List.Item key={featureIndex}>{feature}</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: ffcae8dbe7...ad7a4bcf57
Reference in New Issue
Block a user