Passa al contenuto principale

Akira — Agent Model v5.16 (fee rules: catch-all + per-destination override)

Data: 2026-05-14 (sessione 4) Riferimento docx: cap. 30 (Agent Management), cap. 14.9 (Rating engine), cap. 15 (Rebill) Schema DB: akira_schema_v1.sql — tabelle agents, agent_fee_rules, agent_fee_cdr_attribution, funzione fn_resolve_agent_fee() Mockup riferimento: akira-mockup.html route #/agents, #/agents/:id (tab Fee Rules)


0. Scopo

Documento di chiarimento del modello Agent + fee rules dopo il feedback dell'utente del 2026-05-14:

"Domanda: se un agent ha su Sierra Leone fee fissa 0,01, per Senegal fee 0,03 e per Cameroon fee su revenue %, va inserito 3 volte l'agent? Forse sarebbe il caso di prevedere che il piano agent possa essere anche misto, e semplicemente vada definito con Rules ALL destinations o per destinazione — e quello per destinazione override quello ALL."

Risposta sintetica: il modello cap. 30.2 docx già prevede "Coesistenza" (mix di fee per_minute + percent_profit). Lo schema DB agent_fee_rules lo supporta nativamente da v5.15. Il problema era solo nel mockup openNewAgent() che esponeva un radio fee_model a livello agent — concettualmente sbagliato. Sistemato in v5.16.


1. Modello dati

1.1 Entità

agents (1) ───< agent_fee_rules (N) >─── destinations (0..1, NULL = catch-all)

└──< agent_fee_cdr_attribution (M) — snapshot per CDR
  • agents: anagrafica pura. Nome, company linked (opzionale), email, stato attivo, note.
    • agents.fee_model RIMOSSO da v5.16 (era ridondante e fonte di confusione).
  • agent_fee_rules: la verità sul calcolo fee. Ogni rule ha:
    • agent_id (chi)
    • destination_idNULL = catch-all "ALL destinations" · NOT NULL = override per quella destination
    • fee_per_minute OR percent_profit — esattamente uno (XOR constraint)
    • valid_from / valid_to — versioning temporale
  • agent_fee_cdr_attribution: snapshot immutabile della fee attribuita a ogni CDR (per audit + rebill).

1.2 Constraint chiave (cap. 30.3 v5.16)

-- XOR: una rule è O per_minute O percent_profit, mai entrambi o nessuno
CHECK ( (fee_per_minute IS NOT NULL AND percent_profit IS NULL) OR
(fee_per_minute IS NULL AND percent_profit IS NOT NULL) )

-- 0..1 per percent_profit (es. 0.03 = 3%)
CHECK (percent_profit IS NULL OR (percent_profit >= 0 AND percent_profit <= 1))

-- Non-negative per fee_per_minute
CHECK (fee_per_minute IS NULL OR fee_per_minute >= 0)

-- Versioning sano
CHECK (valid_to IS NULL OR valid_to > valid_from)

1.3 EXCLUDE constraint temporale (v5.16, richiede btree_gist)

Impedisce 2 rule attive sovrapposte temporalmente per la stessa coppia (agent_id, destination_id NULL-safe):

ALTER TABLE agent_fee_rules
ADD CONSTRAINT excl_agent_fee_rules_temporal
EXCLUDE USING gist (
agent_id WITH =,
(COALESCE(destination_id, -1)) WITH =, -- NULL trattato come "-1" univoco per la catch-all
tstzrange(valid_from, COALESCE(valid_to, 'infinity'::timestamptz), '[)') WITH &&
);

Garantisce per ogni (agent, destination, momento) esiste al più 1 rule attiva.


2. Precedence rule (rating engine, cap. 30.4)

Quando il rating engine deve attribuire la fee a un CDR, fa lookup nella tabella agent_fee_rules applicando questa precedence:

  1. Match esatto su destination (rule con destination_id = cdr.destination_id valid_at cdr.started_at)
  2. Fallback su catch-all (rule con destination_id IS NULL valid_at cdr.started_at)
  3. Nessuna rule applicabile → no fee attribuita (zero payout per quel CDR)

Espressione SQL canonica (funzione fn_resolve_agent_fee v5.16):

