Files
arcadia-cloud/lib/arcadia_cloud/quoting.ex
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

209 lines
6.4 KiB
Elixir

defmodule ArcadiaCloud.Quoting do
@moduledoc """
The quote engine — turns a plan version + addons + usage into itemized
pricing.
Used two ways:
- projection: at provisioning time, with *projected* usage, to show a
tenant what they'd likely pay.
- actual: at month-end, with *actual* metered usage, to produce the
invoice line set.
Same function, different usage input — so projection and invoicing can
never disagree on the math.
A quote has:
recurring — plan base + addon lines (fixed monthly)
first_invoice — recurring prorated to a mid-month start date
overage — usage beyond plan+addon allowance (billed arrears)
all_in_monthly_cents
Lines are maps: %{kind, resource_kind, description, qty, unit,
unit_price_cents, amount_cents, meta}.
kind ∈ "plan_base" | "addon" | "overage".
"""
alias ArcadiaCloud.Catalog.{PlanVersion, Addon}
alias ArcadiaCloud.Billing.SubscriptionAddon
# base unit → overage unit divisor. overage_price_cents is per overage
# unit; usage + included_qty are in the resource's base unit.
@unit_factor %{
"hour" => 1,
"gb_month" => 1,
"gb" => 1,
"zone" => 1,
"1k_tokens" => 1000,
"snapshot" => 1
}
@doc """
Build a quote.
plan_version — a PlanVersion preloaded with :items
addons — list of Addon or SubscriptionAddon structs
opts:
:usage — map of resource_kind => used qty (nil = skip overage)
:starts_on — Date; if set, first_invoice prorates from this date
"""
def quote(%PlanVersion{} = plan_version, addons, opts \\ []) do
currency = plan_version.currency
base_lines = [plan_base_line(plan_version) | addon_lines(addons)]
monthly_base = sum_amounts(base_lines)
overage_lines =
case opts[:usage] do
nil -> []
usage -> compute_overage(plan_version, addons, usage)
end
overage_total = sum_amounts(overage_lines)
%{
currency: currency,
recurring: %{lines: base_lines, monthly_total_cents: monthly_base},
first_invoice: first_invoice(base_lines, monthly_base, opts[:starts_on]),
overage: %{lines: overage_lines, monthly_total_cents: overage_total},
all_in_monthly_cents: monthly_base + overage_total
}
end
# ---- base lines -----------------------------------------------------------
defp plan_base_line(%PlanVersion{} = pv) do
%{
kind: "plan_base",
resource_kind: nil,
description: "Plan base",
qty: 1,
unit: "month",
unit_price_cents: pv.base_price_cents,
amount_cents: pv.base_price_cents,
meta: %{plan_version_id: pv.id, version: pv.version}
}
end
defp addon_lines(addons) do
Enum.map(addons, fn addon ->
{code, kind, qty, price} = addon_fields(addon)
%{
kind: "addon",
resource_kind: kind,
description: "Addon: #{code}",
qty: 1,
unit: "month",
unit_price_cents: price,
amount_cents: price,
meta: %{addon_code: code, included_qty: to_number(qty)}
}
end)
end
defp addon_fields(%Addon{} = a), do: {a.code, a.resource_kind, a.qty, a.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 --------------------------------------------------------------
defp compute_overage(%PlanVersion{items: items}, addons, usage) do
addon_allowance = addon_allowance_by_kind(addons)
items
|> Enum.flat_map(fn item ->
kind = item.resource_kind
included = to_number(item.included_qty)
allowance = included + Map.get(addon_allowance, kind, 0)
used = usage_for(usage, kind)
billable_used = apply_cap(used, item.hard_cap_qty)
overage_qty = max(billable_used - allowance, 0)
if overage_qty > 0 and is_integer(item.overage_price_cents) do
factor = Map.get(@unit_factor, item.overage_unit, 1)
units = overage_qty / factor
amount = round(units * item.overage_price_cents)
[
%{
kind: "overage",
resource_kind: kind,
description: "#{kind} overage",
qty: overage_qty,
unit: item.overage_unit,
unit_price_cents: item.overage_price_cents,
amount_cents: amount,
meta: %{
included_qty: included,
addon_qty: Map.get(addon_allowance, kind, 0),
allowance: allowance,
used: used,
capped: billable_used != used
}
}
]
else
[]
end
end)
end
defp addon_allowance_by_kind(addons) do
Enum.reduce(addons, %{}, fn addon, acc ->
{_code, kind, qty, _price} = addon_fields(addon)
Map.update(acc, kind, to_number(qty), &(&1 + to_number(qty)))
end)
end
defp apply_cap(used, nil), do: used
defp apply_cap(used, cap), do: min(used, to_number(cap))
defp usage_for(usage, kind) do
(Map.get(usage, kind) || Map.get(usage, to_string(kind)) || 0) |> to_number()
end
# ---- proration ------------------------------------------------------------
defp first_invoice(base_lines, monthly_base, nil) do
%{period_start: nil, period_end: nil, prorated_total_cents: monthly_base, lines: base_lines}
end
defp first_invoice(base_lines, _monthly_base, %Date{} = starts_on) do
days_in_month = Date.days_in_month(starts_on)
days_remaining = days_in_month - starts_on.day + 1
fraction = days_remaining / days_in_month
prorated_lines =
Enum.map(base_lines, fn line ->
prorated = round(line.amount_cents * fraction)
%{line | amount_cents: prorated, meta: Map.put(line.meta, :proration_fraction, fraction)}
end)
%{
period_start: starts_on,
period_end: Date.end_of_month(starts_on),
prorated_total_cents: sum_amounts(prorated_lines),
lines: prorated_lines
}
end
# ---- helpers --------------------------------------------------------------
defp sum_amounts(lines), do: Enum.reduce(lines, 0, &(&1.amount_cents + &2))
defp to_number(%Decimal{} = d), do: Decimal.to_float(d)
defp to_number(n) when is_number(n), do: n
defp to_number(s) when is_binary(s), do: String.to_integer(s)
defp to_number(nil), do: 0
end