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_modelRIMOSSO da v5.16 (era ridondante e fonte di confusione).
agent_fee_rules: la verità sul calcolo fee. Ogni rule ha:agent_id(chi)destination_id— NULL = catch-all "ALL destinations" · NOT NULL = override per quella destinationfee_per_minuteORpercent_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:
- Match esatto su destination (rule con
destination_id = cdr.destination_idvalid_at cdr.started_at) - Fallback su catch-all (rule con
destination_id IS NULLvalid_at cdr.started_at) - 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 difn_resolve_agent_fee().
rating_integration.py:- Hook in
services/rating/cdr_rater.py: per ogni CDR, se l'originator è linked a un agent, chiamaresolve_fee()+ insert inagent_fee_cdr_attributionconrule_id_snapshot.
- Hook in
payouts_service.py(Fase 3b roadmap):compute_payout_mtd(agent_id, month)— aggregato diagent_fee_cdr_attributionper il periodo.
6.2 Frontend Next.js (Fase 3a)
Componenti:
AgentList+AgentDetail(riusa shadcnTable+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:
UPDATE agent_fee_rules SET valid_to = now() WHERE id = vecchia_rule;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.sqlsezione 10c (linee 1338-1430 circa) - Docx capitolo origine → cap. 30 (Agent Management) v5.15 → v5.16 con questa correzione
9. Storia delle revisioni
| Data | Versione | Cambio |
|---|---|---|
| 2026-05-14 (sessione 4) | 1.0 | Prima 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).