initial integration done

This commit is contained in:
2026-05-03 19:40:05 +02:00
parent d369176ba3
commit 364128df67
20 changed files with 1225 additions and 56 deletions

View File

@@ -0,0 +1,525 @@
{
"info": {
"_postman_id": "b240d6e9-e5d3-43a1-8d73-170edd02d714",
"name": "HotRecharge",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "569823"
},
"item": [
{
"name": "Auth",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Parse the response JSON and extract the token",
"const jsonData = pm.response.json();",
"const token = jsonData.token;",
"",
"// Store the token in a collection variable",
"pm.collectionVariables.set(\"token\", token);"
],
"type": "text/javascript",
"packages": {},
"requests": {}
}
}
],
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"AccessCode\": \"vusumuzi@qantra.co.zw\",\n \"Password\": \"Stikbanch@50\"\n}\n",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/identity/login",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"identity",
"login"
]
}
},
"response": []
},
{
"name": "Get Balance Zesa",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/account/balance/4",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"account",
"balance",
"4"
]
}
},
"response": []
},
{
"name": "Get Balance Airtime",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/account/balance/3",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"account",
"balance",
"3"
]
}
},
"response": []
},
{
"name": "Get Products",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/products/",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"products",
""
]
}
},
"response": []
},
{
"name": "Check Available Stock",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/query/stock/41",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"query",
"stock",
"41"
]
}
},
"response": []
},
{
"name": "Check Customer",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/query/customer/41/07088597534",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"query",
"customer",
"41",
"07088597534"
]
}
},
"response": [
{
"name": "Check Customer",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/query/customer/41/07088597534",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"query",
"customer",
"41",
"07088597534"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": null,
"header": [
{
"key": ":status",
"value": 200
},
{
"key": "content-length",
"value": "129"
},
{
"key": "content-type",
"value": "application/json; charset=utf-8"
},
{
"key": "server",
"value": ""
},
{
"key": "x-powered-by",
"value": "ASP.NET"
},
{
"key": "strict-transport-security",
"value": "max-age=0; includeSubDomains"
},
{
"key": "date",
"value": "Sat, 02 May 2026 08:29:13 GMT"
}
],
"cookie": [],
"body": "{\n \"accountNumber\": \"07088597534\",\n \"details\": {\n \"AccountName\": \"CHIRINDO LETWINA\\n2144 MARLBOROUGH\",\n \"Status\": \"Active\",\n \"Currency\": \"ZWG\"\n }\n}"
}
]
},
{
"name": "Recharge Airtime",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"AgentReference\": \"278917b4-4180-413d-9a31-59fcd4b9b654\",\n \"ProductId\": 101,\n \"Target\": \"0773591219\",\n \"Amount\": 0.1\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/products/recharge",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"products",
"recharge"
]
}
},
"response": [
{
"name": "Recharge Airtime",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"AgentReference\": \"278917b4-4180-413d-9a31-59fcd4b9b654\",\n \"ProductId\": 101,\n \"Target\": \"0773591219\",\n \"Amount\": 0.1\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/products/recharge",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"products",
"recharge"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": null,
"header": [
{
"key": ":status",
"value": 200
},
{
"key": "content-length",
"value": "218"
},
{
"key": "content-type",
"value": "application/json; charset=utf-8"
},
{
"key": "server",
"value": ""
},
{
"key": "x-powered-by",
"value": "ASP.NET"
},
{
"key": "strict-transport-security",
"value": "max-age=0; includeSubDomains"
},
{
"key": "date",
"value": "Sat, 02 May 2026 08:37:11 GMT"
}
],
"cookie": [],
"body": "{\n \"successful\": true,\n \"rechargeId\": 190356544,\n \"amount\": 0.1,\n \"discount\": 5,\n \"balance\": {\n \"accountTypeId\": 3,\n \"name\": \"USD\",\n \"balance\": 49.715\n },\n \"message\": \"OK\",\n \"rechargeData\": {\n \"Network\": \"Econet\",\n \"Target\": \"0773591219\",\n \"Cost\": \"0.09500\"\n }\n}"
}
]
},
{
"name": "Recharge ZESA",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"AgentReference\": \"{{$guid}}\",\n \"ProductId\": 41,\n \"Target\": \"07088597534\",\n \"Amount\": 5,\n \"RechargeOptions\": [\n {\n \"Name\": \"NotifyNumber\",\n \"ParameterType\": \"String\",\n \"Value\": \"0773591219\"\n }\n ]\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/products/recharge",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"products",
"recharge"
]
}
},
"response": [
{
"name": "Recharge ZESA",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"AgentReference\": \"{{$guid}}\",\n \"ProductId\": 41,\n \"Target\": \"07088597534\",\n \"Amount\": 5,\n \"RechargeOptions\": [\n {\n \"Name\": \"NotifyNumber\",\n \"ParameterType\": \"String\",\n \"Value\": \"0773591219\"\n }\n ]\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "https://ssl.hot.co.zw/api/v3/products/recharge",
"protocol": "https",
"host": [
"ssl",
"hot",
"co",
"zw"
],
"path": [
"api",
"v3",
"products",
"recharge"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": null,
"header": [
{
"key": ":status",
"value": 200
},
{
"key": "content-length",
"value": "371"
},
{
"key": "content-type",
"value": "application/json; charset=utf-8"
},
{
"key": "server",
"value": ""
},
{
"key": "x-powered-by",
"value": "ASP.NET"
},
{
"key": "strict-transport-security",
"value": "max-age=0; includeSubDomains"
},
{
"key": "date",
"value": "Sat, 02 May 2026 08:34:46 GMT"
}
],
"cookie": [],
"body": "{\n \"successful\": true,\n \"rechargeId\": 190356513,\n \"amount\": 5,\n \"discount\": 1.2,\n \"balance\": {\n \"accountTypeId\": 4,\n \"name\": \"Utility USD\",\n \"balance\": 45.06\n },\n \"message\": \"Transaction processed successfully\",\n \"rechargeData\": {\n \"Network\": \"ZESA\",\n \"Target\": \"07088597534\",\n \"Cost\": \"4.9400\",\n \"Token\": \"0099 4439 8936 4293 1759\",\n \"Units\": \"57.86\",\n \"NetAmount\": \"125.29\",\n \"TaxAmount\": \"0\",\n \"Levy\": \"7.52\",\n \"Arrears\": \"0\"\n }\n}"
}
]
}
],
"variable": [
{
"key": "token",
"value": ""
}
]
}

