From 9cbe7a86c8283d82fc969cfe68773d51d0e3f213 Mon Sep 17 00:00:00 2001 From: abdulwahabmonilola-ctrl <301061378+abdulwahabmonilola-ctrl@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:31:10 +0100 Subject: [PATCH] feat(billing): add usage time series, admin report, and billing docs Closes #449 Closes #480 Closes #487 - Add daily/weekly usage time-series aggregation and user/workspace APIs. - Add the admin usage report page with date/scope filters and bounded paging. - Document quotas, alerts, invoices, time series, and every billing setting. - Repair the merged invoice export module/tests and make PDF export an explicit fpdf2 dependency. - Reset the process-local marketplace fallback cache between tests. --- .env.example | 8 +- README.md | 8 +- app/admin/routes.py | 118 +++++++++++++- app/billing/routes.py | 42 +++++ app/config.py | 3 + app/services/invoice_export.py | 184 +++++++++++---------- app/services/usage_dashboards.py | 138 +++++++++++++++- app/templates/admin/usage_report.html | 133 +++++++++++++++ app/templates/base.html | 3 + docs/billing.md | 95 +++++++++++ requirements.txt | 4 + tests/conftest.py | 15 ++ tests/test_admin_usage_report.py | 208 ++++++++++++++++++++++++ tests/test_invoice_export.py | 61 ++++--- tests/test_usage_timeseries.py | 224 ++++++++++++++++++++++++++ 15 files changed, 1123 insertions(+), 121 deletions(-) create mode 100644 app/templates/admin/usage_report.html create mode 100644 tests/test_admin_usage_report.py create mode 100644 tests/test_usage_timeseries.py diff --git a/.env.example b/.env.example index 4b61c595..689e0ccd 100644 --- a/.env.example +++ b/.env.example @@ -272,14 +272,18 @@ RATE_LIMIT_AI_ENABLED=1 # are updated from the usage ledger, and label cardinality is capped. METRICS_ENABLED=1 METRICS_TOKEN= -# Optional SMTP for invitation emails. When unset, invitations are delivered as -# in-app notifications only (never crashes a request on mail failure). +# Optional SMTP for invitation and budget-alert emails. When unset, invitations +# and alerts are delivered as in-app notifications only (never crashes a request +# on mail failure). BILLING_ALERT_EMAIL is the recipient for over-budget alerts; +# in-app budget alerts are always created regardless of this setting. SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= MAIL_DEFAULT_SENDER= MAIL_USE_TLS=1 +# Recipient for over-budget email alerts (best-effort; empty disables email). +BILLING_ALERT_EMAIL= # Stellar / Soroban integration (Phase 8). # Default network used by the Stellar service: mainnet | testnet | futurenet | custom. diff --git a/README.md b/README.md index afca757c..b4bfde6e 100644 --- a/README.md +++ b/README.md @@ -98,15 +98,19 @@ from a documented price table, not provider invoices. | `USAGE_RETENTION_DAYS` | `365` | Minimum age before `flask usage purge` may delete ledger rows. | | `BUDGET_ENFORCEMENT_ENABLED` | `1` | Refuses LLM calls that would exceed a monthly token/cost budget (fail closed). | | `BUDGET_DEFAULT_WARN_THRESHOLD` | `0.8` | Default warning fraction for new budgets. | +| `BILLING_ALERT_EMAIL` | empty | Recipient for best-effort over-budget alert email; empty disables email. | | `BILLING_IMPORT_MAX_BYTES` | `1048576` | Maximum provider billing CSV upload size. | | `BILLING_IMPORT_MAX_ROWS` | `5000` | Maximum billing CSV rows. | +| `USAGE_EXPORT_CSV_MAX_ROWS` | `5000` | Hard row cap for CSV exports; clipped exports advertise truncation. | | `CURRENCY_CODE` | `USD` | Currency code shown on every cost figure. | +| `CURRENCY_SYMBOL` | `$` | Display symbol paired with the currency code. | +| `CURRENCY_DECIMAL_PLACES` | `4` | Display precision for estimates (stored costs use six decimals). | | `LLM_MODEL` | empty | Pre-flight estimate model; when set, the price table is used. | | `LLM_PRICE_TABLE` | empty | Optional JSON provider/model price overlay; invalid values log and fall back safely. | See [Observability](docs/observability.md) for the ledger, metrics, dashboards, -and export contract, and [Billing](docs/billing.md) for the price table, -budgets, and reconciliation. +time series, and export contract, and [Billing](docs/billing.md) for the price +table, quotas, budgets, alerts, invoices, and reconciliation. ## Documentation diff --git a/app/admin/routes.py b/app/admin/routes.py index 3b2301f0..395c93e6 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -6,18 +6,25 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta from functools import wraps -from flask import Response, jsonify, request +from flask import Response, jsonify, render_template, request from flask_login import current_user, login_required from app.admin import bp -from app.models import AuditLog, User -from app.services import usage_dashboards +from app.models import AuditLog, UsageRecord, User, Workspace +from app.services import usage_dashboards, usage_ledger +from app.services.currency import get_currency_config #: Hard cap on page size so an admin cannot force an unbounded scan. MAX_PER_PAGE = 200 +#: Bounds for the detailed usage report table (#480). +REPORT_DEFAULT_PER_PAGE = 50 +REPORT_MAX_PER_PAGE = 100 +REPORT_SCOPES = ("all", "user", "workspace") + def admin_required(view): """Reject non-admin callers with a 403 JSON error.""" @@ -97,6 +104,111 @@ def usage(): return jsonify(usage_dashboards.admin_dashboard(days=days)) +@bp.route("/usage/report") +@login_required +@admin_required +def usage_report(): + """Render the admin usage report page with filters and a bounded table. + + Query parameters: + ``days`` date window, 1-365 days (default 30). + ``scope`` ``all``, ``user`` or ``workspace``. + ``user_id`` required when ``scope=user``. + ``workspace_id`` required when ``scope=workspace``. + ``source`` / ``provider`` / ``model`` optional exact filters. + ``page`` / ``per_page`` bounded pagination (``per_page`` capped at + :data:`REPORT_MAX_PER_PAGE`). + + All figures come from stored :class:`~app.models.UsageRecord` rows. The + page never reads prompts or completions. + """ + try: + days = usage_dashboards.normalize_days(request.args.get("days")) + except ValueError as exc: + return jsonify({"error": str(exc), "kind": "invalid"}), 400 + + scope = (request.args.get("scope") or "all").strip().lower() + if scope not in REPORT_SCOPES: + return jsonify({"error": "scope must be all, user or workspace.", "kind": "invalid"}), 400 + + user_id = request.args.get("user_id", type=int) + workspace_id = request.args.get("workspace_id", type=int) + filters = { + "source": usage_dashboards.normalize_filter(request.args.get("source")), + "provider": usage_dashboards.normalize_filter(request.args.get("provider")), + "model": usage_dashboards.normalize_filter(request.args.get("model")), + } + + current = datetime.now(UTC) + query = UsageRecord.query.filter( + UsageRecord.created_at >= current - timedelta(days=days), + UsageRecord.created_at <= current, + ) + if scope == "user": + # An unknown or missing id yields an empty report rather than all rows. + query = query.filter(UsageRecord.user_id == (user_id if user_id is not None else -1)) + elif scope == "workspace": + query = query.filter( + UsageRecord.workspace_id == (workspace_id if workspace_id is not None else -1) + ) + if filters["source"]: + query = query.filter(UsageRecord.source == filters["source"]) + if filters["provider"]: + query = query.filter(UsageRecord.provider == filters["provider"]) + if filters["model"]: + query = query.filter(UsageRecord.model == filters["model"]) + + summary = usage_ledger.summarize_query(query) + page = max(1, request.args.get("page", 1, type=int) or 1) + per_page = min( + max( + 1, + request.args.get("per_page", REPORT_DEFAULT_PER_PAGE, type=int) + or REPORT_DEFAULT_PER_PAGE, + ), + REPORT_MAX_PER_PAGE, + ) + pagination = query.order_by(UsageRecord.created_at.desc(), UsageRecord.id.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + rows = pagination.items + user_ids = {row.user_id for row in rows if row.user_id is not None} + workspace_ids = {row.workspace_id for row in rows if row.workspace_id is not None} + users_by_id = ( + {user.id: user for user in User.query.filter(User.id.in_(user_ids)).all()} + if user_ids + else {} + ) + workspaces_by_id = ( + { + workspace.id: workspace + for workspace in Workspace.query.filter(Workspace.id.in_(workspace_ids)).all() + } + if workspace_ids + else {} + ) + + return render_template( + "admin/usage_report.html", + days=days, + scope=scope, + user_id=user_id, + workspace_id=workspace_id, + filters=filters, + summary=summary, + rows=rows, + users_by_id=users_by_id, + workspaces_by_id=workspaces_by_id, + pagination=pagination, + per_page=per_page, + users=User.query.order_by(User.username, User.id).all(), + workspaces=Workspace.query.order_by(Workspace.name, Workspace.id).all(), + available_filters=usage_dashboards.available_filters(days=days, now=current), + currency=get_currency_config(), + ) + + @bp.route("/usage/export.csv") @login_required @admin_required diff --git a/app/billing/routes.py b/app/billing/routes.py index a5718afa..c5ffe905 100644 --- a/app/billing/routes.py +++ b/app/billing/routes.py @@ -273,6 +273,14 @@ def _parse_days(): return None, (jsonify({"error": str(exc), "kind": "invalid"}), 400) +def _parse_bucket(): + """Parse ``bucket`` and return ``(bucket, error_response)``.""" + try: + return usage_dashboards.normalize_bucket(request.args.get("bucket")), None + except ValueError as exc: + return None, (jsonify({"error": str(exc), "kind": "invalid"}), 400) + + def _parse_filters() -> dict: """Read the optional source/provider/model filters from the query string.""" return { @@ -335,6 +343,22 @@ def api_usage_dashboard(): return jsonify(usage_dashboards.dashboard(user_id=current_user.id, days=days, **filters)) +@bp.route("/api/usage/timeseries") +@login_required +def api_usage_timeseries(): + """Return the signed-in user's usage and cost as daily/weekly buckets (#449).""" + days, error = _parse_days() + if error is not None: + return error + bucket, error = _parse_bucket() + if error is not None: + return error + filters = _parse_filters() + return jsonify( + usage_dashboards.timeseries(user_id=current_user.id, days=days, bucket=bucket, **filters) + ) + + @bp.route("/api/usage/dashboard.csv") @login_required def api_usage_dashboard_csv(): @@ -360,6 +384,24 @@ def api_workspace_usage(workspace_id: int): return jsonify(usage_dashboards.dashboard(workspace_id=workspace_id, days=days, **filters)) +@bp.route("/api/workspaces//usage/timeseries") +@login_required +def api_workspace_timeseries(workspace_id: int): + """Return a workspace time series for its owner or a site administrator.""" + if not can_access_workspace_billing(workspace_id, current_user): + return _dashboard_refusal() + days, error = _parse_days() + if error is not None: + return error + bucket, error = _parse_bucket() + if error is not None: + return error + filters = _parse_filters() + return jsonify( + usage_dashboards.timeseries(workspace_id=workspace_id, days=days, bucket=bucket, **filters) + ) + + @bp.route("/api/workspaces//usage.csv") @login_required def api_workspace_usage_csv(workspace_id: int): diff --git a/app/config.py b/app/config.py index ed14669c..5ab88266 100644 --- a/app/config.py +++ b/app/config.py @@ -133,6 +133,9 @@ class Config: # spend. The default warning threshold is applied to new budgets. BUDGET_ENFORCEMENT_ENABLED = os.getenv("BUDGET_ENFORCEMENT_ENABLED", "1") == "1" BUDGET_DEFAULT_WARN_THRESHOLD = float(os.getenv("BUDGET_DEFAULT_WARN_THRESHOLD", "0.8")) + # Optional recipient for over-budget alert email. In-app notifications are + # always created; email is best-effort and requires SMTP to be configured. + BILLING_ALERT_EMAIL = os.getenv("BILLING_ALERT_EMAIL", "") # Display currency for every cost figure (issue #441). Defaults to USD; the # code is always shown next to the symbol so a figure is never ambiguous. # Operators that change the code are responsible for keeping the configured diff --git a/app/services/invoice_export.py b/app/services/invoice_export.py index 6b5dcc54..4c888ae0 100644 --- a/app/services/invoice_export.py +++ b/app/services/invoice_export.py @@ -1,4 +1,5 @@ """Invoice export helpers (CSV and PDF) with bounded output.""" + import csv import io from decimal import Decimal @@ -13,84 +14,105 @@ class ExportTooLargeError(ValueError): """Raised when an export exceeds the configured bounds.""" - def _safe(value): - text = "" if value is None else str(value)[:MAX_TEXT_LEN] - if text.startswith(_FORMULA_PREFIXES): - text = "'" + text - return text - - - def _money(value): - return f"{Decimal(str(value)):.2f}" - - - def _rows(invoice): - items = invoice.get("items") or [] - if len(items) > MAX_LINE_ITEMS: - raise ExportTooLargeError( - f"Invoice has {len(items)} items; limit is {MAX_LINE_ITEMS}" - ) - rows = [] - for item in items: - qty = Decimal(str(item.get("quantity", 0))) - price = Decimal(str(item.get("unit_price", 0))) - rows.append((_safe(item.get("description")), qty, price, qty * price)) - return rows - - - def _check_size(data): - if len(data) > MAX_BYTES: - raise ExportTooLargeError(f"Export exceeds {MAX_BYTES} bytes") - return data - - - def export_invoice_csv(invoice): - number = _safe(invoice.get("number")) - currency = _safe(invoice.get("currency")) - buf = io.StringIO() - writer = csv.writer(buf) - writer.writerow( - ["invoice_number", "currency", "description", - "quantity", "unit_price", "line_total"] - ) - total = Decimal("0") - for desc, qty, price, line in _rows(invoice): - total += line - writer.writerow([number, currency, desc, qty, _money(price), _money(line)]) - writer.writerow([number, currency, "TOTAL", "", "", _money(total)]) - return _check_size(buf.getvalue().encode("utf-8")) - - - def _latin(text): - return text.encode("latin-1", "replace").decode("latin-1") - - - def export_invoice_pdf(invoice): - from fpdf import FPDF - - rows = _rows(invoice) - pdf = FPDF() - pdf.add_page() - pdf.set_font("Helvetica", "B", 16) - pdf.cell(0, 10, _latin(f"Invoice {_safe(invoice.get('number'))}"), - new_x="LMARGIN", new_y="NEXT") - pdf.set_font("Helvetica", size=10) - pdf.cell(0, 8, _latin(f"Currency: {_safe(invoice.get('currency'))}"), - new_x="LMARGIN", new_y="NEXT") - pdf.ln(4) - for head, width in (("Description", 90), ("Qty", 20), - ("Unit price", 35), ("Total", 35)): - pdf.cell(width, 8, head, border=1) - pdf.ln() - total = Decimal("0") - for desc, qty, price, line in rows: - total += line - pdf.cell(90, 8, _latin(desc[:45]), border=1) - pdf.cell(20, 8, str(qty), border=1) - pdf.cell(35, 8, _money(price), border=1) - pdf.cell(35, 8, _money(line), border=1) - pdf.ln() - pdf.set_font("Helvetica", "B", 11) - pdf.cell(145, 8, "TOTAL", border=1) - pdf.cell(35, 8, _money(total), border=1) - return _check_size(bytes(pdf.output())) \ No newline at end of file +def _safe(value): + """Return a length-bounded string, neutralising spreadsheet formulas.""" + text = "" if value is None else str(value)[:MAX_TEXT_LEN] + if text.startswith(_FORMULA_PREFIXES): + text = "'" + text + return text + + +def _money(value): + return f"{Decimal(str(value)):.2f}" + + +def _rows(invoice): + items = invoice.get("items") or [] + if len(items) > MAX_LINE_ITEMS: + raise ExportTooLargeError(f"Invoice has {len(items)} items; limit is {MAX_LINE_ITEMS}") + rows = [] + for item in items: + qty = Decimal(str(item.get("quantity", 0))) + price = Decimal(str(item.get("unit_price", 0))) + rows.append((_safe(item.get("description")), qty, price, qty * price)) + return rows + + +def _check_size(data): + if len(data) > MAX_BYTES: + raise ExportTooLargeError(f"Export exceeds {MAX_BYTES} bytes") + return data + + +def export_invoice_csv(invoice): + """Return the invoice as UTF-8 CSV bytes, bounded by the module limits.""" + number = _safe(invoice.get("number")) + currency = _safe(invoice.get("currency")) + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow( + [ + "invoice_number", + "currency", + "description", + "quantity", + "unit_price", + "line_total", + ] + ) + total = Decimal("0") + for desc, qty, price, line in _rows(invoice): + total += line + writer.writerow([number, currency, desc, qty, _money(price), _money(line)]) + writer.writerow([number, currency, "TOTAL", "", "", _money(total)]) + return _check_size(buf.getvalue().encode("utf-8")) + + +def _latin(text): + return text.encode("latin-1", "replace").decode("latin-1") + + +def export_invoice_pdf(invoice): + """Return the invoice as PDF bytes, bounded by the module limits.""" + from fpdf import FPDF + + rows = _rows(invoice) + pdf = FPDF() + pdf.add_page() + pdf.set_font("Helvetica", "B", 16) + pdf.cell( + 0, + 10, + _latin(f"Invoice {_safe(invoice.get('number'))}"), + new_x="LMARGIN", + new_y="NEXT", + ) + pdf.set_font("Helvetica", size=10) + pdf.cell( + 0, + 8, + _latin(f"Currency: {_safe(invoice.get('currency'))}"), + new_x="LMARGIN", + new_y="NEXT", + ) + pdf.ln(4) + for head, width in ( + ("Description", 90), + ("Qty", 20), + ("Unit price", 35), + ("Total", 35), + ): + pdf.cell(width, 8, head, border=1) + pdf.ln() + total = Decimal("0") + for desc, qty, price, line in rows: + total += line + pdf.cell(90, 8, _latin(desc[:45]), border=1) + pdf.cell(20, 8, str(qty), border=1) + pdf.cell(35, 8, _money(price), border=1) + pdf.cell(35, 8, _money(line), border=1) + pdf.ln() + pdf.set_font("Helvetica", "B", 11) + pdf.cell(145, 8, "TOTAL", border=1) + pdf.cell(35, 8, _money(total), border=1) + return _check_size(bytes(pdf.output())) diff --git a/app/services/usage_dashboards.py b/app/services/usage_dashboards.py index dab3822a..fceb1835 100644 --- a/app/services/usage_dashboards.py +++ b/app/services/usage_dashboards.py @@ -23,6 +23,13 @@ DEFAULT_WINDOW_DAYS = 30 MAX_WINDOW_DAYS = 365 +#: Supported time-series bucket sizes (#449). Daily buckets start at midnight +#: UTC; weekly buckets start on Monday UTC and end on Sunday. +BUCKET_DAY = "day" +BUCKET_WEEK = "week" +DEFAULT_BUCKET = BUCKET_DAY +VALID_BUCKETS = (BUCKET_DAY, BUCKET_WEEK) + #: Hard cap on CSV rows and bytes, so an export can never stream unbounded data. MAX_CSV_ROWS = 5000 MAX_CSV_BYTES = 2 * 1024 * 1024 @@ -61,6 +68,22 @@ def normalize_days(value, default: int = DEFAULT_WINDOW_DAYS) -> int: return days +def normalize_bucket(value) -> str: + """Coerce a query-string bucket into :data:`VALID_BUCKETS`. + + A blank value means the default (daily) bucket. + + Raises: + ValueError: If the value is not ``day`` or ``week``. + """ + if value is None or value == "": + return DEFAULT_BUCKET + bucket = str(value).strip().lower() + if bucket not in VALID_BUCKETS: + raise ValueError("bucket must be 'day' or 'week'.") + return bucket + + def _window_start(days: int, now: datetime | None) -> datetime: current = now or datetime.now(UTC) return current - timedelta(days=days) @@ -80,13 +103,21 @@ def query_records( workspace_id: int | None = None, days: int = DEFAULT_WINDOW_DAYS, now: datetime | None = None, + until: datetime | None = None, source: str | None = None, provider: str | None = None, model: str | None = None, ) -> list[UsageRecord]: - """Return ledger rows inside the window, in deterministic order.""" + """Return ledger rows inside the window, in deterministic order. + + ``until`` optionally adds an inclusive upper bound. Dashboards omit it; the + time-series endpoint passes the same ``now`` value so a row dated in the + future can never leak into the selected range. + """ since = _window_start(days, now) query = UsageRecord.query.filter(UsageRecord.created_at >= since) + if until is not None: + query = query.filter(UsageRecord.created_at <= until) if user_id is not None: query = query.filter(UsageRecord.user_id == user_id) if workspace_id is not None: @@ -226,6 +257,105 @@ def admin_dashboard(*, days: int = DEFAULT_WINDOW_DAYS, now: datetime | None = N return payload +def _bucket_start(moment, bucket: str): + """Return the calendar start date for the bucket containing ``moment``.""" + day = moment.date() if isinstance(moment, datetime) else moment + if bucket == BUCKET_WEEK: + return day - timedelta(days=day.weekday()) + return day + + +def _bucket_dates(start, end, bucket: str): + """Yield every bucket start between ``start`` and ``end`` inclusive.""" + step = timedelta(days=7 if bucket == BUCKET_WEEK else 1) + cursor = start + while cursor <= end: + yield cursor + cursor += step + + +def _bucket_end(start, bucket: str): + """Return the inclusive calendar end date for a bucket.""" + return start + timedelta(days=6 if bucket == BUCKET_WEEK else 0) + + +def timeseries( + *, + user_id: int | None = None, + workspace_id: int | None = None, + days: int = DEFAULT_WINDOW_DAYS, + bucket: str = DEFAULT_BUCKET, + now: datetime | None = None, + source: str | None = None, + provider: str | None = None, + model: str | None = None, +) -> dict: + """Aggregate usage and cost into bounded daily or weekly buckets (#449). + + The range is ``[now - days, now]``: both ends are inclusive, so a partial + first/last day or week contains only rows inside the requested window. Every + bucket intersecting the range is returned, including empty buckets, which + keeps the series stable for charts. Aggregation is computed strictly from + stored :class:`~app.models.UsageRecord` rows and the same source/provider/ + model filters as :func:`dashboard`. + """ + bucket = normalize_bucket(bucket) + filters = { + "source": normalize_filter(source), + "provider": normalize_filter(provider), + "model": normalize_filter(model), + } + current = now or datetime.now(UTC) + since = _window_start(days, current) + records = query_records( + user_id=user_id, + workspace_id=workspace_id, + days=days, + now=current, + until=current, + **filters, + ) + + rows: dict = {} + first = _bucket_start(since, bucket) + last = _bucket_start(current, bucket) + for key in _bucket_dates(first, last, bucket): + rows[key] = [] + for record in records: + created = getattr(record, "created_at", None) + if created is None: + continue + rows.setdefault(_bucket_start(created, bucket), []).append(record) + + buckets = [ + { + "start": start.isoformat(), + "end": _bucket_end(start, bucket).isoformat(), + "totals": usage_ledger.summarize_records(rows[start]), + } + for start in sorted(rows) + ] + return { + "bucket": bucket, + "range": { + "start": since.isoformat(), + "end": current.isoformat(), + "days": days, + }, + "currency": get_currency_config(), + "cost_is_estimate": True, + "totals": usage_ledger.summarize_records(records), + "buckets": buckets, + "filters": filters, + "scope": { + "type": "workspace" if workspace_id is not None else "user", + "user_id": user_id, + "workspace_id": workspace_id, + "days": days, + }, + } + + def export_csv( *, user_id: int | None = None, @@ -294,16 +424,22 @@ def export_csv( __all__ = [ + "BUCKET_DAY", + "BUCKET_WEEK", "CSV_COLUMNS", + "DEFAULT_BUCKET", "DEFAULT_WINDOW_DAYS", "MAX_CSV_BYTES", "MAX_CSV_ROWS", "MAX_WINDOW_DAYS", + "VALID_BUCKETS", "admin_dashboard", "available_filters", "dashboard", "export_csv", + "normalize_bucket", "normalize_days", "normalize_filter", "query_records", + "timeseries", ] diff --git a/app/templates/admin/usage_report.html b/app/templates/admin/usage_report.html new file mode 100644 index 00000000..b8b9404a --- /dev/null +++ b/app/templates/admin/usage_report.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} + +{% block title %}Admin usage report - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +

