From 991001f95afacc3ed9bcc48e620d96fad09ea986 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 10 Mar 2026 12:17:42 -0500 Subject: [PATCH] fix(critical): ensure all money math uses Decimal, never floating point CRITICAL FINANCIAL BUGS FIXED: 1. Stripe price creation was sending dollars instead of cents - create_price() now converts $2.00 -> 200 cents - Was sending "2.00" to unit_amount_decimal (0.02 dollars!) - Now correctly sends "200.00" (2 dollars in cents) 2. VISP sync was using floating point arithmetic for MRR - calculate_total_mrr() was using 0.0 accumulator and float math - Now uses Decimal.new("0") and Decimal.add() throughout - Prevents precision loss when summing subscriber MRR All money calculations now use Decimal library to avoid floating point precision errors. Amounts are only converted to cents (integers) at the boundary when sending to Stripe API. --- lib/towerops/billing/stripe_client.ex | 15 +++++++++++++-- lib/towerops/visp/sync.ex | 20 +++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/towerops/billing/stripe_client.ex b/lib/towerops/billing/stripe_client.ex index 06ed7242..f29351e3 100644 --- a/lib/towerops/billing/stripe_client.ex +++ b/lib/towerops/billing/stripe_client.ex @@ -130,13 +130,24 @@ defmodule Towerops.Billing.StripeClient do @doc """ Create a new Price object for metered billing. + Accepts price in dollars (e.g. "2.00") and converts to cents for Stripe API. + Stripe's unit_amount_decimal expects cents, not dollars. + Returns {:ok, price_object} or {:error, reason}. """ - def create_price(unit_amount_decimal) when is_binary(unit_amount_decimal) do + def create_price(unit_amount_dollars) when is_binary(unit_amount_dollars) do + # Convert dollars to cents (Stripe expects cents) + # "2.00" -> Decimal.new("2.00") -> Decimal.mult(100) -> "200.00" + unit_amount_cents = + unit_amount_dollars + |> Decimal.new() + |> Decimal.mult(Decimal.new("100")) + |> Decimal.to_string() + params = %{ product: stripe_product_id(), currency: "usd", - unit_amount_decimal: unit_amount_decimal, + unit_amount_decimal: unit_amount_cents, recurring: %{ interval: "month", usage_type: "metered", diff --git a/lib/towerops/visp/sync.ex b/lib/towerops/visp/sync.ex index 213431b9..a059aeea 100644 --- a/lib/towerops/visp/sync.ex +++ b/lib/towerops/visp/sync.ex @@ -31,10 +31,10 @@ defmodule Towerops.Visp.Sync do message = "Synced #{subscribers_count} subscribers, #{sites_count} sites, #{equipment_count} equipment" <> - if(total_mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(total_mrr / 1, decimals: 2)})", else: "") + if(Decimal.compare(total_mrr, "0") == :gt, do: " (MRR: $#{Decimal.round(total_mrr, 2)})", else: "") Integrations.update_sync_status(integration, "success", message) - Integrations.update_billing_totals(integration, subscribers_count, Decimal.from_float(total_mrr)) + Integrations.update_billing_totals(integration, subscribers_count, total_mrr) {:ok, %{ @@ -50,12 +50,22 @@ defmodule Towerops.Visp.Sync do end defp calculate_total_mrr(subscribers) do - Enum.reduce(subscribers, 0.0, fn sub, acc -> - mrr = sub["mrr"] - if is_number(mrr), do: acc + mrr, else: acc + Enum.reduce(subscribers, Decimal.new("0"), fn sub, acc -> + add_subscriber_mrr(sub["mrr"], acc) end) end + defp add_subscriber_mrr(mrr, acc) when is_float(mrr) do + # Convert float to Decimal to avoid floating point math + Decimal.add(acc, Decimal.from_float(mrr)) + end + + defp add_subscriber_mrr(mrr, acc) when is_integer(mrr) do + Decimal.add(acc, Decimal.new(mrr)) + end + + defp add_subscriber_mrr(_mrr, acc), do: acc + defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key" defp humanize_sync_error(:forbidden), do: "Access denied — your API key may not have sufficient permissions" defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by VISP — retry after #{retry_after}s"