Source code for ferrosoft.apps.emiflow.services.tonnage

#  Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
"""Tonnage accounting for subscription billing.

Customers are billed on the cumulative weight of (non-simulation)
emission bookings. This module exposes two query paths:

* A direct database aggregate per tenant (:meth:`TonnageService.get_summary`)
  used at startup to seed the Prometheus counter and on demand for
  reconciliation. Expensive — it opens a connection to every active
  tenant database.
* A cheap VictoriaMetrics query (:meth:`TonnageServiceVictoria.get_org_stats`)
  that returns the rolling 4-week tonnage and configured limit per
  organisation from the metrics store.
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from decimal import Decimal
from urllib.parse import urljoin

import requests
from django.core.exceptions import ImproperlyConfigured
from django.db.models.aggregates import Sum

from ferrosoft.apps.emiflow.models import EmissionBookingRecord
from ferrosoft.apps.ferrobase.models import Organization, Tenant, MeasurementUnit
from ferrosoft.apps.ferrobase.models.decimal import DecimalWithUnit
from ferrosoft.apps.ferrobase.tenant.context import active_tenant
from ferrosoft.config import require_settings
from ferrosoft.decimal import zero


[docs] @dataclass class TonnageSummary: """Aggregated booked tonnage for one ``(organization, tenant)`` pair.""" organization: Organization tenant: Tenant tonnage: Decimal
[docs] @dataclass class TonnageStats: """Billing snapshot for an organisation: current usage versus configured limit.""" billable_tonnage: DecimalWithUnit tonnage_limit: DecimalWithUnit
[docs] class TonnageService(ABC): """Abstract tonnage service. The concrete singleton is :class:`TonnageServiceVictoria`."""
[docs] @classmethod def get_instance(cls) -> "TonnageService": """Return the configured production tonnage service.""" return TonnageServiceVictoria.get_instance()
[docs] def get_summary(self) -> list[TonnageSummary]: """Return per-tenant booked tonnage across every active tenant. Iterates over every organisation and active tenant, opening each tenant database to read its booking-record aggregate. Misconfigured databases (``ImproperlyConfigured``) yield a zero-tonnage row so the summary remains complete. Costly; prefer :meth:`get_org_stats` whenever per-organisation totals are sufficient. """ summaries = [] for org in Organization.objects.all(): for tenant in org.tenants.filter(active=True): try: tonnage = self.get_tenant_tonnage(tenant) summaries.append(TonnageSummary(org, tenant, tonnage)) except ImproperlyConfigured: # This case may happen with false database credentials. summaries.append(TonnageSummary(org, tenant, zero)) return summaries
[docs] @staticmethod def get_tenant_tonnage(tenant): """Return ``tenant``'s booked tonnage from its own database. Simulation records are excluded so dry-run bookings don't count toward the customer's subscription quota. """ with active_tenant(tenant): records = EmissionBookingRecord.objects.filter(simulation=False) records = records.aggregate(Sum("weight_value", default=zero)) return records["weight_value__sum"]
[docs] @abstractmethod def get_org_stats(self, org: Organization) -> TonnageStats: """Return the billable tonnage and limit for ``org`` (implementation-specific).""" raise NotImplementedError()
[docs] class TonnageServiceVictoria(TonnageService): """Tonnage service backed by VictoriaMetrics Prometheus-compatible queries. Uses a single PromQL ``union`` over the ``emiflow_tonnage_limit`` gauge and the 4-week increase of the ``emiflow_tonnage_total`` counter to return both values in one HTTP call. Returns zero-stats on query failure so callers can render a safe fallback. """ _METRIC_TON = MeasurementUnit(symbol="t") _ZERO_TON = DecimalWithUnit(zero, _METRIC_TON) _ZERO_STATS = TonnageStats(_ZERO_TON, _ZERO_TON) _STATS_QUERY = """union( alias(emiflow_tonnage_limit{organization="%(org)s"}, "tonnage_limit"), alias( sum(increase(emiflow_tonnage_total{organization="%(org)s"}[4w])) by (organization), "current_tonnage" ))"""
[docs] @dataclass class Config: """VictoriaMetrics endpoint configuration.""" base_url: str timeout: float = 10
[docs] @classmethod def from_django_settings(cls): """Build a config from the ``VICTORIA_BASE_URL`` Django setting.""" settings = require_settings("VICTORIA_BASE_URL") return cls(settings["VICTORIA_BASE_URL"])
[docs] @classmethod def get_instance(cls): """Instantiate the service with config loaded from Django settings.""" return cls(cls.Config.from_django_settings())
def __init__(self, config: Config): self.config = config
[docs] def get_org_stats(self, org: Organization) -> TonnageStats: """Return ``org``'s current tonnage and limit from VictoriaMetrics. Falls back to a zero stats tuple (and logs the response) when the query fails, returns a non-``success`` status, or yields an unexpected result type. """ org_name = self._escape_string(org.name) response = requests.post( urljoin(self.config.base_url, "api/v1/query"), data={ "query": self._STATS_QUERY % {"org": org_name}, }, timeout=self.config.timeout, ) query_result = response.json() if query_result.get("status", None) != "success": logging.error("Victoria query failed: %s", query_result) return self._ZERO_STATS data = query_result.get("data", {}) result_type = data.get("resultType", None) if result_type != "vector": logging.error("Victoria query returned unexpected result: %s", query_result) return self._ZERO_STATS result = data.get("result", []) if len(result) == 0: return self._ZERO_STATS values = self._extract_metric_values(result) return TonnageStats( DecimalWithUnit(values.get("current_tonnage", zero), self._METRIC_TON), DecimalWithUnit(values.get("tonnage_limit", zero), self._METRIC_TON), )
@staticmethod def _escape_string(value: str) -> str: """Escape double quotes so the value can be embedded into a PromQL label match.""" return value.replace('"', '\\"') def _extract_metric_values(self, results: list) -> dict: """Map a PromQL ``vector`` result list to ``{metric_name: Decimal}``.""" return { result["metric"]["__name__"]: Decimal(result["value"][1]) for result in results if "metric" in result and "__name__" in result["metric"] and "value" in result and len(result["value"]) > 1 }