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>
111 lines
3.3 KiB
Elixir
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
|