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