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 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 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 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 checkStatus(String providerReference) { return lifecycle.checkStatus(provider(), providerReference, this::queryProvider); } private Mono 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 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; } } }