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.
79 lines
2.8 KiB
Elixir
79 lines
2.8 KiB
Elixir
defmodule Towerops.Visp.Sync do
|
|
@moduledoc """
|
|
Orchestrates syncing data from the VISP REST API into the local database.
|
|
|
|
Pulls subscribers, sites, and equipment from VISP
|
|
and updates integration sync status.
|
|
"""
|
|
|
|
alias Towerops.Integrations
|
|
alias Towerops.Visp.Client
|
|
|
|
require Logger
|
|
|
|
@doc """
|
|
Main entry point: syncs all entity types for the given integration.
|
|
|
|
Returns `{:ok, %{subscribers: n, sites: n, equipment: n}}`
|
|
or `{:error, reason}`.
|
|
"""
|
|
def sync_organization(%Integrations.Integration{} = integration) do
|
|
api_key = integration.credentials["api_key"]
|
|
|
|
with {:ok, subscribers} <- Client.list_subscribers(api_key),
|
|
{:ok, sites} <- Client.list_sites(api_key),
|
|
{:ok, equipment} <- Client.list_equipment(api_key) do
|
|
subscribers_count = length(subscribers)
|
|
sites_count = length(sites)
|
|
equipment_count = length(equipment)
|
|
|
|
total_mrr = calculate_total_mrr(subscribers)
|
|
|
|
message =
|
|
"Synced #{subscribers_count} subscribers, #{sites_count} sites, #{equipment_count} equipment" <>
|
|
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, total_mrr)
|
|
|
|
{:ok,
|
|
%{
|
|
subscribers: subscribers_count,
|
|
sites: sites_count,
|
|
equipment: equipment_count
|
|
}}
|
|
else
|
|
{:error, reason} ->
|
|
Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason))
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
defp calculate_total_mrr(subscribers) do
|
|
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"
|
|
defp humanize_sync_error({:unexpected_status, status}), do: "VISP returned unexpected HTTP #{status}"
|
|
defp humanize_sync_error(reason) when is_binary(reason), do: reason
|
|
|
|
defp humanize_sync_error(reason) do
|
|
Logger.warning("VISP sync failed with unexpected error: #{inspect(reason)}")
|
|
"An unexpected error occurred during sync: #{inspect(reason)}"
|
|
end
|
|
end
|