Files
Webflux-Payments/src/main/java/com/test/payment/service/AirtelService.java
spiro-alvin-nyasimi cd186868fa Updates
2026-08-17 16:20:13 +03:00

165 lines
7.8 KiB
Java

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