feat(p0-2026-04-25): PI-SPI auth 3-facteurs + RTP + alias
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m32s
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:
@@ -7,6 +7,10 @@ import jakarta.json.JsonObject;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
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.io.StringReader;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
@@ -14,9 +18,44 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Optional;
|
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
|
@Slf4j
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class PispiAuth {
|
public class PispiAuth {
|
||||||
@@ -27,20 +66,116 @@ public class PispiAuth {
|
|||||||
@ConfigProperty(name = "pispi.api.client-secret")
|
@ConfigProperty(name = "pispi.api.client-secret")
|
||||||
Optional<String> clientSecretOpt;
|
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 clientId;
|
||||||
String clientSecret;
|
String clientSecret;
|
||||||
|
String apiKey;
|
||||||
|
|
||||||
|
private HttpClient mtlsClient;
|
||||||
|
private String cachedToken;
|
||||||
|
private Instant cacheExpiry;
|
||||||
|
|
||||||
@jakarta.annotation.PostConstruct
|
@jakarta.annotation.PostConstruct
|
||||||
void init() {
|
void init() {
|
||||||
clientId = clientIdOpt.orElse("");
|
clientId = clientIdOpt.orElse("");
|
||||||
clientSecret = clientSecretOpt.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;
|
/** Header {@code X-API-Key} à ajouter sur chaque requête API Business. */
|
||||||
private Instant cacheExpiry;
|
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 {
|
public synchronized String getAccessToken() throws PaymentException {
|
||||||
if (cachedToken != null && Instant.now().isBefore(cacheExpiry)) {
|
if (cachedToken != null && Instant.now().isBefore(cacheExpiry)) {
|
||||||
@@ -55,11 +190,15 @@ public class PispiAuth {
|
|||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(baseUrl + "/oauth2/token"))
|
.uri(URI.create(baseUrl + "/oauth2/token"))
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.header("X-API-Key", apiKey)
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = HttpClient.newHttpClient()
|
// Le endpoint OAuth2 utilise déjà mTLS — utiliser le client mTLS si configuré,
|
||||||
.send(request, HttpResponse.BodyHandlers.ofString());
|
// 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) {
|
if (response.statusCode() >= 400) {
|
||||||
throw new PaymentException("PISPI",
|
throw new PaymentException("PISPI",
|
||||||
@@ -72,7 +211,8 @@ public class PispiAuth {
|
|||||||
int expiresIn = json.getInt("expires_in", 3600);
|
int expiresIn = json.getInt("expires_in", 3600);
|
||||||
cacheExpiry = Instant.now().plusSeconds(expiresIn - 60);
|
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;
|
return cachedToken;
|
||||||
} catch (PaymentException e) {
|
} catch (PaymentException e) {
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -3,25 +3,62 @@ package dev.lions.unionflow.server.payment.pispi;
|
|||||||
import dev.lions.unionflow.server.api.payment.PaymentException;
|
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.Pacs002Response;
|
||||||
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
|
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.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.json.Json;
|
||||||
|
import jakarta.json.JsonObject;
|
||||||
|
import jakarta.json.JsonReader;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
||||||
|
import java.io.StringReader;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
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;
|
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
|
@Slf4j
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class PispiClient {
|
public class PispiClient {
|
||||||
|
|
||||||
@Inject
|
@Inject PispiAuth pispiAuth;
|
||||||
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;
|
String baseUrl;
|
||||||
|
|
||||||
@ConfigProperty(name = "pispi.institution.code")
|
@ConfigProperty(name = "pispi.institution.code")
|
||||||
@@ -34,6 +71,10 @@ public class PispiClient {
|
|||||||
institutionCode = institutionCodeOpt.orElse("");
|
institutionCode = institutionCodeOpt.orElse("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Paiements ISO 20022 (pacs.008 / pacs.002)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
public Pacs002Response initiatePayment(Pacs008Request request) throws PaymentException {
|
public Pacs002Response initiatePayment(Pacs008Request request) throws PaymentException {
|
||||||
try {
|
try {
|
||||||
String token = pispiAuth.getAccessToken();
|
String token = pispiAuth.getAccessToken();
|
||||||
@@ -41,56 +82,240 @@ public class PispiClient {
|
|||||||
|
|
||||||
log.debug("PI-SPI initiatePayment endToEndId={}", request.getEndToEndId());
|
log.debug("PI-SPI initiatePayment endToEndId={}", request.getEndToEndId());
|
||||||
|
|
||||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/transactions/initiate"), token)
|
||||||
.uri(URI.create(baseUrl + "/transactions/initiate"))
|
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Authorization", "Bearer " + token)
|
|
||||||
.header("X-Institution-Code", institutionCode)
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(xmlBody))
|
.POST(HttpRequest.BodyPublishers.ofString(xmlBody))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = HttpClient.newHttpClient()
|
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
||||||
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
checkStatus(response, "initiatePayment");
|
||||||
|
|
||||||
int status = response.statusCode();
|
|
||||||
if (status >= 400) {
|
|
||||||
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Pacs002Response.fromXml(response.body());
|
return Pacs002Response.fromXml(response.body());
|
||||||
} catch (PaymentException e) {
|
} catch (PaymentException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception 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 {
|
public Pacs002Response getStatus(String transactionId) throws PaymentException {
|
||||||
try {
|
try {
|
||||||
String token = pispiAuth.getAccessToken();
|
String token = pispiAuth.getAccessToken();
|
||||||
|
|
||||||
log.debug("PI-SPI getStatus transactionId={}", transactionId);
|
log.debug("PI-SPI getStatus transactionId={}", transactionId);
|
||||||
|
|
||||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
HttpRequest httpRequest = baseRequestBuilder(URI.create(baseUrl + "/transactions/" + transactionId), token)
|
||||||
.uri(URI.create(baseUrl + "/transactions/" + transactionId))
|
|
||||||
.header("Authorization", "Bearer " + token)
|
|
||||||
.header("X-Institution-Code", institutionCode)
|
|
||||||
.GET()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = HttpClient.newHttpClient()
|
HttpResponse<String> response = httpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
||||||
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
checkStatus(response, "getStatus");
|
||||||
|
|
||||||
int status = response.statusCode();
|
|
||||||
if (status >= 400) {
|
|
||||||
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Pacs002Response.fromXml(response.body());
|
return Pacs002Response.fromXml(response.body());
|
||||||
} catch (PaymentException e) {
|
} catch (PaymentException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user