bug fixes

This commit is contained in:
2026-06-25 00:39:58 +02:00
parent db1fe2672a
commit dc0c42d12e
17 changed files with 532 additions and 41 deletions

View File

@@ -35,7 +35,8 @@ public class SecurityConfig {
.requestMatchers("/test/**").permitAll() .requestMatchers("/test/**").permitAll()
.requestMatchers("/nflow/**").permitAll() .requestMatchers("/nflow/**").permitAll()
.requestMatchers("/auth/**").permitAll() .requestMatchers("/auth/**").permitAll()
.requestMatchers("/public/**").permitAll() .requestMatchers("/public/**").permitAll()
.requestMatchers("/explorer/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.authenticationProvider(authenticationProvider(userService)) .authenticationProvider(authenticationProvider(userService))

View File

@@ -1,6 +1,12 @@
package zw.qantra.tm.domain.controllers; package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor; 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.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.PaymentProcessor; import zw.qantra.tm.domain.models.PaymentProcessor;
@@ -18,8 +24,12 @@ public class PaymentProcessorController {
private final PaymentProcessorService paymentProcessorService; private final PaymentProcessorService paymentProcessorService;
@GetMapping @GetMapping
public ResponseEntity<List<PaymentProcessor>> getAllPaymentProcessors() { public ResponseEntity getAllPaymentProcessors(
return ResponseEntity.ok(paymentProcessorService.getPaymentProcessorRepository().findAll()); @And({
@Spec(path = "label", spec = Equal.class),
@Spec(path = "currency", spec = Equal.class),
})Specification<PaymentProcessor> specification, Pageable pageable) {
return ResponseEntity.ok(paymentProcessorService.getAllPaymentProcessors(specification, pageable));
} }
@GetMapping("/{id}") @GetMapping("/{id}")

View File

@@ -0,0 +1,87 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.TransactionReportService;
import zw.qantra.tm.domain.services.TransactionReportService.TransactionReportFileResult;
import zw.qantra.tm.domain.services.TransactionReportService.TransactionReportSummary;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/public/reports/transactions")
@RequiredArgsConstructor
public class TransactionReportController {
private final TransactionReportService transactionReportService;
/**
* Generates a transaction report filtered by date range.
* Transactions are automatically filtered to only include
* partyType = INDIVIDUAL or GROUP.
*
* @param startDate start of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param endDate end of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param workspaceId optional workspace ID filter
* @return list of matching transactions
*/
@GetMapping
public ResponseEntity<List<Transaction>> getTransactionReport(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String workspaceId) {
List<Transaction> transactions = transactionReportService.generateReport(startDate, endDate, workspaceId);
return ResponseEntity.ok(transactions);
}
/**
* Generates a transaction report summary with counts and totals,
* filtered by date range and default party type filter.
*
* @param startDate start of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param endDate end of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param workspaceId optional workspace ID filter
* @return report summary with aggregated data
*/
@GetMapping("/summary")
public ResponseEntity<TransactionReportSummary> getTransactionReportSummary(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String workspaceId) {
TransactionReportSummary summary = transactionReportService.generateReportSummary(startDate, endDate, workspaceId);
return ResponseEntity.ok(summary);
}
/**
* Downloads a transaction report as an Excel file (.xlsx).
* The report is filtered by date range, with a default filter for
* partyType = INDIVIDUAL or GROUP.
*
* @param startDate start of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param endDate end of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param workspaceId optional workspace ID filter
* @return the generated Excel file as a downloadable resource
*/
@GetMapping("/download")
public ResponseEntity<Resource> downloadTransactionReport(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String workspaceId) {
TransactionReportFileResult reportFile = transactionReportService.generateReportFile(startDate, endDate, workspaceId);
ByteArrayResource resource = new ByteArrayResource(reportFile.fileBytes());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + reportFile.filename() + "\"")
.body(resource);
}
}

View File

@@ -65,5 +65,6 @@ public class GroupBatchItem extends BaseEntity {
private String billProductName; private String billProductName;
private String providerImage; private String providerImage;
private String providerLabel; private String providerLabel;
private String creditAddress;
} }

View File

@@ -3,6 +3,7 @@ package zw.qantra.tm.domain.models;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
import zw.qantra.tm.domain.enums.CurrencyType;
@Entity @Entity
@Table(name = "payment_processor", indexes = { @Table(name = "payment_processor", indexes = {
@@ -21,6 +22,8 @@ public class PaymentProcessor extends BaseEntity {
private String type; private String type;
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String image; private String image;
@Enumerated(EnumType.STRING)
private CurrencyType currency;
private String accountFieldName; private String accountFieldName;
private String accountFieldLabel; private String accountFieldLabel;
private String authType; private String authType;

View File

@@ -37,6 +37,7 @@ public class Transaction extends BaseEntity {
private UUID workspaceId; private UUID workspaceId;
@Column(name = "user_id") @Column(name = "user_id")
private String userId; private String userId;
private String username;
private String trace; private String trace;
private String region; private String region;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@@ -87,6 +88,7 @@ public class Transaction extends BaseEntity {
private String providerImage; private String providerImage;
private String paymentProcessorImage; private String paymentProcessorImage;
private UUID batchId; private UUID batchId;
private String batchDescription;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Status authorizationStatus; private Status authorizationStatus;
@@ -105,7 +107,6 @@ public class Transaction extends BaseEntity {
@Column(name = "polling_status") @Column(name = "polling_status")
private Status pollingStatus; private Status pollingStatus;
private String orderId; private String orderId;
private String orderTrace; private String orderTrace;
private String orderTransactionTrace; private String orderTransactionTrace;

View File

@@ -1,12 +1,16 @@
package zw.qantra.tm.domain.repositories; package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.PaymentProcessor; import zw.qantra.tm.domain.models.PaymentProcessor;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
public interface PaymentProcessorRepository extends JpaRepository<PaymentProcessor, UUID> { public interface PaymentProcessorRepository extends JpaRepository<PaymentProcessor, UUID>,
JpaSpecificationExecutor<PaymentProcessor> {
boolean existsByLabel(String label); boolean existsByLabel(String label);
boolean existsByLabelAndCurrency(String label, CurrencyType currencyType);
} }

View File

@@ -119,6 +119,7 @@ public class GroupBatchWorkflowService {
transaction.setPaymentProcessorName(batch.getPaymentProcessorName()); transaction.setPaymentProcessorName(batch.getPaymentProcessorName());
transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage()); transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage());
transaction.setBatchId(batch.getId()); transaction.setBatchId(batch.getId());
transaction.setBatchDescription(batch.getDescription());
transaction.setBillName("Batch - " + batch.getDescription()); transaction.setBillName("Batch - " + batch.getDescription());
transaction = calculateChargesHandler.process(transaction); transaction = calculateChargesHandler.process(transaction);
@@ -225,6 +226,7 @@ public class GroupBatchWorkflowService {
transaction.setProviderLabel(item.getProviderLabel()); transaction.setProviderLabel(item.getProviderLabel());
transaction.setUserId(batch.getUserId()); transaction.setUserId(batch.getUserId());
transaction.setUsername(user.getUsername());
transaction.setWorkspaceId(batch.getWorkspaceId()); transaction.setWorkspaceId(batch.getWorkspaceId());
transaction.setType(RequestType.CONFIRM); transaction.setType(RequestType.CONFIRM);
transaction.setPartyType(PartyType.BATCH); transaction.setPartyType(PartyType.BATCH);
@@ -250,6 +252,8 @@ public class GroupBatchWorkflowService {
mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.CONFIRMED); mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.CONFIRMED);
mutableItem.setStatus(GroupBatchItemStatus.CONFIRMED); mutableItem.setStatus(GroupBatchItemStatus.CONFIRMED);
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.PENDING); mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.PENDING);
if(result.getCreditAddress() != null)
mutableItem.setCreditAddress(result.getCreditAddress());
mutableItem.setConfirmError(null); mutableItem.setConfirmError(null);
} else { } else {
mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED); mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED);

View File

@@ -1,7 +1,11 @@
package zw.qantra.tm.domain.services; package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.PaymentProcessor;
import zw.qantra.tm.domain.repositories.PaymentProcessorRepository; import zw.qantra.tm.domain.repositories.PaymentProcessorRepository;
@Service @Service
@@ -12,4 +16,8 @@ public class PaymentProcessorService {
public PaymentProcessorRepository getPaymentProcessorRepository() { public PaymentProcessorRepository getPaymentProcessorRepository() {
return paymentProcessorRepository; return paymentProcessorRepository;
} }
public Page<PaymentProcessor> getAllPaymentProcessors(Specification<PaymentProcessor> specification, Pageable pageable) {
return paymentProcessorRepository.findAll(specification, pageable);
}
} }

View File

@@ -240,7 +240,18 @@ public class RecipientGroupService {
item.setAmount(productDto.getDefaultAmount()); item.setAmount(productDto.getDefaultAmount());
} }
updatedItems.add(groupBatchItemRepository.save(item)); updatedItems.add(groupBatchItemRepository.save(item));
// Recalculate batch totals
BigDecimal newTotalValue = updatedItems.stream()
.map(GroupBatchItem::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
GroupBatch batch = groupBatchRepository.findById(batchId)
.orElseThrow(() -> new ApiException("Batch not found"));
batch.setValue(newTotalValue);
batch.setCount(updatedItems.size());
groupBatchRepository.save(batch);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace();
providerErrors.add(GroupCreateFromFileResult.RowError.builder() providerErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(update.row.lineNumber) .lineNumber(update.row.lineNumber)
.account(update.row.account) .account(update.row.account)
@@ -250,19 +261,6 @@ public class RecipientGroupService {
} }
} }
// Recalculate batch totals
if (!updatedItems.isEmpty()) {
List<GroupBatchItem> allItems = groupBatchItemRepository.findAllByGroupBatchId(batchId);
BigDecimal newTotalValue = allItems.stream()
.map(GroupBatchItem::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
GroupBatch batch = groupBatchRepository.findById(batchId)
.orElseThrow(() -> new ApiException("Batch not found"));
batch.setValue(newTotalValue);
batch.setCount(allItems.size());
groupBatchRepository.save(batch);
}
return providerErrors; return providerErrors;
} }

