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