"use client" import { dmSans125ClassName } from "@/lib/fonts" import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage" import { useHasCompanyBrain } from "@/hooks/use-company-brain" import { getBrainTrialInfo } from "@/lib/billing-utils" import { cn } from "@lib/utils" import { useAuth } from "@lib/auth-context" import { getCanceledSubscription } from "@lib/queries" import { Dialog, DialogClose, DialogContent, DialogTrigger, } from "@ui/components/dialog" import { Logo } from "@ui/assets/Logo" import { useQuery, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" import { usePostHog } from "@lib/posthog" import { CANCEL_REASONS, cancelReasonNeedsDetail, type CancelReasonValue, } from "./cancel-reasons" import { Check, ChevronLeft, ChevronRight, Coins, ExternalLink, LoaderIcon, Plus, ReceiptText, Settings, X, } from "lucide-react" import { useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { usePromoCode } from "@/hooks/use-promo-code" const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min" function GoogleMeetIcon({ className }: { className?: string }) { return ( ) } const CREDIT_FEATURE_ID = "usd_credits" const TOP_UP_PLAN_ID = "credits_topup" const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const const PLAN_CARD_ACTION_CLASS = "inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] text-[14px] font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60" const SURFACE_SHADOW = "0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset" type BillingInvoice = { planIds?: string[] stripeId: string status: string total: number currency: string createdAt: number hostedInvoiceUrl?: string | null } type AutoTopupConfig = { featureId: string enabled: boolean threshold: number quantity: number purchaseLimit: { interval: "hour" | "day" | "week" | "month" intervalCount: number limit: number } | null } type AutoTopupsResponse = | { ok: true hasPaymentMethod: boolean autoTopup: AutoTopupConfig | null } | { ok: false; reason: string; message?: string } type PlanCardDefinition = { id: "free" | "pro" | "max" | "scale" | "enterprise" name: string price: string period: string credits: string 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[] = [ { id: "free", name: "Free", price: "$0", period: "", credits: "$5", productId: "api_free", description: "Try supermemory with no commitment", features: [ "Pay-as-you-go after $5 runs out", "Full search and memory access", "All plugins (Claude Code, Cursor, Hermes...)", "Email support", ], }, { id: "pro", name: "Pro", price: "$19", period: "/mo", credits: "$20", productId: "api_pro", description: "For people building with AI memory", features: [ "Auto top-up when balance runs low", "Google Drive, Notion, OneDrive & Granola connectors", "Priority support", ], }, ] 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", price: "$399", period: "/mo", credits: "$600", productId: "api_scale", description: "For teams and production workloads", includesFrom: "Max", features: [ "Auto top-up & spend caps", "S3 & Web Crawler connectors", "Dedicated support", ], }, { id: "enterprise", name: "Enterprise", price: "Custom", period: "", credits: "Unlimited", productId: "api_enterprise", description: "Custom deployments with dedicated engineering", includesFrom: "Scale", features: [ "Custom metering & billing", "Custom integrations & SSO", "Forward-deployed engineer", ], isContactSales: true, }, ] // Company Brain workspaces sell Max / Scale / Enterprise (no Free, Pro). const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [ { id: "max", name: "Max", price: "$100", period: "/mo", credits: "$130", productId: "api_max", description: "Company Brain for teams with everyday usage", mostPopular: true, features: [ "Company Brain Slack agent & shared memory", "$130 monthly usage credits", "Unlimited seats", "Auto top-up & spend caps", ], }, { id: "scale", name: "Scale", price: "$399", period: "/mo", credits: "$600", productId: "api_scale", description: "Company Brain for production workloads", includesFrom: "Max", features: [ "$600 monthly usage credits", "GitHub, S3 & Web Crawler connectors", "Restricted access, container tags & User Insights", "Dedicated support", ], }, { id: "enterprise", name: "Enterprise", price: "Custom", period: "", credits: "Unlimited", productId: "api_enterprise", description: "Custom deployments with dedicated engineering", includesFrom: "Scale", features: [ "Custom metering & billing", "Custom integrations & SSO", "Forward-deployed engineer", ], isContactSales: true, }, ] const PLAN_RANK: Record = { free: 0, pro: 1, max: 2, scale: 3, enterprise: 4, } function SectionTitle({ children, aside, }: { children: React.ReactNode aside?: React.ReactNode }) { return (

