Payments
Come ho progettato un payment gateway orchestrator dopo un corso di iGaming
PayMaestro: un'unica API davanti a molti payment gateway, con routing, cascading retry, idempotency imposta dal database, e fraud screening basato su una certificazione anti-frode della iGaming Academy.
Dopo aver finito il corso Anti-Fraud and Payments Handling della iGaming Academy, volevo davvero costruire qualcosa con quello che insegnava invece di lasciarlo lì come un certificato. È diventato PayMaestro: una payment orchestration API, costruita spec-first in un weekend.
Cosa significa davvero orchestration
Payment orchestration è lo strato sopra i payment gateway. Un merchant si integra una volta, diverse rotte di acquiring stanno dietro quell'unica integrazione, e la orchestration platform aggiunge l'intelligenza: scegliere la rotta migliore per ogni transazione, fare retry in sicurezza quando una rotta fallisce, e mantenere evidenza di tutto quello che è successo.
Quest'ultima parte conta più in questo dominio che nella maggior parte degli altri. I record di pagamento sono evidenza regolamentare, quindi il design deve trattarli così fin dall'inizio.
Hard decline e soft decline non sono lo stesso fallimento
Questa è la distinzione che il corso mi ha reso concreta, ed è il nucleo della cascade policy. Un soft decline, fondi insufficienti o un timeout, è recuperabile, quindi ha senso provare il gateway successivo nella rotta. Un hard decline significa una carta rubata o bloccata, e riprovare su un altro acquirer non è resilienza, è solo cercare di spingere una transazione fraudolenta a passare da un'altra parte.
Quindi la cascade si ferma immediatamente su un hard decline e continua su uno soft:
if (result.ResultType is GatewayResultType.Approved)
{
payment.Authorize();
payment.Capture();
return;
}
if (result.ResultType is GatewayResultType.HardDecline)
break; // fraud signal: never retry elsewhere
// SoftDecline / Error -> next gateway in the route
Fraud screening prima di contattare qualsiasi gateway
Ogni fraud rule implementa un unico Domain contract, quindi aggiungerne una non tocca l'orchestrator:
public interface IFraudRule
{
string RuleName { get; }
Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default);
}
La prima regola in produzione è decline velocity: una carta con tre o più tentativi rifiutati in 24 ore è il classico pattern di card testing, quindi il quarto tentativo viene rifiutato con zero chiamate al gateway. Lo screening gira prima che qualsiasi gateway venga contattato, il che significa che una carta già nota come cattiva non costa niente da rifiutare.
L'idempotency appartiene al database, non solo al codice
Se un merchant ripete un request, lo stesso pagamento non deve mai essere addebitato due volte. L'approccio ovvio è controllare se l'idempotency key esiste già prima di inserire, ma questo ha una finestra di race tra la lettura e la scrittura, e due retry concorrenti possono superare entrambi il controllo.
Quindi l'idempotency key ha un indice univoco su di sé. Il database serializza i due insert, quello perdente lancia un errore, e quell'exception viene catturata e risposta ripetendo il risultato salvato. La garanzia vive dove la concorrenza viene davvero risolta, invece che nel codice applicativo che spera di vincere una race.
Routing come configurazione
L'idoneità del gateway, quali valute ciascuno supporta e quali limiti di importo si applicano, più la priorità delle rotte, vivono tutti in appsettings.json e vengono collegati tramite l'options pattern. Aggiungere un acquirer è una classe che implementa il gateway contract e una voce di config, senza modifiche a nessuna business logic.
Dove la clean architecture ha davvero dato i suoi frutti
Questo è il progetto dove il layering che stavo imparando ha smesso di essere teorico. API → Application → Domain ← Infrastructure, con il Domain che contiene le entity, la state machine dei pagamenti, e tutti i contract, senza alcun riferimento esterno. Infrastructure implementa quei contract con EF Core e SQLite.
La state machine dei pagamenti è protetta dentro l'aggregate stesso, quindi una transizione non valida lancia una domain exception invece di corrompere silenziosamente un record. Non c'è modo di tornare indietro da Captured. I miei progetti precedenti usavano un modello anemico dove l'entity era solo un sacco di proprietà e le regole vivevano altrove. Spostare gli invariant dentro l'aggregate è stato un cambiamento deliberato rispetto a quello.
Audit-first, e attento al PCI
Ogni tentativo di gateway viene persistito con il nome del gateway, l'ordine in cui è stato provato, il result code, e quanto tempo ci è voluto. Anche ogni fraud flag viene salvata, e i delete sono limitati a livello di database. Se un acquirer solleva una richiesta di informazioni, una singola GET restituisce il pacchetto completo di evidenze.
Vengono salvati solo il BIN e le ultime quattro cifre di una carta, mai il numero completo, che è la base minima per gestire dati di carta in generale.
Cosa aggiungerei dopo
Altre regole sullo stesso IFraudRule contract: geo mismatch tra il paese dell'IP e il paese della carta, che ha bisogno di dati BIN e GeoIP reali per essere significativo, e amount anomaly detection. Poi i refund, e CI/CD verso Azure Container Apps.