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