implement zesa currency swap feature

This commit is contained in:
2026-06-01 20:34:48 +02:00
parent 92276c07d8
commit 461dc7e7e4
17 changed files with 257 additions and 48 deletions

View File

@@ -23,7 +23,8 @@ public class ProviderController {
@GetMapping
public ResponseEntity getProviders(
@And({
@Spec(path = "category", spec = Equal.class)
@Spec(path = "category", spec = Equal.class),
@Spec(path = "currency", spec = Equal.class),
}) Specification<Provider> spec, Pageable pageable) {
return ResponseEntity.ok(providerService.findAll(spec, pageable));
}
@@ -71,4 +72,10 @@ public class ProviderController {
}
return ResponseEntity.notFound().build();
}
@DeleteMapping
public ResponseEntity evictCache() {
providerService.evictProviderProductsCache();
return ResponseEntity.ok("Cache evicted");
}
}

View File

@@ -13,4 +13,6 @@ public class Currency extends BaseEntity {
private String isoCode;
private String name;
private String displayName;
private String bankRate;
private String forecastRate;
}

View File

@@ -2,8 +2,11 @@ package zw.qantra.tm.domain.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.*;
import org.hibernate.annotations.SQLRestriction;
import zw.qantra.tm.domain.enums.CurrencyType;
@Entity
@@ -19,6 +22,8 @@ public class Provider extends BaseEntity {
private String name;
private String description;
private String externalId;
@Enumerated(EnumType.STRING)
private CurrencyType currency;
private String image;
private String category;
private String accountFieldName;

View File

@@ -51,6 +51,7 @@ public class Transaction extends BaseEntity {
private String creditAccount;
@Enumerated(EnumType.STRING)
private CurrencyType creditCurrency;
private BigDecimal creditAmount;
private String creditName;
private String creditCard;
private String creditRef;

View File

@@ -4,8 +4,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.Currency;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface CurrencyRepository extends JpaRepository<Currency, UUID> {
}
Optional<Currency> findByIsoCode(String isoCode);
}

View File

@@ -2,7 +2,9 @@ package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.Currency;
import zw.qantra.tm.domain.repositories.CurrencyRepository;
import zw.qantra.tm.exceptions.ApiException;
@Service
@RequiredArgsConstructor
@@ -12,4 +14,9 @@ public class CurrencyService {
public CurrencyRepository getCurrencyRepository() {
return currencyRepository;
}
}
public Currency findCurrencyByIsoCode(String isoCode) {
return currencyRepository.findByIsoCode(isoCode)
.orElseThrow(() -> new ApiException("Currency not found for ISO code: " + isoCode));
}
}

View File

