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 : + * + *

+ * + *

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 : + * + *

+ * + *

Toutes les requêtes utilisent l'auth 3-facteurs ({@link PispiAuth}) : + * + *

    + *
  1. Bearer token OAuth2 (header {@code Authorization}) + *
  2. mTLS avec certif client (configuré sur le {@link HttpClient}) + *
  3. 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 : + * + *

    + *
  1. La SFD émet un RTP avec le montant et l'échéance ; + *
  2. Le membre reçoit la notification dans son app Mobile Money / banque ; + *
  3. Il valide ou refuse en un clic ; + *
  4. 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); + } +}