{children}

{aside}
) } function SettingsCard({ children, className, }: { children: React.ReactNode className?: string }) { return (
{children}
) } function PlanCard({ action, plan, }: { action: React.ReactNode plan: PlanCardDefinition }) { return (
{plan.mostPopular ? ( Most popular ) : null}

{plan.name}

{plan.price} {plan.period ? ( {plan.period} ) : null}

{plan.description}

{plan.isContactSales ? null : (

{plan.credits}

of usage included

)} {plan.includesFrom ? (
Everything in {plan.includesFrom}, plus
) : null}
    {plan.features.map((feature) => (
  • {feature}
  • ))}
{action}
) } function Pill({ children, tone = "muted", }: { children: React.ReactNode tone?: "active" | "muted" | "warning" }) { return ( {children} ) } function FieldSelect({ value, values, prefix, onChange, disabled, }: { value: number values: readonly number[] prefix?: string onChange: (value: number) => void disabled?: boolean }) { const cols = values.length <= 3 ? "grid-cols-3" : "grid-cols-4" return (
{values.map((item) => ( ))}
) } function formatUsd(value: number) { return value.toLocaleString(undefined, { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 2, }) } function formatInvoiceAmount(total: number, currency: string) { const normalizedTotal = Number.isInteger(total) && total > 100 ? total / 100 : total return normalizedTotal.toLocaleString(undefined, { style: "currency", currency: currency?.toUpperCase() || "USD", minimumFractionDigits: 2, maximumFractionDigits: 2, }) } function formatDate(timestamp: number) { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", }).format(new Date(timestamp)) } function normalizeTimestamp(timestamp: number) { return timestamp < 10_000_000_000 ? timestamp * 1000 : timestamp } function getStatusTone(status: string): "active" | "muted" | "warning" { const normalized = status.toLowerCase() if (normalized === "paid" || normalized === "succeeded") return "active" if (normalized === "open" || normalized === "draft") return "muted" return "warning" } function getInvoiceProductLabel(productId: string | undefined): string { if (!productId) return "Billing invoice" if (productId === TOP_UP_PLAN_ID || productId === "api_topup") return "Credits top-up" const planMap: Record = { api_free: "Free", api_pro: "Pro", api_max: "Max", api_scale: "Scale", api_enterprise: "Enterprise", memory_free: "Free", memory_starter: "Pro", memory_growth: "Scale", memory_enterprise: "Enterprise", } if (planMap[productId]) return planMap[productId] return productId .split("_") .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join(" ") } export default function Billing() { const queryClient = useQueryClient() const { user, org } = useAuth() const autumn = useCustomer() const promoCode = usePromoCode() const posthog = usePostHog() const isCompanyBrain = useHasCompanyBrain() const brainTrial = useMemo( () => getBrainTrialInfo(org?.metadata as Record | string), [org?.metadata], ) const [isUpgrading, setIsUpgrading] = useState(false) const [isCancelling, setIsCancelling] = useState(false) const [isResuming, setIsResuming] = useState(false) const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false) const [cancelReason, setCancelReason] = useState( null, ) const [cancelDetail, setCancelDetail] = useState("") const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false) const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false) const [planPage, setPlanPage] = useState<0 | 1>(0) const [topUpAmount, setTopUpAmount] = useState(25) const [customTopUpAmount, setCustomTopUpAmount] = useState("") const [topUpPendingAmount, setTopUpPendingAmount] = useState( null, ) const [autoTopUpEnabled, setAutoTopUpEnabled] = useState(false) const [autoTopUpThreshold, setAutoTopUpThreshold] = useState(5) const [autoTopUpAmount, setAutoTopUpAmount] = useState(25) const [isSavingAutoTopUp, setIsSavingAutoTopUp] = useState(false) const currentMember = org?.members?.find( (m: { userId: string }) => m.userId === user?.id, ) const userRole = (currentMember?.role ?? "member") as string const isAdmin = userRole === "owner" || userRole === "admin" const { usdIncluded, usdSpent, planUsagePct, currentPlan, hasPaidPlan, isTrialing: autumnTrialing, trialEndsAtMs: autumnTrialEndsAtMs, isLoading: isCheckingStatus, daysRemaining, } = useTokenUsage(autumn) const brainTrialStillOpen = brainTrial.status === "active" && (brainTrial.endsAtMs == null || brainTrial.endsAtMs > Date.now()) const isBrainTrialEnded = isCompanyBrain && (brainTrial.status === "expired" || brainTrial.status === "exhausted" || (brainTrial.status === "active" && brainTrial.endsAtMs != null && brainTrial.endsAtMs <= Date.now())) const isOnTrial = !isBrainTrialEnded && (autumnTrialing || (isCompanyBrain && brainTrialStillOpen)) const trialEndsAtMs = brainTrial.endsAtMs ?? autumnTrialEndsAtMs ?? null const trialDaysLeft = brainTrial.daysRemaining ?? (trialEndsAtMs != null ? Math.max( 0, Math.ceil((trialEndsAtMs - Date.now()) / (1000 * 60 * 60 * 24)), ) : null) const trialEndsLabel = trialEndsAtMs != null ? new Date(trialEndsAtMs).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }) : null const trialCredits = brainTrial.credits ?? (isOnTrial ? 200 : null) const showPlanUsage = hasPaidPlan || isOnTrial || isCompanyBrain // Open the carousel to the page holding the current plan (Max/Scale/Enterprise live on page 2). // Company Brain orgs only list Scale + Enterprise — no carousel. const didAutoOpenPlanPage = useRef(false) useEffect(() => { if (isCompanyBrain) return if (didAutoOpenPlanPage.current || isCheckingStatus) return didAutoOpenPlanPage.current = true if (ADVANCED_PLAN_CARDS.some((p) => p.id === currentPlan)) { setIsPlanCarouselActive(true) setPlanPage(1) } }, [isCheckingStatus, currentPlan, isCompanyBrain]) const balance = autumn.data?.balances?.[CREDIT_FEATURE_ID] const creditRemaining = balance?.remaining ?? Math.max(usdIncluded - usdSpent, 0) // --- Invoices via dedicated billing API (matches console) --- const invoicesQuery = useQuery({ queryKey: ["billing", org?.id ?? "", "invoices"], queryFn: async () => { const res = await fetch(`${API_BASE}/v3/auth/billing/invoices`, { credentials: "include", headers: { "X-App-Source": "nova" }, }) if (!res.ok) return [] const data = (await res.json()) as { invoices?: BillingInvoice[] } return data.invoices ?? [] }, enabled: Boolean(org?.id), staleTime: 60_000, }) const invoices = useMemo(() => { return [...(invoicesQuery.data ?? [])].sort( (a, b) => b.createdAt - a.createdAt, ) }, [invoicesQuery.data]) // --- Auto top-ups via dedicated billing API (matches console) --- const autoTopupsQuery = useQuery({ queryKey: ["billing", org?.id ?? "", "auto-topups"], queryFn: async () => { const res = await fetch(`${API_BASE}/v3/auth/billing/auto-topups`, { credentials: "include", headers: { "X-App-Source": "nova" }, }) if (!res.ok) return null return (await res.json()) as AutoTopupsResponse }, enabled: Boolean(org?.id), staleTime: 20_000, }) const autoTopupData = autoTopupsQuery.data && "ok" in autoTopupsQuery.data && autoTopupsQuery.data.ok ? autoTopupsQuery.data : null const hasPaymentMethod = Boolean(autoTopupData?.hasPaymentMethod) const activeAutoTopUp = autoTopupData?.autoTopup ?? null const selectedTopUpAmount = customTopUpAmount ? Number.parseFloat(customTopUpAmount) || 0 : topUpAmount useEffect(() => { if (!autoTopupData) return if (!activeAutoTopUp) { setAutoTopUpEnabled(false) return } setAutoTopUpEnabled(activeAutoTopUp.enabled) setAutoTopUpThreshold(activeAutoTopUp.threshold) setAutoTopUpAmount(activeAutoTopUp.quantity) }, [activeAutoTopUp, autoTopupData]) useEffect(() => { if (!hasPaymentMethod && !activeAutoTopUp?.enabled) { setAutoTopUpEnabled(false) } }, [activeAutoTopUp?.enabled, hasPaymentMethod]) const planDisplayNames = PLAN_DISPLAY_NAMES const handleUpgrade = async (planId: "api_pro" | "api_max" | "api_scale") => { setIsUpgrading(true) try { const result = await autumn.attach({ planId, discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#billing`, }) promoCode.clear() if ((result as { paymentUrl?: string })?.paymentUrl) { window.location.href = (result as { paymentUrl: string }).paymentUrl return } autumn.refetch?.() } catch (error) { console.error(error) toast.error("Failed to start checkout. Please try again.") } finally { setIsUpgrading(false) } } const cancellablePlanId = currentPlan === "pro" || currentPlan === "max" || currentPlan === "scale" ? (`api_${currentPlan}` as const) : null const cancelNeedsDetail = cancelReason != null && cancelReasonNeedsDetail(cancelReason) const canConfirmCancel = cancelReason != null && (!cancelNeedsDetail || cancelDetail.trim().length > 0) 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 resetCancelForm = () => { setCancelReason(null) setCancelDetail("") } const handleCancelSubscription = async () => { if (!cancellablePlanId) return setIsCancelling(true) try { await autumn.updateSubscription({ planId: cancellablePlanId, cancelAction: "cancel_end_of_cycle", }) if (posthog?.__loaded) { posthog.capture("subscription_cancelled", { reason: cancelReason, reason_detail: cancelDetail.trim() || null, plan: currentPlan, plan_id: cancellablePlanId, surface: "nova", }) } autumn.refetch?.() setIsCancelDialogOpen(false) resetCancelForm() toast.success( `Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`, ) } catch (error) { console.error(error) toast.error("Failed to cancel subscription. Please try again.") } finally { setIsCancelling(false) } } const handleTopUp = async (amount: number) => { if (!isAdmin) { toast.error("Only owners/admins can purchase credits.") return } if (!hasPaidPlan) { toast.error("Upgrade to a paid plan before purchasing credits.") return } setTopUpPendingAmount(amount) try { const result = await autumn.attach({ planId: TOP_UP_PLAN_ID, featureQuantities: [{ featureId: CREDIT_FEATURE_ID, quantity: amount }], successUrl: `${window.location.origin}/settings#billing`, metadata: { source: "nova_billing_topup", amount: String(amount), }, }) if ((result as { paymentUrl?: string })?.paymentUrl) { window.location.href = (result as { paymentUrl: string }).paymentUrl return } autumn.refetch?.() toast.success(`${formatUsd(amount)} credit top-up added.`) } catch (error) { console.error(error) toast.error("Failed to start top-up checkout. Please try again.") } finally { setTopUpPendingAmount(null) } } const handleAutoReloadToggle = (next: boolean) => { if (next && !hasPaymentMethod) { toast.error( "Add a payment method under Manage Billing before enabling auto reload.", ) return } setAutoTopUpEnabled(next) } const handleSaveAutoTopUp = async () => { if (!isAdmin) { toast.error("Only owners/admins can change auto top-up settings.") return } if (autoTopUpEnabled && !hasPaymentMethod) { toast.error( "Add a payment method under Manage Billing before enabling auto top-up.", ) return } setIsSavingAutoTopUp(true) try { const response = await fetch(`${API_BASE}/v3/auth/billing/auto-topups`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-App-Source": "nova", }, body: JSON.stringify({ enabled: autoTopUpEnabled, threshold: autoTopUpThreshold, quantity: autoTopUpAmount, purchaseLimit: { interval: "month" as const, intervalCount: 1, limit: 10, }, }), }) if (!response.ok) { const body = (await response.json().catch(() => ({}))) as { message?: string } throw new Error(body.message ?? "Failed to update auto top-up") } await queryClient.invalidateQueries({ queryKey: ["billing", org?.id ?? "", "auto-topups"], }) await queryClient.invalidateQueries({ queryKey: ["autumn"] }) autumn.refetch?.() toast.success( autoTopUpEnabled ? "Auto top-up settings saved." : "Auto top-up disabled.", ) } catch (error) { console.error(error) toast.error( error instanceof Error ? error.message : "Failed to update auto top-up.", ) } finally { setIsSavingAutoTopUp(false) } } const handleManageBilling = () => { autumn.openCustomerPortal?.({ returnUrl: `${window.location.origin}/settings#billing`, }) } const getPlanCardAction = (plan: PlanCardDefinition) => { const disabled = isUpgrading || isCheckingStatus || autumn.isLoading const isCurrentPlan = plan.id === currentPlan const isIncludedPlan = PLAN_RANK[currentPlan] > PLAN_RANK[plan.id] if (plan.id === "free") { return ( ) } // The trial runs on api_scale, so Max ranks below the current plan and would // otherwise render as a dead "Included with Scale" button. Trial users are // exactly who we want on Max, so it needs its own actionable path. if (plan.id === "max" && (isOnTrial || isBrainTrialEnded)) { return ( ) } // Trial Scale: primary CTA is activate paid Scale (not a dead "current" state). if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) { return ( ) } if (isCurrentPlan) { return ( ) } if (isIncludedPlan) { return ( ) } if (plan.isContactSales) { return ( Contact sales ) } const checkoutPlanId = plan.productId === "api_pro" || plan.productId === "api_max" || plan.productId === "api_scale" ? plan.productId : null if (!checkoutPlanId) return null return ( ) } return (
Billing & Subscription

{isOnTrial || isBrainTrialEnded || (isCompanyBrain && currentPlan === "scale") ? "Scale plan" : hasPaidPlan ? `${planDisplayNames[currentPlan]} plan` : "Free plan"}

{isPlanCanceling ? "Cancelling" : isBrainTrialEnded ? brainTrial.status === "exhausted" ? "Credits used up" : "Trial ended" : isOnTrial ? "Free trial" : hasPaidPlan ? "Active" : "Free"}

{isPlanCanceling ? `Cancels on ${cancelEndsLabel}${cancelEndsDays !== null ? ` · ${cancelEndsDays} day${cancelEndsDays !== 1 ? "s" : ""} left` : ""}.${isCompanyBrain ? "" : " You'll move to Free after that."}` : isOnTrial ? [ trialEndsLabel ? `Ends ${trialEndsLabel}${trialDaysLeft != null ? ` · ${trialDaysLeft} day${trialDaysLeft !== 1 ? "s" : ""} left` : ""}` : null, trialCredits != null ? `$${trialCredits} trial credits` : null, ] .filter(Boolean) .join(" · ") : isBrainTrialEnded ? "Trial ended. Activate Scale to restore access." : hasPaidPlan ? "Expanded memory, connections, and usage for this workspace." : "Upgrade when you need more workspace usage and integrations."}

{isBrainTrialEnded ? (
) : null}
{isPlanCanceling ? ( ) : cancellablePlanId ? ( { setIsCancelDialogOpen(open) if (!open) resetCancelForm() }} >

Cancel {planDisplayNames[currentPlan]}?

You keep paid features until the current billing period ends {daysRemaining !== null ? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)` : ""} .

Talk to us first

Give us 15 minutes before you go — a quick call with the team where we'll:

    {[ "Fix anything that's not working for you", "Help you integrate Supermemory the right way", "Get a discount to stay", ].map((benefit) => (
  • {benefit}
  • ))}
{ if (posthog?.__loaded) { posthog.capture( "cancel_book_call_clicked", { plan: currentPlan, surface: "nova", }, ) } }} className={cn( dmSans125ClassName(), "group mt-auto inline-flex h-10 items-center justify-center gap-1.5 rounded-[10px] bg-[#0054AD] px-4 text-[13px] font-semibold text-white transition-colors hover:bg-[#0B65C9]", )} > Book a call
or

Why are you leaving?

{CANCEL_REASONS.map((option) => { const selected = cancelReason === option.value return ( ) })}
{cancelReason !== null ? (