CREATE OR REPLACE FUNCTION fn_resolve_agent_fee(
p_agent_id BIGINT, p_destination_id BIGINT, p_ts TIMESTAMPTZ
) RETURNS TABLE(rule_id BIGINT, fee_per_minute NUMERIC, percent_profit NUMERIC)
LANGUAGE SQL STABLE AS $$
SELECT id, fee_per_minute, percent_profit
FROM agent_fee_rules
WHERE agent_id = p_agent_id
AND (destination_id = p_destination_id OR destination_id IS NULL)
AND p_ts >= valid_from
AND (valid_to IS NULL OR p_ts < valid_to)
ORDER BY (destination_id IS NOT NULL) DESC, valid_from DESC
LIMIT 1;
$$;

L'ORDER BY (destination_id IS NOT NULL) DESC è la chiave: prima vengono rule per-destination (TRUE), poi catch-all (FALSE).


3. Esempi concreti

3.1 Esempio 1 — Agent puro per_minute uniforme

Agent "Anna Bianchi" (Sparkle SpA): €0.0015/min su qualunque destination.

INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
VALUES (3, NULL, 0.0015, NULL, '2025-12-01');

1 sola rule catch-all. Nessun override. Modello identico al pre-v5.16 fee_model='per_minute' + agents.fee_rate.

3.2 Esempio 2 — Agent puro percent_profit uniforme

Agent "Reseller Group DE" (BICS Belgium): 2.5% sul profit su qualunque destination, override 5% su Germany Mobile.

-- Catch-all
INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
VALUES (4, NULL, NULL, 0.025, '2026-01-01');

-- Override per Germany Mobile (dst id=7)
INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
VALUES (4, 7, NULL, 0.05, '2026-01-01');

CDR verso Germany Mobile → 5% del profit; CDR verso altre destination → 2.5% del profit.

3.3 Esempio 3 — MIX per_minute + percent_profit (il caso utente)

Agent "Sales Team UK" (Bitvoice International):

  • Catch-all: 1% del profit su tutte le destination
  • UK Mobile (all): 3% del profit
  • Sierra Leone: €0.01/min (fee fissa)
  • Senegal: €0.03/min (fee fissa)
  • Cameroon: 3% del profit
-- Catch-all
INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
VALUES (2, NULL, NULL, 0.01, '2026-02-01');

-- Override UK Mobile (dst id=5)
INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
VALUES (2, 5, NULL, 0.03, '2026-02-01');

-- Sierra Leone (dst id=20)
INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
VALUES (2, 20, 0.0100, NULL, '2026-03-15');

-- Senegal (dst id=21)
INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
VALUES (2, 21, 0.0300, NULL, '2026-03-15');

-- Cameroon (dst id=22)
INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
VALUES (2, 22, NULL, 0.03, '2026-03-15');

1 agent + 5 rule. Niente più "3 agenti separati per 3 destination".

3.4 Esempio rating engine in azione

CDR verso Sierra Leone, durata 60s, revenue €0.50, cost €0.40:

SELECT * FROM fn_resolve_agent_fee(2, 20, '2026-05-14 10:00:00');
-- ritorna: rule_id=143, fee_per_minute=0.0100, percent_profit=NULL
-- fee = 0.0100 * (60/60) = €0.0100 attribuiti all'agent

CDR verso Cameroon, durata 120s, revenue €1.00, cost €0.70:

SELECT * FROM fn_resolve_agent_fee(2, 22, '2026-05-14 10:00:00');
-- ritorna: rule_id=145, fee_per_minute=NULL, percent_profit=0.03
-- fee = 0.03 * (1.00 - 0.70) = €0.009 attribuiti all'agent

CDR verso Italia (no override, no catch-all match? Esempio non per Sales Team UK):

SELECT * FROM fn_resolve_agent_fee(2, 1, '2026-05-14 10:00:00');
-- ritorna: rule_id=141 (catch-all), fee_per_minute=NULL, percent_profit=0.01
-- fee = 0.01 * profit · catch-all attiva

4. UI mockup v5.16

4.1 Modal "Nuovo Agent" (openNewAgent())

