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

View File

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

View File

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

View File

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

View File

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

View File

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