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