Sostituito il radio fee_model a livello agent con:

  • Sezione "Fee rule catch-all (ALL destinations)" obbligatoria con scelta per_minute / percent_profit + valore + validity.
  • Sezione "Override per destination" opzionale con tabella (destination, type, value, valid_from, valid_to) + button "+ Aggiungi override".

Banner contestuale: "Modello fee rules cap. 30.2 v5.16 — Ogni agent ha 1 rule catch-all obbligatoria + N override per-destination opzionali."

4.2 Agent detail page (#/agents/:id tab Fee Rules)

Tabella che mostra tutte le rule attive: prima riga catch-all evidenziata in cyan, sotto le override per-destination. Bottoni "+ Aggiungi override per destination" (action card-header) + "⚙ Edit / 🗑 Remove" per riga (la catch-all non si può rimuovere, solo modificare).

Banner che spiega la precedence rule. Card "Esempio rating runtime" mostra il SELECT SQL canonico per trasparenza tecnica.

4.3 Agent list page (#/agents)

Colonna "Fee structure" sostituisce la vecchia "Fee model": mostra descrizione compatta:

  • €0.0015/min (solo catch-all per_minute, no override)
  • 2.50% profit · +1 override (catch-all percent + 1 override)
  • 1.00% profit · +4 override (caso Sales UK con mix)

Colonna "Rules" mostra count totale N rules (1 default + M ovr).


5. Migrazione da v5.15 → v5.16

5.1 Schema changes

-- Step 1: rimuovi colonna fee_model da agents (era ridondante)
ALTER TABLE agents DROP COLUMN IF EXISTS fee_model;

-- Step 2: aggiungi EXCLUDE temporal constraint (richiede btree_gist extension)
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE agent_fee_rules
ADD CONSTRAINT excl_agent_fee_rules_temporal
EXCLUDE USING gist (
agent_id WITH =,
(COALESCE(destination_id, -1)) WITH =,
tstzrange(valid_from, COALESCE(valid_to, 'infinity'::timestamptz), '[)') WITH &&
);

-- Step 3: tighten CHECK XOR (rimpiazza il check OR debole con XOR stretto)
ALTER TABLE agent_fee_rules DROP CONSTRAINT IF EXISTS agent_fee_rules_check;
ALTER TABLE agent_fee_rules ADD CONSTRAINT agent_fee_rules_xor_check
CHECK ( (fee_per_minute IS NOT NULL AND percent_profit IS NULL) OR
(fee_per_minute IS NULL AND percent_profit IS NOT NULL) );

-- Step 4: crea helper function fn_resolve_agent_fee
CREATE OR REPLACE FUNCTION fn_resolve_agent_fee(...) ...;

-- L'ENUM agent_fee_model_enum resta orfano — mantenuto per backward-compat / viste API
-- (non usato da nessuna tabella attualmente).

5.2 Data migration

Se in produzione esistevano agenti con solo fee_model='per_minute' (no agent_fee_rules), creare la rule catch-all corrispondente:

INSERT INTO agent_fee_rules (agent_id, destination_id, fee_per_minute, percent_profit, valid_from)
SELECT id, NULL, 0.0010, NULL, created_at -- default €0.001/min, da rivedere caso per caso
FROM agents
WHERE NOT EXISTS (
SELECT 1 FROM agent_fee_rules WHERE agent_id = agents.id AND destination_id IS NULL
);

In Akira pre-MVP non c'è ancora produzione → nessuna data migration reale necessaria. Punto rilevante per il futuro.


6. Implicazioni implementative

6.1 Backend FastAPI (Fase 3a)

Modulo services/agents/:

  • agents_service.py — CRUD agent.
  • agent_fee_rules_service.py:
    • list_rules(agent_id) — tutte le rule, catch-all in cima.
    • create_rule(agent_id, destination_id, fee_per_minute|percent_profit, valid_from, valid_to) — con validation XOR + EXCLUDE conflict detection.
    • update_rule(rule_id, ...) — usa pattern "soft close + new rule" (no UPDATE diretto su rule attive per audit trail pulito).
    • resolve_fee(agent_id, destination_id, ts) — wrapper Python di fn_resolve_agent_fee().
  • rating_integration.py:
    • Hook in services/rating/cdr_rater.py: per ogni CDR, se l'originator è linked a un agent, chiama resolve_fee() + insert in agent_fee_cdr_attribution con rule_id_snapshot.
  • payouts_service.py (Fase 3b roadmap):
    • compute_payout_mtd(agent_id, month) — aggregato di agent_fee_cdr_attribution per il periodo.

6.2 Frontend Next.js (Fase 3a)

Componenti:

  • AgentList + AgentDetail (riusa shadcn Table + Tabs).
  • AgentFeeRulesTable — visualizza catch-all + override con badge semantico (badge cyan "DEFAULT · ALL destinations" + mono per per-destination).
  • AddAgentFeeRuleModal — wizard con select destination (con typeahead se >100 destinations) + radio fee_type + input value + validity dates.
  • FeeStructureDescription — componente che genera la stringa "€0.0015/min" / "2.50% profit · +N override" usata in list e header.

6.3 AgentCore tools (cap. 48)

Tool ufficiali per Telegram bot:

  • query_agent_rules(agent_name) — read-only.
  • add_agent_fee_override(agent_name, destination, fee_type, value) — destructive, requires_confirmation=True. Es. Massimo via Telegram: "Aggiungi Mario Rossi override 0.03 EUR/min su Senegal" → bot conferma e crea la rule.

7. FAQ

Q1: Posso avere catch-all con valid_to passato e nessuna rule attiva? A: Sì, ma in quel caso il rating engine non attribuisce fee all'agent (zero payout). Lo schema lo permette. UI mostra warning "Agent senza rule attiva — payout = 0".

Q2: Posso avere 2 rule catch-all per lo stesso agent con periodi disgiunti? A: Sì, è il caso storico — es. catch-all €0.001/min dal 2024 al 2025-12-31, poi catch-all €0.0015/min dal 2026-01-01. EXCLUDE constraint permette periodi disgiunti, blocca solo overlap.

Q3: Cosa succede se cambio il prezzo della rule catch-all in corso d'opera? A: Pattern raccomandato: NON UPDATE la rule attiva. Invece:

  1. UPDATE agent_fee_rules SET valid_to = now() WHERE id = vecchia_rule;
  2. INSERT ... valid_from = now(); per la nuova rule.

Così l'audit trail e agent_fee_cdr_attribution.rule_id_snapshot restano coerenti con i CDR storici.

Q4: Le rule possono essere riusate tra agent diversi? A: No, ogni rule è specifica per un agent_id. Se vuoi "template" di rule, definiscile a livello di company (cap. 30 roadmap v2 — "Agent Templates").

Q5: Cosa significa "0%" percent_profit? A: Valid: l'agent guadagna 0 su quella destination (modo per "disabilitare" il payout senza eliminare la rule). Caso d'uso: contratto con clausola "payout solo su 5 destinations specifiche, zero sul resto" → catch-all 0%, override sulle 5.


8. Riferimenti incrociati

  • ADR-0001 stack Python/FastAPI/Next.js → docs/adr/0001-stack-python-fastapi-nextjs.md
  • Routing model (per pattern simile destination-centric) → docs/architecture/routing-model-v5.16.md
  • Signaling decisions → docs/architecture/signaling-decisions.md
  • Schema DB completo → akira_schema_v1.sql sezione 10c (linee 1338-1430 circa)
  • Docx capitolo origine → cap. 30 (Agent Management) v5.15 → v5.16 con questa correzione

9. Storia delle revisioni

DataVersioneCambio
2026-05-14 (sessione 4)1.0Prima versione consolidata dopo feedback utente sul mix fee per_minute + percent_profit. Schema già supportava il modello da v5.15; chiarito che agents.fee_model era ridondante (rimosso v5.16). Aggiunti EXCLUDE constraint temporale + funzione fn_resolve_agent_fee() per rating engine. UI mockup openNewAgent riscritto con catch-all + override pattern.

Documento consolidato 2026-05-14 da Massimo Bagnoli (m.bagnoli@asheep.it). Vincolante per implementazione Fase 3a (services/agents + frontend AgentDetail) e Fase 3b (payouts_service).