View File

@@ -0,0 +1,347 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.PartyType;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.repositories.TransactionRepository;
import jakarta.persistence.criteria.Predicate;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TransactionReportService {
private static final String REPORT_FILENAME_PREFIX = "transaction-report-";
private static final String REPORT_FILENAME_SUFFIX = ".xlsx";
private static final short DATA_FONT_SIZE = 12;
private static final short HEADER_FONT_SIZE = 14;
private static final short TITLE_FONT_SIZE = 16;
@Value("${app.reports.storage-path:./reports}")
private String reportsStoragePath;
private final TransactionRepository transactionRepository;
/**
* Generates a transaction report filtered by date range and party type.
* The default filter restricts results to transactions where partyType is
* either INDIVIDUAL or GROUP (excluding BATCH).
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @return list of transactions matching the criteria
*/
public List<Transaction> generateReport(LocalDateTime startDate, LocalDateTime endDate) {
return generateReport(startDate, endDate, null);
}
/**
* Generates a transaction report filtered by date range, party type, and an
* optional workspace ID.
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @param workspaceId optional workspace ID to further filter transactions
* @return list of transactions matching the criteria
*/
public List<Transaction> generateReport(LocalDateTime startDate, LocalDateTime endDate,
String workspaceId) {
Specification<Transaction> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// Default filter: partyType must be INDIVIDUAL or GROUP
predicates.add(root.get("partyType").in(PartyType.INDIVIDUAL, PartyType.GROUP));
// Date range filter on createdAt
if (startDate != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
}
if (endDate != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
}
// Optional workspace filter
if (workspaceId != null && !workspaceId.isEmpty()) {
predicates.add(cb.equal(root.get("workspaceId").as(String.class), workspaceId));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
return transactionRepository.findAll(spec);
}
/**
* Generates a transaction report and returns a summary with counts and totals.
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @return a report summary containing transactions and aggregated data
*/
public TransactionReportSummary generateReportSummary(LocalDateTime startDate, LocalDateTime endDate) {
return generateReportSummary(startDate, endDate, null);
}
/**
* Generates a transaction report summary with an optional workspace filter.
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @param workspaceId optional workspace ID to further filter transactions
* @return a report summary containing transactions and aggregated data
*/
public TransactionReportSummary generateReportSummary(LocalDateTime startDate, LocalDateTime endDate,
String workspaceId) {
List<Transaction> transactions = generateReport(startDate, endDate, workspaceId);
int totalCount = transactions.size();
int individualCount = 0;
int groupCount = 0;
for (Transaction txn : transactions) {
if (txn.getPartyType() == PartyType.INDIVIDUAL) {
individualCount++;
} else if (txn.getPartyType() == PartyType.GROUP) {
groupCount++;
}
}
return new TransactionReportSummary(
transactions,
totalCount,
individualCount,
groupCount,
startDate,
endDate
);
}
/**
* Generates an Excel workbook from a transaction report summary and returns the bytes.
*
* @param summary the report summary with transaction data
* @return byte array of the Excel file
*/
public byte[] generateExcelBytes(TransactionReportSummary summary) {
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
try (Workbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("Transaction Report");
// Styles
CellStyle titleStyle = workbook.createCellStyle();
Font titleFont = workbook.createFont();
titleFont.setBold(true);
titleFont.setFontHeightInPoints(TITLE_FONT_SIZE);
titleStyle.setFont(titleFont);
titleStyle.setAlignment(HorizontalAlignment.CENTER);
titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle headerStyle = workbook.createCellStyle();
headerStyle.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
Font headerFont = workbook.createFont();
headerFont.setBold(true);
headerFont.setFontHeightInPoints(HEADER_FONT_SIZE);
headerFont.setColor(IndexedColors.WHITE.getIndex());
headerStyle.setFont(headerFont);
headerStyle.setAlignment(HorizontalAlignment.CENTER);
headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle dataStyle = workbook.createCellStyle();
Font dataFont = workbook.createFont();
dataFont.setFontHeightInPoints(DATA_FONT_SIZE);
dataStyle.setFont(dataFont);
dataStyle.setAlignment(HorizontalAlignment.CENTER);
dataStyle.setVerticalAlignment(VerticalAlignment.CENTER);
dataStyle.setBorderTop(BorderStyle.THIN);
dataStyle.setBorderBottom(BorderStyle.THIN);
dataStyle.setBorderLeft(BorderStyle.THIN);
dataStyle.setBorderRight(BorderStyle.THIN);
CellStyle currencyStyle = workbook.createCellStyle();
currencyStyle.cloneStyleFrom(dataStyle);
currencyStyle.setDataFormat(workbook.createDataFormat().getFormat("#,##0.00"));
CellStyle summaryLabelStyle = workbook.createCellStyle();
summaryLabelStyle.setFont(dataFont);
summaryLabelStyle.setAlignment(HorizontalAlignment.RIGHT);
summaryLabelStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle summaryValueStyle = workbook.createCellStyle();
Font summaryValueFont = workbook.createFont();
summaryValueFont.setBold(true);
summaryValueFont.setFontHeightInPoints(DATA_FONT_SIZE);
summaryValueStyle.setFont(summaryValueFont);
summaryValueStyle.setAlignment(HorizontalAlignment.LEFT);
summaryValueStyle.setVerticalAlignment(VerticalAlignment.CENTER);
int rowNum = 0;
// Title row
Row titleRow = sheet.createRow(rowNum++);
Cell titleCell = titleRow.createCell(0);
String title = String.format("Transaction Report (%s - %s)",
summary.startDate().format(dateFormatter),
summary.endDate().format(dateFormatter));
titleCell.setCellValue(title);
titleCell.setCellStyle(titleStyle);
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 11));
titleRow.setHeightInPoints(30);
rowNum++;
// Summary rows
writeSummaryRow(sheet, rowNum++, "Total Transactions:", String.valueOf(summary.totalCount()), summaryLabelStyle, summaryValueStyle);
writeSummaryRow(sheet, rowNum++, "INDIVIDUAL:", String.valueOf(summary.individualCount()), summaryLabelStyle, summaryValueStyle);
writeSummaryRow(sheet, rowNum++, "GROUP:", String.valueOf(summary.groupCount()), summaryLabelStyle, summaryValueStyle);
rowNum++;
// Header row
String[] headers = {
"#", "Reference", "Party Type", "Amount", "Charge",
"Total Amount", "Currency", "Payment Status", "Integration Status", "Payment Processor Ref",
"Username", "Credit Phone",
"Velocity Ref", "Credit Name", "Provider", "Error Message", "Date", "Batch Name"
};
Row headerRow = sheet.createRow(rowNum++);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
headerRow.setHeightInPoints(25);
// Data rows
int index = 1;
for (Transaction txn : summary.transactions()) {
Row row = sheet.createRow(rowNum++);
writeCell(row, 0, index++, dataStyle);
writeCell(row, 1, txn.getReference() != null ? txn.getReference() : "", dataStyle);
writeCell(row, 2, txn.getPartyType() != null ? txn.getPartyType().name() : "", dataStyle);
writeCellAmount(row, 3, txn.getAmount(), currencyStyle);
writeCellAmount(row, 4, txn.getGatewayCharge(), currencyStyle);
writeCellAmount(row, 5, txn.getTotalAmount(), currencyStyle);
writeCell(row, 6, txn.getDebitCurrency().name(), dataStyle);
writeCell(row, 7, txn.getPollingStatus() != null ? txn.getPollingStatus().name() : "", dataStyle);
writeCell(row, 8, txn.getIntegrationStatus() != null ? txn.getIntegrationStatus().name() : "", dataStyle);
writeCell(row, 9, txn.getPaymentProcessorRef() != null ? txn.getPaymentProcessorRef() : "", dataStyle);
writeCell(row, 10, txn.getUsername() != null ? txn.getUsername() : txn.getDebitPhone(), dataStyle);
writeCell(row, 11, txn.getCreditPhone() != null ? txn.getCreditPhone() : "", dataStyle);
writeCell(row, 12, txn.getErpSalesPaymentRef() != null ? txn.getErpSalesPaymentRef() : "", dataStyle);
writeCell(row, 13, txn.getCreditName() != null ? txn.getCreditName() : "", dataStyle);
writeCell(row, 14, txn.getProviderLabel() != null ? txn.getProviderLabel() : "", dataStyle);
writeCell(row, 15, txn.getErrorMessage() != null ? txn.getErrorMessage() : "", dataStyle);
writeCell(row, 16, txn.getCreatedAt() != null ? txn.getCreatedAt().format(dateFormatter) : "", dataStyle);
writeCell(row, 17, txn.getBatchDescription() != null ? txn.getBatchDescription() : "", dataStyle);
}
// Auto-size columns
for (int i = 0; i < headers.length; i++) {
sheet.autoSizeColumn(i);
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException e) {
throw new RuntimeException("Failed to generate transaction report Excel file", e);
}
}
/**
* Generates a transaction report Excel file, saves it to disk, and returns the file URL.
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @param workspaceId optional workspace ID filter
* @return result containing the file URL and byte contents
*/
public TransactionReportFileResult generateReportFile(LocalDateTime startDate, LocalDateTime endDate,
String workspaceId) {
TransactionReportSummary summary = generateReportSummary(startDate, endDate, workspaceId);
byte[] fileBytes = generateExcelBytes(summary);
String datePart = startDate.format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
String filename = REPORT_FILENAME_PREFIX + datePart + REPORT_FILENAME_SUFFIX;
java.nio.file.Path reportsDir = java.nio.file.Path.of(reportsStoragePath).toAbsolutePath();
java.nio.file.Path filePath = reportsDir.resolve(filename);
try {
java.nio.file.Files.createDirectories(reportsDir);
try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(filePath)) {
os.write(fileBytes);
}
} catch (IOException e) {
throw new RuntimeException("Failed to persist transaction report file", e);
}
return new TransactionReportFileResult(filename, fileBytes);
}
private void writeSummaryRow(Sheet sheet, int rowNum, String label, String value,
CellStyle labelStyle, CellStyle valueStyle) {
Row row = sheet.createRow(rowNum);
Cell labelCell = row.createCell(0);
labelCell.setCellValue(label);
labelCell.setCellStyle(labelStyle);
Cell valueCell = row.createCell(1);
valueCell.setCellValue(value);
valueCell.setCellStyle(valueStyle);
}
private void writeCell(Row row, int col, String value, CellStyle style) {
Cell cell = row.createCell(col);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private void writeCell(Row row, int col, int value, CellStyle style) {
Cell cell = row.createCell(col);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private void writeCellAmount(Row row, int col, java.math.BigDecimal value, CellStyle style) {
Cell cell = row.createCell(col);
cell.setCellValue(value != null ? value.doubleValue() : 0.0);
cell.setCellStyle(style);
}
/**
* Result of a transaction report file generation request.
*/
public record TransactionReportFileResult(
String filename,
byte[] fileBytes
) {}
/**
* Summary of a transaction report.
*/
public record TransactionReportSummary(
List<Transaction> transactions,
int totalCount,
int individualCount,
int groupCount,
LocalDateTime startDate,
LocalDateTime endDate
) {}
}

View File

@@ -51,7 +51,7 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter
if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount(), "3")) { if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount(), "3")) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("98"); transaction.setResponseCode("98");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later."); transaction.setErrorMessage("We can't process that transaction right now. Please try again later.");
return transaction; return transaction;
} }
} }

