feat(p0-2026-04-25): PI-SPI auth 3-facteurs + RTP + alias
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m32s

P0-NEW-2 — Authentification PI-SPI 3-facteurs (spec sandbox developer.pispi.bceao.int)
  - PispiAuth : OAuth2 + mTLS (PKCS12 keystore + truststore optionnel) + X-API-Key
  - SSLContext TLS 1.3 + KeyManagerFactory + TrustManagerFactory
  - HttpClient mTLS lazy + cache, HTTP/2, timeout 15s/30s
  - isConfigured() pour bascule auto mode mock si secrets absents

P0-NEW-3 — Request To Pay (RTP — pain.013/014)
  - dto/PispiRtpRequest (record validate())
  - dto/PispiRtpResponse avec helpers isAccepted()/isRefused()/...
  - PispiClient.initiateRtp() + getRtpStatus()
  - Cas d'usage : appel cotisation initié par la SFD vers le membre

P0-NEW-4 — Gestion d'alias (téléphone/email → compte SFD)
  - dto/PispiAlias (record + Types : PHONE_NUMBER/EMAIL/NATIONAL_ID/CUSTOM)
  - PispiClient.resolveAlias() + createAlias() + revokeAlias()
  - Cas d'usage : '+22507XXX@unionflow' ou 'cotisation-{slug}@unionflow'

PispiClient harmonisé : baseRequestBuilder() central avec 3 headers obligatoires, gestion 404 sur resolveAlias, timeouts 30s.
This commit is contained in:
2026-04-25 01:18:46 +00:00
parent d8006c8425
commit 144137656f
5 changed files with 530 additions and 37 deletions

View File

