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>
cloud_deployments — the billable unit (one app instance). A tenant has
1..N deployments; cloud_resources.deployment_id ties resources to one.
Fields: tenant_id, slug (unique per tenant), display_name, region,
state, llm_mode, billing_action_suspended (operator override),
template_code/version (nullable — formal templates land in phase 4).
Lifecycle state machine in ArcadiaCloud.Deployments — states trial /
active / past_due / paused / suspended / cancelled / archived. Every
transition is validated against an explicit @transitions map and
recorded in cloud_deployment_events. create_deployment defaults to
`active` (trial is wired but no flow enters it yet).
subscriptions — one per deployment, binds it to a plan_version. status
active/paused/cancelled, current period dates, trial_ends_at.
subscription_addons — addons attached to a subscription with price + qty
SNAPSHOTTED at attach time, so a later catalog price change can't
retroactively reprice an existing subscriber.
ArcadiaCloud.Subscriptions context: create_subscription (period defaults
to current calendar month), attach_addon (snapshots from the live Addon),
change_plan_version (migrate to a new version — price changes / up-down
grades), get_subscription_for_deployment.
API (platform_admin sees all tenants; others scoped to own tenant_id):
- GET/POST /api/v1/deployments
- GET /api/v1/deployments/:id (with subscription + events)
- POST /api/v1/deployments/:id/transition
- POST /api/v1/deployments/:id/subscribe (plan_code + optional addons)
Smoke verified: created a deployment, transitioned active->paused
(events logged with actor), rejected an invalid paused->archived
transition (422), subscribed to Studio with the storage_50gb addon —
addon price snapshotted at 750c/qty 50; show returns deployment +
subscription + event history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After InvoiceIngestWorker writes cost lines and matches resources, it
pushes a normalized invoice payload to skyai-finance-svc's new endpoint
POST /api/v1/integrations/cloud/import-invoice. Idempotent — finance
dedups on (tenant_id, source, external_id), so re-runs return "updated"
not duplicate rows.
ArcadiaCloud.Integrations.SkyaiFinance is the HTTP client. Auth is a
short-lived JWT minted via Guardian (shared secret per memory) with
identity {tenant_id: "platform-admin", roles: ["admin"]} — the role
satisfies finance's RequireWriteRole plug. Identity + base URL are
configurable; SKYAI_FINANCE_URL env var can override the default
http://localhost:4010 for arcadia-dev / prod.
GST line detection: lines whose description contains "gst" or "tax"
get summed into amount_tax_minor; everything else into amount_net_minor;
sum stays gross. Phase 1 enough — proper tax handling lands when
real per-tenant invoices flow.
cloud_invoices gains pushed_to_finance_at + finance_invoice_id so we
don't re-push uselessly (Billing.mark_invoice_pushed/2 records both).
A missing finance config (no :skyai_finance app env) makes the push a
silent skip rather than a worker failure — environments without finance
configured still get a working ingest.
Live verified end-to-end against both services:
- April 2026 DO invoice (33 lines, $86.92) lands in finance as a row
with gross=$86.92, tax=$7.90, source=digitalocean, tenant=platform-admin
- DigitalOcean vendor auto-created (category=cloud) under platform-admin
- Re-running the worker returns action: "updated" not "created";
finance still has exactly 1 row for the invoice
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new schemas:
- cloud_balance_snapshots — hourly MTD balance/usage poll for live-accrual.
- cloud_invoices — header per provider invoice, with ingest status flags.
- cloud_cost_lines — per-line-item COGS, FK to cloud_resources where matched.
Three new Oban workers (queue: cloud_billing):
- BalanceWorker (hourly) records a snapshot.
- BillingHistoryWorker (daily) discovers invoices via /v2/customers/my/
billing_history, upserts headers, enqueues an InvoiceIngestWorker for
each not-yet-ingested invoice.
- InvoiceIngestWorker (per-invoice) fetches /invoices/:uuid/csv, parses
with NimbleCSV (header-keyed so column order shifts don't break us),
replaces the invoice's line set, then matches lines to cloud_resources
by (kind, name) — case-insensitive, name extracted from "name (size)"
description format.
DigitalOcean.Client gains get_balance / list_billing_history /
get_invoice_summary / fetch_invoice_csv. The CSV endpoint returns text/csv
so we bypass Req's body decoder.
Cron additions: BalanceWorker hourly at :07, BillingHistoryWorker daily
at 02:23.
API:
- GET /api/v1/billing/balance — latest snapshot, platform_admin only.
- GET /api/v1/billing/cost-lines?period=YYYY-MM-DD&kind&limit — per-line
COGS, platform_admin only.
Live smoke against real DO billing API surfaced and fixed three CSV-format
gotchas: column headers use underscores not spaces (group_description,
project_name), USD column has $ prefix, dates use "YYYY-MM-DD HH:MM:SS
+0000" format (space separator + RFC822 offset).
Verified: 137 historical invoices discovered going back to 2014;
April 2026 invoice (33 lines, $86.92 total) ingested with 6/33 lines
matched to current cloud_resources. Unmatched lines are correctly
historic droplets, Spaces buckets (not yet synced), and GST.
NimbleCSV ~> 1.2 added as a dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>