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