diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx
index aca23307..fcf450bc 100644
--- a/apps/web/components/settings/billing.tsx
+++ b/apps/web/components/settings/billing.tsx
@@ -1,171 +1,245 @@
"use client"
import { dmSans125ClassName } from "@/lib/fonts"
-import { cn } from "@lib/utils"
+import { calculateUsagePercent } from "@/lib/billing-utils"
import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage"
+import { cn } from "@lib/utils"
import {
Dialog,
+ DialogClose,
DialogContent,
DialogTrigger,
- DialogClose,
} from "@ui/components/dialog"
-import { useCustomer } from "autumn-js/react"
-import { Check, X, LoaderIcon, Settings } from "lucide-react"
-import { useState } from "react"
+import { useQueryClient } from "@tanstack/react-query"
+import { useCustomer, useListPlans } from "autumn-js/react"
+import {
+ Check,
+ CreditCard,
+ ExternalLink,
+ LoaderIcon,
+ ReceiptText,
+ Settings,
+ X,
+ Zap,
+} from "lucide-react"
+import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
-function SectionTitle({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
+
+
+ {children}
+
+ {aside}
)
}
-function PlanComparisonCard({
- name,
- price,
- period,
- description,
- credits,
- features,
- highlight,
+function SettingsCard({
+ children,
+ className,
}: {
- name: string
- price: string
- period: string
- description: string
- credits: string
- features: string[]
- highlight: boolean
+ children: React.ReactNode
+ className?: string
}) {
return (
-
-
- {name}
-
- {highlight && (
-
- RECOMMENDED
-
- )}
-
-
-
-
- {price}
-
- {period && (
-
- {period}
-
- )}
-
-
-
- {description}
-
-
-
-
-
- {credits}
-
-
- of usage included
-
-
-
-
-
- {features.map((text) => (
-
-
- {text}
-
- ))}
-
+ {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
+}) {
+ return (
+
+ {values.map((item) => (
+ onChange(item)}
+ className={cn(
+ dmSans125ClassName(),
+ "h-8 rounded-[7px] text-[13px] font-semibold tabular-nums transition-colors",
+ item === value
+ ? "bg-[#1C2B3E] text-[#FAFAFA]"
+ : "text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]",
+ disabled && "cursor-not-allowed opacity-50",
+ )}
+ >
+ {prefix}
+ {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 findTopUpPlanId(
+ plans: Array<{
+ id: string
+ name?: string
+ description?: string | null
+ addOn?: boolean
+ }>,
+) {
+ const knownPlan = [
+ "api_topup",
+ "api_top_up",
+ "api_credit_topup",
+ "api_credits_topup",
+ "api_usage_topup",
+ ].find((id) => plans.some((plan) => plan.id === id))
+
+ if (knownPlan) return knownPlan
+
+ const discovered = plans.find((plan) => {
+ const label = `${plan.id} ${plan.name ?? ""} ${plan.description ?? ""}`
+ return plan.addOn && /top.?up|credit|usage/i.test(label)
+ })
+
+ return discovered?.id ?? FALLBACK_TOP_UP_PLAN_ID
+}
+
export default function Billing() {
- const autumn = useCustomer()
+ const queryClient = useQueryClient()
+ const autumn = useCustomer({ expand: ["invoices", "payment_method"] })
+ const plansQuery = useListPlans({
+ queryOptions: { staleTime: 5 * 60 * 1000 },
+ })
const [isUpgrading, setIsUpgrading] = useState(false)
const [isCancelling, setIsCancelling] = useState(false)
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
+ const [topUpAmount, setTopUpAmount] = useState
(25)
+ 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 {
usdIncluded,
@@ -177,25 +251,49 @@ 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)
+ const creditGranted = balance?.granted ?? usdIncluded
+ const creditUsagePct = creditGranted
+ ? calculateUsagePercent(creditGranted - creditRemaining, creditGranted)
+ : planUsagePct
+
+ const invoices = useMemo(() => {
+ return ([...(autumn.data?.invoices ?? [])] as BillingInvoice[])
+ .sort((a, b) => b.createdAt - a.createdAt)
+ .slice(0, 8)
+ }, [autumn.data?.invoices])
+
+ const topUpPlanId = useMemo(
+ () => findTopUpPlanId(plansQuery.data ?? []),
+ [plansQuery.data],
+ )
+
+ const activeAutoTopUp = useMemo(() => {
+ const autoTopups = (autumn.data?.billingControls?.autoTopups ??
+ []) as BillingAutoTopup[]
+ return autoTopups.find(
+ (item: BillingAutoTopup) => item.featureId === CREDIT_FEATURE_ID,
+ )
+ }, [autumn.data?.billingControls?.autoTopups])
+
+ useEffect(() => {
+ if (!activeAutoTopUp) return
+ setAutoTopUpEnabled(activeAutoTopUp.enabled)
+ setAutoTopUpThreshold(activeAutoTopUp.threshold)
+ setAutoTopUpAmount(activeAutoTopUp.quantity)
+ }, [activeAutoTopUp])
const planDisplayNames = PLAN_DISPLAY_NAMES
const handleUpgrade = async () => {
setIsUpgrading(true)
try {
- const result = await autumn.attach({
+ await autumn.attach({
planId: "api_pro",
successUrl: `${window.location.origin}/settings#billing`,
})
- if (result?.paymentUrl) {
- window.open(result.paymentUrl, "_self")
- return
- }
autumn.refetch?.()
} catch (error) {
console.error(error)
@@ -231,347 +329,547 @@ export default function Billing() {
}
}
+ const handleTopUp = async (amount: number) => {
+ setTopUpPendingAmount(amount)
+ try {
+ await autumn.attach({
+ planId: topUpPlanId,
+ featureQuantities: [{ featureId: CREDIT_FEATURE_ID, quantity: amount }],
+ successUrl: `${window.location.origin}/settings#billing`,
+ metadata: {
+ source: "nova_billing_topup",
+ amount: String(amount),
+ },
+ })
+ 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 handleSaveAutoTopUp = async () => {
+ setIsSavingAutoTopUp(true)
+ const existingAutoTopups = (autumn.data?.billingControls?.autoTopups ??
+ []) as BillingAutoTopup[]
+ const nextAutoTopups = [
+ ...existingAutoTopups.filter(
+ (item: BillingAutoTopup) => item.featureId !== CREDIT_FEATURE_ID,
+ ),
+ {
+ featureId: CREDIT_FEATURE_ID,
+ enabled: autoTopUpEnabled,
+ threshold: autoTopUpThreshold,
+ quantity: autoTopUpAmount,
+ purchaseLimit: {
+ interval: "month",
+ intervalCount: 1,
+ limit: 10,
+ },
+ },
+ ]
+
+ try {
+ const response = await fetch(`${API_BASE}/api/autumn/updateCustomer`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ "X-App-Source": "nova",
+ },
+ body: JSON.stringify({
+ billingControls: {
+ ...autumn.data?.billingControls,
+ autoTopups: nextAutoTopups,
+ },
+ }),
+ })
+
+ 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: ["autumn"] })
+ autumn.refetch?.()
+ toast.success(
+ autoTopUpEnabled ? "Auto top-up updated." : "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"}
+
-
-
-
-
- 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`}
- />
-
-
- {daysRemaining !== null
- ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
- : ""}
-
-
-
-
-
{
- autumn.openCustomerPortal?.({
- returnUrl:
- "https://app.supermemory.ai/settings#billing",
- })
- }}
- className={cn(
- "relative flex-1 h-11 rounded-full flex items-center justify-center gap-2",
- "bg-[#0D121A] border border-[rgba(115,115,115,0.2)]",
- "text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
- "cursor-pointer transition-opacity hover:opacity-90",
- dmSans125ClassName(),
- )}
- >
-
- Manage billing
-
-
- {cancellablePlanId && (
-
-
-
- Cancel subscription
-
-
-
-
-
-
-
-
- Cancel {planDisplayNames[currentPlan]}{" "}
- subscription?
-
-
- You'll keep Pro features until the end of
- your current billing period
- {daysRemaining !== null
- ? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)`
- : ""}
- . After that, your account will switch to the
- Free plan.
-
-
-
-
-
-
-
-
-
-
-
-
-
- Keep plan
-
-
-
void handleCancelSubscription()}
- disabled={isCancelling}
- className={cn(
- "relative flex items-center gap-1.5 px-4 py-2 rounded-full",
- "bg-[#290F0A] text-[#C73B1B]",
- "font-normal text-[14px] tracking-[-0.14px]",
- "cursor-pointer transition-opacity",
- "disabled:opacity-40 disabled:cursor-not-allowed",
- !isCancelling && "hover:opacity-90",
- dmSans125ClassName(),
- )}
- >
- {isCancelling && (
-
- )}
-
- {isCancelling
- ? "Cancelling…"
- : "Cancel subscription"}
-
-
-
-
-
-
-
-
+
- >
- ) : (
- <>
-
-
- Free Plan
-
-
- You are on basic plan
-
-
-
-
-
-
- Plan usage
-
-
- {planUsagePct < 1 && planUsagePct > 0
- ? "< 1"
- : Math.round(planUsagePct)}
- % used
-
-
-
-
80 ? "#ef4444" : "#0054AD",
- }}
- title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`}
- />
-
-
- {daysRemaining !== null
- ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
- : ""}
-
-
+ >
+ {hasPaidPlan
+ ? "Expanded memory, connections, and usage for this workspace."
+ : "Upgrade when you need more workspace usage and integrations."}
+
+
+
- {isUpgrading || isCheckingStatus || autumn.isLoading ? (
- <>
-
- Upgrading…
- >
- ) : (
- "Upgrade to Pro - $19/month"
- )}
-
+
+ Manage
+ {cancellablePlanId ? (
+
+
+
+ Cancel
+
+
+
+
+
+
+ Cancel {planDisplayNames[currentPlan]}?
+
+
+ You keep paid features until the current billing
+ period ends
+ {daysRemaining !== null
+ ? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)`
+ : ""}
+ .
+
+
+
+
+
+
+
+
+
+
+
+ Keep plan
+
+
+ void handleCancelSubscription()}
+ disabled={isCancelling}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#290F0A] px-3 text-[13px] font-medium text-[#C73B1B] transition-opacity disabled:cursor-not-allowed disabled:opacity-50",
+ )}
+ >
+ {isCancelling ? (
+
+ ) : null}
+ Cancel subscription
+
+
+
+
+ ) : 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 ? (
+
+ {isUpgrading || isCheckingStatus || autumn.isLoading ? (
+
+ ) : null}
+ Upgrade to Pro - $19/month
+
+ ) : null}
+
+
+
+
+
+ Auto top-up on
+ ) : (
+ Auto top-up off
+ )
+ }
+ >
+ Credits
+
+
+
+
+
+
+
+ Available balance
+
+
+ {formatUsd(creditRemaining)}
+
+
+
+
+
+
+
+
+
+ Credits used
+
+ {Math.round(creditUsagePct)}%
+
+
+
- >
- )}
-
+
+
+
+
+ void handleTopUp(topUpAmount)}
+ disabled={topUpPendingAmount !== null}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-10 items-center justify-center gap-2 rounded-[10px] bg-[#0D121A] text-[14px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-60",
+ )}
+ >
+ {topUpPendingAmount !== null ? (
+
+ ) : (
+
+ )}
+ Add {formatUsd(topUpAmount)}
+
+
+
+
+
+
+
+
+
+
+ Auto top-up
+
+
+ Add credits automatically when the workspace balance gets
+ low.
+
+
+
setAutoTopUpEnabled((enabled) => !enabled)}
+ className={cn(
+ "relative h-6 w-11 rounded-full border transition-colors",
+ autoTopUpEnabled
+ ? "border-[#4BA0FA]/40 bg-[#0E2C4E]"
+ : "border-white/10 bg-[#0D121A]",
+ )}
+ aria-pressed={autoTopUpEnabled}
+ >
+
+
+
+
+
+
+
+
+
+
+ Limit: up to 10 automatic top-ups per month.
+
+
+
void handleSaveAutoTopUp()}
+ disabled={isSavingAutoTopUp}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-8 shrink-0 items-center justify-center gap-2 rounded-[8px] bg-[#1C2B3E] px-3 text-[13px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#24384F] disabled:cursor-not-allowed disabled:opacity-60",
+ )}
+ >
+ {isSavingAutoTopUp ? (
+
+ ) : null}
+ Save
+
+
+
+
+
+
+
+
+ Invoice history
+
+ {autumn.isLoading ? (
+
+
+
+ Loading invoices
+
+
+ ) : invoices.length === 0 ? (
+
+
+
+ No invoices yet
+
+
+ ) : (
+
+ {invoices.map((invoice) => {
+ const date = formatDate(normalizeTimestamp(invoice.createdAt))
+ return (
+
+
+
+ {invoice.planIds?.length
+ ? invoice.planIds.join(", ")
+ : "Billing invoice"}
+
+
+ {invoice.stripeId}
+
+
+
+ {date}
+
+
+ {formatInvoiceAmount(invoice.total, invoice.currency)}
+
+
+
+ {invoice.status}
+
+ {invoice.hostedInvoiceUrl ? (
+
+
+
+ ) : null}
+
+
+ )
+ })}
+
+ )}
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts
index eef87c14..d762654e 100644
--- a/apps/web/lib/analytics.ts
+++ b/apps/web/lib/analytics.ts
@@ -151,7 +151,7 @@ export const analytics = {
// settings / spaces / docs analytics
settingsTabChanged: (props: {
- tab: "account" | "integrations" | "connections" | "support"
+ tab: "account" | "billing" | "integrations" | "connections" | "support"
}) => safeCapture("settings_tab_changed", props),
spaceCreated: () => safeCapture("space_created"),