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:
27
lib/arcadia_cloud/catalog/addon.ex
Normal file
27
lib/arcadia_cloud/catalog/addon.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule ArcadiaCloud.Catalog.Addon do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "addons" do
|
||||
field :code, :string
|
||||
field :name, :string
|
||||
field :resource_kind, :string
|
||||
field :qty, :decimal, default: Decimal.new(0)
|
||||
field :price_cents, :integer, default: 0
|
||||
field :currency, :string, default: "AUD"
|
||||
field :active, :boolean, default: true
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(addon, attrs) do
|
||||
addon
|
||||
|> cast(attrs, [:code, :name, :resource_kind, :qty, :price_cents, :currency, :active])
|
||||
|> validate_required([:code, :name, :resource_kind, :qty, :price_cents])
|
||||
|> validate_length(:currency, is: 3)
|
||||
|> unique_constraint(:code)
|
||||
end
|
||||
end
|
||||
25
lib/arcadia_cloud/catalog/plan.ex
Normal file
25
lib/arcadia_cloud/catalog/plan.ex
Normal file
@@ -0,0 +1,25 @@
|
||||
defmodule ArcadiaCloud.Catalog.Plan do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "plans" do
|
||||
field :code, :string
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :active, :boolean, default: true
|
||||
|
||||
has_many :versions, ArcadiaCloud.Catalog.PlanVersion
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(plan, attrs) do
|
||||
plan
|
||||
|> cast(attrs, [:code, :name, :description, :active])
|
||||
|> validate_required([:code, :name])
|
||||
|> unique_constraint(:code)
|
||||
end
|
||||
end
|
||||
33
lib/arcadia_cloud/catalog/plan_item.ex
Normal file
33
lib/arcadia_cloud/catalog/plan_item.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule ArcadiaCloud.Catalog.PlanItem do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "plan_items" do
|
||||
field :resource_kind, :string
|
||||
field :included_qty, :decimal, default: Decimal.new(0)
|
||||
field :overage_unit, :string
|
||||
field :overage_price_cents, :integer
|
||||
field :hard_cap_qty, :decimal
|
||||
|
||||
belongs_to :plan_version, ArcadiaCloud.Catalog.PlanVersion
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(item, attrs) do
|
||||
item
|
||||
|> cast(attrs, [
|
||||
:plan_version_id,
|
||||
:resource_kind,
|
||||
:included_qty,
|
||||
:overage_unit,
|
||||
:overage_price_cents,
|
||||
:hard_cap_qty
|
||||
])
|
||||
|> validate_required([:plan_version_id, :resource_kind, :included_qty])
|
||||
|> unique_constraint([:plan_version_id, :resource_kind])
|
||||
end
|
||||
end
|
||||
31
lib/arcadia_cloud/catalog/plan_version.ex
Normal file
31
lib/arcadia_cloud/catalog/plan_version.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule ArcadiaCloud.Catalog.PlanVersion do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@statuses ~w(draft active retired)
|
||||
|
||||
schema "plan_versions" do
|
||||
field :version, :integer
|
||||
field :base_price_cents, :integer, default: 0
|
||||
field :currency, :string, default: "AUD"
|
||||
field :status, :string, default: "draft"
|
||||
field :published_at, :utc_datetime
|
||||
|
||||
belongs_to :plan, ArcadiaCloud.Catalog.Plan
|
||||
has_many :items, ArcadiaCloud.Catalog.PlanItem
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(version, attrs) do
|
||||
version
|
||||
|> cast(attrs, [:plan_id, :version, :base_price_cents, :currency, :status, :published_at])
|
||||
|> validate_required([:plan_id, :version])
|
||||
|> validate_inclusion(:status, @statuses)
|
||||
|> validate_length(:currency, is: 3)
|
||||
|> unique_constraint([:plan_id, :version])
|
||||
end
|
||||
end
|
||||
32
lib/arcadia_cloud/catalog/resource_price.ex
Normal file
32
lib/arcadia_cloud/catalog/resource_price.ex
Normal file
@@ -0,0 +1,32 @@
|
||||
defmodule ArcadiaCloud.Catalog.ResourcePrice do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "resource_prices" do
|
||||
field :resource_kind, :string
|
||||
field :unit, :string
|
||||
field :unit_price_cents, :integer
|
||||
field :currency, :string, default: "AUD"
|
||||
field :effective_from, :date
|
||||
field :effective_to, :date
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(price, attrs) do
|
||||
price
|
||||
|> cast(attrs, [
|
||||
:resource_kind,
|
||||
:unit,
|
||||
:unit_price_cents,
|
||||
:currency,
|
||||
:effective_from,
|
||||
:effective_to
|
||||
])
|
||||
|> validate_required([:resource_kind, :unit, :unit_price_cents, :effective_from])
|
||||
|> validate_length(:currency, is: 3)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user