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:
parent
de075ea8d9
commit
8b51b02d45
3 changed files with 496 additions and 0 deletions
104
lib/towerops/gaiia/site_aggregation.ex
Normal file
104
lib/towerops/gaiia/site_aggregation.ex
Normal 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
|
||||
|
|
@ -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,
|
||||
%{
|
||||
|
|
|
|||
391
test/towerops/gaiia/site_aggregation_test.exs
Normal file
391
test/towerops/gaiia/site_aggregation_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue