Platform admin

The cross-tenant view: billing, growth, and who can see it

Everything in Aexy is scoped to a workspace, except this. The platform admin area is the view across every tenant: what the business is billing, whether it is growing, and which workspaces are actually being used. It is staff tooling, not a customer surface.

Getting in#

Access is by email allowlist. Set ADMIN_EMAILS on the backend to a comma-separated list; a signed-in developer whose email is on it is a platform admin, matched case-insensitively.

ADMIN_EMAILS=ops@example.com,founder@example.com

There is no database column and no UI for granting it, deliberately: changing who can see every tenant's revenue should be a deployment, not a click. An empty list means nobody is a platform admin, which is the safe default rather than a broken one — but it also means the area is unreachable, including the API, so set it before expecting the dashboard to load.

Platform admins get a Platform admin entry at the bottom of the sidebar. Everyone else sees nothing and, if they type the URL, is sent back to their dashboard.

What each page is for#

PageQuestion it answers
Dashboard (/admin)What is the business doing today, and is it moving?
Growth (/admin/growth)Signups, active workspaces, MRR, AI spend and cancellations over time
Adoption (/admin/adoption)Which modules customers actually use, and how much
AI spend (/admin/ai-spend)Where the AI bill went: by day, provider, workspace and feature
Billing (/admin/billing)What is each workspace being charged, and what is the margin
Workspaces / UsersWho exists, on what plan, with how many members
Plans / Plan overridesThe price list, and per-workspace exceptions to it
InvoicesManual and bank-transfer invoicing: raise, mark paid, void
Email logs / NotificationsDid the mail go out, and if not, why
Feedback / AI benchmarkingWhat people told us, and how the models are rating

The daily snapshot#

Every platform figure used to be a live query, which meant each one described this instant and nothing else. "What is MRR?" was answerable; "is it growing?" was not, and could not be made answerable after the fact — nothing in the live tables records that a workspace was active thirty days ago, and a plan's price today is not the price it was billed at.

So one row is written per day into platform_daily_stats by a Temporal schedule (snapshot-platform-stats), and the dashboard reads it.