@@ -7,6 +7,10 @@ import jakarta.json.JsonObject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.FileInputStream;
import java.io.StringReader;
import java.net.URI;
import java.net.URLEncoder;
@@ -14,9 +18,44 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
/**
* Authentification PI-SPI à 3 facteurs : OAuth2 + mTLS + API Key.
*
* <p>Conforme à la spec sandbox developer.pispi.bceao.int (vérifiée 2026-04-25). Les 3 facteurs
* sont systématiquement présents sur tous les appels API Business :
*
* <ul>
* <li><strong>OAuth2 client_credentials</strong> — clientId + clientSecret pour récupérer un
* Bearer token, mis en cache jusqu'à expiration ({@code expires_in - 60s}).
* <li><strong>mTLS (mutual TLS)</strong> — certificat client (PKCS12) présenté pendant la
* handshake TLS. Configuré via {@link SSLContext} sur le {@link HttpClient}.
* <li><strong>API Key</strong> — header {@code X-API-Key} ajouté sur chaque requête (géré par
* {@link PispiClient}, exposé par {@link #getApiKey()}).
* </ul>
*
* <p>Configuration ({@code application.properties}) :
*
* <pre>{@code
* pispi.api.base-url=https://sandbox.pispi.bceao.int/business-api/v1
* pispi.api.client-id=<clientId BCEAO>
* pispi.api.client-secret=<clientSecret BCEAO>
* pispi.api.api-key=<X-API-Key BCEAO>
* pispi.api.tls.keystore-path=/secrets/pispi-client.p12
* pispi.api.tls.keystore-password=<password>
* pispi.api.tls.truststore-path=/secrets/pispi-truststore.p12 # optionnel
* pispi.api.tls.truststore-password=<password> # optionnel
* }</pre>
*
* <p>En l'absence de credentials (mode dev sans sandbox), {@link #isConfigured()} renvoie
* {@code false} et {@link PispiPaymentProvider} bascule en mode mock.
*
* @since 2026-04-25 — auth 3-facteurs ajoutée (OAuth2 seul auparavant)
*/
@Slf4j
@ApplicationScoped
public class PispiAuth {
@@ -27,20 +66,116 @@ public class PispiAuth {
@ConfigProperty(name = "pispi.api.client-secret")
Optional<String> clientSecretOpt;
@ConfigProperty(name = "pispi.api.api-key")
Optional<String> apiKeyOpt;
@ConfigProperty(name = "pispi.api.tls.keystore-path")
Optional<String> keystorePathOpt;
@ConfigProperty(name = "pispi.api.tls.keystore-password")
Optional<String> keystorePasswordOpt;
@ConfigProperty(name = "pispi.api.tls.truststore-path")
Optional<String> truststorePathOpt;
@ConfigProperty(name = "pispi.api.tls.truststore-password")
Optional<String> truststorePasswordOpt;
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
String clientId;
String clientSecret;
String apiKey;
private HttpClient mtlsClient;
private String cachedToken;
private Instant cacheExpiry;
@jakarta.annotation.PostConstruct
void init() {
clientId = clientIdOpt.orElse("");
clientSecret = clientSecretOpt.orElse("");
apiKey = apiKeyOpt.orElse("");
// Le client mTLS est construit lazy au premier usage (évite échec au boot si secrets absents)
}
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
/**
* @return true si tous les facteurs (OAuth2 + mTLS + API Key) sont configurés et que le
* provider peut effectivement appeler la production. False = mode mock auto.
*/
public boolean isConfigured() {
return !clientId.isEmpty()
&& !clientSecret.isEmpty()
&& !apiKey.isEmpty()
&& keystorePathOpt.isPresent()
&& keystorePasswordOpt.isPresent();
}
private String cachedToken;
private Instant cacheExpiry;
/** Header {@code X-API-Key} à ajouter sur chaque requête API Business. */
public String getApiKey() {
return apiKey;
}
/** Base URL configurée (sandbox ou production). */
public String getBaseUrl() {
return baseUrl;
}
/**
* Retourne un {@link HttpClient} configuré avec mTLS (keystore client + truststore optionnel).
* Construction lazy + cache instance unique.
*/
public synchronized HttpClient getMtlsHttpClient() throws PaymentException {
if (mtlsClient != null) {
return mtlsClient;
}
try {
SSLContext sslContext = buildSSLContext();
mtlsClient = HttpClient.newBuilder()
.sslContext(sslContext)
.connectTimeout(Duration.ofSeconds(15))
.version(HttpClient.Version.HTTP_2)
.build();
return mtlsClient;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Impossible d'initialiser le client mTLS PI-SPI : " + e.getMessage(), 503, e);
}
}
/**
* Construit le {@link SSLContext} avec keystore client (PKCS12) + truststore optionnel.
* Si truststore absent, utilise le truststore Java par défaut (cacerts JDK).
*/
private SSLContext buildSSLContext() throws Exception {
if (keystorePathOpt.isEmpty() || keystorePasswordOpt.isEmpty()) {
throw new IllegalStateException(
"Keystore PI-SPI non configuré (pispi.api.tls.keystore-path / -password)");
}
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(keystorePathOpt.get())) {
keyStore.load(fis, keystorePasswordOpt.get().toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, keystorePasswordOpt.get().toCharArray());
TrustManagerFactory tmf = null;
if (truststorePathOpt.isPresent() && truststorePasswordOpt.isPresent()) {
KeyStore trustStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(truststorePathOpt.get())) {
trustStore.load(fis, truststorePasswordOpt.get().toCharArray());
}
tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trustStore);
}
SSLContext sslContext = SSLContext.getInstance("TLSv1.3");
sslContext.init(kmf.getKeyManagers(),
tmf != null ? tmf.getTrustManagers() : null,
null);
return sslContext;
}
public synchronized String getAccessToken() throws PaymentException {
if (cachedToken != null && Instant.now().isBefore(cacheExpiry)) {
@@ -55,11 +190,15 @@ public class PispiAuth {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/oauth2/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("X-API-Key", apiKey)
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Le endpoint OAuth2 utilise déjà mTLS — utiliser le client mTLS si configuré,
// sinon le client par défaut (mode dégradé / dev sans certif)
HttpClient client = isConfigured() ? getMtlsHttpClient() : HttpClient.newHttpClient();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new PaymentException("PISPI",
@@ -72,7 +211,8 @@ public class PispiAuth {
int expiresIn = json.getInt("expires_in", 3600);
cacheExpiry = Instant.now().plusSeconds(expiresIn - 60);
log.debug("Token PI-SPI obtenu, expire dans {}s", expiresIn - 60);
log.debug("Token PI-SPI obtenu (expire dans {}s, mTLS={})",
expiresIn - 60, isConfigured());
return cachedToken;
} catch (PaymentException e) {
throw e;

View File

@@ -3,25 +3,62 @@ package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import dev.lions.unionflow.server.payment.pispi.dto.PispiAlias;
import dev.lions.unionflow.server.payment.pispi.dto.PispiRtpRequest;
import dev.lions.unionflow.server.payment.pispi.dto.PispiRtpResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.json.JsonReader;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.StringReader;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
/**
* Client Business API PI-SPI (Plateforme Interopérable Système Paiement Instantané UEMOA).
*
* <p>Endpoints couverts :
*
* <ul>
* <li><strong>POST /transactions/initiate</strong> — initiation paiement pacs.008
* <li><strong>GET /transactions/{id}</strong> — statut transaction pacs.002
* <li><strong>POST /rtp/request</strong> — Request To Pay (pain.013) — appel cotisation
* <li><strong>GET /rtp/{id}</strong> — statut RTP (pain.014)
* <li><strong>POST /aliases</strong> — créer un alias téléphone/email → compte
* <li><strong>GET /aliases/{value}</strong> — résoudre un alias
* <li><strong>DELETE /aliases/{id}</strong> — révoquer un alias
* </ul>
*
* <p>Toutes les requêtes utilisent l'auth 3-facteurs ({@link PispiAuth}) :
*
* <ol>
* <li>Bearer token OAuth2 (header {@code Authorization})
* <li>mTLS avec certif client (configuré sur le {@link HttpClient})
* <li>API Key (header {@code X-API-Key})
* </ol>
*
* @since 2026-04-25 — RTP + alias + auth 3-facteurs ajoutés
*/
@Slf4j
@ApplicationScoped
public class PispiClient {
@Inject
PispiAuth pispiAuth;
@Inject PispiAuth pispiAuth;
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
@ConfigProperty(name = "pispi.api.base-url",
defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
@ConfigProperty(name = "pispi.institution.code")
@@ -34,6 +71,10 @@ public class PispiClient {
institutionCode = institutionCodeOpt.orElse("");
}
// ================================================================
// Paiements ISO 20022 (pacs.008 / pacs.002)
// ================================================================
public Pacs002Response initiatePayment(Pacs008Request request) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
@@ -41,56 +82,240 @@ public class PispiClient {
log.debug("PI-SPI initiatePayment endToEndId={}", request.getEndToEndId());
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/transactions/initiate"))
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/transactions/initiate"), token)
.header("Content-Type", "application/xml")
.header("Authorization", "Bearer " + token)
.header("X-Institution-Code", institutionCode)
.POST(HttpRequest.BodyPublishers.ofString(xmlBody))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status >= 400) {
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
}
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "initiatePayment");
return Pacs002Response.fromXml(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de l'initiation du paiement PI-SPI : " + e.getMessage(), 503, e);
throw new PaymentException("PISPI",
"Erreur lors de l'initiation du paiement PI-SPI : " + e.getMessage(), 503, e);
}
}
public Pacs002Response getStatus(String transactionId) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
log.debug("PI-SPI getStatus transactionId={}", transactionId);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/transactions/" + transactionId))
.header("Authorization", "Bearer " + token)
.header("X-Institution-Code", institutionCode)
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/transactions/" + transactionId), token)
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status >= 400) {
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
}
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "getStatus");
return Pacs002Response.fromXml(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de la récupération du statut PI-SPI : " + e.getMessage(), 503, e);
throw new PaymentException("PISPI",
"Erreur lors de la récupération du statut PI-SPI : " + e.getMessage(), 503, e);
}
}
// ================================================================
// Request To Pay (RTP) — appels de cotisation
// ================================================================
/**
* Initie un Request To Pay (pain.013) — la SFD demande un paiement au débiteur. Cas d'usage
* principal : appel de cotisation envoyé en push vers le membre.
*/
public PispiRtpResponse initiateRtp(PispiRtpRequest request) throws PaymentException {
request.validate();
try {
String token = pispiAuth.getAccessToken();
DateTimeFormatter iso = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
String body = Json.createObjectBuilder()
.add("rtpId", request.rtpId())
.add("creditorInstitutionCode", nullSafe(request.creditorInstitutionCode()))
.add("creditorAccountNumber", nullSafe(request.creditorAccountNumber()))
.add("creditorName", nullSafe(request.creditorName()))
.add("debtorAlias", request.debtorAlias())
.add("amount", request.amount())
.add("currency", request.currency())
.add("purpose", nullSafe(request.purpose()))
.add("description", nullSafe(request.description()))
.add("requestedExecutionDate",
request.requestedExecutionDate() != null
? request.requestedExecutionDate().format(iso) : "")
.add("expiryDate",
request.expiryDate() != null ? request.expiryDate().format(iso) : "")
.build()
.toString();
log.debug("PI-SPI initiateRtp rtpId={} amount={}", request.rtpId(), request.amount());
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/rtp/request"), token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "initiateRtp");
return parseRtpResponse(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de l'initiation RTP PI-SPI : " + e.getMessage(), 503, e);
}
}
public PispiRtpResponse getRtpStatus(String rtpId) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/rtp/" + rtpId), token)
.GET()
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "getRtpStatus");
return parseRtpResponse(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de la récupération du statut RTP PI-SPI : " + e.getMessage(), 503, e);
}
}
// ================================================================
// Alias (téléphone/email → compte)
// ================================================================
/** Résout un alias (ex: "+22507XXXXXXXX@unionflow") en informations de compte SFD. */
public Optional<PispiAlias> resolveAlias(String aliasValue) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
String encoded = URLEncoder.encode(aliasValue, StandardCharsets.UTF_8);
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/aliases/" + encoded), token)
.GET()
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 404) {
return Optional.empty();
}
checkStatus(response, "resolveAlias");
return Optional.of(parseAlias(response.body()));
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de la résolution d'alias PI-SPI : " + e.getMessage(), 503, e);
}
}
/** Enregistre un nouvel alias (ex: créer "+225XXX@unionflow" à l'inscription d'un membre). */
public PispiAlias createAlias(PispiAlias alias) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
String body = Json.createObjectBuilder()
.add("aliasType", alias.aliasType())
.add("aliasValue", alias.aliasValue())
.add("institutionCode", nullSafe(alias.institutionCode()))
.add("accountNumber", nullSafe(alias.accountNumber()))
.add("accountHolderName", nullSafe(alias.accountHolderName()))
.build()
.toString();
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/aliases"), token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
checkStatus(response, "createAlias");
return parseAlias(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de la création d'alias PI-SPI : " + e.getMessage(), 503, e);
}
}
/** Révoque un alias (ex: à la radiation d'un membre). */
public void revokeAlias(String aliasId) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/aliases/" + aliasId), token)
.DELETE()
.build();
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 204) {
checkStatus(response, "revokeAlias");
}
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI",
"Erreur lors de la révocation d'alias PI-SPI : " + e.getMessage(), 503, e);
}
}
// ================================================================
// Helpers
// ================================================================
/** Construit le builder de requête HTTP avec auth 3-facteurs (Bearer + API Key + Institution). */
private HttpRequest.Builder baseRequestBuilder(URI uri, String bearerToken) {
return HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + bearerToken)
.header("X-API-Key", pispiAuth.getApiKey())
.header("X-Institution-Code", institutionCode)
.header("Accept", "application/json, application/xml");
}
/** Retourne le client HTTP à utiliser : mTLS si configuré, sinon par défaut (mode dev). */
private HttpClient httpClient() throws PaymentException {
return pispiAuth.isConfigured() ? pispiAuth.getMtlsHttpClient() : HttpClient.newHttpClient();
}
private void checkStatus(HttpResponse<String> response, String operation) throws PaymentException {
int status = response.statusCode();
if (status >= 400) {
throw new PaymentException("PISPI",
"PI-SPI " + operation + " HTTP " + status + " : " + response.body(), status);
}
}
private PispiAlias parseAlias(String json) {
try (JsonReader reader = Json.createReader(new StringReader(json))) {
JsonObject obj = reader.readObject();
return new PispiAlias(
obj.getString("aliasId", null),
obj.getString("aliasType", null),
obj.getString("aliasValue", null),
obj.getString("institutionCode", null),
obj.getString("accountNumber", null),
obj.getString("accountHolderName", null),
obj.getString("status", null));
}
}
private PispiRtpResponse parseRtpResponse(String json) {
try (JsonReader reader = Json.createReader(new StringReader(json))) {
JsonObject obj = reader.readObject();
String responseAt = obj.getString("responseAt", null);
return new PispiRtpResponse(
obj.getString("rtpId", null),
obj.getString("status", null),
obj.getString("reasonCode", null),
obj.getString("reasonDescription", null),
responseAt != null ? LocalDateTime.parse(responseAt) : null,
obj.getString("settledTransactionId", null));
}
}
private static String nullSafe(String s) {
return s == null ? "" : s;
}
}

