Phase 3: tenant invoice rollup

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 15:32:21 +10:00
parent 501e333811
commit 3d54078c60
10 changed files with 423 additions and 4 deletions

View File

@@ -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

View File

@@ -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