added version control & cloudflare cache clearing

This commit is contained in:
Prince
2026-06-21 00:25:15 +02:00
parent a367acd81d
commit caf4e4bc89
16 changed files with 348 additions and 45 deletions

View File

@@ -0,0 +1,57 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.AppVersion;
import zw.qantra.tm.domain.services.AppVersionService;
import java.util.Optional;
@RestController
@RequestMapping("/public/app-version")
@RequiredArgsConstructor
public class AppVersionController {
private final AppVersionService appVersionService;
/**
* Client checks if a newer app build is available.
* Pass the current build number as a query param.
*/
@GetMapping("/check/app")
public ResponseEntity<AppVersion> checkAppUpdate(@RequestParam Integer currentBuildNumber) {
Optional<AppVersion> update = appVersionService.checkForAppUpdate(currentBuildNumber);
return update.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
/**
* Client checks if a newer web build is available.
* Pass the current build number as a query param.
*/
@GetMapping("/check/web")
public ResponseEntity<AppVersion> checkWebUpdate(@RequestParam Integer currentBuildNumber) {
Optional<AppVersion> update = appVersionService.checkForWebUpdate(currentBuildNumber);
return update.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
/**
* Get the latest version record.
*/
@GetMapping("/latest")
public ResponseEntity<AppVersion> getLatestVersion() {
Optional<AppVersion> latest = appVersionService.getLatestVersion();
return latest.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
/**
* Persist a new version record (internal/admin use).
*/
@PostMapping
public ResponseEntity<AppVersion> save(@RequestBody AppVersion appVersion) {
return ResponseEntity.ok(appVersionService.save(appVersion));
}
}

View File

@@ -0,0 +1,26 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import zw.qantra.tm.domain.dtos.CloudflarePurgeResponse;
import zw.qantra.tm.domain.services.CloudflareCacheService;
@Slf4j
@RestController
@RequestMapping("/public/cache/cloudflare")
@RequiredArgsConstructor
public class CloudflareCacheController {
private final CloudflareCacheService cloudflareCacheService;
@PostMapping("/clear")
public ResponseEntity<CloudflarePurgeResponse> clearCache() {
log.info("Received request to clear Cloudflare cache");
CloudflarePurgeResponse response = cloudflareCacheService.purgeEverything();
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,33 @@
package zw.qantra.tm.domain.dtos;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CloudflarePurgeResponse {
private boolean success;
private List<CloudflareError> errors;
private List<String> messages;
private CloudflareResult result;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CloudflareError {
private int code;
private String message;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CloudflareResult {
private String id;
}
}

View File

@@ -14,6 +14,7 @@ public class GroupBatchItemUpdateRequest {
private BigDecimal amount; private BigDecimal amount;
private String recipientName; private String recipientName;
private String recipientPhone; private String recipientPhone;
private String recipientAccount;
private String billClientId; private String billClientId;
private String billName; private String billName;
private String billProductName; private String billProductName;

View File

@@ -0,0 +1,19 @@
package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity;
import lombok.*;
import java.sql.Timestamp;
@Getter
@Setter
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AppVersion extends BaseEntity{
private String description;
private Integer appBuildNumber;
private Integer webBuildNumber;
private Timestamp timestamp;
}

View File

@@ -36,6 +36,7 @@ public class GroupBatchItem extends BaseEntity {
private String recipientName; private String recipientName;
@Column(name = "recipient_phone") @Column(name = "recipient_phone")
private String recipientPhone; private String recipientPhone;
private String recipientAccount;
private BigDecimal amount; private BigDecimal amount;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)

View File

@@ -0,0 +1,18 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.AppVersion;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface AppVersionRepository extends JpaRepository<AppVersion, UUID> {
Optional<AppVersion> findTopByOrderByAppBuildNumberDesc();
Optional<AppVersion> findTopByOrderByWebBuildNumberDesc();
Optional<AppVersion> findTopByOrderByTimestampDesc();
}

View File

@@ -0,0 +1,56 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.AppVersion;
import zw.qantra.tm.domain.repositories.AppVersionRepository;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AppVersionService {
private final AppVersionRepository appVersionRepository;
/**
* Checks if a newer app build number exists than the one provided by the client.
*
* @param currentBuildNumber the build number the client currently has
* @return the latest AppVersion if a newer one exists, empty otherwise
*/
public Optional<AppVersion> checkForAppUpdate(Integer currentBuildNumber) {
Optional<AppVersion> latest = appVersionRepository.findTopByOrderByAppBuildNumberDesc();
if (latest.isPresent() && latest.get().getAppBuildNumber() > currentBuildNumber) {
return latest;
}
return Optional.empty();
}
/**
* Checks if a newer web build number exists than the one provided by the client.
*
* @param currentBuildNumber the build number the client currently has
* @return the latest AppVersion if a newer one exists, empty otherwise
*/
public Optional<AppVersion> checkForWebUpdate(Integer currentBuildNumber) {
Optional<AppVersion> latest = appVersionRepository.findTopByOrderByWebBuildNumberDesc();
if (latest.isPresent() && latest.get().getWebBuildNumber() > currentBuildNumber) {
return latest;
}
return Optional.empty();
}
/**
* Returns the latest AppVersion record regardless of build number.
*
* @return the latest AppVersion by timestamp
*/
public Optional<AppVersion> getLatestVersion() {
return appVersionRepository.findTopByOrderByTimestampDesc();
}
public AppVersion save(AppVersion appVersion) {
return appVersionRepository.save(appVersion);
}
}

View File

@@ -0,0 +1,58 @@
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.stereotype.Service;
import zw.qantra.tm.domain.dtos.CloudflarePurgeResponse;
import zw.qantra.tm.rest.RestService;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class CloudflareCacheService {
private static final Logger logger = LoggerFactory.getLogger(CloudflareCacheService.class);
private static final String CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
private final RestService restService;
@Value("${cloudflare-token}")
private String cloudflareToken;
@Value("${cloudflare-zone-id}")
private String cloudflareZoneId;
/**
* Purges the entire Cloudflare cache for the configured zone.
*
* @return CloudflarePurgeResponse containing success status and any errors
*/
public CloudflarePurgeResponse purgeEverything() {
logger.info("Initiating Cloudflare purge_everything for zone: {}", cloudflareZoneId);
String path = CLOUDFLARE_API_BASE + "/zones/" + cloudflareZoneId + "/purge_cache";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(cloudflareToken);
Map<String, Boolean> payload = Map.of("purge_everything", true);
CloudflarePurgeResponse response = restService.postWithExternalAuth(
path, payload, headers, CloudflarePurgeResponse.class
);
if (response != null && response.isSuccess()) {
logger.info("Cloudflare cache purged successfully for zone: {}", cloudflareZoneId);
} else if (response != null) {
logger.error("Cloudflare cache purge failed for zone: {}. Errors: {}",
cloudflareZoneId, response.getErrors());
}
return response;
}
}

View File

@@ -107,6 +107,7 @@ public class GroupBatchService {
.recipientId(recipient.getId()) .recipientId(recipient.getId())
.recipientName(recipient.getName()) .recipientName(recipient.getName())
.recipientPhone(recipient.getPhoneNumber()) .recipientPhone(recipient.getPhoneNumber())
.recipientAccount(recipient.getAccount())
.amount(defaultAmount) .amount(defaultAmount)
.status(GroupBatchItemStatus.NEW) .status(GroupBatchItemStatus.NEW)
.confirmStatus(GroupBatchItemConfirmStatus.PENDING) .confirmStatus(GroupBatchItemConfirmStatus.PENDING)
@@ -385,6 +386,9 @@ public class GroupBatchService {
if (update.getRecipientPhone() != null) { if (update.getRecipientPhone() != null) {
item.setRecipientPhone(update.getRecipientPhone()); item.setRecipientPhone(update.getRecipientPhone());
} }
if (update.getRecipientAccount() != null) {
item.setRecipientAccount(update.getRecipientAccount());
}
if (update.getBillClientId() != null) { if (update.getBillClientId() != null) {
item.setBillClientId(update.getBillClientId()); item.setBillClientId(update.getBillClientId());
} }

View File

@@ -9,6 +9,7 @@ import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.LinkedList; import java.util.LinkedList;
@@ -21,6 +22,7 @@ public class HotRechargeBalanceService {
Logger logger = LoggerFactory.getLogger(HotRechargeBalanceService.class); Logger logger = LoggerFactory.getLogger(HotRechargeBalanceService.class);
private final RestService restService; private final RestService restService;
private final SettingService settingService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl; private String hotApiUrl;
@@ -28,6 +30,10 @@ public class HotRechargeBalanceService {
@Cacheable(value = "hotrechargeBalance", key = "#token + '_' + #amount.toString()") @Cacheable(value = "hotrechargeBalance", key = "#token + '_' + #amount.toString()")
public boolean checkBalance(String token, BigDecimal amount, String accId) { public boolean checkBalance(String token, BigDecimal amount, String accId) {
try { try {
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
return true;
}
String balanceUrl = hotApiUrl + "/account/balance/" + accId; String balanceUrl = hotApiUrl + "/account/balance/" + accId;
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token); headers.setBearerAuth(token);

View File

@@ -178,6 +178,7 @@ public class RecipientGroupService {
.recipientId(recipient.getId()) .recipientId(recipient.getId())
.recipientName(recipient.getName()) .recipientName(recipient.getName())
.recipientPhone(recipient.getPhoneNumber()) .recipientPhone(recipient.getPhoneNumber())
.recipientAccount(recipient.getAccount())
.status(GroupBatchItemStatus.NEW) .status(GroupBatchItemStatus.NEW)
.confirmStatus(GroupBatchItemConfirmStatus.PENDING) .confirmStatus(GroupBatchItemConfirmStatus.PENDING)
.integrationStatus(GroupBatchItemIntegrationStatus.PENDING) .integrationStatus(GroupBatchItemIntegrationStatus.PENDING)
@@ -217,6 +218,7 @@ public class RecipientGroupService {
try { try {
item.setRecipientName(update.row.name); item.setRecipientName(update.row.name);
item.setRecipientPhone(update.row.phone); item.setRecipientPhone(update.row.phone);
item.setRecipientAccount(update.row.account);
item.setBillName(update.provider.getName()); item.setBillName(update.provider.getName());
item.setBillProductName(update.row.billProductName); item.setBillProductName(update.row.billProductName);
item.setProviderLabel(update.row.billLabel); item.setProviderLabel(update.row.billLabel);
@@ -474,7 +476,10 @@ public class RecipientGroupService {
List<Recipient> existing = recipientRepository.findByAccountAndUserId(incomingRecipient.getAccount(), recipientUserId); List<Recipient> existing = recipientRepository.findByAccountAndUserId(incomingRecipient.getAccount(), recipientUserId);
if (!existing.isEmpty()) { if (!existing.isEmpty()) {
return existing.get(0); Recipient recipient = existing.get(0);
recipient.setName(incomingRecipient.getName());
recipient.setPhoneNumber(incomingRecipient.getPhoneNumber());
return recipientRepository.save(recipient);
} }
incomingRecipient.setId(null); incomingRecipient.setId(null);

View File

@@ -39,27 +39,14 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl; private String hotApiUrl;
String token;
@Override @Override
public Object process(Transaction transaction) throws Exception { public Object process(Transaction transaction) throws Exception {
logger.info("Processing ZESA confirmation from HotRecharge"); logger.info("Processing ZESA confirmation from HotRecharge");
try { try {
// do this if we not simulating success, if simulating success, we skip balance check and just return success Map<String, Object> customerData = checkCustomer(transaction);
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("02");
return transaction;
}
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;
}
Map<String, Object> customerData = checkCustomer(token, transaction);
if (customerData == null) { if (customerData == null) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("90"); transaction.setResponseCode("90");
@@ -84,6 +71,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
transaction.setCreditAmount(transactionService.calculateCreditAmount(transaction)); transaction.setCreditAmount(transactionService.calculateCreditAmount(transaction));
// token should've been updated by checkCustomer
if (!hotRechargeBalanceService.checkBalance(token, transaction.getCreditAmount(), accId)) { if (!hotRechargeBalanceService.checkBalance(token, transaction.getCreditAmount(), accId)) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92"); transaction.setResponseCode("92");
@@ -161,7 +149,25 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
return currency == null ? "" : currency.toString().trim(); return currency == null ? "" : currency.toString().trim();
} }
private Map<String, Object> checkCustomer(String token, Transaction transaction) { private Map<String, Object> checkCustomer(Transaction transaction) {
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
logger.info("Simulating HotRecharge success");
return Utils.fromJson("{\n" +
" \"accountNumber\": \"07088597534\",\n" +
" \"details\": {\n" +
" \"AccountName\": \"CHIRINDO LETWINA\\n2144 MARLBOROUGH\",\n" +
" \"Status\": \"Active\",\n" +
" \"Currency\": \"ZWG\"\n" +
" }\n" +
"}", LinkedHashMap.class);
}
token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
throw new ApiException("Failed to authenticate with HotRecharge");
}
try { try {
String hotProductId = providerService.getProviderId(transaction.getBillClientId()); String hotProductId = providerService.getProviderId(transaction.getBillClientId());

View File

@@ -55,29 +55,12 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
return transaction; return transaction;
} }
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("02");
transaction.setCreditRef("Simulated Integration Success");
transaction.setErrorMessage(null);
return transaction;
}
try { try {
logger.info("Processing HotRecharge integration"); 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;
}
// todo: a balance check might be necessary here. Removing // todo: a balance check might be necessary here. Removing
// for now so not to spam hotrecharge with requests // for now so not to spam hotrecharge with requests
LinkedHashMap<String, Object> rechargeResponse = performRecharge(token, transaction); LinkedHashMap<String, Object> rechargeResponse = performRecharge(transaction);
if (rechargeResponse == null) { if (rechargeResponse == null) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
@@ -137,8 +120,40 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
} }
private LinkedHashMap<String, Object> performRecharge(String token, Transaction transaction) { private LinkedHashMap<String, Object> performRecharge(Transaction transaction) {
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
logger.info("Simulating HotRecharge success");
return Utils.fromJson("{\n" +
" \"successful\": true,\n" +
" \"rechargeId\": 1.9080854E8,\n" +
" \"amount\": 265.0,\n" +
" \"discount\": 1.2,\n" +
" \"balance\": {\n" +
" \"accountTypeId\": 2.0,\n" +
" \"name\": \"Utility ZWG\",\n" +
" \"balance\": 3306.32\n" +
" },\n" +
" \"message\": \"Transaction processed successfully\",\n" +
" \"rechargeData\": {\n" +
" \"Network\": \"ZESA\",\n" +
" \"Target\": \"07088597534\",\n" +
" \"Cost\": \"261.8200\",\n" +
" \"Token\": \"6616 0517 2063 6675 8254\",\n" +
" \"Units\": \"40.92\",\n" +
" \"NetAmount\": \"250\",\n" +
" \"TaxAmount\": \"0\",\n" +
" \"Levy\": \"15\",\n" +
" \"Arrears\": \"0\"\n" +
" }\n" +
"}", LinkedHashMap.class);
}
try { try {
String token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
throw new Exception("Failed to authenticate with HotRecharge");
}
String rechargeUrl = hotApiUrl + "/products/recharge"; String rechargeUrl = hotApiUrl + "/products/recharge";
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token); headers.setBearerAuth(token);

View File

@@ -87,3 +87,6 @@ app.base-url=https://pay.velocityafrica.net
zss.sms.api.url=https://secure.zss.co.zw/vportal/cnm/vsms/json/batch zss.sms.api.url=https://secure.zss.co.zw/vportal/cnm/vsms/json/batch
zss.sms.api.auth=Basic UWFudHJhdGVjaDpVbUk3OUpwUDh3QTY= zss.sms.api.auth=Basic UWFudHJhdGVjaDpVbUk3OUpwUDh3QTY=
sms.debug=false sms.debug=false
cloudflare-token=cfut_U0qhvhzAATLqHQa9lad7oRmBmPoJ6n9uIoCBbNOWc9884f18
cloudflare-zone-id=378cea8e50c0d9a158816109b3b1fd8d

View File

@@ -1,13 +1,8 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?> <?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" xmlns:pro="http://www.liquibase.org/xml/ns/pro" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" xmlns:pro="http://www.liquibase.org/xml/ns/pro" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet author="khoza (generated)" id="1781914969011-3"> <changeSet author="khoza (generated)" id="1781993557092-7">
<addColumn tableName="transaction"> <addColumn tableName="group_batch_item">
<column name="credit_address" type="varchar(255)"/> <column name="recipient_account" type="varchar(255)"/>
</addColumn>
</changeSet>
<changeSet author="khoza (generated)" id="1781914969011-4">
<addColumn tableName="transaction">
<column name="credit_voucher" type="varchar(255)"/>
</addColumn> </addColumn>
</changeSet> </changeSet>
</databaseChangeLog> </databaseChangeLog>