fix: correct subscriber counts by filtering CGNAT IPs and excluding routers

Router devices were inflating per-site subscriber counts because:
1. CGNAT IPs (100.64.0.0/10) in router ARP tables caused false matches
2. Routers see all traffic and shouldn't have subscribers attributed
3. Site totals were computed from raw links without filtering

Changes:
- Filter CGNAT (RFC 6598) IPs from device subscriber links
- Deduplicate accounts across devices by match method priority
- Detect routers via SNMP sys_descr and zero out per-device counts
- Derive site totals from filtered per-device impacts
- Add per-device Subs/MRR columns to site device tables
- Add device link fallback for sites without IP blocks
- Fix Gaiia sync case mismatch and execution order
- Improve device list toolbar button icons and tooltips
This commit is contained in:
Graham McIntire 2026-02-18 09:15:10 -06:00
parent 076b8454de
commit 7081e5daed
No known key found for this signature in database
10 changed files with 741 additions and 49 deletions

View file

@ -13,6 +13,7 @@ defmodule Towerops.Gaiia do
alias Towerops.Gaiia.NetworkSite
alias Towerops.Repo
alias Towerops.Sites.Site
alias Towerops.Snmp.Device, as: SnmpDevice
# --- Accounts ---
@ -303,33 +304,94 @@ defmodule Towerops.Gaiia do
@doc """
Get subscriber impact for multiple devices (batch).
Returns `%{device_id => %{subscriber_count: integer, mrr: Decimal.t()}}`.
Deduplicates accounts across devices: if the same account is linked to
multiple devices, it is attributed only to the device with the
highest-priority match method (wireless > ARP > site fallback).
"""
def get_devices_impact(device_ids) when is_list(device_ids) do
if device_ids == [] do
%{}
else
# Get account links per device
# Fetch all links with account gaiia_id for MRR lookup
links =
DeviceSubscriberLink
|> where([l], l.device_id in ^device_ids)
|> join(:inner, [l], a in Account, on: l.gaiia_account_id == a.id)
|> join(:left, [l, a], bs in BillingSubscription,
on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id and bs.status == "ACTIVE"
)
|> group_by([l, a, bs], l.device_id)
|> select([l, a, bs], %{
|> select([l, a], %{
device_id: l.device_id,
subscriber_count: count(a.id, :distinct),
mrr: coalesce(sum(bs.mrr_amount), 0)
gaiia_account_id: l.gaiia_account_id,
account_gaiia_id: a.gaiia_id,
organization_id: a.organization_id,
match_method: l.match_method,
subscriber_ip: l.subscriber_ip
})
|> Repo.all()
|> Enum.reject(&cgnat_ip?/1)
Map.new(links, fn row ->
{row.device_id, %{subscriber_count: row.subscriber_count, mrr: row.mrr}}
# Deduplicate: per account, keep only the link with the best match method
best_links =
links
|> Enum.group_by(& &1.gaiia_account_id)
|> Enum.map(fn {_acct_id, acct_links} ->
Enum.min_by(acct_links, &match_method_priority/1)
end)
# Group by device and compute subscriber counts + MRR
best_links
|> Enum.group_by(& &1.device_id)
|> Map.new(fn {device_id, device_links} ->
account_gaiia_ids =
device_links
|> Enum.map(& &1.account_gaiia_id)
|> Enum.uniq()
org_id = hd(device_links).organization_id
mrr = compute_mrr_for_accounts(org_id, account_gaiia_ids)
{device_id, %{subscriber_count: length(account_gaiia_ids), mrr: mrr}}
end)
end
end
defp match_method_priority(%{match_method: "wireless_mac"}), do: 1
defp match_method_priority(%{match_method: "wireless_ip"}), do: 2
defp match_method_priority(%{match_method: "arp_mac"}), do: 3
defp match_method_priority(%{match_method: "arp_ip"}), do: 4
defp match_method_priority(%{match_method: "site_fallback"}), do: 5
defp match_method_priority(_), do: 6
# RFC 6598 CGNAT range: 100.64.0.0/10 (100.64.0.0 100.127.255.255)
# These IPs appear in router ARP tables but not on APs, causing false matches.
defp cgnat_ip?(%{subscriber_ip: nil}), do: false
defp cgnat_ip?(%{subscriber_ip: ""}), do: false
defp cgnat_ip?(%{subscriber_ip: ip}) do
case String.split(ip, ".") do
[first, second | _] ->
with {100, ""} <- Integer.parse(first),
{octet2, ""} <- Integer.parse(second) do
octet2 >= 64 and octet2 <= 127
else
_ -> false
end
_ ->
false
end
end
defp compute_mrr_for_accounts(_org_id, []), do: Decimal.new(0)
defp compute_mrr_for_accounts(org_id, account_gaiia_ids) do
BillingSubscription
|> where(organization_id: ^org_id)
|> where([bs], bs.account_gaiia_id in ^account_gaiia_ids)
|> where([bs], bs.status == "ACTIVE")
|> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0)
end
@doc """
Get subscriber impact for a site (all devices at site).
Returns `%{subscriber_count: integer, mrr: Decimal.t(), devices: [%{device_id, name, count, mrr}]}`.
@ -343,38 +405,38 @@ defmodule Towerops.Gaiia do
impact = get_devices_impact(device_ids)
# Detect router devices via SNMP sys_descr
router_device_ids =
SnmpDevice
|> where([sd], sd.device_id in ^device_ids)
|> where([sd], ilike(sd.sys_descr, "%RouterOS%"))
|> select([sd], sd.device_id)
|> Repo.all()
|> MapSet.new()
devices_with_names =
Device
|> where([d], d.id in ^device_ids)
|> select([d], {d.id, d.name})
|> Repo.all()
|> Enum.map(fn {id, name} ->
di = Map.get(impact, id, %{subscriber_count: 0, mrr: Decimal.new(0)})
%{device_id: id, name: name, count: di.subscriber_count, mrr: di.mrr}
if MapSet.member?(router_device_ids, id) do
%{device_id: id, name: name, count: 0, mrr: Decimal.new(0)}
else
di = Map.get(impact, id, %{subscriber_count: 0, mrr: Decimal.new(0)})
%{device_id: id, name: name, count: di.subscriber_count, mrr: di.mrr}
end
end)
total_account_ids =
DeviceSubscriberLink
|> where([l], l.device_id in ^device_ids)
|> select([l], l.gaiia_account_id)
|> Repo.all()
|> Enum.uniq()
total_mrr =
if total_account_ids == [] do
Decimal.new(0)
else
BillingSubscription
|> join(:inner, [bs], a in Account,
on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id
)
|> where([bs, a], a.id in ^total_account_ids)
|> where([bs], bs.status == "ACTIVE")
|> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0)
end
# Site totals = sum of non-router per-device impacts
# (already CGNAT-filtered and deduplicated)
{total_subs, total_mrr} =
Enum.reduce(devices_with_names, {0, Decimal.new(0)}, fn dev, {subs, mrr} ->
{subs + dev.count, Decimal.add(mrr, dev.mrr)}
end)
%{
subscriber_count: length(total_account_ids),
subscriber_count: total_subs,
mrr: total_mrr,
devices: devices_with_names
}

View file

@ -1,13 +1,20 @@
defmodule Towerops.Gaiia.SiteAggregation do
@moduledoc """
Computes per-site subscriber counts and MRR by matching inventory item
IP addresses against network site CIDR blocks.
Computes per-site subscriber counts and MRR.
Two strategies:
1. **CIDR matching** for sites with IP blocks, matches inventory item IPs
against CIDR ranges to find accounts.
2. **Device link fallback** for sites without IP blocks that are mapped to
a Towerops site, aggregates from `device_subscriber_links`.
"""
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Gaiia.Account
alias Towerops.Gaiia.BillingSubscription
alias Towerops.Gaiia.DeviceSubscriberLink
alias Towerops.Gaiia.InventoryItem
alias Towerops.Gaiia.NetworkSite
alias Towerops.Repo
@ -15,13 +22,17 @@ defmodule Towerops.Gaiia.SiteAggregation do
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.
Computes and stores subscriber counts and MRR for all network sites
in the organization. Uses CIDR matching when IP blocks are available,
falls back to device_subscriber_links for mapped sites without IP blocks.
"""
def compute_and_store(organization_id) do
compute_cidr_sites(organization_id)
compute_linked_sites(organization_id)
end
# Sites with IP blocks: match inventory item IPs against CIDR ranges.
defp compute_cidr_sites(organization_id) do
sites = load_sites_with_ip_blocks(organization_id)
items = load_assigned_items(organization_id)
@ -33,6 +44,18 @@ defmodule Towerops.Gaiia.SiteAggregation do
end)
end
# Sites without IP blocks but mapped to a Towerops site: aggregate from
# device_subscriber_links for devices at that Towerops site.
defp compute_linked_sites(organization_id) do
sites = load_mapped_sites_without_ip_blocks(organization_id)
Enum.each(sites, fn site ->
account_gaiia_ids = linked_account_gaiia_ids(organization_id, site.site_id)
{account_count, total_mrr} = compute_totals(organization_id, account_gaiia_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)
@ -40,6 +63,25 @@ defmodule Towerops.Gaiia.SiteAggregation do
|> Repo.all()
end
defp load_mapped_sites_without_ip_blocks(organization_id) do
NetworkSite
|> where(organization_id: ^organization_id)
|> where([ns], not is_nil(ns.site_id))
|> where([ns], fragment("cardinality(?) = 0", ns.ip_blocks))
|> Repo.all()
end
defp linked_account_gaiia_ids(organization_id, towerops_site_id) do
DeviceSubscriberLink
|> join(:inner, [l], d in Device, on: l.device_id == d.id)
|> join(:inner, [l, _d], a in Account, on: l.gaiia_account_id == a.id)
|> where([l, d, _a], d.site_id == ^towerops_site_id)
|> where([l, _d, _a], l.organization_id == ^organization_id)
|> select([_l, _d, a], a.gaiia_id)
|> distinct(true)
|> Repo.all()
end
defp load_assigned_items(organization_id) do
InventoryItem
|> where(organization_id: ^organization_id)

View file

@ -39,8 +39,8 @@ defmodule Towerops.Gaiia.Sync do
Integrations.update_sync_status(integration, "success", message)
persist_billing_totals(integration)
Gaiia.SiteAggregation.compute_and_store(org_id)
SubscriberMatching.refresh_device_links(org_id)
Gaiia.SiteAggregation.compute_and_store(org_id)
{:ok,
%{
@ -119,8 +119,8 @@ defmodule Towerops.Gaiia.Sync do
{account_id, site_id} =
case assignation do
%{"assigneeType" => "Account", "assignee" => %{"id" => id}} -> {id, nil}
%{"assigneeType" => "NetworkSite", "assignee" => %{"id" => id}} -> {nil, id}
%{"assigneeType" => "ACCOUNT", "assignee" => %{"id" => id}} -> {id, nil}
%{"assigneeType" => "NETWORK_SITE", "assignee" => %{"id" => id}} -> {nil, id}
_ -> {nil, nil}
end

View file

@ -91,15 +91,19 @@
<.icon name="hero-check" class="h-4 w-4" /> Done
</.button>
<% else %>
<.button type="button" phx-click="toggle_reorder_mode">
<.icon name="hero-bars-3" class="h-4 w-4" />
<.button type="button" phx-click="toggle_reorder_mode" title="Reorder devices and sites">
<.icon name="hero-bars-arrow-up" class="h-4 w-4" />
</.button>
<.button
type="button"
phx-click="toggle_compact_mode"
title={if @compact_mode, do: "Normal view", else: "Compact view"}
variant={if @compact_mode, do: "primary", else: nil}
>
<.icon name="hero-bars-2" class="h-4 w-4" />
<.icon
name={if @compact_mode, do: "hero-view-columns", else: "hero-table-cells"}
class="h-4 w-4"
/>
</.button>
<% end %>
<% end %>
@ -110,6 +114,7 @@
<.button
type="button"
phx-click="force_rediscover_all"
title="Rediscover all devices"
data-confirm={
t("This will trigger SNMP discovery for all SNMP-enabled devices. Continue?")
}

View file

@ -51,12 +51,19 @@ defmodule ToweropsWeb.SiteLive.Show do
_ -> %{subscriber_count: 0, mrr: nil, devices: []}
end
# Build per-device impact lookup for device health table
device_impact = Map.new(site_impact.devices, fn d -> {d.device_id, d} end)
# Use site_impact as fallback when network site isn't mapped
site_summary = merge_impact_into_summary(site_summary, site_impact)
{:noreply,
socket
|> assign(:page_title, site.name)
|> assign(:site, site)
|> assign(:device, devices)
|> assign(:site_impact, site_impact)
|> assign(:device_impact, device_impact)
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:site_summary, site_summary)
|> assign(:qoe_summary, qoe_summary)
@ -125,6 +132,14 @@ defmodule ToweropsWeb.SiteLive.Show do
}
end
defp merge_impact_into_summary(summary, impact) do
if impact.subscriber_count > 0 do
%{summary | subscribers: impact.subscriber_count, mrr: impact.mrr}
else
summary
end
end
defp format_mrr(nil), do: "$0"
defp format_mrr(%Decimal{} = amount) do

View file

@ -381,6 +381,10 @@
<tr class="text-xs text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 text-left font-medium">{t("Device")}</th>
<th class="px-3 py-2 text-left font-medium">{t("IP Address")}</th>
<th class="px-3 py-2 text-right font-medium">{t("Subs")}</th>
<%= if @can_view_financials do %>
<th class="px-3 py-2 text-right font-medium">{t("MRR")}</th>
<% end %>
<th class="px-3 py-2 text-right font-medium">{t("Latency")}</th>
</tr>
</thead>
@ -404,6 +408,22 @@
<td class="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">
{eq.ip_address}
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-700 dark:text-gray-300">
<%= if di = @device_impact[eq.id] do %>
{if di.count > 0, do: di.count, else: "—"}
<% else %>
<% end %>
</td>
<%= if @can_view_financials do %>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-700 dark:text-gray-300">
<%= if di = @device_impact[eq.id] do %>
{if Decimal.gt?(di.mrr, 0), do: format_mrr(di.mrr), else: "—"}
<% else %>
<% end %>
</td>
<% end %>
<td class={[
"px-3 py-2 text-right font-mono text-xs",
case @response_times[eq.id] do

View file

@ -2,9 +2,11 @@ defmodule Towerops.Gaiia.SiteAggregationTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia
alias Towerops.Gaiia.DeviceSubscriberLink
alias Towerops.Gaiia.SiteAggregation
setup do
@ -341,6 +343,58 @@ defmodule Towerops.Gaiia.SiteAggregationTest do
assert Decimal.equal?(site.total_mrr, Decimal.new("99.99"))
end
test "aggregates from device_subscriber_links when site has no IP blocks", %{org: org} do
# Create a Towerops site and map a Gaiia network site (no IP blocks) to it
towerops_site = create_site(org.id)
{:ok, _ns} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-noip",
name: "Tower No Blocks",
ip_blocks: [],
site_id: towerops_site.id
})
# Create an account with an active subscription
{:ok, account} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "acct-linked",
name: "Linked Subscriber",
subscription_count: 1
})
{:ok, _sub} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-linked",
account_gaiia_id: "acct-linked",
status: "ACTIVE",
mrr_amount: Decimal.new("65.00")
})
# Create a device at the Towerops site
device = device_fixture(%{organization_id: org.id, site: towerops_site})
# Create a device_subscriber_link connecting the device to the account
now = DateTime.truncate(DateTime.utc_now(), :second)
Repo.insert!(%DeviceSubscriberLink{
organization_id: org.id,
device_id: device.id,
gaiia_account_id: account.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "10.0.0.5",
inserted_at: now,
updated_at: now
})
SiteAggregation.compute_and_store(org.id)
site = Gaiia.get_network_site(org.id, "ns-noip")
assert site.account_count == 1
assert Decimal.equal?(site.total_mrr, Decimal.new("65.00"))
end
test "only sums MRR from ACTIVE subscriptions", %{org: org} do
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
@ -388,4 +442,14 @@ defmodule Towerops.Gaiia.SiteAggregationTest do
assert Decimal.equal?(site.total_mrr, Decimal.new("50.00"))
end
end
defp create_site(organization_id) do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site #{System.unique_integer([:positive])}",
organization_id: organization_id
})
site
end
end

