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>
192 lines
5.2 KiB
Elixir
192 lines
5.2 KiB
Elixir
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
|
|
|
|
@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)
|
|
|> 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 """
|
|
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.
|
|
"""
|
|
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
|