View File

@@ -1,5 +1,6 @@
package zw.qantra.tm.domain.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import lombok.*;
import org.hibernate.annotations.SQLRestriction;
@@ -22,4 +23,6 @@ public class Provider extends BaseEntity {
private String accountFieldName;
private String integrationProcessorLabel;
private double percentageCommission;
@Column(columnDefinition = "TEXT")
private String meta;
}

View File

@@ -0,0 +1,82 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import zw.qantra.tm.rest.RestService;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class HotRechargeTokenService {
Logger logger = LoggerFactory.getLogger(HotRechargeTokenService.class);
private final RestService restService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotAuthUrl;
@Value("${hot.access.code}")
private String hotAccessCode;
@Value("${hot.password}")
private String hotPassword;
@Value("${hot.token.expiry.minutes:30}")
private int tokenExpiryMinutes;
private String cachedToken;
private long tokenExpiryTime;
public synchronized String getToken() {
if (isTokenValid()) {
logger.info("Returning cached HotRecharge token");
return cachedToken;
}
return fetchNewToken();
}
private boolean isTokenValid() {
return cachedToken != null && System.currentTimeMillis() < tokenExpiryTime;
}
private synchronized String fetchNewToken() {
if (isTokenValid()) {
return cachedToken;
}
try {
logger.info("Fetching new HotRecharge token");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
Map<String, String> payload = Map.of(
"AccessCode", hotAccessCode,
"Password", hotPassword
);
LinkedHashMap<String, Object> response = restService.postAs(hotAuthUrl + "/identity/login", payload, headers, LinkedHashMap.class);
cachedToken = (String) response.get("token");
tokenExpiryTime = System.currentTimeMillis() + (tokenExpiryMinutes * 60 * 1000L);
logger.info("New HotRecharge token cached, expires in {} minutes", tokenExpiryMinutes);
return cachedToken;
} catch (Exception e) {
logger.error("Failed to fetch HotRecharge token: " + e.getMessage(), e);
cachedToken = null;
return null;
}
}
public synchronized void invalidateToken() {
logger.info("Invalidating cached HotRecharge token");
cachedToken = null;
tokenExpiryTime = 0;
}
}

View File

