Files
arcadia-cloud/lib/arcadia_cloud_web/controllers/catalog_controller.ex
Giuliano Silvestro 34fd811e91 Add operator write endpoints for the pricing catalog
CatalogController gains platform-admin-gated writes: create a plan,
create a draft version (with its plan items, transactionally), publish
a version, create an addon — plus a plan-detail endpoint exposing every
version. Pricing stays versioned: create_version always makes a new
draft, publish retires the prior active version, existing subscriptions
are untouched. The Catalog context functions already existed; this just
exposes them over HTTP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:21:29 +10:00

186 lines
5.3 KiB
Elixir

defmodule ArcadiaCloudWeb.CatalogController do
@moduledoc """
Pricing catalog. The list endpoints (`plans`, `addons`) are readable by
any authenticated tenant — that's what they pick a plan from.
The write endpoints are operator-only (platform-admin). Pricing is
versioned: you never edit a published plan in place. `create_version`
always makes a new DRAFT version; `publish_version` activates it and
retires the prior active one. Existing subscriptions keep the version
they were signed on until explicitly migrated.
"""
use ArcadiaCloudWeb, :controller
alias ArcadiaCloud.Catalog
# ---- reads ----------------------------------------------------------------
def plans(conn, _params) do
plans =
Catalog.list_plans()
|> Enum.map(fn plan -> shape_plan(plan, Catalog.active_version(plan)) end)
json(conn, %{plans: plans})
end
def addons(conn, _params) do
json(conn, %{addons: Enum.map(Catalog.list_addons(), &shape_addon/1)})
end
@doc "Full plan detail incl. every version (draft/active/retired). Operator-only."
def show(conn, %{"id" => id}) do
with :ok <- require_platform_admin(conn) do
case Catalog.get_plan(id) do
nil ->
conn |> put_status(:not_found) |> json(%{error: "not_found"})
plan ->
json(conn, %{
plan: %{
id: plan.id,
code: plan.code,
name: plan.name,
description: plan.description,
active: plan.active,
versions: Enum.map(plan.versions, &shape_version/1)
}
})
end
end
end
# ---- writes (operator-only) -----------------------------------------------
def create_plan(conn, params) do
with :ok <- require_platform_admin(conn) do
case Catalog.create_plan(params) do
{:ok, plan} ->
conn |> put_status(:created) |> json(%{plan: shape_plan_bare(plan)})
{:error, changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: errors(changeset)})
end
end
end
def create_version(conn, %{"plan_id" => plan_id} = params) do
with :ok <- require_platform_admin(conn) do
attrs = Map.take(params, ["base_price_cents", "currency"])
items = params["items"] || []
case Catalog.create_plan_version(plan_id, attrs, items) do
{:ok, version} ->
conn |> put_status(:created) |> json(%{version: shape_version(version)})
{:error, changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: errors(changeset)})
end
end
end
def publish_version(conn, %{"id" => id}) do
with :ok <- require_platform_admin(conn) do
case Catalog.get_version(id) do
nil ->
conn |> put_status(:not_found) |> json(%{error: "not_found"})
%{status: "retired"} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "cannot publish a retired version"})
version ->
{:ok, published} = Catalog.publish_version(version)
json(conn, %{version: shape_version(Catalog.get_version!(published.id))})
end
end
end
def create_addon(conn, params) do
with :ok <- require_platform_admin(conn) do
case Catalog.create_addon(params) do
{:ok, addon} ->
conn |> put_status(:created) |> json(%{addon: shape_addon(addon)})
{:error, changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: errors(changeset)})
end
end
end
# ---- shaping --------------------------------------------------------------
defp shape_plan_bare(plan) do
%{id: plan.id, code: plan.code, name: plan.name, description: plan.description, active: plan.active}
end
defp shape_plan(plan, nil) do
%{id: plan.id, code: plan.code, name: plan.name, description: plan.description, active_version: nil}
end
defp shape_plan(plan, version) do
%{
id: plan.id,
code: plan.code,
name: plan.name,
description: plan.description,
active_version: shape_version(version)
}
end
defp shape_version(v) do
%{
id: v.id,
version: v.version,
status: v.status,
base_price_cents: v.base_price_cents,
currency: v.currency,
published_at: v.published_at,
items: Enum.map(v.items, &shape_item/1)
}
end
defp shape_item(i) do
%{
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
defp shape_addon(a) do
%{
code: a.code,
name: a.name,
resource_kind: a.resource_kind,
qty: a.qty,
price_cents: a.price_cents,
currency: a.currency
}
end
# ---- helpers --------------------------------------------------------------
defp require_platform_admin(conn) do
identity = conn.assigns.current_identity
if is_list(identity.roles) and "platform-admin" in identity.roles do
:ok
else
conn
|> put_status(:forbidden)
|> json(%{error: "platform_admin_required"})
|> halt()
end
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