Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/ui/lib/billingApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
169 changes: 166 additions & 3 deletions src/ui/pages/Billing.jsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -159,6 +179,22 @@ function BillingSummary({ orgId }) {
read-only view of organization #{orgId}’s current billing state
</div>
</div>
</div>

<Tabs
tabs={[
{ key: "overview", label: "Overview", content: <BillingOverview load={load} error={error} hasSub={hasSub} subscription={subscription} limitRows={limitRows} summary={summary} /> },
{ key: "usage", label: "Usage", content: <UsageTab orgId={orgId} /> },
]}
/>
</div>
);
}

function BillingOverview({ load, error, hasSub, subscription, limitRows, summary }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<Button variant="secondary" size="sm" onClick={load}>Refresh</Button>
</div>

Expand Down Expand Up @@ -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 (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: 24, color: "var(--color-text-muted)" }}>
<Spinner size="sm" /> Loading usage…
</div>
);
}

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 (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<span style={{ ...valueStyle, color: "var(--color-text-muted)", fontFamily: "var(--mono)" }}>
{formatDate(startDate)} → {formatDate(endDate)} (trailing 30 days)
</span>
<Button variant="secondary" size="sm" onClick={load}>Refresh</Button>
</div>

{error && <Badge variant="danger">{error}</Badge>}

{/* ── Raw usage by service/action/resource ────────────────────────── */}
<Card elevated>
<div style={sectionTitleStyle}>Usage by service</div>
<Table
columns={[
{ key: "service", label: "Service", sortable: true },
{ key: "action", label: "Action", sortable: true },
{ key: "resource", label: "Resource", sortable: true },
{ key: "quantity", label: "Quantity", sortable: true, align: "right", render: (v, row) => `${Number(v)} ${row.unit}` },
]}
data={services}
emptyMessage={EMPTY_USAGE_MESSAGE}
/>
</Card>

{/* ── Daily cost over the window ───────────────────────────────────── */}
<Card elevated>
<div style={sectionTitleStyle}>Daily cost</div>
<Table
columns={[
{ key: "date", label: "Date", sortable: true, render: (v) => formatDate(v) },
{ key: "cost", label: "Cost", sortable: true, align: "right", render: (v) => money(v, costHistory?.currency) },
]}
data={historyPoints}
emptyMessage={EMPTY_USAGE_MESSAGE}
/>
</Card>

{/* ── Cost grouped by service ──────────────────────────────────────── */}
<Card elevated>
<div style={sectionTitleStyle}>Cost by service</div>
<Table
columns={[
{ key: "group", label: "Service", sortable: true },
{ key: "quantity", label: "Quantity", sortable: true, align: "right", render: (v) => Number(v) },
{ key: "cost", label: "Cost", sortable: true, align: "right", render: (v) => money(v, costBreakdown?.currency) },
]}
data={breakdownRows}
emptyMessage={EMPTY_USAGE_MESSAGE}
/>
</Card>

<div style={{ fontSize: "var(--font-size-xs)", color: "var(--color-text-muted)", fontFamily: "var(--mono)", lineHeight: 1.6 }}>
Aggregate usage and cost only. Per-user activity (who did what) is
deliberately not shown here — see billingApi.js for why.
</div>
</div>
);
}

function Field({ label, value }) {
return (
<div>
Expand Down
Loading
Loading