From de075ea8d9db61db7ada0b4f53defa622f54fe3f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 15 Feb 2026 16:51:33 -0600 Subject: [PATCH 1/3] Store billing subscriber/MRR data on integrations for dashboard Each billing sync (Gaiia, Splynx, VISP, Sonar) already computes subscriber counts and MRR but only stored them in sync messages. Now persists them on the integrations table so the dashboard can aggregate across all billing providers instead of only querying Gaiia network sites (which were never populated). Also fixes SnmpKit Config environment detection to use Mix.env() as fallback when MIX_ENV env var is not set. --- lib/snmpkit/snmp_lib/config.ex | 1 + lib/towerops/dashboard.ex | 7 +- lib/towerops/gaiia.ex | 14 +++ lib/towerops/gaiia/sync.ex | 8 ++ lib/towerops/integrations.ex | 26 +++++ lib/towerops/integrations/integration.ex | 6 +- lib/towerops/sonar/sync.ex | 1 + lib/towerops/splynx/sync.ex | 1 + lib/towerops/visp/sync.ex | 1 + .../live/org/integrations_live.html.heex | 95 ++++++++++++++----- ...949_add_billing_fields_to_integrations.exs | 10 ++ test/towerops/dashboard_test.exs | 43 ++++----- test/towerops/gaiia/sync_test.exs | 12 +++ test/towerops/integrations_test.exs | 86 +++++++++++++++++ .../towerops_web/live/dashboard_live_test.exs | 15 +-- 15 files changed, 266 insertions(+), 60 deletions(-) create mode 100644 priv/repo/migrations/20260215222949_add_billing_fields_to_integrations.exs diff --git a/lib/snmpkit/snmp_lib/config.ex b/lib/snmpkit/snmp_lib/config.ex index 61579daf..5039b018 100644 --- a/lib/snmpkit/snmp_lib/config.ex +++ b/lib/snmpkit/snmp_lib/config.ex @@ -448,6 +448,7 @@ defmodule SnmpKit.SnmpLib.Config do env = System.get_env("MIX_ENV") -> String.to_atom(env) env = System.get_env("SNMP_LIB_ENV") -> String.to_atom(env) env = Application.get_env(:snmp_lib, :environment) -> env + Code.ensure_loaded?(Mix) -> Mix.env() true -> :dev end end diff --git a/lib/towerops/dashboard.ex b/lib/towerops/dashboard.ex index d910a2bf..85ab7271 100644 --- a/lib/towerops/dashboard.ex +++ b/lib/towerops/dashboard.ex @@ -7,6 +7,7 @@ defmodule Towerops.Dashboard do alias Towerops.Devices alias Towerops.Gaiia alias Towerops.Gaiia.ImpactAnalysis + alias Towerops.Integrations alias Towerops.Preseem alias Towerops.Preseem.Insights alias Towerops.Sites @@ -16,7 +17,7 @@ defmodule Towerops.Dashboard do status_counts = Devices.get_device_status_counts(organization_id) total_devices = status_counts |> Map.values() |> Enum.sum() active_alerts = Alerts.list_organization_active_alerts(organization_id) - subscriber_totals = Gaiia.get_org_subscriber_totals(organization_id) + subscriber_totals = Integrations.get_billing_summary(organization_id) insight_counts = Insights.count_active_by_urgency(organization_id) %{ @@ -83,9 +84,9 @@ defmodule Towerops.Dashboard do end end - @doc "Returns subscriber summary for the org from Gaiia data." + @doc "Returns subscriber summary for the org from billing integrations." def get_org_subscriber_summary(organization_id) do - totals = Gaiia.get_org_subscriber_totals(organization_id) + totals = Integrations.get_billing_summary(organization_id) %{ total: totals.total_subscribers, diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index b8c0c66c..e37da734 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -38,6 +38,20 @@ defmodule Towerops.Gaiia do ) end + def count_active_subscribers(organization_id) do + Account + |> where(organization_id: ^organization_id) + |> where([a], a.subscription_count > 0) + |> Repo.aggregate(:count) + end + + def sum_active_mrr(organization_id) do + BillingSubscription + |> where(organization_id: ^organization_id) + |> where(status: "ACTIVE") + |> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0) + end + # --- Network Sites --- def list_network_sites(organization_id) do diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index 47eab447..dd2e2fe5 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -37,6 +37,7 @@ defmodule Towerops.Gaiia.Sync do "Synced #{accounts_count} accounts, #{sites_count} sites, #{items_count} inventory items, #{subs_count} subscriptions" Integrations.update_sync_status(integration, "success", message) + persist_billing_totals(integration) {:ok, %{ @@ -168,6 +169,13 @@ defmodule Towerops.Gaiia.Sync do } end + defp persist_billing_totals(integration) do + org_id = integration.organization_id + subscriber_count = Gaiia.count_active_subscribers(org_id) + total_mrr = Gaiia.sum_active_mrr(org_id) + Integrations.update_billing_totals(integration, subscriber_count, total_mrr) + end + defp client_opts(integration) do case integration.credentials["endpoint"] do nil -> [] diff --git a/lib/towerops/integrations.ex b/lib/towerops/integrations.ex index d4129465..08595cf2 100644 --- a/lib/towerops/integrations.ex +++ b/lib/towerops/integrations.ex @@ -62,6 +62,32 @@ defmodule Towerops.Integrations do end end + @billing_providers ~w(gaiia splynx visp sonar) + + def update_billing_totals(%Integration{} = integration, subscriber_count, total_mrr) do + integration + |> Integration.changeset(%{subscriber_count: subscriber_count, total_mrr: total_mrr}) + |> Repo.update() + end + + def get_billing_summary(organization_id) do + result = + Integration + |> where(organization_id: ^organization_id) + |> where([i], i.provider in @billing_providers) + |> where(enabled: true) + |> select([i], %{ + total_subscribers: coalesce(sum(i.subscriber_count), 0), + total_mrr: coalesce(sum(i.total_mrr), 0) + }) + |> Repo.one() + + %{ + total_subscribers: result.total_subscribers, + total_mrr: result.total_mrr || Decimal.new(0) + } + end + def list_enabled_integrations(provider) do Integration |> where(provider: ^provider, enabled: true) diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex index 532615af..b211fd62 100644 --- a/lib/towerops/integrations/integration.ex +++ b/lib/towerops/integrations/integration.ex @@ -25,6 +25,8 @@ defmodule Towerops.Integrations.Integration do field :last_synced_at, :utc_datetime field :last_sync_status, :string, default: "never" field :last_sync_message, :string + field :subscriber_count, :integer, default: 0 + field :total_mrr, :decimal belongs_to :organization, Towerops.Organizations.Organization @@ -41,7 +43,9 @@ defmodule Towerops.Integrations.Integration do :sync_interval_minutes, :last_synced_at, :last_sync_status, - :last_sync_message + :last_sync_message, + :subscriber_count, + :total_mrr ]) |> validate_required([:organization_id, :provider]) |> validate_inclusion(:provider, @valid_providers) diff --git a/lib/towerops/sonar/sync.ex b/lib/towerops/sonar/sync.ex index 9bae166d..56490876 100644 --- a/lib/towerops/sonar/sync.ex +++ b/lib/towerops/sonar/sync.ex @@ -35,6 +35,7 @@ defmodule Towerops.Sonar.Sync do if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, accounts_count, Decimal.from_float(mrr)) {:ok, %{ diff --git a/lib/towerops/splynx/sync.ex b/lib/towerops/splynx/sync.ex index 5af37e66..5880db36 100644 --- a/lib/towerops/splynx/sync.ex +++ b/lib/towerops/splynx/sync.ex @@ -33,6 +33,7 @@ defmodule Towerops.Splynx.Sync do if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, active_count, Decimal.from_float(mrr)) {:ok, %{ diff --git a/lib/towerops/visp/sync.ex b/lib/towerops/visp/sync.ex index 7cbdac49..213431b9 100644 --- a/lib/towerops/visp/sync.ex +++ b/lib/towerops/visp/sync.ex @@ -34,6 +34,7 @@ defmodule Towerops.Visp.Sync do if(total_mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(total_mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, subscribers_count, Decimal.from_float(total_mrr)) {:ok, %{ diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index df4d7c4c..2f76b243 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -32,7 +32,10 @@ <%= if category.exclusive do %> <%!-- Exclusive category (billing): show as picker + single config panel --%> - <% active_billing = Enum.find(category.providers, fn p -> @integrations[p.id] && @integrations[p.id].enabled end) %> + <% active_billing = + Enum.find(category.providers, fn p -> + @integrations[p.id] && @integrations[p.id].enabled + end) %>
<%= if active_billing do %> <%!-- Active billing provider --%> @@ -40,7 +43,10 @@
- <.icon name={active_billing.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> + <.icon + name={active_billing.icon} + class="h-6 w-6 text-indigo-600 dark:text-indigo-400" + />
@@ -71,10 +77,17 @@ "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" - "partial" -> "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" - "failed" -> "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" - _ -> "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + "success" -> + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + + "partial" -> + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + + "failed" -> + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" end ]}> {String.capitalize(integration.last_sync_status)} @@ -83,19 +96,26 @@ <%= if integration.last_synced_at do %> - · Next sync in ~{next_sync_minutes(integration.last_synced_at, integration.sync_interval_minutes)}m + · Next sync in ~{next_sync_minutes( + integration.last_synced_at, + integration.sync_interval_minutes + )}m <% end %> <%= if active_billing.id == "gaiia" do %> <.link - navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"} + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping" + } class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" > Entity Mapping → <.link - navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"} + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation" + } class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" > Reconciliation → @@ -147,13 +167,17 @@ class={[ "group relative flex flex-col items-center rounded-lg border-2 p-4 text-center transition-all hover:border-indigo-300 hover:bg-indigo-50/50 dark:hover:border-indigo-700 dark:hover:bg-indigo-900/10", if(@configuring == provider.id, - do: "border-indigo-500 bg-indigo-50/50 dark:border-indigo-600 dark:bg-indigo-900/20", + do: + "border-indigo-500 bg-indigo-50/50 dark:border-indigo-600 dark:bg-indigo-900/20", else: "border-gray-200 dark:border-white/10" ) ]} >
- <.icon name={provider.icon} class="h-5 w-5 text-indigo-600 dark:text-indigo-400" /> + <.icon + name={provider.icon} + class="h-5 w-5 text-indigo-600 dark:text-indigo-400" + />
{provider.name} @@ -167,7 +191,7 @@ <%!-- Config form for whichever billing provider is being configured --%> <%= if @configuring && find_category_id(@configuring) == "billing" do %> - <% provider = Enum.find(category.providers, & &1.id == @configuring) %> + <% provider = Enum.find(category.providers, &(&1.id == @configuring)) %>

