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

( +

+ {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 -

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

- {daysRemaining !== null - ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` - : ""} -

-
+ + + + +
+
+

+ 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"} +

+
+ +
+ + - {cancellablePlanId && ( - - + + +
+
+

+ 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} + +
-
-
-
-

- 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. -

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

- Free Plan -

-

- You are on basic plan -

-
+
+ + { + 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" + /> +
+
+ +
+
+
-
-
-

- Plan usage -

-

void handleTopUp(selectedTopUpAmount)} + disabled={ + topUpPendingAmount !== null || + !isAdmin || + selectedTopUpAmount <= 0 + } className={cn( dmSans125ClassName(), - "font-medium text-[16px] tracking-[-0.16px] text-[#737373] tabular-nums", + "inline-flex h-11 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-bold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60", )} > - {planUsagePct < 1 && planUsagePct > 0 - ? "< 1" - : Math.round(planUsagePct)} - % used -

-
-
-
80 ? "#ef4444" : "#0054AD", - }} - title={`$${formatUsd(usdSpent)} of $${formatUsd(usdIncluded)} used`} - /> + {topUpPendingAmount !== null ? ( + + ) : null} + Buy {formatUsd(selectedTopUpAmount)} in credits + + + {!isAdmin ? ( +

+ Only owners/admins can purchase credits. +

+ ) : null}
+ + + +
+
+
+ + {hasPaidPlan ? ( + +
+
+ +
+

+ Top-up credits{" "} + + (optional) + +

- {daysRemaining !== null - ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` - : ""} + {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} +
+
+ ) + })} +
+ )}
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index eef87c146..d762654e5 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"),