diff --git a/src/main/java/zw/qantra/tm/configs/SecurityConfig.java b/src/main/java/zw/qantra/tm/configs/SecurityConfig.java index 1630a94..416e12a 100644 --- a/src/main/java/zw/qantra/tm/configs/SecurityConfig.java +++ b/src/main/java/zw/qantra/tm/configs/SecurityConfig.java @@ -35,7 +35,8 @@ public class SecurityConfig { .requestMatchers("/test/**").permitAll() .requestMatchers("/nflow/**").permitAll() .requestMatchers("/auth/**").permitAll() - .requestMatchers("/public/**").permitAll() + .requestMatchers("/public/**").permitAll() + .requestMatchers("/explorer/**").permitAll() .anyRequest().authenticated() ) .authenticationProvider(authenticationProvider(userService)) diff --git a/src/main/java/zw/qantra/tm/domain/controllers/PaymentProcessorController.java b/src/main/java/zw/qantra/tm/domain/controllers/PaymentProcessorController.java index c15afb8..fe6a1fb 100644 --- a/src/main/java/zw/qantra/tm/domain/controllers/PaymentProcessorController.java +++ b/src/main/java/zw/qantra/tm/domain/controllers/PaymentProcessorController.java @@ -1,6 +1,12 @@ package zw.qantra.tm.domain.controllers; import lombok.RequiredArgsConstructor; +import net.kaczmarzyk.spring.data.jpa.domain.Equal; +import net.kaczmarzyk.spring.data.jpa.web.annotation.And; +import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import zw.qantra.tm.domain.models.PaymentProcessor; @@ -18,8 +24,12 @@ public class PaymentProcessorController { private final PaymentProcessorService paymentProcessorService; @GetMapping - public ResponseEntity> getAllPaymentProcessors() { - return ResponseEntity.ok(paymentProcessorService.getPaymentProcessorRepository().findAll()); + public ResponseEntity getAllPaymentProcessors( + @And({ + @Spec(path = "label", spec = Equal.class), + @Spec(path = "currency", spec = Equal.class), + })Specification specification, Pageable pageable) { + return ResponseEntity.ok(paymentProcessorService.getAllPaymentProcessors(specification, pageable)); } @GetMapping("/{id}") diff --git a/src/main/java/zw/qantra/tm/domain/controllers/TransactionReportController.java b/src/main/java/zw/qantra/tm/domain/controllers/TransactionReportController.java new file mode 100644 index 0000000..fc1ea05 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/controllers/TransactionReportController.java @@ -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> 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 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 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 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); + } +} 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 11d06a0..fec4aad 100644 --- a/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java +++ b/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java @@ -65,5 +65,6 @@ public class GroupBatchItem extends BaseEntity { private String billProductName; private String providerImage; private String providerLabel; + private String creditAddress; } diff --git a/src/main/java/zw/qantra/tm/domain/models/PaymentProcessor.java b/src/main/java/zw/qantra/tm/domain/models/PaymentProcessor.java index 6cda3c9..a05c3c5 100644 --- a/src/main/java/zw/qantra/tm/domain/models/PaymentProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/models/PaymentProcessor.java @@ -3,6 +3,7 @@ package zw.qantra.tm.domain.models; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.SQLRestriction; +import zw.qantra.tm.domain.enums.CurrencyType; @Entity @Table(name = "payment_processor", indexes = { @@ -21,6 +22,8 @@ public class PaymentProcessor extends BaseEntity { private String type; @Column(columnDefinition = "TEXT") private String image; + @Enumerated(EnumType.STRING) + private CurrencyType currency; private String accountFieldName; private String accountFieldLabel; private String authType; diff --git a/src/main/java/zw/qantra/tm/domain/models/Transaction.java b/src/main/java/zw/qantra/tm/domain/models/Transaction.java index 37e132b..02e9e46 100644 --- a/src/main/java/zw/qantra/tm/domain/models/Transaction.java +++ b/src/main/java/zw/qantra/tm/domain/models/Transaction.java @@ -37,6 +37,7 @@ public class Transaction extends BaseEntity { private UUID workspaceId; @Column(name = "user_id") private String userId; + private String username; private String trace; private String region; @Enumerated(EnumType.STRING) @@ -87,6 +88,7 @@ public class Transaction extends BaseEntity { private String providerImage; private String paymentProcessorImage; private UUID batchId; + private String batchDescription; @Enumerated(EnumType.STRING) private Status authorizationStatus; @@ -105,7 +107,6 @@ public class Transaction extends BaseEntity { @Column(name = "polling_status") private Status pollingStatus; - private String orderId; private String orderTrace; private String orderTransactionTrace; diff --git a/src/main/java/zw/qantra/tm/domain/repositories/PaymentProcessorRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/PaymentProcessorRepository.java index 521eb50..57d0547 100644 --- a/src/main/java/zw/qantra/tm/domain/repositories/PaymentProcessorRepository.java +++ b/src/main/java/zw/qantra/tm/domain/repositories/PaymentProcessorRepository.java @@ -1,12 +1,16 @@ package zw.qantra.tm.domain.repositories; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; +import zw.qantra.tm.domain.enums.CurrencyType; import zw.qantra.tm.domain.models.PaymentProcessor; import java.util.UUID; @Repository -public interface PaymentProcessorRepository extends JpaRepository { +public interface PaymentProcessorRepository extends JpaRepository, + JpaSpecificationExecutor { boolean existsByLabel(String label); + boolean existsByLabelAndCurrency(String label, CurrencyType currencyType); } \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/services/GroupBatchWorkflowService.java b/src/main/java/zw/qantra/tm/domain/services/GroupBatchWorkflowService.java index a4879d3..db1bb13 100644 --- a/src/main/java/zw/qantra/tm/domain/services/GroupBatchWorkflowService.java +++ b/src/main/java/zw/qantra/tm/domain/services/GroupBatchWorkflowService.java @@ -119,6 +119,7 @@ public class GroupBatchWorkflowService { transaction.setPaymentProcessorName(batch.getPaymentProcessorName()); transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage()); transaction.setBatchId(batch.getId()); + transaction.setBatchDescription(batch.getDescription()); transaction.setBillName("Batch - " + batch.getDescription()); transaction = calculateChargesHandler.process(transaction); @@ -225,6 +226,7 @@ public class GroupBatchWorkflowService { transaction.setProviderLabel(item.getProviderLabel()); transaction.setUserId(batch.getUserId()); + transaction.setUsername(user.getUsername()); transaction.setWorkspaceId(batch.getWorkspaceId()); transaction.setType(RequestType.CONFIRM); transaction.setPartyType(PartyType.BATCH); @@ -250,6 +252,8 @@ public class GroupBatchWorkflowService { mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.CONFIRMED); mutableItem.setStatus(GroupBatchItemStatus.CONFIRMED); mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.PENDING); + if(result.getCreditAddress() != null) + mutableItem.setCreditAddress(result.getCreditAddress()); mutableItem.setConfirmError(null); } else { mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED); diff --git a/src/main/java/zw/qantra/tm/domain/services/PaymentProcessorService.java b/src/main/java/zw/qantra/tm/domain/services/PaymentProcessorService.java index 0c8f473..e491038 100644 --- a/src/main/java/zw/qantra/tm/domain/services/PaymentProcessorService.java +++ b/src/main/java/zw/qantra/tm/domain/services/PaymentProcessorService.java @@ -1,7 +1,11 @@ package zw.qantra.tm.domain.services; 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 zw.qantra.tm.domain.models.PaymentProcessor; import zw.qantra.tm.domain.repositories.PaymentProcessorRepository; @Service @@ -12,4 +16,8 @@ public class PaymentProcessorService { public PaymentProcessorRepository getPaymentProcessorRepository() { return paymentProcessorRepository; } + + public Page getAllPaymentProcessors(Specification specification, Pageable pageable) { + return paymentProcessorRepository.findAll(specification, pageable); + } } \ No newline at end of file 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 6a8609c..9a2d65a 100644 --- a/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java +++ b/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java @@ -240,7 +240,18 @@ public class RecipientGroupService { item.setAmount(productDto.getDefaultAmount()); } 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) { + e.printStackTrace(); providerErrors.add(GroupCreateFromFileResult.RowError.builder() .lineNumber(update.row.lineNumber) .account(update.row.account) @@ -250,19 +261,6 @@ public class RecipientGroupService { } } - // Recalculate batch totals - if (!updatedItems.isEmpty()) { - List 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; } diff --git a/src/main/java/zw/qantra/tm/domain/services/TransactionReportService.java b/src/main/java/zw/qantra/tm/domain/services/TransactionReportService.java new file mode 100644 index 0000000..d136a55 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/TransactionReportService.java @@ -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 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 generateReport(LocalDateTime startDate, LocalDateTime endDate, + String workspaceId) { + Specification spec = (root, query, cb) -> { + List 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 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 transactions, + int totalCount, + int individualCount, + int groupCount, + LocalDateTime startDate, + LocalDateTime endDate + ) {} +} diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/NetoneConfirmationHotProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/NetoneConfirmationHotProcessor.java index d165537..362b1fb 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/NetoneConfirmationHotProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/NetoneConfirmationHotProcessor.java @@ -51,7 +51,7 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount(), "3")) { transaction.setStatus(Status.FAILED); 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; } } 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 4759862..2f27303 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 @@ -75,7 +75,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa if (!hotRechargeBalanceService.checkBalance(token, transaction.getCreditAmount(), accId)) { transaction.setStatus(Status.FAILED); 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; } 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 b2c6da2..090ff5d 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 @@ -122,6 +122,11 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte private LinkedHashMap performRecharge(Transaction transaction) { if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){ + try { + Thread.sleep(3); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } logger.info("Simulating HotRecharge success"); return Utils.fromJson("{\n" + " \"successful\": true,\n" + diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CleanupHandler.java b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CleanupHandler.java index d1c5189..1d4c054 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CleanupHandler.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CleanupHandler.java @@ -6,13 +6,17 @@ import com.google.i18n.phonenumbers.Phonenumber; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import zw.qantra.tm.domain.enums.PartyType; import zw.qantra.tm.domain.enums.RequestType; import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.models.Provider; 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.UserService; import zw.qantra.tm.exceptions.ApiException; 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 ProviderService providerService; + private final UserService userService; @Override public Object process(Transaction transaction) throws Exception { @@ -39,6 +44,11 @@ public class CleanupHandler implements HandlerInterface { 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 // ======================= if(transaction.getType().equals(RequestType.CONFIRM)) { diff --git a/src/main/java/zw/qantra/tm/seeders/PaymentProcessorSeeder.java b/src/main/java/zw/qantra/tm/seeders/PaymentProcessorSeeder.java index f2ba7b2..53f0b2c 100644 --- a/src/main/java/zw/qantra/tm/seeders/PaymentProcessorSeeder.java +++ b/src/main/java/zw/qantra/tm/seeders/PaymentProcessorSeeder.java @@ -3,6 +3,7 @@ package zw.qantra.tm.seeders; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import zw.qantra.tm.domain.enums.CurrencyType; import zw.qantra.tm.domain.models.PaymentProcessor; import zw.qantra.tm.domain.repositories.PaymentProcessorRepository; @@ -33,6 +34,18 @@ public class PaymentProcessorSeeder implements Seeder { .accountFieldLabel("Wallet Phone Number") .description("Pay using your CityWallet") .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(), PaymentProcessor.builder() .name("Ecocash") @@ -43,6 +56,7 @@ public class PaymentProcessorSeeder implements Seeder { .accountFieldLabel("EcoCash Phone Number") .description("Pay using your mobile wallet") .authType("REMOTE") + .currency(CurrencyType.USD) .build(), PaymentProcessor.builder() .name("Visa/MasterCard") @@ -53,11 +67,12 @@ public class PaymentProcessorSeeder implements Seeder { .accountFieldLabel("Card Number") .description("Pay using your international card") .authType("WEB") + .currency(CurrencyType.USD) .build() ); for (PaymentProcessor processor : processors) { - if (paymentProcessorRepository.existsByLabel(processor.getLabel())) { + if (paymentProcessorRepository.existsByLabelAndCurrency(processor.getLabel(), processor.getCurrency())) { log.info("PaymentProcessor '{}' already exists, skipping.", processor.getLabel()); } else { paymentProcessorRepository.save(processor); diff --git a/src/main/resources/liquibase-diff-changeLog.xml b/src/main/resources/liquibase-diff-changeLog.xml index 23a0a47..17c0bd9 100644 --- a/src/main/resources/liquibase-diff-changeLog.xml +++ b/src/main/resources/liquibase-diff-changeLog.xml @@ -1,26 +1,23 @@ - - Convert party_type column from integer ordinal to string enum values to match @Enumerated(EnumType.STRING) in Transaction.java - - + - - - + + + + + + + + + + + + + + + + - - - - 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'; - - - - - - -