diff --git a/src/main/java/zw/qantra/tm/domain/controllers/AppVersionController.java b/src/main/java/zw/qantra/tm/domain/controllers/AppVersionController.java new file mode 100644 index 0000000..3d4c965 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/controllers/AppVersionController.java @@ -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 checkAppUpdate(@RequestParam Integer currentBuildNumber) { + Optional 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 checkWebUpdate(@RequestParam Integer currentBuildNumber) { + Optional update = appVersionService.checkForWebUpdate(currentBuildNumber); + return update.map(ResponseEntity::ok) + .orElse(ResponseEntity.noContent().build()); + } + + /** + * Get the latest version record. + */ + @GetMapping("/latest") + public ResponseEntity getLatestVersion() { + Optional latest = appVersionService.getLatestVersion(); + return latest.map(ResponseEntity::ok) + .orElse(ResponseEntity.noContent().build()); + } + + /** + * Persist a new version record (internal/admin use). + */ + @PostMapping + public ResponseEntity save(@RequestBody AppVersion appVersion) { + return ResponseEntity.ok(appVersionService.save(appVersion)); + } +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/controllers/CloudflareCacheController.java b/src/main/java/zw/qantra/tm/domain/controllers/CloudflareCacheController.java new file mode 100644 index 0000000..8700810 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/controllers/CloudflareCacheController.java @@ -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 clearCache() { + log.info("Received request to clear Cloudflare cache"); + CloudflarePurgeResponse response = cloudflareCacheService.purgeEverything(); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/dtos/CloudflarePurgeResponse.java b/src/main/java/zw/qantra/tm/domain/dtos/CloudflarePurgeResponse.java new file mode 100644 index 0000000..05f4777 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/CloudflarePurgeResponse.java @@ -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 errors; + private List 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; + } +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchItemUpdateRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchItemUpdateRequest.java index 0d155ff..5724a39 100644 --- a/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchItemUpdateRequest.java +++ b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchItemUpdateRequest.java @@ -14,6 +14,7 @@ public class GroupBatchItemUpdateRequest { private BigDecimal amount; private String recipientName; private String recipientPhone; + private String recipientAccount; private String billClientId; private String billName; private String billProductName; diff --git a/src/main/java/zw/qantra/tm/domain/models/AppVersion.java b/src/main/java/zw/qantra/tm/domain/models/AppVersion.java new file mode 100644 index 0000000..2be7bb5 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/models/AppVersion.java @@ -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; +} diff --git a/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java b/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java index b10b524..11d06a0 100644 --- a/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java +++ b/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java @@ -36,6 +36,7 @@ public class GroupBatchItem extends BaseEntity { private String recipientName; @Column(name = "recipient_phone") private String recipientPhone; + private String recipientAccount; private BigDecimal amount; @Enumerated(EnumType.STRING) diff --git a/src/main/java/zw/qantra/tm/domain/repositories/AppVersionRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/AppVersionRepository.java new file mode 100644 index 0000000..fc027e1 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/repositories/AppVersionRepository.java @@ -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 { + + Optional findTopByOrderByAppBuildNumberDesc(); + + Optional findTopByOrderByWebBuildNumberDesc(); + + Optional findTopByOrderByTimestampDesc(); +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/services/AppVersionService.java b/src/main/java/zw/qantra/tm/domain/services/AppVersionService.java new file mode 100644 index 0000000..e31541f --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/AppVersionService.java @@ -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 checkForAppUpdate(Integer currentBuildNumber) { + Optional 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 checkForWebUpdate(Integer currentBuildNumber) { + Optional 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 getLatestVersion() { + return appVersionRepository.findTopByOrderByTimestampDesc(); + } + + public AppVersion save(AppVersion appVersion) { + return appVersionRepository.save(appVersion); + } +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/services/CloudflareCacheService.java b/src/main/java/zw/qantra/tm/domain/services/CloudflareCacheService.java new file mode 100644 index 0000000..358e05a --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/CloudflareCacheService.java @@ -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 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; + } +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/services/GroupBatchService.java b/src/main/java/zw/qantra/tm/domain/services/GroupBatchService.java index da0305e..1fb0bcd 100644 --- a/src/main/java/zw/qantra/tm/domain/services/GroupBatchService.java +++ b/src/main/java/zw/qantra/tm/domain/services/GroupBatchService.java @@ -107,6 +107,7 @@ public class GroupBatchService { .recipientId(recipient.getId()) .recipientName(recipient.getName()) .recipientPhone(recipient.getPhoneNumber()) + .recipientAccount(recipient.getAccount()) .amount(defaultAmount) .status(GroupBatchItemStatus.NEW) .confirmStatus(GroupBatchItemConfirmStatus.PENDING) @@ -385,6 +386,9 @@ public class GroupBatchService { if (update.getRecipientPhone() != null) { item.setRecipientPhone(update.getRecipientPhone()); } + if (update.getRecipientAccount() != null) { + item.setRecipientAccount(update.getRecipientAccount()); + } if (update.getBillClientId() != null) { item.setBillClientId(update.getBillClientId()); } diff --git a/src/main/java/zw/qantra/tm/domain/services/HotRechargeBalanceService.java b/src/main/java/zw/qantra/tm/domain/services/HotRechargeBalanceService.java index 8102484..f34fd62 100644 --- a/src/main/java/zw/qantra/tm/domain/services/HotRechargeBalanceService.java +++ b/src/main/java/zw/qantra/tm/domain/services/HotRechargeBalanceService.java @@ -9,6 +9,7 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import zw.qantra.tm.rest.RestService; +import zw.qantra.tm.utils.Utils; import java.math.BigDecimal; import java.util.LinkedList; @@ -21,6 +22,7 @@ public class HotRechargeBalanceService { Logger logger = LoggerFactory.getLogger(HotRechargeBalanceService.class); private final RestService restService; + private final SettingService settingService; @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") private String hotApiUrl; @@ -28,6 +30,10 @@ public class HotRechargeBalanceService { @Cacheable(value = "hotrechargeBalance", key = "#token + '_' + #amount.toString()") public boolean checkBalance(String token, BigDecimal amount, String accId) { try { + if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){ + return true; + } + String balanceUrl = hotApiUrl + "/account/balance/" + accId; HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token); diff --git a/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java b/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java index 17c0515..9f9abf4 100644 --- a/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java +++ b/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java @@ -178,6 +178,7 @@ public class RecipientGroupService { .recipientId(recipient.getId()) .recipientName(recipient.getName()) .recipientPhone(recipient.getPhoneNumber()) + .recipientAccount(recipient.getAccount()) .status(GroupBatchItemStatus.NEW) .confirmStatus(GroupBatchItemConfirmStatus.PENDING) .integrationStatus(GroupBatchItemIntegrationStatus.PENDING) @@ -217,6 +218,7 @@ public class RecipientGroupService { try { item.setRecipientName(update.row.name); item.setRecipientPhone(update.row.phone); + item.setRecipientAccount(update.row.account); item.setBillName(update.provider.getName()); item.setBillProductName(update.row.billProductName); item.setProviderLabel(update.row.billLabel); @@ -474,7 +476,10 @@ public class RecipientGroupService { List existing = recipientRepository.findByAccountAndUserId(incomingRecipient.getAccount(), recipientUserId); 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); diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/ZesaConfirmationHotProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/ZesaConfirmationHotProcessor.java index d5c17a4..4759862 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/ZesaConfirmationHotProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/ZesaConfirmationHotProcessor.java @@ -39,27 +39,14 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") private String hotApiUrl; + String token; + @Override public Object process(Transaction transaction) throws Exception { 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("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 customerData = checkCustomer(token, transaction); + Map customerData = checkCustomer(transaction); if (customerData == null) { transaction.setStatus(Status.FAILED); transaction.setResponseCode("90"); @@ -84,6 +71,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa transaction.setCreditAmount(transactionService.calculateCreditAmount(transaction)); + // token should've been updated by checkCustomer if (!hotRechargeBalanceService.checkBalance(token, transaction.getCreditAmount(), accId)) { transaction.setStatus(Status.FAILED); transaction.setResponseCode("92"); @@ -161,7 +149,25 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa return currency == null ? "" : currency.toString().trim(); } - private Map checkCustomer(String token, Transaction transaction) { + private Map 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 { String hotProductId = providerService.getProviderId(transaction.getBillClientId()); diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/integrations/HotRechargeIntegrationProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/integrations/HotRechargeIntegrationProcessor.java index e1827ec..b2c6da2 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/integrations/HotRechargeIntegrationProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/integrations/HotRechargeIntegrationProcessor.java @@ -55,29 +55,12 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte 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 { 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 // for now so not to spam hotrecharge with requests - LinkedHashMap rechargeResponse = performRecharge(token, transaction); + LinkedHashMap rechargeResponse = performRecharge(transaction); if (rechargeResponse == null) { transaction.setStatus(Status.FAILED); transaction.setResponseCode("99"); @@ -137,8 +120,40 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte } - private LinkedHashMap performRecharge(String token, Transaction transaction) { + private LinkedHashMap 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 { + String token = hotRechargeTokenService.getToken(); + + if (token == null || token.isEmpty()) { + throw new Exception("Failed to authenticate with HotRecharge"); + } String rechargeUrl = hotApiUrl + "/products/recharge"; HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4edaaf2..53cc7a1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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.auth=Basic UWFudHJhdGVjaDpVbUk3OUpwUDh3QTY= sms.debug=false + +cloudflare-token=cfut_U0qhvhzAATLqHQa9lad7oRmBmPoJ6n9uIoCBbNOWc9884f18 +cloudflare-zone-id=378cea8e50c0d9a158816109b3b1fd8d \ No newline at end of file diff --git a/src/main/resources/liquibase-diff-changeLog.xml b/src/main/resources/liquibase-diff-changeLog.xml index e4a2b69..588a470 100644 --- a/src/main/resources/liquibase-diff-changeLog.xml +++ b/src/main/resources/liquibase-diff-changeLog.xml @@ -1,13 +1,8 @@ - - - - - - - - + + +