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>
This commit is contained in:
@@ -69,7 +69,9 @@ config :arcadia_cloud, Oban,
|
|||||||
{"10 1 * * *", ArcadiaCloud.Sync.MeteringWorker},
|
{"10 1 * * *", ArcadiaCloud.Sync.MeteringWorker},
|
||||||
# Billing: hourly balance, daily invoice discovery
|
# Billing: hourly balance, daily invoice discovery
|
||||||
{"7 * * * *", ArcadiaCloud.Sync.BalanceWorker},
|
{"7 * * * *", ArcadiaCloud.Sync.BalanceWorker},
|
||||||
{"23 2 * * *", ArcadiaCloud.Sync.BillingHistoryWorker}
|
{"23 2 * * *", ArcadiaCloud.Sync.BillingHistoryWorker},
|
||||||
|
# Tenant invoice rollup — 1st of the month, 03:00 UTC
|
||||||
|
{"0 3 1 * *", ArcadiaCloud.Sync.InvoiceRollupWorker}
|
||||||
]}
|
]}
|
||||||
],
|
],
|
||||||
repo: ArcadiaCloud.Repo
|
repo: ArcadiaCloud.Repo
|
||||||
|
|||||||
40
lib/arcadia_cloud/billing/tenant_invoice.ex
Normal file
40
lib/arcadia_cloud/billing/tenant_invoice.ex
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
defmodule ArcadiaCloud.Billing.TenantInvoice do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
@statuses ~w(draft issued paid void)
|
||||||
|
|
||||||
|
schema "tenant_invoices" do
|
||||||
|
field :tenant_id, :string
|
||||||
|
field :period_start, :date
|
||||||
|
field :period_end, :date
|
||||||
|
field :currency, :string, default: "AUD"
|
||||||
|
field :subtotal_cents, :integer, default: 0
|
||||||
|
field :tax_cents, :integer, default: 0
|
||||||
|
field :total_cents, :integer, default: 0
|
||||||
|
field :status, :string, default: "issued"
|
||||||
|
field :issued_at, :utc_datetime
|
||||||
|
field :paid_at, :utc_datetime
|
||||||
|
field :finance_invoice_id, :string
|
||||||
|
field :pushed_to_finance_at, :utc_datetime
|
||||||
|
|
||||||
|
has_many :lines, ArcadiaCloud.Billing.TenantInvoiceLine, foreign_key: :invoice_id
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@required ~w(tenant_id period_start period_end)a
|
||||||
|
@optional ~w(currency subtotal_cents tax_cents total_cents status issued_at
|
||||||
|
paid_at finance_invoice_id pushed_to_finance_at)a
|
||||||
|
|
||||||
|
def changeset(invoice, attrs) do
|
||||||
|
invoice
|
||||||
|
|> cast(attrs, @required ++ @optional)
|
||||||
|
|> validate_required(@required)
|
||||||
|
|> validate_inclusion(:status, @statuses)
|
||||||
|
|> unique_constraint([:tenant_id, :period_start])
|
||||||
|
end
|
||||||
|
end
|
||||||
32
lib/arcadia_cloud/billing/tenant_invoice_line.ex
Normal file
32
lib/arcadia_cloud/billing/tenant_invoice_line.ex
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
defmodule ArcadiaCloud.Billing.TenantInvoiceLine do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
schema "tenant_invoice_lines" do
|
||||||
|
field :deployment_id, :binary_id
|
||||||
|
field :kind, :string
|
||||||
|
field :resource_kind, :string
|
||||||
|
field :description, :string
|
||||||
|
field :qty, :decimal
|
||||||
|
field :unit, :string
|
||||||
|
field :unit_price_cents, :integer
|
||||||
|
field :amount_cents, :integer
|
||||||
|
field :meta, :map, default: %{}
|
||||||
|
|
||||||
|
belongs_to :invoice, ArcadiaCloud.Billing.TenantInvoice
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime, updated_at: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
@required ~w(invoice_id kind amount_cents)a
|
||||||
|
@optional ~w(deployment_id resource_kind description qty unit unit_price_cents meta)a
|
||||||
|
|
||||||
|
def changeset(line, attrs) do
|
||||||
|
line
|
||||||
|
|> cast(attrs, @required ++ @optional)
|
||||||
|
|> validate_required(@required)
|
||||||
|
end
|
||||||
|
end
|
||||||
187
lib/arcadia_cloud/invoicing.ex
Normal file
187
lib/arcadia_cloud/invoicing.ex
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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
|
||||||
@@ -102,8 +102,17 @@ defmodule ArcadiaCloud.Quoting do
|
|||||||
|
|
||||||
defp addon_fields(%Addon{} = a), do: {a.code, a.resource_kind, a.qty, a.price_cents}
|
defp addon_fields(%Addon{} = a), do: {a.code, a.resource_kind, a.qty, a.price_cents}
|
||||||
|
|
||||||
defp addon_fields(%SubscriptionAddon{} = sa),
|
defp addon_fields(%SubscriptionAddon{} = sa) do
|
||||||
do: {sa.addon_id, sa.resource_kind, sa.qty, sa.price_cents}
|
# 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 --------------------------------------------------------------
|
# ---- overage --------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ defmodule ArcadiaCloud.Subscriptions do
|
|||||||
def detach_addon(%SubscriptionAddon{} = sa), do: Repo.delete(sa)
|
def detach_addon(%SubscriptionAddon{} = sa), do: Repo.delete(sa)
|
||||||
|
|
||||||
def list_addons(subscription_id) do
|
def list_addons(subscription_id) do
|
||||||
from(sa in SubscriptionAddon, where: sa.subscription_id == ^subscription_id)
|
from(sa in SubscriptionAddon, where: sa.subscription_id == ^subscription_id, preload: [:addon])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
22
lib/arcadia_cloud/sync/invoice_rollup_worker.ex
Normal file
22
lib/arcadia_cloud/sync/invoice_rollup_worker.ex
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
defmodule ArcadiaCloud.Sync.InvoiceRollupWorker do
|
||||||
|
@moduledoc """
|
||||||
|
Monthly invoice rollup — runs on the 1st, invoices the month just
|
||||||
|
ended. Idempotent (Invoicing.roll_up_month skips tenants already
|
||||||
|
invoiced for the period).
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Oban.Worker, queue: :cloud_billing, max_attempts: 3
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias ArcadiaCloud.Invoicing
|
||||||
|
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(_job) do
|
||||||
|
results = Invoicing.roll_up_month()
|
||||||
|
rolled = Enum.count(results, &(elem(&1, 0) == :rolled))
|
||||||
|
skipped = Enum.count(results, &(elem(&1, 0) == :skipped))
|
||||||
|
Logger.info("[invoice rollup] rolled=#{rolled} skipped=#{skipped}")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
70
lib/arcadia_cloud_web/controllers/invoice_controller.ex
Normal file
70
lib/arcadia_cloud_web/controllers/invoice_controller.ex
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
defmodule ArcadiaCloudWeb.InvoiceController do
|
||||||
|
@moduledoc """
|
||||||
|
Tenant invoices (revenue side). platform_admin sees all tenants;
|
||||||
|
others are scoped to their own tenant_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ArcadiaCloudWeb, :controller
|
||||||
|
|
||||||
|
alias ArcadiaCloud.Invoicing
|
||||||
|
|
||||||
|
def index(conn, params) do
|
||||||
|
identity = conn.assigns.current_identity
|
||||||
|
|
||||||
|
opts =
|
||||||
|
if platform_admin?(identity),
|
||||||
|
do: [tenant_id: params["tenant_id"], status: params["status"]],
|
||||||
|
else: [tenant_id: identity.tenant_id, status: params["status"]]
|
||||||
|
|
||||||
|
invoices = Invoicing.list_invoices(opts) |> Enum.map(&shape/1)
|
||||||
|
json(conn, %{invoices: invoices, count: length(invoices)})
|
||||||
|
end
|
||||||
|
|
||||||
|
def show(conn, %{"id" => id}) do
|
||||||
|
identity = conn.assigns.current_identity
|
||||||
|
|
||||||
|
case Invoicing.get_invoice(id) do
|
||||||
|
nil ->
|
||||||
|
conn |> put_status(:not_found) |> json(%{error: "not_found"})
|
||||||
|
|
||||||
|
invoice ->
|
||||||
|
if platform_admin?(identity) or invoice.tenant_id == identity.tenant_id do
|
||||||
|
json(conn, %{invoice: shape(invoice), lines: Enum.map(invoice.lines, &shape_line/1)})
|
||||||
|
else
|
||||||
|
conn |> put_status(:forbidden) |> json(%{error: "forbidden"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp platform_admin?(%{roles: roles}) when is_list(roles), do: "platform_admin" in roles
|
||||||
|
defp platform_admin?(_), do: false
|
||||||
|
|
||||||
|
defp shape(i) do
|
||||||
|
%{
|
||||||
|
id: i.id,
|
||||||
|
tenant_id: i.tenant_id,
|
||||||
|
period_start: i.period_start,
|
||||||
|
period_end: i.period_end,
|
||||||
|
currency: i.currency,
|
||||||
|
subtotal_cents: i.subtotal_cents,
|
||||||
|
tax_cents: i.tax_cents,
|
||||||
|
total_cents: i.total_cents,
|
||||||
|
status: i.status,
|
||||||
|
issued_at: i.issued_at,
|
||||||
|
pushed_to_finance_at: i.pushed_to_finance_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp shape_line(l) do
|
||||||
|
%{
|
||||||
|
kind: l.kind,
|
||||||
|
deployment_id: l.deployment_id,
|
||||||
|
resource_kind: l.resource_kind,
|
||||||
|
description: l.description,
|
||||||
|
qty: l.qty,
|
||||||
|
unit: l.unit,
|
||||||
|
unit_price_cents: l.unit_price_cents,
|
||||||
|
amount_cents: l.amount_cents
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -36,5 +36,8 @@ defmodule ArcadiaCloudWeb.Router do
|
|||||||
get "/deployments/:id", DeploymentController, :show
|
get "/deployments/:id", DeploymentController, :show
|
||||||
post "/deployments/:id/transition", DeploymentController, :transition
|
post "/deployments/:id/transition", DeploymentController, :transition
|
||||||
post "/deployments/:id/subscribe", DeploymentController, :subscribe
|
post "/deployments/:id/subscribe", DeploymentController, :subscribe
|
||||||
|
|
||||||
|
get "/invoices", InvoiceController, :index
|
||||||
|
get "/invoices/:id", InvoiceController, :show
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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
|
||||||
Reference in New Issue
Block a user