diff --git a/lib/arcadia_cloud/quoting.ex b/lib/arcadia_cloud/quoting.ex new file mode 100644 index 0000000..1363c03 --- /dev/null +++ b/lib/arcadia_cloud/quoting.ex @@ -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 diff --git a/lib/arcadia_cloud_web/controllers/quote_controller.ex b/lib/arcadia_cloud_web/controllers/quote_controller.ex new file mode 100644 index 0000000..e5667f4 --- /dev/null +++ b/lib/arcadia_cloud_web/controllers/quote_controller.ex @@ -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 diff --git a/lib/arcadia_cloud_web/router.ex b/lib/arcadia_cloud_web/router.ex index efa6aa9..091c5a8 100644 --- a/lib/arcadia_cloud_web/router.ex +++ b/lib/arcadia_cloud_web/router.ex @@ -29,6 +29,8 @@ defmodule ArcadiaCloudWeb.Router do get "/catalog/plans", CatalogController, :plans get "/catalog/addons", CatalogController, :addons + post "/quote", QuoteController, :create + get "/deployments", DeploymentController, :index post "/deployments", DeploymentController, :create get "/deployments/:id", DeploymentController, :show