diff --git a/lib/arcadia_cloud/analytics.ex b/lib/arcadia_cloud/analytics.ex new file mode 100644 index 0000000..8e5e5f5 --- /dev/null +++ b/lib/arcadia_cloud/analytics.ex @@ -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 diff --git a/lib/arcadia_cloud/cloud/cloud_project.ex b/lib/arcadia_cloud/cloud/cloud_project.ex index dd3a07f..02301d2 100644 --- a/lib/arcadia_cloud/cloud/cloud_project.ex +++ b/lib/arcadia_cloud/cloud/cloud_project.ex @@ -9,7 +9,7 @@ defmodule ArcadiaCloud.Cloud.CloudProject do field :provider, :string field :provider_id, :string field :name, :string - field :tenant_id, :binary_id + field :tenant_id, :string field :purpose, :string field :metadata, :map, default: %{} diff --git a/lib/arcadia_cloud/cloud/cloud_resource.ex b/lib/arcadia_cloud/cloud/cloud_resource.ex index 39cd2b8..4a6d78f 100644 --- a/lib/arcadia_cloud/cloud/cloud_resource.ex +++ b/lib/arcadia_cloud/cloud/cloud_resource.ex @@ -13,7 +13,7 @@ defmodule ArcadiaCloud.Cloud.CloudResource do field :region, :string field :status, :string field :size_slug, :string - field :tenant_id, :binary_id + field :tenant_id, :string field :deployment_id, :binary_id field :tags, {:array, :string}, default: [] field :attrs, :map, default: %{} diff --git a/lib/arcadia_cloud_web/controllers/dashboard_controller.ex b/lib/arcadia_cloud_web/controllers/dashboard_controller.ex new file mode 100644 index 0000000..331589c --- /dev/null +++ b/lib/arcadia_cloud_web/controllers/dashboard_controller.ex @@ -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 diff --git a/lib/arcadia_cloud_web/router.ex b/lib/arcadia_cloud_web/router.ex index 437e78f..2a67f46 100644 --- a/lib/arcadia_cloud_web/router.ex +++ b/lib/arcadia_cloud_web/router.ex @@ -39,5 +39,8 @@ defmodule ArcadiaCloudWeb.Router do get "/invoices", InvoiceController, :index get "/invoices/:id", InvoiceController, :show + + get "/dashboard/margin", DashboardController, :margin + get "/dashboard/accrual", DashboardController, :accrual end end diff --git a/priv/repo/migrations/20260520180000_tenant_id_to_string.exs b/priv/repo/migrations/20260520180000_tenant_id_to_string.exs new file mode 100644 index 0000000..2e5524f --- /dev/null +++ b/priv/repo/migrations/20260520180000_tenant_id_to_string.exs @@ -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