@@ -1,26 +0,0 @@
package zw.qantra.tm.domain.services.processors.confirmations;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.dtos.BillPaymentDto;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap;
@Service("CONFIRM_ECONET_BROADBAND")
public class EconetBroadbandConfirmationProcessor extends EconetConfirmationProcessor{
public EconetBroadbandConfirmationProcessor(RestService restService, TransactionService transactionService, AdditionalDataService additionalDataService) {
super(restService, transactionService, additionalDataService);
}
}

View File

@@ -1,13 +0,0 @@
package zw.qantra.tm.domain.services.processors.confirmations;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.rest.RestService;
@Service("CONFIRM_NETONE")
public class NetoneConfirmationProcessor extends EconetConfirmationProcessor{
public NetoneConfirmationProcessor(RestService restService, TransactionService transactionService, AdditionalDataService additionalDataService) {
super(restService, transactionService, additionalDataService);
}
}

View File

@@ -0,0 +1,29 @@
package zw.qantra.tm.domain.services.processors.confirmations.hotrecharge;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
@Service("CONFIRM_ECONET_BUNDLES_HOT")
@RequiredArgsConstructor
public class EconetBundlesConfirmationHotProcessor implements TransactionProcessorInterface {
@Override
public Object process(Transaction transaction) throws Exception {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
return transaction;
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) throws Exception {
return transaction;
}
}

View File

@@ -0,0 +1,29 @@
package zw.qantra.tm.domain.services.processors.confirmations.hotrecharge;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
@Service("CONFIRM_ECONET_HOT")
@RequiredArgsConstructor
public class EconetConfirmationHotProcessor implements TransactionProcessorInterface {
@Override
public Object process(Transaction transaction) throws Exception {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
return transaction;
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) throws Exception {
return transaction;
}
}

View File

@@ -0,0 +1,29 @@
package zw.qantra.tm.domain.services.processors.confirmations.hotrecharge;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
@Service("CONFIRM_NETONE_HOT")
@RequiredArgsConstructor
public class NetoneConfirmationHotProcessor implements TransactionProcessorInterface {
@Override
public Object process(Transaction transaction) throws Exception {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
return transaction;
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) throws Exception {
return transaction;
}
}

View File

@@ -0,0 +1,163 @@
package zw.qantra.tm.domain.services.processors.confirmations.hotrecharge;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
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.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.domain.services.ProviderService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal;
import java.util.*;
@Service("CONFIRM_ZESA_HOT")
@RequiredArgsConstructor
public class ZesaConfirmationHotProcessor implements TransactionProcessorInterface {
Logger logger = LoggerFactory.getLogger(ZesaConfirmationHotProcessor.class);
private final RestService restService;
private final AdditionalDataService additionalDataService;
private final ProviderService providerService;
private final HotRechargeTokenService hotRechargeTokenService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
@Override
public Object process(Transaction transaction) throws Exception {
logger.info("Processing ZESA confirmation from HotRecharge");
try {
String token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("91");
transaction.setErrorMessage("Failed to authenticate with HotRecharge");
return transaction;
}
if (!checkBalance(token, transaction.getAmount())) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("91");
transaction.setErrorMessage("Insufficient balance");
return transaction;
}
Map<String, Object> customerData = checkCustomer(token, transaction);
if (customerData == null) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("91");
transaction.setErrorMessage("Customer not found or invalid");
return transaction;
}
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
List<Map<String, String>> additionalDataList = buildAdditionalData(customerData);
transaction.setAdditionalData(additionalDataList);
AdditionalData additionalData = AdditionalData.builder()
.transactionId(transaction.getId().toString())
.requestType(RequestType.CONFIRM)
.jsonString(Utils.toJson(additionalDataList))
.build();
additionalDataService.getAdditionalDataRepository().save(additionalData);
} catch (Exception e) {
logger.error("Error processing ZESA confirmation: " + e.getMessage(), e);
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99");
transaction.setErrorMessage("Processing error: " + e.getMessage());
}
return transaction;
}
private List<Map<String, String>> buildAdditionalData(Map<String, Object> customerData) {
List<Map<String, String>> additionalDataList = new ArrayList<>();
if (customerData != null) {
additionalDataList.add(Map.of("key", "accountNumber",
"value", customerData.getOrDefault("accountNumber", "").toString()));
Map<String, Object> details = (Map<String, Object>) customerData.get("details");
if (details != null) {
for (Map.Entry<String, Object> entry : details.entrySet()) {
additionalDataList.add(Map.of("key", entry.getKey(), "value", entry.getValue().toString()));
}
}
}
return additionalDataList;
}
private boolean checkBalance(String token, BigDecimal amount) {
try {
String balanceUrl = hotApiUrl + "/account/balance/4";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
LinkedList responseEntity = restService.getAs(balanceUrl, headers, LinkedList.class);
LinkedHashMap response = (LinkedHashMap) responseEntity.get(0);
logger.info("Response: " + response);
if (response != null && response.containsKey("balance")) {
Object balance = response.get("balance");
logger.info("Current balance: " + balance);
return (new BigDecimal(balance.toString())).compareTo(amount) > 0;
}
return false;
} catch (Exception e) {
logger.error("Failed to check balance: " + e.getMessage(), e);
return false;
}
}
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 customerUrl = hotApiUrl + "/query/customer/" + hotProductId + "/" + transaction.getCreditAccount();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
LinkedHashMap<String, Object> response = restService.getAs(customerUrl, headers, LinkedHashMap.class);
if (response != null && response.containsKey("details")) {
logger.info("Customer found: " + response.get("accountNumber"));
return response;
}
return null;
} catch (Exception e) {
logger.error("Failed to check customer: " + e.getMessage(), e);
return null;
}
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) throws Exception {
return transaction;
}
}

