velocity integration complete

This commit is contained in:
2026-05-29 23:02:50 +02:00
parent 24ba6d13f6
commit c2f5b63fa6
21 changed files with 148 additions and 178 deletions

View File

@@ -1,6 +1,11 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal;
import net.kaczmarzyk.spring.data.jpa.web.annotation.And;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.Provider;
@@ -16,8 +21,11 @@ public class ProviderController {
private final ProviderService providerService;
@GetMapping
public ResponseEntity getProviders(@RequestParam(required = false) String category) {
return ResponseEntity.ok(providerService.getProvidersByCategory(category));
public ResponseEntity getProviders(
@And({
@Spec(path = "category", spec = Equal.class)
}) Specification<Provider> spec, Pageable pageable) {
return ResponseEntity.ok(providerService.findAll(spec, pageable));
}
@GetMapping("/{id}/products")

View File

@@ -30,4 +30,5 @@ public class Provider extends BaseEntity {
private double percentageCommission;
@Column(columnDefinition = "TEXT")
private String meta;
private Integer priority;
}

View File

@@ -1,6 +1,7 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.Provider;
@@ -8,7 +9,7 @@ import java.util.List;
import java.util.UUID;
@Repository
public interface ProviderRepository extends JpaRepository<Provider, UUID> {
public interface ProviderRepository extends JpaRepository<Provider, UUID>, JpaSpecificationExecutor<Provider> {
List<Provider> findByCategory(String category);
Provider findByClientId(String clientId);
boolean existsByClientId(String clientId);

View File

@@ -26,31 +26,62 @@ public class HotRechargeTokenService {
private String hotAccessCode;
@Value("${hot.password}")
private String hotPassword;
@Value("${hot.token.expiry.minutes:25}")
private int tokenExpiryMinutes;
@Value("${hot.cache.duration.minutes:5}")
private int cacheDurationMinutes;
private String cachedToken;
private long tokenExpiryTime;
private String cachedRefreshToken;
private long cacheExpiryTime;
public synchronized String getToken() {
if (isTokenValid()) {
logger.info("Returning cached HotRecharge token");
return cachedToken;
if (isCacheValid()) {
logger.info("Cache is valid, attempting token refresh");
return refreshCachedToken();
}
return fetchNewToken();
}
private boolean isTokenValid() {
return cachedToken != null && System.currentTimeMillis() < tokenExpiryTime;
private boolean isCacheValid() {
return cachedToken != null && cachedRefreshToken != null && System.currentTimeMillis() < cacheExpiryTime;
}
private synchronized String fetchNewToken() {
if (isTokenValid()) {
return cachedToken;
private synchronized String refreshCachedToken() {
if (!isCacheValid()) {
return fetchNewToken();
}
try {
logger.info("Fetching new HotRecharge token");
logger.info("Refreshing HotRecharge token via refresh endpoint");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
Map<String, String> payload = Map.of(
"token", cachedToken,
"refreshToken", cachedRefreshToken
);
LinkedHashMap<String, Object> response = restService.postAs(hotAuthUrl + "/identity/refresh", payload, headers, LinkedHashMap.class);
cachedToken = (String) response.get("token");
cachedRefreshToken = (String) response.get("refreshToken");
cacheExpiryTime = System.currentTimeMillis() + (cacheDurationMinutes * 60 * 1000L);
logger.info("HotRecharge token refreshed via refresh endpoint, cache extended by {} minutes", cacheDurationMinutes);
return cachedToken;
} catch (Exception e) {
logger.error("Failed to refresh HotRecharge token: " + e.getMessage(), e);
// Fall back to login on refresh failure
return fetchNewToken();
}
}
private synchronized String fetchNewToken() {
if (isCacheValid()) {
return refreshCachedToken();
}
try {
logger.info("Fetching new HotRecharge token via login");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
@@ -62,13 +93,15 @@ public class HotRechargeTokenService {
LinkedHashMap<String, Object> response = restService.postAs(hotAuthUrl + "/identity/login", payload, headers, LinkedHashMap.class);
cachedToken = (String) response.get("token");
tokenExpiryTime = System.currentTimeMillis() + (tokenExpiryMinutes * 60 * 1000L);
cachedRefreshToken = (String) response.get("refreshToken");
cacheExpiryTime = System.currentTimeMillis() + (cacheDurationMinutes * 60 * 1000L);
logger.info("New HotRecharge token cached, expires in {} minutes", tokenExpiryMinutes);
logger.info("New HotRecharge token cached, cache expires in {} minutes", cacheDurationMinutes);
return cachedToken;
} catch (Exception e) {
logger.error("Failed to fetch HotRecharge token: " + e.getMessage(), e);
cachedToken = null;
cachedRefreshToken = null;
return null;
}
}
@@ -76,7 +109,7 @@ public class HotRechargeTokenService {
public synchronized void invalidateToken() {
logger.info("Invalidating cached HotRecharge token");
cachedToken = null;
tokenExpiryTime = 0;
cachedRefreshToken = null;
cacheExpiryTime = 0;
}
}
}

View File

@@ -6,6 +6,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
@@ -164,16 +166,8 @@ public class ProviderService {
return null;
}
public Object getProvidersByCategory(String category) {
List<Provider> existingProviders;
if (category != null && !category.isEmpty()) {
existingProviders = getProviderRepository().findByCategory(category);
} else {
existingProviders = getProviderRepository().findAll();
}
return existingProviders;
public Object findAll(Specification<Provider> specification, Pageable pageable) {
return getProviderRepository().findAll(specification, pageable);
}
public Object getProviders(List<Provider> existingProviders) {

View File

@@ -181,7 +181,10 @@ public class VelocityPaymentService {
transaction.setTargetUrl(redirectUrl);
return transaction;
}
return null;
String errorMessage = (String) body.get("errorMessage");
log.error("Unexpected transaction response from Velocity: {}", response);
throw new IllegalStateException(errorMessage);
} catch (Exception e) {
log.error("Error initiating payment: {}", e.getMessage(), e);

View File

@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.HotRechargeBalanceService;
import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.rest.RestService;
@@ -13,7 +14,8 @@ public class EconetBundlesConfirmationHotProcessor extends NetoneConfirmationHot
AdditionalDataService additionalDataService,
HotRechargeTokenService hotRechargeTokenService,
HotRechargeBalanceService hotRechargeBalanceService,
RestService restService) {
super(additionalDataService, hotRechargeTokenService, hotRechargeBalanceService, restService);
RestService restService,
SettingService settingService) {
super(additionalDataService, hotRechargeTokenService, hotRechargeBalanceService, restService, settingService);
}
}

View File

@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.HotRechargeBalanceService;
import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.rest.RestService;
@@ -13,7 +14,8 @@ public class EconetConfirmationHotProcessor extends NetoneConfirmationHotProcess
AdditionalDataService additionalDataService,
HotRechargeTokenService hotRechargeTokenService,
HotRechargeBalanceService hotRechargeBalanceService,
RestService restService) {
super(additionalDataService, hotRechargeTokenService, hotRechargeBalanceService, restService);
RestService restService,
SettingService settingService) {
super(additionalDataService, hotRechargeTokenService, hotRechargeBalanceService, restService, settingService);
}
}

View File

@@ -12,6 +12,7 @@ import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.HotRechargeBalanceService;
import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
@@ -28,6 +29,7 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter
protected final HotRechargeTokenService hotRechargeTokenService;
protected final HotRechargeBalanceService hotRechargeBalanceService;
protected final RestService restService;
protected final SettingService settingService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
@@ -36,14 +38,18 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter
@Override
public Object process(Transaction transaction) throws Exception {
String token = hotRechargeTokenService.getToken();
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;
// 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())) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later.");
return transaction;
}
}
transaction.setCreditAccount(Utils.formatPhoneNumber(transaction.getCreditAccount(), "ZW"));
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");

View File

@@ -13,10 +13,7 @@ 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.HotRechargeBalanceService;
import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.domain.services.ProviderService;
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;
@@ -35,6 +32,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
private final ProviderService providerService;
private final HotRechargeTokenService hotRechargeTokenService;
private final HotRechargeBalanceService hotRechargeBalanceService;
private final SettingService settingService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
@@ -44,6 +42,13 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
logger.info("Processing ZESA confirmation from HotRecharge");
try {
// do this if we not simulating success, if simulating success, we skip balance check and just return success
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
return transaction;
}
String token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
transaction.setStatus(Status.FAILED);

View File

@@ -1,5 +0,0 @@
package zw.qantra.tm.domain.services.processors.handlers;
public interface LogicHandler<I, O> {
O process(I input);
}

View File

@@ -1,18 +0,0 @@
package zw.qantra.tm.domain.services.processors.handlers;
public class LogicPipeline<I, O> {
private final LogicHandler<I, O> currentHandler;
public LogicPipeline(LogicHandler<I, O> currentHandler) {
this.currentHandler = currentHandler;
}
public <K> LogicPipeline<I, K> addHandler(LogicHandler<O, K> newHandler) {
return new LogicPipeline<>(input -> newHandler.process(currentHandler.process(input)));
}
public O execute(I input) {
return currentHandler.process(input);
}
}

View File

@@ -1,20 +1,16 @@
package zw.qantra.tm.domain.services.processors.payments;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpHeaders;
import lombok.RequiredArgsConstructor;
import zw.qantra.tm.domain.dtos.SdkActionDto;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.SettingService;

View File

@@ -30,6 +30,7 @@ import zw.qantra.tm.exceptions.AlreadyExistsException;
import zw.qantra.tm.rest.RestService;
import org.springframework.http.HttpHeaders;
import zw.qantra.tm.utils.Utils;
import java.nio.charset.StandardCharsets;
import java.util.*;
@@ -99,16 +100,7 @@ public class RegistrationWorkflow extends WorkflowDefinition {
}
// update phone number format
PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
try {
String phone = payload.getPhone();
Phonenumber.PhoneNumber zimProto = phoneUtil.parse(phone, "ZW");
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");
}
payload.setPhone(Utils.formatPhoneNumber(payload.getPhone(), "ZW"));
execution.setVariable("registerRequest", payload);
execution.setVariable("body", payload);

View File

@@ -77,24 +77,11 @@ public class CleanupHandler implements HandlerInterface {
PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
if(debitPhoneNumber != null && !debitPhoneNumber.isEmpty()){
try {
// phone must begin with '+'
Phonenumber.PhoneNumber zimPhoneNumber = phoneUtil.parse(debitPhoneNumber, "");
String formattedPhoneNumber = phoneUtil.format(zimPhoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
transaction.setDebitPhone(formattedPhoneNumber.replaceAll("[^0-9]", ""));
} catch (NumberParseException e) {
System.err.println("NumberParseException was thrown: " + e.toString());
}
transaction.setDebitPhone(Utils.formatPhoneNumber(debitPhoneNumber, ""));
}
if(creditPhoneNumber != null && !creditPhoneNumber.isEmpty()){
try {
Phonenumber.PhoneNumber zimPhoneNumber = phoneUtil.parse(creditPhoneNumber, "ZW");
String formattedPhoneNumber = phoneUtil.format(zimPhoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
transaction.setCreditPhone(formattedPhoneNumber.replaceAll("[^0-9]", ""));
} catch (NumberParseException e) {
System.err.println("NumberParseException was thrown: " + e.toString());
}
transaction.setDebitPhone(Utils.formatPhoneNumber(creditPhoneNumber, "ZW"));
}
return transaction;

View File

@@ -24,7 +24,7 @@ public class RecipientsUpdateHandler implements HandlerInterface {
List<Recipient> recipients = recipientService.getRecipient(transaction.getCreditAccount(), transaction.getUserId());
Recipient recipient = null;
if(recipients.size() > 0){
if(!recipients.isEmpty()){
recipient = recipients.get(0);
}else{
recipient = new Recipient();

View File

@@ -1,75 +0,0 @@
package zw.qantra.tm.rest;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.RequiredArgsConstructor;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.stream.Collectors;
public class RestTemplateInterceptor
implements ClientHttpRequestInterceptor {
private final Logger logger = LoggerFactory.getLogger(getClass().getName());
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
@Override
public ClientHttpResponse intercept(
HttpRequest request,
byte[] reqBody,
ClientHttpRequestExecution execution) throws IOException {
String requestBody = new String(reqBody, StandardCharsets.UTF_8);
if(!isJSONValid(requestBody)) requestBody = toBasicJson(requestBody);
logger.info("Request Headers: {}", request.getHeaders().toString());
// we don't log file data
if(request.getHeaders().getContentLength() < 10000){
logger.info("Request: {}: {}\n{}",
request.getMethod(),
request.getURI(),
gson.toJson(gson.fromJson(requestBody, HashMap.class)));
}
ClientHttpResponse response = execution.execute(request, reqBody);
InputStreamReader isr = new InputStreamReader(response.getBody());
String body = new BufferedReader(isr).lines()
.collect(Collectors.joining("\n"));
try{
logger.info("Response body:\n{}", gson.toJson(gson.fromJson(body, HashMap.class)));
}catch (Exception exception){
logger.info("Problem serializing resp: {}", exception.getMessage());
}
return response;
}
public String toBasicJson(String string){
HashMap map = new HashMap();
map.put("log", string);
return gson.toJson(map);
}
public boolean isJSONValid(String test) {
try {
new JSONObject(test);
} catch (JSONException ex) {
try {
new JSONArray(test);
} catch (JSONException ex1) {
return false;
}
}
return true;
}
}

View File

@@ -36,9 +36,11 @@ public class ProviderSeeder implements Seeder {
.integrationProcessorLabel("HOT")
.accountFieldName("Phone Number")
.percentageCommission(5)
.requiresAccount(true)
.requiresAccount(false)
.requiresAmount(true)
.requiresPhone(true)
.priority(2)
.meta("{\"hotProductId\": \"101\"}")
.uid(Utils.getUid())
.build(),
Provider.builder()
@@ -52,10 +54,12 @@ public class ProviderSeeder implements Seeder {
.integrationProcessorLabel("HOT")
.accountFieldName("Phone Number")
.percentageCommission(5)
.requiresAccount(true)
.requiresAccount(false)
.requiresAmount(true)
.requiresPhone(true)
.uid(Utils.getUid())
.priority(3)
.meta("{\"hotProductId\": \"35\"}")
.build(),
Provider.builder()
.name("Zesa Prepaid")
@@ -71,6 +75,8 @@ public class ProviderSeeder implements Seeder {
.requiresAccount(true)
.requiresAmount(true)
.requiresPhone(true)
.priority(1)
.meta("{\"hotProductId\": \"41\"}")
.uid(Utils.getUid())
.build(),
Provider.builder()
@@ -84,9 +90,11 @@ public class ProviderSeeder implements Seeder {
.integrationProcessorLabel("HOT")
.accountFieldName("Phone Number")
.percentageCommission(5)
.requiresAccount(true)
.requiresAccount(false)
.requiresAmount(false)
.requiresPhone(true)
.priority(4)
.meta("{\"hotProductId\": \"111\"}")
.uid(Utils.getUid())
.build()
);

View File

@@ -7,12 +7,31 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.util.StdDateFormat;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
public class Utils {
public static String formatPhoneNumber(String phoneNumber, String region) {
if (phoneNumber == null || phoneNumber.isEmpty()) {
return phoneNumber;
}
PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
try {
Phonenumber.PhoneNumber numberProto = phoneUtil.parse(phoneNumber, region);
return phoneUtil.format(numberProto, PhoneNumberUtil.PhoneNumberFormat.E164)
.replaceAll("[^0-9]", "");
} catch (NumberParseException e) {
return phoneNumber;
}
}
public static <T> T deepCopy(Object object, Class<T> clazz) {
String json = toJson(object);
return fromJson(json, clazz);

View File

@@ -35,7 +35,7 @@ jwt.secret=your-super-secret-jwt-key-here-make-it-very-long-and-secure-in-produc
jwt.expiration=86400000
logging.level.root=WARN
spring.datasource.nflow.url=jdbc:postgresql://62.171.179.108:5432/nflow
spring.datasource.nflow.url=jdbc:postgresql://62.171.179.108:5432/qpay-nflow
spring.datasource.nflow.username=postgres
spring.datasource.nflow.password=zdDZMzq6F4B4L1IUla
spring.main.allow-bean-definition-overriding=true
@@ -64,7 +64,7 @@ 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=25
hot.cache.duration.minutes=5
velocity.api.base-url=https://api.velocityafrica.net
velocity.api.api-key=cHE08ADmiATtf8VYgA59-wKVKVKj2WusKDAaaRLDsG8

View File

@@ -66,4 +66,15 @@
<changeSet author="khoza (generated)" id="1780061546055-16">
<addUniqueConstraint columnNames="username" constraintName="UC_USERSUSERNAME_COL" tableName="users"/>
</changeSet>
<changeSet author="khoza (generated)" id="1780062553770-3">
<addColumn tableName="provider">
<column name="priority" type="integer"/>
</addColumn>
</changeSet>
<changeSet author="khoza (generated)" id="1780062553770-1">
<dropUniqueConstraint constraintName="UC_USERSUSERNAME_COL" tableName="users"/>
</changeSet>
<changeSet author="khoza (generated)" id="1780062553770-2">
<addUniqueConstraint columnNames="username" constraintName="UC_USERSUSERNAME_COL" tableName="users"/>
</changeSet>
</databaseChangeLog>