Updates and payments additions
This commit is contained in:
@@ -1,204 +1,156 @@
|
||||
package com.test.payment.service;
|
||||
|
||||
|
||||
import com.test.payment.client.MpesaClient;
|
||||
import com.test.payment.dto.CallbackAckDto;
|
||||
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.MpesaResponse;
|
||||
import com.test.payment.models.PaymentRequest;
|
||||
import com.test.payment.repository.MpesaRepository;
|
||||
import com.test.payment.dto.MpesaResponse;
|
||||
import com.test.payment.dto.PaymentRequest;
|
||||
import com.test.payment.dto.PaymentResultDto;
|
||||
import com.test.payment.dto.StkCallbackPayload;
|
||||
import com.test.payment.dto.StkQueryRequestDto;
|
||||
import com.test.payment.dto.TransactionStatusDto;
|
||||
import com.test.payment.exceptions.ProviderBusyException;
|
||||
import com.test.payment.exceptions.ProviderProcessingException;
|
||||
import com.test.payment.models.PaymentProviderType;
|
||||
import com.test.payment.models.PaymentResponse;
|
||||
import com.test.payment.models.TransactionStatus;
|
||||
import com.test.payment.service.PaymentLifecycleService.CallbackData;
|
||||
import com.test.payment.service.PaymentLifecycleService.ProviderResponseData;
|
||||
import com.test.payment.service.PaymentLifecycleService.QueryOutcome;
|
||||
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 tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MpesaService {
|
||||
public class MpesaService implements PaymentProviderService {
|
||||
|
||||
private final WebClient mpesaWebClient;
|
||||
private final MpesaRepository mpesaRepository;
|
||||
private final MpesaClient mpesaClient;
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final Environment environment;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public Mono<String> getToken() {
|
||||
return Mono.just("hVG5ybD47dHUMQ6RfWby2ZpIs1Ul");
|
||||
@Override
|
||||
public String provider() {
|
||||
return PaymentProviderType.MPESA.name();
|
||||
}
|
||||
|
||||
@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");
|
||||
@Override
|
||||
public Mono<PaymentResultDto> initiatePayment(PaymentRequest request) {
|
||||
return lifecycle.saveInitiation(provider(), request)
|
||||
.flatMap(initiation -> Mono.defer(() -> mpesaClient.stkPush(buildStkRequest(request)))
|
||||
.flatMap(response -> lifecycle.persistResponse(initiation, toResponseData(response)))
|
||||
.onErrorResume(ex -> lifecycle.markFailed(initiation, ex)));
|
||||
}
|
||||
|
||||
public Mono<CallbackAckDto> handleCallback(StkCallbackPayload payload) {
|
||||
if (payload == null || payload.getBody() == null || payload.getBody().getStkCallback() == null) {
|
||||
log.warn("Received malformed STK callback payload");
|
||||
return Mono.just(CallbackAckDto.accepted("Ignored: empty callback"));
|
||||
}
|
||||
StkCallbackPayload.StkCallbackBody callback = payload.getBody().getStkCallback();
|
||||
Map<String, Object> metadata = extractMetadata(callback);
|
||||
boolean success = callback.getResultCode() != null && callback.getResultCode() == 0;
|
||||
CallbackData data = new CallbackData(
|
||||
callback.getCheckoutRequestId(),
|
||||
callback.getResultCode() == null ? null : String.valueOf(callback.getResultCode()),
|
||||
callback.getResultDesc(),
|
||||
asString(metadata.get("MpesaReceiptNumber")),
|
||||
metadata.get("Amount") != null ? new BigDecimal(metadata.get("Amount").toString()) : null,
|
||||
asString(metadata.get("PhoneNumber")),
|
||||
asString(metadata.get("TransactionDate")),
|
||||
success);
|
||||
return lifecycle.applyCallback(provider(), data, toJson(payload));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<TransactionStatusDto> checkStatus(String providerReference) {
|
||||
return lifecycle.checkStatus(provider(), providerReference, this::queryProvider);
|
||||
}
|
||||
|
||||
private Mono<QueryOutcome> queryProvider(PaymentResponse response) {
|
||||
String shortCode = environment.getProperty("mpesa.business-short-code");
|
||||
String passkey = environment.getProperty("mpesa.pass-key");
|
||||
String callback = "https://mydomain.com/path";
|
||||
MpesaUtils.MpesaAuthData auth = MpesaUtils.generateAuthData(shortCode, passkey);
|
||||
StkQueryRequestDto query = new StkQueryRequestDto(
|
||||
Long.valueOf(shortCode), auth.getPassword(), auth.getTimestamp(), response.getProviderReference());
|
||||
|
||||
MpesaUtils.MpesaAuthData authData = MpesaUtils.generateAuthData(businessShortCode, passkey);
|
||||
return mpesaClient.stkQuery(query)
|
||||
.map(result -> new QueryOutcome(
|
||||
mapQueryResult(result.getResultCode()), result.getResultCode(), result.getResultDesc(), null))
|
||||
.onErrorResume(ProviderProcessingException.class,
|
||||
e -> Mono.just(QueryOutcome.pending("Transaction is still being processed by M-Pesa")))
|
||||
.onErrorResume(ProviderBusyException.class,
|
||||
e -> Mono.just(QueryOutcome.pending("M-Pesa status query rate-limited — showing last known state")));
|
||||
}
|
||||
|
||||
MpesaRequestDto mpesaRequestDto = new MpesaRequestDto(
|
||||
Long.valueOf(businessShortCode),
|
||||
authData.getPassword(),
|
||||
authData.getTimestamp(),
|
||||
private MpesaRequestDto buildStkRequest(PaymentRequest request) {
|
||||
String shortCode = environment.getProperty("mpesa.business-short-code");
|
||||
String passkey = environment.getProperty("mpesa.pass-key");
|
||||
String callbackUrl = environment.getProperty("mpesa.callback-url");
|
||||
MpesaUtils.MpesaAuthData auth = MpesaUtils.generateAuthData(shortCode, passkey);
|
||||
Long phone = Long.valueOf(request.getPhoneNumber());
|
||||
return new MpesaRequestDto(
|
||||
Long.valueOf(shortCode),
|
||||
auth.getPassword(),
|
||||
auth.getTimestamp(),
|
||||
"CustomerPayBillOnline",
|
||||
request.getAmount(),
|
||||
request.getPhoneNumber(),
|
||||
Long.valueOf(businessShortCode),
|
||||
request.getPhoneNumber(),
|
||||
callback,
|
||||
phone,
|
||||
Long.valueOf(shortCode),
|
||||
phone,
|
||||
callbackUrl,
|
||||
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)
|
||||
);
|
||||
private ProviderResponseData toResponseData(MpesaResponse response) {
|
||||
boolean accepted = "0".equals(response.getResponseCode());
|
||||
return new ProviderResponseData(
|
||||
response.getCheckoutRequestId(),
|
||||
response.getMerchantRequestID(),
|
||||
response.getResponseCode(),
|
||||
response.getResponseDescription(),
|
||||
response.getCustomerMessage(),
|
||||
accepted);
|
||||
}
|
||||
|
||||
// 🧯 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
|
||||
));
|
||||
private TransactionStatus mapQueryResult(String resultCode) {
|
||||
if (resultCode == null) {
|
||||
return TransactionStatus.PENDING;
|
||||
}
|
||||
return "0".equals(resultCode) ? TransactionStatus.SUCCESS : TransactionStatus.FAILED;
|
||||
}
|
||||
|
||||
private Map<String, Object> extractMetadata(StkCallbackPayload.StkCallbackBody callback) {
|
||||
if (callback.getCallbackMetadata() == null || callback.getCallbackMetadata().getItem() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
return callback.getCallbackMetadata().getItem().stream()
|
||||
.filter(item -> item.getName() != null && item.getValue() != null)
|
||||
.collect(Collectors.toMap(StkCallbackPayload.Item::getName, StkCallbackPayload.Item::getValue, (a, b) -> a));
|
||||
}
|
||||
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not serialize callback payload: {}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user