RBAC:
- HealthResource: @PermitAll
- RoleResource: @RolesAllowed ADMIN/SUPER_ADMIN/ADMIN_ORGANISATION class-level
- PropositionAideResource: @RolesAllowed MEMBRE/USER class-level
- AuthCallbackResource: @PermitAll
- EvenementResource: @PermitAll /publics et /test, count restreint
- BackupResource/LogsMonitoringResource/SystemResource: MODERATOR → MODERATEUR
- AnalyticsResource: MANAGER/MEMBER → ADMIN_ORGANISATION/MEMBRE
- RoleConstant.java: constantes de rôles centralisées
Cycle de vie membres:
- MemberLifecycleService: ajouterMembre()/retirerMembre() sur activation/radiation/archivage
- MembreResource: endpoint GET /numero/{numeroMembre}
- MembreService: méthode trouverParNumeroMembre()
Changement mot de passe:
- CompteAdherentResource: endpoint POST /auth/change-password (mobile)
- MembreKeycloakSyncService: changerMotDePasseDirectKeycloak() via API Admin Keycloak directe
- Fallback automatique si lions-user-manager indisponible
Workflow:
- Flyway V17-V23: rôles, types org, formules Option C, lifecycle columns, bareme cotisation
- Nouvelles classes: MemberLifecycleService, OrganisationModuleService, scheduler
- Security: OrganisationContextFilter, OrganisationContextHolder, ModuleAccessFilter
263 lines
12 KiB
Java
263 lines
12 KiB
Java
package dev.lions.unionflow.server.service;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import jakarta.enterprise.context.ApplicationScoped;
|
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
|
import org.jboss.logging.Logger;
|
|
|
|
import javax.crypto.Mac;
|
|
import javax.crypto.spec.SecretKeySpec;
|
|
import java.net.URI;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.security.InvalidKeyException;
|
|
import java.security.NoSuchAlgorithmException;
|
|
import java.time.Duration;
|
|
import java.util.HexFormat;
|
|
import java.util.UUID;
|
|
|
|
/**
|
|
* Service d'appel à l'API Wave Checkout (https://docs.wave.com/checkout).
|
|
* Conforme à la spec : POST /v1/checkout/sessions, Wave-Signature si secret configuré.
|
|
*/
|
|
@ApplicationScoped
|
|
public class WaveCheckoutService {
|
|
|
|
private static final Logger LOG = Logger.getLogger(WaveCheckoutService.class);
|
|
|
|
@ConfigProperty(name = "wave.api.key", defaultValue = "")
|
|
String apiKey;
|
|
|
|
@ConfigProperty(name = "wave.api.base.url", defaultValue = "https://api.wave.com/v1")
|
|
String baseUrl;
|
|
|
|
@ConfigProperty(name = "wave.api.secret", defaultValue = "")
|
|
String signingSecret;
|
|
|
|
@ConfigProperty(name = "wave.redirect.base.url", defaultValue = "http://localhost:8080")
|
|
String redirectBaseUrl;
|
|
|
|
@ConfigProperty(name = "wave.mock.enabled", defaultValue = "false")
|
|
boolean mockEnabled;
|
|
|
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
|
/**
|
|
* Crée une session Checkout Wave (spec : POST /v1/checkout/sessions).
|
|
*
|
|
* @param amount Montant (string, pas de décimales pour XOF)
|
|
* @param currency Code ISO 4217 (ex: XOF)
|
|
* @param successUrl URL https de redirection après succès
|
|
* @param errorUrl URL https de redirection en cas d'erreur
|
|
* @param clientRef Référence client optionnelle (max 255 caractères)
|
|
* @param restrictMobile Numéro E.164 optionnel pour restreindre le payeur
|
|
* @return id de la session (cos-xxx) et wave_launch_url
|
|
*/
|
|
public WaveCheckoutSessionResponse createSession(
|
|
String amount,
|
|
String currency,
|
|
String successUrl,
|
|
String errorUrl,
|
|
String clientRef,
|
|
String restrictMobile) throws WaveCheckoutException {
|
|
|
|
boolean useMock = mockEnabled || apiKey == null || apiKey.trim().isBlank();
|
|
if (useMock) {
|
|
LOG.warn("Wave Checkout en mode MOCK (pas d'appel API Wave)");
|
|
return createMockSession(successUrl, clientRef);
|
|
}
|
|
|
|
String base = (baseUrl == null || baseUrl.endsWith("/")) ? baseUrl.replaceAll("/+$", "") : baseUrl;
|
|
if (!base.endsWith("/v1")) base = base + "/v1";
|
|
String url = base + "/checkout/sessions";
|
|
String body = buildRequestBody(amount, currency, successUrl, errorUrl, clientRef, restrictMobile);
|
|
|
|
try {
|
|
long timestamp = System.currentTimeMillis() / 1000;
|
|
String waveSignature = null;
|
|
if (signingSecret != null && !signingSecret.trim().isBlank()) {
|
|
waveSignature = computeWaveSignature(timestamp, body);
|
|
}
|
|
|
|
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
|
|
.uri(URI.create(url))
|
|
.header("Authorization", "Bearer " + apiKey)
|
|
.header("Content-Type", "application/json")
|
|
.timeout(Duration.ofSeconds(30))
|
|
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
|
|
|
|
if (waveSignature != null) {
|
|
requestBuilder.header("Wave-Signature", "t=" + timestamp + ",v1=" + waveSignature);
|
|
}
|
|
|
|
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder().build();
|
|
java.net.http.HttpResponse<String> response = client.send(
|
|
requestBuilder.build(),
|
|
java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
|
|
|
if (response.statusCode() >= 400) {
|
|
LOG.errorf("Wave Checkout API error: %d %s", response.statusCode(), response.body());
|
|
throw new WaveCheckoutException("Wave API: " + response.statusCode() + " " + response.body());
|
|
}
|
|
|
|
JsonNode root = objectMapper.readTree(response.body());
|
|
String id = root.has("id") ? root.get("id").asText() : null;
|
|
String waveLaunchUrl = root.has("wave_launch_url") ? root.get("wave_launch_url").asText() : null;
|
|
if (id == null || waveLaunchUrl == null) {
|
|
throw new WaveCheckoutException("Réponse Wave invalide (id ou wave_launch_url manquant)");
|
|
}
|
|
return new WaveCheckoutSessionResponse(id, waveLaunchUrl);
|
|
} catch (WaveCheckoutException e) {
|
|
throw e;
|
|
} catch (Exception e) {
|
|
LOG.error(e.getMessage(), e);
|
|
throw new WaveCheckoutException("Erreur appel Wave Checkout: " + e.getMessage(), e);
|
|
}
|
|
}
|
|
|
|
private String buildRequestBody(String amount, String currency, String successUrl, String errorUrl,
|
|
String clientRef, String restrictMobile) {
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.append("{\"amount\":\"").append(escapeJson(amount)).append("\"");
|
|
sb.append(",\"currency\":\"").append(escapeJson(currency != null ? currency : "XOF")).append("\"");
|
|
sb.append(",\"success_url\":\"").append(escapeJson(successUrl)).append("\"");
|
|
sb.append(",\"error_url\":\"").append(escapeJson(errorUrl)).append("\"");
|
|
if (clientRef != null && !clientRef.isBlank()) {
|
|
String ref = clientRef.length() > 255 ? clientRef.substring(0, 255) : clientRef;
|
|
sb.append(",\"client_reference\":\"").append(escapeJson(ref)).append("\"");
|
|
}
|
|
if (restrictMobile != null && !restrictMobile.isBlank()) {
|
|
sb.append(",\"restrict_payer_mobile\":\"").append(escapeJson(restrictMobile)).append("\"");
|
|
}
|
|
sb.append("}");
|
|
return sb.toString();
|
|
}
|
|
|
|
private static String escapeJson(String s) {
|
|
if (s == null) return "";
|
|
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
|
|
}
|
|
|
|
/**
|
|
* Spec Wave : payload = timestamp + body (raw string), HMAC-SHA256 avec signing secret.
|
|
*/
|
|
private String computeWaveSignature(long timestamp, String body) throws NoSuchAlgorithmException, InvalidKeyException {
|
|
String payload = timestamp + body;
|
|
Mac mac = Mac.getInstance("HmacSHA256");
|
|
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
|
return HexFormat.of().formatHex(hash);
|
|
}
|
|
|
|
/**
|
|
* Interroge l'état d'une session Wave Checkout (spec : GET /v1/checkout/sessions/:id).
|
|
* Utilisé par le polling web pour détecter automatiquement la complétion du paiement.
|
|
*
|
|
* @param sessionId ID de session Wave (cos-xxx)
|
|
* @return statut de la session (checkout_status, payment_status, transaction_id)
|
|
*/
|
|
public WaveSessionStatusResponse getSession(String sessionId) throws WaveCheckoutException {
|
|
boolean useMock = mockEnabled || apiKey == null || apiKey.trim().isBlank();
|
|
if (useMock) {
|
|
// En mock, on ne peut pas vraiment vérifier — retourner EN_COURS (polling s'arrête via /web-success)
|
|
LOG.warnf("Wave getSession en mode MOCK — session %s", sessionId);
|
|
return new WaveSessionStatusResponse(sessionId, "open", "processing", null);
|
|
}
|
|
|
|
String base = (baseUrl == null || baseUrl.endsWith("/")) ? baseUrl.replaceAll("/+$", "") : baseUrl;
|
|
if (!base.endsWith("/v1")) base = base + "/v1";
|
|
String url = base + "/checkout/sessions/" + sessionId;
|
|
|
|
try {
|
|
long timestamp = System.currentTimeMillis() / 1000;
|
|
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
|
|
.uri(URI.create(url))
|
|
.header("Authorization", "Bearer " + apiKey)
|
|
.header("Content-Type", "application/json")
|
|
.timeout(Duration.ofSeconds(15))
|
|
.GET();
|
|
|
|
if (signingSecret != null && !signingSecret.trim().isBlank()) {
|
|
String sig = computeWaveSignature(timestamp, "");
|
|
requestBuilder.header("Wave-Signature", "t=" + timestamp + ",v1=" + sig);
|
|
}
|
|
|
|
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder().build();
|
|
java.net.http.HttpResponse<String> response = client.send(
|
|
requestBuilder.build(),
|
|
java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
|
|
|
if (response.statusCode() >= 400) {
|
|
throw new WaveCheckoutException("Wave API: " + response.statusCode() + " " + response.body());
|
|
}
|
|
|
|
JsonNode root = objectMapper.readTree(response.body());
|
|
String checkoutStatus = root.has("checkout_status") ? root.get("checkout_status").asText() : null;
|
|
String paymentStatus = root.has("payment_status") ? root.get("payment_status").asText() : null;
|
|
String transactionId = root.has("transaction_id") ? root.get("transaction_id").asText() : null;
|
|
return new WaveSessionStatusResponse(sessionId, checkoutStatus, paymentStatus, transactionId);
|
|
|
|
} catch (WaveCheckoutException e) {
|
|
throw e;
|
|
} catch (Exception e) {
|
|
LOG.error(e.getMessage(), e);
|
|
throw new WaveCheckoutException("Erreur vérification session Wave: " + e.getMessage(), e);
|
|
}
|
|
}
|
|
|
|
public String getRedirectBaseUrl() {
|
|
return (redirectBaseUrl == null || redirectBaseUrl.trim().isBlank()) ? "http://localhost:8080" : redirectBaseUrl.trim();
|
|
}
|
|
|
|
/** Session mock pour tests : wave_launch_url = successUrl pour simuler le retour dans l'app. */
|
|
private WaveCheckoutSessionResponse createMockSession(String successUrl, String clientRef) {
|
|
String mockId = "cos-mock-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
|
return new WaveCheckoutSessionResponse(mockId, successUrl);
|
|
}
|
|
|
|
public static final class WaveSessionStatusResponse {
|
|
public final String sessionId;
|
|
/** "open" | "complete" | "expired" */
|
|
public final String checkoutStatus;
|
|
/** "processing" | "cancelled" | "succeeded" */
|
|
public final String paymentStatus;
|
|
/** ID transaction Wave (TCN...) — non-null si succeeded */
|
|
public final String transactionId;
|
|
|
|
public WaveSessionStatusResponse(String sessionId, String checkoutStatus, String paymentStatus, String transactionId) {
|
|
this.sessionId = sessionId;
|
|
this.checkoutStatus = checkoutStatus;
|
|
this.paymentStatus = paymentStatus;
|
|
this.transactionId = transactionId;
|
|
}
|
|
|
|
public boolean isSucceeded() {
|
|
return "succeeded".equals(paymentStatus) && "complete".equals(checkoutStatus);
|
|
}
|
|
|
|
public boolean isExpired() {
|
|
return "expired".equals(checkoutStatus);
|
|
}
|
|
}
|
|
|
|
public static final class WaveCheckoutSessionResponse {
|
|
public final String id;
|
|
public final String waveLaunchUrl;
|
|
|
|
public WaveCheckoutSessionResponse(String id, String waveLaunchUrl) {
|
|
this.id = id;
|
|
this.waveLaunchUrl = waveLaunchUrl;
|
|
}
|
|
}
|
|
|
|
public static class WaveCheckoutException extends RuntimeException {
|
|
public WaveCheckoutException(String message) {
|
|
super(message);
|
|
}
|
|
|
|
public WaveCheckoutException(String message, Throwable cause) {
|
|
super(message, cause);
|
|
}
|
|
}
|
|
}
|