"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 { Dialog, DialogClose, DialogContent, DialogTrigger, } from "@ui/components/dialog" import { useQuery, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" import { Coins, ExternalLink, LoaderIcon, Plus, ReceiptText, Settings, X, } from "lucide-react" import { useEffect, useMemo, 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 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 } 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 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_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({ expand: ["payment_method"] }) const [isUpgrading, setIsUpgrading] = useState(false) const [isCancelling, setIsCancelling] = useState(false) const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false) const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false) 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) 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 () => { setIsUpgrading(true) try { const result = await autumn.attach({ planId: "api_pro", 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 === "scale" ? (`api_${currentPlan}` as const) : null const handleCancelSubscription = async () => { if (!cancellablePlanId) return setIsCancelling(true) try { await autumn.updateSubscription({ planId: cancellablePlanId, cancelAction: "cancel_end_of_cycle", }) autumn.refetch?.() setIsCancelDialogOpen(false) 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`, }) } return (
Billing & Subscription

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

{hasPaidPlan ? "Active" : "Free"}

{hasPaidPlan ? "Expanded memory, connections, and usage for this workspace." : "Upgrade when you need more workspace usage and integrations."}

{cancellablePlanId ? (

Cancel {planDisplayNames[currentPlan]}?

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

) : 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"}

{!hasPaidPlan ? ( ) : null}
Credits

Usage this period

{planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)} % of monthly usage {daysRemaining !== null ? `· resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` : "· resets with your billing cycle"}

{planUsagePct > 0 ? `${formatUsd(usdSpent)} used this period` : "No usage yet this period"}

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 ? (

Top-up credits{" "} (optional)

{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}
) })}
)}
) }