Merge pull request 'gaiia' (#1) from gaiia into main

Reviewed-on: graham/towerops-web#1
This commit is contained in:
Graham McIntire 2026-02-15 15:17:27 -08:00
commit 6d678b6d17
17 changed files with 765 additions and 61 deletions

View file

@ -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,7 +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
true -> :dev
true -> @compile_env
end
end

View file

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

View file

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

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

@ -37,6 +37,8 @@ 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)
Gaiia.SiteAggregation.compute_and_store(org_id)
{:ok,
%{
@ -168,6 +170,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 -> []

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) %>
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5">
<%= if active_billing do %>
<%!-- Active billing provider --%>
@ -40,7 +43,10 @@
<div class="flex items-start justify-between">
<div class="flex items-start gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
<.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"
/>
</div>
<div>
<div class="flex items-center gap-3">
@ -71,10 +77,17 @@
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"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"
"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 %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· 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
</span>
<% 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>
<.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"
)
]}
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50 group-hover:bg-indigo-100 dark:bg-indigo-900/30">
<.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"
/>
</div>
<span class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{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)) %>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">
Configure {provider.name}
@ -292,7 +316,9 @@
{t("Webhook Configuration")}
</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{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."
)}
</p>
<div class="mt-4 space-y-4">
@ -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 @@
<div class="flex items-start justify-between">
<div class="flex items-start gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
<.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"
/>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
@ -398,7 +431,8 @@
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
if(integration.enabled,
do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
do:
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
else: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
)
]}>
@ -420,10 +454,17 @@
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"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"
"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 %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· 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
</span>
<% 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"
)
]}
>
<span class={[

View file

@ -0,0 +1,10 @@
defmodule Towerops.Repo.Migrations.AddBillingFieldsToIntegrations do
use Ecto.Migration
def change do
alter table(:integrations) do
add :subscriber_count, :integer, default: 0
add :total_mrr, :decimal, default: 0
end
end
end

View file

@ -6,7 +6,7 @@ defmodule Towerops.DashboardTest do
import Towerops.OrganizationsFixtures
alias Towerops.Dashboard
alias Towerops.Gaiia
alias Towerops.Integrations
setup do
user = user_fixture()
@ -53,21 +53,18 @@ defmodule Towerops.DashboardTest do
assert summary.alerts.active_count == 1
end
test "returns subscriber totals from Gaiia", %{org: org} do
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-dash",
name: "Tower",
account_count: 100,
total_mrr: Decimal.new("5000.00")
})
test "returns subscriber totals from billing integrations", %{org: org} do
{:ok, integration} =
Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true})
{:ok, _} = Integrations.update_billing_totals(integration, 100, Decimal.new("5000.00"))
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.subscribers.total == 100
assert Decimal.equal?(summary.subscribers.total_mrr, Decimal.new("5000.00"))
end
test "returns zero subscribers when no Gaiia data", %{org: org} do
test "returns zero subscribers when no billing integrations", %{org: org} do
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.subscribers.total == 0
end
@ -123,29 +120,23 @@ defmodule Towerops.DashboardTest do
end
describe "get_org_subscriber_summary/1" do
test "returns totals from Gaiia network sites", %{org: org} do
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-sub1",
name: "Tower A",
account_count: 50,
total_mrr: Decimal.new("2500.00")
})
test "returns totals from billing integrations", %{org: org} do
{:ok, i1} =
Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true})
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-sub2",
name: "Tower B",
account_count: 30,
total_mrr: Decimal.new("1500.00")
})
{:ok, _} = Integrations.update_billing_totals(i1, 50, Decimal.new("2500.00"))
{:ok, i2} =
Integrations.create_integration(org.id, %{provider: "splynx", enabled: true})
{:ok, _} = Integrations.update_billing_totals(i2, 30, Decimal.new("1500.00"))
result = Dashboard.get_org_subscriber_summary(org.id)
assert result.total == 80
assert Decimal.equal?(result.total_mrr, Decimal.new("4000.00"))
end
test "returns zeros when no Gaiia data", %{org: org} do
test "returns zeros when no billing integrations", %{org: org} do
result = Dashboard.get_org_subscriber_summary(org.id)
assert result.total == 0
assert Decimal.equal?(result.total_mrr, Decimal.new(0))

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

View file

@ -150,6 +150,18 @@ defmodule Towerops.Gaiia.SyncTest do
assert result.accounts == 0
end
test "persists billing totals on the integration after sync", %{integration: integration} do
stub_all_endpoints()
{:ok, _} = Sync.sync_organization(integration)
updated = Repo.reload!(integration)
# 1 active account with subscription_count > 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()

View file

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

View file

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