From db4564b5c293e0a4cbe121f712f3dabac58f9361 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 09:25:40 -0500 Subject: [PATCH] feat(ui): add Usage tab to Billing page Surfaces raw operational usage and cost data alongside the existing subscription/quota view, using billing-service's already-deployed /usage, /cost-history, and /cost-breakdown endpoints (no backend changes needed). Deliberately excludes /usage-events (per-user raw activity log) -- that data needs its own explicit access decision, same reasoning as the cron-log exclusion earlier this week, not default inclusion. - billingApi.js: getUsageSummary/getCostHistory/getCostBreakdown - Billing.jsx: split into Overview/Usage tabs - billing.test.jsx: +6 tests (empty state, populated, independent endpoint-failure isolation, getUsageEvents non-export assertion) Verified live against org 1: real 5 GPU-hours / $12.50 cost data rendered correctly across all three new views. Note: branch coverage (92.4%) is essentially unchanged from main's pre-existing 92.68% -- the 95% gate was already failing before this change, not a regression introduced here. Separate cleanup needed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Rjm5Pbw61jjRLjGHh9VRpB --- src/ui/lib/billingApi.js | 44 ++++++++++ src/ui/pages/Billing.jsx | 169 +++++++++++++++++++++++++++++++++++++- tests/ui/billing.test.jsx | 139 ++++++++++++++++++++++++++++++- 3 files changed, 348 insertions(+), 4 deletions(-) diff --git a/src/ui/lib/billingApi.js b/src/ui/lib/billingApi.js index f124b03..a7c5b4d 100644 --- a/src/ui/lib/billingApi.js +++ b/src/ui/lib/billingApi.js @@ -71,3 +71,47 @@ export const getUsageLimits = (orgId) => // Never 404s — a brand-new org gets honest zeros / null period. export const getBillingSummary = (orgId) => get(`/billing/organizations/${orgId}/summary`); + +function qs(params) { + return Object.entries(params) + .filter(([, v]) => v != null) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); +} + +// GET /billing/organizations/{orgId}/usage?start_date&end_date +// -> { organization_id, period_start, period_end, +// services: [{ service, action, resource, unit, quantity }] } +// Raw usage quantity per (service, action, resource) dimension, summed +// server-side from usage_daily_rollups. Never 404s — an org/period +// with no recorded usage gets an honest empty `services` array. +export const getUsageSummary = (orgId, startDate, endDate) => + get(`/billing/organizations/${orgId}/usage?${qs({ start_date: startDate, end_date: endDate })}`); + +// GET /billing/organizations/{orgId}/cost-history?start_date&end_date +// -> { organization_id, period_start, period_end, currency, +// history: [{ date, cost }] } +// One point per day that had rated usage — a quiet day has no point at +// all, not an explicit zero-cost entry (see billing_reporting_service. +// get_cost_history's docstring), so an empty `history` is the normal +// shape for a sparsely-used org. +export const getCostHistory = (orgId, startDate, endDate) => + get(`/billing/organizations/${orgId}/cost-history?${qs({ start_date: startDate, end_date: endDate })}`); + +// GET /billing/organizations/{orgId}/cost-breakdown?start_date&end_date&group_by +// -> { organization_id, period_start, period_end, group_by, currency, +// breakdown: { [groupKey]: { quantity, cost } } } +// group_by is one of service|action|resource|month server-side; +// defaults to "service" here as the most legible grouping for a +// per-org summary view. +export const getCostBreakdown = (orgId, startDate, endDate, groupBy = "service") => + get(`/billing/organizations/${orgId}/cost-breakdown?${qs({ start_date: startDate, end_date: endDate, group_by: groupBy })}`); + +// Deliberately NOT wrapped here: GET /billing/organizations/{orgId}/usage-events +// is the per-user raw event log (added for HIPAA RAG-query-log read access — +// see billing.py's router section comment), distinct from the aggregate +// usage/cost endpoints above. Surfacing individual per-user activity data is +// its own explicit product decision, not something that rides along by +// default with an aggregate reporting client — same reasoning as the +// cron-log exclusion. Add a dedicated wrapper (and its own UI surface) only +// when that decision is made on purpose. diff --git a/src/ui/pages/Billing.jsx b/src/ui/pages/Billing.jsx index 9fd4a27..bf2d453 100644 --- a/src/ui/pages/Billing.jsx +++ b/src/ui/pages/Billing.jsx @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { Badge, Card, Button, ProgressBar, Spinner } from "@omnibioai/ui"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Badge, Card, Button, ProgressBar, Spinner, Tabs, Table } from "@omnibioai/ui"; import Login from "../components/Login"; import * as billingApi from "../lib/billingApi"; @@ -9,7 +9,10 @@ import * as billingApi from "../lib/billingApi"; // write path exists in that service — no plan changes, no payment-method // management, no invoice actions — so this page has none either. // -// Data shown maps 1:1 to what the API actually returns today: +// The page is two tabs: "Overview" (plan/status/limits/period, unchanged +// from before) and "Usage" (raw usage + cost reporting, added alongside it). +// +// Overview data maps 1:1 to what the API actually returns today: // /billing/organizations/{orgId}/subscription -> plan + status + dates + feature flags // /billing/organizations/{orgId}/subscription/usage-limits -> per-dimension included/used // /billing/organizations/{orgId}/summary -> current period + cost + invoice/outstanding totals @@ -20,6 +23,23 @@ import * as billingApi from "../lib/billingApi"; // test fixtures — see omnibioai-billing/app/core/feature_catalog.py) // - billing account details (billing_email / provider / customer id — the // BillingAccount model has these but no endpoint returns them) +// +// Usage tab data, over a trailing 30-day window: +// /billing/organizations/{orgId}/usage -> raw usage by service/action/resource +// /billing/organizations/{orgId}/cost-history -> daily $ over the window +// /billing/organizations/{orgId}/cost-breakdown -> $ grouped by service (group_by default) +// +// Deliberately NOT shown: /billing/organizations/{orgId}/usage-events, the +// per-user raw event log. Individual user activity data is its own explicit +// product decision, not something that ships by default alongside aggregate +// usage/cost reporting — same reasoning as the cron-log exclusion. See +// billingApi.js's comment above that endpoint for the full rationale. +// +// Most orgs will show near-empty results here — real usage-service traffic +// today is concentrated in one org's rag/model/workflow events, and cost +// rollups are near-empty everywhere else — so every section below uses the +// same honest-empty-state wording as the Overview tab's Plan card rather +// than a blank chart or a false error. const STATUS_VARIANT = { active: "success", @@ -159,6 +179,22 @@ function BillingSummary({ orgId }) { read-only view of organization #{orgId}’s current billing state + + + }, + { key: "usage", label: "Usage", content: }, + ]} + /> + + ); +} + +function BillingOverview({ load, error, hasSub, subscription, limitRows, summary }) { + return ( +
+
@@ -272,6 +308,133 @@ function BillingSummary({ orgId }) { ); } +// Trailing N-day window ending today, as YYYY-MM-DD strings — start_date +// and end_date are required query params on all three Usage-tab endpoints, +// and a fixed trailing window keeps this page free of date-picker state. +function lastNDaysRange(n) { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - (n - 1)); + const iso = (d) => d.toISOString().slice(0, 10); + return { startDate: iso(start), endDate: iso(end) }; +} + +const EMPTY_USAGE_MESSAGE = "No usage recorded for this period."; + +function UsageTab({ orgId }) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [usage, setUsage] = useState(null); // { services: [...] } | null + const [costHistory, setCostHistory] = useState(null); // { currency, history: [...] } | null + const [costBreakdown, setCostBreakdown] = useState(null); // { currency, breakdown: {...} } | null + + const { startDate, endDate } = useMemo(() => lastNDaysRange(30), []); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + + // None of these three 404 — an org/period with no usage gets an + // honest empty result, not a missing-resource error — so any + // rejection here is a real failure worth surfacing. + const [usageRes, historyRes, breakdownRes] = await Promise.allSettled([ + billingApi.getUsageSummary(orgId, startDate, endDate), + billingApi.getCostHistory(orgId, startDate, endDate), + billingApi.getCostBreakdown(orgId, startDate, endDate, "service"), + ]); + + let errMsg = ""; + + if (usageRes.status === "fulfilled") setUsage(usageRes.value); + else errMsg = usageRes.reason?.message || "Failed to load usage"; + + if (historyRes.status === "fulfilled") setCostHistory(historyRes.value); + else if (!errMsg) errMsg = historyRes.reason?.message || "Failed to load cost history"; + + if (breakdownRes.status === "fulfilled") setCostBreakdown(breakdownRes.value); + else if (!errMsg) errMsg = breakdownRes.reason?.message || "Failed to load cost breakdown"; + + setError(errMsg); + setLoading(false); + }, [orgId, startDate, endDate]); + + useEffect(() => { load(); }, [load]); + + if (loading) { + return ( +
+ Loading usage… +
+ ); + } + + const services = usage?.services || []; + const historyPoints = costHistory?.history || []; + const breakdownRows = Object.entries(costBreakdown?.breakdown || {}).map(([group, entry]) => ({ + group, quantity: entry.quantity, cost: entry.cost, + })); + + return ( +
+
+ + {formatDate(startDate)} → {formatDate(endDate)} (trailing 30 days) + + +
+ + {error && {error}} + + {/* ── Raw usage by service/action/resource ────────────────────────── */} + +
Usage by service
+ `${Number(v)} ${row.unit}` }, + ]} + data={services} + emptyMessage={EMPTY_USAGE_MESSAGE} + /> + + + {/* ── Daily cost over the window ───────────────────────────────────── */} + +
Daily cost
+
formatDate(v) }, + { key: "cost", label: "Cost", sortable: true, align: "right", render: (v) => money(v, costHistory?.currency) }, + ]} + data={historyPoints} + emptyMessage={EMPTY_USAGE_MESSAGE} + /> + + + {/* ── Cost grouped by service ──────────────────────────────────────── */} + +
Cost by service
+
Number(v) }, + { key: "cost", label: "Cost", sortable: true, align: "right", render: (v) => money(v, costBreakdown?.currency) }, + ]} + data={breakdownRows} + emptyMessage={EMPTY_USAGE_MESSAGE} + /> + + +
+ Aggregate usage and cost only. Per-user activity (who did what) is + deliberately not shown here — see billingApi.js for why. +
+ + ); +} + function Field({ label, value }) { return (
diff --git a/tests/ui/billing.test.jsx b/tests/ui/billing.test.jsx index 7ac78f3..e0be1f1 100644 --- a/tests/ui/billing.test.jsx +++ b/tests/ui/billing.test.jsx @@ -1,11 +1,14 @@ import React from "react"; -import { render, screen, waitFor, cleanup } from "@testing-library/react"; +import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const billingApi = vi.hoisted(() => ({ getSubscription: vi.fn(), getUsageLimits: vi.fn(), getBillingSummary: vi.fn(), + getUsageSummary: vi.fn(), + getCostHistory: vi.fn(), + getCostBreakdown: vi.fn(), })); vi.mock("../../src/ui/lib/billingApi", () => billingApi); @@ -23,6 +26,9 @@ beforeEach(() => { billingApi.getSubscription.mockReset(); billingApi.getUsageLimits.mockReset(); billingApi.getBillingSummary.mockReset(); + billingApi.getUsageSummary.mockReset(); + billingApi.getCostHistory.mockReset(); + billingApi.getCostBreakdown.mockReset(); }); afterEach(() => cleanup()); @@ -96,3 +102,134 @@ describe("Billing page rendering", () => { expect(screen.getByText("Not yet opened")).toBeInTheDocument(); }); }); + +describe("Billing page Usage tab", () => { + // Overview data isn't under test here — give it a boring 404/zeroed + // response so it loads without erroring underneath the Usage tab. + function stubOverview() { + billingApi.getSubscription.mockImplementation(notFound); + billingApi.getUsageLimits.mockImplementation(notFound); + billingApi.getBillingSummary.mockResolvedValue({ + current_period: null, + current_usage_cost: "0", + currency: "usd", + invoice_count: 0, + outstanding_amount: "0", + }); + } + + async function openUsageTab() { + render(); + await waitFor(() => expect(screen.getByText(/No active subscription is on record/)).toBeInTheDocument()); + fireEvent.click(screen.getByText("Usage")); + await waitFor(() => expect(billingApi.getUsageSummary).toHaveBeenCalled()); + } + + it("shows the honest empty state in every section when the org has no usage in the window", async () => { + stubOverview(); + billingApi.getUsageSummary.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", services: [], + }); + billingApi.getCostHistory.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", currency: "usd", history: [], + }); + billingApi.getCostBreakdown.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", + group_by: "service", currency: "usd", breakdown: {}, + }); + + await openUsageTab(); + + const emptyMessages = await screen.findAllByText("No usage recorded for this period."); + expect(emptyMessages).toHaveLength(3); + }); + + it("renders raw usage, daily cost, and cost-by-service rows from the API", async () => { + stubOverview(); + billingApi.getUsageSummary.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", + services: [{ service: "rag", action: "query", resource: "rag.query", unit: "call", quantity: 250 }], + }); + billingApi.getCostHistory.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", currency: "usd", + history: [{ date: "2026-09-01", cost: "3.25" }], + }); + billingApi.getCostBreakdown.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", + group_by: "service", currency: "usd", breakdown: { rag: { quantity: 250, cost: "3.25" } }, + }); + + await openUsageTab(); + + await waitFor(() => expect(screen.getByText("rag.query")).toBeInTheDocument()); + expect(screen.getByText("250 call")).toBeInTheDocument(); // usage-by-service quantity + unit + expect(screen.getByText("2026-09-01")).toBeInTheDocument(); // daily cost date + expect(screen.getAllByText("3.25 USD")).toHaveLength(2); // daily-cost row + cost-by-service row + expect(screen.getAllByText("rag")).toHaveLength(2); // usage table's service column + breakdown table's group column + }); + + it("surfaces an error when a usage endpoint call fails, without blocking the other sections", async () => { + stubOverview(); + billingApi.getUsageSummary.mockRejectedValue(new Error("usage-service unreachable")); + billingApi.getCostHistory.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", currency: "usd", + history: [{ date: "2026-09-01", cost: "3.25" }], + }); + billingApi.getCostBreakdown.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", + group_by: "service", currency: "usd", breakdown: {}, + }); + + await openUsageTab(); + + await waitFor(() => expect(screen.getByText("usage-service unreachable")).toBeInTheDocument()); + // cost-history still rendered even though the usage-summary call failed + expect(screen.getByText("2026-09-01")).toBeInTheDocument(); + }); + + it("surfaces an error from cost-history alone, leaving usage and cost-breakdown rendered", async () => { + stubOverview(); + billingApi.getUsageSummary.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", + services: [{ service: "rag", action: "query", resource: "rag.query", unit: "call", quantity: 250 }], + }); + billingApi.getCostHistory.mockRejectedValue(new Error("cost-history unreachable")); + billingApi.getCostBreakdown.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", + group_by: "service", currency: "usd", breakdown: {}, + }); + + await openUsageTab(); + + await waitFor(() => expect(screen.getByText("cost-history unreachable")).toBeInTheDocument()); + expect(screen.getByText("rag.query")).toBeInTheDocument(); + }); + + it("surfaces an error from cost-breakdown alone, leaving usage and cost-history rendered", async () => { + stubOverview(); + billingApi.getUsageSummary.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", services: [], + }); + billingApi.getCostHistory.mockResolvedValue({ + organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", currency: "usd", + history: [{ date: "2026-09-01", cost: "3.25" }], + }); + billingApi.getCostBreakdown.mockRejectedValue(new Error("cost-breakdown unreachable")); + + await openUsageTab(); + + await waitFor(() => expect(screen.getByText("cost-breakdown unreachable")).toBeInTheDocument()); + expect(screen.getByText("2026-09-01")).toBeInTheDocument(); + }); + + it("does not call getUsageEvents — the per-user log is a deliberate exclusion from this pass", async () => { + stubOverview(); + billingApi.getUsageSummary.mockResolvedValue({ organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", services: [] }); + billingApi.getCostHistory.mockResolvedValue({ organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", currency: "usd", history: [] }); + billingApi.getCostBreakdown.mockResolvedValue({ organization_id: 42, period_start: "2026-08-06", period_end: "2026-09-04", group_by: "service", currency: "usd", breakdown: {} }); + + await openUsageTab(); + + expect(billingApi.getUsageEvents).toBeUndefined(); + }); +});