Phase 3: quote engine
ArcadiaCloud.Quoting.quote/3 — turns a plan version + addons + usage into
itemized pricing. The same function serves both provisioning-time
projection (with projected usage) and month-end invoicing (with actual
metered usage), so the two can never disagree on the math.
A quote has:
- recurring — plan base + addon lines (fixed monthly)
- first_invoice — recurring prorated to a mid-month start date
(fraction = days_remaining / days_in_month)
- overage — usage beyond plan+addon allowance, billed in arrears
- all_in_monthly_cents
Overage computation: per plan_item, effective allowance =
included_qty + summed addon qty for the same resource_kind. Overage =
max(0, used - allowance), clamped by hard_cap_qty. overage_price_cents
is per overage_unit; a @unit_factor map converts base-unit usage to
overage units (e.g. 1k_tokens divides token counts by 1000).
Lines are uniform maps (kind/resource_kind/description/qty/unit/
unit_price_cents/amount_cents/meta) — the invoice rollup chunk will
persist them near-verbatim as invoice_lines.
addon_fields/1 accepts either a catalog Addon (projection from the
catalog) or a SubscriptionAddon (actual, using snapshotted price/qty).
API: POST /api/v1/quote { plan_code, addons[], projected_usage{},
starts_on } — tenant-facing pre-provisioning price preview.
Smoke verified against seeded plans:
- Studio bare: $50/mo, no overage.
- Studio + storage_50gb, mid-month start 2026-05-20, projected usage:
recurring $57.50; first invoice prorated 12/31 -> $22.25; overage
LLM 500k-token $5.00 + Spaces 50GB $1.00 (the addon correctly lifted
the Spaces allowance 100->150 GB); droplet_hours exactly at allowance
produced no line; all-in $63.50/mo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
199
lib/arcadia_cloud/quoting.ex
Normal file
199
lib/arcadia_cloud/quoting.ex
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
defmodule ArcadiaCloud.Quoting do
|
||||||
|
@moduledoc """
|
||||||
|
The quote engine — turns a plan version + addons + usage into itemized
|
||||||
|
pricing.
|
||||||
|
|
||||||
|
Used two ways:
|
||||||
|
- projection: at provisioning time, with *projected* usage, to show a
|
||||||
|
tenant what they'd likely pay.
|
||||||
|
- actual: at month-end, with *actual* metered usage, to produce the
|
||||||
|
invoice line set.
|
||||||
|
|
||||||
|
Same function, different usage input — so projection and invoicing can
|
||||||
|
never disagree on the math.
|
||||||
|
|
||||||
|
A quote has:
|
||||||
|
recurring — plan base + addon lines (fixed monthly)
|
||||||
|
first_invoice — recurring prorated to a mid-month start date
|
||||||
|
overage — usage beyond plan+addon allowance (billed arrears)
|
||||||
|
all_in_monthly_cents
|
||||||
|
|
||||||
|
Lines are maps: %{kind, resource_kind, description, qty, unit,
|
||||||
|
unit_price_cents, amount_cents, meta}.
|
||||||
|
kind ∈ "plan_base" | "addon" | "overage".
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias ArcadiaCloud.Catalog.{PlanVersion, Addon}
|
||||||
|
alias ArcadiaCloud.Billing.SubscriptionAddon
|
||||||
|
|
||||||
|
# base unit → overage unit divisor. overage_price_cents is per overage
|
||||||
|
# unit; usage + included_qty are in the resource's base unit.
|
||||||
|
@unit_factor %{
|
||||||
|
"hour" => 1,
|
||||||
|
"gb_month" => 1,
|
||||||
|
"gb" => 1,
|
||||||
|
"zone" => 1,
|
||||||
|
"1k_tokens" => 1000,
|
||||||
|
"snapshot" => 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Build a quote.
|
||||||
|
|
||||||
|
plan_version — a PlanVersion preloaded with :items
|
||||||
|
addons — list of Addon or SubscriptionAddon structs
|
||||||
|
opts:
|
||||||
|
:usage — map of resource_kind => used qty (nil = skip overage)
|
||||||
|
:starts_on — Date; if set, first_invoice prorates from this date
|
||||||
|
"""
|
||||||
|
def quote(%PlanVersion{} = plan_version, addons, opts \\ []) do
|
||||||
|
currency = plan_version.currency
|
||||||
|
base_lines = [plan_base_line(plan_version) | addon_lines(addons)]
|
||||||
|
monthly_base = sum_amounts(base_lines)
|
||||||
|
|
||||||
|
overage_lines =
|
||||||
|
case opts[:usage] do
|
||||||
|
nil -> []
|
||||||
|
usage -> compute_overage(plan_version, addons, usage)
|
||||||
|
end
|
||||||
|
|
||||||
|
overage_total = sum_amounts(overage_lines)
|
||||||
|
|
||||||
|
%{
|
||||||
|
currency: currency,
|
||||||
|
recurring: %{lines: base_lines, monthly_total_cents: monthly_base},
|
||||||
|
first_invoice: first_invoice(base_lines, monthly_base, opts[:starts_on]),
|
||||||
|
overage: %{lines: overage_lines, monthly_total_cents: overage_total},
|
||||||
|
all_in_monthly_cents: monthly_base + overage_total
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- base lines -----------------------------------------------------------
|
||||||
|
|
||||||
|
defp plan_base_line(%PlanVersion{} = pv) do
|
||||||
|
%{
|
||||||
|
kind: "plan_base",
|
||||||
|
resource_kind: nil,
|
||||||
|
description: "Plan base",
|
||||||
|
qty: 1,
|
||||||
|
unit: "month",
|
||||||
|
unit_price_cents: pv.base_price_cents,
|
||||||
|
amount_cents: pv.base_price_cents,
|
||||||
|
meta: %{plan_version_id: pv.id, version: pv.version}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp addon_lines(addons) do
|
||||||
|
Enum.map(addons, fn addon ->
|
||||||
|
{code, kind, qty, price} = addon_fields(addon)
|
||||||
|
|
||||||
|
%{
|
||||||
|
kind: "addon",
|
||||||
|
resource_kind: kind,
|
||||||
|
description: "Addon: #{code}",
|
||||||
|
qty: 1,
|
||||||
|
unit: "month",
|
||||||
|
unit_price_cents: price,
|
||||||
|
amount_cents: price,
|
||||||
|
meta: %{addon_code: code, included_qty: to_number(qty)}
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp addon_fields(%Addon{} = a), do: {a.code, a.resource_kind, a.qty, a.price_cents}
|
||||||
|
|
||||||
|
defp addon_fields(%SubscriptionAddon{} = sa),
|
||||||
|
do: {sa.addon_id, sa.resource_kind, sa.qty, sa.price_cents}
|
||||||
|
|
||||||
|
# ---- overage --------------------------------------------------------------
|
||||||
|
|
||||||
|
defp compute_overage(%PlanVersion{items: items}, addons, usage) do
|
||||||
|
addon_allowance = addon_allowance_by_kind(addons)
|
||||||
|
|
||||||
|
items
|
||||||
|
|> Enum.flat_map(fn item ->
|
||||||
|
kind = item.resource_kind
|
||||||
|
included = to_number(item.included_qty)
|
||||||
|
allowance = included + Map.get(addon_allowance, kind, 0)
|
||||||
|
used = usage_for(usage, kind)
|
||||||
|
|
||||||
|
billable_used = apply_cap(used, item.hard_cap_qty)
|
||||||
|
overage_qty = max(billable_used - allowance, 0)
|
||||||
|
|
||||||
|
if overage_qty > 0 and is_integer(item.overage_price_cents) do
|
||||||
|
factor = Map.get(@unit_factor, item.overage_unit, 1)
|
||||||
|
units = overage_qty / factor
|
||||||
|
amount = round(units * item.overage_price_cents)
|
||||||
|
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
kind: "overage",
|
||||||
|
resource_kind: kind,
|
||||||
|
description: "#{kind} overage",
|
||||||
|
qty: overage_qty,
|
||||||
|
unit: item.overage_unit,
|
||||||
|
unit_price_cents: item.overage_price_cents,
|
||||||
|
amount_cents: amount,
|
||||||
|
meta: %{
|
||||||
|
included_qty: included,
|
||||||
|
addon_qty: Map.get(addon_allowance, kind, 0),
|
||||||
|
allowance: allowance,
|
||||||
|
used: used,
|
||||||
|
capped: billable_used != used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp addon_allowance_by_kind(addons) do
|
||||||
|
Enum.reduce(addons, %{}, fn addon, acc ->
|
||||||
|
{_code, kind, qty, _price} = addon_fields(addon)
|
||||||
|
Map.update(acc, kind, to_number(qty), &(&1 + to_number(qty)))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_cap(used, nil), do: used
|
||||||
|
defp apply_cap(used, cap), do: min(used, to_number(cap))
|
||||||
|
|
||||||
|
defp usage_for(usage, kind) do
|
||||||
|
(Map.get(usage, kind) || Map.get(usage, to_string(kind)) || 0) |> to_number()
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- proration ------------------------------------------------------------
|
||||||
|
|
||||||
|
defp first_invoice(base_lines, monthly_base, nil) do
|
||||||
|
%{period_start: nil, period_end: nil, prorated_total_cents: monthly_base, lines: base_lines}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp first_invoice(base_lines, _monthly_base, %Date{} = starts_on) do
|
||||||
|
days_in_month = Date.days_in_month(starts_on)
|
||||||
|
days_remaining = days_in_month - starts_on.day + 1
|
||||||
|
fraction = days_remaining / days_in_month
|
||||||
|
|
||||||
|
prorated_lines =
|
||||||
|
Enum.map(base_lines, fn line ->
|
||||||
|
prorated = round(line.amount_cents * fraction)
|
||||||
|
%{line | amount_cents: prorated, meta: Map.put(line.meta, :proration_fraction, fraction)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
period_start: starts_on,
|
||||||
|
period_end: Date.end_of_month(starts_on),
|
||||||
|
prorated_total_cents: sum_amounts(prorated_lines),
|
||||||
|
lines: prorated_lines
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
defp sum_amounts(lines), do: Enum.reduce(lines, 0, &(&1.amount_cents + &2))
|
||||||
|
|
||||||
|
defp to_number(%Decimal{} = d), do: Decimal.to_float(d)
|
||||||
|
defp to_number(n) when is_number(n), do: n
|
||||||
|
defp to_number(s) when is_binary(s), do: String.to_integer(s)
|
||||||
|
defp to_number(nil), do: 0
|
||||||
|
end
|
||||||
52
lib/arcadia_cloud_web/controllers/quote_controller.ex
Normal file
52
lib/arcadia_cloud_web/controllers/quote_controller.ex
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
defmodule ArcadiaCloudWeb.QuoteController do
|
||||||
|
@moduledoc """
|
||||||
|
Pricing quotes. A tenant hits this before provisioning to see what a
|
||||||
|
plan + addons would cost, optionally with projected usage to preview
|
||||||
|
likely overage.
|
||||||
|
|
||||||
|
Body:
|
||||||
|
plan_code — required
|
||||||
|
addons — optional list of addon codes
|
||||||
|
projected_usage — optional map resource_kind => qty
|
||||||
|
starts_on — optional ISO date; if set, returns prorated
|
||||||
|
first-invoice figures
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ArcadiaCloudWeb, :controller
|
||||||
|
|
||||||
|
alias ArcadiaCloud.{Catalog, Quoting}
|
||||||
|
|
||||||
|
def create(conn, params) do
|
||||||
|
plan = params["plan_code"] && Catalog.get_plan_by_code(params["plan_code"])
|
||||||
|
version = plan && Catalog.active_version(plan)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(version) ->
|
||||||
|
conn |> put_status(:not_found) |> json(%{error: "plan_or_version_not_found"})
|
||||||
|
|
||||||
|
true ->
|
||||||
|
addons = Catalog.get_addons(params["addons"] || [])
|
||||||
|
|
||||||
|
opts =
|
||||||
|
[]
|
||||||
|
|> put_opt(:usage, params["projected_usage"])
|
||||||
|
|> put_opt(:starts_on, parse_date(params["starts_on"]))
|
||||||
|
|
||||||
|
quote = Quoting.quote(version, addons, opts)
|
||||||
|
json(conn, %{quote: quote, plan_code: plan.code})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_opt(opts, _key, nil), do: opts
|
||||||
|
defp put_opt(opts, key, value), do: Keyword.put(opts, key, value)
|
||||||
|
|
||||||
|
defp parse_date(nil), do: nil
|
||||||
|
defp parse_date(""), do: nil
|
||||||
|
|
||||||
|
defp parse_date(s) do
|
||||||
|
case Date.from_iso8601(s) do
|
||||||
|
{:ok, d} -> d
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -29,6 +29,8 @@ defmodule ArcadiaCloudWeb.Router do
|
|||||||
get "/catalog/plans", CatalogController, :plans
|
get "/catalog/plans", CatalogController, :plans
|
||||||
get "/catalog/addons", CatalogController, :addons
|
get "/catalog/addons", CatalogController, :addons
|
||||||
|
|
||||||
|
post "/quote", QuoteController, :create
|
||||||
|
|
||||||
get "/deployments", DeploymentController, :index
|
get "/deployments", DeploymentController, :index
|
||||||
post "/deployments", DeploymentController, :create
|
post "/deployments", DeploymentController, :create
|
||||||
get "/deployments/:id", DeploymentController, :show
|
get "/deployments/:id", DeploymentController, :show
|
||||||
|
|||||||
Reference in New Issue
Block a user