Compare commits

...

2 Commits

Author SHA1 Message Date
48f3efe2a8 old billing grace period 2025-07-14 02:27:55 -07:00
1d87c0085c billing updates (cloud) 2025-07-14 02:16:01 -07:00
4 changed files with 87 additions and 39 deletions

View File

@ -117,7 +117,8 @@ export default function BillingDetails() {
{billing.billingScheme === "tiered" && ( {billing.billingScheme === "tiered" && (
<> <>
<Text fw={700} fz="lg"> <Text fw={700} fz="lg">
${billing.amount / 100} {billing.currency.toUpperCase()} ${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
{billing.interval}
</Text> </Text>
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
per {billing.interval} per {billing.interval}
@ -129,7 +130,7 @@ export default function BillingDetails() {
<> <>
<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()} / {billing.interval}
</Text> </Text>
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval} ${billing.amount / 100} /user/{billing.interval}

View File

@ -12,14 +12,18 @@ import {
Badge, Badge,
Flex, Flex,
Switch, Switch,
Alert,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
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"; import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export default function BillingPlans() { export default function BillingPlans() {
const { data: plans } = useBillingPlans(); const { data: plans } = useBillingPlans();
const workspace = useAtomValue(workspaceAtom);
const [isAnnual, setIsAnnual] = useState(true); const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>( const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
null, null,
@ -36,49 +40,76 @@ export default function BillingPlans() {
} }
}; };
// TODO: remove by July 30.
// Check if workspace was created between June 28 and July 14, 2025
const showTieredPricingNotice = (() => {
if (!workspace?.createdAt) return false;
const createdDate = new Date(workspace.createdAt);
const startDate = new Date('2025-06-20');
const endDate = new Date('2025-07-14');
return createdDate >= startDate && createdDate <= endDate;
})();
if (!plans || plans.length === 0) { if (!plans || plans.length === 0) {
return null; return null;
} }
const firstPlan = plans[0]; // Check if any plan is tiered
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
// Set initial tier value if not set // Set initial tier value if not set and we have tiered plans
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) { if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString()); setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
return null; return null;
} }
if (!selectedTierValue) { // For tiered plans, ensure we have a selected tier
if (hasTieredPlans && !selectedTierValue) {
return null; return null;
} }
const selectData = firstPlan.pricingTiers const selectData = firstTieredPlan?.pricingTiers
.filter((tier) => !tier.custom) ?.filter((tier) => !tier.custom)
.map((tier, index) => { .map((tier, index) => {
const prevMaxUsers = const prevMaxUsers =
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0; index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
return { return {
value: tier.upTo.toString(), value: tier.upTo.toString(),
label: `${prevMaxUsers + 1}-${tier.upTo} users`, label: `${prevMaxUsers + 1}-${tier.upTo} users`,
}; };
}); }) || [];
return ( return (
<Container size="xl" py="xl"> <Container size="xl" py="xl">
{/* Tiered pricing notice for eligible workspaces */}
{showTieredPricingNotice && !hasTieredPlans && (
<Alert
icon={<IconInfoCircle size={16} />}
title="Want the old tiered pricing?"
color="blue"
mb="lg"
>
Contact support to switch back to our tiered pricing model.
</Alert>
)}
{/* Controls Section */} {/* Controls Section */}
<Stack gap="xl" mb="md"> <Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */} {/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm"> <Group justify="center" align="center" gap="sm">
<Select {hasTieredPlans && (
label="Team size" <Select
description="Select the number of users" label="Team size"
value={selectedTierValue} description="Select the number of users"
onChange={setSelectedTierValue} value={selectedTierValue}
data={selectData} onChange={setSelectedTierValue}
w={250} data={selectData}
size="md" w={250}
allowDeselect={false} size="md"
/> allowDeselect={false}
/>
)}
<Group justify="center" align="start"> <Group justify="center" align="start">
<Flex justify="center" gap="md" align="center"> <Flex justify="center" gap="md" align="center">
@ -102,17 +133,29 @@ export default function BillingPlans() {
{/* Plans Grid */} {/* Plans Grid */}
<Group justify="center" gap="lg" align="stretch"> <Group justify="center" gap="lg" align="stretch">
{plans.map((plan, index) => { {plans.map((plan, index) => {
const tieredPlan = plan; let price;
const planSelectedTier = let displayPrice;
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; const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
// Tiered billing logic
const planSelectedTier =
plan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || plan.pricingTiers[0];
price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
} else {
// Per-unit billing logic
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
price = isAnnual ? yearlyPrice : monthlyPrice;
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
}
return ( return (
<Card <Card
key={plan.name} key={plan.name}
@ -143,10 +186,12 @@ export default function BillingPlans() {
<Stack gap="xs"> <Stack gap="xs">
<Group align="baseline" gap="xs"> <Group align="baseline" gap="xs">
<Title order={1} size="h1"> <Title order={1} size="h1">
${isAnnual ? (price / 12).toFixed(0) : price} ${displayPrice}
</Title> </Title>
<Text size="lg" c="dimmed"> <Text size="lg" c="dimmed">
per {isAnnual ? "month" : "month"} {plan.billingScheme === 'per_unit'
? `per user/month`
: `per month`}
</Text> </Text>
</Group> </Group>
{isAnnual && ( {isAnnual && (
@ -154,14 +199,16 @@ export default function BillingPlans() {
Billed annually Billed annually
</Text> </Text>
)} )}
<Text size="md" fw={500}> {plan.billingScheme === 'tiered' && plan.pricingTiers && (
For {planSelectedTier.upTo} users <Text size="md" fw={500}>
</Text> For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
</Text>
)}
</Stack> </Stack>
{/* CTA Button */} {/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth> <Button onClick={() => handleCheckout(priceId)} fullWidth>
Upgrade Subscribe
</Button> </Button>
{/* Features */} {/* Features */}

View File

@ -53,7 +53,7 @@ export interface IBillingPlan {
}; };
features: string[]; features: string[];
billingScheme: string | null; billingScheme: string | null;
pricingTiers: PricingTier[]; pricingTiers?: PricingTier[];
} }
interface PricingTier { interface PricingTier {