package com.test.payment.service; import com.test.payment.client.AirtelClient; import com.test.payment.dto.AirtelCallbackPayload; import com.test.payment.dto.AirtelPaymentRequestDto; import com.test.payment.dto.AirtelResponseDto; import com.test.payment.dto.CallbackAckDto; import com.test.payment.dto.PaymentRequest; import com.test.payment.dto.PaymentResultDto; import com.test.payment.dto.TransactionStatusDto; import com.test.payment.exceptions.ProviderBusyException; import com.test.payment.models.Country; import com.test.payment.models.Operator; import com.test.payment.models.PaymentProviderType; import com.test.payment.models.Status; 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.service.PaymentLifecycleService.StoredResponse; 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.util.UUID; /** * Airtel Money USSD-push collections. Transaction status codes: * TIP = in progress, TS = success, TF = failed. */ @Service @RequiredArgsConstructor @Slf4j public class AirtelService implements PaymentProviderService { private final AirtelClient airtelClient; private final PaymentLifecycleService lifecycle; private final ProviderMarkets markets; private final ProviderCallAudit audit; private final StatusCatalog statuses; private final PaymentLimitService limits; private final Environment environment; private final ObjectMapper objectMapper; @Override public PaymentProviderType provider() { return markets.resolve(Operator.AIRTEL); } @Override public Mono initiatePayment(PaymentRequest request) { return limits.enforce(provider(), request) .then(lifecycle.saveInitiation(provider(), request)) .flatMap(initiation -> Mono.defer(() -> { String reference = "ATL" + UUID.randomUUID().toString().replace("-", ""); AirtelPaymentRequestDto payload = buildRequest(request, reference); ProviderCallAudit.Handle call = audit.begin(provider(), "USSD_PUSH", String.valueOf(initiation.getId()), "POST", baseUrl() + "/merchant/v1/payments/", payload); audit.linkReference(call, reference); return airtelClient.pay(payload) .doOnNext(response -> audit.complete(call, 200, response, null)) .doOnError(ex -> audit.complete(call, null, null, ex)) .map(response -> toResponseData(response, reference)); }) .flatMap(data -> lifecycle.persistResponse(initiation, data)) .onErrorResume(ex -> lifecycle.markFailed(initiation, ex))); } public Mono handleCallback(AirtelCallbackPayload payload) { if (payload == null || payload.getTransaction() == null) { log.warn("Received malformed Airtel callback payload"); return Mono.just(CallbackAckDto.accepted("Ignored: empty callback")); } AirtelCallbackPayload.Transaction transaction = payload.getTransaction(); boolean success = "TS".equalsIgnoreCase(transaction.getStatusCode()); CallbackData data = new CallbackData( transaction.getId(), transaction.getStatusCode(), transaction.getMessage(), transaction.getAirtelMoneyId(), null, null, null, success); String raw = toJson(payload); audit.recordCallback(provider(), new ProviderCallAudit.CallbackAudit( data.providerReference(), data.resultCode(), data.resultDesc(), data.receiptNumber(), data.amount(), data.phoneNumber(), data.transactionDate()), raw); return lifecycle.applyCallback(provider(), data, raw); } @Override public Mono checkStatus(String providerReference) { return lifecycle.checkStatus(provider(), providerReference, this::queryProvider); } private Mono queryProvider(StoredResponse response) { ProviderCallAudit.Handle call = audit.begin(provider(), "STATUS_QUERY", String.valueOf(response.initiationId()), "GET", baseUrl() + "/standard/v1/payments/" + response.providerReference(), null); return airtelClient.status(response.providerReference()) .doOnNext(result -> audit.complete(call, 200, result, null)) .doOnError(ex -> audit.complete(call, null, null, ex)) .map(result -> { AirtelResponseDto.Transaction tx = result.getData() != null ? result.getData().getTransaction() : null; String status = tx != null ? tx.getStatus() : null; String message = tx != null && tx.getMessage() != null ? tx.getMessage() : (result.getStatus() != null ? result.getStatus().getMessage() : null); return new QueryOutcome(mapStatus(status), status, message, tx != null ? tx.getAirtelMoneyId() : null); }) .onErrorResume(ProviderBusyException.class, e -> Mono.just(QueryOutcome.pending(statuses.pending(), "Airtel status query rate-limited — showing last known state"))); } private String baseUrl() { return environment.getProperty("airtel.base-url", ""); } private AirtelPaymentRequestDto buildRequest(PaymentRequest request, String reference) { Country country = markets.resolve(Operator.AIRTEL).country(); // the market's own currency unless airtel.currency deliberately overrides it String currency = environment.getProperty("airtel.currency", country.currency()); return new AirtelPaymentRequestDto( request.getAccountReference(), new AirtelPaymentRequestDto.Subscriber(country.name(), currency, request.getPhoneNumber()), new AirtelPaymentRequestDto.Transaction( String.valueOf(request.getAmount()), country.name(), currency, reference)); } private ProviderResponseData toResponseData(AirtelResponseDto response, String reference) { AirtelResponseDto.Status status = response.getStatus(); boolean accepted = status != null && Boolean.TRUE.equals(status.getSuccess()); String transactionStatus = response.getData() != null && response.getData().getTransaction() != null ? response.getData().getTransaction().getStatus() : null; return new ProviderResponseData( reference, null, status != null ? status.getCode() : null, status != null ? status.getMessage() : null, transactionStatus, accepted); } private Status mapStatus(String airtelStatus) { if ("TS".equalsIgnoreCase(airtelStatus)) { return statuses.success(); } if ("TF".equalsIgnoreCase(airtelStatus)) { return statuses.failed(); } return statuses.pending(); // TIP or unknown — keep waiting } private String toJson(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { log.warn("Could not serialize callback payload: {}", e.toString()); return null; } } }