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

View File

@ -12,14 +12,18 @@ import {
Badge,
Flex,
Switch,
Alert,
} from "@mantine/core";
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 { 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() {
const { data: plans } = useBillingPlans();
const workspace = useAtomValue(workspaceAtom);
const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | 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) {
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
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
// Set initial tier value if not set and we have tiered plans
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
return null;
}
if (!selectedTierValue) {
// For tiered plans, ensure we have a selected tier
if (hasTieredPlans && !selectedTierValue) {
return null;
}
const selectData = firstPlan.pricingTiers
.filter((tier) => !tier.custom)
const selectData = firstTieredPlan?.pricingTiers
?.filter((tier) => !tier.custom)
.map((tier, index) => {
const prevMaxUsers =
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
return {
value: tier.upTo.toString(),
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
};
});
}) || [];
return (
<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 */}
<Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm">
<Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
{hasTieredPlans && (
<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">
@ -102,17 +133,29 @@ export default function BillingPlans() {
{/* 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;
let price;
let displayPrice;
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 (
<Card
key={plan.name}
@ -143,10 +186,12 @@ export default function BillingPlans() {
<Stack gap="xs">
<Group align="baseline" gap="xs">
<Title order={1} size="h1">
${isAnnual ? (price / 12).toFixed(0) : price}
${displayPrice}
</Title>
<Text size="lg" c="dimmed">
per {isAnnual ? "month" : "month"}
{plan.billingScheme === 'per_unit'
? `per user/month`
: `per month`}
</Text>
</Group>
{isAnnual && (
@ -154,14 +199,16 @@ export default function BillingPlans() {
Billed annually
</Text>
)}
<Text size="md" fw={500}>
For {planSelectedTier.upTo} users
</Text>
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
<Text size="md" fw={500}>
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
</Text>
)}
</Stack>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Upgrade
Subscribe
</Button>
{/* Features */}

View File

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