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:
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
|
||||
Reference in New Issue
Block a user