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:
101
priv/repo/seeds/catalog_seed.exs
Normal file
101
priv/repo/seeds/catalog_seed.exs
Normal file
@@ -0,0 +1,101 @@
|
||||
# Seed example hosting plans. Idempotent — skips plans that already exist.
|
||||
# Run with: mix run priv/repo/seeds/catalog_seed.exs
|
||||
#
|
||||
# Prices are AUD placeholders for Sky AI's hosted-tenant offering; tune
|
||||
# them against real DO COGS once the cost-vs-revenue dashboard is live.
|
||||
|
||||
alias ArcadiaCloud.Catalog
|
||||
|
||||
# {plan_code, name, description, base_price_cents, [plan_items]}
|
||||
# plan_item: {resource_kind, included_qty, overage_unit, overage_price_cents, hard_cap_qty}
|
||||
plans = [
|
||||
{"starter", "Starter", "Single small instance — good for a low-traffic app.", 2000,
|
||||
[
|
||||
{"droplet_hours", 744, "hour", 1, nil},
|
||||
{"spaces_gb_month", 25, "gb_month", 2, nil},
|
||||
{"snapshot_gb_month", 10, "gb_month", 3, nil},
|
||||
{"bandwidth_gb", 1000, "gb", 1, nil},
|
||||
{"dns_zones", 1, "zone", 100, nil}
|
||||
]},
|
||||
{"studio", "Studio", "Two instances + room for assets and a managed LLM budget.", 5000,
|
||||
[
|
||||
{"droplet_hours", 1488, "hour", 1, nil},
|
||||
{"spaces_gb_month", 100, "gb_month", 2, nil},
|
||||
{"snapshot_gb_month", 50, "gb_month", 3, nil},
|
||||
{"bandwidth_gb", 3000, "gb", 1, nil},
|
||||
{"dns_zones", 3, "zone", 100, nil},
|
||||
{"llm_tokens_input", 1_000_000, "1k_tokens", 1, nil},
|
||||
{"llm_tokens_output", 200_000, "1k_tokens", 3, nil}
|
||||
]},
|
||||
{"pro", "Pro", "Production workloads — generous compute, storage and LLM allowance.", 12000,
|
||||
[
|
||||
{"droplet_hours", 4464, "hour", 1, nil},
|
||||
{"spaces_gb_month", 500, "gb_month", 2, nil},
|
||||
{"snapshot_gb_month", 200, "gb_month", 3, nil},
|
||||
{"bandwidth_gb", 10000, "gb", 1, nil},
|
||||
{"dns_zones", 10, "zone", 100, nil},
|
||||
{"llm_tokens_input", 10_000_000, "1k_tokens", 1, nil},
|
||||
{"llm_tokens_output", 2_000_000, "1k_tokens", 3, nil}
|
||||
]}
|
||||
]
|
||||
|
||||
for {code, name, desc, base_cents, items} <- plans do
|
||||
case Catalog.get_plan_by_code(code) do
|
||||
nil ->
|
||||
{:ok, plan} = Catalog.create_plan(%{code: code, name: name, description: desc})
|
||||
|
||||
{:ok, version} =
|
||||
Catalog.create_version(%{
|
||||
plan_id: plan.id,
|
||||
version: 1,
|
||||
base_price_cents: base_cents,
|
||||
currency: "AUD",
|
||||
status: "draft"
|
||||
})
|
||||
|
||||
for {kind, incl, unit, overage, cap} <- items do
|
||||
{:ok, _} =
|
||||
Catalog.add_plan_item(%{
|
||||
plan_version_id: version.id,
|
||||
resource_kind: kind,
|
||||
included_qty: incl,
|
||||
overage_unit: unit,
|
||||
overage_price_cents: overage,
|
||||
hard_cap_qty: cap
|
||||
})
|
||||
end
|
||||
|
||||
{:ok, _} = Catalog.publish_version(version)
|
||||
IO.puts("seeded plan #{code} (#{length(items)} items) @ $#{base_cents / 100}/mo AUD")
|
||||
|
||||
_existing ->
|
||||
IO.puts("plan #{code} already exists — skipped")
|
||||
end
|
||||
end
|
||||
|
||||
# Example addons
|
||||
addons = [
|
||||
{"storage_50gb", "+50 GB Spaces storage", "spaces_gb_month", 50, 750},
|
||||
{"storage_250gb", "+250 GB Spaces storage", "spaces_gb_month", 250, 3000},
|
||||
{"llm_5m_input", "+5M LLM input tokens", "llm_tokens_input", 5_000_000, 4000}
|
||||
]
|
||||
|
||||
for {code, name, kind, qty, price} <- addons do
|
||||
case ArcadiaCloud.Repo.get_by(ArcadiaCloud.Catalog.Addon, code: code) do
|
||||
nil ->
|
||||
{:ok, _} =
|
||||
Catalog.create_addon(%{
|
||||
code: code,
|
||||
name: name,
|
||||
resource_kind: kind,
|
||||
qty: qty,
|
||||
price_cents: price,
|
||||
currency: "AUD"
|
||||
})
|
||||
|
||||
IO.puts("seeded addon #{code} @ $#{price / 100}/mo AUD")
|
||||
|
||||
_ ->
|
||||
IO.puts("addon #{code} already exists — skipped")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user