Files
Webflux-Payments/src/main/java/com/test/payment/service/MpesaService.java
2026-07-02 16:45:48 +03:00

157 lines
7.0 KiB
Java

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.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.stereotype.Service;
import reactor.core.publisher.Mono;
import tools.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class MpesaService implements PaymentProviderService {
private final MpesaClient mpesaClient;
private final PaymentLifecycleService lifecycle;
private final Environment environment;
private final ObjectMapper objectMapper;
@Override
public String provider() {
return PaymentProviderType.MPESA.name();
}
@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");
MpesaUtils.MpesaAuthData auth = MpesaUtils.generateAuthData(shortCode, passkey);
StkQueryRequestDto query = new StkQueryRequestDto(
Long.valueOf(shortCode), auth.getPassword(), auth.getTimestamp(), response.getProviderReference());
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")));
}
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(),
phone,
Long.valueOf(shortCode),
phone,
callbackUrl,
request.getAccountReference(),
request.getTransactionDesc()
);
}
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);
}
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;
}
}
}