package zw.qantra.tm.domain.services; import io.nflow.engine.service.WorkflowInstanceService; import io.nflow.engine.workflow.instance.WorkflowInstance; import io.nflow.engine.workflow.instance.WorkflowInstanceAction; import io.nflow.engine.workflow.instance.WorkflowInstanceFactory; import lombok.RequiredArgsConstructor; import org.joda.time.DateTime; import org.joda.time.Instant; 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 zw.qantra.tm.domain.dtos.GroupBatchCreateRequest; import zw.qantra.tm.domain.dtos.GroupBatchUpdateRequest; import zw.qantra.tm.domain.dtos.StateResponse; import zw.qantra.tm.domain.enums.AuthType; import zw.qantra.tm.domain.enums.GroupBatchItemConfirmStatus; import zw.qantra.tm.domain.enums.GroupBatchItemIntegrationStatus; import zw.qantra.tm.domain.enums.GroupBatchItemStatus; import zw.qantra.tm.domain.enums.GroupBatchStatus; 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.RecipientRepository; import zw.qantra.tm.domain.services.processors.workflows.BatchWorkflow; import zw.qantra.tm.domain.services.processors.workflows.WorkflowUtils; import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.utils.Utils; import java.math.BigDecimal; import java.util.HashMap; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @Service @RequiredArgsConstructor public class GroupBatchService { private static final String BATCH_WORKFLOW_TYPE = "batchWorkflow"; private static final Set UPDATABLE_STATUSES = Set.of( GroupBatchStatus.CREATED ); private static final Set DELETABLE_STATUSES = Set.of( GroupBatchStatus.CREATED, GroupBatchStatus.CONFIRMING, GroupBatchStatus.CONFIRMED_ALL, GroupBatchStatus.CONFIRMED_PARTIAL, GroupBatchStatus.CONFIRM_FAILED ); private final RecipientGroupService recipientGroupService; private final RecipientRepository recipientRepository; private final GroupBatchRepository groupBatchRepository; private final GroupBatchItemRepository groupBatchItemRepository; private final WorkflowInstanceFactory workflowInstanceFactory; private final WorkflowInstanceService workflowInstanceService; private final WorkflowUtils workflowUtils; private final TransactionService transactionService; @Transactional public GroupBatch createBatch(GroupBatchCreateRequest request) { RecipientGroup group = recipientGroupService.get( request.getRecipientGroupId(), request.getUserId()); List members = recipientGroupService.members( group.getId(), request.getUserId()); if (members.isEmpty()) { throw new ApiException("Recipient group has no members"); } if (request.getTransactionTemplate() == null) { throw new ApiException("transactionTemplate is required"); } GroupBatch batch = GroupBatch.builder() .recipientGroupId(group.getId()) .userId(request.getUserId()) .description(request.getDescription()) .requestedBy(request.getRequestedBy()) .currency(request.getCurrency()) .status(GroupBatchStatus.CREATED) .paymentProcessorLabel(request.getPaymentProcessorLabel()) .paymentProcessorName(request.getPaymentProcessorName()) .paymentProcessorImage(request.getPaymentProcessorImage()) .transactionTemplate(Utils.toJson(request.getTransactionTemplate())) .build(); GroupBatch savedBatch = groupBatchRepository.save(batch); BigDecimal defaultAmount = request.getAmount() == null ? request.getTransactionTemplate().getAmount() : request.getAmount(); BigDecimal totalValue = BigDecimal.ZERO; 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()) .amount(defaultAmount) .status(GroupBatchItemStatus.NEW) .confirmStatus(GroupBatchItemConfirmStatus.PENDING) .integrationStatus(GroupBatchItemIntegrationStatus.PENDING) .build(); totalValue = totalValue.add(defaultAmount); itemCount++; groupBatchItemRepository.save(item); } savedBatch.setCount(itemCount); savedBatch.setValue(totalValue); savedBatch.setSuccessfulCount(0); savedBatch.setFailedCount(0); return groupBatchRepository.save(savedBatch); } public GroupBatch triggerConfirm(UUID batchId, String userId, String idempotencyKey) { GroupBatch batch = getBatch(batchId, userId); if (batch.getConfirmIdempotencyKey() != null && batch.getConfirmIdempotencyKey().equals(idempotencyKey)) { return batch; } batch.setConfirmIdempotencyKey(idempotencyKey); batch.setStatus(GroupBatchStatus.CONFIRMING); groupBatchRepository.save(batch); if (batch.getWorkflowId() == null) { var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() .setType(BatchWorkflow.TYPE) .setExternalId(batch.getUserId() + "-batch-" + new Instant().getMillis()) .setBusinessKey(batch.getId().toString()) .putStateVariable("batchId", Utils.toJson(batch.getId().toString())) .setNextActivation(DateTime.now()) .build(); long workflowId = workflowInstanceService.insertWorkflowInstance(instance); batch.setWorkflowId(workflowId); return groupBatchRepository.save(batch); } WorkflowInstance update = new WorkflowInstance.Builder() .setId(batch.getWorkflowId()) .setState("confirm") .setNextActivation(DateTime.now()) .setStateVariables(new HashMap<>() {{ put("batchId", Utils.toJson(batch.getId().toString())); }}) .setStateText("Resumed from network trigger") .build(); WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update) .setType(WorkflowInstanceAction.WorkflowActionType.externalChange) .setExecutionEnd(DateTime.now()) .build(); workflowInstanceService.updateWorkflowInstance(update, action); return batch; } public Object triggerRequest(UUID batchId, String userId, AuthType authType, String idempotencyKey) { GroupBatch batch = getBatch(batchId, userId); if (batch.getRequestIdempotencyKey() != null && batch.getRequestIdempotencyKey().equals(idempotencyKey)) { return batch; } batch.setRequestIdempotencyKey(idempotencyKey); groupBatchRepository.save(batch); if (batch.getWorkflowId() == null) { throw new ApiException("Batch workflow has not been started. Trigger confirm first."); } WorkflowInstance update = new WorkflowInstance.Builder() .setId(batch.getWorkflowId()) .setState("request") .setNextActivation(DateTime.now()) .setStateVariables(new HashMap<>() {{ put("batchId", Utils.toJson(batch.getId().toString())); put("authType", Utils.toJson((authType == null ? AuthType.REMOTE : authType).name())); }}) .setStateText("Resumed from network trigger") .build(); WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update) .setType(WorkflowInstanceAction.WorkflowActionType.externalChange) .setExecutionEnd(DateTime.now()) .build(); workflowInstanceService.updateWorkflowInstance(update, action); try { CompletableFuture future = workflowUtils .waitForState(batch.getWorkflowId(), new String[]{"requestSuccess", "done", "failed"}, 15000, 50); StateResponse response = (StateResponse) future.get(); String id = response.getBody().toString(); // this returns Batch Transaction ID String cleaned = id.replaceAll("^\"|\"$", ""); return transactionService.findById(UUID.fromString(cleaned)); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } } public Object triggerPoll(UUID batchId, String userId, String idempotencyKey) { GroupBatch batch = getBatch(batchId, userId); if (batch.getPollIdempotencyKey() != null && batch.getPollIdempotencyKey().equals(idempotencyKey)) { return batch; } batch.setPollIdempotencyKey(idempotencyKey); groupBatchRepository.save(batch); if (batch.getWorkflowId() == null) { throw new ApiException("Batch workflow has not been started. Trigger confirm first."); } WorkflowInstance update = new WorkflowInstance.Builder() .setId(batch.getWorkflowId()) .setState("poll") .setNextActivation(DateTime.now()) .setStateVariables(new HashMap<>() {{ put("batchId", Utils.toJson(batch.getId().toString())); }}) .setStateText("Resumed from network trigger") .build(); WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update) .setType(WorkflowInstanceAction.WorkflowActionType.externalChange) .setExecutionEnd(DateTime.now()) .build(); workflowInstanceService.updateWorkflowInstance(update, action); try { CompletableFuture future = workflowUtils .waitForState(batch.getWorkflowId(), new String[]{"done", "failed"}, 15000, 50); StateResponse response = (StateResponse) future.get(); String id = response.getBody().toString(); // this returns Batch Transaction ID String cleaned = id.replaceAll("^\"|\"$", ""); return transactionService.findById(UUID.fromString(cleaned)); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } } public GroupBatch getBatch(UUID batchId, String userId) { return groupBatchRepository.findByIdAndUserId(batchId, userId) .orElseThrow(() -> new ApiException("Group batch not found")); } public Page getBatchItems(Specification spec, Pageable pageable) { return groupBatchItemRepository.findAll(spec, pageable); } public Page findAll(Specification spec, Pageable pageable) { return groupBatchRepository.findAll(spec, pageable); } @Transactional public GroupBatch updateBatch(GroupBatchUpdateRequest request) { GroupBatch batch = getBatch(request.getId(), request.getUserId()); if (!UPDATABLE_STATUSES.contains(batch.getStatus())) { throw new ApiException( "Batch cannot be updated at current status: " + batch.getStatus() + ". Only batches in CREATED status can be updated." ); } if (request.getDescription() != null) { batch.setDescription(request.getDescription()); } if (request.getRequestedBy() != null) { batch.setRequestedBy(request.getRequestedBy()); } if (request.getCurrency() != null) { batch.setCurrency(request.getCurrency()); } if (request.getPaymentProcessorLabel() != null) { batch.setPaymentProcessorLabel(request.getPaymentProcessorLabel()); } if (request.getPaymentProcessorName() != null) { batch.setPaymentProcessorName(request.getPaymentProcessorName()); } if (request.getPaymentProcessorImage() != null) { batch.setPaymentProcessorImage(request.getPaymentProcessorImage()); } if (request.getTransactionTemplate() != null) { batch.setTransactionTemplate(Utils.toJson(request.getTransactionTemplate())); } if (request.getAmount() != null) { BigDecimal newAmount = request.getAmount(); BigDecimal newTotalValue = BigDecimal.ZERO; int newCount = 0; List items = groupBatchItemRepository.findAllByGroupBatchId(batch.getId()); for (GroupBatchItem item : items) { item.setAmount(newAmount); groupBatchItemRepository.save(item); newTotalValue = newTotalValue.add(newAmount); newCount++; } batch.setValue(newTotalValue); batch.setCount(newCount); } return groupBatchRepository.save(batch); } @Transactional public void deleteBatch(UUID batchId, String userId) { GroupBatch batch = getBatch(batchId, userId); if (!DELETABLE_STATUSES.contains(batch.getStatus())) { throw new ApiException( "Batch cannot be deleted at current status: " + batch.getStatus() + ". Only batches up to the confirmed stage can be deleted." ); } batch.setDeleted(true); groupBatchRepository.save(batch); } }