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

111 lines
3.3 KiB
Elixir

defmodule ArcadiaCloud.Subscriptions do
@moduledoc """
Subscriptions bind a deployment to a plan version. One subscription per
deployment. Addons attach with their price + qty SNAPSHOTTED, so a
later catalog price change doesn't retroactively reprice an existing
subscriber.
"""
import Ecto.Query, warn: false
alias ArcadiaCloud.Repo
alias ArcadiaCloud.Catalog
alias ArcadiaCloud.Billing.{Subscription, SubscriptionAddon}
@doc """
Create a subscription for a deployment on a plan version. Period
defaults to the current calendar month.
"""
def create_subscription(deployment_id, plan_version_id, opts \\ []) do
{period_start, period_end} = opts[:period] || current_month()
%Subscription{}
|> Subscription.changeset(%{
deployment_id: deployment_id,
plan_version_id: plan_version_id,
status: opts[:status] || "active",
current_period_start: period_start,
current_period_end: period_end,
trial_ends_at: opts[:trial_ends_at]
})
|> Repo.insert()
end
def get_subscription(id) do
case Repo.get(Subscription, id) do
nil -> nil
sub -> Repo.preload(sub, [:addons, :plan_version])
end
end
def get_subscription_for_deployment(deployment_id) do
case Repo.get_by(Subscription, deployment_id: deployment_id) do
nil -> nil
sub -> Repo.preload(sub, [:addons, :plan_version])
end
end
def list_active_subscriptions do
from(s in Subscription, where: s.status == "active", preload: [:addons, :plan_version])
|> Repo.all()
end
def update_subscription(%Subscription{} = sub, attrs) do
sub
|> Subscription.changeset(attrs)
|> Repo.update()
end
@doc """
Migrate a subscription to a different plan version (e.g. a price
change, or an up/downgrade). The change takes effect from the next
period unless `:immediate` is set.
"""
def change_plan_version(%Subscription{} = sub, new_plan_version_id) do
update_subscription(sub, %{plan_version_id: new_plan_version_id})
end
# ---- addons ---------------------------------------------------------------
@doc """
Attach an addon to a subscription, snapshotting its current price + qty.
`addon` may be an Addon struct or an addon code string.
"""
def attach_addon(%Subscription{id: sub_id}, addon) do
addon = resolve_addon(addon)
%SubscriptionAddon{}
|> SubscriptionAddon.changeset(%{
subscription_id: sub_id,
addon_id: addon.id,
resource_kind: addon.resource_kind,
qty: addon.qty,
price_cents: addon.price_cents,
currency: addon.currency,
attached_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
|> Repo.insert()
end
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, preload: [:addon])
|> Repo.all()
end
defp resolve_addon(%ArcadiaCloud.Catalog.Addon{} = addon), do: addon
defp resolve_addon(code) when is_binary(code) do
[addon] = Catalog.get_addons([code])
addon
end
# ---- helpers --------------------------------------------------------------
defp current_month do
today = Date.utc_today()
{Date.beginning_of_month(today), Date.end_of_month(today)}
end
end