diff --git a/lib/arcadia_cloud/catalog.ex b/lib/arcadia_cloud/catalog.ex index f13244b..3718388 100644 --- a/lib/arcadia_cloud/catalog.ex +++ b/lib/arcadia_cloud/catalog.ex @@ -25,6 +25,22 @@ defmodule ArcadiaCloud.Catalog do Repo.get_by(Plan, code: code) 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 %Plan{} |> Plan.changeset(attrs) @@ -69,6 +85,48 @@ defmodule ArcadiaCloud.Catalog do |> Repo.insert() 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 """ Publishes a draft version: retires any currently-active version of the same plan, then flips this one to active. diff --git a/lib/arcadia_cloud_web/controllers/catalog_controller.ex b/lib/arcadia_cloud_web/controllers/catalog_controller.ex index 225259a..3339269 100644 --- a/lib/arcadia_cloud_web/controllers/catalog_controller.ex +++ b/lib/arcadia_cloud_web/controllers/catalog_controller.ex @@ -1,67 +1,185 @@ 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). + 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 -> - version = Catalog.active_version(plan) - shape_plan(plan, version) - end) + |> Enum.map(fn plan -> shape_plan(plan, Catalog.active_version(plan)) 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: Enum.map(Catalog.list_addons(), &shape_addon/1)}) + end - json(conn, %{addons: addons}) + @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 - %{code: plan.code, name: plan.name, description: plan.description, active_version: nil} + %{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: %{ - 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) - } + 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 diff --git a/lib/arcadia_cloud_web/router.ex b/lib/arcadia_cloud_web/router.ex index d53a5c8..b9f4539 100644 --- a/lib/arcadia_cloud_web/router.ex +++ b/lib/arcadia_cloud_web/router.ex @@ -27,7 +27,12 @@ defmodule ArcadiaCloudWeb.Router do post "/drift/:id/accept", DriftController, :accept 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 + post "/catalog/addons", CatalogController, :create_addon post "/quote", QuoteController, :create