"use client" 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, DialogContent, DialogTrigger, } from "@ui/components/dialog" import { useQuery, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" 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" const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" 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", "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", "All plugins (Claude Code, Cursor, Hermes...)", "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, }, ] 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 [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) 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, isLoading: isCheckingStatus, 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) // --- 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, successUrl: `${window.location.origin}/settings#billing`, }) 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 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) try { await autumn.updateSubscription({ planId: cancellablePlanId, cancelAction: "cancel_end_of_cycle", }) autumn.refetch?.() setIsCancelDialogOpen(false) setCancelConfirmText("") 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 ( ) } 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

{hasPaidPlan ? `${planDisplayNames[currentPlan]} plan` : "Free plan"}

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

{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."}

{isPlanCanceling ? ( ) : cancellablePlanId ? ( { setIsCancelDialogOpen(open) if (!open) setCancelConfirmText("") }} >

Cancel {planDisplayNames[currentPlan]}?

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

{cancelLossItems.length > 0 ? (

You'll lose

    {cancelLossItems.map((item) => (
  • {item}
  • ))}
) : null}
setCancelConfirmText(e.target.value)} placeholder="CANCEL" type="text" value={cancelConfirmText} />
) : null}

Plan usage

{planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)} % used

80 ? "#C73B1B" : "linear-gradient(90deg, #2368D2 0%, #4BA0FA 100%)", }} />

{daysRemaining !== null ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` : "Usage resets with your billing cycle"}

) : undefined } > Plans
{PLAN_CARDS.map((plan) => ( ))}
{ADVANCED_PLAN_CARDS.map((plan) => ( ))}
{isPlanCarouselActive ? null : (
)}

Buy Credits

Add USD to your balance for metered usage.

Choose an amount

{ setTopUpAmount(value) setCustomTopUpAmount("") }} disabled={topUpPendingAmount !== null} />
setCustomTopUpAmount(event.target.value)} placeholder="e.g. 75" type="number" value={customTopUpAmount} className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]" />

Auto reload

{autoTopUpEnabled ? "on" : "off"}

Auto reload is {autoTopUpEnabled ? "enabled" : "disabled"}

{!hasPaymentMethod && !activeAutoTopUp?.enabled ? (

Save a card in Manage Billing to enable automatic reloads.

) : null}
{ const value = Number.parseFloat(event.target.value) setAutoTopUpThreshold(Number.isFinite(value) ? value : 0) }} type="number" value={ Number.isFinite(autoTopUpThreshold) ? autoTopUpThreshold : "" } className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60" />
{ const value = Number.parseFloat(event.target.value) setAutoTopUpAmount(Number.isFinite(value) ? value : 0) }} type="number" value={ Number.isFinite(autoTopUpAmount) ? autoTopUpAmount : "" } className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60" />
{!isAdmin ? (

Only owners/admins can purchase credits.

) : null}
{hasPaidPlan ? (
Credits

Top-up credits

{creditRemaining > 0 ? `${formatUsd(creditRemaining)} available` : "No top-up credits yet"}

Optional add-on that{" "} rolls over {" "} month-to-month, separate from your monthly usage above.

) : null}
Invoice history {invoicesQuery.isLoading ? (
Loading invoices
) : invoices.length === 0 ? (

No invoices yet

) : (
{invoices.map((invoice) => { const date = formatDate(normalizeTimestamp(invoice.createdAt)) const label = invoice.planIds?.length ? invoice.planIds .map((id) => getInvoiceProductLabel(id)) .join(", ") : "Billing invoice" return (

{label}

{invoice.stripeId}

{date}

{formatInvoiceAmount(invoice.total, invoice.currency)}

{invoice.status} {invoice.hostedInvoiceUrl ? ( ) : null}
) })}
)}
) }