adding citywallet support

This commit is contained in:
Prince
2026-06-11 16:34:01 +02:00
parent fadb1e3f8d
commit 68ed2650e8
18 changed files with 477 additions and 131 deletions

View File

@@ -0,0 +1,47 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.dtos.erp.VelocityAccountDto;
import zw.qantra.tm.domain.dtos.erp.VelocityStatementDto;
import zw.qantra.tm.domain.services.VelocityAccountService;
import java.util.List;
@RestController
@RequestMapping("/accounts")
@RequiredArgsConstructor
public class AccountController {
private final VelocityAccountService velocityAccountService;
private static final List<String> SUPPORTED_CURRENCIES = List.of("USD", "ZWG");
@GetMapping("/phone/{currencyPhoneConcat}")
@PreAuthorize("hasRole('ACCOUNT_READ')")
public ResponseEntity<VelocityAccountDto> getAccountByPhone(
@PathVariable String currencyPhoneConcat) {
try {
VelocityAccountDto account = velocityAccountService.getAccountByPhone(currencyPhoneConcat);
return ResponseEntity.ok(account);
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/phone/{currencyPhoneConcat}/statement")
@PreAuthorize("hasRole('ACCOUNT_READ')")
public ResponseEntity<VelocityStatementDto> getStatementByPhone(
@PathVariable String currencyPhoneConcat,
@RequestParam String startDate,
@RequestParam String endDate) {
try {
VelocityStatementDto statement = velocityAccountService.getStatementByPhone(
currencyPhoneConcat, startDate, endDate);
return ResponseEntity.ok(statement);
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
}
}

View File

@@ -27,6 +27,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.services.processors.payments.CityWalletRemotePaymentProcessor;
import zw.qantra.tm.domain.services.processors.workflows.TransactionWorkflow;
import zw.qantra.tm.domain.services.processors.workflows.WorkflowUtils;
import zw.qantra.tm.utils.LogUtils;
@@ -48,6 +49,7 @@ public class TransactonController {
private final WorkflowInstanceFactory workflowInstanceFactory;
private final WorkflowUtils workflowUtils;
private final ObjectStringMapper objectStringMapper;
private final CityWalletRemotePaymentProcessor cityWalletRemotePaymentProcessor;
@GetMapping
public ResponseEntity<Page<Transaction>> getAll(
@@ -206,6 +208,16 @@ public class TransactonController {
return ResponseEntity.ok(response);
}
@PutMapping("/{id}/velocity-otp")
public ResponseEntity<Transaction> updateOtp(@PathVariable UUID id, @RequestBody Transaction transaction) {
Transaction fetchTransaction = transactionService.findById(id);
if (fetchTransaction == null) {
return ResponseEntity.notFound().build();
}
fetchTransaction.setVerificationCode(transaction.getVerificationCode());
return ResponseEntity.ok(cityWalletRemotePaymentProcessor.updateVelocityTransactionOtp(fetchTransaction));
}
@PutMapping("/{id}")
public ResponseEntity<Transaction> update(@PathVariable UUID id, @RequestBody Transaction transaction) {
if (!transactionService.getTransactionRepository().existsById(id)) {

View File

@@ -0,0 +1,33 @@
package zw.qantra.tm.domain.dtos.erp;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.sql.Timestamp;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class VelocityAccountDto {
private String name;
private String description;
private String accountNumber;
private String alternateAccountNumber;
private String parentAccountId;
private String type;
private String category;
private String ledger;
private Timestamp deactivatedAt;
private BigDecimal balance;
private String partyType;
private String party;
private String currency;
private String customerStringId;
private String companyStringId;
}

View File

@@ -0,0 +1,51 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class VelocityStatementDto {
private BigDecimal startBalance;
private BigDecimal endBalance;
private List<StatementItem> transactions;
private String accountName;
private String accountNumber;
private String startDate;
private String endDate;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class StatementItem {
private String name;
private String narration;
private String party;
private String company;
private String notes;
private Timestamp postingDate;
private BigDecimal amount;
private String account;
private String accountName;
private String currency;
private String reference;
private TransactionSide type;
private BigDecimal balance;
}
public enum TransactionSide {
DEBIT, CREDIT
}
}

View File

@@ -1,5 +1,6 @@
package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import lombok.*;
@@ -17,6 +18,7 @@ import java.util.UUID;
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public class Transaction extends BaseEntity {
private String userId;
private String trace;
@@ -81,6 +83,7 @@ public class Transaction extends BaseEntity {
private String orderId;
private String orderTrace;
private String orderTransactionTrace;
private String orderTransactionId;
private String erpSalesRef;
private String erpJournalRef;
@@ -107,6 +110,8 @@ public class Transaction extends BaseEntity {
@Column(columnDefinition = "TEXT")
private String targetUrl;
private String sdkActionId;
private String verificationCode;
private Integer retries;
@Transient
private Object additionalData;

View File

@@ -30,6 +30,7 @@ public class JobService {
private final WorkflowInstanceService workflowInstanceService;
private final WorkflowUtils workflowUtils;
private final TransactionService transactionService;
private final SettingService settingService;
public void checkOrphanedTransactions() {
List<Transaction> transactions = transactionService.getTransactionRepository()
@@ -42,6 +43,13 @@ public class JobService {
for (Transaction transaction: transactions) {
attempts++;
try {
int maxRetries = settingService.getIntegerSetting("MAX_RETRIES");
int retries = transaction.getRetries() == null ? 0 : transaction.getRetries();
if (retries >= maxRetries)
break;
transaction.setRetries(retries + 1);
// polling logic
WorkflowInstance update = new WorkflowInstance.Builder()
.setId(transaction.getWorkflowId())

View File

@@ -128,7 +128,7 @@ public class ProviderService {
return products;
} catch (IllegalArgumentException e) {
log.error("Error fetching products: " + providerUid, e);
log.error("Error fetching products: {}", providerUid);
return Collections.emptyList();
}
}

View File

@@ -14,6 +14,19 @@ public class SettingService {
return settingRepository;
}
public Integer getIntegerSetting(String key) {
try {
Setting setting = settingRepository.findBySettingName(key);
if (setting != null) {
return Integer.parseInt(setting.getSettingValue());
}
return 0;
} catch (Exception exception) {
exception.printStackTrace();
return 0;
}
}
public Boolean getBooleanSetting(String key) {
try {
Setting setting = settingRepository.findBySettingName(key);

View File

@@ -0,0 +1,77 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.dtos.erp.VelocityAccountDto;
import zw.qantra.tm.domain.dtos.erp.VelocityStatementDto;
import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap;
@Service
@Slf4j
@RequiredArgsConstructor
public class VelocityAccountService {
private final VelocityPaymentService velocityPaymentService;
private static final String VELOCITY_API_ACCOUNT_BY_PHONE_PATH = "/accounts/phone/{currencyPhoneConcat}";
private static final String VELOCITY_API_STATEMENT_BY_PHONE_PATH = "/statements/phone/{currencyPhoneConcat}";
/**
* Fetches an account by the currency-phone concatenation.
*
* @param currencyPhoneConcat The concatenation of currency and phone (e.g. "USD+263771234567").
* @return VelocityAccountDto with account details.
*/
public VelocityAccountDto getAccountByPhone(String currencyPhoneConcat) {
try {
LinkedHashMap responseMap = velocityPaymentService.makeRequest(
HttpMethod.GET,
VELOCITY_API_ACCOUNT_BY_PHONE_PATH.replace("{currencyPhoneConcat}", currencyPhoneConcat),
null
);
if (responseMap == null || responseMap.isEmpty()) {
log.error("Error fetching account");
throw new IllegalStateException("Error fetching account");
}
return Utils.deepCopy(responseMap, VelocityAccountDto.class);
} catch (Exception e) {
log.error("Error fetching account by phone {}: {}", currencyPhoneConcat, e.getMessage(), e);
throw new IllegalStateException("Error fetching account: " + e.getMessage(), e);
}
}
/**
* Fetches an account statement by the currency-phone concatenation and date range.
*
* @param currencyPhoneConcat The concatenation of currency and phone (e.g. "USD+263771234567").
* @param startDate The start date for the statement (e.g. "2025-01-01").
* @param endDate The end date for the statement (e.g. "2025-12-31").
* @return VelocityStatementDto with statement details.
*/
public VelocityStatementDto getStatementByPhone(String currencyPhoneConcat, String startDate, String endDate) {
try {
String path = VELOCITY_API_STATEMENT_BY_PHONE_PATH
.replace("{currencyPhoneConcat}", currencyPhoneConcat)
+ "?startDate=" + startDate + "&endDate=" + endDate;
LinkedHashMap responseMap = velocityPaymentService.makeRequest(
HttpMethod.GET,
path,
null
);
if (responseMap == null || responseMap.isEmpty()) {
log.error("Error fetching statement");
throw new IllegalStateException("Error fetching statement");
}
return Utils.deepCopy(responseMap, VelocityStatementDto.class);
} catch (Exception e) {
log.error("Error fetching statement for phone {} ({} - {}): {}",
currencyPhoneConcat, startDate, endDate, e.getMessage(), e);
throw new IllegalStateException("Error fetching statement: " + e.getMessage(), e);
}
}
}

View File

@@ -44,6 +44,7 @@ public class VelocityPaymentService {
private static final String VELOCITY_API_POLL_PAYMENT_PATH = "/transactions/poll/{traceId}";
private static final String VELOCITY_API_UPDATE_WORKFLOW_PATH = "/sales-orders/update-workflow/{traceId}";
private static final String VELOCITY_API_CUSTOMERS = "/customers";
private static final String VELOCITY_API_UPDATE_TRANSACTION_PATH = "/transactions/{id}";
public VelocityCustomerResponseDto createCustomer(RegisterRequest registerRequest) {
VelocityCustomerDto payload = VelocityCustomerDto.builder()
@@ -170,14 +171,22 @@ public class VelocityPaymentService {
LinkedHashMap response = makeRequest(
HttpMethod.POST, VELOCITY_API_INITIATE_PAYMENT_PATH, payload);
LinkedHashMap body = (LinkedHashMap) response.get("body");
if(body == null){
String errorMessage = (String) response.get("errorMessage");
log.error("Error initiating payment: {}", errorMessage);
throw new IllegalStateException("Error initiating payment: " + errorMessage);
}
String transactionStatus = (String) body.get("status");
String tranTrace = (String) body.get("trace");
String tranId = (String) body.get("id");
String transactionName = (String) body.get("name");
String redirectUrl = body.containsKey("redirectUrl") ? (String) body.get("redirectUrl") : null;
if ("SUCCESS".equalsIgnoreCase(transactionStatus)) {
transaction.setErpSalesPaymentRef(transactionName);
transaction.setOrderTransactionTrace(tranTrace);
transaction.setOrderTransactionId(tranId);
transaction.setTargetUrl(redirectUrl);
return transaction;
}
@@ -193,6 +202,14 @@ public class VelocityPaymentService {
}
public Transaction pollPaymentStatus(Transaction transaction) {
int maxRetries = settingService.getIntegerSetting("MAX_RETRIES");
int retries = transaction.getRetries() == null ? 0 : transaction.getRetries();
if (retries >= maxRetries)
return transaction;
transaction.setRetries(retries + 1);
LinkedHashMap response = makeRequest(
HttpMethod.PUT,
VELOCITY_API_POLL_PAYMENT_PATH.replace("{traceId}", transaction.getOrderTransactionTrace()),
@@ -200,14 +217,44 @@ public class VelocityPaymentService {
);
LinkedHashMap body = (LinkedHashMap) response.get("body");
if(body == null){
String errorMessage = (String) response.get("errorMessage");
log.error("Error polling payment: {}", errorMessage);
throw new IllegalStateException("Error initiating payment: " + errorMessage);
}
transaction.setStatus(Status.valueOf((String) body.get("status")));
transaction.setPaymentStatus(Status.valueOf((String) body.get("paymentStatus")));
transaction.setPollingStatus(Status.valueOf((String) body.get("pollStatus")));
transaction.setDebitRef((String) body.get("debitRef"));
transaction.setPaymentProcessorRef((String) body.get("paymentProcessorRef"));
transaction.setResponseCode((String) body.get("responseCode"));
if (!transaction.getStatus().equals(Status.SUCCESS)) {
transaction.setErrorMessage((String) body.get("errorMessage"));
}
return transaction;
}
/**
* Updates the OTP (additional data) on a transaction requiring manual verification.
* Equivalent to the Flutter/Dart {@code updateTransaction} method.
*
* @param id The transaction ID to update.
* @param otp The OTP value to set as additional data.
* @return A {@code LinkedHashMap} containing the Velocity API response.
*/
public LinkedHashMap updateTransaction(String id, String otp) {
Map<String, String> payload = new HashMap<>();
payload.put("additionalData", otp);
return makeRequest(
HttpMethod.PUT,
VELOCITY_API_UPDATE_TRANSACTION_PATH.replace("{id}", id),
payload
);
}
@Async
public void updateSalesOrderWorkflow(Transaction transaction) {
LinkedHashMap response = makeRequest(
@@ -226,7 +273,7 @@ public class VelocityPaymentService {
}
private LinkedHashMap makeRequest(HttpMethod method, String path, Object payload) {
LinkedHashMap makeRequest(HttpMethod method, String path, Object payload) {
long startNs = System.nanoTime();
ResponseEntity<LinkedHashMap> response;
try {

View File

@@ -0,0 +1,102 @@
package zw.qantra.tm.domain.services.processors.payments;
import lombok.RequiredArgsConstructor;
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 zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@Service("REQUEST_WALLET_REMOTE")
@RequiredArgsConstructor
public class CityWalletRemotePaymentProcessor implements TransactionProcessorInterface {
private final Logger logger = LoggerFactory.getLogger(CityWalletRemotePaymentProcessor.class);
private final TransactionService transactionService;
private final SettingService settingService;
private final VelocityPaymentService velocityPaymentService;
@Override
public Object process(Transaction transaction) throws Exception {
logger.info("processing transaction");
if(settingService.getBooleanSetting("SIMULATE_WALLET_SUCCESS")) {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("02");
transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10));
transaction.setDebitRef(RandomStringUtils.randomNumeric(10));
return transaction;
}
try {
transaction = velocityPaymentService.processPayment(transaction);
} catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again.");
}
return transactionService.save(transaction);
}
@Override
public Object reverse(Transaction transaction) {
return transaction;
}
@Override
public Object poll(Transaction transaction) throws Exception {
logger.info("polling transaction");
if(settingService.getBooleanSetting("SIMULATE_WALLET_SUCCESS")) {
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("02");
transaction.setTargetUrl("/home");
return transaction;
}
try {
transaction = velocityPaymentService.pollPaymentStatus(transaction);
} catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again.");
}
return transactionService.save(transaction);
}
public Transaction updateVelocityTransactionOtp(Transaction transaction) {
String code = transaction.getVerificationCode();
try {
velocityPaymentService.updateTransaction(transaction.getOrderTransactionId(), code);
} catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment update: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again.");
}
return transaction;
}
}

View File

@@ -23,20 +23,10 @@ import zw.qantra.tm.rest.RestService;
@RequiredArgsConstructor
public class EcocashRemotePaymentProcessor implements TransactionProcessorInterface {
private final Logger logger = LoggerFactory.getLogger(EcocashRemotePaymentProcessor.class);
private final RestService restService;
private final TransactionService transactionService;
private final SettingService settingService;
private final VelocityPaymentService velocityPaymentService;
@Value("${sbz.aggregator.encryption-key}")
private String encryptionKey;
@Value("${sbz.aggregator.client-id}")
private String clientId;
@Value("${sbz.aggregator.client-secret}")
private String clientSecret;
@Value("${steward.payment.processor.url}")
private String url;
@Override
public Object process(Transaction transaction) throws Exception {
logger.info("processing transaction");
@@ -81,11 +71,6 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
try {
transaction = velocityPaymentService.pollPaymentStatus(transaction);
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
transactionService.save(transaction);
} catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e);
@@ -95,7 +80,7 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
transaction.setErrorMessage("Payment failed. Please try again.");
}
return transaction;
return transactionService.save(transaction);
}
public String getHash(String originalString){

View File

@@ -31,22 +31,10 @@ import java.util.Random;
@RequiredArgsConstructor
public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
private final Logger logger = LoggerFactory.getLogger(MPGSWebPaymentProcessor.class);
private final RestService restService;
private final TransactionService transactionService;
private final SettingService settingService;
private final VelocityPaymentService velocityPaymentService;
@Value("${sbz.aggregator.encryption-key}")
private String encryptionKey;
@Value("${sbz.aggregator.client-id}")
private String clientId;
@Value("${sbz.aggregator.client-secret}")
private String clientSecret;
@Value("${steward.payment.processor.url}")
private String url;
@Value("${mpgs.return-url}")
private String returnUrl;
@Override
public Object process(Transaction transaction) throws Exception {
logger.info("processing transaction");
@@ -88,11 +76,6 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
try {
transaction = velocityPaymentService.pollPaymentStatus(transaction);
transaction.setStatus(transaction.getStatus());
transaction.setResponseCode("00");
transactionService.save(transaction);
} catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e);
@@ -102,7 +85,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
transaction.setErrorMessage("Payment failed. Please try again");
}
return transaction;
return transactionService.save(transaction);
}
@Override

View File

@@ -24,6 +24,16 @@ public class PaymentProcessorSeeder implements Seeder {
@Override
public void seed() {
List<PaymentProcessor> processors = Arrays.asList(
PaymentProcessor.builder()
.name("CityWallet")
.label("WALLET")
.type("GATEWAY")
.image("city.png")
.accountFieldName("phoneNumber")
.accountFieldLabel("Wallet Phone Number")
.description("Pay using your CityWallet")
.authType("REMOTE")
.build(),
PaymentProcessor.builder()
.name("Ecocash")
.label("ECOCASH")