Phase 3: pricing catalog

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:29:52 +10:00
parent b9fc4f9cf3
commit c10f87b6e0
10 changed files with 539 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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