Files
arcadia-cloud/priv/repo/migrations/20260520170000_create_tenant_invoices.exs
Giuliano Silvestro 3d54078c60 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>
2026-05-20 15:32:21 +10:00

55 lines
2.1 KiB
Elixir

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