Phase 3: cost-vs-revenue dashboard
ArcadiaCloud.Analytics — the operator margin view. Revenue (tenant
invoices, ex-GST) vs COGS (DO cost lines), margin = revenue - COGS.
- margin_summary/1 — overall P&L + per-tenant + per-deployment margin
for a month.
- by_kind/1 — revenue and COGS broken down by resource kind (separate
axes; billing kinds and DO kinds don't 1:1).
- live_accrual/0 — current-month unbilled: runs the quote engine with
partial metered usage per active subscription. "What tenants are
racking up right now before the rollup."
COGS-to-tenant attribution uses COALESCE(deployment.tenant_id,
resource.tenant_id) — a resource in a deployment bills to the
deployment's tenant; standalone resources fall back to their own
tenant_id (skyai-internal infra).
API (platform_admin only):
- GET /api/v1/dashboard/margin?period=YYYY-MM-DD
- GET /api/v1/dashboard/accrual
Schema fix: cloud_resources.tenant_id and cloud_projects.tenant_id were
binary_id (UUID) while cloud_deployments / tenant_invoices use string.
Migrated both to text — a UUID is a valid string, arcadia also uses
non-UUID tenant slugs ("platform-admin"), and the type alignment lets
the analytics COALESCE join work. Side benefit: kills the phase-1 bug
where a non-UUID tenant_id claim crashed the inventory query.
Smoke verified against real ingested April DO COGS: pilot deployment on
Studio with 5 droplets, metered + rolled up — by_tenant shows
dashboard-pilot rev $71.12 / COGS $30.53 / margin $40.59 (57.1%);
overall P&L revenue $71.12 vs all-April COGS $86.92 = -$15.80 (the
pilot's revenue doesn't cover Sky AI's full April infra — correct, the
rest is internal/unattributed).
Phase 3 complete: catalog, deployments+subscriptions, quote engine,
metering, invoice rollup, cost-vs-revenue dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
211
lib/arcadia_cloud/analytics.ex
Normal file
211
lib/arcadia_cloud/analytics.ex
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
defmodule ArcadiaCloud.Analytics do
|
||||||
|
@moduledoc """
|
||||||
|
Cost-vs-revenue analytics — the operator's margin view.
|
||||||
|
|
||||||
|
Revenue side: tenant_invoice_lines / tenant_invoices (what tenants owe
|
||||||
|
Sky AI, ex-GST).
|
||||||
|
COGS side: cloud_cost_lines (what DO bills Sky AI), attributed to a
|
||||||
|
tenant/deployment via cloud_resources.
|
||||||
|
|
||||||
|
margin = revenue (ex-GST) - COGS. GST is pass-through, excluded both
|
||||||
|
sides.
|
||||||
|
|
||||||
|
Phase 3 runs these queries directly. A monthly_margin_rollup
|
||||||
|
materialized view is the optimization for when the dashboard is slow —
|
||||||
|
deliberately deferred.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
|
alias ArcadiaCloud.Repo
|
||||||
|
alias ArcadiaCloud.Cloud.CloudResource
|
||||||
|
alias ArcadiaCloud.Billing.{CloudCostLine, TenantInvoice, TenantInvoiceLine}
|
||||||
|
alias ArcadiaCloud.Deployments.CloudDeployment
|
||||||
|
alias ArcadiaCloud.{Catalog, Metering, Quoting, Subscriptions}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Margin summary for a month. `period` is the first-of-month date.
|
||||||
|
Returns overall P&L plus per-tenant and per-deployment breakdowns.
|
||||||
|
"""
|
||||||
|
def margin_summary(%Date{} = period) do
|
||||||
|
revenue_overall = total_revenue(period)
|
||||||
|
cogs_overall = total_cogs(period)
|
||||||
|
|
||||||
|
%{
|
||||||
|
period: period,
|
||||||
|
overall: margin_row(revenue_overall, cogs_overall),
|
||||||
|
by_tenant: by_tenant(period),
|
||||||
|
by_deployment: by_deployment(period)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- overall --------------------------------------------------------------
|
||||||
|
|
||||||
|
defp total_revenue(period) do
|
||||||
|
from(i in TenantInvoice,
|
||||||
|
where: i.period_start == ^period,
|
||||||
|
select: coalesce(sum(i.subtotal_cents), 0)
|
||||||
|
)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp total_cogs(period) do
|
||||||
|
from(cl in CloudCostLine,
|
||||||
|
where: cl.invoice_period == ^period,
|
||||||
|
select: coalesce(sum(cl.amount_cents), 0)
|
||||||
|
)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- per tenant -----------------------------------------------------------
|
||||||
|
|
||||||
|
defp by_tenant(period) do
|
||||||
|
revenue =
|
||||||
|
from(i in TenantInvoice,
|
||||||
|
where: i.period_start == ^period,
|
||||||
|
group_by: i.tenant_id,
|
||||||
|
select: {i.tenant_id, sum(i.subtotal_cents)}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
# A resource's billing tenant is its deployment's tenant if it's in a
|
||||||
|
# deployment, else its own tenant_id (skyai-internal infra).
|
||||||
|
cogs =
|
||||||
|
from(cl in CloudCostLine,
|
||||||
|
join: r in CloudResource,
|
||||||
|
on: r.id == cl.resource_id,
|
||||||
|
left_join: d in CloudDeployment,
|
||||||
|
on: d.id == r.deployment_id,
|
||||||
|
where: cl.invoice_period == ^period,
|
||||||
|
group_by: fragment("COALESCE(?, ?)", d.tenant_id, r.tenant_id),
|
||||||
|
select: {fragment("COALESCE(?, ?)", d.tenant_id, r.tenant_id), sum(cl.amount_cents)}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.reject(fn {tenant_id, _} -> is_nil(tenant_id) end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
merge_keys(revenue, cogs)
|
||||||
|
|> Enum.map(fn tenant_id ->
|
||||||
|
Map.put(
|
||||||
|
margin_row(Map.get(revenue, tenant_id, 0), Map.get(cogs, tenant_id, 0)),
|
||||||
|
:tenant_id,
|
||||||
|
tenant_id
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|> Enum.sort_by(& &1.margin_cents)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- per deployment -------------------------------------------------------
|
||||||
|
|
||||||
|
defp by_deployment(period) do
|
||||||
|
revenue =
|
||||||
|
from(l in TenantInvoiceLine,
|
||||||
|
join: i in TenantInvoice,
|
||||||
|
on: i.id == l.invoice_id,
|
||||||
|
where: i.period_start == ^period and not is_nil(l.deployment_id) and l.kind != "tax",
|
||||||
|
group_by: l.deployment_id,
|
||||||
|
select: {l.deployment_id, sum(l.amount_cents)}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
cogs =
|
||||||
|
from(cl in CloudCostLine,
|
||||||
|
join: r in CloudResource,
|
||||||
|
on: r.id == cl.resource_id,
|
||||||
|
where: cl.invoice_period == ^period and not is_nil(r.deployment_id),
|
||||||
|
group_by: r.deployment_id,
|
||||||
|
select: {r.deployment_id, sum(cl.amount_cents)}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
names =
|
||||||
|
from(d in CloudDeployment, select: {d.id, d.slug}) |> Repo.all() |> Map.new()
|
||||||
|
|
||||||
|
merge_keys(revenue, cogs)
|
||||||
|
|> Enum.map(fn dep_id ->
|
||||||
|
margin_row(Map.get(revenue, dep_id, 0), Map.get(cogs, dep_id, 0))
|
||||||
|
|> Map.put(:deployment_id, dep_id)
|
||||||
|
|> Map.put(:deployment_slug, Map.get(names, dep_id))
|
||||||
|
end)
|
||||||
|
|> Enum.sort_by(& &1.margin_cents)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- by kind --------------------------------------------------------------
|
||||||
|
|
||||||
|
@doc "Revenue and COGS broken down by resource kind (separate axes — DO kinds and billing kinds don't 1:1)."
|
||||||
|
def by_kind(%Date{} = period) do
|
||||||
|
revenue =
|
||||||
|
from(l in TenantInvoiceLine,
|
||||||
|
join: i in TenantInvoice,
|
||||||
|
on: i.id == l.invoice_id,
|
||||||
|
where: i.period_start == ^period and not is_nil(l.resource_kind),
|
||||||
|
group_by: l.resource_kind,
|
||||||
|
select: {l.resource_kind, sum(l.amount_cents)}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(fn {k, v} -> %{kind: k, amount_cents: v} end)
|
||||||
|
|
||||||
|
cogs =
|
||||||
|
from(cl in CloudCostLine,
|
||||||
|
where: cl.invoice_period == ^period and not is_nil(cl.kind),
|
||||||
|
group_by: cl.kind,
|
||||||
|
select: {cl.kind, sum(cl.amount_cents)}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(fn {k, v} -> %{kind: k, amount_cents: v} end)
|
||||||
|
|
||||||
|
%{revenue_by_kind: revenue, cogs_by_kind: cogs}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- live accrual ---------------------------------------------------------
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Current-month unbilled accrual per tenant — runs the quote engine with
|
||||||
|
partial metered usage. Answers "what are tenants racking up right now,
|
||||||
|
before the month-end rollup".
|
||||||
|
"""
|
||||||
|
def live_accrual do
|
||||||
|
today = Date.utc_today()
|
||||||
|
month_start = Date.beginning_of_month(today)
|
||||||
|
|
||||||
|
Subscriptions.list_active_subscriptions()
|
||||||
|
|> Enum.map(fn sub ->
|
||||||
|
deployment = Repo.get(CloudDeployment, sub.deployment_id)
|
||||||
|
plan_version = Catalog.get_version(sub.plan_version_id)
|
||||||
|
addons = Subscriptions.list_addons(sub.id)
|
||||||
|
usage = Metering.usage_for_period(sub.deployment_id, month_start, today)
|
||||||
|
quote = Quoting.quote(plan_version, addons, usage: usage)
|
||||||
|
|
||||||
|
%{
|
||||||
|
tenant_id: deployment && deployment.tenant_id,
|
||||||
|
deployment_id: sub.deployment_id,
|
||||||
|
deployment_slug: deployment && deployment.slug,
|
||||||
|
accrued_cents: quote.all_in_monthly_cents,
|
||||||
|
recurring_cents: quote.recurring.monthly_total_cents,
|
||||||
|
overage_cents: quote.overage.monthly_total_cents
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
defp margin_row(revenue, cogs) do
|
||||||
|
revenue = revenue || 0
|
||||||
|
cogs = cogs || 0
|
||||||
|
margin = revenue - cogs
|
||||||
|
|
||||||
|
%{
|
||||||
|
revenue_cents: revenue,
|
||||||
|
cogs_cents: cogs,
|
||||||
|
margin_cents: margin,
|
||||||
|
margin_pct: if(revenue > 0, do: Float.round(margin / revenue * 100, 1), else: nil)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp merge_keys(map_a, map_b) do
|
||||||
|
(Map.keys(map_a) ++ Map.keys(map_b)) |> Enum.uniq()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,7 @@ defmodule ArcadiaCloud.Cloud.CloudProject do
|
|||||||
field :provider, :string
|
field :provider, :string
|
||||||
field :provider_id, :string
|
field :provider_id, :string
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :tenant_id, :binary_id
|
field :tenant_id, :string
|
||||||
field :purpose, :string
|
field :purpose, :string
|
||||||
field :metadata, :map, default: %{}
|
field :metadata, :map, default: %{}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ defmodule ArcadiaCloud.Cloud.CloudResource do
|
|||||||
field :region, :string
|
field :region, :string
|
||||||
field :status, :string
|
field :status, :string
|
||||||
field :size_slug, :string
|
field :size_slug, :string
|
||||||
field :tenant_id, :binary_id
|
field :tenant_id, :string
|
||||||
field :deployment_id, :binary_id
|
field :deployment_id, :binary_id
|
||||||
field :tags, {:array, :string}, default: []
|
field :tags, {:array, :string}, default: []
|
||||||
field :attrs, :map, default: %{}
|
field :attrs, :map, default: %{}
|
||||||
|
|||||||
51
lib/arcadia_cloud_web/controllers/dashboard_controller.ex
Normal file
51
lib/arcadia_cloud_web/controllers/dashboard_controller.ex
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
defmodule ArcadiaCloudWeb.DashboardController do
|
||||||
|
@moduledoc """
|
||||||
|
Cost-vs-revenue dashboard. platform_admin only — this is the operator's
|
||||||
|
margin view across all tenants.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ArcadiaCloudWeb, :controller
|
||||||
|
|
||||||
|
alias ArcadiaCloud.Analytics
|
||||||
|
|
||||||
|
def margin(conn, params) do
|
||||||
|
with :ok <- require_platform_admin(conn) do
|
||||||
|
period = parse_period(params["period"])
|
||||||
|
summary = Analytics.margin_summary(period)
|
||||||
|
by_kind = Analytics.by_kind(period)
|
||||||
|
json(conn, Map.merge(summary, by_kind))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def accrual(conn, _params) do
|
||||||
|
with :ok <- require_platform_admin(conn) do
|
||||||
|
accrual = Analytics.live_accrual()
|
||||||
|
|
||||||
|
json(conn, %{
|
||||||
|
accrual: accrual,
|
||||||
|
total_accrued_cents: Enum.reduce(accrual, 0, &(&1.accrued_cents + &2))
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp require_platform_admin(conn) do
|
||||||
|
identity = conn.assigns.current_identity
|
||||||
|
|
||||||
|
if is_list(identity.roles) and "platform_admin" in identity.roles do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
conn |> put_status(:forbidden) |> json(%{error: "platform_admin_required"}) |> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# period defaults to the previous calendar month (what the last rollup invoiced)
|
||||||
|
defp parse_period(nil), do: Date.beginning_of_month(Date.add(Date.beginning_of_month(Date.utc_today()), -1))
|
||||||
|
defp parse_period(""), do: parse_period(nil)
|
||||||
|
|
||||||
|
defp parse_period(str) do
|
||||||
|
case Date.from_iso8601(str) do
|
||||||
|
{:ok, d} -> Date.beginning_of_month(d)
|
||||||
|
_ -> parse_period(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -39,5 +39,8 @@ defmodule ArcadiaCloudWeb.Router do
|
|||||||
|
|
||||||
get "/invoices", InvoiceController, :index
|
get "/invoices", InvoiceController, :index
|
||||||
get "/invoices/:id", InvoiceController, :show
|
get "/invoices/:id", InvoiceController, :show
|
||||||
|
|
||||||
|
get "/dashboard/margin", DashboardController, :margin
|
||||||
|
get "/dashboard/accrual", DashboardController, :accrual
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
19
priv/repo/migrations/20260520180000_tenant_id_to_string.exs
Normal file
19
priv/repo/migrations/20260520180000_tenant_id_to_string.exs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
defmodule ArcadiaCloud.Repo.Migrations.TenantIdToString do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
# tenant_id is string-typed everywhere else (cloud_deployments,
|
||||||
|
# tenant_invoices) and arcadia uses non-UUID tenant slugs like
|
||||||
|
# "platform-admin". cloud_resources/cloud_projects were binary_id —
|
||||||
|
# align them so cross-table joins (analytics COALESCE) work and
|
||||||
|
# non-UUID tenant ids don't blow up Ecto casts.
|
||||||
|
|
||||||
|
def up do
|
||||||
|
execute "ALTER TABLE cloud_resources ALTER COLUMN tenant_id TYPE text USING tenant_id::text"
|
||||||
|
execute "ALTER TABLE cloud_projects ALTER COLUMN tenant_id TYPE text USING tenant_id::text"
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute "ALTER TABLE cloud_resources ALTER COLUMN tenant_id TYPE uuid USING tenant_id::uuid"
|
||||||
|
execute "ALTER TABLE cloud_projects ALTER COLUMN tenant_id TYPE uuid USING tenant_id::uuid"
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user