diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java
index 24c403d..9e300bb 100644
--- a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java
+++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java
@@ -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.
+ *
+ *
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 :
+ *
+ *
+ * - OAuth2 client_credentials — clientId + clientSecret pour récupérer un
+ * Bearer token, mis en cache jusqu'à expiration ({@code expires_in - 60s}).
+ *
- mTLS (mutual TLS) — certificat client (PKCS12) présenté pendant la
+ * handshake TLS. Configuré via {@link SSLContext} sur le {@link HttpClient}.
+ *
- API Key — header {@code X-API-Key} ajouté sur chaque requête (géré par
+ * {@link PispiClient}, exposé par {@link #getApiKey()}).
+ *
+ *
+ * Configuration ({@code application.properties}) :
+ *
+ *
{@code
+ * pispi.api.base-url=https://sandbox.pispi.bceao.int/business-api/v1
+ * pispi.api.client-id=
+ * pispi.api.client-secret=
+ * pispi.api.api-key=
+ * pispi.api.tls.keystore-path=/secrets/pispi-client.p12
+ * pispi.api.tls.keystore-password=
+ * pispi.api.tls.truststore-path=/secrets/pispi-truststore.p12 # optionnel
+ * pispi.api.tls.truststore-password= # optionnel
+ * }
+ *
+ * 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 clientSecretOpt;
+ @ConfigProperty(name = "pispi.api.api-key")
+ Optional apiKeyOpt;
+
+ @ConfigProperty(name = "pispi.api.tls.keystore-path")
+ Optional keystorePathOpt;
+
+ @ConfigProperty(name = "pispi.api.tls.keystore-password")
+ Optional keystorePasswordOpt;
+
+ @ConfigProperty(name = "pispi.api.tls.truststore-path")
+ Optional truststorePathOpt;
+
+ @ConfigProperty(name = "pispi.api.tls.truststore-password")
+ Optional 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 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 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;
diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiClient.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiClient.java
index 4d91ba2..e48b8c1 100644
--- a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiClient.java
+++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiClient.java
@@ -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).
+ *
+ * Endpoints couverts :
+ *
+ *
+ * - POST /transactions/initiate — initiation paiement pacs.008
+ *
- GET /transactions/{id} — statut transaction pacs.002
+ *
- POST /rtp/request — Request To Pay (pain.013) — appel cotisation
+ *
- GET /rtp/{id} — statut RTP (pain.014)
+ *
- POST /aliases — créer un alias téléphone/email → compte
+ *
- GET /aliases/{value} — résoudre un alias
+ *
- DELETE /aliases/{id} — révoquer un alias
+ *
+ *
+ * Toutes les requêtes utilisent l'auth 3-facteurs ({@link PispiAuth}) :
+ *
+ *
+ * - Bearer token OAuth2 (header {@code Authorization})
+ *
- mTLS avec certif client (configuré sur le {@link HttpClient})
+ *
- API Key (header {@code X-API-Key})
+ *
+ *
+ * @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 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 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 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 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 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 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 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 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 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 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 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;
+ }
}
diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiAlias.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiAlias.java
new file mode 100644
index 0000000..0383148
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiAlias.java
@@ -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.
+ *
+ * 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.
+ *
+ *
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() {}
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpRequest.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpRequest.java
new file mode 100644
index 0000000..7762c2c
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpRequest.java
@@ -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.
+ *
+ *
Permet à une institution (SFD UnionFlow) d'initier une demande de paiement
+ * vers un membre, plutôt que d'attendre que le membre pousse le paiement. Cas d'usage parfait
+ * pour les appels de cotisation :
+ *
+ *
+ * - La SFD émet un RTP avec le montant et l'échéance ;
+ *
- Le membre reçoit la notification dans son app Mobile Money / banque ;
+ *
- Il valide ou refuse en un clic ;
+ *
- Si validé → flux pacs.008 standard, mais initié par le débiteur sans saisie manuelle.
+ *
+ *
+ * La réponse est un message {@code pain.014.001} indiquant le statut (ACCEPTED / REFUSED /
+ * EXPIRED) — modélisé par {@link PispiRtpResponse}.
+ *
+ *
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");
+ }
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpResponse.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpResponse.java
new file mode 100644
index 0000000..27333c9
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpResponse.java
@@ -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);
+ }
+}