feat(billing): add Max plan to nova with cancel/resume flow (#1024)

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
This commit is contained in:
MaheshtheDev 2026-05-31 19:10:17 +00:00
parent 4eb8399358
commit 53c971ac85
6 changed files with 234 additions and 26 deletions

View file

@ -104,6 +104,7 @@ function parseHashToTab(hash: string): SettingsTab {
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
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]",
}

View file

@ -10,6 +10,7 @@ const orgPlanBadgeBase = cn(
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
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]",
}

View file

@ -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<PlanCardDefinition["id"], number> = {
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 ? (
<span className="absolute right-5 top-5 inline-flex h-[18px] items-center rounded-[3px] bg-[#4BA0FA] px-1.5 text-[10px] font-bold uppercase tracking-[0.36px] text-[#00171A]">
Most popular
</span>
) : null}
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
{plan.name}
</p>
@ -396,6 +418,7 @@ function getInvoiceProductLabel(productId: string | undefined): string {
const planMap: Record<string, string> = {
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"}
</p>
<Pill tone={hasPaidPlan ? "active" : "muted"}>
{hasPaidPlan ? "Active" : "Free"}
<Pill
tone={
isPlanCanceling
? "warning"
: hasPaidPlan
? "active"
: "muted"
}
>
{isPlanCanceling
? "Cancelling"
: hasPaidPlan
? "Active"
: "Free"}
</Pill>
</div>
<p
@ -794,9 +894,11 @@ export default function Billing() {
"mt-1 text-[13px] leading-relaxed text-[#A3A3A3]",
)}
>
{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."}
</p>
</div>
@ -812,10 +914,28 @@ export default function Billing() {
<Settings className="size-3.5 text-[#737373]" />
Manage
</button>
{cancellablePlanId ? (
{isPlanCanceling ? (
<button
type="button"
onClick={() => void handleResumeSubscription()}
disabled={isResuming}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#0054AD] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{isResuming ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : null}
Resume plan
</button>
) : cancellablePlanId ? (
<Dialog
open={isCancelDialogOpen}
onOpenChange={setIsCancelDialogOpen}
onOpenChange={(open) => {
setIsCancelDialogOpen(open)
if (!open) setCancelConfirmText("")
}}
>
<DialogTrigger asChild>
<button
@ -865,6 +985,48 @@ export default function Billing() {
</button>
</DialogClose>
</div>
{cancelLossItems.length > 0 ? (
<div className="mt-4 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3.5">
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
You'll lose
</p>
<ul className="mt-2.5 flex flex-col gap-1.5">
{cancelLossItems.map((item) => (
<li
className="flex items-start gap-2 text-[13px] leading-snug text-[#C8D0DA]"
key={item}
>
<X className="mt-0.5 size-3.5 shrink-0 text-[#C73B1B]" />
<span>{item}</span>
</li>
))}
</ul>
</div>
) : null}
<div className="mt-4">
<label
className={cn(
dmSans125ClassName(),
"text-[13px] text-[#A3A3A3]",
)}
htmlFor="cancel-confirm"
>
Type{" "}
<span className="font-semibold text-[#FAFAFA]">
CANCEL
</span>{" "}
to confirm
</label>
<input
autoComplete="off"
className="mt-2 h-9 w-full rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] text-[#FAFAFA] outline-none placeholder:text-[#3F4651] focus:border-white/25"
id="cancel-confirm"
onChange={(e) => setCancelConfirmText(e.target.value)}
placeholder="CANCEL"
type="text"
value={cancelConfirmText}
/>
</div>
<div className="mt-5 flex items-center justify-end gap-3">
<DialogClose asChild>
<button
@ -880,7 +1042,7 @@ export default function Billing() {
<button
type="button"
onClick={() => void handleCancelSubscription()}
disabled={isCancelling}
disabled={isCancelling || !canConfirmCancel}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#290F0A] px-3 text-[13px] font-medium text-[#C73B1B] transition-opacity disabled:cursor-not-allowed disabled:opacity-50",
@ -966,7 +1128,7 @@ export default function Billing() {
onClick={() => setPlanPage(1)}
disabled={planPage === 1}
className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
aria-label="Show Scale and Enterprise plans"
aria-label="Show Max, Scale and Enterprise plans"
>
<ChevronRight className="size-4" />
</button>
@ -993,7 +1155,7 @@ export default function Billing() {
/>
))}
</div>
<div className="grid w-full shrink-0 gap-4 md:grid-cols-2">
<div className="grid w-full shrink-0 gap-4 md:grid-cols-3">
{ADVANCED_PLAN_CARDS.map((plan) => (
<PlanCard
action={getPlanCardAction(plan)}

View file

@ -34,7 +34,13 @@ export function UserProfileMenu({
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
const planBadgeLabel =
currentPlan === "pro" ? "PRO" : currentPlan === "scale" ? "SCALE" : null
currentPlan === "pro"
? "PRO"
: currentPlan === "max"
? "MAX"
: currentPlan === "scale"
? "SCALE"
: null
const handleTryOnboarding = () => {
resetOrgOnboarded()

View file

@ -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<PlanType, string> = {
free: "Free",
pro: "Pro",
max: "Max",
scale: "Scale",
enterprise: "Enterprise",
}
@ -15,8 +16,9 @@ export const PLAN_DISPLAY_NAMES: Record<PlanType, string> = {
export const PLAN_RANK: Record<PlanType, number> = {
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<typeof useCustomer>) {
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"
}

View file

@ -7,7 +7,12 @@ import { $fetch } from "./api"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
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()