Nel settore telco, le controversie sulle fatture sono una costante. Un operatore contesta un importo, l'ufficio amministrativo cerca il foglio Excel del mese scorso, e nel frattempo i dati sono stati già sovrascritti. Carrier HUB è stato progettato per eliminare questa situazione alla radice: ogni voce di ricavo è tracciabile fino alla regola che l'ha generata, indipendentemente da quando viene consultata.

Il problema: i billing engine sono scatole nere

I sistemi di fatturazione tradizionali calcolano un importo e lo salvano. Se il calcolo cambia — per una variazione di tariffe, un'eccezione cliente o un errore corretto — non c'è modo di sapere perché la fattura del mese precedente riportava un valore diverso.

Carrier HUB risolve questo con tre componenti distinte che lavorano insieme: uno stack di regole ispezionabile, un ledger immutabile per voce, e un sistema di versioning delle regole con fingerprinting delle fatture.

Lo stack di regole: BaseBillingRule

Il calcolo di ogni fattura parte da uno stack ordinato di regole, ognuna delle quali eredita da BaseBillingRule. Ogni regola riceve un BillingContext — un oggetto che porta la sessione DB, il cliente, la fattura, il report traffico, e il dict dei parametri policy — e lo modifica in modo controllato.

class BaseBillingRule:
    name: str

    def evaluate(self, context: BillingContext) -> None:
        raise NotImplementedError

Lo stack è costruito in modo deterministico da BillingCalculator._build_rule_stack(). L'ordine è fisso: prima si calcolano i canoni base, poi le attivazioni, poi le voci extra. Ogni regola scrive solo i campi di sua competenza. Il risultato finale è la somma di contributi separati, ognuno con una firma.

Il ledger: LedgerEntry e context.log_trace

Ogni regola può registrare la propria decisione in due modi:

context.log_trace(rule_name, description, amount) scrive nel trace della fattura una riga leggibile che spiega perché è stato calcolato quel valore. Ad esempio:

NumeroVerdeRule → "Numero Verde da invoice.importo_numero_verde" → 0.00
AttivazioniRule → "Attivazioni VDSL: 1 connessione Fibra 1G" → 120.00

LedgerEntry è più strutturato: ogni voce ha tipo (RICAVO o COSTO), importo, codice articolo ERP, e un flag exportable che determina se la voce concorre al totale export. L'ExportPolicyEngine usa questo ledger per costruire il pacchetto dati verso il sistema contabile, separando i ricavi esportabili dai costi interni.

@dataclass
class LedgerEntry:
    entry_type: Literal["RICAVO", "COSTO"]
    label: str
    amount: Decimal
    article_code: str | None
    exportable: bool

Il totale di export non è mai calcolato sommando tutti i ricavi: è la somma dei soli LedgerEntry con exportable=True e entry_type="RICAVO". Se una voce non deve uscire verso l'ERP — per policy, per eccezione o per azzeramento esplicito — basta marcarla exportable=False nel ledger, e il totale cambia di conseguenza senza toccare il calcolo base.

Versioning e validità temporale delle regole

Il modello ExportArticleRule gestisce la mappatura tra le voci di billing e gli articoli del sistema contabile. Con l'introduzione di Fase 2, ogni regola porta quattro campi aggiuntivi:

  • version (intero) — incrementato ad ogni modifica, mai decrementato
  • statusdraft, active o archived
  • valid_from / valid_to — la finestra temporale in cui la regola è applicabile

Quando viene generato un export, il resolver applica solo le regole con status = active e la cui finestra di validità copre la data di competenza del cliente. Questo significa che una regola archiviata a febbraio non influisce sul ricalcolo di gennaio: il sistema determina il set corretto in base alla data, non in base allo stato attuale.

def _filter_rules_by_date(
    rules: list[ExportArticleRule],
    competenza_date: date,
) -> list[ExportArticleRule]:
    result = []
    for r in rules:
        vf = getattr(r, "valid_from", None)
        vt = getattr(r, "valid_to", None)
        if vf is not None and vf > competenza_date:
            continue
        if vt is not None and vt < competenza_date:
            continue
        result.append(r)
    return result

Il filtro avviene in memoria (il DB carica già solo le regole active), una volta per ogni cliente in base alla sua data di competenza effettiva — che può differire tra clienti con fatturazione anticipata e posticipata nello stesso ciclo mensile.

Il fingerprint: article_rules_hash

Per ogni fattura esportata, Carrier HUB calcola un fingerprint SHA-256 (troncato a 16 caratteri hex) del set di regole che è stato effettivamente applicato:

pairs = sorted((r.id, r.version) for r in active_rules)
hash = sha256(json.dumps(pairs).encode()).hexdigest()[:16]

Questo hash viene scritto su MonthlyInvoice.article_rules_hash al momento dell'export. Il risultato pratico: se in futuro un cliente contesta una fattura, basta leggere il fingerprint e confrontarlo con i set di regole versionate nel DB per ricostruire esattamente quale configurazione ha prodotto quell'importo.

Policy configurabile: separare le decisioni dal codice

Le scelte di calcolo non hardcoded nelle regole — come la modalità di gestione delle attivazioni, il comportamento del Numero Verde o la sorgente del costo fornitore VDSL — sono esposte come parametri in resolve_billing_policy(). Ogni profilo runtime può specificare valori diversi da quelli di default, e le regole li leggono da context.policy senza logica condizionale sparsa nel codice:

if context.policy.get("numero_verde_mode", "standard") == "disabled":
    context.importo_numero_verde = Decimal("0.00")
    context.log_trace(self.name, "Numero Verde disabilitato da policy", Decimal("0"))
    return

Cambiare una policy non richiede un deploy. Richiede una modifica alla configurazione del profilo runtime e, se necessario, un ricalcolo della fattura — che produce un nuovo fingerprint, tracciabile.

Conclusioni

Un billing engine non è mai davvero "funzionante" se non è anche spiegabile. In Carrier HUB, la domanda "perché questo importo?" ha sempre una risposta: il trace, il ledger, il fingerprint e la cronologia versioni delle regole. Non è un'aggiunta a posteriori — è il modo in cui il sistema è stato costruito.

Vuoi vedere il motore applicato al tuo ciclo carrier? Richiedi una Demo.