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>
This commit is contained in:
2026-05-20 15:12:15 +10:00
parent c10f87b6e0
commit aee5e07b26
9 changed files with 659 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
defmodule ArcadiaCloud.Repo.Migrations.CreateDeployments do
use Ecto.Migration
def change do
# A deployment is the billable unit — one app instance. A tenant has
# 1..N deployments; resources tag to a deployment via
# cloud_resources.deployment_id (already migrated).
create table(:cloud_deployments, primary_key: false) do
add :id, :binary_id, primary_key: true
add :tenant_id, :string, null: false
add :cloud_project_id, references(:cloud_projects, type: :binary_id, on_delete: :nilify_all)
add :slug, :string, null: false
add :display_name, :string
# template_code/version nullable — phase 3 pilot deployments are
# provisioned by hand; formal templates land in phase 4.
add :template_code, :string
add :template_version, :string
add :region, :string
add :state, :string, null: false, default: "active"
add :state_since, :utc_datetime, null: false
add :llm_mode, :string, null: false, default: "none"
# operator override: keep this deployment running even when billing
# rules would otherwise suspend it.
add :billing_action_suspended, :boolean, null: false, default: false
timestamps(type: :utc_datetime)
end
create unique_index(:cloud_deployments, [:tenant_id, :slug])
create index(:cloud_deployments, [:tenant_id])
create index(:cloud_deployments, [:state])
create table(:cloud_deployment_events, primary_key: false) do
add :id, :binary_id, primary_key: true
add :deployment_id,
references(:cloud_deployments, type: :binary_id, on_delete: :delete_all),
null: false
add :from_state, :string
add :to_state, :string, null: false
add :reason, :string
add :actor, :string
add :notes, :text
add :occurred_at, :utc_datetime, null: false
end
create index(:cloud_deployment_events, [:deployment_id, :occurred_at])
# One subscription per deployment — binds it to a plan version.
create table(:subscriptions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :deployment_id,
references(:cloud_deployments, type: :binary_id, on_delete: :delete_all),
null: false
add :plan_version_id,
references(:plan_versions, type: :binary_id, on_delete: :restrict),
null: false
add :status, :string, null: false, default: "active"
add :current_period_start, :date, null: false
add :current_period_end, :date, null: false
add :trial_ends_at, :date
timestamps(type: :utc_datetime)
end
create unique_index(:subscriptions, [:deployment_id])
create index(:subscriptions, [:plan_version_id])
create index(:subscriptions, [:status])
# Addons attached to a subscription. Price/qty are SNAPSHOTTED at
# attach time so later catalog changes don't retroactively reprice.
create table(:subscription_addons, primary_key: false) do
add :id, :binary_id, primary_key: true
add :subscription_id,
references(:subscriptions, type: :binary_id, on_delete: :delete_all),
null: false
add :addon_id, references(:addons, type: :binary_id, on_delete: :restrict), null: false
add :resource_kind, :string, null: false
add :qty, :decimal, null: false
add :price_cents, :integer, null: false
add :currency, :string, null: false, default: "AUD"
add :attached_at, :utc_datetime, null: false
timestamps(type: :utc_datetime)
end
create index(:subscription_addons, [:subscription_id])
end
end