Files
velocity-pay-springboot/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java
2026-06-25 00:39:58 +02:00

512 lines
22 KiB
Java

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<Recipient> 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<RecipientGroupMember> 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<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.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<RecipientGroupMember> 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<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.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<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) {
RecipientGroup savedGroup = recipientGroupRepository.save(group);
if (recipients != null && !recipients.isEmpty()) {
addMembers(savedGroup.getId(), savedGroup.getWorkspaceId(), recipients);
}
return savedGroup;
}
@Transactional
public List<RecipientGroupMember> addMembers(UUID groupId, UUID workspaceId, List<Recipient> recipients) {
get(groupId, workspaceId);
List<RecipientGroupMember> 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<RecipientGroup> findAll(Specification<RecipientGroup> spec, Pageable pageable) {
return recipientGroupRepository.findAll(spec, pageable);
}
public List<RecipientGroup> 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<RecipientGroupMember> members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
members.forEach(member -> member.setDeleted(true));
recipientGroupMemberRepository.saveAll(members);
}
@Transactional
public List<RecipientGroupMember> addMembersByIds(UUID groupId, UUID workspaceId, List<UUID> recipientIds) {
get(groupId, workspaceId);
List<RecipientGroupMember> 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<Recipient> 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<RecipientGroupMember> 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<RecipientGroupMember> members(UUID groupId, UUID workspaceId) {
get(groupId, workspaceId);
return recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
}
public Page<RecipientGroupMember> findAllMembers(Specification<RecipientGroupMember> spec, Pageable pageable) {
return recipientGroupMemberRepository.findAll(spec, pageable);
}
}