From 3d54078c6006d8697fe67cf32a1ebeccf5150e53 Mon Sep 17 00:00:00 2001 From: Giuliano Silvestro Date: Wed, 20 May 2026 15:32:21 +1000 Subject: [PATCH] Phase 3: tenant invoice rollup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Month-end engine — turns a period of metered usage into tenant invoices (revenue side). Distinct from cloud_invoices, which are DO's bills to Sky AI (COGS). tenant_invoices — one per (tenant, period). subtotal/tax/total cents, status draft/issued/paid/void. unique (tenant_id, period_start). tenant_invoice_lines — kind plan_base/addon/overage/tax, tagged with deployment_id (NULL for tenant-level lines like GST) + resource_kind, so the cost-vs-revenue dashboard can group by deployment and by kind. ArcadiaCloud.Invoicing.roll_up_period/3: - groups active subscriptions by tenant - one tenant_invoice per tenant; per subscription, runs the quote engine with the deployment's ACTUAL metered usage (Metering.usage_for_period) and persists the recurring + overage lines tagged with the deployment - appends a tenant-level GST line (AU 10%, per project_skyai_australia) - idempotent on (tenant_id, period_start); re-run skips unless force:true Because the same quote engine serves provisioning-time projection and month-end invoicing, a tenant's quoted price and invoiced price are computed identically. InvoiceRollupWorker — Oban cron, 1st of month 03:00 UTC, invoices the month just ended. API (platform_admin sees all; tenants scoped to own): - GET /api/v1/invoices — tenant invoice list - GET /api/v1/invoices/:id — invoice with lines Also: SubscriptionAddon now preloads its :addon so quote/invoice lines read "Addon: storage_50gb" rather than the addon UUID. Smoke verified: pilot deployment on Studio + storage_50gb, 3 droplets metered across all 30 days of April (2160 droplet_hours vs 1488 included) — rollup produced an invoice with plan_base $50 + addon $7.50 + droplet_hours overage $6.72 (672h x 1c) = $64.22 subtotal, GST $6.42, total $70.64. Re-run without force correctly skipped. NOT in this chunk: pushing tenant invoices to skyai-finance as AR — that needs an income-side endpoint on skyai-finance (the phase-1 push endpoint creates vendor expense invoices, wrong direction). Deferred to its own chunk. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/config.exs | 4 +- lib/arcadia_cloud/billing/tenant_invoice.ex | 40 ++++ .../billing/tenant_invoice_line.ex | 32 +++ lib/arcadia_cloud/invoicing.ex | 187 ++++++++++++++++++ lib/arcadia_cloud/quoting.ex | 13 +- lib/arcadia_cloud/subscriptions.ex | 2 +- .../sync/invoice_rollup_worker.ex | 22 +++ .../controllers/invoice_controller.ex | 70 +++++++ lib/arcadia_cloud_web/router.ex | 3 + .../20260520170000_create_tenant_invoices.exs | 54 +++++ 10 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 lib/arcadia_cloud/billing/tenant_invoice.ex create mode 100644 lib/arcadia_cloud/billing/tenant_invoice_line.ex create mode 100644 lib/arcadia_cloud/invoicing.ex create mode 100644 lib/arcadia_cloud/sync/invoice_rollup_worker.ex create mode 100644 lib/arcadia_cloud_web/controllers/invoice_controller.ex create mode 100644 priv/repo/migrations/20260520170000_create_tenant_invoices.exs diff --git a/config/config.exs b/config/config.exs index f4d946c..072695a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -69,7 +69,9 @@ config :arcadia_cloud, Oban, {"10 1 * * *", ArcadiaCloud.Sync.MeteringWorker}, # Billing: hourly balance, daily invoice discovery {"7 * * * *", ArcadiaCloud.Sync.BalanceWorker}, - {"23 2 * * *", ArcadiaCloud.Sync.BillingHistoryWorker} + {"23 2 * * *", ArcadiaCloud.Sync.BillingHistoryWorker}, + # Tenant invoice rollup — 1st of the month, 03:00 UTC + {"0 3 1 * *", ArcadiaCloud.Sync.InvoiceRollupWorker} ]} ], repo: ArcadiaCloud.Repo diff --git a/lib/arcadia_cloud/billing/tenant_invoice.ex b/lib/arcadia_cloud/billing/tenant_invoice.ex new file mode 100644 index 0000000..d5b77ee --- /dev/null +++ b/lib/arcadia_cloud/billing/tenant_invoice.ex @@ -0,0 +1,40 @@ +defmodule ArcadiaCloud.Billing.TenantInvoice do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @statuses ~w(draft issued paid void) + + schema "tenant_invoices" do + field :tenant_id, :string + field :period_start, :date + field :period_end, :date + field :currency, :string, default: "AUD" + field :subtotal_cents, :integer, default: 0 + field :tax_cents, :integer, default: 0 + field :total_cents, :integer, default: 0 + field :status, :string, default: "issued" + field :issued_at, :utc_datetime + field :paid_at, :utc_datetime + field :finance_invoice_id, :string + field :pushed_to_finance_at, :utc_datetime + + has_many :lines, ArcadiaCloud.Billing.TenantInvoiceLine, foreign_key: :invoice_id + + timestamps(type: :utc_datetime) + end + + @required ~w(tenant_id period_start period_end)a + @optional ~w(currency subtotal_cents tax_cents total_cents status issued_at + paid_at finance_invoice_id pushed_to_finance_at)a + + def changeset(invoice, attrs) do + invoice + |> cast(attrs, @required ++ @optional) + |> validate_required(@required) + |> validate_inclusion(:status, @statuses) + |> unique_constraint([:tenant_id, :period_start]) + end +end diff --git a/lib/arcadia_cloud/billing/tenant_invoice_line.ex b/lib/arcadia_cloud/billing/tenant_invoice_line.ex new file mode 100644 index 0000000..ed8f29f --- /dev/null +++ b/lib/arcadia_cloud/billing/tenant_invoice_line.ex @@ -0,0 +1,32 @@ +defmodule ArcadiaCloud.Billing.TenantInvoiceLine do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "tenant_invoice_lines" do + field :deployment_id, :binary_id + field :kind, :string + field :resource_kind, :string + field :description, :string + field :qty, :decimal + field :unit, :string + field :unit_price_cents, :integer + field :amount_cents, :integer + field :meta, :map, default: %{} + + belongs_to :invoice, ArcadiaCloud.Billing.TenantInvoice + + timestamps(type: :utc_datetime, updated_at: false) + end + + @required ~w(invoice_id kind amount_cents)a + @optional ~w(deployment_id resource_kind description qty unit unit_price_cents meta)a + + def changeset(line, attrs) do + line + |> cast(attrs, @required ++ @optional) + |> validate_required(@required) + end +end diff --git a/lib/arcadia_cloud/invoicing.ex b/lib/arcadia_cloud/invoicing.ex new file mode 100644 index 0000000..aa460e2 --- /dev/null +++ b/lib/arcadia_cloud/invoicing.ex @@ -0,0 +1,187 @@ +defmodule ArcadiaCloud.Invoicing do + @moduledoc """ + Month-end invoice rollup — turns a period of metered usage into tenant + invoices. + + For each tenant with active subscriptions, one tenant_invoice is + produced. For each of that tenant's subscriptions, the quote engine + runs with the deployment's ACTUAL metered usage; the resulting + recurring + overage lines become tenant_invoice_lines tagged with the + deployment. A GST line (AU 10%) is appended at the tenant level. + + Because the same quote engine serves projection and invoicing, the + price a tenant was quoted at provisioning time and the price they're + invoiced are computed identically. + + Idempotent: unique (tenant_id, period_start). Re-running skips tenants + that already have an invoice for the period unless `force: true`. + """ + + import Ecto.Query, warn: false + + alias ArcadiaCloud.Repo + alias ArcadiaCloud.{Catalog, Metering, Quoting, Subscriptions} + alias ArcadiaCloud.Billing.{Subscription, TenantInvoice, TenantInvoiceLine} + alias ArcadiaCloud.Deployments.CloudDeployment + + # AU GST. Sky AI is Australian (project_skyai_australia memory). + @gst_rate 0.10 + + @doc """ + Roll up invoices for a calendar month. `month` is any Date in that + month; defaults to the previous month. + """ + def roll_up_month(month \\ default_month(), opts \\ []) do + period_start = Date.beginning_of_month(month) + period_end = Date.end_of_month(month) + roll_up_period(period_start, period_end, opts) + end + + def roll_up_period(%Date{} = period_start, %Date{} = period_end, opts \\ []) do + force = Keyword.get(opts, :force, false) + + subscriptions_by_tenant() + |> Enum.map(fn {tenant_id, subs} -> + roll_up_tenant(tenant_id, subs, period_start, period_end, force) + end) + end + + # ---- per-tenant ----------------------------------------------------------- + + defp roll_up_tenant(tenant_id, subscriptions, period_start, period_end, force) do + existing = Repo.get_by(TenantInvoice, tenant_id: tenant_id, period_start: period_start) + + cond do + existing && not force -> + {:skipped, tenant_id, existing.id} + + true -> + if existing, do: Repo.delete!(existing) + {:ok, invoice} = build_invoice(tenant_id, subscriptions, period_start, period_end) + {:rolled, tenant_id, invoice.id} + end + end + + defp build_invoice(tenant_id, subscriptions, period_start, period_end) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.transaction(fn -> + {:ok, invoice} = + %TenantInvoice{} + |> TenantInvoice.changeset(%{ + tenant_id: tenant_id, + period_start: period_start, + period_end: period_end, + status: "issued", + issued_at: now + }) + |> Repo.insert() + + lines = Enum.flat_map(subscriptions, &subscription_lines(&1, period_start, period_end)) + Enum.each(lines, &insert_line(invoice.id, &1)) + + subtotal = Enum.reduce(lines, 0, &(&1.amount_cents + &2)) + tax = round(subtotal * @gst_rate) + + if tax > 0 do + insert_line(invoice.id, %{ + deployment_id: nil, + kind: "tax", + resource_kind: nil, + description: "GST (#{round(@gst_rate * 100)}%)", + qty: nil, + unit: nil, + unit_price_cents: nil, + amount_cents: tax, + meta: %{"rate" => @gst_rate} + }) + end + + invoice + |> TenantInvoice.changeset(%{ + subtotal_cents: subtotal, + tax_cents: tax, + total_cents: subtotal + tax + }) + |> Repo.update!() + end) + end + + # quote one subscription -> invoice lines tagged with its deployment + defp subscription_lines(%Subscription{} = sub, period_start, period_end) do + deployment_id = sub.deployment_id + plan_version = Catalog.get_version(sub.plan_version_id) + addons = Subscriptions.list_addons(sub.id) + usage = Metering.usage_for_period(deployment_id, period_start, period_end) + + quote = Quoting.quote(plan_version, addons, usage: usage) + + (quote.recurring.lines ++ quote.overage.lines) + |> Enum.map(fn line -> Map.put(line, :deployment_id, deployment_id) end) + end + + defp insert_line(invoice_id, line) do + %TenantInvoiceLine{} + |> TenantInvoiceLine.changeset(%{ + invoice_id: invoice_id, + deployment_id: line[:deployment_id], + kind: line.kind, + resource_kind: line[:resource_kind], + description: line[:description], + qty: to_decimal(line[:qty]), + unit: line[:unit], + unit_price_cents: line[:unit_price_cents], + amount_cents: line.amount_cents, + meta: stringify(line[:meta] || %{}) + }) + |> Repo.insert!() + end + + # ---- queries -------------------------------------------------------------- + + def list_invoices(opts \\ []) do + from(i in TenantInvoice, order_by: [desc: i.period_start]) + |> filter(:tenant_id, opts[:tenant_id]) + |> filter(:status, opts[:status]) + |> Repo.all() + end + + def get_invoice(id) do + case Repo.get(TenantInvoice, id) do + nil -> nil + inv -> Repo.preload(inv, :lines) + end + end + + # ---- helpers -------------------------------------------------------------- + + defp subscriptions_by_tenant do + from(s in Subscription, + where: s.status == "active", + join: d in CloudDeployment, + on: d.id == s.deployment_id, + preload: [:addons], + select: {d.tenant_id, s} + ) + |> Repo.all() + |> Enum.group_by(fn {tenant_id, _} -> tenant_id end, fn {_, sub} -> sub end) + end + + defp default_month do + today = Date.utc_today() + Date.add(Date.beginning_of_month(today), -1) + end + + defp filter(q, _f, nil), do: q + defp filter(q, field, value), do: from(i in q, where: field(i, ^field) == ^value) + + defp to_decimal(nil), do: nil + defp to_decimal(%Decimal{} = d), do: d + defp to_decimal(n) when is_integer(n), do: Decimal.new(n) + defp to_decimal(n) when is_float(n), do: Decimal.from_float(Float.round(n, 4)) + + # invoice_lines.meta is a jsonb column — keys must be strings. + defp stringify(map) when is_map(map) do + Map.new(map, fn {k, v} -> {to_string(k), v} end) + end +end diff --git a/lib/arcadia_cloud/quoting.ex b/lib/arcadia_cloud/quoting.ex index 1363c03..00aa276 100644 --- a/lib/arcadia_cloud/quoting.ex +++ b/lib/arcadia_cloud/quoting.ex @@ -102,8 +102,17 @@ defmodule ArcadiaCloud.Quoting do defp addon_fields(%Addon{} = a), do: {a.code, a.resource_kind, a.qty, a.price_cents} - defp addon_fields(%SubscriptionAddon{} = sa), - do: {sa.addon_id, sa.resource_kind, sa.qty, sa.price_cents} + defp addon_fields(%SubscriptionAddon{} = sa) do + # prefer the snapshotted addon's code for a readable line; fall back + # to the id if the :addon association wasn't preloaded. + code = + case sa.addon do + %Addon{code: code} -> code + _ -> sa.addon_id + end + + {code, sa.resource_kind, sa.qty, sa.price_cents} + end # ---- overage -------------------------------------------------------------- diff --git a/lib/arcadia_cloud/subscriptions.ex b/lib/arcadia_cloud/subscriptions.ex index ae18ab9..63ad70a 100644 --- a/lib/arcadia_cloud/subscriptions.ex +++ b/lib/arcadia_cloud/subscriptions.ex @@ -90,7 +90,7 @@ defmodule ArcadiaCloud.Subscriptions do def detach_addon(%SubscriptionAddon{} = sa), do: Repo.delete(sa) def list_addons(subscription_id) do - from(sa in SubscriptionAddon, where: sa.subscription_id == ^subscription_id) + from(sa in SubscriptionAddon, where: sa.subscription_id == ^subscription_id, preload: [:addon]) |> Repo.all() end diff --git a/lib/arcadia_cloud/sync/invoice_rollup_worker.ex b/lib/arcadia_cloud/sync/invoice_rollup_worker.ex new file mode 100644 index 0000000..4fe7c15 --- /dev/null +++ b/lib/arcadia_cloud/sync/invoice_rollup_worker.ex @@ -0,0 +1,22 @@ +defmodule ArcadiaCloud.Sync.InvoiceRollupWorker do + @moduledoc """ + Monthly invoice rollup — runs on the 1st, invoices the month just + ended. Idempotent (Invoicing.roll_up_month skips tenants already + invoiced for the period). + """ + + use Oban.Worker, queue: :cloud_billing, max_attempts: 3 + + require Logger + + alias ArcadiaCloud.Invoicing + + @impl Oban.Worker + def perform(_job) do + results = Invoicing.roll_up_month() + rolled = Enum.count(results, &(elem(&1, 0) == :rolled)) + skipped = Enum.count(results, &(elem(&1, 0) == :skipped)) + Logger.info("[invoice rollup] rolled=#{rolled} skipped=#{skipped}") + :ok + end +end diff --git a/lib/arcadia_cloud_web/controllers/invoice_controller.ex b/lib/arcadia_cloud_web/controllers/invoice_controller.ex new file mode 100644 index 0000000..5cfcca4 --- /dev/null +++ b/lib/arcadia_cloud_web/controllers/invoice_controller.ex @@ -0,0 +1,70 @@ +defmodule ArcadiaCloudWeb.InvoiceController do + @moduledoc """ + Tenant invoices (revenue side). platform_admin sees all tenants; + others are scoped to their own tenant_id. + """ + + use ArcadiaCloudWeb, :controller + + alias ArcadiaCloud.Invoicing + + def index(conn, params) do + identity = conn.assigns.current_identity + + opts = + if platform_admin?(identity), + do: [tenant_id: params["tenant_id"], status: params["status"]], + else: [tenant_id: identity.tenant_id, status: params["status"]] + + invoices = Invoicing.list_invoices(opts) |> Enum.map(&shape/1) + json(conn, %{invoices: invoices, count: length(invoices)}) + end + + def show(conn, %{"id" => id}) do + identity = conn.assigns.current_identity + + case Invoicing.get_invoice(id) do + nil -> + conn |> put_status(:not_found) |> json(%{error: "not_found"}) + + invoice -> + if platform_admin?(identity) or invoice.tenant_id == identity.tenant_id do + json(conn, %{invoice: shape(invoice), lines: Enum.map(invoice.lines, &shape_line/1)}) + else + conn |> put_status(:forbidden) |> json(%{error: "forbidden"}) + end + end + end + + defp platform_admin?(%{roles: roles}) when is_list(roles), do: "platform_admin" in roles + defp platform_admin?(_), do: false + + defp shape(i) do + %{ + id: i.id, + tenant_id: i.tenant_id, + period_start: i.period_start, + period_end: i.period_end, + currency: i.currency, + subtotal_cents: i.subtotal_cents, + tax_cents: i.tax_cents, + total_cents: i.total_cents, + status: i.status, + issued_at: i.issued_at, + pushed_to_finance_at: i.pushed_to_finance_at + } + end + + defp shape_line(l) do + %{ + kind: l.kind, + deployment_id: l.deployment_id, + resource_kind: l.resource_kind, + description: l.description, + qty: l.qty, + unit: l.unit, + unit_price_cents: l.unit_price_cents, + amount_cents: l.amount_cents + } + end +end diff --git a/lib/arcadia_cloud_web/router.ex b/lib/arcadia_cloud_web/router.ex index 091c5a8..437e78f 100644 --- a/lib/arcadia_cloud_web/router.ex +++ b/lib/arcadia_cloud_web/router.ex @@ -36,5 +36,8 @@ defmodule ArcadiaCloudWeb.Router do get "/deployments/:id", DeploymentController, :show post "/deployments/:id/transition", DeploymentController, :transition post "/deployments/:id/subscribe", DeploymentController, :subscribe + + get "/invoices", InvoiceController, :index + get "/invoices/:id", InvoiceController, :show end end diff --git a/priv/repo/migrations/20260520170000_create_tenant_invoices.exs b/priv/repo/migrations/20260520170000_create_tenant_invoices.exs new file mode 100644 index 0000000..ec71317 --- /dev/null +++ b/priv/repo/migrations/20260520170000_create_tenant_invoices.exs @@ -0,0 +1,54 @@ +defmodule ArcadiaCloud.Repo.Migrations.CreateTenantInvoices do + use Ecto.Migration + + def change do + # Revenue-side invoices: what a tenant owes Sky AI. Distinct from + # cloud_invoices, which are what DO bills Sky AI (COGS). One invoice + # per tenant per period; lines are grouped by deployment. + create table(:tenant_invoices, primary_key: false) do + add :id, :binary_id, primary_key: true + add :tenant_id, :string, null: false + add :period_start, :date, null: false + add :period_end, :date, null: false + add :currency, :string, null: false, default: "AUD" + add :subtotal_cents, :integer, null: false, default: 0 + add :tax_cents, :integer, null: false, default: 0 + add :total_cents, :integer, null: false, default: 0 + add :status, :string, null: false, default: "issued" + add :issued_at, :utc_datetime + add :paid_at, :utc_datetime + # set when pushed to skyai-finance as AR (separate chunk) + add :finance_invoice_id, :string + add :pushed_to_finance_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:tenant_invoices, [:tenant_id, :period_start]) + create index(:tenant_invoices, [:status]) + + create table(:tenant_invoice_lines, primary_key: false) do + add :id, :binary_id, primary_key: true + add :invoice_id, + references(:tenant_invoices, type: :binary_id, on_delete: :delete_all), + null: false + # NULL for tenant-level lines (GST, credits, adjustments). + add :deployment_id, + references(:cloud_deployments, type: :binary_id, on_delete: :nilify_all) + add :kind, :string, null: false + add :resource_kind, :string + add :description, :string + add :qty, :decimal + add :unit, :string + add :unit_price_cents, :integer + add :amount_cents, :integer, null: false + add :meta, :map, default: %{} + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:tenant_invoice_lines, [:invoice_id]) + create index(:tenant_invoice_lines, [:deployment_id]) + create index(:tenant_invoice_lines, [:resource_kind]) + end +end