added batch file upload feature

This commit is contained in:
Prince
2026-06-20 02:33:37 +02:00
parent dcb917e899
commit 4d0e18c1a6
26 changed files with 2263 additions and 104 deletions

View File

@@ -8,8 +8,12 @@ import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import org.hibernate.query.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import zw.qantra.tm.domain.dtos.GroupCreateFromFileRequest;
import zw.qantra.tm.domain.dtos.GroupCreateFromFileResult;
import zw.qantra.tm.domain.dtos.GroupMemberRequest;
import zw.qantra.tm.domain.dtos.RecipientGroupCreateRequest;
import zw.qantra.tm.domain.dtos.RecipientGroupUpdateRequest;
@@ -45,6 +49,24 @@ public class RecipientGroupController {
return ResponseEntity.ok(recipientGroupService.create(group, request.getRecipients()));
}
@PostMapping(value = "/create-from-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<GroupCreateFromFileResult> createFromFile(
@RequestParam("file") MultipartFile file,
@RequestParam("groupName") String groupName,
@RequestParam(value = "description", required = false) String description,
@RequestParam(value = "requestedBy", required = false) String requestedBy,
@RequestParam(value = "currency", required = false) String currency,
@RequestParam("userId") String userId) {
GroupCreateFromFileRequest request = new GroupCreateFromFileRequest();
request.setGroupName(groupName.trim());
request.setDescription(description.trim());
request.setRequestedBy(requestedBy);
request.setCurrency(currency);
return ResponseEntity.ok(recipientGroupService.createFromFile(file, request, userId));
}
@PutMapping("/update/{groupId}")
public ResponseEntity<RecipientGroup> update(@PathVariable UUID groupId,
@RequestBody RecipientGroupUpdateRequest request) {

View File

@@ -21,4 +21,3 @@ public class GroupBatchCreateRequest {
private String paymentProcessorImage;
private Transaction transactionTemplate;
}

View File

@@ -0,0 +1,22 @@
package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import zw.qantra.tm.domain.models.Transaction;
import java.math.BigDecimal;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupCreateFromFileRequest {
private String groupName;
private String groupDescription;
private String description;
private String requestedBy;
private String currency;
private BigDecimal amount;
private String paymentProcessorLabel;
private String paymentProcessorName;
private String paymentProcessorImage;
private Transaction transactionTemplate;
}

View File

@@ -0,0 +1,29 @@
package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import zw.qantra.tm.domain.models.GroupBatch;
import java.util.List;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupCreateFromFileResult {
private GroupBatch batch;
private int totalRows;
private int successCount;
private int errorCount;
private List<RowError> errors;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class RowError {
private int lineNumber;
private String account;
private String name;
private String message;
}
}

View File

@@ -14,7 +14,7 @@ import java.util.UUID;
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class SBZProductDto {
public class ProductDto {
private String name;
private String description;
private String displayName;

View File

@@ -72,6 +72,8 @@ public class Transaction extends BaseEntity {
private String creditCard;
private String creditRef;
private String creditEmail;
private String creditAddress;
private String creditVoucher;
private String billClientId;
private String clientSecret;
private String aggregatorId;

View File

@@ -3,6 +3,7 @@ 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.Provider;
import java.util.List;
@@ -12,5 +13,7 @@ import java.util.UUID;
public interface ProviderRepository extends JpaRepository<Provider, UUID>, JpaSpecificationExecutor<Provider> {
List<Provider> findByCategory(String category);
Provider findByClientId(String clientId);
Provider findByLabel(String label);
Provider findByLabelAndCurrency(String label, CurrencyType currency);
boolean existsByClientId(String clientId);
}

View File

@@ -121,7 +121,7 @@ public class BatchReportService {
Cell titleCell = titleRow.createCell(0);
titleCell.setCellValue("Batch Report - " + (batch.getDescription() != null ? batch.getDescription() : batch.getId().toString()));
titleCell.setCellStyle(titleStyle);
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 9));
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 11));
titleRow.setHeightInPoints(30);
rowNum++;
@@ -149,7 +149,8 @@ public class BatchReportService {
String[] headers = {
"#", "Recipient Name", "Phone", "Amount",
"Status", "Confirm Status", "Integration Status",
"Transaction Ref", "Confirm Error", "Integration Error"
"Transaction Ref", "Confirm Error", "Integration Error",
"Credit Address", "Credit Voucher"
};
Row headerRow = sheet.createRow(rowNum++);
@@ -214,6 +215,22 @@ public class BatchReportService {
Cell integrationErrCell = row.createCell(9);
integrationErrCell.setCellValue(item.getIntegrationError() != null ? item.getIntegrationError() : "");
integrationErrCell.setCellStyle(dataStyle);
Cell creditAddressCell = row.createCell(10);
if (item.getTransaction() != null && item.getTransaction().getCreditAddress() != null) {
creditAddressCell.setCellValue(item.getTransaction().getCreditAddress());
} else {
creditAddressCell.setCellValue("");
}
creditAddressCell.setCellStyle(dataStyle);
Cell creditVoucherCell = row.createCell(11);
if (item.getTransaction() != null && item.getTransaction().getCreditVoucher() != null) {
creditVoucherCell.setCellValue(item.getTransaction().getCreditVoucher());
} else {
creditVoucherCell.setCellValue("");
}
creditVoucherCell.setCellStyle(dataStyle);
}
// Auto-size columns

View File

@@ -4,6 +4,7 @@ import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.instance.WorkflowInstance;
import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jobrunr.jobs.context.JobRunrDashboardLogger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,10 +23,10 @@ import java.util.concurrent.CompletableFuture;
import static org.joda.time.DateTime.now;
@Slf4j
@Component
@RequiredArgsConstructor
public class JobService {
Logger log = new JobRunrDashboardLogger(LoggerFactory.getLogger(JobService.class));
private final WorkflowInstanceService workflowInstanceService;
private final WorkflowUtils workflowUtils;

View File

@@ -6,16 +6,13 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import zw.qantra.tm.domain.dtos.SBZProductDto;
import zw.qantra.tm.domain.dtos.ProductDto;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.repositories.ProviderRepository;
import zw.qantra.tm.exceptions.ApiException;
@@ -35,59 +32,33 @@ public class ProviderService {
private final RestService restService;
private final HotRechargeTokenService hotRechargeTokenService;
private Map<String, List<ProductDto>> providerProductsCache = new HashMap<>();
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
@Value("${sbz.aggregator.url}")
private String aggregatorUrl;
public SBZProductDto getProviderProduct(UUID providerId, String productName) {
List<SBZProductDto> products = getProviderProducts(providerId.toString());
SBZProductDto productResult = products.stream()
public ProductDto getProviderProduct(UUID providerId, String productName) {
List<ProductDto> products = getProviderProducts(providerId.toString());
ProductDto productResult = products.stream()
.filter(product -> product.getName().equals(productName))
.findFirst()
.orElse(null);
log.info("Product: {}", productResult);
return productResult;
}
@Deprecated
public List<SBZProductDto> getProviderProducts(UUID providerId) {
String token = restService.getMerchantToken();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
headers.setBearerAuth(token);
LinkedHashMap response;
public List<ProductDto> getProviderProducts(String providerUid) {
try {
response = this.restService.getAs(
aggregatorUrl + "/merchant/aggregation/products?size=20&clientUid=" + providerId.toString(),
headers,
LinkedHashMap.class
);
} catch (Exception e) {
log.error("Error fetching products for provider " + providerId, e);
return Collections.emptyList();
}
List<ProductDto> cachedProducts = providerProductsCache.get(providerUid);
if (cachedProducts != null) {
log.info("Returning cached products for provider: {}, number of products: {}", providerUid,
cachedProducts.size());
return cachedProducts;
}
LinkedHashMap body = (LinkedHashMap) response.get("body");
List<LinkedHashMap> content = (List<LinkedHashMap>) body.get("content");
List<SBZProductDto> products = new ArrayList<>();
for (LinkedHashMap product : content) {
ObjectMapper mapper = new ObjectMapper();
SBZProductDto productDto = mapper.convertValue(product, SBZProductDto.class);
products.add(productDto);
}
return products;
}
// todo: figure out caching for different providers
// @Cacheable(value = "providerProducts", key = "#providerUid")
public List<SBZProductDto> getProviderProducts(String providerUid) {
try {
Provider provider = providerRepository.findById(UUID.fromString(providerUid)).orElse(null);
if (provider == null) {
return Collections.emptyList();
@@ -113,25 +84,28 @@ public class ProviderService {
}
List stock = (List) response.get("stock");
List<SBZProductDto> products = new ArrayList<>();
List<ProductDto> products = new ArrayList<>();
for (Object item : stock) {
LinkedHashMap<String, Object> stockItem = (LinkedHashMap<String, Object>) item;
int stockProviderId = (int) stockItem.get("productId"); // providerId is referred to as productId in the stock response
if (String.valueOf(stockProviderId).equals(providerId)) {
log.info("Found matching provider in stock: " + stockItem);
SBZProductDto sbzProductDto = new SBZProductDto();
sbzProductDto.setName((String) stockItem.get("name"));
sbzProductDto.setDescription((String) stockItem.get("description"));
sbzProductDto.setDisplayName((String) stockItem.get("name"));
sbzProductDto.setDefaultAmount(new BigDecimal(stockItem.get("amount").toString()));
sbzProductDto.setRequiresAmount(true); // always true for now
sbzProductDto.setUid(UUID.fromString(Utils.getUid()));
sbzProductDto.setExternalId((String) stockItem.get("productCode"));
products.add(sbzProductDto);
ProductDto productDto = new ProductDto();
productDto.setName((String) stockItem.get("name"));
productDto.setDescription((String) stockItem.get("description"));
productDto.setDisplayName((String) stockItem.get("name"));
productDto.setDefaultAmount(new BigDecimal(stockItem.get("amount").toString()));
productDto.setRequiresAmount(true); // always true for now
productDto.setUid(UUID.fromString(Utils.getUid()));
productDto.setExternalId((String) stockItem.get("productCode"));
products.add(productDto);
}
}
providerProductsCache.put(providerUid, products);
log.info("Cached products for provider: {}, number of products: {}", providerUid, products.size());
log.info("Provider products cache size: {}", providerProductsCache.size());
return products;
} catch (IllegalArgumentException e) {
log.error("Error fetching products: {}", providerUid);
@@ -152,11 +126,11 @@ public class ProviderService {
public String getExternalId(String providerId, String productName) {
try {
List<SBZProductDto> products = getProviderProducts(providerId);
List<ProductDto> products = getProviderProducts(providerId);
return products.stream()
.filter(product -> product.getName().equals(productName))
.map(SBZProductDto::getExternalId)
.map(ProductDto::getExternalId)
.findFirst()
.orElse(null);
} catch (Exception e) {

View File

@@ -6,14 +6,25 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.models.Recipient;
import zw.qantra.tm.domain.models.RecipientGroup;
import zw.qantra.tm.domain.models.RecipientGroupMember;
import org.springframework.web.multipart.MultipartFile;
import zw.qantra.tm.domain.dtos.GroupCreateFromFileRequest;
import zw.qantra.tm.domain.dtos.GroupCreateFromFileResult;
import zw.qantra.tm.domain.dtos.ProductDto;
import zw.qantra.tm.domain.enums.*;
import zw.qantra.tm.domain.models.*;
import zw.qantra.tm.domain.repositories.GroupBatchItemRepository;
import zw.qantra.tm.domain.repositories.GroupBatchRepository;
import zw.qantra.tm.domain.repositories.RecipientGroupMemberRepository;
import zw.qantra.tm.domain.repositories.RecipientGroupRepository;
import zw.qantra.tm.domain.repositories.RecipientRepository;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -24,6 +35,347 @@ public class RecipientGroupService {
private final RecipientGroupRepository recipientGroupRepository;
private final RecipientGroupMemberRepository recipientGroupMemberRepository;
private final RecipientRepository recipientRepository;
private final GroupBatchRepository groupBatchRepository;
private final GroupBatchItemRepository groupBatchItemRepository;
private final ProviderService providerService;
/**
* Creates a recipient group, creates a batch from it, then updates batch items
* with bill/provider fields from the CSV file, and returns the new batch
* along with any per-row errors.
*
* CSV file structure: name, account, phone, billName, billProductName
*
* Steps:
* 1. Parse CSV rows, collecting per-row validation errors
* 2. Create recipient group from all valid rows
* 3. Create group batch from the group
* 4. Update batch items with billName & billProductName from CSV, skipping rows
* where the provider cannot be resolved and recording those errors
* 5. Return the batch + any row-level errors
*/
@Transactional
public GroupCreateFromFileResult createFromFile(MultipartFile file, GroupCreateFromFileRequest request, String userId) {
ParseResult parseResult = parseCsv(file);
if(!parseResult.parseErrors.isEmpty()) {
return GroupCreateFromFileResult.builder()
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.errorCount(parseResult.parseErrors.size())
.errors(parseResult.parseErrors)
.build();
}
if (parseResult.rows.isEmpty()) {
throw new ApiException("CSV file has no valid data rows after validation");
}
// Step 1: build recipient list from valid CSV rows only
List<Recipient> recipients = new ArrayList<>();
for (CsvRow row : parseResult.rows) {
Recipient recipient = Recipient.builder()
.name(row.name)
.account(row.account)
.phoneNumber(row.phone)
.userId(userId)
.build();
recipients.add(recipient);
}
// Step 2: create recipient group with recipients
RecipientGroup group = RecipientGroup.builder()
.name(request.getGroupName())
.description(request.getDescription())
.userId(userId)
.build();
RecipientGroup savedGroup = recipientGroupRepository.save(group);
List<RecipientGroupMember> members = addMembers(savedGroup.getId(), userId, recipients);
// Step 3: create group batch (inline to avoid circular dependency with GroupBatchService)
GroupBatch batch = createBatchInline(savedGroup.getId(), userId, request, members);
// Step 4: update batch items with bill/provider fields from CSV
List<GroupBatchItem> batchItems = groupBatchItemRepository.findAllByGroupBatchId(batch.getId());
List<GroupBatchItemUpdate> itemUpdates = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> rowErrors = new ArrayList<>(parseResult.parseErrors);
for (int i = 0; i < batchItems.size() && i < parseResult.rows.size(); i++) {
GroupBatchItem batchItem = batchItems.get(i);
CsvRow row = parseResult.rows.get(i);
// Attempt to resolve provider by label
Provider provider = providerService.getProviderRepository().findByLabelAndCurrency(
row.billLabel, CurrencyType.valueOf(request.getCurrency()));
if (provider == null) {
rowErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(row.lineNumber)
.account(row.account)
.name(row.name)
.message("Provider not found for bill label: " + row.billLabel)
.build());
// Skip this row - item remains with default values from batch creation
continue;
}
itemUpdates.add(new GroupBatchItemUpdate(batchItem.getId(), row, provider));
}
// Apply successful item updates directly
if (!itemUpdates.isEmpty()) {
List<GroupCreateFromFileResult.RowError> providerErrors = updateBatchItemsInline(batch.getId(), itemUpdates);
if (!providerErrors.isEmpty()) {
return GroupCreateFromFileResult.builder()
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.errorCount(providerErrors.size())
.errors(providerErrors)
.build();
}
}
// Return the updated batch + errors
GroupBatch resultBatch = groupBatchRepository.findByIdAndUserId(batch.getId(), userId)
.orElseThrow(() -> new ApiException("Batch not found after creation"));
return GroupCreateFromFileResult.builder()
.batch(resultBatch)
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.successCount(itemUpdates.size())
.errorCount(rowErrors.size())
.errors(rowErrors)
.build();
}
/**
* Create a GroupBatch and its items directly (mirrors GroupBatchService.createBatch logic
* but without circular dependency).
*/
private GroupBatch createBatchInline(UUID recipientGroupId, String userId,
GroupCreateFromFileRequest request,
List<RecipientGroupMember> members) {
GroupBatch batch = GroupBatch.builder()
.recipientGroupId(recipientGroupId)
.userId(userId)
.description(request.getDescription())
.requestedBy(request.getRequestedBy())
.currency(request.getCurrency())
.status(GroupBatchStatus.CREATED)
.transactionTemplate(Utils.toJson(request.getTransactionTemplate()))
.build();
GroupBatch savedBatch = groupBatchRepository.save(batch);
int itemCount = 0;
for (RecipientGroupMember member : members) {
Recipient recipient = recipientRepository.findById(member.getRecipientUid())
.orElseThrow(() -> new ApiException("Recipient not found: " + member.getRecipientUid()));
GroupBatchItem item = GroupBatchItem.builder()
.groupBatchId(savedBatch.getId())
.recipientId(recipient.getId())
.recipientName(recipient.getName())
.recipientPhone(recipient.getPhoneNumber())
.status(GroupBatchItemStatus.NEW)
.confirmStatus(GroupBatchItemConfirmStatus.PENDING)
.integrationStatus(GroupBatchItemIntegrationStatus.PENDING)
.build();
itemCount++;
groupBatchItemRepository.save(item);
}
savedBatch.setCount(itemCount);
savedBatch.setValue(BigDecimal.ZERO);
savedBatch.setSuccessfulCount(0);
savedBatch.setFailedCount(0);
return groupBatchRepository.save(savedBatch);
}
/**
* Update batch items with CSV data directly (mirrors GroupBatchService.updateBatchItems logic
* but without circular dependency).
*/
private List<GroupCreateFromFileResult.RowError> updateBatchItemsInline(UUID batchId, List<GroupBatchItemUpdate> updates) {
List<GroupBatchItem> existingItems = groupBatchItemRepository.findAllByGroupBatchId(batchId);
java.util.Map<UUID, GroupBatchItem> itemMap = new java.util.HashMap<>();
for (GroupBatchItem item : existingItems) {
itemMap.put(item.getId(), item);
}
List<GroupBatchItem> updatedItems = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> providerErrors = new ArrayList<>();
for (GroupBatchItemUpdate update : updates) {
GroupBatchItem item = itemMap.get(update.batchItemId);
if (item == null) {
continue;
}
try {
item.setRecipientName(update.row.name);
item.setRecipientPhone(update.row.phone);
item.setBillName(update.provider.getName());
item.setBillProductName(update.row.billProductName);
item.setProviderLabel(update.row.billLabel);
item.setProviderImage(update.provider.getImage());
item.setBillClientId(update.provider.getClientId());
if(update.row.billProductName.isBlank() && update.row.amount.compareTo(BigDecimal.ZERO) != 0) {
item.setAmount(update.row.amount);
} else if (!update.row.billProductName.isBlank()) {
ProductDto productDto = providerService.getProviderProduct(
update.provider.getId(), update.row.billProductName);
if( productDto == null) {
throw new ApiException("Product not found for provider: " + update.provider.getName() +
", product name: " + update.row.billProductName);
}
item.setAmount(productDto.getDefaultAmount());
}
updatedItems.add(groupBatchItemRepository.save(item));
} catch (Exception e) {
providerErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(update.row.lineNumber)
.account(update.row.account)
.name(update.row.name)
.message("Failed to update batch item: " + e.getMessage())
.build());
}
}
// 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;
}
private record GroupBatchItemUpdate(UUID batchItemId, CsvRow row, Provider provider) {}
/**
* Parse CSV rows, collecting validation errors per row rather than failing fast.
*/
private ParseResult parseCsv(MultipartFile file) {
List<CsvRow> rows = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> parseErrors = new ArrayList<>();
try (Reader reader = new InputStreamReader(file.getInputStream());
BufferedReader br = new BufferedReader(reader)) {
String headerLine = br.readLine(); // skip header
if (headerLine == null) {
throw new ApiException("CSV file is empty");
}
String line;
int lineNum = 1;
while ((line = br.readLine()) != null) {
lineNum++;
if (line.isBlank()) {
continue;
}
String[] parts = parseCsvLine(line);
if (parts.length < 5) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.message("Invalid CSV format: expected at least 5 columns (name, account, phone, billName, billProductName) but got " + parts.length)
.build());
continue;
}
String name = parts[0].trim();
String account = parts[1].trim();
String phone = parts[2].trim();
String billLabel = parts[3].trim();
String billProductName = parts[4].trim();
String amount = parts[5].trim();
if (name.isEmpty() || account.isEmpty()) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.account(account)
.name(name)
.message("name and account are required")
.build());
continue;
}
if(amount.isEmpty() && billProductName.isEmpty()) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.account(account)
.name(name)
.message("amount or billProductName is required")
.build());
continue;
}
CsvRow row = new CsvRow();
row.lineNumber = lineNum;
row.name = name;
row.account = account;
row.phone = phone;
row.billLabel = billLabel;
row.billProductName = billProductName;
row.amount = !amount.isEmpty() ? new BigDecimal(amount) : BigDecimal.ZERO;
rows.add(row);
}
if (rows.isEmpty() && parseErrors.isEmpty()) {
throw new ApiException("CSV file has no data rows");
}
} catch (IOException e) {
throw new ApiException("Failed to read CSV file: " + e.getMessage());
}
return new ParseResult(rows, parseErrors);
}
/**
* Parse a CSV line respecting simple double-quoted fields.
*/
private String[] parseCsvLine(String line) {
List<String> fields = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '"') {
inQuotes = !inQuotes;
} else if (c == ',' && !inQuotes) {
fields.add(current.toString());
current = new StringBuilder();
} else {
current.append(c);
}
}
fields.add(current.toString());
return fields.toArray(new String[0]);
}
private static class CsvRow {
int lineNumber;
String name;
String account;
String phone;
String billLabel;
String billProductName;
BigDecimal amount;
}
private record ParseResult(List<CsvRow> rows, List<GroupCreateFromFileResult.RowError> parseErrors) {}
@Transactional
public RecipientGroup create(RecipientGroup group, List<Recipient> recipients) {
@@ -43,10 +395,6 @@ public class RecipientGroupService {
for (Recipient incomingRecipient : recipients) {
Recipient recipient = resolveOrCreateRecipient(incomingRecipient, userId);
if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipient.getId())) {
continue;
}
RecipientGroupMember member = RecipientGroupMember.builder()
.recipientGroupId(groupId)
.recipientUid(recipient.getId())
@@ -154,5 +502,4 @@ public class RecipientGroupService {
public Page<RecipientGroupMember> findAllMembers(Specification<RecipientGroupMember> spec, Pageable pageable) {
return recipientGroupMemberRepository.findAll(spec, pageable);
}
}
}

View File

@@ -104,6 +104,17 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
.build();
additionalDataService.getAdditionalDataRepository().save(additionalData);
// update customer address in creditAddress field
Map<String, Object> details = (Map<String, Object>) customerData.get("details");
if (details != null) {
for (Map.Entry<String, Object> entry : details.entrySet()) {
if (entry.getKey().equalsIgnoreCase("AccountName")) {
transaction.setCreditAddress(entry.getValue().toString());
break;
}
}
}
} catch (Exception e) {
logger.error("Error processing ZESA confirmation: " + e.getMessage(), e);
transaction.setStatus(Status.FAILED);
@@ -122,7 +133,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
Map<String, Object> details = (Map<String, Object>) customerData.get("details");
if (details != null) {
for (Map.Entry<String, Object> entry : details.entrySet()) {
// don't show customer ZWG value. It leads to confusion
// don't show customer ZWG value. It causes confusion
if(entry.getValue().toString().equals("ZWG"))
continue;
additionalDataList.add(Map.of("name", entry.getKey(), "value", entry.getValue().toString()));

View File

@@ -105,6 +105,18 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
.jsonString(Utils.toJson(additionalDataList))
.build();
additionalDataService.getAdditionalDataRepository().save(additionalData);
// if provider is zesa, update customer token in creditVoucher field
String providerId = providerService.getProviderId(transaction.getBillClientId());
if(providerId.equals("41")) {
for (Map.Entry<String, Object> entry : rechargeData.entrySet()) {
if (entry.getKey().equalsIgnoreCase("Token")) {
transaction.setCreditVoucher(entry.getValue().toString());
break;
}
}
}
}
} else {
transaction.setStatus(Status.FAILED);
@@ -138,19 +150,15 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
request.put("ProductId", providerId);
request.put("Target", transaction.getCreditAccount());
request.put("Amount", transaction.getCreditAmount());
// 41 = zesa
if(providerId.equals("41")) {
request.put("CustomerSMS", "A ZETDC token was purchased for " +
"%ACOUNTNAME% (%METERNUMBER%) that resulted in %KWH% units.");
}
else {
request.put("CustomerSMS", "%COMPANYNAME% topped up your account with $%AMOUNT%.");
}
request.put("CustomerSMS", "%COMPANYNAME% topped up your account with $%AMOUNT%.");
// do this for zesa only
if(providerId.equals("41")) {
// update product to zwg product id
// override sms for zesa
request.put("CustomerSMS", "A ZETDC token was purchased for " +
"%ACCOUNTNAME% (%METERNUMBER%) that resulted in %KWH% units.");
// if necessary, update product to zwg product id
if(transaction.getCreditCurrency().equals(CurrencyType.ZWG)) {
String zwgProviderId = providerService.getProviderId(ZESA_ZWG_CLIENT);
request.put("ProductId", zwgProviderId);
@@ -169,7 +177,7 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
}
}
// do this for Econet bundles
// do this for providers with bundles
Provider provider = providerService.getProviderRepository()
.findByClientId(transaction.getBillClientId());
if(provider.getRequiresProducts().equals(true)) {