Configure {provider.name} @@ -292,7 +316,9 @@ {t("Webhook Configuration")}

- {t("Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change.")} + {t( + "Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change." + )}

@@ -347,7 +373,11 @@ type="button" id="regenerate-webhook-secret" phx-click="regenerate_webhook_secret" - data-confirm={t("Are you sure? Any existing Gaiia webhook using the current secret will stop working.")} + data-confirm={ + t( + "Are you sure? Any existing Gaiia webhook using the current secret will stop working." + ) + } class="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" > {t("Regenerate Secret")} @@ -383,7 +413,10 @@
- <.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> + <.icon + name={provider.icon} + class="h-6 w-6 text-indigo-600 dark:text-indigo-400" + />

@@ -398,7 +431,8 @@ @@ -420,10 +454,17 @@ "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" - "partial" -> "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" - "failed" -> "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" - _ -> "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + "success" -> + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + + "partial" -> + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + + "failed" -> + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" end ]}> {String.capitalize(integration.last_sync_status)} @@ -432,13 +473,18 @@ <%= if integration.last_synced_at do %> - · Next sync in ~{next_sync_minutes(integration.last_synced_at, integration.sync_interval_minutes)}m + · Next sync in ~{next_sync_minutes( + integration.last_synced_at, + integration.sync_interval_minutes + )}m <% end %> <%= if provider.id == "preseem" do %> <.link - navigate={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"} + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices" + } class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" > Manage Devices → @@ -476,7 +522,10 @@ phx-value-provider={provider.id} class={[ "relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2", - if(integration.enabled, do: "bg-indigo-600", else: "bg-gray-200 dark:bg-gray-700") + if(integration.enabled, + do: "bg-indigo-600", + else: "bg-gray-200 dark:bg-gray-700" + ) ]} > 0 + assert updated.subscriber_count == 1 + # 1 active subscription at $79.99 (7999 cents) + assert Decimal.equal?(updated.total_mrr, Decimal.new("79.99")) + end + test "upserts existing records without duplicating", %{org: org, integration: integration} do stub_all_endpoints() diff --git a/test/towerops/integrations_test.exs b/test/towerops/integrations_test.exs index c3869a11..350c09bf 100644 --- a/test/towerops/integrations_test.exs +++ b/test/towerops/integrations_test.exs @@ -121,6 +121,92 @@ defmodule Towerops.IntegrationsTest do end end + describe "update_billing_totals/3" do + test "persists subscriber_count and total_mrr on integration", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true}) + + assert {:ok, updated} = + Integrations.update_billing_totals(integration, 42, Decimal.new("3500.00")) + + assert updated.subscriber_count == 42 + assert Decimal.equal?(updated.total_mrr, Decimal.new("3500.00")) + end + + test "overwrites previous billing totals", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "splynx", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(integration, 10, Decimal.new("500.00")) + integration = Repo.reload!(integration) + {:ok, updated} = Integrations.update_billing_totals(integration, 20, Decimal.new("1000.00")) + + assert updated.subscriber_count == 20 + assert Decimal.equal?(updated.total_mrr, Decimal.new("1000.00")) + end + end + + describe "get_billing_summary/1" do + test "sums across multiple billing integrations", %{organization: org} do + {:ok, i1} = + Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(i1, 100, Decimal.new("5000.00")) + + user2 = user_fixture() + org2 = organization_fixture(user2.id) + + {:ok, i2} = + Integrations.create_integration(org2.id, %{provider: "splynx", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(i2, 50, Decimal.new("2500.00")) + + # Only org's integrations + summary = Integrations.get_billing_summary(org.id) + assert summary.total_subscribers == 100 + assert Decimal.equal?(summary.total_mrr, Decimal.new("5000.00")) + end + + test "excludes disabled integrations", %{organization: org} do + {:ok, i1} = + Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(i1, 100, Decimal.new("5000.00")) + + {:ok, i2} = + Integrations.create_integration(org.id, %{provider: "splynx", enabled: false}) + + {:ok, _} = Integrations.update_billing_totals(i2, 50, Decimal.new("2500.00")) + + summary = Integrations.get_billing_summary(org.id) + assert summary.total_subscribers == 100 + assert Decimal.equal?(summary.total_mrr, Decimal.new("5000.00")) + end + + test "excludes non-billing integrations", %{organization: org} do + {:ok, i1} = + Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(i1, 100, Decimal.new("5000.00")) + + {:ok, _} = + Integrations.create_integration(org.id, %{ + provider: "preseem", + enabled: true + }) + + summary = Integrations.get_billing_summary(org.id) + assert summary.total_subscribers == 100 + assert Decimal.equal?(summary.total_mrr, Decimal.new("5000.00")) + end + + test "returns zeros when no billing integrations", %{organization: org} do + summary = Integrations.get_billing_summary(org.id) + assert summary.total_subscribers == 0 + assert Decimal.equal?(summary.total_mrr, Decimal.new(0)) + end + end + describe "list_enabled_integrations/1" do test "returns only enabled integrations for a provider", %{organization: org} do {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem", enabled: true}) diff --git a/test/towerops_web/live/dashboard_live_test.exs b/test/towerops_web/live/dashboard_live_test.exs index 255350e4..238c30e6 100644 --- a/test/towerops_web/live/dashboard_live_test.exs +++ b/test/towerops_web/live/dashboard_live_test.exs @@ -120,21 +120,22 @@ defmodule ToweropsWeb.DashboardLiveTest do refute html =~ "MRR" end - test "shows subscriber section when Gaiia data exists", %{ + test "shows subscriber section when billing data exists", %{ conn: conn, organization: organization, site: site } do {:ok, _device} = create_device(organization, site) - {:ok, _} = - Towerops.Gaiia.upsert_network_site(organization.id, %{ - gaiia_id: "ns-dash", - name: "Tower", - account_count: 100, - total_mrr: Decimal.new("5000.00") + {:ok, integration} = + Towerops.Integrations.create_integration(organization.id, %{ + provider: "gaiia", + enabled: true }) + {:ok, _} = + Towerops.Integrations.update_billing_totals(integration, 100, Decimal.new("5000.00")) + {:ok, _view, html} = live(conn, ~p"/dashboard") assert html =~ "Subs" From 8b51b02d454cf449d8d59b889d52b2c8e4e4754d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 15 Feb 2026 17:11:23 -0600 Subject: [PATCH 2/3] Compute per-site subscriber counts from IP block matching After Gaiia sync, match inventory item IPs against network site CIDR blocks to populate account_count and total_mrr on each site. This enables the Site Health table to show subscriber and MRR data per site. --- lib/towerops/gaiia/site_aggregation.ex | 104 +++++ lib/towerops/gaiia/sync.ex | 1 + test/towerops/gaiia/site_aggregation_test.exs | 391 ++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 lib/towerops/gaiia/site_aggregation.ex create mode 100644 test/towerops/gaiia/site_aggregation_test.exs diff --git a/lib/towerops/gaiia/site_aggregation.ex b/lib/towerops/gaiia/site_aggregation.ex new file mode 100644 index 00000000..c374e361 --- /dev/null +++ b/lib/towerops/gaiia/site_aggregation.ex @@ -0,0 +1,104 @@ +defmodule Towerops.Gaiia.SiteAggregation do + @moduledoc """ + Computes per-site subscriber counts and MRR by matching inventory item + IP addresses against network site CIDR blocks. + """ + + import Ecto.Query + + alias Towerops.Gaiia.Account + alias Towerops.Gaiia.BillingSubscription + alias Towerops.Gaiia.InventoryItem + alias Towerops.Gaiia.NetworkSite + alias Towerops.Repo + + require Logger + + @doc """ + For each network site with IP blocks, finds inventory items whose IP addresses + fall within those blocks, then counts unique accounts with active subscriptions + and sums their MRR. + + Updates each network site's `account_count` and `total_mrr` fields. + """ + def compute_and_store(organization_id) do + sites = load_sites_with_ip_blocks(organization_id) + items = load_assigned_items(organization_id) + + Enum.each(sites, fn site -> + cidrs = parse_cidrs(site.ip_blocks) + matched_account_ids = match_accounts_to_cidrs(items, cidrs) + {account_count, total_mrr} = compute_totals(organization_id, matched_account_ids) + update_site_totals(site, account_count, total_mrr) + end) + end + + defp load_sites_with_ip_blocks(organization_id) do + NetworkSite + |> where(organization_id: ^organization_id) + |> where([ns], fragment("cardinality(?) > 0", ns.ip_blocks)) + |> Repo.all() + end + + defp load_assigned_items(organization_id) do + InventoryItem + |> where(organization_id: ^organization_id) + |> where([ii], not is_nil(ii.assigned_account_gaiia_id)) + |> where([ii], not is_nil(ii.ip_address)) + |> Repo.all() + end + + defp parse_cidrs(ip_blocks) do + Enum.flat_map(ip_blocks, fn block -> + case InetCidr.parse_cidr(block) do + {:ok, cidr} -> [cidr] + _ -> [] + end + end) + end + + defp match_accounts_to_cidrs(items, cidrs) do + items + |> Enum.filter(fn item -> ip_in_any_cidr?(item.ip_address, cidrs) end) + |> Enum.map(& &1.assigned_account_gaiia_id) + |> Enum.uniq() + end + + defp ip_in_any_cidr?(ip_string, cidrs) do + case :inet.parse_address(String.to_charlist(ip_string)) do + {:ok, addr} -> + Enum.any?(cidrs, fn cidr -> InetCidr.contains?(cidr, addr) end) + + _ -> + false + end + end + + defp compute_totals(_organization_id, []) do + {0, Decimal.new("0")} + end + + defp compute_totals(organization_id, account_gaiia_ids) do + account_count = + Account + |> where(organization_id: ^organization_id) + |> where([a], a.gaiia_id in ^account_gaiia_ids) + |> where([a], a.subscription_count > 0) + |> Repo.aggregate(:count) + + total_mrr = + BillingSubscription + |> where(organization_id: ^organization_id) + |> where([bs], bs.account_gaiia_id in ^account_gaiia_ids) + |> where(status: "ACTIVE") + |> Repo.aggregate(:sum, :mrr_amount) || Decimal.new("0") + + {account_count, total_mrr} + end + + defp update_site_totals(site, account_count, total_mrr) do + site + |> Ecto.Changeset.change(%{account_count: account_count, total_mrr: total_mrr}) + |> Repo.update() + end +end diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index dd2e2fe5..cb3c6435 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -38,6 +38,7 @@ defmodule Towerops.Gaiia.Sync do Integrations.update_sync_status(integration, "success", message) persist_billing_totals(integration) + Gaiia.SiteAggregation.compute_and_store(org_id) {:ok, %{ diff --git a/test/towerops/gaiia/site_aggregation_test.exs b/test/towerops/gaiia/site_aggregation_test.exs new file mode 100644 index 00000000..ab8113c6 --- /dev/null +++ b/test/towerops/gaiia/site_aggregation_test.exs @@ -0,0 +1,391 @@ +defmodule Towerops.Gaiia.SiteAggregationTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Gaiia + alias Towerops.Gaiia.SiteAggregation + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{org: org} + end + + describe "compute_and_store/1" do + test "matches account to site via IP block", %{org: org} do + {:ok, _site} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-1", + name: "Tower A", + ip_blocks: ["10.0.0.0/24"] + }) + + {:ok, _account} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-1", + name: "Alice", + subscription_count: 1 + }) + + {:ok, _item} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-1", + name: "CPE", + ip_address: "10.0.0.5", + assigned_account_gaiia_id: "acct-1" + }) + + {:ok, _sub} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-1", + account_gaiia_id: "acct-1", + status: "ACTIVE", + mrr_amount: Decimal.new("79.99") + }) + + SiteAggregation.compute_and_store(org.id) + + site = Gaiia.get_network_site(org.id, "ns-1") + assert site.account_count == 1 + assert Decimal.equal?(site.total_mrr, Decimal.new("79.99")) + end + + test "counts multiple accounts at one site", %{org: org} do + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-1", + name: "Tower A", + ip_blocks: ["10.0.0.0/24"] + }) + + {:ok, _} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-1", + name: "Alice", + subscription_count: 1 + }) + + {:ok, _} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-2", + name: "Bob", + subscription_count: 1 + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-1", + name: "CPE-1", + ip_address: "10.0.0.5", + assigned_account_gaiia_id: "acct-1" + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-2", + name: "CPE-2", + ip_address: "10.0.0.6", + assigned_account_gaiia_id: "acct-2" + }) + + {:ok, _} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-1", + account_gaiia_id: "acct-1", + status: "ACTIVE", + mrr_amount: Decimal.new("50.00") + }) + + {:ok, _} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-2", + account_gaiia_id: "acct-2", + status: "ACTIVE", + mrr_amount: Decimal.new("75.00") + }) + + SiteAggregation.compute_and_store(org.id) + + site = Gaiia.get_network_site(org.id, "ns-1") + assert site.account_count == 2 + assert Decimal.equal?(site.total_mrr, Decimal.new("125.00")) + end + + test "excludes accounts with no active subscriptions", %{org: org} do + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-1", + name: "Tower A", + ip_blocks: ["10.0.0.0/24"] + }) + + {:ok, _} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-no-subs", + name: "Charlie", + subscription_count: 0 + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-1", + name: "CPE", + ip_address: "10.0.0.10", + assigned_account_gaiia_id: "acct-no-subs" + }) + + SiteAggregation.compute_and_store(org.id) + + site = Gaiia.get_network_site(org.id, "ns-1") + assert site.account_count == 0 + assert Decimal.equal?(site.total_mrr, Decimal.new("0")) + end + + test "ignores items assigned to sites (not accounts)", %{org: org} do + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-1", + name: "Tower A", + ip_blocks: ["10.0.0.0/24"] + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-infra", + name: "AP", + ip_address: "10.0.0.1", + assigned_network_site_gaiia_id: "ns-1", + assigned_account_gaiia_id: nil + }) + + SiteAggregation.compute_and_store(org.id) + + site = Gaiia.get_network_site(org.id, "ns-1") + assert site.account_count == 0 + assert Decimal.equal?(site.total_mrr, Decimal.new("0")) + end + + test "matches across multiple IP blocks on one site", %{org: org} do + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-1", + name: "Tower A", + ip_blocks: ["10.0.0.0/24", "172.16.0.0/16"] + }) + + {:ok, _} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-1", + name: "Alice", + subscription_count: 1 + }) + + {:ok, _} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-2", + name: "Bob", + subscription_count: 1 + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-1", + name: "CPE-1", + ip_address: "10.0.0.50", + assigned_account_gaiia_id: "acct-1" + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-2", + name: "CPE-2", + ip_address: "172.16.5.10", + assigned_account_gaiia_id: "acct-2" + }) + + {:ok, _} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-1", + account_gaiia_id: "acct-1", + status: "ACTIVE", + mrr_amount: Decimal.new("50.00") + }) + + {:ok, _} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-2", + account_gaiia_id: "acct-2", + status: "ACTIVE", + mrr_amount: Decimal.new("75.00") + }) + + SiteAggregation.compute_and_store(org.id) + + site = Gaiia.get_network_site(org.id, "ns-1") + assert site.account_count == 2 + assert Decimal.equal?(site.total_mrr, Decimal.new("125.00")) + end + + test "skips sites with empty ip_blocks", %{org: org} do + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-empty", + name: "No Blocks", + ip_blocks: [] + }) + + SiteAggregation.compute_and_store(org.id) + + site = Gaiia.get_network_site(org.id, "ns-empty") + # Site was skipped — values remain at DB defaults (not computed) + assert site.account_count == 0 + assert is_nil(site.total_mrr) + end + + test "cross-site isolation — IP matches only the correct site", %{org: org} do + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-a", + name: "Tower A", + ip_blocks: ["10.0.0.0/24"] + }) + + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-b", + name: "Tower B", + ip_blocks: ["192.168.0.0/24"] + }) + + {:ok, _} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-1", + name: "Alice", + subscription_count: 1 + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-1", + name: "CPE", + ip_address: "10.0.0.5", + assigned_account_gaiia_id: "acct-1" + }) + + {:ok, _} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-1", + account_gaiia_id: "acct-1", + status: "ACTIVE", + mrr_amount: Decimal.new("60.00") + }) + + SiteAggregation.compute_and_store(org.id) + + site_a = Gaiia.get_network_site(org.id, "ns-a") + assert site_a.account_count == 1 + assert Decimal.equal?(site_a.total_mrr, Decimal.new("60.00")) + + site_b = Gaiia.get_network_site(org.id, "ns-b") + assert site_b.account_count == 0 + assert Decimal.equal?(site_b.total_mrr, Decimal.new("0")) + end + + test "deduplicates accounts with multiple inventory items", %{org: org} do + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-1", + name: "Tower A", + ip_blocks: ["10.0.0.0/24"] + }) + + {:ok, _} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-1", + name: "Alice", + subscription_count: 1 + }) + + # Two items for the same account + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-1", + name: "CPE-Primary", + ip_address: "10.0.0.5", + assigned_account_gaiia_id: "acct-1" + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-2", + name: "CPE-Secondary", + ip_address: "10.0.0.6", + assigned_account_gaiia_id: "acct-1" + }) + + {:ok, _} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-1", + account_gaiia_id: "acct-1", + status: "ACTIVE", + mrr_amount: Decimal.new("99.99") + }) + + SiteAggregation.compute_and_store(org.id) + + site = Gaiia.get_network_site(org.id, "ns-1") + # Account counted once despite two items + assert site.account_count == 1 + assert Decimal.equal?(site.total_mrr, Decimal.new("99.99")) + end + + test "only sums MRR from ACTIVE subscriptions", %{org: org} do + {:ok, _} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "ns-1", + name: "Tower A", + ip_blocks: ["10.0.0.0/24"] + }) + + {:ok, _} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "acct-1", + name: "Alice", + subscription_count: 1 + }) + + {:ok, _} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "item-1", + name: "CPE", + ip_address: "10.0.0.5", + assigned_account_gaiia_id: "acct-1" + }) + + {:ok, _} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-active", + account_gaiia_id: "acct-1", + status: "ACTIVE", + mrr_amount: Decimal.new("50.00") + }) + + {:ok, _} = + Gaiia.upsert_billing_subscription(org.id, %{ + gaiia_id: "sub-cancelled", + account_gaiia_id: "acct-1", + status: "CANCELLED", + mrr_amount: Decimal.new("25.00") + }) + + SiteAggregation.compute_and_store(org.id) + + site = Gaiia.get_network_site(org.id, "ns-1") + assert site.account_count == 1 + # Only the active subscription's MRR + assert Decimal.equal?(site.total_mrr, Decimal.new("50.00")) + end + end +end From 719ac1eb0ffc15d06f41b758515cf825ccbeb609 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 15 Feb 2026 17:16:48 -0600 Subject: [PATCH 3/3] Fix Mix.env() runtime crash in releases Capture Mix.env() at compile time via module attribute since Mix is not available at runtime in production releases. --- lib/snmpkit/snmp_lib/config.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/snmpkit/snmp_lib/config.ex b/lib/snmpkit/snmp_lib/config.ex index 5039b018..e4f947b3 100644 --- a/lib/snmpkit/snmp_lib/config.ex +++ b/lib/snmpkit/snmp_lib/config.ex @@ -441,6 +441,9 @@ defmodule SnmpKit.SnmpLib.Config do ## Private Implementation + # Capture Mix.env() at compile time since Mix is not available in releases + @compile_env if Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod + # Environment detection with multiple fallbacks defp detect_environment(opts) do cond do @@ -448,8 +451,7 @@ defmodule SnmpKit.SnmpLib.Config do env = System.get_env("MIX_ENV") -> String.to_atom(env) env = System.get_env("SNMP_LIB_ENV") -> String.to_atom(env) env = Application.get_env(:snmp_lib, :environment) -> env - Code.ensure_loaded?(Mix) -> Mix.env() - true -> :dev + true -> @compile_env end end