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:
70
lib/arcadia_cloud_web/controllers/invoice_controller.ex
Normal file
70
lib/arcadia_cloud_web/controllers/invoice_controller.ex
Normal 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
|
||||
Reference in New Issue
Block a user