View File

@@ -0,0 +1,13 @@
package zw.qantra.tm.domain.services.processors.confirmations.steward;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.rest.RestService;
@Service("CONFIRM_ECONET_BUNDLES_STEWARD")
public class EconetBroadbandConfirmationStewardProcessor extends EconetConfirmationStewardProcessor {
public EconetBroadbandConfirmationStewardProcessor(RestService restService, TransactionService transactionService, AdditionalDataService additionalDataService) {
super(restService, transactionService, additionalDataService);
}
}

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.services.processors.confirmations;
package zw.qantra.tm.domain.services.processors.confirmations.steward;
import lombok.RequiredArgsConstructor;
@@ -18,12 +18,11 @@ import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap;
import java.util.UUID;
@Service("CONFIRM_ECONET")
@Service("CONFIRM_ECONET_STEWARD")
@RequiredArgsConstructor
public class EconetConfirmationProcessor implements TransactionProcessorInterface {
Logger logger = LoggerFactory.getLogger(EconetConfirmationProcessor.class);
public class EconetConfirmationStewardProcessor implements TransactionProcessorInterface {
Logger logger = LoggerFactory.getLogger(EconetConfirmationStewardProcessor.class);
private final RestService restService;
private final TransactionService transactionService;

View File

@@ -0,0 +1,13 @@
package zw.qantra.tm.domain.services.processors.confirmations.steward;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.rest.RestService;
@Service("CONFIRM_NETONE_STEWARD")
public class NetoneConfirmationStewardStewardProcessor extends EconetConfirmationStewardProcessor {
public NetoneConfirmationStewardStewardProcessor(RestService restService, TransactionService transactionService, AdditionalDataService additionalDataService) {
super(restService, transactionService, additionalDataService);
}
}

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.services.processors.confirmations;
package zw.qantra.tm.domain.services.processors.confirmations.steward;
import lombok.RequiredArgsConstructor;
@@ -19,10 +19,10 @@ import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap;
@Service("CONFIRM_ZESA")
@Service("CONFIRM_ZESA_STEWARD")
@RequiredArgsConstructor
public class ZesaConfirmationProcessor implements TransactionProcessorInterface {
Logger logger = LoggerFactory.getLogger(ZesaConfirmationProcessor.class);
public class ZesaConfirmationStewardProcessor implements TransactionProcessorInterface {
Logger logger = LoggerFactory.getLogger(ZesaConfirmationStewardProcessor.class);
private final RestService restService;
private final TransactionService transactionService;

View File

@@ -0,0 +1,245 @@
package zw.qantra.tm.domain.services.processors.integrations;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
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.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.*;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal;
import java.util.*;
@Service("INTEGRATION_HOT")
@RequiredArgsConstructor
public class HotRechargeIntegrationProcessor implements TransactionProcessorInterface {
Logger logger = LoggerFactory.getLogger(HotRechargeIntegrationProcessor.class);
private final RestService restService;
private final AdditionalDataService additionalDataService;
private final SettingService settingService;
private final HotRechargeTokenService hotRechargeTokenService;
private final ProviderService providerService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
@Override
@Transactional
public Object process(Transaction transaction) throws Exception {
if(settingService.getBooleanSetting("SIMULATE_INTEGRATION_DELAY")){
Thread.sleep(5000);
}
if(settingService.getBooleanSetting("SIMULATE_INTEGRATION_FAILURE")){
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92");
transaction.setErrorMessage("Simulated failure");
return transaction;
}
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("responseCode");
transaction.setCreditRef("creditRef");
transaction.setErrorMessage(null);
return transaction;
}
try {
logger.info("Processing HotRecharge integration");
String token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("91");
transaction.setErrorMessage("Failed to authenticate with HotRecharge");
return transaction;
}
if (!checkBalance(token, transaction.getAmount())) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("91");
transaction.setErrorMessage("Insufficient balance");
return transaction;
}
LinkedHashMap<String, Object> rechargeResponse = performRecharge(token, transaction);
if (rechargeResponse == null) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99");
transaction.setErrorMessage("Recharge request failed");
return transaction;
}
Boolean successful = (Boolean) rechargeResponse.get("successful");
if (successful != null && successful) {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
transaction.setCreditRef(rechargeResponse.get("rechargeId").toString());
transaction.setErrorMessage(null);
@SuppressWarnings("unchecked")
Map<String, Object> rechargeData = (Map<String, Object>) rechargeResponse.get("rechargeData");
if (rechargeData != null) {
List<Map<String, String>> additionalDataList = buildAdditionalDataList(rechargeData);
transaction.setAdditionalData(additionalDataList);
AdditionalData additionalData = AdditionalData.builder()
.transactionId(transaction.getId().toString())
.requestType(RequestType.INTEGRATION)
.jsonString(Utils.toJson(additionalDataList))
.build();
additionalDataService.getAdditionalDataRepository().save(additionalData);
}
} else {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode((String) rechargeResponse.getOrDefault("responseCode", "99"));
transaction.setErrorMessage((String) rechargeResponse.getOrDefault("message", "Recharge failed"));
}
} catch (Exception e) {
logger.error("Error processing HotRecharge integration: " + e.getMessage(), e);
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99");
transaction.setErrorMessage("Processing error: " + e.getMessage());
}
return transaction;
}
private boolean checkBalance(String token, BigDecimal amount) {
try {
String balanceUrl = hotApiUrl + "/account/balance/4";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
@SuppressWarnings("unchecked")
LinkedList<LinkedHashMap<String, Object>> responseEntity = restService.getAs(balanceUrl, headers, LinkedList.class);
LinkedHashMap<String, Object> response = responseEntity.get(0);
logger.info("Balance response: {}", response);
if (response != null && response.containsKey("balance")) {
BigDecimal balance = new BigDecimal(response.get("balance").toString());
logger.info("Current balance: {}", balance);
return balance.compareTo(amount) > 0;
}
return false;
} catch (Exception e) {
logger.error("Failed to check balance: {}", e.getMessage(), e);
return false;
}
}
private LinkedHashMap<String, Object> performRecharge(String token, Transaction transaction) {
try {
String rechargeUrl = hotApiUrl + "/products/recharge";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
Map<String, Object> request = new LinkedHashMap<>();
request.put("AgentReference", UUID.randomUUID().toString());
request.put("ProductId", getProductId(transaction.getBillClientId()));
request.put("Target", transaction.getCreditAccount());
request.put("Amount", transaction.getAmount());
if(getProductId(transaction.getBillClientId()).equals("41")) {
List<Map<String, String>> rechargeOptions = new ArrayList<>();
if (transaction.getCreditPhone() != null) {
Map<String, String> option = new LinkedHashMap<>();
option.put("Name", "NotifyNumber");
option.put("ParameterType", "String");
option.put("Value", transaction.getCreditPhone());
rechargeOptions.add(option);
}
if (!rechargeOptions.isEmpty()) {
request.put("RechargeOptions", rechargeOptions);
}
}
logger.info("Sending recharge request: {}", request);
ResponseEntity<?> response = restService.postAsEntity(rechargeUrl, request, headers, LinkedHashMap.class);
logger.info("Recharge response: {}", response.getBody());
LinkedHashMap<String, Object> successResponse = (LinkedHashMap<String, Object>) response.getBody();
return successResponse;
} catch (HttpClientErrorException e) {
logger.info("HTTP error response received: {}", e.getStatusCode());
try {
String responseBody = e.getResponseBodyAsString();
logger.info("Error response body: {}", responseBody);
LinkedHashMap<String, Object> errorResponseMap = Utils.fromJson(responseBody, LinkedHashMap.class);
List<Map<String, String>> errors = (List<Map<String, String>>) errorResponseMap.get("errors");
if(errors != null && !errors.isEmpty()){
String errorMessage = errors.get(0).get("message");
errorResponseMap.put("message", errorMessage);
logger.info("Extracted error message: {}", errorMessage);
}
return errorResponseMap;
} catch (Exception parseException) {
logger.error("Failed to parse error response: {}", parseException.getMessage());
LinkedHashMap<String, Object> errorMap = new LinkedHashMap<>();
errorMap.put("message", e.getMessage());
return errorMap;
}
} catch (ResourceAccessException e) {
logger.error("Network error during recharge: {}", e.getMessage(), e);
LinkedHashMap<String, Object> errorMap = new LinkedHashMap<>();
errorMap.put("message", "Network error: " + e.getMessage());
return errorMap;
} catch (Exception e) {
logger.error("Failed to perform recharge: {}", e.getMessage(), e);
return null;
}
}
private List<Map<String, String>> buildAdditionalDataList(Map<String, Object> rechargeData) {
List<Map<String, String>> additionalDataList = new ArrayList<>();
for (Map.Entry<String, Object> entry : rechargeData.entrySet()) {
Map<String, String> dataItem = new LinkedHashMap<>();
dataItem.put("key", entry.getKey());
dataItem.put("value", entry.getValue().toString());
additionalDataList.add(dataItem);
}
return additionalDataList;
}
private String getProductId(String billClientId) {
Provider provider = providerService.getProviderRepository()
.findByClientId(billClientId);
if(provider == null){
throw new ApiException("Provider not found for billClientId: " + billClientId);
}
@SuppressWarnings("unchecked")
Map<String, String> meta = Utils.fromJson(provider.getMeta(), Map.class);
return meta.get("hotProductId");
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) {
return transaction;
}
}

