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 org.springframework.transaction.annotation.Transactional; 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; @Service @RequiredArgsConstructor 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) { 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 recipients = new ArrayList<>(); for (CsvRow row : parseResult.rows) { Recipient recipient = Recipient.builder() .name(row.name) .account(row.account) .phoneNumber(row.phone) .userId(request.getUserId().toString()) .workspaceId(request.getWorkspaceId()) .build(); recipients.add(recipient); } // Step 2: create recipient group with recipients RecipientGroup group = RecipientGroup.builder() .name(request.getGroupName()) .description(request.getDescription()) .userId(request.getUserId().toString()) .workspaceId(request.getWorkspaceId()) .build(); RecipientGroup savedGroup = recipientGroupRepository.save(group); List members = addMembers(savedGroup.getId(), request.getWorkspaceId(), recipients); // Step 3: create group batch (inline to avoid circular dependency with GroupBatchService) GroupBatch batch = createBatchInline(savedGroup.getId(), request.getWorkspaceId(), request, members); // Step 4: update batch items with bill/provider fields from CSV List batchItems = groupBatchItemRepository.findAllByGroupBatchId(batch.getId()); List itemUpdates = new ArrayList<>(); List 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 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.findByIdAndWorkspaceId(batch.getId(), request.getWorkspaceId()) .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, UUID workspaceId, GroupCreateFromFileRequest request, List members) { GroupBatch batch = GroupBatch.builder() .recipientGroupId(recipientGroupId) .userId(request.getUserId().toString()) .workspaceId(workspaceId) .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()) .recipientAccount(recipient.getAccount()) .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 updateBatchItemsInline(UUID batchId, List updates) { List existingItems = groupBatchItemRepository.findAllByGroupBatchId(batchId); java.util.Map itemMap = new java.util.HashMap<>(); for (GroupBatchItem item : existingItems) { itemMap.put(item.getId(), item); } List updatedItems = new ArrayList<>(); List 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.setRecipientAccount(update.row.account); 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)); // 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) .name(update.row.name) .message("Failed to update batch item: " + e.getMessage()) .build()); } } 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 rows = new ArrayList<>(); List 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 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 rows, List parseErrors) {} @Transactional public RecipientGroup create(RecipientGroup group, List recipients) { RecipientGroup savedGroup = recipientGroupRepository.save(group); if (recipients != null && !recipients.isEmpty()) { addMembers(savedGroup.getId(), savedGroup.getWorkspaceId(), recipients); } return savedGroup; } @Transactional public List addMembers(UUID groupId, UUID workspaceId, List recipients) { get(groupId, workspaceId); List added = new ArrayList<>(); for (Recipient incomingRecipient : recipients) { Recipient recipient = resolveOrCreateRecipient(incomingRecipient, workspaceId); RecipientGroupMember member = RecipientGroupMember.builder() .recipientGroupId(groupId) .recipientUid(recipient.getId()) .recipient(recipient) .account(recipient.getAccount()) .workspaceId(workspaceId) .userId(incomingRecipient.getUserId()) .build(); added.add(recipientGroupMemberRepository.save(member)); } return added; } public Page findAll(Specification spec, Pageable pageable) { return recipientGroupRepository.findAll(spec, pageable); } public List list(UUID workspaceId) { return recipientGroupRepository.findAllByWorkspaceId(workspaceId); } public RecipientGroup get(UUID groupId, UUID workspaceId) { return recipientGroupRepository.findByIdAndWorkspaceId(groupId, workspaceId) .orElseThrow(() -> new ApiException("Recipient group not found")); } public RecipientGroup update(UUID groupId, UUID workspaceId, String name, String description) { RecipientGroup group = get(groupId, workspaceId); group.setName(name); group.setDescription(description); return recipientGroupRepository.save(group); } @Transactional public void delete(UUID groupId, UUID workspaceId) { RecipientGroup group = get(groupId, workspaceId); group.setDeleted(true); recipientGroupRepository.save(group); List members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId); members.forEach(member -> member.setDeleted(true)); recipientGroupMemberRepository.saveAll(members); } @Transactional public List addMembersByIds(UUID groupId, UUID workspaceId, List recipientIds) { get(groupId, workspaceId); List added = new ArrayList<>(); for (UUID recipientId : recipientIds) { Recipient recipient = recipientRepository.findById(recipientId) .orElseThrow(() -> new ApiException("Recipient not found: " + recipientId)); if (!workspaceId.equals(recipient.getWorkspaceId())) { throw new ApiException("Recipient does not belong to workspace"); } if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipientId)) { continue; } RecipientGroupMember member = RecipientGroupMember.builder() .recipientGroupId(groupId) .recipientUid(recipientId) .recipient(recipient) .account(recipient.getAccount()) .workspaceId(workspaceId) .build(); added.add(recipientGroupMemberRepository.save(member)); } return added; } private Recipient resolveOrCreateRecipient(Recipient incomingRecipient, UUID fallbackWorkspaceId) { if (incomingRecipient == null || incomingRecipient.getAccount() == null || incomingRecipient.getAccount().isBlank()) { throw new ApiException("Recipient account is required"); } UUID recipientWorkspaceId = incomingRecipient.getWorkspaceId() == null ? fallbackWorkspaceId : incomingRecipient.getWorkspaceId(); List existing = recipientRepository.findByAccountAndWorkspaceId(incomingRecipient.getAccount(), recipientWorkspaceId); if (!existing.isEmpty()) { Recipient recipient = existing.get(0); recipient.setName(incomingRecipient.getName()); recipient.setPhoneNumber(incomingRecipient.getPhoneNumber()); return recipientRepository.save(recipient); } incomingRecipient.setId(null); incomingRecipient.setWorkspaceId(recipientWorkspaceId); return recipientRepository.save(incomingRecipient); } @Transactional public void removeMember(UUID groupId, UUID recipientId, UUID workspaceId) { get(groupId, workspaceId); List members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId); RecipientGroupMember target = members.stream() .filter(item -> recipientId.equals(item.getRecipientUid())) .findFirst() .orElseThrow(() -> new ApiException("Group member not found")); target.setDeleted(true); recipientGroupMemberRepository.save(target); } public List members(UUID groupId, UUID workspaceId) { get(groupId, workspaceId); return recipientGroupMemberRepository.findAllByRecipientGroupId(groupId); } public Page findAllMembers(Specification spec, Pageable pageable) { return recipientGroupMemberRepository.findAll(spec, pageable); } }