View file

@ -133,6 +133,65 @@ defmodule Towerops.Gaiia.SyncTest do
assert result.network_sites == 1
end
test "maps NETWORK_SITE assigneeType to assigned_network_site_gaiia_id", %{
org: org,
integration: integration
} do
stub_gaiia_api(
inventory_items: [
%{
"id" => "item-ns",
"status" => "FUNCTIONAL",
"ipAddressV4" => "10.0.0.50",
"model" => %{
"name" => "Switch",
"manufacturer" => %{"name" => "Cambium"},
"category" => %{"name" => "Infrastructure"}
},
"assignation" => %{
"assigneeType" => "NETWORK_SITE",
"assignee" => %{"id" => "site-456"}
},
"fields" => %{"edges" => []}
}
]
)
{:ok, _} = Sync.sync_organization(integration)
[item] = Gaiia.list_inventory_items(org.id)
assert item.assigned_network_site_gaiia_id == "site-456"
assert is_nil(item.assigned_account_gaiia_id)
end
test "ignores INVENTORY_LOCATION assigneeType", %{org: org, integration: integration} do
stub_gaiia_api(
inventory_items: [
%{
"id" => "item-loc",
"status" => "IN_STOCK",
"ipAddressV4" => nil,
"model" => %{
"name" => "Spare Router",
"manufacturer" => %{"name" => "MikroTik"},
"category" => %{"name" => "Router"}
},
"assignation" => %{
"assigneeType" => "INVENTORY_LOCATION",
"assignee" => %{"id" => "loc-789"}
},
"fields" => %{"edges" => []}
}
]
)
{:ok, _} = Sync.sync_organization(integration)
[item] = Gaiia.list_inventory_items(org.id)
assert is_nil(item.assigned_account_gaiia_id)
assert is_nil(item.assigned_network_site_gaiia_id)
end
test "handles nil root key in API response gracefully", %{integration: integration} do
Req.Test.stub(Client, fn conn ->
{:ok, body, _conn} = Plug.Conn.read_body(conn)
@ -179,6 +238,49 @@ defmodule Towerops.Gaiia.SyncTest do
end
end
defp stub_gaiia_api(opts) do
items = Keyword.get(opts, :inventory_items, [])
Req.Test.stub(Client, fn conn ->
{:ok, body, _conn} = Plug.Conn.read_body(conn)
decoded = Jason.decode!(body)
query = decoded["query"]
response =
cond do
String.contains?(query, "ListAccounts") ->
empty_relay_response(query)
String.contains?(query, "ListNetworkSites") ->
empty_relay_response(query)
String.contains?(query, "ListInventoryItems") ->
paginated_response("inventoryItems", items)
String.contains?(query, "ListBillingSubscriptions") ->
empty_relay_response(query)
true ->
%{"data" => %{}}
end
Req.Test.json(conn, response)
end)
end
defp paginated_response(root_key, nodes) do
edges = Enum.map(nodes, fn node -> %{"node" => node} end)
%{
"data" => %{
root_key => %{
"edges" => edges,
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
}
}
}
end
defp stub_all_endpoints do
Req.Test.stub(Client, fn conn ->
{:ok, body, _conn} = Plug.Conn.read_body(conn)
@ -286,7 +388,7 @@ defmodule Towerops.Gaiia.SyncTest do
"category" => %{"name" => "AP"}
},
"assignation" => %{
"assigneeType" => "Account",
"assigneeType" => "ACCOUNT",
"assignee" => %{"id" => "acct-1"}
},
"fields" => %{

View file

@ -6,6 +6,7 @@ defmodule Towerops.GaiiaTest do
import Towerops.OrganizationsFixtures
alias Towerops.Gaiia
alias Towerops.Snmp.Device
setup do
user = user_fixture()
@ -169,6 +170,387 @@ defmodule Towerops.GaiiaTest do
end
end
describe "get_devices_impact/1" do
test "deduplicates accounts across devices, preferring higher-confidence matches", %{org: org} do
site = create_site(org.id)
# Create two devices: a router and an AP
router = device_fixture(%{organization_id: org.id, site: site, name: "Router"})
ap = device_fixture(%{organization_id: org.id, site: site, name: "AP-1"})
# Create an account with an active subscription
{:ok, account} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "acct-1",
name: "Alice",
subscription_count: 1
})
{:ok, _} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-1",
account_gaiia_id: "acct-1",
status: "ACTIVE",
mrr_amount: Decimal.new("50.00")
})
now = DateTime.truncate(DateTime.utc_now(), :second)
# Same account linked to AP via wireless_mac (high confidence)
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: ap.id,
gaiia_account_id: account.id,
match_method: "wireless_mac",
confidence: "high",
subscriber_mac: "aa:bb:cc:dd:ee:ff",
inserted_at: now,
updated_at: now
})
# Same account linked to Router via arp_ip (medium confidence)
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: router.id,
gaiia_account_id: account.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "10.0.0.5",
inserted_at: now,
updated_at: now
})
impact = Gaiia.get_devices_impact([router.id, ap.id])
# Account should be counted only on the AP (wireless_mac beats arp_ip)
assert impact[ap.id].subscriber_count == 1
assert Decimal.equal?(impact[ap.id].mrr, Decimal.new("50.00"))
# Router should have 0 subscribers (deduped to AP)
router_impact = Map.get(impact, router.id, %{subscriber_count: 0, mrr: Decimal.new(0)})
assert router_impact.subscriber_count == 0
end
test "keeps ARP match on router when no higher-confidence match exists elsewhere", %{org: org} do
site = create_site(org.id)
router = device_fixture(%{organization_id: org.id, site: site, name: "Router"})
ap = device_fixture(%{organization_id: org.id, site: site, name: "AP-1"})
{:ok, account} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "acct-only-router",
name: "Bob",
subscription_count: 1
})
{:ok, _} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-bob",
account_gaiia_id: "acct-only-router",
status: "ACTIVE",
mrr_amount: Decimal.new("75.00")
})
now = DateTime.truncate(DateTime.utc_now(), :second)
# Account only linked to router via ARP (no AP match)
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: router.id,
gaiia_account_id: account.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "10.0.0.10",
inserted_at: now,
updated_at: now
})
impact = Gaiia.get_devices_impact([router.id, ap.id])
# Router should keep the account since no better match exists
assert impact[router.id].subscriber_count == 1
assert Decimal.equal?(impact[router.id].mrr, Decimal.new("75.00"))
end
test "deduplicates across same-method matches, keeping first device", %{org: org} do
site = create_site(org.id)
router = device_fixture(%{organization_id: org.id, site: site, name: "Router"})
ap = device_fixture(%{organization_id: org.id, site: site, name: "AP-1"})
{:ok, account} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "acct-both-arp",
name: "Charlie",
subscription_count: 1
})
{:ok, _} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-charlie",
account_gaiia_id: "acct-both-arp",
status: "ACTIVE",
mrr_amount: Decimal.new("60.00")
})
now = DateTime.truncate(DateTime.utc_now(), :second)
# Same account linked to both devices via arp_ip (same method)
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: ap.id,
gaiia_account_id: account.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "10.0.0.20",
inserted_at: now,
updated_at: now
})
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: router.id,
gaiia_account_id: account.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "100.64.0.20",
inserted_at: now,
updated_at: now
})
impact = Gaiia.get_devices_impact([router.id, ap.id])
# Account should be counted on exactly one device (not both)
router_count = get_in(impact, [router.id, :subscriber_count]) || 0
ap_count = get_in(impact, [ap.id, :subscriber_count]) || 0
assert router_count + ap_count == 1
end
test "excludes links matched through CGNAT IPs (100.64.0.0/10)", %{org: org} do
site = create_site(org.id)
router = device_fixture(%{organization_id: org.id, site: site, name: "Router"})
{:ok, account} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "acct-cgnat",
name: "CGNAT Sub",
subscription_count: 1
})
{:ok, _} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-cgnat",
account_gaiia_id: "acct-cgnat",
status: "ACTIVE",
mrr_amount: Decimal.new("50.00")
})
now = DateTime.truncate(DateTime.utc_now(), :second)
# Link via CGNAT IP — should be excluded from per-device counts
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: router.id,
gaiia_account_id: account.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "100.64.62.50",
inserted_at: now,
updated_at: now
})
impact = Gaiia.get_devices_impact([router.id])
router_impact = Map.get(impact, router.id, %{subscriber_count: 0, mrr: Decimal.new(0)})
assert router_impact.subscriber_count == 0
end
test "keeps links with non-CGNAT IPs", %{org: org} do
site = create_site(org.id)
ap = device_fixture(%{organization_id: org.id, site: site, name: "AP-1"})
{:ok, account} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "acct-lan",
name: "LAN Sub",
subscription_count: 1
})
{:ok, _} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-lan",
account_gaiia_id: "acct-lan",
status: "ACTIVE",
mrr_amount: Decimal.new("80.00")
})
now = DateTime.truncate(DateTime.utc_now(), :second)
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: ap.id,
gaiia_account_id: account.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "10.0.0.5",
inserted_at: now,
updated_at: now
})
impact = Gaiia.get_devices_impact([ap.id])
assert impact[ap.id].subscriber_count == 1
assert Decimal.equal?(impact[ap.id].mrr, Decimal.new("80.00"))
end
end
describe "get_site_impact/1" do
test "excludes router devices from per-device impact", %{org: org} do
site = create_site(org.id)
router = device_fixture(%{organization_id: org.id, site: site, name: "Router"})
ap = device_fixture(%{organization_id: org.id, site: site, name: "AP-1"})
# Mark router as a RouterOS device via SNMP
Repo.insert!(%Device{
device_id: router.id,
sys_descr: "RouterOS CCR1009-7G-1C-1S+",
sys_object_id: "1.3.6.1.4.1.14988.1",
sys_name: "router"
})
{:ok, account} =
Gaiia.upsert_account(org.id, %{
gaiia_id: "acct-r",
name: "Router Sub",
subscription_count: 1
})
{:ok, _} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-r",
account_gaiia_id: "acct-r",
status: "ACTIVE",
mrr_amount: Decimal.new("50.00")
})
now = DateTime.truncate(DateTime.utc_now(), :second)
# Link to router via LAN IP
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: router.id,
gaiia_account_id: account.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "10.0.0.5",
inserted_at: now,
updated_at: now
})
result = Gaiia.get_site_impact(site.id)
# Site totals should exclude router-only subscribers
assert result.subscriber_count == 0
assert Decimal.equal?(result.mrr, Decimal.new(0))
# Router's per-device entry should show 0
router_device = Enum.find(result.devices, &(&1.device_id == router.id))
assert router_device.count == 0
# AP should show 0 (no links to it)
ap_device = Enum.find(result.devices, &(&1.device_id == ap.id))
assert ap_device.count == 0
end
test "site totals equal sum of non-router per-device impacts", %{org: org} do
site = create_site(org.id)
router = device_fixture(%{organization_id: org.id, site: site, name: "Router"})
ap1 = device_fixture(%{organization_id: org.id, site: site, name: "AP-1"})
ap2 = device_fixture(%{organization_id: org.id, site: site, name: "AP-2"})
Repo.insert!(%Device{
device_id: router.id,
sys_descr: "RouterOS CCR1009-7G-1C-1S+",
sys_object_id: "1.3.6.1.4.1.14988.1",
sys_name: "router"
})
{:ok, acct1} =
Gaiia.upsert_account(org.id, %{gaiia_id: "acct-a1", name: "Sub A", subscription_count: 1})
{:ok, acct2} =
Gaiia.upsert_account(org.id, %{gaiia_id: "acct-a2", name: "Sub B", subscription_count: 1})
{:ok, _} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-a1",
account_gaiia_id: "acct-a1",
status: "ACTIVE",
mrr_amount: Decimal.new("40.00")
})
{:ok, _} =
Gaiia.upsert_billing_subscription(org.id, %{
gaiia_id: "sub-a2",
account_gaiia_id: "acct-a2",
status: "ACTIVE",
mrr_amount: Decimal.new("60.00")
})
now = DateTime.truncate(DateTime.utc_now(), :second)
# acct1 on AP-1 and router (AP wins via wireless_mac)
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: ap1.id,
gaiia_account_id: acct1.id,
match_method: "wireless_mac",
confidence: "high",
subscriber_mac: "aa:bb:cc:dd:ee:01",
inserted_at: now,
updated_at: now
})
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: router.id,
gaiia_account_id: acct1.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "10.0.0.1",
inserted_at: now,
updated_at: now
})
# acct2 on AP-2 only
Repo.insert!(%Gaiia.DeviceSubscriberLink{
organization_id: org.id,
device_id: ap2.id,
gaiia_account_id: acct2.id,
match_method: "arp_ip",
confidence: "medium",
subscriber_ip: "10.0.0.2",
inserted_at: now,
updated_at: now
})
result = Gaiia.get_site_impact(site.id)
# Site totals = AP-1 (1 sub, $40) + AP-2 (1 sub, $60) = 2 subs, $100
assert result.subscriber_count == 2
assert Decimal.equal?(result.mrr, Decimal.new("100.00"))
# Per-device
ap1_dev = Enum.find(result.devices, &(&1.device_id == ap1.id))
assert ap1_dev.count == 1
ap2_dev = Enum.find(result.devices, &(&1.device_id == ap2.id))
assert ap2_dev.count == 1
router_dev = Enum.find(result.devices, &(&1.device_id == router.id))
assert router_dev.count == 0
end
end
describe "get_site_subscriber_summary/1" do
test "returns subscriber data for a mapped site", %{org: org} do
site = create_site(org.id)

View file

@ -225,7 +225,7 @@ defmodule ToweropsWeb.SiteLiveTest do
assert html =~ "$3,750"
end
test "hides subscriber badge when no Gaiia mapping", %{
test "shows zero subscribers when no Gaiia mapping", %{
conn: conn,
organization: organization
} do
@ -242,8 +242,8 @@ defmodule ToweropsWeb.SiteLiveTest do
{:ok, _view, html} = live(conn, ~p"/sites/#{site.id}")
refute html =~ "subscribers"
refute html =~ "MRR"
assert html =~ "Subscribers"
assert html =~ "$0"
end
end