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 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 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); } } }