From aee5e07b26332c03b37af9e8ca3be530861bc5e3 Mon Sep 17 00:00:00 2001 From: Giuliano Silvestro Date: Wed, 20 May 2026 15:12:15 +1000 Subject: [PATCH] Phase 3: deployment model + subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/arcadia_cloud/billing/subscription.ex | 33 ++++ .../billing/subscription_addon.ex | 29 +++ lib/arcadia_cloud/deployments.ex | 137 +++++++++++++ .../deployments/cloud_deployment.ex | 45 +++++ .../deployments/cloud_deployment_event.ex | 27 +++ lib/arcadia_cloud/subscriptions.ex | 110 +++++++++++ .../controllers/deployment_controller.ex | 184 ++++++++++++++++++ lib/arcadia_cloud_web/router.ex | 6 + .../20260520150000_create_deployments.exs | 88 +++++++++ 9 files changed, 659 insertions(+) create mode 100644 lib/arcadia_cloud/billing/subscription.ex create mode 100644 lib/arcadia_cloud/billing/subscription_addon.ex create mode 100644 lib/arcadia_cloud/deployments.ex create mode 100644 lib/arcadia_cloud/deployments/cloud_deployment.ex create mode 100644 lib/arcadia_cloud/deployments/cloud_deployment_event.ex create mode 100644 lib/arcadia_cloud/subscriptions.ex create mode 100644 lib/arcadia_cloud_web/controllers/deployment_controller.ex create mode 100644 priv/repo/migrations/20260520150000_create_deployments.exs diff --git a/lib/arcadia_cloud/billing/subscription.ex b/lib/arcadia_cloud/billing/subscription.ex new file mode 100644 index 0000000..9ceeadb --- /dev/null +++ b/lib/arcadia_cloud/billing/subscription.ex @@ -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 diff --git a/lib/arcadia_cloud/billing/subscription_addon.ex b/lib/arcadia_cloud/billing/subscription_addon.ex new file mode 100644 index 0000000..f3b6f7d --- /dev/null +++ b/lib/arcadia_cloud/billing/subscription_addon.ex @@ -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 diff --git a/lib/arcadia_cloud/deployments.ex b/lib/arcadia_cloud/deployments.ex new file mode 100644 index 0000000..0bf5bce --- /dev/null +++ b/lib/arcadia_cloud/deployments.ex @@ -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 diff --git a/lib/arcadia_cloud/deployments/cloud_deployment.ex b/lib/arcadia_cloud/deployments/cloud_deployment.ex new file mode 100644 index 0000000..b02da5f --- /dev/null +++ b/lib/arcadia_cloud/deployments/cloud_deployment.ex @@ -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 diff --git a/lib/arcadia_cloud/deployments/cloud_deployment_event.ex b/lib/arcadia_cloud/deployments/cloud_deployment_event.ex new file mode 100644 index 0000000..80994ff --- /dev/null +++ b/lib/arcadia_cloud/deployments/cloud_deployment_event.ex @@ -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 diff --git a/lib/arcadia_cloud/subscriptions.ex b/lib/arcadia_cloud/subscriptions.ex new file mode 100644 index 0000000..ae18ab9 --- /dev/null +++ b/lib/arcadia_cloud/subscriptions.ex @@ -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 diff --git a/lib/arcadia_cloud_web/controllers/deployment_controller.ex b/lib/arcadia_cloud_web/controllers/deployment_controller.ex new file mode 100644 index 0000000..57e829b --- /dev/null +++ b/lib/arcadia_cloud_web/controllers/deployment_controller.ex @@ -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 diff --git a/lib/arcadia_cloud_web/router.ex b/lib/arcadia_cloud_web/router.ex index 7a31561..efa6aa9 100644 --- a/lib/arcadia_cloud_web/router.ex +++ b/lib/arcadia_cloud_web/router.ex @@ -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 diff --git a/priv/repo/migrations/20260520150000_create_deployments.exs b/priv/repo/migrations/20260520150000_create_deployments.exs new file mode 100644 index 0000000..39a6e73 --- /dev/null +++ b/priv/repo/migrations/20260520150000_create_deployments.exs @@ -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