View File

@@ -0,0 +1,32 @@
package dev.lions.unionflow.server.payment.pispi.dto;
/**
* Alias PI-SPI mappant un identifiant lisible (téléphone, email) vers un compte SFD.
*
* <p>Permet aux membres de payer leur cotisation via une adresse simple type {@code
* +22507XXXXXXXX@unionflow} ou {@code cotisation-{orgSlug}@unionflow} sans avoir à connaître les
* détails techniques du compte bénéficiaire.
*
* <p>Référence : {@code https://developer.pispi.bceao.int/guides/alias-gerer}.
*
* @since 2026-04-25
*/
public record PispiAlias(
String aliasId,
String aliasType, // PHONE_NUMBER, EMAIL, NATIONAL_ID, CUSTOM
String aliasValue, // ex: "+22507123456" ou "cotisation-mutuelle-x@unionflow"
String institutionCode, // code BIC/IBAN-like de la SFD bénéficiaire
String accountNumber, // numéro de compte SFD
String accountHolderName,
String status // ACTIVE, PENDING, REVOKED
) {
/** Types d'alias supportés par PI-SPI. */
public static final class Types {
public static final String PHONE_NUMBER = "PHONE_NUMBER";
public static final String EMAIL = "EMAIL";
public static final String NATIONAL_ID = "NATIONAL_ID";
public static final String CUSTOM = "CUSTOM";
private Types() {}
}
}

