From c10f87b6e0f60785e157126921a1e143be75972b Mon Sep 17 00:00:00 2001 From: Giuliano Silvestro Date: Wed, 20 May 2026 14:29:52 +1000 Subject: [PATCH] Phase 3: pricing catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/arcadia_cloud/catalog.ex | 133 ++++++++++++++++++ lib/arcadia_cloud/catalog/addon.ex | 27 ++++ lib/arcadia_cloud/catalog/plan.ex | 25 ++++ lib/arcadia_cloud/catalog/plan_item.ex | 33 +++++ lib/arcadia_cloud/catalog/plan_version.ex | 31 ++++ lib/arcadia_cloud/catalog/resource_price.ex | 32 +++++ .../controllers/catalog_controller.ex | 67 +++++++++ lib/arcadia_cloud_web/router.ex | 3 + .../20260520140000_create_catalog.exs | 87 ++++++++++++ priv/repo/seeds/catalog_seed.exs | 101 +++++++++++++ 10 files changed, 539 insertions(+) create mode 100644 lib/arcadia_cloud/catalog.ex create mode 100644 lib/arcadia_cloud/catalog/addon.ex create mode 100644 lib/arcadia_cloud/catalog/plan.ex create mode 100644 lib/arcadia_cloud/catalog/plan_item.ex create mode 100644 lib/arcadia_cloud/catalog/plan_version.ex create mode 100644 lib/arcadia_cloud/catalog/resource_price.ex create mode 100644 lib/arcadia_cloud_web/controllers/catalog_controller.ex create mode 100644 priv/repo/migrations/20260520140000_create_catalog.exs create mode 100644 priv/repo/seeds/catalog_seed.exs diff --git a/lib/arcadia_cloud/catalog.ex b/lib/arcadia_cloud/catalog.ex new file mode 100644 index 0000000..f13244b --- /dev/null +++ b/lib/arcadia_cloud/catalog.ex @@ -0,0 +1,133 @@ +defmodule ArcadiaCloud.Catalog do + @moduledoc """ + Pricing catalog — plans, versioned pricing, plan items (included + resources + overage), addons, fallback resource prices. + + Versioning: a subscription binds to a specific plan_version. Raising + prices = publish a new version; existing subs are unaffected until + explicitly migrated. `active_version/1` returns the version new + signups get. + """ + + import Ecto.Query, warn: false + + alias ArcadiaCloud.Repo + alias ArcadiaCloud.Catalog.{Plan, PlanVersion, PlanItem, Addon, ResourcePrice} + + # ---- plans ---------------------------------------------------------------- + + def list_plans do + from(p in Plan, where: p.active == true, order_by: p.code) + |> Repo.all() + end + + def get_plan_by_code(code) do + Repo.get_by(Plan, code: code) + end + + def create_plan(attrs) do + %Plan{} + |> Plan.changeset(attrs) + |> Repo.insert() + end + + # ---- versions ------------------------------------------------------------- + + @doc "The version a new subscription to this plan would get." + def active_version(%Plan{id: plan_id}), do: active_version(plan_id) + + def active_version(plan_id) when is_binary(plan_id) do + from(v in PlanVersion, + where: v.plan_id == ^plan_id and v.status == "active", + order_by: [desc: v.version], + limit: 1, + preload: [:items] + ) + |> Repo.one() + end + + def get_version!(id), do: Repo.get!(PlanVersion, id) |> Repo.preload([:items, :plan]) + + def get_version(id) do + case Repo.get(PlanVersion, id) do + nil -> nil + v -> Repo.preload(v, [:items, :plan]) + end + end + + def next_version_number(plan_id) do + max = + from(v in PlanVersion, where: v.plan_id == ^plan_id, select: max(v.version)) + |> Repo.one() + + (max || 0) + 1 + end + + def create_version(attrs) do + %PlanVersion{} + |> PlanVersion.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Publishes a draft version: retires any currently-active version of the + same plan, then flips this one to active. + """ + def publish_version(%PlanVersion{} = version) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.transaction(fn -> + from(v in PlanVersion, + where: v.plan_id == ^version.plan_id and v.status == "active" + ) + |> Repo.update_all(set: [status: "retired", updated_at: now]) + + version + |> PlanVersion.changeset(%{status: "active", published_at: now}) + |> Repo.update!() + end) + end + + # ---- plan items ----------------------------------------------------------- + + def add_plan_item(attrs) do + %PlanItem{} + |> PlanItem.changeset(attrs) + |> Repo.insert() + end + + # ---- addons --------------------------------------------------------------- + + def list_addons do + from(a in Addon, where: a.active == true, order_by: a.code) |> Repo.all() + end + + def get_addons(codes) when is_list(codes) do + from(a in Addon, where: a.code in ^codes) |> Repo.all() + end + + def create_addon(attrs) do + %Addon{} + |> Addon.changeset(attrs) + |> Repo.insert() + end + + # ---- resource prices ------------------------------------------------------ + + def current_resource_price(resource_kind, on_date \\ Date.utc_today()) do + from(p in ResourcePrice, + where: + p.resource_kind == ^resource_kind and p.effective_from <= ^on_date and + (is_nil(p.effective_to) or p.effective_to >= ^on_date), + order_by: [desc: p.effective_from], + limit: 1 + ) + |> Repo.one() + end + + def create_resource_price(attrs) do + %ResourcePrice{} + |> ResourcePrice.changeset(attrs) + |> Repo.insert() + end +end diff --git a/lib/arcadia_cloud/catalog/addon.ex b/lib/arcadia_cloud/catalog/addon.ex new file mode 100644 index 0000000..c015528 --- /dev/null +++ b/lib/arcadia_cloud/catalog/addon.ex @@ -0,0 +1,27 @@ +defmodule ArcadiaCloud.Catalog.Addon do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "addons" do + field :code, :string + field :name, :string + field :resource_kind, :string + field :qty, :decimal, default: Decimal.new(0) + field :price_cents, :integer, default: 0 + field :currency, :string, default: "AUD" + field :active, :boolean, default: true + + timestamps(type: :utc_datetime) + end + + def changeset(addon, attrs) do + addon + |> cast(attrs, [:code, :name, :resource_kind, :qty, :price_cents, :currency, :active]) + |> validate_required([:code, :name, :resource_kind, :qty, :price_cents]) + |> validate_length(:currency, is: 3) + |> unique_constraint(:code) + end +end diff --git a/lib/arcadia_cloud/catalog/plan.ex b/lib/arcadia_cloud/catalog/plan.ex new file mode 100644 index 0000000..5d22418 --- /dev/null +++ b/lib/arcadia_cloud/catalog/plan.ex @@ -0,0 +1,25 @@ +defmodule ArcadiaCloud.Catalog.Plan do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "plans" do + field :code, :string + field :name, :string + field :description, :string + field :active, :boolean, default: true + + has_many :versions, ArcadiaCloud.Catalog.PlanVersion + + timestamps(type: :utc_datetime) + end + + def changeset(plan, attrs) do + plan + |> cast(attrs, [:code, :name, :description, :active]) + |> validate_required([:code, :name]) + |> unique_constraint(:code) + end +end diff --git a/lib/arcadia_cloud/catalog/plan_item.ex b/lib/arcadia_cloud/catalog/plan_item.ex new file mode 100644 index 0000000..ac0a7ce --- /dev/null +++ b/lib/arcadia_cloud/catalog/plan_item.ex @@ -0,0 +1,33 @@ +defmodule ArcadiaCloud.Catalog.PlanItem do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "plan_items" do + field :resource_kind, :string + field :included_qty, :decimal, default: Decimal.new(0) + field :overage_unit, :string + field :overage_price_cents, :integer + field :hard_cap_qty, :decimal + + belongs_to :plan_version, ArcadiaCloud.Catalog.PlanVersion + + timestamps(type: :utc_datetime) + end + + def changeset(item, attrs) do + item + |> cast(attrs, [ + :plan_version_id, + :resource_kind, + :included_qty, + :overage_unit, + :overage_price_cents, + :hard_cap_qty + ]) + |> validate_required([:plan_version_id, :resource_kind, :included_qty]) + |> unique_constraint([:plan_version_id, :resource_kind]) + end +end diff --git a/lib/arcadia_cloud/catalog/plan_version.ex b/lib/arcadia_cloud/catalog/plan_version.ex new file mode 100644 index 0000000..1726780 --- /dev/null +++ b/lib/arcadia_cloud/catalog/plan_version.ex @@ -0,0 +1,31 @@ +defmodule ArcadiaCloud.Catalog.PlanVersion do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @statuses ~w(draft active retired) + + schema "plan_versions" do + field :version, :integer + field :base_price_cents, :integer, default: 0 + field :currency, :string, default: "AUD" + field :status, :string, default: "draft" + field :published_at, :utc_datetime + + belongs_to :plan, ArcadiaCloud.Catalog.Plan + has_many :items, ArcadiaCloud.Catalog.PlanItem + + timestamps(type: :utc_datetime) + end + + def changeset(version, attrs) do + version + |> cast(attrs, [:plan_id, :version, :base_price_cents, :currency, :status, :published_at]) + |> validate_required([:plan_id, :version]) + |> validate_inclusion(:status, @statuses) + |> validate_length(:currency, is: 3) + |> unique_constraint([:plan_id, :version]) + end +end diff --git a/lib/arcadia_cloud/catalog/resource_price.ex b/lib/arcadia_cloud/catalog/resource_price.ex new file mode 100644 index 0000000..2804ef3 --- /dev/null +++ b/lib/arcadia_cloud/catalog/resource_price.ex @@ -0,0 +1,32 @@ +defmodule ArcadiaCloud.Catalog.ResourcePrice do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "resource_prices" do + field :resource_kind, :string + field :unit, :string + field :unit_price_cents, :integer + field :currency, :string, default: "AUD" + field :effective_from, :date + field :effective_to, :date + + timestamps(type: :utc_datetime) + end + + def changeset(price, attrs) do + price + |> cast(attrs, [ + :resource_kind, + :unit, + :unit_price_cents, + :currency, + :effective_from, + :effective_to + ]) + |> validate_required([:resource_kind, :unit, :unit_price_cents, :effective_from]) + |> validate_length(:currency, is: 3) + end +end diff --git a/lib/arcadia_cloud_web/controllers/catalog_controller.ex b/lib/arcadia_cloud_web/controllers/catalog_controller.ex new file mode 100644 index 0000000..225259a --- /dev/null +++ b/lib/arcadia_cloud_web/controllers/catalog_controller.ex @@ -0,0 +1,67 @@ +defmodule ArcadiaCloudWeb.CatalogController do + @moduledoc """ + Read-only pricing catalog. Available to any authenticated tenant — the + catalog is what they pick a plan from. Pricing changes are operator + actions; no write endpoints here for MVP (seed/CLI only). + """ + + use ArcadiaCloudWeb, :controller + + alias ArcadiaCloud.Catalog + + def plans(conn, _params) do + plans = + Catalog.list_plans() + |> Enum.map(fn plan -> + version = Catalog.active_version(plan) + shape_plan(plan, version) + end) + + json(conn, %{plans: plans}) + end + + def addons(conn, _params) do + addons = + Catalog.list_addons() + |> Enum.map(fn a -> + %{ + code: a.code, + name: a.name, + resource_kind: a.resource_kind, + qty: a.qty, + price_cents: a.price_cents, + currency: a.currency + } + end) + + json(conn, %{addons: addons}) + end + + defp shape_plan(plan, nil) do + %{code: plan.code, name: plan.name, description: plan.description, active_version: nil} + end + + defp shape_plan(plan, version) do + %{ + code: plan.code, + name: plan.name, + description: plan.description, + active_version: %{ + id: version.id, + version: version.version, + base_price_cents: version.base_price_cents, + currency: version.currency, + items: + Enum.map(version.items, fn i -> + %{ + resource_kind: i.resource_kind, + included_qty: i.included_qty, + overage_unit: i.overage_unit, + overage_price_cents: i.overage_price_cents, + hard_cap_qty: i.hard_cap_qty + } + end) + } + } + end +end diff --git a/lib/arcadia_cloud_web/router.ex b/lib/arcadia_cloud_web/router.ex index c516c6f..7a31561 100644 --- a/lib/arcadia_cloud_web/router.ex +++ b/lib/arcadia_cloud_web/router.ex @@ -25,5 +25,8 @@ defmodule ArcadiaCloudWeb.Router do get "/drift", DriftController, :index post "/drift/:id/accept", DriftController, :accept + + get "/catalog/plans", CatalogController, :plans + get "/catalog/addons", CatalogController, :addons end end diff --git a/priv/repo/migrations/20260520140000_create_catalog.exs b/priv/repo/migrations/20260520140000_create_catalog.exs new file mode 100644 index 0000000..a24ccd8 --- /dev/null +++ b/priv/repo/migrations/20260520140000_create_catalog.exs @@ -0,0 +1,87 @@ +defmodule ArcadiaCloud.Repo.Migrations.CreateCatalog do + use Ecto.Migration + + def change do + # Plan identity — the stable thing customers refer to ("Studio"). + create table(:plans, primary_key: false) do + add :id, :binary_id, primary_key: true + add :code, :string, null: false + add :name, :string, null: false + add :description, :text + add :active, :boolean, null: false, default: true + + timestamps(type: :utc_datetime) + end + + create unique_index(:plans, [:code]) + + # Versioned pricing. A subscription binds to a specific plan_version; + # raising prices = publishing a new version, existing subs unaffected + # until explicitly migrated. + create table(:plan_versions, primary_key: false) do + add :id, :binary_id, primary_key: true + add :plan_id, references(:plans, type: :binary_id, on_delete: :delete_all), null: false + add :version, :integer, null: false + add :base_price_cents, :integer, null: false, default: 0 + add :currency, :string, null: false, default: "AUD" + add :status, :string, null: false, default: "draft" + add :published_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:plan_versions, [:plan_id, :version]) + create index(:plan_versions, [:status]) + + # What a plan version INCLUDES, per resource kind, plus overage terms. + create table(:plan_items, primary_key: false) do + add :id, :binary_id, primary_key: true + add :plan_version_id, + references(:plan_versions, type: :binary_id, on_delete: :delete_all), + null: false + add :resource_kind, :string, null: false + add :included_qty, :decimal, null: false, default: 0 + add :overage_unit, :string + add :overage_price_cents, :integer + add :hard_cap_qty, :decimal + + timestamps(type: :utc_datetime) + end + + create unique_index(:plan_items, [:plan_version_id, :resource_kind]) + + # A la carte upgrades. Not versioned for MVP — price changes apply + # forward; existing subscription addon refs keep the price snapshotted + # on the subscription (phase 3 subscriptions chunk). + create table(:addons, primary_key: false) do + add :id, :binary_id, primary_key: true + add :code, :string, null: false + add :name, :string, null: false + add :resource_kind, :string, null: false + add :qty, :decimal, null: false, default: 0 + add :price_cents, :integer, null: false, default: 0 + add :currency, :string, null: false, default: "AUD" + add :active, :boolean, null: false, default: true + + timestamps(type: :utc_datetime) + end + + create unique_index(:addons, [:code]) + + # Fallback per-unit pricing for ad-hoc / pure-usage items not covered + # by a plan_item. Effective-dated. + create table(:resource_prices, primary_key: false) do + add :id, :binary_id, primary_key: true + add :resource_kind, :string, null: false + add :unit, :string, null: false + add :unit_price_cents, :integer, null: false + add :currency, :string, null: false, default: "AUD" + add :effective_from, :date, null: false + add :effective_to, :date + + timestamps(type: :utc_datetime) + end + + create index(:resource_prices, [:resource_kind, :effective_from]) + end +end diff --git a/priv/repo/seeds/catalog_seed.exs b/priv/repo/seeds/catalog_seed.exs new file mode 100644 index 0000000..1b54caa --- /dev/null +++ b/priv/repo/seeds/catalog_seed.exs @@ -0,0 +1,101 @@ +# Seed example hosting plans. Idempotent — skips plans that already exist. +# Run with: mix run priv/repo/seeds/catalog_seed.exs +# +# Prices are AUD placeholders for Sky AI's hosted-tenant offering; tune +# them against real DO COGS once the cost-vs-revenue dashboard is live. + +alias ArcadiaCloud.Catalog + +# {plan_code, name, description, base_price_cents, [plan_items]} +# plan_item: {resource_kind, included_qty, overage_unit, overage_price_cents, hard_cap_qty} +plans = [ + {"starter", "Starter", "Single small instance — good for a low-traffic app.", 2000, + [ + {"droplet_hours", 744, "hour", 1, nil}, + {"spaces_gb_month", 25, "gb_month", 2, nil}, + {"snapshot_gb_month", 10, "gb_month", 3, nil}, + {"bandwidth_gb", 1000, "gb", 1, nil}, + {"dns_zones", 1, "zone", 100, nil} + ]}, + {"studio", "Studio", "Two instances + room for assets and a managed LLM budget.", 5000, + [ + {"droplet_hours", 1488, "hour", 1, nil}, + {"spaces_gb_month", 100, "gb_month", 2, nil}, + {"snapshot_gb_month", 50, "gb_month", 3, nil}, + {"bandwidth_gb", 3000, "gb", 1, nil}, + {"dns_zones", 3, "zone", 100, nil}, + {"llm_tokens_input", 1_000_000, "1k_tokens", 1, nil}, + {"llm_tokens_output", 200_000, "1k_tokens", 3, nil} + ]}, + {"pro", "Pro", "Production workloads — generous compute, storage and LLM allowance.", 12000, + [ + {"droplet_hours", 4464, "hour", 1, nil}, + {"spaces_gb_month", 500, "gb_month", 2, nil}, + {"snapshot_gb_month", 200, "gb_month", 3, nil}, + {"bandwidth_gb", 10000, "gb", 1, nil}, + {"dns_zones", 10, "zone", 100, nil}, + {"llm_tokens_input", 10_000_000, "1k_tokens", 1, nil}, + {"llm_tokens_output", 2_000_000, "1k_tokens", 3, nil} + ]} +] + +for {code, name, desc, base_cents, items} <- plans do + case Catalog.get_plan_by_code(code) do + nil -> + {:ok, plan} = Catalog.create_plan(%{code: code, name: name, description: desc}) + + {:ok, version} = + Catalog.create_version(%{ + plan_id: plan.id, + version: 1, + base_price_cents: base_cents, + currency: "AUD", + status: "draft" + }) + + for {kind, incl, unit, overage, cap} <- items do + {:ok, _} = + Catalog.add_plan_item(%{ + plan_version_id: version.id, + resource_kind: kind, + included_qty: incl, + overage_unit: unit, + overage_price_cents: overage, + hard_cap_qty: cap + }) + end + + {:ok, _} = Catalog.publish_version(version) + IO.puts("seeded plan #{code} (#{length(items)} items) @ $#{base_cents / 100}/mo AUD") + + _existing -> + IO.puts("plan #{code} already exists — skipped") + end +end + +# Example addons +addons = [ + {"storage_50gb", "+50 GB Spaces storage", "spaces_gb_month", 50, 750}, + {"storage_250gb", "+250 GB Spaces storage", "spaces_gb_month", 250, 3000}, + {"llm_5m_input", "+5M LLM input tokens", "llm_tokens_input", 5_000_000, 4000} +] + +for {code, name, kind, qty, price} <- addons do + case ArcadiaCloud.Repo.get_by(ArcadiaCloud.Catalog.Addon, code: code) do + nil -> + {:ok, _} = + Catalog.create_addon(%{ + code: code, + name: name, + resource_kind: kind, + qty: qty, + price_cents: price, + currency: "AUD" + }) + + IO.puts("seeded addon #{code} @ $#{price / 100}/mo AUD") + + _ -> + IO.puts("addon #{code} already exists — skipped") + end +end