Commit Graph

12 Commits

Author SHA1 Message Date
e1f0aedcf7 Receive llm_usage_recorded events from arcadia-llm-gateway
POST /api/v1/integrations/llm-usage stores priced LLM usage events
(idempotent on gateway_request_id) in llm_usage_events. The gateway is
the LLM-pricing authority — arcadia-cloud trusts the charge it sends
rather than re-pricing.

The monthly invoice rollup now appends an llm_usage line per deployment
alongside the infra quote lines; the exact decimal charges are summed
and rounded to cents once. Closes the gateway→cloud billing loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:29:36 +10:00
6ec7e9b93a Phase 3: cost-vs-revenue dashboard
ArcadiaCloud.Analytics — the operator margin view. Revenue (tenant
invoices, ex-GST) vs COGS (DO cost lines), margin = revenue - COGS.

- margin_summary/1 — overall P&L + per-tenant + per-deployment margin
  for a month.
- by_kind/1 — revenue and COGS broken down by resource kind (separate
  axes; billing kinds and DO kinds don't 1:1).
- live_accrual/0 — current-month unbilled: runs the quote engine with
  partial metered usage per active subscription. "What tenants are
  racking up right now before the rollup."

COGS-to-tenant attribution uses COALESCE(deployment.tenant_id,
resource.tenant_id) — a resource in a deployment bills to the
deployment's tenant; standalone resources fall back to their own
tenant_id (skyai-internal infra).

API (platform_admin only):
- GET /api/v1/dashboard/margin?period=YYYY-MM-DD
- GET /api/v1/dashboard/accrual

Schema fix: cloud_resources.tenant_id and cloud_projects.tenant_id were
binary_id (UUID) while cloud_deployments / tenant_invoices use string.
Migrated both to text — a UUID is a valid string, arcadia also uses
non-UUID tenant slugs ("platform-admin"), and the type alignment lets
the analytics COALESCE join work. Side benefit: kills the phase-1 bug
where a non-UUID tenant_id claim crashed the inventory query.

Smoke verified against real ingested April DO COGS: pilot deployment on
Studio with 5 droplets, metered + rolled up — by_tenant shows
dashboard-pilot rev $71.12 / COGS $30.53 / margin $40.59 (57.1%);
overall P&L revenue $71.12 vs all-April COGS $86.92 = -$15.80 (the
pilot's revenue doesn't cover Sky AI's full April infra — correct, the
rest is internal/unattributed).

Phase 3 complete: catalog, deployments+subscriptions, quote engine,
metering, invoice rollup, cost-vs-revenue dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:57:08 +10:00
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
501e333811 Phase 3: usage metering
usage_records — one metered observation per (deployment, resource,
period, granularity). resource_kind is the BILLING kind (droplet_hours,
spaces_gb_month, ...) so it joins straight to plan_items. sub_attribution
jsonb column present from day one for forward-compat (sub-billing) but
never written in phase 3.

metering_config — per-billing-kind granularity (daily default; flip to
hourly when fine-grained billing is wanted) + retention_days.

ArcadiaCloud.Metering:
- meter_day/1 — walks every deployment-attributed resource, maps its
  inventory kind to a billing kind, records that day's usage. Idempotent
  via the unique (deployment, resource, period, granularity) index.
- usage_for_period/3 — SUM(qty) GROUP BY billing kind over a date range,
  returns the %{kind => qty} map the quote engine takes as :usage.

Uniform accrual model so everything downstream is just SUM(qty):
- droplet      -> droplet_hours, 24/day when active (0 when off)
- spaces_bucket / snapshot / droplet_backup -> *_gb_month, size/days so
  the month sums to GB-months
- dns_zone     -> dns_zones, 1/days so the month sums to the zone count

MeteringWorker — Oban cron 01:10 UTC daily, meters the previous
complete day.

Smoke verified: a pilot deployment with 2 droplets + 2 DNS zones + 3
snapshots attributed; 10 days metered -> 70 usage records; aggregation
gave droplet_hours 480 (2x24x10), dns_zones 0.65 (2x10/31),
snapshot_gb_month 15.48; fed into the quote engine against Studio — all
within allowance so $50 base, no overage (correct for a light partial
month).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:23:12 +10:00
aee5e07b26 Phase 3: deployment model + subscriptions
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>
2026-05-20 15:12:15 +10:00
c10f87b6e0 Phase 3: pricing catalog
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>
2026-05-20 14:29:52 +10:00
445b7b60d4 Phase 2: drift detection
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>
2026-05-20 11:08:27 +10:00
3274a4adab Phase 2 saga engine: compensation-based runner + step contract
The architectural spine of every write workflow phase 2+ — provisioning,
suspension, offboarding, updates, rollback — all ride this engine. Each
becomes a step list, not new orchestration code.

Schemas:
- saga_runs           — kind, status (pending/running/completed/failed/
                        compensating/rolled_back), step_modules, current
                        step idx, accumulating context (jsonb), cancel
                        flag, error.
- saga_step_results   — per-step audit ledger: status (running/completed/
                        failed/compensated), output, attempts, timings,
                        unique (saga_id, step_idx).
- cloud_provisioned   — desired-state for resources WE provisioned. spec
                        + spec_version + saga_id; FK to cloud_resources.
                        Phase 2's drift-detection diff lands here.

Step contract (ArcadiaCloud.Provisioning.Step):
- execute(state) -> {:ok, state} | {:error, reason}
- compensate(state) -> :ok | {:error, _}   (optional)
- name() -> String.t()

SagaState carries the live execution state — accumulating context,
immutable inputs, current step_idx. Helpers: get_output/put_output
(context r/w), get_input (inputs read-only).

Runner (Oban worker, queue: provisioning, max_attempts: 1):
- kick_off: pending -> running, run_step(0)
- run_step: idempotent re-entry on saga.current_step_idx; persists step
  result + saga context after each step; recursive forward walk through
  the whole step list within one perform/1 call.
- safe_execute: try/rescue/catch around module.execute so a raised
  exception triggers compensation rather than blowing up the worker.
- start_compensation: status=compensating, walk from idx-1 down to 0,
  calling compensate/1 where it's exported; logs but doesn't halt on
  compensate failure (best-effort + audit log).
- cancellation: checked between steps; cancel_requested=true -> trigger
  compensation from current idx.
- crash recovery: max_attempts: 1 + run_step keyed on
  saga.current_step_idx means Oban requeue picks up at the right place,
  but full crash-resume infra is deferred to phase 2.5 (manual re-enqueue
  works for now).

Two proof-of-concept steps (Steps.Echo, Steps.Fail) demonstrate the
engine without any DO API exposure. First real DO write step lands in
the next chunk.

Provisioning context provides start_saga/1, list_sagas/1,
list_step_results/1, cancel_saga/1, upsert_step_result/3.

Live smoke verified end-to-end:
- [Echo, Echo, Echo] happy path: all 3 completed, context accumulated
  echoed_at_step_0/1/2 = "hello".
- [Echo, Echo, Fail] failure path: step 2 failed, compensation walked
  back through step 1 then step 0; final status rolled_back with error
  {compensate_from_idx: 1, reason: "step_failed:fail"}; ledger shows
  echo/echo/fail with statuses compensated/compensated/failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:24:30 +10:00
4f2e52af01 Wire skyai-finance push: cloud invoices flow into Sky AI's books
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>
2026-05-19 22:35:52 +10:00
0079f98bb5 Phase 1 cost ingestion: balance + invoices + CSV parse + resource match
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>
2026-05-19 22:20:50 +10:00
c1cbd434ac Phase 1 first chunk: inventory schema + DO droplet sync
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>
2026-05-19 22:07:29 +10:00
5959479ce1 Phase 0 scaffold: arcadia-cloud Phoenix service
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>
2026-05-19 21:51:11 +10:00