View File

@@ -0,0 +1,61 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* Request To Pay (RTP) — message ISO 20022 {@code pain.013.001} mappé vers la Business API
* PI-SPI.
*
* <p>Permet à une institution (SFD UnionFlow) d'<strong>initier une demande de paiement</strong>
* vers un membre, plutôt que d'attendre que le membre pousse le paiement. Cas d'usage parfait
* pour les <strong>appels de cotisation</strong> :
*
* <ol>
* <li>La SFD émet un RTP avec le montant et l'échéance ;
* <li>Le membre reçoit la notification dans son app Mobile Money / banque ;
* <li>Il valide ou refuse en un clic ;
* <li>Si validé → flux pacs.008 standard, mais initié par le débiteur sans saisie manuelle.
* </ol>
*
* <p>La réponse est un message {@code pain.014.001} indiquant le statut (ACCEPTED / REFUSED /
* EXPIRED) — modélisé par {@link PispiRtpResponse}.
*
* <p>Référence : {@code https://developer.pispi.bceao.int/guides/rtp-overview}.
*
* @since 2026-04-25
*/
public record PispiRtpRequest(
String rtpId, // identifiant unique de la demande RTP
String creditorInstitutionCode, // SFD UnionFlow (créancier)
String creditorAccountNumber,
String creditorName,
String debtorAlias, // tel/email du débiteur (résolu via l'API alias)
BigDecimal amount, // montant FCFA
String currency, // toujours "XOF" en UEMOA
String purpose, // ex: "COTISATION_OCT_2026"
String description, // ex: "Cotisation mensuelle octobre 2026"
LocalDateTime requestedExecutionDate,
LocalDateTime expiryDate // au-delà : RTP expiré, débiteur ne peut plus accepter
) {
/** Validation minimale avant envoi. */
public void validate() {
if (rtpId == null || rtpId.isBlank()) {
throw new IllegalArgumentException("RTP id manquant");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Montant RTP doit être positif");
}
if (debtorAlias == null || debtorAlias.isBlank()) {
throw new IllegalArgumentException("Alias débiteur manquant");
}
if (currency == null || !"XOF".equals(currency)) {
throw new IllegalArgumentException("Seul XOF est supporté en UEMOA");
}
if (expiryDate != null && requestedExecutionDate != null
&& expiryDate.isBefore(requestedExecutionDate)) {
throw new IllegalArgumentException("Date expiration avant date exécution demandée");
}
}
}

View File

@@ -0,0 +1,35 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import java.time.LocalDateTime;
/**
* Réponse à un Request To Pay (RTP) — message ISO 20022 {@code pain.014.001} mappé vers la
* Business API PI-SPI.
*
* @since 2026-04-25
*/
public record PispiRtpResponse(
String rtpId,
String status, // ACCEPTED, REFUSED, EXPIRED, PENDING
String reasonCode, // code BCEAO si REFUSED (DUPL, FOCR, FRAD, RR01-RR06, etc.)
String reasonDescription,
LocalDateTime responseAt,
String settledTransactionId // si ACCEPTED → ID de la transaction pacs.008 générée
) {
public boolean isAccepted() {
return "ACCEPTED".equals(status);
}
public boolean isRefused() {
return "REFUSED".equals(status);
}
public boolean isPending() {
return "PENDING".equals(status);
}
public boolean isExpired() {
return "EXPIRED".equals(status);
}
}