The time of day matters. The snapshot describes the day it runs on, so it runs at 23:50 UTC, when that day is essentially complete. A plain 24-hour interval would not do: Temporal aligns interval schedules to the Unix epoch, so one with no offset fires at 00:00 UTC — and a snapshot taken then describes a day that is zero seconds old, recording every dated figure in it (signups, people joining, the whole day's AI spend) as zero, permanently. The job also revisits yesterday before writing today, which finishes the last ten minutes and covers a night when the worker was down.

What a row holds:

  • Growth — total and newly created workspaces and people; workspaces active in the trailing 30 days.
  • Subscriptions — MRR, paying and trialing workspaces, billable seats, cancellations, and a count by subscription status.
  • Money — month-to-date revenue across every workspace, the provider cost behind it, the margin, and the split by plan tier and billing model.
  • AI — requests, tokens, billed amount and provider cost for that day alone, broken down by provider.
  • Unpaid — open and overdue invoices, by count and amount.

Two definitions worth knowing#

Active means somebody did something: created or changed a document, task or project, posted a progress update, or spent AI budget in the trailing 30 days. The figure this replaced counted WorkspaceMember.updated_at — a membership row's modification time, which is not activity at all.

MRR is recurring money only: the base fee plus the seats beyond those the plan includes. Usage is deliberately excluded, so a heavy month of AI does not read as subscription growth. Month-to-date revenue, which does include usage, is the separate "Revenue this month" figure.

What a backfilled day cannot tell you#

POST /platform-admin/stats/refresh?backfill_days=N fills in the days before the table existed. Only the parts that still carry their date can be recovered: signups, cancellations and AI spend. Subscription state, seat counts and the month-to-date bill describe now, so a past day is left at zero and marked partial (is_partial) rather than stamped with today's numbers. The growth charts break the line across those days instead of drawing a drop to zero that never happened, and a headline card offers no comparison against one rather than reporting the whole of MRR as growth.

Revisiting a day that was written while it was current never downgrades it: the recomputable figures are refreshed and the rest — including the note saying what is missing — is left exactly as found.

The backfill runs on the queue, not in the request. A year of it is hundreds of passes and thousands of queries in one transaction, which a proxy timeout or a closed tab would roll back in full, with nothing written and no way to tell how far it got. The response says a backfill was queued; today's row is still computed inline, because that is what the caller is waiting for.

Freshness#

If the newest snapshot is more than 36 hours old, the dashboard says so at the top, and so does the platform billing page — its totals are served from the snapshot, and its period heading reads "this month" whether the numbers are from last night or three weeks ago. ?live=true on /billing/totals forces the slow per-workspace pass. That staleness usually means the Temporal worker is not running the analysis queue. Refresh on the dashboard writes today's row immediately — useful on first setup, or after changing a plan.

Module adoption#

The matrix is module against day: how many workspaces created something in each module in the trailing 30 days, and how many things they created. The count matters as much as the reach — 1 of 13 workspaces with 600 documents is a different story from 1 of 13 with three.

The share is against workspaces that did anything in that same 30-day window, not against every workspace on the platform. The two have to mean the same thing or the percentage is nonsense: three of forty, when thirty of the forty have been dormant for months, reads as 8% adoption where the honest figure is 30%. The page prints both numbers so the denominator is never a guess.

Each module's signal is the table whose rows mean somebody did that module's work, not the table that means somebody configured it. Creating a chat channel is configuration and happens once; sending a message is use, so chat counts messages through their channel.

Eleven modules have nothing that separates the two — automations, community, dashboard, learning, MCP, on-call, organization, reports, reviews, tables and uptime. They are listed on the page as not measured rather than shown as zero, because a module nobody can measure must not read as a module nobody uses. Adding one means adding its signal to _module_signals() in platform_stats_service.py.

The same distinction holds when a signal breaks. Seventeen queries over seventeen unrelated tables means one of them can fail on its own — a column renamed, a table not yet created on a node partway through a migration. Each signal runs inside its own savepoint, so a failure costs that module and no other; the savepoint is what makes this work at all, because a failed statement leaves a Postgres transaction aborted and everything after it would fail too. What could not be read is named — in the snapshot's notes, and on the customer page — rather than left absent, which would read as a module nobody uses.

Alerts#

The dashboard leads with what needs attention and usually has nothing, which is deliberate — a list that always has ten entries is a list nobody reads. It raises:

  • invoices past their due date, with the amount outstanding;
  • subscriptions Stripe could not charge (past_due, unpaid), which become cancellations if nobody acts;
  • workspaces that have used all the AI their plan includes, so they are about to be billed for overage or refused;
  • a day of AI spend more than double the trailing week's average, compared against the week rather than yesterday so one quiet Sunday does not make Monday look like a spike.

Why the totals endpoint is fast now#

GET /platform-admin/billing/totals used to loop every active workspace and run a full billing breakdown per workspace, uncached, on every request — work that grows with the tenant count. That is exactly what the nightly snapshot does, so the endpoint reads one row instead. ?live=true forces the old path, and so does asking for a period the snapshot does not cover (period=previous).

API#

All of these require a platform admin.

GET  /api/v1/platform-admin/check                    is the caller one
GET  /api/v1/platform-admin/stats/overview           headline KPIs + deltas
GET  /api/v1/platform-admin/stats/series?days=90     the daily series
POST /api/v1/platform-admin/stats/refresh            write today's row now
GET  /api/v1/platform-admin/stats/adoption           module usage, day by day
GET  /api/v1/platform-admin/stats/ai-spend           the AI bill, broken down
GET  /api/v1/platform-admin/stats/alerts             what needs attention
GET  /api/v1/platform-admin/workspaces/{id}/detail   one customer, in one place
GET  /api/v1/platform-admin/billing/totals           platform revenue and margin
GET  /api/v1/platform-admin/billing/summary          one row per workspace
GET  /api/v1/platform-admin/billing/breakdown        one workspace, line by line
GET  /api/v1/platform-admin/workspaces               every workspace
GET  /api/v1/platform-admin/users                    every developer

/stats/overview returns each figure as a small object carrying its value, what it was on the comparison day, and the difference. When no snapshot reaches that far back the previous value is null rather than 0, because "we do not know" and "it was zero" are different answers and only one of them means the number is flat.

  • Stripe — subscriptions, webhooks and the payment side
  • Deployment — where ADMIN_EMAILS goes in production