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>
This commit is contained in:
@@ -25,6 +25,22 @@ defmodule ArcadiaCloud.Catalog do
|
|||||||
Repo.get_by(Plan, code: code)
|
Repo.get_by(Plan, code: code)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "A plan with every version (newest first) + each version's items."
|
||||||
|
def get_plan(id) do
|
||||||
|
case Repo.get(Plan, id) do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
plan ->
|
||||||
|
versions =
|
||||||
|
from(v in PlanVersion, where: v.plan_id == ^plan.id, order_by: [desc: v.version])
|
||||||
|
|> Repo.all()
|
||||||
|
|> Repo.preload(:items)
|
||||||
|
|
||||||
|
%{plan | versions: versions}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def create_plan(attrs) do
|
def create_plan(attrs) do
|
||||||
%Plan{}
|
%Plan{}
|
||||||
|> Plan.changeset(attrs)
|
|> Plan.changeset(attrs)
|
||||||
@@ -69,6 +85,48 @@ defmodule ArcadiaCloud.Catalog do
|
|||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a new draft version of a plan together with its plan items, in
|
||||||
|
one transaction. The version number is assigned automatically. Pricing
|
||||||
|
edits are always a new version — never an in-place mutation — so
|
||||||
|
existing subscriptions keep the terms they signed up on.
|
||||||
|
|
||||||
|
`items` is a list of maps (string or atom keys) shaped like PlanItem.
|
||||||
|
Returns `{:ok, version}` (with items preloaded) or `{:error, changeset}`.
|
||||||
|
"""
|
||||||
|
def create_plan_version(plan_id, attrs, items) when is_list(items) do
|
||||||
|
version_attrs =
|
||||||
|
attrs
|
||||||
|
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||||
|
|> Map.merge(%{
|
||||||
|
"plan_id" => plan_id,
|
||||||
|
"version" => next_version_number(plan_id),
|
||||||
|
"status" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
version =
|
||||||
|
case %PlanVersion{} |> PlanVersion.changeset(version_attrs) |> Repo.insert() do
|
||||||
|
{:ok, v} -> v
|
||||||
|
{:error, cs} -> Repo.rollback(cs)
|
||||||
|
end
|
||||||
|
|
||||||
|
Enum.each(items, fn item ->
|
||||||
|
item_attrs =
|
||||||
|
item
|
||||||
|
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|
||||||
|
|> Map.put("plan_version_id", version.id)
|
||||||
|
|
||||||
|
case %PlanItem{} |> PlanItem.changeset(item_attrs) |> Repo.insert() do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, cs} -> Repo.rollback(cs)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
get_version!(version.id)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Publishes a draft version: retires any currently-active version of the
|
Publishes a draft version: retires any currently-active version of the
|
||||||
same plan, then flips this one to active.
|
same plan, then flips this one to active.
|
||||||
|
|||||||
@@ -1,29 +1,157 @@
|
|||||||
defmodule ArcadiaCloudWeb.CatalogController do
|
defmodule ArcadiaCloudWeb.CatalogController do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Read-only pricing catalog. Available to any authenticated tenant — the
|
Pricing catalog. The list endpoints (`plans`, `addons`) are readable by
|
||||||
catalog is what they pick a plan from. Pricing changes are operator
|
any authenticated tenant — that's what they pick a plan from.
|
||||||
actions; no write endpoints here for MVP (seed/CLI only).
|
|
||||||
|
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
|
use ArcadiaCloudWeb, :controller
|
||||||
|
|
||||||
alias ArcadiaCloud.Catalog
|
alias ArcadiaCloud.Catalog
|
||||||
|
|
||||||
|
# ---- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
def plans(conn, _params) do
|
def plans(conn, _params) do
|
||||||
plans =
|
plans =
|
||||||
Catalog.list_plans()
|
Catalog.list_plans()
|
||||||
|> Enum.map(fn plan ->
|
|> Enum.map(fn plan -> shape_plan(plan, Catalog.active_version(plan)) end)
|
||||||
version = Catalog.active_version(plan)
|
|
||||||
shape_plan(plan, version)
|
|
||||||
end)
|
|
||||||
|
|
||||||
json(conn, %{plans: plans})
|
json(conn, %{plans: plans})
|
||||||
end
|
end
|
||||||
|
|
||||||
def addons(conn, _params) do
|
def addons(conn, _params) do
|
||||||
addons =
|
json(conn, %{addons: Enum.map(Catalog.list_addons(), &shape_addon/1)})
|
||||||
Catalog.list_addons()
|
end
|
||||||
|> Enum.map(fn a ->
|
|
||||||
|
@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,
|
code: a.code,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
@@ -32,36 +160,26 @@ defmodule ArcadiaCloudWeb.CatalogController do
|
|||||||
price_cents: a.price_cents,
|
price_cents: a.price_cents,
|
||||||
currency: a.currency
|
currency: a.currency
|
||||||
}
|
}
|
||||||
end)
|
|
||||||
|
|
||||||
json(conn, %{addons: addons})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp shape_plan(plan, nil) do
|
# ---- helpers --------------------------------------------------------------
|
||||||
%{code: plan.code, name: plan.name, description: plan.description, active_version: nil}
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
defp shape_plan(plan, version) do
|
defp errors(changeset) do
|
||||||
%{
|
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||||
code: plan.code,
|
Enum.reduce(opts, msg, fn {k, v}, acc -> String.replace(acc, "%{#{k}}", to_string(v)) end)
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ defmodule ArcadiaCloudWeb.Router do
|
|||||||
post "/drift/:id/accept", DriftController, :accept
|
post "/drift/:id/accept", DriftController, :accept
|
||||||
|
|
||||||
get "/catalog/plans", CatalogController, :plans
|
get "/catalog/plans", CatalogController, :plans
|
||||||
|
get "/catalog/plans/:id", CatalogController, :show
|
||||||
|
post "/catalog/plans", CatalogController, :create_plan
|
||||||
|
post "/catalog/plans/:plan_id/versions", CatalogController, :create_version
|
||||||
|
post "/catalog/versions/:id/publish", CatalogController, :publish_version
|
||||||
get "/catalog/addons", CatalogController, :addons
|
get "/catalog/addons", CatalogController, :addons
|
||||||
|
post "/catalog/addons", CatalogController, :create_addon
|
||||||
|
|
||||||
post "/quote", QuoteController, :create
|
post "/quote", QuoteController, :create
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user