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.
This commit is contained in:
Graham McIntire 2026-02-15 17:11:23 -06:00
parent de075ea8d9
commit 8b51b02d45
No known key found for this signature in database
3 changed files with 496 additions and 0 deletions

View file

@ -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

View file

@ -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,
%{

View file

@ -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