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