@@ -26,9 +26,9 @@ public class HotRechargeBalanceService {
private String hotApiUrl;
@Cacheable(value = "hotrechargeBalance", key = "#token + '_' + #amount.toString()")
public boolean checkBalance(String token, BigDecimal amount) {
public boolean checkBalance(String token, BigDecimal amount, String accId) {
try {
String balanceUrl = hotApiUrl + "/account/balance/4";
String balanceUrl = hotApiUrl + "/account/balance/" + accId;
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);

View File

@@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
@@ -81,7 +82,8 @@ public class ProviderService {
return products;
}
@Cacheable(value = "providerProducts", key = "#providerUid")
// todo: figure out caching for different providers
// @Cacheable(value = "providerProducts", key = "#providerUid")
public List<SBZProductDto> getProviderProducts(String providerUid) {
try {
Provider provider = providerRepository.findById(UUID.fromString(providerUid)).orElse(null);
@@ -126,6 +128,7 @@ public class ProviderService {
return products;
} catch (IllegalArgumentException e) {
log.error("Error fetching products: " + providerUid, e);
return Collections.emptyList();
}
}
@@ -159,11 +162,7 @@ public class ProviderService {
}
public Object getProviderByClientId(String clientId) {
Provider provider = providerRepository.findByClientId(clientId);
if (provider != null) {
return ((List)getProviders(Collections.singletonList(provider))).stream().findFirst().orElse(null);
}
return null;
return providerRepository.findByClientId(clientId);
}
public Object findAll(Specification<Provider> specification, Pageable pageable) {
@@ -237,4 +236,17 @@ public class ProviderService {
}
return false;
}
// Evict ALL providerProducts cache entries
@CacheEvict(value = "providerProducts", allEntries = true)
public void evictProviderProductsCache() {
// empty - annotation handles the eviction
}
// Or evict a SINGLE entry by key
@CacheEvict(value = "providerProducts", key = "#providerUid")
public void evictProviderProductCache(String providerUid) {
// only evicts cache for this specific providerUid
}
}

View File

@@ -3,6 +3,8 @@ package zw.qantra.tm.domain.services;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.UUID;
@@ -15,6 +17,7 @@ import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Currency;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.repositories.TransactionRepository;
import zw.qantra.tm.exceptions.ApiException;
@@ -28,6 +31,21 @@ public class TransactionService {
@Getter
private final TransactionRepository transactionRepository;
private final AdditionalDataService additionalDataService;
private final CurrencyService currencyService;
public BigDecimal calculateCreditAmount(Transaction transaction) {
if (transaction.getCreditCurrency() == null) {
return BigDecimal.ZERO;
}
Currency currency = currencyService.findCurrencyByIsoCode(transaction.getCreditCurrency().name());
BigDecimal bankRate = new BigDecimal(currency.getBankRate());
BigDecimal creditAmount = transaction.getAmount().multiply(bankRate).setScale(4, RoundingMode.HALF_EVEN);
transaction.setCreditAmount(creditAmount);
return creditAmount;
}
public void reassignTransactions(String oldUid, String newUid){
List<Transaction> transactions = transactionRepository.findAllByUserId(oldUid);

View File

@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
@@ -41,7 +42,13 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter
// do this if we not simulating success, if simulating success, we skip balance check and just return success
if(!settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
String token = hotRechargeTokenService.getToken();
if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount())) {
// Account Type Id Account Name
// 1 ZWG
// 2 ZWG Non-VAT (Utilitiies)
// 3 USD
// 4 USD Non-VAT (Utilitiies)
if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount(), "3")) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later.");
@@ -49,6 +56,8 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter
}
}
transaction.setCreditCurrency(CurrencyType.USD);
transaction.setCreditAmount(transaction.getAmount());
transaction.setCreditAccount(Utils.formatPhoneNumber(transaction.getCreditAccount(), "ZW"));
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");

View File

@@ -8,6 +8,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
@@ -33,6 +34,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
private final HotRechargeTokenService hotRechargeTokenService;
private final HotRechargeBalanceService hotRechargeBalanceService;
private final SettingService settingService;
private final TransactionService transactionService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
@@ -57,13 +59,6 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
return transaction;
}
if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount())) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later.");
return transaction;
}
Map<String, Object> customerData = checkCustomer(token, transaction);
if (customerData == null) {
transaction.setStatus(Status.FAILED);
@@ -72,6 +67,30 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
return transaction;
}
// Account Type Id Account Name
// 1 ZWG
// 2 ZWG Non-VAT (Utilitiies)
// 3 USD
// 4 USD Non-VAT (Utilitiies)
String accId = "4";
String currency = extractCurrency(customerData);
logger.info("Customer currency: {}", currency.isEmpty() ? "UNKNOWN" : currency);
if(currency.equals("ZWG")){
accId = "2";
transaction.setCreditCurrency(CurrencyType.ZWG);
}else {
transaction.setCreditCurrency(CurrencyType.USD);
}
transaction.setCreditAmount(transactionService.calculateCreditAmount(transaction));
if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount(), accId)) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later.");
return transaction;
}
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
@@ -110,15 +129,27 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
return additionalDataList;
}
private String extractCurrency(Map<String, Object> customerData) {
if (customerData == null) {
return "";
}
Object detailsObj = customerData.get("details");
if (!(detailsObj instanceof Map<?, ?> details)) {
return "";
}
Object currency = details.get("Currency");
if (currency == null) {
currency = details.get("currency");
}
return currency == null ? "" : currency.toString().trim();
}
private Map<String, Object> checkCustomer(String token, Transaction transaction) {
try {
Provider provider = providerService.getProviderRepository()
.findByClientId(transaction.getBillClientId());
if(provider == null){
throw new ApiException("Provider not found for billClientId: " + transaction.getBillClientId());
}
Map meta = Utils.fromJson(provider.getMeta(), Map.class);
String hotProductId = (String) meta.get("hotProductId");
String hotProductId = providerService.getProviderId(transaction.getBillClientId());
String customerUrl = hotApiUrl + "/query/customer/" + hotProductId + "/" + transaction.getCreditAccount();
HttpHeaders headers = new HttpHeaders();

View File

@@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.ResourceAccessException;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
@@ -36,6 +37,8 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
private static final String ZESA_ZWG_CLIENT = "zesa_prepaid_zwg";
@Override
@Transactional
public Object process(Transaction transaction) throws Exception {
@@ -70,12 +73,8 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
return transaction;
}
if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount())) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later.");
return transaction;
}
// todo: a balance check might be necessary here. Removing
// for now so not to spam hotrecharge with requests
LinkedHashMap<String, Object> rechargeResponse = performRecharge(token, transaction);
if (rechargeResponse == null) {
@@ -132,19 +131,30 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
headers.setBearerAuth(token);
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
String providerId = providerService.getProviderId(transaction.getBillClientId());
Map<String, Object> request = new LinkedHashMap<>();
request.put("AgentReference", UUID.randomUUID().toString());
request.put("ProductId", providerService.getProviderId(transaction.getBillClientId()));
request.put("ProductId", providerId);
request.put("Target", transaction.getCreditAccount());
request.put("Amount", transaction.getAmount());
request.put("Amount", transaction.getCreditAmount());
// for zesa skip this
if(!providerService.getProviderId(transaction.getBillClientId()).equals("41")) {
// 41 = zesa
if(providerId.equals("41")) {
request.put("CustomerSMS", "A ZETDC token was purchased for " +
"%ACOUNTNAME% (%METERNUMBER%) that resulted in %KWH% units.");
}
else {
request.put("CustomerSMS", "%COMPANYNAME% topped up your account with $%AMOUNT%.");
}
// do this for zesa only
if(providerService.getProviderId(transaction.getBillClientId()).equals("41")) {
if(providerId.equals("41")) {
// update product to zwg product id
if(transaction.getCreditCurrency().equals(CurrencyType.ZWG)) {
String zwgProviderId = providerService.getProviderId(ZESA_ZWG_CLIENT);
request.put("ProductId", zwgProviderId);
}
List<Map<String, String>> rechargeOptions = new ArrayList<>();
if (transaction.getDebitPhone() != null) {
Map<String, String> option = new LinkedHashMap<>();
@@ -159,9 +169,8 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
}
// do this for Econet bundles
if(providerService.getProviderId(transaction.getBillClientId()).equals("111")) {
String productCode = providerService.getExternalId(
providerService.getProviderId(transaction.getBillClientId()), transaction.getProductUid());
if(providerId.equals("111")) {
String productCode = providerService.getExternalId(providerId, transaction.getProductUid());
List<Map<String, String>> rechargeOptions = new ArrayList<>();
Map<String, String> option = new LinkedHashMap<>();
option.put("Name", "ProductCode");