diff --git a/apps/client/src/ee/billing/components/billing-details.tsx b/apps/client/src/ee/billing/components/billing-details.tsx index 9ecd1558..e8534457 100644 --- a/apps/client/src/ee/billing/components/billing-details.tsx +++ b/apps/client/src/ee/billing/components/billing-details.tsx @@ -112,18 +112,58 @@ export default function BillingDetails() { fz="xs" className={classes.label} > - Total - - - {(billing.amount / 100) * billing.quantity}{" "} - {billing.currency.toUpperCase()} - - - ${billing.amount / 100} /user/{billing.interval} + Cost + {billing.billingScheme === "tiered" && ( + <> + + ${billing.amount / 100} {billing.currency.toUpperCase()} + + + per {billing.interval} + + > + )} + + {billing.billingScheme !== "tiered" && ( + <> + + {(billing.amount / 100) * billing.quantity}{" "} + {billing.currency.toUpperCase()} + + + ${billing.amount / 100} /user/{billing.interval} + + > + )} + + {billing.billingScheme === "tiered" && billing.tieredUpTo && ( + + + + + Current Tier + + + For up to {billing.tieredUpTo} users + + {/*billing.tieredFlatAmount && ( + + + )*/} + + + + )} ); diff --git a/apps/client/src/ee/billing/components/billing-plans.tsx b/apps/client/src/ee/billing/components/billing-plans.tsx index 3ff655d6..5a287bba 100644 --- a/apps/client/src/ee/billing/components/billing-plans.tsx +++ b/apps/client/src/ee/billing/components/billing-plans.tsx @@ -2,24 +2,28 @@ import { Button, Card, List, - SegmentedControl, ThemeIcon, Title, Text, Group, + Select, + Container, + Stack, + Badge, + Flex, + Switch, } from "@mantine/core"; import { useState } from "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 { useBillingPlans } from "@/ee/billing/queries/billing-query.ts"; export default function BillingPlans() { const { data: plans } = useBillingPlans(); - const [interval, setInterval] = useState("yearly"); - - if (!plans) { - return null; - } + const [isAnnual, setIsAnnual] = useState(true); + const [selectedTierValue, setSelectedTierValue] = useState( + null, + ); const handleCheckout = async (priceId: string) => { 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 ( - - {plans.map((plan) => { - const price = - interval === "monthly" ? plan.price.monthly : plan.price.yearly; - const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId; - const yearlyMonthPrice = parseInt(plan.price.yearly) / 12; + + {/* Controls Section */} + + {/* Team Size and Billing Controls */} + + - return ( - - - - - {plan.name} - - - {interval === "monthly" && ( - <> - ${price}{" "} - - /user/month - - > - )} - {interval === "yearly" && ( - <> - ${yearlyMonthPrice}{" "} - - /user/month - - > - )} - - - billed {interval} - - - - - handleCheckout(priceId)} fullWidth> - Subscribe - - - - - + + Monthly + setIsAnnual(event.target.checked)} size="sm" - center - icon={ - - - - } - > - {plan.features.map((feature, index) => ( - {feature} - ))} - - - - ); - })} - + /> + + Annually + + 15% OFF + + + + + + + + {/* Plans Grid */} + + {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 ( + + + {/* Plan Header */} + + + {plan.name} + + {plan.description && ( + + {plan.description} + + )} + + + {/* Pricing */} + + + + ${isAnnual ? (price / 12).toFixed(0) : price} + + + per {isAnnual ? "month" : "month"} + + + {isAnnual && ( + + Billed annually + + )} + + for up to {planSelectedTier.upTo} users + + + + {/* CTA Button */} + handleCheckout(priceId)} fullWidth> + Upgrade + + + {/* Features */} + + + + } + > + {plan.features.map((feature, featureIndex) => ( + {feature} + ))} + + + + ); + })} + + ); } diff --git a/apps/client/src/ee/billing/types/billing.types.ts b/apps/client/src/ee/billing/types/billing.types.ts index 240e55fd..dfa1a60b 100644 --- a/apps/client/src/ee/billing/types/billing.types.ts +++ b/apps/client/src/ee/billing/types/billing.types.ts @@ -25,6 +25,11 @@ export interface IBilling { createdAt: Date; updatedAt: Date; deletedAt: Date; + billingScheme: string | null; + tieredUpTo: string | null; + tieredFlatAmount: number | null; + tieredUnitAmount: number | null; + planName: string | null; } export interface ICheckoutLink { @@ -42,9 +47,18 @@ export interface IBillingPlan { monthlyId: string; yearlyId: string; currency: string; - price: { + price?: { monthly: string; yearly: string; }; features: string[]; + billingScheme: string | null; + pricingTiers: PricingTier[]; } + +interface PricingTier { + upTo: number; + monthly?: number; + yearly?: number; + custom?: boolean; +} \ No newline at end of file diff --git a/apps/server/src/database/migrations/20250623T215045-more-billing-columns.ts b/apps/server/src/database/migrations/20250623T215045-more-billing-columns.ts new file mode 100644 index 00000000..ac7baedb --- /dev/null +++ b/apps/server/src/database/migrations/20250623T215045-more-billing-columns.ts @@ -0,0 +1,23 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema + .alterTable('billing') + .dropColumn('billing_scheme') + .dropColumn('tiered_up_to') + .dropColumn('tiered_flat_amount') + .dropColumn('tiered_unit_amount') + .dropColumn('plan_name') + .execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 4545ebc4..b49f15b0 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -84,6 +84,7 @@ export interface Backlinks { export interface Billing { amount: Int8 | null; + billingScheme: string | null; cancelAt: Timestamp | null; cancelAtPeriodEnd: boolean | null; canceledAt: Timestamp | null; @@ -96,6 +97,7 @@ export interface Billing { metadata: Json | null; periodEndAt: Timestamp | null; periodStartAt: Timestamp; + planName: string | null; quantity: Int8 | null; status: string; stripeCustomerId: string | null; @@ -103,6 +105,9 @@ export interface Billing { stripePriceId: string | null; stripeProductId: string | null; stripeSubscriptionId: string; + tieredFlatAmount: Int8 | null; + tieredUnitAmount: Int8 | null; + tieredUpTo: string | null; updatedAt: Generated; workspaceId: string; } diff --git a/apps/server/src/ee b/apps/server/src/ee index ffcae8db..ad7a4bcf 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit ffcae8dbe760ab779733907861228f9b643e11b5 +Subproject commit ad7a4bcf5703a91e25357a435a7dcfa42486798a