Phase 3: pricing catalog
Five catalog tables:
- plans — plan identity (code, name); the stable thing.
- plan_versions — versioned pricing (base_price_cents, currency,
status draft/active/retired). A subscription binds
to a version; raising prices = publish a new
version, existing subs unaffected until migrated.
- plan_items — what a version includes per resource_kind, plus
overage terms (overage_unit, overage_price_cents,
hard_cap_qty).
- addons — a la carte upgrades (code, resource_kind, qty,
price_cents).
- resource_prices — effective-dated fallback per-unit pricing for
ad-hoc items not covered by a plan.
ArcadiaCloud.Catalog context: plan + version CRUD, active_version/1
(what a new signup gets), publish_version/1 (retires the prior active
version transactionally then activates the new one),
current_resource_price/2 (effective-dated lookup).
Seed (priv/repo/seeds/catalog_seed.exs, idempotent) creates three AUD
plans — Starter $20, Studio $50, Pro $120/mo — with included
droplet_hours / spaces_gb_month / snapshot_gb_month / bandwidth_gb /
dns_zones (and LLM token allowances on Studio + Pro), plus three storage
/ LLM addons. Prices are placeholders to tune against real DO COGS once
the cost-vs-revenue dashboard lands.
API (authenticated tenants — the catalog is what they pick from):
- GET /api/v1/catalog/plans — plans with active version + items
- GET /api/v1/catalog/addons
Smoke verified: seed creates 3 plans + 3 addons; endpoints return the
shaped catalog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
133
lib/arcadia_cloud/catalog.ex
Normal file
133
lib/arcadia_cloud/catalog.ex
Normal file
@@ -0,0 +1,133 @@
|
||||
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
|
||||
|
||||
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 """
|
||||
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
|
||||
Reference in New Issue
Block a user