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:
33
lib/arcadia_cloud/billing/subscription.ex
Normal file
33
lib/arcadia_cloud/billing/subscription.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule ArcadiaCloud.Billing.Subscription do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@statuses ~w(active paused cancelled)
|
||||
|
||||
schema "subscriptions" do
|
||||
field :status, :string, default: "active"
|
||||
field :current_period_start, :date
|
||||
field :current_period_end, :date
|
||||
field :trial_ends_at, :date
|
||||
|
||||
belongs_to :deployment, ArcadiaCloud.Deployments.CloudDeployment
|
||||
belongs_to :plan_version, ArcadiaCloud.Catalog.PlanVersion
|
||||
has_many :addons, ArcadiaCloud.Billing.SubscriptionAddon
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required ~w(deployment_id plan_version_id current_period_start current_period_end)a
|
||||
@optional ~w(status trial_ends_at)a
|
||||
|
||||
def changeset(sub, attrs) do
|
||||
sub
|
||||
|> cast(attrs, @required ++ @optional)
|
||||
|> validate_required(@required)
|
||||
|> validate_inclusion(:status, @statuses)
|
||||
|> unique_constraint(:deployment_id)
|
||||
end
|
||||
end
|
||||
29
lib/arcadia_cloud/billing/subscription_addon.ex
Normal file
29
lib/arcadia_cloud/billing/subscription_addon.ex
Normal file
@@ -0,0 +1,29 @@
|
||||
defmodule ArcadiaCloud.Billing.SubscriptionAddon do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "subscription_addons" do
|
||||
field :resource_kind, :string
|
||||
field :qty, :decimal
|
||||
field :price_cents, :integer
|
||||
field :currency, :string, default: "AUD"
|
||||
field :attached_at, :utc_datetime
|
||||
|
||||
belongs_to :subscription, ArcadiaCloud.Billing.Subscription
|
||||
belongs_to :addon, ArcadiaCloud.Catalog.Addon
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required ~w(subscription_id addon_id resource_kind qty price_cents attached_at)a
|
||||
@optional ~w(currency)a
|
||||
|
||||
def changeset(sa, attrs) do
|
||||
sa
|
||||
|> cast(attrs, @required ++ @optional)
|
||||
|> validate_required(@required)
|
||||
end
|
||||
end
|
||||
137
lib/arcadia_cloud/deployments.ex
Normal file
137
lib/arcadia_cloud/deployments.ex
Normal file
@@ -0,0 +1,137 @@
|
||||
defmodule ArcadiaCloud.Deployments do
|
||||
@moduledoc """
|
||||
Deployment lifecycle — a deployment is the billable unit (one app
|
||||
instance). Tenant has 1..N deployments.
|
||||
|
||||
Lifecycle state machine (see project_arcadia_cloud memory):
|
||||
|
||||
trial ──→ active ⇄ paused
|
||||
│
|
||||
├─→ past_due ──→ suspended ──→ cancelled ──→ archived
|
||||
│ │ │
|
||||
│ └→ active └→ active
|
||||
└─→ cancelled
|
||||
|
||||
Every transition is validated against @transitions and recorded in
|
||||
cloud_deployment_events. Phase 3 deployments are created straight in
|
||||
`active` (trial is wired but no flow enters it yet).
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias ArcadiaCloud.Repo
|
||||
alias ArcadiaCloud.Deployments.{CloudDeployment, CloudDeploymentEvent}
|
||||
|
||||
# from_state => [allowed to_states]
|
||||
@transitions %{
|
||||
"trial" => ~w(active cancelled),
|
||||
"active" => ~w(paused past_due cancelled),
|
||||
"paused" => ~w(active cancelled),
|
||||
"past_due" => ~w(active suspended cancelled),
|
||||
"suspended" => ~w(active cancelled),
|
||||
"cancelled" => ~w(active archived),
|
||||
"archived" => []
|
||||
}
|
||||
|
||||
def transitions, do: @transitions
|
||||
|
||||
# ---- CRUD -----------------------------------------------------------------
|
||||
|
||||
def create_deployment(attrs) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
initial_state = attrs[:state] || attrs["state"] || "active"
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||
|> Map.put("state", initial_state)
|
||||
|> Map.put("state_since", now)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
case %CloudDeployment{} |> CloudDeployment.changeset(attrs) |> Repo.insert() do
|
||||
{:ok, deployment} ->
|
||||
write_event(deployment, nil, initial_state, "created", attrs["actor"])
|
||||
deployment
|
||||
|
||||
{:error, changeset} ->
|
||||
Repo.rollback(changeset)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def get_deployment(id), do: Repo.get(CloudDeployment, id)
|
||||
def get_deployment!(id), do: Repo.get!(CloudDeployment, id)
|
||||
|
||||
def get_deployment_by_slug(tenant_id, slug) do
|
||||
Repo.get_by(CloudDeployment, tenant_id: tenant_id, slug: slug)
|
||||
end
|
||||
|
||||
def list_deployments(opts \\ []) do
|
||||
from(d in CloudDeployment, order_by: [desc: d.inserted_at])
|
||||
|> filter(:tenant_id, opts[:tenant_id])
|
||||
|> filter(:state, opts[:state])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_deployment_events(deployment_id) do
|
||||
from(e in CloudDeploymentEvent,
|
||||
where: e.deployment_id == ^deployment_id,
|
||||
order_by: [asc: e.occurred_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
# ---- state machine --------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Transition a deployment to `to_state`, validated against the lifecycle
|
||||
state machine. Records a cloud_deployment_events row. Returns
|
||||
{:ok, deployment} | {:error, {:invalid_transition, from, to}}.
|
||||
"""
|
||||
def transition_state(%CloudDeployment{} = deployment, to_state, opts \\ []) do
|
||||
from_state = deployment.state
|
||||
allowed = Map.get(@transitions, from_state, [])
|
||||
|
||||
cond do
|
||||
to_state == from_state ->
|
||||
{:ok, deployment}
|
||||
|
||||
to_state in allowed ->
|
||||
do_transition(deployment, to_state, opts)
|
||||
|
||||
true ->
|
||||
{:error, {:invalid_transition, from_state, to_state}}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_transition(deployment, to_state, opts) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
{:ok, updated} =
|
||||
deployment
|
||||
|> CloudDeployment.changeset(%{state: to_state, state_since: now})
|
||||
|> Repo.update()
|
||||
|
||||
write_event(deployment, deployment.state, to_state, opts[:reason], opts[:actor], opts[:notes])
|
||||
updated
|
||||
end)
|
||||
end
|
||||
|
||||
defp write_event(deployment, from_state, to_state, reason, actor, notes \\ nil) do
|
||||
%CloudDeploymentEvent{}
|
||||
|> CloudDeploymentEvent.changeset(%{
|
||||
deployment_id: deployment.id,
|
||||
from_state: from_state,
|
||||
to_state: to_state,
|
||||
reason: reason,
|
||||
actor: actor,
|
||||
notes: notes,
|
||||
occurred_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
defp filter(query, _field, nil), do: query
|
||||
defp filter(query, field, value), do: from(d in query, where: field(d, ^field) == ^value)
|
||||
end
|
||||
45
lib/arcadia_cloud/deployments/cloud_deployment.ex
Normal file
45
lib/arcadia_cloud/deployments/cloud_deployment.ex
Normal file
@@ -0,0 +1,45 @@
|
||||
defmodule ArcadiaCloud.Deployments.CloudDeployment do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@states ~w(trial active past_due paused suspended cancelled archived)
|
||||
@llm_modes ~w(managed byo none)
|
||||
|
||||
def states, do: @states
|
||||
|
||||
schema "cloud_deployments" do
|
||||
field :tenant_id, :string
|
||||
field :slug, :string
|
||||
field :display_name, :string
|
||||
field :template_code, :string
|
||||
field :template_version, :string
|
||||
field :region, :string
|
||||
field :state, :string, default: "active"
|
||||
field :state_since, :utc_datetime
|
||||
field :llm_mode, :string, default: "none"
|
||||
field :billing_action_suspended, :boolean, default: false
|
||||
|
||||
belongs_to :cloud_project, ArcadiaCloud.Cloud.CloudProject
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required ~w(tenant_id slug state state_since)a
|
||||
@optional ~w(display_name template_code template_version region llm_mode
|
||||
billing_action_suspended cloud_project_id)a
|
||||
|
||||
def changeset(deployment, attrs) do
|
||||
deployment
|
||||
|> cast(attrs, @required ++ @optional)
|
||||
|> validate_required(@required)
|
||||
|> validate_inclusion(:state, @states)
|
||||
|> validate_inclusion(:llm_mode, @llm_modes)
|
||||
|> validate_format(:slug, ~r/^[a-z0-9][a-z0-9-]*$/,
|
||||
message: "must be lowercase alphanumeric + hyphens"
|
||||
)
|
||||
|> unique_constraint([:tenant_id, :slug])
|
||||
end
|
||||
end
|
||||
27
lib/arcadia_cloud/deployments/cloud_deployment_event.ex
Normal file
27
lib/arcadia_cloud/deployments/cloud_deployment_event.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule ArcadiaCloud.Deployments.CloudDeploymentEvent do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "cloud_deployment_events" do
|
||||
field :from_state, :string
|
||||
field :to_state, :string
|
||||
field :reason, :string
|
||||
field :actor, :string
|
||||
field :notes, :string
|
||||
field :occurred_at, :utc_datetime
|
||||
|
||||
belongs_to :deployment, ArcadiaCloud.Deployments.CloudDeployment
|
||||
end
|
||||
|
||||
@required ~w(deployment_id to_state occurred_at)a
|
||||
@optional ~w(from_state reason actor notes)a
|
||||
|
||||
def changeset(event, attrs) do
|
||||
event
|
||||
|> cast(attrs, @required ++ @optional)
|
||||
|> validate_required(@required)
|
||||
end
|
||||
end
|
||||
110
lib/arcadia_cloud/subscriptions.ex
Normal file
110
lib/arcadia_cloud/subscriptions.ex
Normal file
@@ -0,0 +1,110 @@
|
||||
defmodule ArcadiaCloud.Subscriptions do
|
||||
@moduledoc """
|
||||
Subscriptions bind a deployment to a plan version. One subscription per
|
||||
deployment. Addons attach with their price + qty SNAPSHOTTED, so a
|
||||
later catalog price change doesn't retroactively reprice an existing
|
||||
subscriber.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias ArcadiaCloud.Repo
|
||||
alias ArcadiaCloud.Catalog
|
||||
alias ArcadiaCloud.Billing.{Subscription, SubscriptionAddon}
|
||||
|
||||
@doc """
|
||||
Create a subscription for a deployment on a plan version. Period
|
||||
defaults to the current calendar month.
|
||||
"""
|
||||
def create_subscription(deployment_id, plan_version_id, opts \\ []) do
|
||||
{period_start, period_end} = opts[:period] || current_month()
|
||||
|
||||
%Subscription{}
|
||||
|> Subscription.changeset(%{
|
||||
deployment_id: deployment_id,
|
||||
plan_version_id: plan_version_id,
|
||||
status: opts[:status] || "active",
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
trial_ends_at: opts[:trial_ends_at]
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def get_subscription(id) do
|
||||
case Repo.get(Subscription, id) do
|
||||
nil -> nil
|
||||
sub -> Repo.preload(sub, [:addons, :plan_version])
|
||||
end
|
||||
end
|
||||
|
||||
def get_subscription_for_deployment(deployment_id) do
|
||||
case Repo.get_by(Subscription, deployment_id: deployment_id) do
|
||||
nil -> nil
|
||||
sub -> Repo.preload(sub, [:addons, :plan_version])
|
||||
end
|
||||
end
|
||||
|
||||
def list_active_subscriptions do
|
||||
from(s in Subscription, where: s.status == "active", preload: [:addons, :plan_version])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def update_subscription(%Subscription{} = sub, attrs) do
|
||||
sub
|
||||
|> Subscription.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Migrate a subscription to a different plan version (e.g. a price
|
||||
change, or an up/downgrade). The change takes effect from the next
|
||||
period unless `:immediate` is set.
|
||||
"""
|
||||
def change_plan_version(%Subscription{} = sub, new_plan_version_id) do
|
||||
update_subscription(sub, %{plan_version_id: new_plan_version_id})
|
||||
end
|
||||
|
||||
# ---- addons ---------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Attach an addon to a subscription, snapshotting its current price + qty.
|
||||
`addon` may be an Addon struct or an addon code string.
|
||||
"""
|
||||
def attach_addon(%Subscription{id: sub_id}, addon) do
|
||||
addon = resolve_addon(addon)
|
||||
|
||||
%SubscriptionAddon{}
|
||||
|> SubscriptionAddon.changeset(%{
|
||||
subscription_id: sub_id,
|
||||
addon_id: addon.id,
|
||||
resource_kind: addon.resource_kind,
|
||||
qty: addon.qty,
|
||||
price_cents: addon.price_cents,
|
||||
currency: addon.currency,
|
||||
attached_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def detach_addon(%SubscriptionAddon{} = sa), do: Repo.delete(sa)
|
||||
|
||||
def list_addons(subscription_id) do
|
||||
from(sa in SubscriptionAddon, where: sa.subscription_id == ^subscription_id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp resolve_addon(%ArcadiaCloud.Catalog.Addon{} = addon), do: addon
|
||||
|
||||
defp resolve_addon(code) when is_binary(code) do
|
||||
[addon] = Catalog.get_addons([code])
|
||||
addon
|
||||
end
|
||||
|
||||
# ---- helpers --------------------------------------------------------------
|
||||
|
||||
defp current_month do
|
||||
today = Date.utc_today()
|
||||
{Date.beginning_of_month(today), Date.end_of_month(today)}
|
||||
end
|
||||
end
|
||||
184
lib/arcadia_cloud_web/controllers/deployment_controller.ex
Normal file
184
lib/arcadia_cloud_web/controllers/deployment_controller.ex
Normal file
@@ -0,0 +1,184 @@
|
||||
defmodule ArcadiaCloudWeb.DeploymentController do
|
||||
@moduledoc """
|
||||
Deployment lifecycle + subscription management.
|
||||
|
||||
Scope: platform_admin sees/manages all tenants; other identities are
|
||||
scoped to their own tenant_id.
|
||||
"""
|
||||
|
||||
use ArcadiaCloudWeb, :controller
|
||||
|
||||
alias ArcadiaCloud.{Catalog, Deployments, Subscriptions}
|
||||
|
||||
def index(conn, params) do
|
||||
identity = conn.assigns.current_identity
|
||||
|
||||
opts =
|
||||
if platform_admin?(identity),
|
||||
do: [state: params["state"]],
|
||||
else: [tenant_id: identity.tenant_id, state: params["state"]]
|
||||
|
||||
deployments = Deployments.list_deployments(opts) |> Enum.map(&shape/1)
|
||||
json(conn, %{deployments: deployments, count: length(deployments)})
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
with {:ok, deployment} <- fetch_scoped(conn, id) do
|
||||
sub = Subscriptions.get_subscription_for_deployment(deployment.id)
|
||||
events = Deployments.list_deployment_events(deployment.id)
|
||||
|
||||
json(conn, %{
|
||||
deployment: shape(deployment),
|
||||
subscription: shape_subscription(sub),
|
||||
events: Enum.map(events, &shape_event/1)
|
||||
})
|
||||
else
|
||||
{:halt, conn} -> conn
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
identity = conn.assigns.current_identity
|
||||
# platform_admin may provision for any tenant by passing tenant_id;
|
||||
# otherwise (and as the default) the deployment belongs to the caller.
|
||||
tenant_id =
|
||||
if platform_admin?(identity),
|
||||
do: params["tenant_id"] || identity.tenant_id,
|
||||
else: identity.tenant_id
|
||||
|
||||
attrs =
|
||||
params
|
||||
|> Map.take(["slug", "display_name", "region", "llm_mode", "template_code"])
|
||||
|> Map.put("tenant_id", tenant_id)
|
||||
|> Map.put("actor", identity.email)
|
||||
|
||||
case Deployments.create_deployment(attrs) do
|
||||
{:ok, deployment} ->
|
||||
conn |> put_status(:created) |> json(%{deployment: shape(deployment)})
|
||||
|
||||
{:error, changeset} ->
|
||||
conn |> put_status(:unprocessable_entity) |> json(%{error: errors(changeset)})
|
||||
end
|
||||
end
|
||||
|
||||
def transition(conn, %{"id" => id, "to_state" => to_state} = params) do
|
||||
with {:ok, deployment} <- fetch_scoped(conn, id) do
|
||||
case Deployments.transition_state(deployment, to_state,
|
||||
reason: params["reason"],
|
||||
actor: conn.assigns.current_identity.email,
|
||||
notes: params["notes"]
|
||||
) do
|
||||
{:ok, updated} ->
|
||||
json(conn, %{deployment: shape(updated)})
|
||||
|
||||
{:error, {:invalid_transition, from, to}} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "invalid_transition", from: from, to: to})
|
||||
end
|
||||
else
|
||||
{:halt, conn} -> conn
|
||||
end
|
||||
end
|
||||
|
||||
def subscribe(conn, %{"id" => id, "plan_code" => plan_code} = params) do
|
||||
with {:ok, deployment} <- fetch_scoped(conn, id) do
|
||||
plan = Catalog.get_plan_by_code(plan_code)
|
||||
version = plan && Catalog.active_version(plan)
|
||||
|
||||
cond do
|
||||
is_nil(version) ->
|
||||
conn |> put_status(:not_found) |> json(%{error: "plan_or_version_not_found"})
|
||||
|
||||
Subscriptions.get_subscription_for_deployment(deployment.id) ->
|
||||
conn |> put_status(:conflict) |> json(%{error: "subscription_exists"})
|
||||
|
||||
true ->
|
||||
{:ok, sub} = Subscriptions.create_subscription(deployment.id, version.id)
|
||||
sub = attach_requested_addons(sub, params["addons"] || [])
|
||||
conn |> put_status(:created) |> json(%{subscription: shape_subscription(sub)})
|
||||
end
|
||||
else
|
||||
{:halt, conn} -> conn
|
||||
end
|
||||
end
|
||||
|
||||
# ---- helpers --------------------------------------------------------------
|
||||
|
||||
defp attach_requested_addons(sub, codes) do
|
||||
Enum.each(codes, fn code -> Subscriptions.attach_addon(sub, code) end)
|
||||
Subscriptions.get_subscription(sub.id)
|
||||
end
|
||||
|
||||
defp fetch_scoped(conn, id) do
|
||||
identity = conn.assigns.current_identity
|
||||
|
||||
case Deployments.get_deployment(id) do
|
||||
nil ->
|
||||
{:halt, conn |> put_status(:not_found) |> json(%{error: "not_found"})}
|
||||
|
||||
deployment ->
|
||||
if platform_admin?(identity) or deployment.tenant_id == identity.tenant_id do
|
||||
{:ok, deployment}
|
||||
else
|
||||
{:halt, 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(d) do
|
||||
%{
|
||||
id: d.id,
|
||||
tenant_id: d.tenant_id,
|
||||
slug: d.slug,
|
||||
display_name: d.display_name,
|
||||
region: d.region,
|
||||
state: d.state,
|
||||
state_since: d.state_since,
|
||||
llm_mode: d.llm_mode,
|
||||
template_code: d.template_code,
|
||||
billing_action_suspended: d.billing_action_suspended
|
||||
}
|
||||
end
|
||||
|
||||
defp shape_subscription(nil), do: nil
|
||||
|
||||
defp shape_subscription(sub) do
|
||||
%{
|
||||
id: sub.id,
|
||||
status: sub.status,
|
||||
plan_version_id: sub.plan_version_id,
|
||||
current_period_start: sub.current_period_start,
|
||||
current_period_end: sub.current_period_end,
|
||||
trial_ends_at: sub.trial_ends_at,
|
||||
addons:
|
||||
Enum.map(sub.addons, fn a ->
|
||||
%{
|
||||
addon_id: a.addon_id,
|
||||
resource_kind: a.resource_kind,
|
||||
qty: a.qty,
|
||||
price_cents: a.price_cents
|
||||
}
|
||||
end)
|
||||
}
|
||||
end
|
||||
|
||||
defp shape_event(e) do
|
||||
%{
|
||||
from_state: e.from_state,
|
||||
to_state: e.to_state,
|
||||
reason: e.reason,
|
||||
actor: e.actor,
|
||||
occurred_at: e.occurred_at
|
||||
}
|
||||
end
|
||||
|
||||
defp errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {k, v}, acc -> String.replace(acc, "%{#{k}}", to_string(v)) end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -28,5 +28,11 @@ defmodule ArcadiaCloudWeb.Router do
|
||||
|
||||
get "/catalog/plans", CatalogController, :plans
|
||||
get "/catalog/addons", CatalogController, :addons
|
||||
|
||||
get "/deployments", DeploymentController, :index
|
||||
post "/deployments", DeploymentController, :create
|
||||
get "/deployments/:id", DeploymentController, :show
|
||||
post "/deployments/:id/transition", DeploymentController, :transition
|
||||
post "/deployments/:id/subscribe", DeploymentController, :subscribe
|
||||
end
|
||||
end
|
||||
|
||||
88
priv/repo/migrations/20260520150000_create_deployments.exs
Normal file
88
priv/repo/migrations/20260520150000_create_deployments.exs
Normal 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
|
||||
Reference in New Issue
Block a user