Admin usage report

+

+ Detailed usage rows for every stored ledger entry in the selected window. Cost + is an estimate derived from the configured price table; rows + without a known price are excluded from the cost total rather than treated as + zero. Prompts and completions are never read. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

Filtered totals

+
+
Rows{{ summary['count'] }}
+
Prompt tokens{{ summary.prompt_tokens }}
+
Completion tokens{{ summary.completion_tokens }}
+
Total tokens{{ summary.total_tokens }}
+
Estimated cost{{ currency.symbol }}{{ '%.6f'|format(summary.cost_estimate) }}
+
+ +
+ +
+

Usage rows

+ {% if rows %} + + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + + {% endfor %} + +
DateUserWorkspaceSourceProviderModelStatusTokensEstimated cost
{% if row.created_at %}{{ row.created_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}{% if row.user_id in users_by_id %}{{ users_by_id[row.user_id].username }}{% elif row.user_id %}user {{ row.user_id }}{% else %}—{% endif %}{% if row.workspace_id in workspaces_by_id %}{{ workspaces_by_id[row.workspace_id].name }}{% elif row.workspace_id %}workspace {{ row.workspace_id }}{% else %}—{% endif %}{{ row.source or 'unknown' }}{{ row.provider or 'unknown' }}{{ row.model or 'unknown' }}{{ row.status or 'unknown' }}{{ row.total_tokens or 0 }}{% if row.cost_estimate is none %}—{% else %}{{ currency.symbol }}{{ '%.6f'|format(row.cost_estimate) }}{% endif %}
+ + + {% else %} + + {% endif %} +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 4f53e344..a61eab36 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -28,6 +28,9 @@ Workspaces Usage Billing + {% if current_user.is_admin %} + Admin usage + {% endif %} Plugins Stellar Reviews diff --git a/docs/billing.md b/docs/billing.md index c9db6be5..1c59a6ec 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -112,6 +112,48 @@ Implementation: `app/services/budgets.py`, `app/billing/routes.py`; tests: `tests/test_budgets.py`, `tests/test_budget_e2e.py`, `tests/test_budget_overrides.py`, `tests/test_alerts.py`. +## Quotas + +A budget is the application's quota: a monthly ceiling on tokens, estimated cost, +or both, scoped to one user or one workspace. Quotas are **hard** in the sense +that a refused call never reaches the provider; they are not overage billing. + +- The limit applies to rows in the current calendar month and excludes `denied` + rows, so a refused request cannot consume the caller's remaining quota. +- Evaluation happens before every billable path. When a limit would be exceeded + the request returns `429` with `kind: "budget_exceeded"`, `limits_hit`, + `retry_after`, the evaluated budgets, and a `Retry-After` header. +- A missing budget means unlimited usage. Quotas are opt-in per user/workspace. +- Temporary overrides and workspace overrides can raise or lower a limit without + changing historical ledger rows; see `app/services/budget_overrides.py`. +- The budget management API described above reads live usage ratios, so an + operator can see how much of each quota is consumed. + +## Alerts + +Budget alerts are advisory and never block a request. + +| Alert | When it fires | Delivery | +| ----- | ------------- | -------- | +| `warning` | Spend reaches `warn_threshold` (default 80%) of the limit. | In-app notification. | +| `over_budget` | Spend reaches or exceeds 100% of the limit. | In-app notification plus best-effort email. | + +- Each threshold fires at most once per workspace/period/level. The default + de-duplicator is process-local; a restart or a separate worker may therefore + emit the threshold once more. +- Jumping straight past the limit emits both crossed thresholds once. +- Users can opt out of `budget_alerts` notifications; their preference is + honoured before an in-app notification is created. +- Email is sent only when SMTP is configured and `BILLING_ALERT_EMAIL` names a + recipient. Email failure is logged and never changes the response or the + in-app notification. +- A request that crosses the warning threshold still succeeds and adds + `X-Budget-Warning: approaching_limit` to its response. + +Implementation: `app/services/budget_alerts.py`, `app/services/token_usage.py`, +`app/services/email.py`; tests: `tests/test_alerts.py`, +`tests/test_budget_alerts.py`, `tests/test_budget_warning_threshold.py`. + ## Provider billing reconciliation `POST /billing/import` accepts a bounded CSV of what a provider actually charged @@ -135,6 +177,54 @@ Implementation: `app/services/billing_reconciliation.py`, `app/models/provider_billing_record.py`; tests: `tests/test_billing_reconciliation.py`. +## Invoices + +Invoices are **drafts** generated on demand from the usage ledger. Nothing is +persisted, and every amount is an estimate rather than a provider-confirmed +charge. + +| Route | Access | Purpose | +| ----- | ------ | ------- | +| `GET /billing/api/invoices?period=YYYY-MM` | signed-in user | Draft invoice for the caller. | +| `GET /billing/api/invoices?period=YYYY-MM&workspace_id=` | workspace owner/admin | Draft invoice for a workspace. | +| `GET /billing/api/invoices/` | signed-in user | Same document using a path period. | + +Query parameters: + +- `period` (required) — a `YYYY-MM` calendar month. `GET` with a missing or + malformed period returns `400` with `kind: "period"` or `kind: "invalid"`. +- `workspace_id` (optional) — scopes the invoice to a workspace the caller owns + or administers; an inaccessible workspace returns `404`. + +The response contains `scope`, `scope_id`, `period`, `draft: true`, +`line_items`, and `totals`. Each line item aggregates one `(provider, model)` +pair with record count, token counts, and an estimated cost. Unknown provider or +model labels are preserved as `"unknown"` so historical rows are not dropped. +`cost_estimate` is `null` when no contributing row has a known price, and +`cost_is_estimate` is always `true`. + +Implementation: `app/services/invoices.py`, `app/billing/routes.py`; tests: +`tests/test_invoices.py`, `tests/test_invoice_export.py`. + +## Usage time series + +`GET /billing/api/usage/timeseries` returns the signed-in user's usage and cost +grouped into bounded buckets. Workspace owners/admins can use +`GET /billing/api/workspaces//usage/timeseries`. + +| Query parameter | Default | Meaning | +| --------------- | ------- | ------- | +| `days` | `30` | Inclusive window size, bounded to `1..365`. | +| `bucket` | `day` | `day` or `week`; weekly buckets start Monday UTC and end Sunday UTC. | +| `source` / `provider` / `model` | empty | Exact filters applied before aggregation. | + +The response includes `bucket`, `range` (`start`, `end`, `days`), `totals`, and +a `buckets` array. Each bucket has an inclusive `start`/`end` date and the same +totals shape as the dashboards. Every bucket intersecting the requested range is +returned, including empty ones, so a chart has a stable shape. Buckets are +computed strictly from stored `UsageRecord` rows and rows outside +`[now - days, now]` are excluded, including future-dated rows. + ## Recomputing historical costs When a price or workspace override changes, recompute stored estimates with the @@ -169,9 +259,14 @@ workspace, and org-wide) are bounded to 5,000 rows / 2 MiB and set | `LLM_PRICE_TABLE` | empty | Optional JSON overlay for provider/model prices. Invalid configuration logs an error, sets `PRICE_TABLE_ERROR`, and falls back to defaults. | | `BUDGET_ENFORCEMENT_ENABLED` | `1` | Evaluates budgets before LLM calls; disable only for local debugging. | | `BUDGET_DEFAULT_WARN_THRESHOLD` | `0.8` | Default warning fraction for new budgets. | +| `BILLING_ALERT_EMAIL` | empty | Recipient for best-effort over-budget alert email; empty disables email. | | `BILLING_IMPORT_MAX_BYTES` | `1048576` | Maximum billing CSV upload size. | | `BILLING_IMPORT_MAX_ROWS` | `5000` | Maximum billing CSV rows. | +| `USAGE_EXPORT_CSV_MAX_ROWS` | `5000` | Hard row cap for CSV exports; truncated exports set `X-Export-Truncated: true`. | | `USAGE_RETENTION_DAYS` | `365` | Ledger retention window for `flask usage purge`. | +| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` | empty / `587` / empty / empty | Optional SMTP transport used for invitations and budget-alert email. | +| `MAIL_DEFAULT_SENDER` | empty | Sender address for outgoing email. | +| `MAIL_USE_TLS` | `1` | Enable STARTTLS for SMTP. | Keep the currency code and the price/estimate rates in the same currency. Changing the code does not convert stored values. diff --git a/requirements.txt b/requirements.txt index 4bff7395..cd9f7c6a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,3 +43,7 @@ simple-websocket==1.1.0 # Production server gunicorn==23.0.0 + +# Invoice PDF export (issue #483). CSV export works without this dependency; +# PDF export imports it lazily. +fpdf2==2.8.9 diff --git a/tests/conftest.py b/tests/conftest.py index 00c7be77..e3b12432 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,6 +68,21 @@ def _reset_github_response_cache(): github_cache.reset() +@pytest.fixture(autouse=True) +def _reset_marketplace_cache(): + """Clear the process-local last-known-good marketplace catalog cache. + + The cache is keyed by source but shared across tests, so a successful remote + load in one test would otherwise be served as a stale fallback in the next + test's failure case. + """ + from app.services import marketplace + + marketplace.reset_marketplace_cache() + yield + marketplace.reset_marketplace_cache() + + @pytest.fixture() def app(): """Create a fresh application instance for each test.""" diff --git a/tests/test_admin_usage_report.py b/tests/test_admin_usage_report.py new file mode 100644 index 00000000..1dbe86da --- /dev/null +++ b/tests/test_admin_usage_report.py @@ -0,0 +1,208 @@ +"""Admin usage report page tests (#480). + +The page is admin-only, filters by date window and scope (user/workspace), and +renders a bounded, paginated table from stored usage rows. +""" + +from datetime import UTC, datetime, timedelta + +from app.extensions import db +from app.models import UsageRecord, User, Workspace + +NOW = datetime.now(UTC) + + +def _user(username, email): + user = User(username=username, email=email) + user.set_password("supersecret123") + db.session.add(user) + db.session.commit() + return user + + +def _admin(username="admin", email="admin@example.com"): + user = _user(username, email) + user.is_admin = True + db.session.commit() + return user + + +def _workspace(owner, name="Report workspace"): + workspace = Workspace(user_id=owner.id, name=name) + db.session.add(workspace) + db.session.commit() + return workspace + + +def _record( + user, + *, + days_ago=0, + workspace=None, + source="chat", + provider="openai", + model="gpt-4o", + total_tokens=15, +): + record = UsageRecord( + user_id=user.id, + workspace_id=workspace.id if workspace else None, + source=source, + provider=provider, + model=model, + prompt_tokens=total_tokens - 5, + completion_tokens=5, + total_tokens=total_tokens, + tokens_estimated=False, + cost_estimate=0.01, + status="success", + created_at=NOW - timedelta(days=days_ago), + ) + db.session.add(record) + db.session.commit() + return record + + +class TestAdminUsageReportAccess: + def test_admin_can_open_the_report(self, client, login): + _admin() + login(email="admin@example.com") + response = client.get("/admin/usage/report") + assert response.status_code == 200 + assert b"Admin usage report" in response.data + assert b"No usage rows match this window and scope." in response.data + + def test_non_admin_is_forbidden(self, client, login): + _user("owner", "owner@example.com") + login(email="owner@example.com") + assert client.get("/admin/usage/report").status_code == 403 + + def test_unauthenticated_is_redirected_to_login(self, client): + assert client.get("/admin/usage/report").status_code == 302 + + def test_admin_navigation_link_is_visible_only_to_admins(self, client, login): + _admin() + _user("owner", "owner@example.com") + + login(email="admin@example.com") + assert b"Admin usage" in client.get("/").data + + login(email="owner@example.com") + assert b"Admin usage" not in client.get("/").data + + +class TestAdminUsageReportFilters: + def test_user_scope_filters_rows_and_totals(self, client, login): + first = _user("first", "first@example.com") + second = _user("second", "second@example.com") + _admin() + _record(first, provider="openai", total_tokens=15) + _record(second, provider="anthropic", total_tokens=30) + login(email="admin@example.com") + + response = client.get(f"/admin/usage/report?days=7&scope=user&user_id={first.id}") + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "first" in html + assert "anthropic" not in html + assert "openai" in html + + def test_workspace_scope_filters_rows(self, client, login): + owner = _user("owner", "owner@example.com") + other = _user("other", "other@example.com") + workspace = _workspace(owner) + _admin() + _record(owner, workspace=workspace, provider="openai") + _record(other, provider="anthropic") + login(email="admin@example.com") + + response = client.get( + f"/admin/usage/report?days=7&scope=workspace&workspace_id={workspace.id}" + ) + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "Report workspace" in html + assert "anthropic" not in html + assert "openai" in html + + def test_date_window_excludes_older_rows(self, client, login): + user = _user("owner", "owner@example.com") + _admin() + _record(user, days_ago=0, provider="openai") + _record(user, days_ago=30, provider="anthropic") + login(email="admin@example.com") + + response = client.get("/admin/usage/report?days=7") + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "openai" in html + assert "anthropic" not in html + + def test_source_filter_narrows_the_table(self, client, login): + user = _user("owner", "owner@example.com") + _admin() + _record(user, source="chat", provider="openai") + _record(user, source="analysis", provider="anthropic") + login(email="admin@example.com") + + response = client.get("/admin/usage/report?days=7&source=analysis") + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "analysis" in html + assert "chat" not in html + assert "anthropic" in html + assert "openai" not in html + + def test_unknown_scope_is_rejected(self, client, login): + _admin() + login(email="admin@example.com") + response = client.get("/admin/usage/report?scope=team") + assert response.status_code == 400 + + def test_missing_scope_id_yields_an_empty_report(self, client, login): + _admin() + _user("owner", "owner@example.com") + login(email="admin@example.com") + response = client.get("/admin/usage/report?scope=user") + assert response.status_code == 200 + assert b"No usage rows match this window and scope." in response.data + + +class TestAdminUsageReportTableBounds: + def test_per_page_is_capped_and_pagination_works(self, client, login): + user = _user("owner", "owner@example.com") + _admin() + for _ in range(3): + _record(user) + login(email="admin@example.com") + + response = client.get("/admin/usage/report?days=7&per_page=1000") + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert 'name="per_page" type="number" min="1" max="100" value="100"' in html + assert "Page 1 of 1" in html + + second = client.get("/admin/usage/report?days=7&per_page=2&page=2") + assert second.status_code == 200 + assert b"Page 2 of 2" in second.data + + def test_filters_persist_in_pagination_links(self, client, login): + user = _user("owner", "owner@example.com") + _admin() + for _ in range(3): + _record(user, source="chat") + login(email="admin@example.com") + + response = client.get( + f"/admin/usage/report?days=7&scope=user&user_id={user.id}&source=chat&per_page=2" + ) + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "source=chat" in html + assert f"user_id={user.id}" in html diff --git a/tests/test_invoice_export.py b/tests/test_invoice_export.py index 80fb0719..1ca548f0 100644 --- a/tests/test_invoice_export.py +++ b/tests/test_invoice_export.py @@ -8,45 +8,42 @@ def make_invoice(n=2, desc="Widget"): return { - "number": "INV-1", - "currency": "USD", - "items": [ - {"description": desc, "quantity": 2, "unit_price": "10.50"} - for _ in range(n) - ], - } + "number": "INV-1", + "currency": "USD", + "items": [{"description": desc, "quantity": 2, "unit_price": "10.50"} for _ in range(n)], + } - def test_csv_content(): - rows = list(csv.reader(io.StringIO(ex.export_invoice_csv(make_invoice()).decode()))) - assert rows[0][0] == "invoice_number" - assert len(rows) == 4 # header + 2 items + total - assert rows[1][5] == "21.00" - assert rows[-1][2] == "TOTAL" - assert rows[-1][5] == "42.00" +def test_csv_content(): + rows = list(csv.reader(io.StringIO(ex.export_invoice_csv(make_invoice()).decode()))) + assert rows[0][0] == "invoice_number" + assert len(rows) == 4 # header + 2 items + total + assert rows[1][5] == "21.00" + assert rows[-1][2] == "TOTAL" + assert rows[-1][5] == "42.00" - def test_csv_blocks_formula_injection(): - data = ex.export_invoice_csv(make_invoice(1, desc="=cmd()")).decode() - assert "'=cmd()" in data +def test_csv_blocks_formula_injection(): + data = ex.export_invoice_csv(make_invoice(1, desc="=cmd()")).decode() + assert "'=cmd()" in data - def test_pdf_is_valid_pdf(): - data = ex.export_invoice_pdf(make_invoice()) - assert data.startswith(b"%PDF") - assert len(data) > 200 +def test_pdf_is_valid_pdf(): + data = ex.export_invoice_pdf(make_invoice()) + assert data.startswith(b"%PDF") + assert len(data) > 200 - def test_item_limit(monkeypatch): - monkeypatch.setattr(ex, "MAX_LINE_ITEMS", 3) - ex.export_invoice_csv(make_invoice(3)) - with pytest.raises(ex.ExportTooLargeError): - ex.export_invoice_csv(make_invoice(4)) - with pytest.raises(ex.ExportTooLargeError): - ex.export_invoice_pdf(make_invoice(4)) +def test_item_limit(monkeypatch): + monkeypatch.setattr(ex, "MAX_LINE_ITEMS", 3) + ex.export_invoice_csv(make_invoice(3)) + with pytest.raises(ex.ExportTooLargeError): + ex.export_invoice_csv(make_invoice(4)) + with pytest.raises(ex.ExportTooLargeError): + ex.export_invoice_pdf(make_invoice(4)) - def test_byte_limit(monkeypatch): - monkeypatch.setattr(ex, "MAX_BYTES", 50) - with pytest.raises(ex.ExportTooLargeError): - ex.export_invoice_csv(make_invoice(5)) \ No newline at end of file +def test_byte_limit(monkeypatch): + monkeypatch.setattr(ex, "MAX_BYTES", 50) + with pytest.raises(ex.ExportTooLargeError): + ex.export_invoice_csv(make_invoice(5)) diff --git a/tests/test_usage_timeseries.py b/tests/test_usage_timeseries.py new file mode 100644 index 00000000..bf57cb86 --- /dev/null +++ b/tests/test_usage_timeseries.py @@ -0,0 +1,224 @@ +"""Usage time-series aggregation tests (#449). + +Buckets are computed strictly from stored usage rows, include empty calendar +periods, and are bounded by the selected window. Daily buckets cover UTC dates; +weekly buckets start on Monday and end on Sunday. +""" + +from datetime import UTC, datetime, timedelta + +from app.extensions import db +from app.models import UsageRecord, User, Workspace, WorkspaceMember +from app.models.workspace_member import ROLE_VIEWER, STATUS_ACTIVE +from app.services import usage_dashboards + +NOW = datetime(2026, 10, 20, 12, 0, tzinfo=UTC) + + +def _user(username, email): + user = User(username=username, email=email) + user.set_password("supersecret123") + db.session.add(user) + db.session.commit() + return user + + +def _record( + user, + *, + days_ago=0, + created_at=None, + workspace=None, + source="chat", + provider="openai", + model="gpt-4o", + prompt_tokens=10, + completion_tokens=5, + cost_estimate=0.01, +): + record = UsageRecord( + user_id=user.id, + workspace_id=workspace.id if workspace else None, + source=source, + provider=provider, + model=model, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + tokens_estimated=False, + cost_estimate=cost_estimate, + status="success", + created_at=created_at or (NOW - timedelta(days=days_ago)), + ) + db.session.add(record) + db.session.commit() + return record + + +def _by_start(payload): + return {row["start"]: row for row in payload["buckets"]} + + +class TestTimeseriesBuckets: + def test_daily_buckets_include_empty_days_and_stored_totals(self, db, make_user): + user = make_user(username="owner", email="owner@example.com") + + _record(user, days_ago=0) + _record(user, days_ago=2, prompt_tokens=20, completion_tokens=10, cost_estimate=0.02) + _record(user, days_ago=20) + + payload = usage_dashboards.timeseries(user_id=user.id, days=7, now=NOW) + + assert payload["bucket"] == "day" + assert payload["totals"]["count"] == 2 + assert payload["totals"]["total_tokens"] == 45 + assert payload["totals"]["cost_estimate"] == 0.03 + + starts = [row["start"] for row in payload["buckets"]] + assert starts == [ + "2026-10-13", + "2026-10-14", + "2026-10-15", + "2026-10-16", + "2026-10-17", + "2026-10-18", + "2026-10-19", + "2026-10-20", + ] + buckets = _by_start(payload) + assert buckets["2026-10-18"]["totals"]["total_tokens"] == 30 + assert buckets["2026-10-20"]["totals"]["total_tokens"] == 15 + assert buckets["2026-10-19"]["totals"]["count"] == 0 + assert buckets["2026-10-20"]["end"] == "2026-10-20" + + def test_weekly_buckets_start_on_monday_and_end_on_sunday(self, db, make_user): + user = make_user(username="owner", email="owner@example.com") + _record(user, days_ago=2) # Sunday, 2026-10-18 + _record(user, days_ago=1) # Monday, 2026-10-19 + + payload = usage_dashboards.timeseries(user_id=user.id, days=7, bucket="week", now=NOW) + + assert payload["bucket"] == "week" + starts = [row["start"] for row in payload["buckets"]] + assert starts == ["2026-10-12", "2026-10-19"] + buckets = _by_start(payload) + assert buckets["2026-10-12"]["end"] == "2026-10-18" + assert buckets["2026-10-19"]["end"] == "2026-10-25" + assert buckets["2026-10-12"]["totals"]["count"] == 1 + assert buckets["2026-10-19"]["totals"]["count"] == 1 + + def test_empty_scope_returns_zeroed_buckets(self, db, make_user): + user = make_user(username="owner", email="owner@example.com") + payload = usage_dashboards.timeseries(user_id=user.id, days=3, now=NOW) + + assert payload["totals"]["count"] == 0 + assert payload["buckets"] + assert all(row["totals"]["total_tokens"] == 0 for row in payload["buckets"]) + + def test_range_is_bounded_and_excludes_future_rows(self, db, make_user): + user = make_user(username="owner", email="owner@example.com") + _record(user, days_ago=20) + _record(user, created_at=NOW + timedelta(days=1)) + + payload = usage_dashboards.timeseries(user_id=user.id, days=7, now=NOW) + + assert payload["totals"]["count"] == 0 + assert len(payload["buckets"]) == 8 + assert payload["range"]["days"] == 7 + + def test_partial_first_bucket_only_contains_rows_inside_the_window(self, db, make_user): + user = make_user(username="owner", email="owner@example.com") + _record(user, created_at=NOW - timedelta(days=7)) + _record(user, created_at=NOW - timedelta(days=8)) + _record(user, days_ago=0) + + payload = usage_dashboards.timeseries(user_id=user.id, days=7, now=NOW) + + buckets = _by_start(payload) + assert buckets["2026-10-13"]["totals"]["count"] == 1 + assert payload["totals"]["count"] == 2 + + def test_filters_apply_before_aggregation(self, db, make_user): + user = make_user(username="owner", email="owner@example.com") + _record(user, days_ago=0, source="chat", provider="openai", model="gpt-4o") + _record( + user, + days_ago=1, + source="analysis", + provider="anthropic", + model="claude", + prompt_tokens=20, + completion_tokens=10, + ) + + payload = usage_dashboards.timeseries(user_id=user.id, days=7, now=NOW, source="analysis") + + assert payload["totals"]["count"] == 1 + assert payload["filters"]["source"] == "analysis" + buckets = _by_start(payload) + assert buckets["2026-10-19"]["totals"]["total_tokens"] == 30 + assert buckets["2026-10-20"]["totals"]["count"] == 0 + + +class TestTimeseriesApi: + def test_owner_sees_only_own_buckets(self, client, make_user, login): + owner = make_user(username="owner", email="owner@example.com") + make_user(username="other", email="other@example.com") + _record(owner, days_ago=0) + login(email="owner@example.com") + + response = client.get("/billing/api/usage/timeseries?days=7&bucket=day") + assert response.status_code == 200 + payload = response.get_json() + assert payload["totals"]["count"] == 1 + assert payload["bucket"] == "day" + + login(email="other@example.com") + response = client.get("/billing/api/usage/timeseries?days=7") + assert response.status_code == 200 + assert response.get_json()["totals"]["count"] == 0 + + def test_invalid_window_and_bucket_are_rejected(self, client, make_user, login): + make_user(username="owner", email="owner@example.com") + login(email="owner@example.com") + + for value in ("0", "366", "junk"): + response = client.get(f"/billing/api/usage/timeseries?days={value}") + assert response.status_code == 400 + + response = client.get("/billing/api/usage/timeseries?bucket=month") + assert response.status_code == 400 + assert response.get_json()["kind"] == "invalid" + + def test_workspace_timeseries_requires_owner_or_admin(self, client, make_user, login): + owner = make_user(username="owner", email="owner@example.com") + member = make_user(username="member", email="member@example.com") + admin = make_user(username="admin", email="admin@example.com") + admin.is_admin = True + db.session.commit() + workspace = Workspace(user_id=owner.id, name="Analytics") + db.session.add(workspace) + db.session.commit() + db.session.add( + WorkspaceMember( + workspace_id=workspace.id, + user_id=member.id, + role=ROLE_VIEWER, + status=STATUS_ACTIVE, + ) + ) + db.session.commit() + _record(owner, workspace=workspace) + + login(email="owner@example.com") + response = client.get(f"/billing/api/workspaces/{workspace.id}/usage/timeseries") + assert response.status_code == 200 + assert response.get_json()["totals"]["count"] == 1 + + login(email="member@example.com") + response = client.get(f"/billing/api/workspaces/{workspace.id}/usage/timeseries") + assert response.status_code == 404 + + login(email="admin@example.com") + response = client.get(f"/billing/api/workspaces/{workspace.id}/usage/timeseries") + assert response.status_code == 200