diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx
index aca23307d..07d0fdb1f 100644
--- a/apps/web/components/settings/billing.tsx
+++ b/apps/web/components/settings/billing.tsx
@@ -1,171 +1,256 @@
"use client"
import { dmSans125ClassName } from "@/lib/fonts"
-import { cn } from "@lib/utils"
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,
- DialogClose,
} from "@ui/components/dialog"
+import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
-import { Check, X, LoaderIcon, Settings } from "lucide-react"
-import { useState } from "react"
+import {
+ Coins,
+ ExternalLink,
+ LoaderIcon,
+ Plus,
+ ReceiptText,
+ Settings,
+ X,
+} from "lucide-react"
+import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
-function SectionTitle({ children }: { children: React.ReactNode }) {
+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 SettingsCard({ children }: { children: React.ReactNode }) {
+function Pill({
+ children,
+ tone = "muted",
+}: {
+ children: React.ReactNode
+ tone?: "active" | "muted" | "warning"
+}) {
return (
-
{children}
-
+
)
}
-function PlanComparisonCard({
- name,
- price,
- period,
- description,
- credits,
- features,
- highlight,
+function FieldSelect({
+ value,
+ values,
+ prefix,
+ onChange,
+ disabled,
}: {
- name: string
- price: string
- period: string
- description: string
- credits: string
- features: string[]
- highlight: boolean
+ 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 (
-
-
(
+
- {highlight && (
-
- RECOMMENDED
-
- )}
-
+ {prefix}
+ {item}
+
+ ))}
+
+ )
+}
-
-
- {price}
-
- {period && (
-
- {period}
-
- )}
-
+function formatUsd(value: number) {
+ return value.toLocaleString(undefined, {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })
+}
-
- {description}
-
+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,
+ })
+}
-
-
-
- {credits}
-
-
- of usage included
-
-
-
-
-
- {features.map((text) => (
- -
-
- {text}
-
- ))}
-
-
- )
+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 autumn = useCustomer()
+ 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,
@@ -177,11 +262,74 @@ export default function Billing() {
daysRemaining,
} = useTokenUsage(autumn)
- const formatUsd = (n: number) =>
- n.toLocaleString(undefined, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })
+ 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
@@ -192,8 +340,8 @@ export default function Billing() {
planId: "api_pro",
successUrl: `${window.location.origin}/settings#billing`,
})
- if (result?.paymentUrl) {
- window.open(result.paymentUrl, "_self")
+ if ((result as { paymentUrl?: string })?.paymentUrl) {
+ window.location.href = (result as { paymentUrl: string }).paymentUrl
return
}
autumn.refetch?.()
@@ -231,347 +379,744 @@ export default function Billing() {
}
}
+ 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
-
-
- ACTIVE
-
-
+
+
+
+
- Expanded memory with connections and more
+ {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."}
+
+
-
-
-
- Plan usage
-
-
- {planUsagePct < 1 && planUsagePct > 0
- ? "< 1"
- : Math.round(planUsagePct)}
- % used
-
-
-
-
80
- ? "#ef4444"
- : "linear-gradient(to right, #4BA0FA 80%, #002757 100%)",
- }}
- title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`}
- />
-
-
+
+ {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"}
+
+
+
+
+