View File

@@ -75,7 +75,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
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");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later."); transaction.setErrorMessage("We can't process that transaction right now. Please try again later.");
return transaction; return transaction;
} }

View File

@@ -122,6 +122,11 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
private LinkedHashMap<String, Object> performRecharge(Transaction transaction) { private LinkedHashMap<String, Object> performRecharge(Transaction transaction) {
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){ if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
try {
Thread.sleep(3);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
logger.info("Simulating HotRecharge success"); logger.info("Simulating HotRecharge success");
return Utils.fromJson("{\n" + return Utils.fromJson("{\n" +
" \"successful\": true,\n" + " \"successful\": true,\n" +

View File

@@ -6,13 +6,17 @@ import com.google.i18n.phonenumbers.Phonenumber;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.PartyType; import zw.qantra.tm.domain.enums.PartyType;
import zw.qantra.tm.domain.enums.RequestType; import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Provider; import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.models.User;
import zw.qantra.tm.domain.services.ProviderService; import zw.qantra.tm.domain.services.ProviderService;
import zw.qantra.tm.domain.services.UserService;
import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
@@ -24,6 +28,7 @@ public class CleanupHandler implements HandlerInterface {
private final Logger logger = LoggerFactory.getLogger(CleanupHandler.class); private final Logger logger = LoggerFactory.getLogger(CleanupHandler.class);
private final ProviderService providerService; private final ProviderService providerService;
private final UserService userService;
@Override @Override
public Object process(Transaction transaction) throws Exception { public Object process(Transaction transaction) throws Exception {
@@ -39,6 +44,11 @@ public class CleanupHandler implements HandlerInterface {
transaction.setPartyType(PartyType.INDIVIDUAL); transaction.setPartyType(PartyType.INDIVIDUAL);
} }
if(transaction.getUsername() == null) {
userService.getUserRepository().findById(UUID.fromString(transaction.getUserId()))
.ifPresent(user -> transaction.setUsername(user.getUsername()));
}
// UPDATE TRAN WITH CONFIRMATION HANDLER // UPDATE TRAN WITH CONFIRMATION HANDLER
// ======================= // =======================
if(transaction.getType().equals(RequestType.CONFIRM)) { if(transaction.getType().equals(RequestType.CONFIRM)) {

View File

@@ -3,6 +3,7 @@ package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.PaymentProcessor; import zw.qantra.tm.domain.models.PaymentProcessor;
import zw.qantra.tm.domain.repositories.PaymentProcessorRepository; import zw.qantra.tm.domain.repositories.PaymentProcessorRepository;
@@ -33,6 +34,18 @@ public class PaymentProcessorSeeder implements Seeder {
.accountFieldLabel("Wallet Phone Number") .accountFieldLabel("Wallet Phone Number")
.description("Pay using your CityWallet") .description("Pay using your CityWallet")
.authType("REMOTE") .authType("REMOTE")
.currency(CurrencyType.USD)
.build(),
PaymentProcessor.builder()
.name("CityWallet")
.label("WALLET")
.type("GATEWAY")
.image("city.png")
.accountFieldName("phoneNumber")
.accountFieldLabel("Wallet Phone Number")
.description("Pay using your CityWallet")
.authType("REMOTE")
.currency(CurrencyType.ZWG)
.build(), .build(),
PaymentProcessor.builder() PaymentProcessor.builder()
.name("Ecocash") .name("Ecocash")
@@ -43,6 +56,7 @@ public class PaymentProcessorSeeder implements Seeder {
.accountFieldLabel("EcoCash Phone Number") .accountFieldLabel("EcoCash Phone Number")
.description("Pay using your mobile wallet") .description("Pay using your mobile wallet")
.authType("REMOTE") .authType("REMOTE")
.currency(CurrencyType.USD)
.build(), .build(),
PaymentProcessor.builder() PaymentProcessor.builder()
.name("Visa/MasterCard") .name("Visa/MasterCard")
@@ -53,11 +67,12 @@ public class PaymentProcessorSeeder implements Seeder {
.accountFieldLabel("Card Number") .accountFieldLabel("Card Number")
.description("Pay using your international card") .description("Pay using your international card")
.authType("WEB") .authType("WEB")
.currency(CurrencyType.USD)
.build() .build()
); );
for (PaymentProcessor processor : processors) { for (PaymentProcessor processor : processors) {
if (paymentProcessorRepository.existsByLabel(processor.getLabel())) { if (paymentProcessorRepository.existsByLabelAndCurrency(processor.getLabel(), processor.getCurrency())) {
log.info("PaymentProcessor '{}' already exists, skipping.", processor.getLabel()); log.info("PaymentProcessor '{}' already exists, skipping.", processor.getLabel());
} else { } else {
paymentProcessorRepository.save(processor); paymentProcessorRepository.save(processor);

View File

@@ -1,26 +1,23 @@
<?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" id="convert-party-type-to-string"> <changeSet author="khoza (generated)" id="1782340507017-3">
<comment>Convert party_type column from integer ordinal to string enum values to match @Enumerated(EnumType.STRING) in Transaction.java</comment>
<!-- Add a temporary VARCHAR column -->
<addColumn tableName="transaction"> <addColumn tableName="transaction">
<column name="party_type_str" type="VARCHAR(20)"> <column name="batch_description" type="varchar(255)"/>
<constraints nullable="true"/> </addColumn>
</column> </changeSet>
<changeSet author="khoza (generated)" id="1782340507017-4">
<addColumn tableName="group_batch_item">
<column name="credit_address" type="varchar(255)"/>
</addColumn>
</changeSet>
<changeSet author="khoza (generated)" id="1782340507017-5">
<addColumn tableName="payment_processor">
<column name="currency" type="varchar(255)"/>
</addColumn>
</changeSet>
<changeSet author="khoza (generated)" id="1782340507017-6">
<addColumn tableName="transaction">
<column name="username" type="varchar(255)"/>
</addColumn> </addColumn>
<!-- Convert existing ordinal integer values (0=INDIVIDUAL, 1=GROUP, 2=BATCH) to strings -->
<sql>
UPDATE transaction SET party_type_str = 'INDIVIDUAL' WHERE party_type = 0 OR party_type = '0';
UPDATE transaction SET party_type_str = 'GROUP' WHERE party_type = 1 OR party_type = '1';
UPDATE transaction SET party_type_str = 'BATCH' WHERE party_type = 2 OR party_type = '2';
</sql>
<!-- Drop old integer column -->
<dropColumn tableName="transaction" columnName="party_type"/>
<!-- Rename temp column to original name -->
<renameColumn tableName="transaction" oldColumnName="party_type_str" newColumnName="party_type"/>
</changeSet> </changeSet>
</databaseChangeLog> </databaseChangeLog>