ArcadiaCloud.Quoting.quote/3 — turns a plan version + addons + usage into
itemized pricing. The same function serves both provisioning-time
projection (with projected usage) and month-end invoicing (with actual
metered usage), so the two can never disagree on the math.
A quote has:
- recurring — plan base + addon lines (fixed monthly)
- first_invoice — recurring prorated to a mid-month start date
(fraction = days_remaining / days_in_month)
- overage — usage beyond plan+addon allowance, billed in arrears
- all_in_monthly_cents
Overage computation: per plan_item, effective allowance =
included_qty + summed addon qty for the same resource_kind. Overage =
max(0, used - allowance), clamped by hard_cap_qty. overage_price_cents
is per overage_unit; a @unit_factor map converts base-unit usage to
overage units (e.g. 1k_tokens divides token counts by 1000).
Lines are uniform maps (kind/resource_kind/description/qty/unit/
unit_price_cents/amount_cents/meta) — the invoice rollup chunk will
persist them near-verbatim as invoice_lines.
addon_fields/1 accepts either a catalog Addon (projection from the
catalog) or a SubscriptionAddon (actual, using snapshotted price/qty).
API: POST /api/v1/quote { plan_code, addons[], projected_usage{},
starts_on } — tenant-facing pre-provisioning price preview.
Smoke verified against seeded plans:
- Studio bare: $50/mo, no overage.
- Studio + storage_50gb, mid-month start 2026-05-20, projected usage:
recurring $57.50; first invoice prorated 12/31 -> $22.25; overage
LLM 500k-token $5.00 + Spaces 50GB $1.00 (the addon correctly lifted
the Spaces allowance 100->150 GB); droplet_hours exactly at allowance
produced no line; all-in $63.50/mo.
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>
Five catalog tables:
- plans — plan identity (code, name); the stable thing.
- plan_versions — versioned pricing (base_price_cents, currency,
status draft/active/retired). A subscription binds
to a version; raising prices = publish a new
version, existing subs unaffected until migrated.
- plan_items — what a version includes per resource_kind, plus
overage terms (overage_unit, overage_price_cents,
hard_cap_qty).
- addons — a la carte upgrades (code, resource_kind, qty,
price_cents).
- resource_prices — effective-dated fallback per-unit pricing for
ad-hoc items not covered by a plan.
ArcadiaCloud.Catalog context: plan + version CRUD, active_version/1
(what a new signup gets), publish_version/1 (retires the prior active
version transactionally then activates the new one),
current_resource_price/2 (effective-dated lookup).
Seed (priv/repo/seeds/catalog_seed.exs, idempotent) creates three AUD
plans — Starter $20, Studio $50, Pro $120/mo — with included
droplet_hours / spaces_gb_month / snapshot_gb_month / bandwidth_gb /
dns_zones (and LLM token allowances on Studio + Pro), plus three storage
/ LLM addons. Prices are placeholders to tune against real DO COGS once
the cost-vs-revenue dashboard lands.
API (authenticated tenants — the catalog is what they pick from):
- GET /api/v1/catalog/plans — plans with active version + items
- GET /api/v1/catalog/addons
Smoke verified: seed creates 3 plans + 3 addons; endpoints return the
shaped catalog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compares cloud_provisioned.spec (what we asked DO for) against the live
cloud_resources row (what DO actually has). Any divergence becomes an
operator-resolvable drift record.
cloud_drift table: one row per drifted field. status open/accepted/
reverted/stale. Partial unique index keeps at most one OPEN drift per
(resource, field); resolved rows are retained as history.
ArcadiaCloud.Drift context:
- detect_all/0 — sweeps every provisioned resource. Per field in spec,
resolves the actual value (top-level schema field first, then attrs),
compares with loose equality (stringified scalars; lists as sets so
JSON round-trips don't false-positive). Mismatches upsert a cloud_drift
row + emit a drift_detected event in the resource event log.
- close_stale_drift — an open drift whose field no longer mismatches
(fixed elsewhere) closes as "stale" on the next sweep.
- accept_drift/2 — the live value becomes the new desired-state: parent
cloud_provisioned.spec is updated, spec_version bumped, drift closed
"accepted". Revert (mutating live infra back to spec) is intentionally
NOT here — it needs a saga and lands with the droplet-resize work.
DriftDetectionWorker — Oban cron at :20 past the hour, offset past the
:15 resource syncs so it compares against fresh inventory.
Provisioning.record_provisioned/3 — populates cloud_provisioned desired-
state (upsert on resource_id, bumps spec_version). Future provisioning
sagas call this; for now it's how drift gets something to detect.
API (platform_admin only):
- GET /api/v1/drift — open drift inbox
- POST /api/v1/drift/:id/accept — adopt live value as desired-state
Smoke verified: recorded a droplet's desired spec with a deliberately
wrong size_slug + a correct region; detect_all flagged only size_slug,
wrote the drift_detected event; accept updated the spec to the live
value and closed the drift; re-detect found zero drift.
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>
Models:
- cloud_projects: arcadia-cloud's mirror of DO Projects, indexed by
(provider, provider_id); tenant_id + purpose classify each project.
- cloud_resources: single unified resource table; kind-specific bits in
attrs JSONB; first_seen_at / last_seen_at / stale_strike_count drive
three-strike deletion.
- cloud_resource_events: append-only audit (discovered, updated, deleted,
drift_detected, tagged, restored).
ArcadiaCloud.Cloud context owns the single upsert chokepoint that:
- inserts new with `discovered` event
- updates existing only when meaningful fields change
- restores tombstoned rows seen again
- bumps last_seen_at and resets strike count
mark_stale/3 implements the three-strike rule.
ArcadiaCloud.DigitalOcean.Client is a Req wrapper with auto-pagination.
Per-purpose token resolution via .Tokens (phase 1: env DO_API_TOKEN;
phase 2: vault). Per project_arcadia_cloud memory the long-term shape
is one PAT per queue purpose for rate-limit isolation.
ArcadiaCloud.Sync.Bootstrap ensures the skyai-internal DO Project exists
on first sync, idempotent thereafter. ArcadiaCloud.Sync.DropletsWorker
runs full droplet sync on the cloud_sync_full Oban queue.
InventoryController wired to real data: platform_admin sees all,
tenants see only their scope.
Live smoke test against real DO: 5 droplets synced; skyai-internal
project auto-created; events written; endpoint returns scoped results.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
API-only Phoenix 1.8 project for cloud-ops, inventory, billing, and
provisioning sagas. Validates arcadia JWTs via shared Guardian secret
(verify-only; arcadia-app remains the issuer).
Deps beyond default Phoenix: guardian, cors_plug, oban, req.
Postgres on local port 5433 per arcadia stack convention.
Endpoint runs on :4005.
Endpoints:
- GET /api/health — public, returns service identifier
- GET /api/v1/inventory — auth-gated, returns empty list (phase 0 stub)
Oban configured with the queues phase 1+ will need:
provisioning / cloud_sync_fast|full|slow / cloud_billing / metering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>