From 7faecfff36d605a539767ba501c7b2fdb92e4fa7 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 23 May 2026 13:47:15 +0530 Subject: [PATCH] Implemented the billing additions --- apps/web/components/settings/billing.tsx | 1224 ++++++++++++++-------- apps/web/lib/analytics.ts | 2 +- 2 files changed, 762 insertions(+), 464 deletions(-) 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} -

- ) +const API_BASE = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +const CREDIT_FEATURE_ID = "usd_credits" +const FALLBACK_TOP_UP_PLAN_ID = "api_topup" +const TOP_UP_AMOUNTS = [10, 25, 50] as const +const AUTO_TOP_UP_THRESHOLDS = [2, 5, 10] as const +const AUTO_TOP_UP_AMOUNTS = [10, 25, 50] as const + +type BillingInvoice = { + planIds?: string[] + stripeId: string + status: string + total: number + currency: string + createdAt: number + hostedInvoiceUrl?: string | null } -function SettingsCard({ children }: { children: React.ReactNode }) { +type BillingAutoTopup = { + featureId: string + enabled: boolean + threshold: number + quantity: number + invoiceMode?: boolean + purchaseLimit?: { + interval: "hour" | "day" | "week" | "month" + intervalCount?: number + limit: number + } +} + +function SectionTitle({ + children, + aside, +}: { + children: React.ReactNode + aside?: 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) => ( + + ))} +
+ ) +} + +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" : ""}` - : ""} -

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

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

-
- - - -
- -
- - - - -
-
-
- -
+

- - ) : ( - <> -

-

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

+
+
+ {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} +
+ +
+ +
+ Auto top-up on + ) : ( + Auto top-up off + ) + } + > + Credits + +
+ +
+
+
+

+ Available balance +

+

+ {formatUsd(creditRemaining)} +

+
+
+ +
+
+ +
+
+ Credits used + + {Math.round(creditUsagePct)}% + +
+
+
- - )} -
+
+ +
+ + +
+
+
+ + +
+
+
+

+ Auto top-up +

+

+ Add credits automatically when the workspace balance gets + low. +

+
+ +
+ +
+
+

+ Trigger below +

+ +
+
+

+ Add each time +

+ +
+
+ +
+
+ +

+ Limit: up to 10 automatic top-ups per month. +

+
+ +
+
+
+
+
+ +
+ 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"),