View File

@@ -140,6 +140,14 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
@Override
public Object poll(Transaction transaction) throws Exception {
logger.info("polling transaction");
if(settingService.getBooleanSetting("SIMULATE_ECOCASH_SUCCESS")) {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
transaction.setTargetUrl("/home");
return transaction;
}
String token = restService.getMerchantToken();
HttpHeaders headers = new HttpHeaders();

View File

@@ -114,8 +114,8 @@ public class RegistrationWorkflow extends WorkflowDefinition {
try {
String phone = payload.getPhone();
Phonenumber.PhoneNumber zimProto = phoneUtil.parse(phone, "ZW");
phone = String.valueOf(zimProto.getCountryCode()) + String.valueOf(zimProto.getNationalNumber());
payload.setPhone(phone);
phone = zimProto.getCountryCode() + String.valueOf(zimProto.getNationalNumber());
payload.setPhone(phone.replaceAll("[^0-9]", ""));
} catch (NumberParseException e) {
System.err.println("NumberParseException was thrown: " + e.toString());
return NextAction.moveToState(FAILED, "Invalid phone number");

View File

@@ -44,7 +44,10 @@ public class CleanupHandler implements HandlerInterface {
if(provider == null){
throw new ApiException("Provider not found for billClientId: " + transaction.getBillClientId());
}
String label = transaction.getType() + "_" + provider.getLabel();
String label =
transaction.getType() + "_" +
provider.getLabel() + "_" +
provider.getIntegrationProcessorLabel();
transaction.setConfirmationProcessorLabel(label);
transaction.setProviderLabel(provider.getLabel());
@@ -79,7 +82,7 @@ public class CleanupHandler implements HandlerInterface {
// phone must begin with '+'
Phonenumber.PhoneNumber zimPhoneNumber = phoneUtil.parse(debitPhoneNumber, "");
String formattedPhoneNumber = phoneUtil.format(zimPhoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
transaction.setDebitPhone(formattedPhoneNumber);
transaction.setDebitPhone(formattedPhoneNumber.replaceAll("[^0-9]", ""));
} catch (NumberParseException e) {
System.err.println("NumberParseException was thrown: " + e.toString());
}
@@ -89,7 +92,7 @@ public class CleanupHandler implements HandlerInterface {
try {
Phonenumber.PhoneNumber zimPhoneNumber = phoneUtil.parse(creditPhoneNumber, "ZW");
String formattedPhoneNumber = phoneUtil.format(zimPhoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
transaction.setCreditPhone(formattedPhoneNumber);
transaction.setCreditPhone(formattedPhoneNumber.replaceAll("[^0-9]", ""));
} catch (NumberParseException e) {
System.err.println("NumberParseException was thrown: " + e.toString());
}

View File

@@ -38,6 +38,11 @@ public class ConfirmHandler implements HandlerInterface {
.build();
transactionEventService.save(transactionEvent);
// String confirmationProcessorLabel =
// transaction.getType() + "_" +
// provider.getLabel() + "_" +
// provider.getIntegrationProcessorLabel();
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(
transaction.getConfirmationProcessorLabel());

View File

@@ -1,6 +1,8 @@
package zw.qantra.tm.rest;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -8,7 +10,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
@@ -17,7 +21,9 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import zw.qantra.tm.exceptions.RestTemplateResponseErrorHandler;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -53,16 +59,15 @@ public class RestService {
)
);
this.restTemplate.setErrorHandler(new RestTemplateResponseErrorHandler());
// bootstrap rest template
List<ClientHttpRequestInterceptor> interceptors
= this.restTemplate.getInterceptors();
if (CollectionUtils.isEmpty(interceptors)) {
interceptors = new ArrayList<>();
}
interceptors.add(new RestTemplateInterceptor());
interceptors.add(new LoggingInterceptor(this));
this.restTemplate.setInterceptors(interceptors);
}
public String getUserToken(){
@@ -230,4 +235,52 @@ public class RestService {
public <T> ResponseEntity<?> exchangeGetNoAuth(String path, HttpEntity<?> httpEntity, Class<?> clazz, String pathVariable) {
return this.restTemplate.exchange(path, HttpMethod.GET, httpEntity, clazz, pathVariable);
}
private String prettyPrintJson(String json) {
try {
Object jsonObject = gson.fromJson(json, Object.class);
return new GsonBuilder().setPrettyPrinting().create().toJson(jsonObject);
} catch (JsonSyntaxException e) {
return json;
}
}
private static class LoggingInterceptor implements ClientHttpRequestInterceptor {
Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class);
private final RestService restService;
public LoggingInterceptor(RestService restService) {
this.restService = restService;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
logRequest(request, body);
ClientHttpResponse response = execution.execute(request, body);
logResponse(response);
return response;
}
private void logRequest(HttpRequest request, byte[] body) {
logger.info("=== REST REQUEST ===");
logger.info("Method: {} | URI: {}", request.getMethod(), request.getURI());
logger.info("Headers: {}", request.getHeaders());
if (body.length > 0) {
String bodyStr = new String(body, StandardCharsets.UTF_8);
logger.info("Body: {}", restService.prettyPrintJson(bodyStr));
}
}
private void logResponse(ClientHttpResponse response) throws IOException {
logger.info("=== REST RESPONSE ===");
logger.info("Status: {}", response.getStatusCode());
logger.info("Headers: {}", response.getHeaders());
byte[] responseBody = response.getBody().readAllBytes();
if (responseBody.length > 0) {
String bodyStr = new String(responseBody, StandardCharsets.UTF_8);
logger.info("Body: {}", restService.prettyPrintJson(bodyStr));
}
}
}
}

View File

@@ -77,3 +77,12 @@ management.tracing.enabled=true
management.tracing.sampling.probability=1.0
management.otlp.tracing.endpoint=http://62.171.179.108:10428/insert/opentelemetry/v1/traces
management.otlp.logging.endpoint=http://62.171.179.108:9428/insert/opentelemetry/v1/logs
hot-recharge.url=https://ssl.hot.co.zw/api/v3
hot-recharge.username=vusumuzi@qantra.co.zw
hot-recharge.password=Stikbanch@50
hot.api.url=https://ssl.hot.co.zw/api/v3
hot.auth.url=https://ssl.hot.co.zw/api/v3/identity/login
hot.access.code=vusumuzi@qantra.co.zw
hot.password=Stikbanch@50
hot.token.expiry.minutes=30