From 53c971ac858ad70017e69180a676ab2b4b52d5d8 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Sun, 31 May 2026 19:10:17 +0000 Subject: [PATCH] feat(billing): add Max plan to nova with cancel/resume flow (#1024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the new api_max plan ($100/mo, $130 credits) from mono into the nova web app, mirroring mono's tier semantics. - Tiers: insert api_max between pro and scale; resolves to Pro-tier for feature gates while displaying as "Max" (queries.ts, use-token-usage) - Billing page: Max plan card ("Most Popular"), checkout/cancel/invoice wiring; advanced carousel page now 3-up (Max/Scale/Enterprise); Scale builds on Max - Carousel auto-opens to the page holding the current plan (Max/Scale/ Enterprise users land on their own card instead of Free+Pro) - Cancel: retention dialog listing what you lose + type-to-confirm "CANCEL" - Cancel/resume state mirroring console: detect canceledAt-scheduled cancellation, show "Cancelling" pill + "Cancels on {date} · N days left", and a "Resume plan" (uncancel) action --- apps/web/app/(app)/settings/page.tsx | 1 + apps/web/components/org-plan-badge.tsx | 1 + apps/web/components/settings/billing.tsx | 204 +++++++++++++++++++--- apps/web/components/user-profile-menu.tsx | 8 +- apps/web/hooks/use-token-usage.ts | 11 +- packages/lib/queries.ts | 35 +++- 6 files changed, 234 insertions(+), 26 deletions(-) diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 4d6719774..51745b80a 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -104,6 +104,7 @@ function parseHashToTab(hash: string): SettingsTab { const ORG_PLAN_BADGE_STYLES: Record = { free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]", pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]", + max: "bg-[#1E7FE0] font-bold tracking-[0.36px] text-[#00171A]", scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]", enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]", } diff --git a/apps/web/components/org-plan-badge.tsx b/apps/web/components/org-plan-badge.tsx index e5775e652..12fa1d199 100644 --- a/apps/web/components/org-plan-badge.tsx +++ b/apps/web/components/org-plan-badge.tsx @@ -10,6 +10,7 @@ const orgPlanBadgeBase = cn( const ORG_PLAN_BADGE_STYLES: Record = { free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]", pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]", + max: "bg-[#1E7FE0] font-bold tracking-[0.36px] text-[#00171A]", scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]", enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]", } diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index 6fe59994d..556874f87 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -4,6 +4,7 @@ import { dmSans125ClassName } from "@/lib/fonts" import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage" import { cn } from "@lib/utils" import { useAuth } from "@lib/auth-context" +import { getCanceledSubscription } from "@lib/queries" import { Dialog, DialogClose, @@ -24,7 +25,7 @@ import { Settings, X, } from "lucide-react" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" const API_BASE = @@ -70,16 +71,17 @@ type AutoTopupsResponse = | { ok: false; reason: string; message?: string } type PlanCardDefinition = { - id: "free" | "pro" | "scale" | "enterprise" + id: "free" | "pro" | "max" | "scale" | "enterprise" name: string price: string period: string credits: string - productId: "api_free" | "api_pro" | "api_scale" | "api_enterprise" + productId: "api_free" | "api_pro" | "api_max" | "api_scale" | "api_enterprise" description: string includesFrom?: string features: string[] isContactSales?: boolean + mostPopular?: boolean } const PLAN_CARDS: PlanCardDefinition[] = [ @@ -114,6 +116,18 @@ const PLAN_CARDS: PlanCardDefinition[] = [ ] const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [ + { + id: "max", + name: "Max", + price: "$100", + period: "/mo", + credits: "$130", + productId: "api_max", + description: "For power users who outgrow Pro", + includesFrom: "Pro", + mostPopular: true, + features: ["6× the credits of Pro", "Gmail connector", "Priority support"], + }, { id: "scale", name: "Scale", @@ -122,10 +136,10 @@ const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [ credits: "$600", productId: "api_scale", description: "For teams and production workloads", - includesFrom: "Pro", + includesFrom: "Max", features: [ "Auto top-up & spend caps", - "Gmail, S3 & Web Crawler connectors", + "S3 & Web Crawler connectors", "Dedicated support", ], }, @@ -150,8 +164,9 @@ const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [ const PLAN_RANK: Record = { free: 0, pro: 1, - scale: 2, - enterprise: 3, + max: 2, + scale: 3, + enterprise: 4, } function SectionTitle({ @@ -208,9 +223,16 @@ function PlanCard({ className={cn( "relative flex min-h-[416px] flex-col overflow-hidden rounded-[14px] border p-5", "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]", - "border-white/[0.08] bg-[#14161A]", + plan.mostPopular + ? "border-[#4BA0FA]/40 bg-[#14161A]" + : "border-white/[0.08] bg-[#14161A]", )} > + {plan.mostPopular ? ( + + Most popular + + ) : null}

{plan.name}

@@ -396,6 +418,7 @@ function getInvoiceProductLabel(productId: string | undefined): string { const planMap: Record = { api_free: "Free", api_pro: "Pro", + api_max: "Max", api_scale: "Scale", api_enterprise: "Enterprise", memory_free: "Free", @@ -416,7 +439,9 @@ export default function Billing() { const autumn = useCustomer({ expand: ["payment_method"] }) const [isUpgrading, setIsUpgrading] = useState(false) const [isCancelling, setIsCancelling] = useState(false) + const [isResuming, setIsResuming] = useState(false) const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false) + const [cancelConfirmText, setCancelConfirmText] = useState("") const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false) const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false) const [planPage, setPlanPage] = useState<0 | 1>(0) @@ -446,6 +471,17 @@ export default function Billing() { daysRemaining, } = useTokenUsage(autumn) + // Open the carousel to the page holding the current plan (Max/Scale/Enterprise live on page 2). + const didAutoOpenPlanPage = useRef(false) + useEffect(() => { + if (didAutoOpenPlanPage.current || isCheckingStatus) return + didAutoOpenPlanPage.current = true + if (ADVANCED_PLAN_CARDS.some((p) => p.id === currentPlan)) { + setIsPlanCarouselActive(true) + setPlanPage(1) + } + }, [isCheckingStatus, currentPlan]) + const balance = autumn.data?.balances?.[CREDIT_FEATURE_ID] const creditRemaining = balance?.remaining ?? Math.max(usdIncluded - usdSpent, 0) @@ -517,7 +553,7 @@ export default function Billing() { const planDisplayNames = PLAN_DISPLAY_NAMES - const handleUpgrade = async (planId: "api_pro" | "api_scale") => { + const handleUpgrade = async (planId: "api_pro" | "api_max" | "api_scale") => { setIsUpgrading(true) try { const result = await autumn.attach({ @@ -538,10 +574,59 @@ export default function Billing() { } const cancellablePlanId = - currentPlan === "pro" || currentPlan === "scale" + currentPlan === "pro" || currentPlan === "max" || currentPlan === "scale" ? (`api_${currentPlan}` as const) : null + const currentPlanCard = [...PLAN_CARDS, ...ADVANCED_PLAN_CARDS].find( + (p) => p.id === currentPlan, + ) + const cancelLossItems = currentPlanCard + ? [ + `${currentPlanCard.credits}/mo included credits`, + ...currentPlanCard.features.filter((f) => !/credit/i.test(f)), + ] + : [] + const canConfirmCancel = cancelConfirmText.trim().toUpperCase() === "CANCEL" + + const canceledSub = getCanceledSubscription(autumn.data?.subscriptions) + const isPlanCanceling = canceledSub != null + const cancelEndsAt = + canceledSub?.endsAt != null ? normalizeTimestamp(canceledSub.endsAt) : null + const cancelEndsLabel = + cancelEndsAt != null + ? new Date(cancelEndsAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "the end of your billing period" + const cancelEndsDays = + cancelEndsAt != null + ? Math.max( + 0, + Math.ceil((cancelEndsAt - Date.now()) / (1000 * 60 * 60 * 24)), + ) + : null + + const handleResumeSubscription = async () => { + if (!canceledSub || isResuming) return + setIsResuming(true) + try { + await autumn.updateSubscription({ + planId: canceledSub.planId, + cancelAction: "uncancel", + }) + autumn.refetch?.() + toast.success(`${planDisplayNames[currentPlan]} subscription resumed.`) + } catch (error) { + console.error(error) + toast.error("Failed to resume subscription. Please try again.") + } finally { + setIsResuming(false) + } + } + const handleCancelSubscription = async () => { if (!cancellablePlanId) return setIsCancelling(true) @@ -552,6 +637,7 @@ export default function Billing() { }) autumn.refetch?.() setIsCancelDialogOpen(false) + setCancelConfirmText("") toast.success( `Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`, ) @@ -743,7 +829,9 @@ export default function Billing() { } const checkoutPlanId = - plan.productId === "api_pro" || plan.productId === "api_scale" + plan.productId === "api_pro" || + plan.productId === "api_max" || + plan.productId === "api_scale" ? plan.productId : null if (!checkoutPlanId) return null @@ -784,8 +872,20 @@ export default function Billing() { ? `${planDisplayNames[currentPlan]} plan` : "Free plan"}

- - {hasPaidPlan ? "Active" : "Free"} + + {isPlanCanceling + ? "Cancelling" + : hasPaidPlan + ? "Active" + : "Free"}

- {hasPaidPlan - ? "Expanded memory, connections, and usage for this workspace." - : "Upgrade when you need more workspace usage and integrations."} + {isPlanCanceling + ? `Cancels on ${cancelEndsLabel}${cancelEndsDays !== null ? ` · ${cancelEndsDays} day${cancelEndsDays !== 1 ? "s" : ""} left` : ""}. You'll move to Free after that.` + : hasPaidPlan + ? "Expanded memory, connections, and usage for this workspace." + : "Upgrade when you need more workspace usage and integrations."}

@@ -812,10 +914,28 @@ export default function Billing() { Manage - {cancellablePlanId ? ( + {isPlanCanceling ? ( + + ) : cancellablePlanId ? ( { + setIsCancelDialogOpen(open) + if (!open) setCancelConfirmText("") + }} > @@ -993,7 +1155,7 @@ export default function Billing() { /> ))} -
+
{ADVANCED_PLAN_CARDS.map((plan) => ( { resetOrgOnboarded() diff --git a/apps/web/hooks/use-token-usage.ts b/apps/web/hooks/use-token-usage.ts index 898d89ac5..4dc628110 100644 --- a/apps/web/hooks/use-token-usage.ts +++ b/apps/web/hooks/use-token-usage.ts @@ -2,11 +2,12 @@ import { getSubscriptionStatus, isAllowedFrom } from "@lib/queries" import type { useCustomer } from "autumn-js/react" import { calculateUsagePercent, getDaysRemaining } from "@/lib/billing-utils" -export type PlanType = "free" | "pro" | "scale" | "enterprise" +export type PlanType = "free" | "pro" | "max" | "scale" | "enterprise" export const PLAN_DISPLAY_NAMES: Record = { free: "Free", pro: "Pro", + max: "Max", scale: "Scale", enterprise: "Enterprise", } @@ -15,8 +16,9 @@ export const PLAN_DISPLAY_NAMES: Record = { export const PLAN_RANK: Record = { free: 0, pro: 1, - scale: 2, - enterprise: 3, + max: 2, + scale: 3, + enterprise: 4, } export function normalizePlanType(raw: unknown): PlanType { @@ -24,6 +26,7 @@ export function normalizePlanType(raw: unknown): PlanType { const normalized = raw.toLowerCase().replace(/^api_/, "") if (normalized === "enterprise") return "enterprise" if (normalized === "scale") return "scale" + if (normalized === "max") return "max" if (normalized === "pro") return "pro" return "free" } @@ -43,6 +46,8 @@ export function useTokenUsage(autumn: ReturnType) { currentPlan = "enterprise" } else if (isAllowedFrom(status, "api_scale")) { currentPlan = "scale" + } else if (isAllowedFrom(status, "api_max")) { + currentPlan = "max" } else if (isAllowedFrom(status, "api_pro")) { currentPlan = "pro" } diff --git a/packages/lib/queries.ts b/packages/lib/queries.ts index eb50d0f11..7879a6edc 100644 --- a/packages/lib/queries.ts +++ b/packages/lib/queries.ts @@ -7,7 +7,12 @@ import { $fetch } from "./api" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] -export const PLAN_TIERS = ["api_pro", "api_scale", "api_enterprise"] as const +export const PLAN_TIERS = [ + "api_pro", + "api_max", + "api_scale", + "api_enterprise", +] as const export type PlanTier = (typeof PLAN_TIERS)[number] export type SubscriptionStatusMap = Record< @@ -17,6 +22,7 @@ export type SubscriptionStatusMap = Record< const DEFAULT_SUBSCRIPTION_STATUS: SubscriptionStatusMap = { api_pro: { allowed: false, status: null }, + api_max: { allowed: false, status: null }, api_scale: { allowed: false, status: null }, api_enterprise: { allowed: false, status: null }, } @@ -57,6 +63,33 @@ export function hasActivePlan( return isAllowedFrom(getSubscriptionStatus(subscriptions), minimumTier) } +export type CanceledSubscription = { planId: string; endsAt: number | null } + +// A subscription scheduled to cancel at period end: still active, but canceledAt is set. +export function getCanceledSubscription( + subscriptions: + | Array<{ + planId: string + status?: string + canceledAt?: number | null + currentPeriodEnd?: number | null + expiresAt?: number | null + }> + | undefined, +): CanceledSubscription | null { + const sub = subscriptions?.find( + (s) => + s.status === "active" && + s.canceledAt != null && + (PLAN_TIERS as readonly string[]).includes(s.planId), + ) + if (!sub) return null + return { + planId: sub.planId, + endsAt: sub.currentPeriodEnd ?? sub.expiresAt ?? null, + } +} + export const useDeleteDocument = (selectedProject: string) => { const queryClient = useQueryClient()