This commit is contained in:
NewUsername
2025-10-12 18:37:32 +03:00
parent bec7093889
commit 36c25a8fe6
35 changed files with 644 additions and 85 deletions

View File

@@ -1,21 +1,27 @@
package com.test.payment.service;
import com.test.payment.exceptions.MpesaPermanentException;
import com.test.payment.dto.MpesaRequestDto;
import com.test.payment.dto.MpesaTokenResponse;
import com.test.payment.exceptions.MpesaBusyException;
import com.test.payment.exceptions.MpesaTransientException;
import com.test.payment.models.*;
import com.test.payment.models.MpesaResponse;
import com.test.payment.models.PaymentRequest;
import com.test.payment.repository.MpesaRepository;
import io.github.resilience4j.circuitbreaker.*;
import io.github.resilience4j.ratelimiter.*;
import io.github.resilience4j.retry.*;
import com.test.payment.utils.MpesaUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.retry.annotation.Retry;
import java.util.UUID;
import java.util.function.Supplier;
import java.time.Duration;
import java.util.Base64;
@Service
@RequiredArgsConstructor
@@ -24,42 +30,175 @@ public class MpesaService {
private final WebClient mpesaWebClient;
private final MpesaRepository mpesaRepository;
private final MpesaTokenService tokenService;
private final RateLimiter rateLimiter;
private final Retry retry;
private final CircuitBreaker circuitBreaker;
private final Environment environment;
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
Supplier<Mono<MpesaResponse>> decoratedSupplier =
CircuitBreaker.decorateSupplier(circuitBreaker,
RateLimiter.decorateSupplier(rateLimiter,
Retry.decorateSupplier(retry, () -> callMpesa(request))
)
);
return Mono.defer(decoratedSupplier)
.flatMap(response -> {
Transaction tx = new Transaction(UUID.randomUUID().toString(),
request.getPhoneNumber(), request.getAmount(), response.getStatus());
return mpesaRepository.saveTransaction(tx).thenReturn(response);
})
.doOnError(e -> log.error("M-Pesa API failed: {}", e.getMessage()));
public Mono<String> getToken() {
return Mono.just("hVG5ybD47dHUMQ6RfWby2ZpIs1Ul");
}
private Mono<MpesaResponse> callMpesa(PaymentRequest request) {
return tokenService.getToken()
@CircuitBreaker(name = "mpesaCircuitBreaker", fallbackMethod = "mpesaFallback")
@RateLimiter(name = "mpesaLimiter")
@Retry(name = "mpesaRetry")
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
String businessShortCode = environment.getProperty("mpesa.business-short-code");
String passkey = environment.getProperty("mpesa.pass-key");
String callback = "https://mydomain.com/path";
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(
Long.valueOf(businessShortCode),
authData.getPassword(),
authData.getTimestamp(),
"CustomerPayBillOnline",
request.getAmount(),
request.getPhoneNumber(),
Long.valueOf(businessShortCode),
request.getPhoneNumber(),
callback,
request.getAccountReference(),
request.getTransactionDesc()
);
// Use Mono.defer so each subscription is independent (multi-user safe)
return Mono.defer(() ->
getToken()
.flatMap(token -> callMpesa(mpesaRequestDto))
)
// Reactive retry only on MpesaBusyException, with configurable backoff
.retryWhen(
reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(10))
.filter(ex -> ex instanceof MpesaBusyException)
.onRetryExhaustedThrow((spec, signal) -> signal.failure())
)
.doOnNext(resp -> log.info("M-Pesa STK Response: {}", resp))
.doOnError(e -> log.error("M-Pesa call failed: {}", e.getMessage()));
}
/* @CircuitBreaker(name = "mpesaCircuitBreaker", fallbackMethod = "mpesaFallback")
@RateLimiter(name = "mpesaLimiter")
@Retry(name = "mpesaRetry")
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
String businessShortCode = environment.getProperty("mpesa.business-short-code");
String passkey = environment.getProperty("mpesa.pass-key");
String callback = "https://mydomain.com/path";
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(
Long.valueOf(businessShortCode),
authData.getPassword(),
authData.getTimestamp(),
"CustomerPayBillOnline",
request.getAmount(),
request.getPhoneNumber(),
Long.valueOf(businessShortCode),
request.getPhoneNumber(),
callback,
request.getAccountReference(),
request.getTransactionDesc()
);
*//*return Mono.delay(Duration.ofSeconds(1))
.then(callMpesa(mpesaRequestDto))
.doOnNext(resp -> log.info("M-Pesa STK Response: {}", resp))
.doOnError(e -> log.error("M-Pesa API failed: {}", e.getMessage()));*//*
*//* return callMpesa(mpesaRequestDto)
.doOnNext(resp -> log.info("M-Pesa STK Response: {}", resp))
.doOnError(e -> {
if (e.toString().contains("System is busy")) {
log.warn("M-Pesa system is busy");
throe Mono.error(new MpesaBusyException("System busy"));
} else {
log.error("M-Pesa API failed: {}", e.getMessage());
}
});*//*
return getToken()
.flatMap(token -> callMpesa(mpesaRequestDto))
.retryWhen(
reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(10))
.filter(ex -> ex instanceof MpesaBusyException)
.onRetryExhaustedThrow((retryBackoffSpec, retrySignal) ->
retrySignal.failure()
)
) .doOnError(e -> log.error("M-Pesa call failed: {}", e.getMessage()));
}*/
/*@Retry(name = "mpesaRetry", fallbackMethod = "mpesaFallback")
@RateLimiter(name = "mpesaLimiter")
@CircuitBreaker(name = "mpesaCB", fallbackMethod = "mpesaFallback")
public Mono<MpesaResponse> initiatePayment(PaymentRequest request) {
String businessShortCode = environment.getProperty("mpesa.business-short-code");
String passkey = environment.getProperty("mpesa.pass-key");
String callback = "https://mydomain.com/path";
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(
Long.valueOf(businessShortCode),
authData.getPassword(),
authData.getTimestamp(),
"CustomerPayBillOnline",
request.getAmount(),
request.getPhoneNumber(),
Long.valueOf(businessShortCode),
request.getPhoneNumber(),
callback,
request.getAccountReference(),
request.getTransactionDesc()
);
return mpesaWebClient.post()
.uri("/mpesa/stkpush/v1/processrequest")
.bodyValue(mpesaRequestDto)
.retrieve()
.bodyToMono(MpesaResponse.class)
.flatMap(resp -> {
if (resp.toString().contains("System is busy")) {
return Mono.error(new MpesaBusyException("System busy"));
}
return Mono.just(resp);
});
}*/
private Mono<MpesaResponse> callMpesa(MpesaRequestDto request) {
return getToken()
.flatMap(token ->
mpesaWebClient.post()
.uri("/mpesa/stkpush/v1/processrequest")
.header("Authorization", "Bearer " + token)
.bodyValue(request)
.retrieve()
.onStatus(HttpStatusCode::isError, clientResponse ->
clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> {
log.error("M-Pesa returned {} with body: {}", clientResponse.statusCode(), errorBody);
if (errorBody.contains("System is busy")) {
return Mono.error(new MpesaBusyException("System busy"));
}
return Mono.error(new MpesaTransientException(errorBody, null));
})
)
.bodyToMono(MpesaResponse.class)
.timeout(java.time.Duration.ofSeconds(20))
.onErrorResume(ex -> {
log.error("Error calling M-Pesa: {}", ex.getMessage());
return Mono.error(new MpesaTransientException("Temporary M-Pesa issue", ex));
})
);
}
// 🧯 Fallback if circuit is open or all retries fail
private Mono<MpesaResponse> mpesaFallback(PaymentRequest request, Throwable ex) {
log.error("⚠️ Mpesa fallback triggered: {}", ex.getMessage());
return Mono.just(new MpesaResponse(
"500",
"Fallback triggered due to service unavailability",
null,
"",
null
));
}
}