From 24f435a09fcd28c0fb7c01f1ef6e8e56c9cd6cee Mon Sep 17 00:00:00 2001 From: Vusumuzi Khoza Date: Mon, 8 Jun 2026 09:18:06 +0200 Subject: [PATCH] completed group batch feature --- data/jobrunr.mv.db | Bin 0 -> 221184 bytes pom.xml | 20 +- ...-b4b19592-6d9d-4892-a82e-be7c0810fa6a.xlsx | Bin 0 -> 4286 bytes ...-bffebe3e-1d84-48be-acd4-5213ef61fb4f.xlsx | Bin 0 -> 4281 bytes ...-e72b4959-fd95-4227-a9b4-1754c89231e9.xlsx | Bin 0 -> 4277 bytes .../tm/configs/DatabaseConfiguration.java | 15 + .../tm/configs/ReportsResourceConfig.java | 22 ++ .../zw/qantra/tm/configs/SecurityConfig.java | 5 +- .../controllers/GroupBatchController.java | 113 ++++++ .../controllers/RecipientController.java | 29 +- .../controllers/RecipientGroupController.java | 90 +++++ .../controllers/TransactonController.java | 2 +- .../domain/dtos/GroupBatchCreateRequest.java | 24 ++ .../domain/dtos/GroupBatchTriggerRequest.java | 12 + .../domain/dtos/GroupBatchUpdateRequest.java | 23 ++ .../tm/domain/dtos/GroupMemberRequest.java | 15 + .../dtos/RecipientGroupCreateRequest.java | 17 + .../dtos/RecipientGroupUpdateRequest.java | 13 + .../tm/domain/dtos/RegisterRequest.java | 2 - .../enums/GroupBatchItemConfirmStatus.java | 8 + .../GroupBatchItemIntegrationStatus.java | 9 + .../tm/domain/enums/GroupBatchItemStatus.java | 11 + .../tm/domain/enums/GroupBatchStatus.java | 20 + .../zw/qantra/tm/domain/enums/PartyType.java | 7 + .../qantra/tm/domain/models/GroupBatch.java | 66 ++++ .../tm/domain/models/GroupBatchItem.java | 50 +++ .../zw/qantra/tm/domain/models/Recipient.java | 4 + .../tm/domain/models/RecipientGroup.java | 25 ++ .../domain/models/RecipientGroupMember.java | 33 ++ .../qantra/tm/domain/models/Transaction.java | 6 +- .../GroupBatchItemRepository.java | 20 + .../repositories/GroupBatchRepository.java | 15 + .../RecipientGroupMemberRepository.java | 17 + .../RecipientGroupRepository.java | 17 + .../repositories/TransactionRepository.java | 2 +- .../domain/services/BatchReportService.java | 295 +++++++++++++++ .../tm/domain/services/GroupBatchService.java | 343 ++++++++++++++++++ .../services/GroupBatchWorkflowService.java | 322 ++++++++++++++++ .../PollCronWorkflow.java => JobService.java} | 59 +-- .../services/RecipientGroupService.java | 158 ++++++++ .../services/VelocityPaymentService.java | 1 - .../NetoneConfirmationHotProcessor.java | 2 +- .../ZesaConfirmationHotProcessor.java | 4 +- .../HotRechargeIntegrationProcessor.java | 2 +- .../StewardIntegrationProcessor.java | 2 +- .../EcocashRemotePaymentProcessor.java | 4 +- .../payments/MPGSWebPaymentProcessor.java | 4 +- .../processors/workflows/BatchWorkflow.java | 101 ++++++ .../workflows/PurchaseInvoiceWorkflow.java | 82 ----- .../workflows/RegistrationWorkflow.java | 4 +- .../workflows/SalesInvoiceWorkflow.java | 107 ------ .../workflows/StartupApplicationListener.java | 19 +- .../processors/workflows/WorkflowUtils.java | 20 +- .../workflows/handlers/CleanupHandler.java | 15 +- .../handlers/IntegrationHandler.java | 4 +- src/main/java/zw/qantra/tm/utils/Utils.java | 4 +- src/main/resources/application-lab.properties | 17 +- .../resources/application-staging.properties | 11 +- src/main/resources/application.properties | 16 +- .../resources/liquibase-diff-changeLog.xml | 21 +- 60 files changed, 2006 insertions(+), 323 deletions(-) create mode 100644 data/jobrunr.mv.db create mode 100644 reports/batch-report-b4b19592-6d9d-4892-a82e-be7c0810fa6a.xlsx create mode 100644 reports/batch-report-bffebe3e-1d84-48be-acd4-5213ef61fb4f.xlsx create mode 100644 reports/batch-report-e72b4959-fd95-4227-a9b4-1754c89231e9.xlsx create mode 100644 src/main/java/zw/qantra/tm/configs/ReportsResourceConfig.java create mode 100644 src/main/java/zw/qantra/tm/domain/controllers/GroupBatchController.java create mode 100644 src/main/java/zw/qantra/tm/domain/controllers/RecipientGroupController.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/GroupBatchCreateRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/GroupBatchTriggerRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/GroupBatchUpdateRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/GroupMemberRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/RecipientGroupCreateRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/RecipientGroupUpdateRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemConfirmStatus.java create mode 100644 src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemIntegrationStatus.java create mode 100644 src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemStatus.java create mode 100644 src/main/java/zw/qantra/tm/domain/enums/GroupBatchStatus.java create mode 100644 src/main/java/zw/qantra/tm/domain/enums/PartyType.java create mode 100644 src/main/java/zw/qantra/tm/domain/models/GroupBatch.java create mode 100644 src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java create mode 100644 src/main/java/zw/qantra/tm/domain/models/RecipientGroup.java create mode 100644 src/main/java/zw/qantra/tm/domain/models/RecipientGroupMember.java create mode 100644 src/main/java/zw/qantra/tm/domain/repositories/GroupBatchItemRepository.java create mode 100644 src/main/java/zw/qantra/tm/domain/repositories/GroupBatchRepository.java create mode 100644 src/main/java/zw/qantra/tm/domain/repositories/RecipientGroupMemberRepository.java create mode 100644 src/main/java/zw/qantra/tm/domain/repositories/RecipientGroupRepository.java create mode 100644 src/main/java/zw/qantra/tm/domain/services/BatchReportService.java create mode 100644 src/main/java/zw/qantra/tm/domain/services/GroupBatchService.java create mode 100644 src/main/java/zw/qantra/tm/domain/services/GroupBatchWorkflowService.java rename src/main/java/zw/qantra/tm/domain/services/{processors/workflows/PollCronWorkflow.java => JobService.java} (69%) create mode 100644 src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java create mode 100644 src/main/java/zw/qantra/tm/domain/services/processors/workflows/BatchWorkflow.java delete mode 100644 src/main/java/zw/qantra/tm/domain/services/processors/workflows/PurchaseInvoiceWorkflow.java delete mode 100644 src/main/java/zw/qantra/tm/domain/services/processors/workflows/SalesInvoiceWorkflow.java diff --git a/data/jobrunr.mv.db b/data/jobrunr.mv.db new file mode 100644 index 0000000000000000000000000000000000000000..dc305ea33db61eb34971379a864b303a7c6bc4a1 GIT binary patch literal 221184 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.fasterxml.jackson.datatype jackson-datatype-joda + + com.h2database + h2 + runtime + org.postgresql postgresql @@ -183,7 +188,20 @@ com.github.ben-manes.caffeine caffeine - + + org.jobrunr + jobrunr-spring-boot-3-starter + 8.6.1 + + + + + org.apache.poi + poi-ooxml + 5.2.5 + + + diff --git a/reports/batch-report-b4b19592-6d9d-4892-a82e-be7c0810fa6a.xlsx b/reports/batch-report-b4b19592-6d9d-4892-a82e-be7c0810fa6a.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4c7a1afc3d937ab9ef20367286df7d890d2038bf GIT binary patch literal 4286 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 literal 0 HcmV?d00001 diff --git a/reports/batch-report-e72b4959-fd95-4227-a9b4-1754c89231e9.xlsx b/reports/batch-report-e72b4959-fd95-4227-a9b4-1754c89231e9.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..49e4d27f7af1315140095ef0f8bdc72c714a9df8 GIT binary patch literal 4277 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 auth - .requestMatchers("/configs/seed/**").permitAll() - .requestMatchers("/test/**").permitAll() + .requestMatchers("/reports/**").permitAll() + .requestMatchers("/configs/seed/**").permitAll() + .requestMatchers("/test/**").permitAll() .requestMatchers("/nflow/**").permitAll() .requestMatchers("/auth/**").permitAll() .requestMatchers("/public/**").permitAll() diff --git a/src/main/java/zw/qantra/tm/domain/controllers/GroupBatchController.java b/src/main/java/zw/qantra/tm/domain/controllers/GroupBatchController.java new file mode 100644 index 0000000..17c49e6 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/controllers/GroupBatchController.java @@ -0,0 +1,113 @@ +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.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import zw.qantra.tm.domain.dtos.GroupBatchCreateRequest; +import zw.qantra.tm.domain.dtos.GroupBatchUpdateRequest; +import zw.qantra.tm.domain.dtos.GroupBatchTriggerRequest; +import zw.qantra.tm.domain.models.GroupBatch; +import zw.qantra.tm.domain.models.GroupBatchItem; +import zw.qantra.tm.domain.services.BatchReportService; +import zw.qantra.tm.domain.services.GroupBatchService; + +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/public/group-batches") +@RequiredArgsConstructor +public class GroupBatchController { + private final GroupBatchService groupBatchService; + private final BatchReportService batchReportService; + + @GetMapping + public ResponseEntity findAll( + @And({ + @Spec(path = "recipientGroupId", defaultVal = "null", spec = Equal.class), + @Spec(path = "userId", spec = Equal.class), + @Spec(path = "status", spec = Equal.class), + @Spec(path = "currency", spec = Equal.class), + })Specification spec, Pageable pageable){ + return ResponseEntity.ok(groupBatchService.findAll(spec, pageable)); + } + + @PostMapping + public ResponseEntity create(@RequestBody GroupBatchCreateRequest request) { + return ResponseEntity.ok(groupBatchService.createBatch(request)); + } + + @PutMapping("/{batchId}") + public ResponseEntity update(@PathVariable UUID batchId, + @RequestBody GroupBatchUpdateRequest request) { + request.setId(batchId); + return ResponseEntity.ok(groupBatchService.updateBatch(request)); + } + + @PostMapping("/{batchId}/confirm") + public ResponseEntity confirm(@PathVariable UUID batchId, + @RequestBody GroupBatchTriggerRequest request) { + return ResponseEntity.ok(groupBatchService.triggerConfirm( + batchId, + request.getUserId(), + request.getIdempotencyKey())); + } + + @PostMapping("/{batchId}/request") + public ResponseEntity request(@PathVariable UUID batchId, + @RequestBody GroupBatchTriggerRequest request) { + Object response = groupBatchService.triggerRequest( + batchId, + request.getUserId(), + request.getAuthType(), + request.getIdempotencyKey()); + return ResponseEntity.ok(response); + } + + @PostMapping("/{batchId}/poll") + public ResponseEntity poll(@PathVariable UUID batchId, + @RequestBody GroupBatchTriggerRequest request) { + return ResponseEntity.ok(groupBatchService.triggerPoll( + batchId, + request.getUserId(), + request.getIdempotencyKey())); + } + + @GetMapping("/{batchId}") + public ResponseEntity getBatch(@PathVariable UUID batchId, + @RequestParam String userId) { + return ResponseEntity.ok(groupBatchService.getBatch(batchId, userId)); + } + + @GetMapping("/items") + public ResponseEntity getBatchItems( + @And({ + @Spec(path = "groupBatchId", defaultVal = "null", spec = Equal.class), + @Spec(path = "recipientId", spec = Equal.class), + }) Specification spec, Pageable pageable) { + return ResponseEntity.ok(groupBatchService.getBatchItems(spec, pageable)); + } + + @DeleteMapping("/{batchId}") + public ResponseEntity deleteBatch(@PathVariable UUID batchId, + @RequestParam String userId) { + groupBatchService.deleteBatch(batchId, userId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{batchId}/report") + public ResponseEntity> downloadBatchReport(@PathVariable UUID batchId, + @RequestParam String userId) { + BatchReportService.BatchReportResult result = batchReportService.generateBatchReport(batchId, userId); + return ResponseEntity.ok(Map.of( + "fileUrl", result.fileUrl(), + "alreadyExisted", result.alreadyExisted() + )); + } +} + diff --git a/src/main/java/zw/qantra/tm/domain/controllers/RecipientController.java b/src/main/java/zw/qantra/tm/domain/controllers/RecipientController.java index 98e21a5..01e3b87 100644 --- a/src/main/java/zw/qantra/tm/domain/controllers/RecipientController.java +++ b/src/main/java/zw/qantra/tm/domain/controllers/RecipientController.java @@ -22,9 +22,17 @@ public class RecipientController { private final RecipientService recipientService; - @GetMapping - public ResponseEntity> getAllRecipients() { - return ResponseEntity.ok(recipientService.getAllRecipients()); + @GetMapping("/search") + public ResponseEntity> searchRecipients( + @And({ + @Spec(path = "name", spec = Equal.class), + @Spec(path = "email", spec = Equal.class), + @Spec(path = "phoneNumber", spec = Equal.class), + @Spec(path = "account", spec = Equal.class), + @Spec(path = "latestProviderLabel", spec = Equal.class), + @Spec(path = "userId", defaultVal = "null", spec = Equal.class) + }) Specification specification) { + return ResponseEntity.ok(recipientService.searchRecipients(specification)); } @GetMapping("/{account}/user/{userId}") @@ -57,17 +65,4 @@ public class RecipientController { } return ResponseEntity.notFound().build(); } - - @GetMapping("/search") - public ResponseEntity> searchRecipients( - @And({ - @Spec(path = "name", spec = Equal.class), - @Spec(path = "email", spec = Equal.class), - @Spec(path = "phoneNumber", spec = Equal.class), - @Spec(path = "account", spec = Equal.class), - @Spec(path = "latestProviderLabel", spec = Equal.class), - @Spec(path = "userId", spec = Equal.class) - }) Specification specification) { - return ResponseEntity.ok(recipientService.searchRecipients(specification)); - } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/controllers/RecipientGroupController.java b/src/main/java/zw/qantra/tm/domain/controllers/RecipientGroupController.java new file mode 100644 index 0000000..f95c7b5 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/controllers/RecipientGroupController.java @@ -0,0 +1,90 @@ +package zw.qantra.tm.domain.controllers; + +import lombok.RequiredArgsConstructor; +import net.kaczmarzyk.spring.data.jpa.domain.Equal; +import net.kaczmarzyk.spring.data.jpa.domain.EqualIgnoreCase; +import net.kaczmarzyk.spring.data.jpa.web.annotation.And; +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.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import zw.qantra.tm.domain.dtos.GroupMemberRequest; +import zw.qantra.tm.domain.dtos.RecipientGroupCreateRequest; +import zw.qantra.tm.domain.dtos.RecipientGroupUpdateRequest; +import zw.qantra.tm.domain.models.RecipientGroup; +import zw.qantra.tm.domain.models.RecipientGroupMember; +import zw.qantra.tm.domain.services.RecipientGroupService; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/public/recipient-groups") +@RequiredArgsConstructor +public class RecipientGroupController { + private final RecipientGroupService recipientGroupService; + + @GetMapping + public ResponseEntity findAll( + @And({ + @Spec(path = "name", spec = EqualIgnoreCase.class), + @Spec(path = "userId", defaultVal = "null", spec = Equal.class) + })Specification spec, Pageable pageable) { + return ResponseEntity.ok(recipientGroupService.findAll(spec, pageable)); + } + + @PostMapping + public ResponseEntity create(@RequestBody RecipientGroupCreateRequest request) { + RecipientGroup group = RecipientGroup.builder() + .name(request.getName()) + .description(request.getDescription()) + .userId(request.getUserId()) + .build(); + return ResponseEntity.ok(recipientGroupService.create(group, request.getRecipients())); + } + + @PutMapping("/update/{groupId}") + public ResponseEntity update(@PathVariable UUID groupId, + @RequestBody RecipientGroupUpdateRequest request) { + return ResponseEntity.ok(recipientGroupService.update( + groupId, + request.getUserId(), + request.getName(), + request.getDescription())); + } + + @DeleteMapping("/delete/{groupId}") + public ResponseEntity delete(@PathVariable UUID groupId, + @RequestParam String userId) { + recipientGroupService.delete(groupId, userId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{groupId}/members") + public ResponseEntity> addMembersByIds(@PathVariable UUID groupId, + @RequestBody GroupMemberRequest request) { + return ResponseEntity.ok(recipientGroupService.addMembersByIds( + groupId, + request.getUserId(), + request.getRecipientIds())); + } + + @DeleteMapping("/{groupId}/members/{recipientId}") + public ResponseEntity removeMember(@PathVariable UUID groupId, + @PathVariable UUID recipientId, + @RequestParam String userId) { + recipientGroupService.removeMember(groupId, recipientId, userId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/members") + public ResponseEntity members( + @And({ + @Spec(path = "recipientGroupId", spec = Equal.class), + }) Specification spec, Pageable pageable) { + return ResponseEntity.ok(recipientGroupService.findAllMembers(spec, pageable)); + } +} + diff --git a/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java b/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java index 144ced9..8316df5 100644 --- a/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java +++ b/src/main/java/zw/qantra/tm/domain/controllers/TransactonController.java @@ -70,7 +70,7 @@ public class TransactonController { @Spec(path = "amount", spec = Equal.class), @Spec(path = "reference", spec = Equal.class), @Spec(path = "totalAmount", spec = Equal.class), - @Spec(path = "authType", spec = Equal.class), + @Spec(path = "partyType", spec = Equal.class), @Spec(path = "sdkActionId", spec = Equal.class), @Spec(path = "paymentProcessorLabel", spec = Equal.class), }) diff --git a/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchCreateRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchCreateRequest.java new file mode 100644 index 0000000..0bc21b6 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchCreateRequest.java @@ -0,0 +1,24 @@ +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; +import java.util.UUID; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GroupBatchCreateRequest { + private UUID recipientGroupId; + private String userId; + private String description; + private String requestedBy; + private String currency; + private BigDecimal amount; + private String paymentProcessorLabel; + private String paymentProcessorName; + private String paymentProcessorImage; + private Transaction transactionTemplate; +} + diff --git a/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchTriggerRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchTriggerRequest.java new file mode 100644 index 0000000..2a73de4 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchTriggerRequest.java @@ -0,0 +1,12 @@ +package zw.qantra.tm.domain.dtos; + +import lombok.Data; +import zw.qantra.tm.domain.enums.AuthType; + +@Data +public class GroupBatchTriggerRequest { + private String userId; + private String idempotencyKey; + private AuthType authType; +} + diff --git a/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchUpdateRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchUpdateRequest.java new file mode 100644 index 0000000..23932e8 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchUpdateRequest.java @@ -0,0 +1,23 @@ +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; +import java.util.UUID; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GroupBatchUpdateRequest { + private UUID id; + private String userId; + private String description; + private String requestedBy; + private String currency; + private BigDecimal amount; + private String paymentProcessorLabel; + private String paymentProcessorName; + private String paymentProcessorImage; + private Transaction transactionTemplate; +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/dtos/GroupMemberRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/GroupMemberRequest.java new file mode 100644 index 0000000..bf1ce07 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/GroupMemberRequest.java @@ -0,0 +1,15 @@ +package zw.qantra.tm.domain.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.util.List; +import java.util.UUID; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GroupMemberRequest { + private String userId; + private List recipientIds; +} + diff --git a/src/main/java/zw/qantra/tm/domain/dtos/RecipientGroupCreateRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/RecipientGroupCreateRequest.java new file mode 100644 index 0000000..1757118 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/RecipientGroupCreateRequest.java @@ -0,0 +1,17 @@ +package zw.qantra.tm.domain.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import zw.qantra.tm.domain.models.Recipient; + +import java.util.List; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RecipientGroupCreateRequest { + private String name; + private String description; + private String userId; + private List recipients; +} + diff --git a/src/main/java/zw/qantra/tm/domain/dtos/RecipientGroupUpdateRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/RecipientGroupUpdateRequest.java new file mode 100644 index 0000000..59907ef --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/RecipientGroupUpdateRequest.java @@ -0,0 +1,13 @@ +package zw.qantra.tm.domain.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RecipientGroupUpdateRequest { + private String name; + private String description; + private String userId; +} + diff --git a/src/main/java/zw/qantra/tm/domain/dtos/RegisterRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/RegisterRequest.java index 92601e9..f5e2217 100644 --- a/src/main/java/zw/qantra/tm/domain/dtos/RegisterRequest.java +++ b/src/main/java/zw/qantra/tm/domain/dtos/RegisterRequest.java @@ -21,8 +21,6 @@ public class RegisterRequest { @NotBlank(message = "First name is required") private String firstName; - - @NotBlank(message = "Last name is required") private String lastName; @NotBlank(message = "Phone is required") diff --git a/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemConfirmStatus.java b/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemConfirmStatus.java new file mode 100644 index 0000000..0946f84 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemConfirmStatus.java @@ -0,0 +1,8 @@ +package zw.qantra.tm.domain.enums; + +public enum GroupBatchItemConfirmStatus { + PENDING, + CONFIRMED, + FAILED +} + diff --git a/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemIntegrationStatus.java b/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemIntegrationStatus.java new file mode 100644 index 0000000..84594f5 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemIntegrationStatus.java @@ -0,0 +1,9 @@ +package zw.qantra.tm.domain.enums; + +public enum GroupBatchItemIntegrationStatus { + PENDING, + INTEGRATED, + FAILED, + SKIPPED +} + diff --git a/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemStatus.java b/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemStatus.java new file mode 100644 index 0000000..d5f2e1c --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/enums/GroupBatchItemStatus.java @@ -0,0 +1,11 @@ +package zw.qantra.tm.domain.enums; + +public enum GroupBatchItemStatus { + NEW, + CONFIRMED, + READY_FOR_INTEGRATION, + INTEGRATED, + FAILED, + SKIPPED +} + diff --git a/src/main/java/zw/qantra/tm/domain/enums/GroupBatchStatus.java b/src/main/java/zw/qantra/tm/domain/enums/GroupBatchStatus.java new file mode 100644 index 0000000..ea5edeb --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/enums/GroupBatchStatus.java @@ -0,0 +1,20 @@ +package zw.qantra.tm.domain.enums; + +public enum GroupBatchStatus { + CREATED, + CONFIRMING, + CONFIRMED_ALL, + CONFIRMED_PARTIAL, + CONFIRM_FAILED, + REQUESTING, + REQUESTED, + REQUEST_FAILED, + POLLING, + POLL_SUCCESS, + POLL_FAILED, + INTEGRATING, + COMPLETED, + PARTIAL, + FAILED +} + diff --git a/src/main/java/zw/qantra/tm/domain/enums/PartyType.java b/src/main/java/zw/qantra/tm/domain/enums/PartyType.java new file mode 100644 index 0000000..37463e1 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/enums/PartyType.java @@ -0,0 +1,7 @@ +package zw.qantra.tm.domain.enums; + +public enum PartyType { + INDIVIDUAL, + GROUP, + BATCH +} diff --git a/src/main/java/zw/qantra/tm/domain/models/GroupBatch.java b/src/main/java/zw/qantra/tm/domain/models/GroupBatch.java new file mode 100644 index 0000000..6fd0293 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/models/GroupBatch.java @@ -0,0 +1,66 @@ +package zw.qantra.tm.domain.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.SQLRestriction; +import zw.qantra.tm.domain.enums.GroupBatchStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@SQLRestriction("deleted = false") +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GroupBatch extends BaseEntity { + private UUID recipientGroupId; + private String userId; + private String description; + private String requestedBy; + private String currency; + + @Enumerated(EnumType.STRING) + private GroupBatchStatus status; + + private String paymentReference; + private UUID paymentTransactionId; + private Long workflowId; + + private String confirmIdempotencyKey; + private String requestIdempotencyKey; + private String pollIdempotencyKey; + private String paymentProcessorLabel; + private String paymentProcessorName; + private String paymentProcessorImage; + + @Column(columnDefinition = "TEXT") + private String resultFileUrl; + + private LocalDateTime confirmStartedAt; + private LocalDateTime requestStartedAt; + private LocalDateTime pollStartedAt; + private LocalDateTime integrationStartedAt; + private LocalDateTime completedAt; + + @Column(columnDefinition = "TEXT") + private String transactionTemplate; + + private Integer count; + private BigDecimal value; + private Integer successfulCount; + private Integer failedCount; +} + diff --git a/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java b/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java new file mode 100644 index 0000000..4b20fb6 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/models/GroupBatchItem.java @@ -0,0 +1,50 @@ +package zw.qantra.tm.domain.models; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.SQLRestriction; +import zw.qantra.tm.domain.enums.GroupBatchItemConfirmStatus; +import zw.qantra.tm.domain.enums.GroupBatchItemIntegrationStatus; +import zw.qantra.tm.domain.enums.GroupBatchItemStatus; + +import java.math.BigDecimal; +import java.util.UUID; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@SQLRestriction("deleted = false") +public class GroupBatchItem extends BaseEntity { + private UUID groupBatchId; + private UUID recipientId; + private String recipientName; + private String recipientPhone; + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + private GroupBatchItemStatus status; + + @Enumerated(EnumType.STRING) + private GroupBatchItemConfirmStatus confirmStatus; + + @Enumerated(EnumType.STRING) + private GroupBatchItemIntegrationStatus integrationStatus; + + @Column(columnDefinition = "TEXT") + private String confirmError; + + @Column(columnDefinition = "TEXT") + private String integrationError; + + @ManyToOne + @JoinColumn(name = "transaction_id") + private Transaction transaction; +} + diff --git a/src/main/java/zw/qantra/tm/domain/models/Recipient.java b/src/main/java/zw/qantra/tm/domain/models/Recipient.java index b367e19..936be5e 100644 --- a/src/main/java/zw/qantra/tm/domain/models/Recipient.java +++ b/src/main/java/zw/qantra/tm/domain/models/Recipient.java @@ -1,6 +1,9 @@ package zw.qantra.tm.domain.models; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.persistence.Entity; +import jakarta.validation.constraints.NotNull; import lombok.*; import org.hibernate.annotations.SQLRestriction; @@ -10,6 +13,7 @@ import org.hibernate.annotations.SQLRestriction; @Builder @AllArgsConstructor @NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) @SQLRestriction("deleted = false") public class Recipient extends BaseEntity { private String name; diff --git a/src/main/java/zw/qantra/tm/domain/models/RecipientGroup.java b/src/main/java/zw/qantra/tm/domain/models/RecipientGroup.java new file mode 100644 index 0000000..2316966 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/models/RecipientGroup.java @@ -0,0 +1,25 @@ +package zw.qantra.tm.domain.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.persistence.Entity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@SQLRestriction("deleted = false") +public class RecipientGroup extends BaseEntity { + private String name; + private String description; + private String userId; +} + diff --git a/src/main/java/zw/qantra/tm/domain/models/RecipientGroupMember.java b/src/main/java/zw/qantra/tm/domain/models/RecipientGroupMember.java new file mode 100644 index 0000000..40ca5a1 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/models/RecipientGroupMember.java @@ -0,0 +1,33 @@ +package zw.qantra.tm.domain.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.SQLRestriction; + +import java.util.UUID; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@SQLRestriction("deleted = false") +public class RecipientGroupMember extends BaseEntity { + private UUID recipientGroupId; + @ManyToOne + @JoinColumn(name = "recipient_id") + private Recipient recipient; + private UUID recipientUid; + private String account; + private String userId; +} + 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 6c57dcb..d655e3a 100644 --- a/src/main/java/zw/qantra/tm/domain/models/Transaction.java +++ b/src/main/java/zw/qantra/tm/domain/models/Transaction.java @@ -5,10 +5,7 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.SQLRestriction; import org.springframework.data.domain.Persistable; -import zw.qantra.tm.domain.enums.AuthType; -import zw.qantra.tm.domain.enums.CurrencyType; -import zw.qantra.tm.domain.enums.RequestType; -import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.enums.*; import java.math.BigDecimal; import java.util.UUID; @@ -24,6 +21,7 @@ public class Transaction extends BaseEntity { private String userId; private String trace; private String region; + private PartyType partyType; private BigDecimal amount; private BigDecimal charge; private BigDecimal gatewayCharge; diff --git a/src/main/java/zw/qantra/tm/domain/repositories/GroupBatchItemRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/GroupBatchItemRepository.java new file mode 100644 index 0000000..e2affd1 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/repositories/GroupBatchItemRepository.java @@ -0,0 +1,20 @@ +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.GroupBatchItemConfirmStatus; +import zw.qantra.tm.domain.enums.GroupBatchItemIntegrationStatus; +import zw.qantra.tm.domain.models.GroupBatchItem; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface GroupBatchItemRepository extends JpaRepository, JpaSpecificationExecutor { + List findAllByGroupBatchId(UUID groupBatchId); + List findAllByGroupBatchIdAndConfirmStatus(UUID groupBatchId, GroupBatchItemConfirmStatus status); + long countByGroupBatchIdAndConfirmStatus(UUID groupBatchId, GroupBatchItemConfirmStatus status); + long countByGroupBatchIdAndIntegrationStatus(UUID groupBatchId, GroupBatchItemIntegrationStatus status); +} + diff --git a/src/main/java/zw/qantra/tm/domain/repositories/GroupBatchRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/GroupBatchRepository.java new file mode 100644 index 0000000..cc50350 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/repositories/GroupBatchRepository.java @@ -0,0 +1,15 @@ +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.models.GroupBatch; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface GroupBatchRepository extends JpaRepository, JpaSpecificationExecutor { + Optional findByIdAndUserId(UUID id, String userId); +} + diff --git a/src/main/java/zw/qantra/tm/domain/repositories/RecipientGroupMemberRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/RecipientGroupMemberRepository.java new file mode 100644 index 0000000..18baf0b --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/repositories/RecipientGroupMemberRepository.java @@ -0,0 +1,17 @@ +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.models.RecipientGroupMember; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface RecipientGroupMemberRepository extends JpaRepository, JpaSpecificationExecutor { + List findAllByRecipientGroupId(UUID recipientGroupId); + boolean existsByRecipientGroupIdAndRecipientId(UUID recipientGroupId, UUID recipientId); + void deleteAllByRecipientGroupId(UUID recipientGroupId); +} + diff --git a/src/main/java/zw/qantra/tm/domain/repositories/RecipientGroupRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/RecipientGroupRepository.java new file mode 100644 index 0000000..da86d8c --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/repositories/RecipientGroupRepository.java @@ -0,0 +1,17 @@ +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.models.RecipientGroup; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface RecipientGroupRepository extends JpaRepository, JpaSpecificationExecutor { + List findAllByUserId(String userId); + Optional findByIdAndUserId(UUID id, String userId); +} + diff --git a/src/main/java/zw/qantra/tm/domain/repositories/TransactionRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/TransactionRepository.java index 281d2fb..8264318 100644 --- a/src/main/java/zw/qantra/tm/domain/repositories/TransactionRepository.java +++ b/src/main/java/zw/qantra/tm/domain/repositories/TransactionRepository.java @@ -19,5 +19,5 @@ public interface TransactionRepository extends JpaRepository, List findAllByUserId(String uid); List findAllByConfirmationStatusAndPaymentStatusAndIntegrationStatus( Status s1, Status s2, Status s3); - List findAllByConfirmationStatusAndIntegrationStatus(Status s1, Status s2); + List findAllByPaymentStatusAndPollingStatus(Status s1, Status s2); } \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/services/BatchReportService.java b/src/main/java/zw/qantra/tm/domain/services/BatchReportService.java new file mode 100644 index 0000000..a739bd6 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/BatchReportService.java @@ -0,0 +1,295 @@ +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import zw.qantra.tm.domain.enums.GroupBatchItemStatus; +import zw.qantra.tm.domain.enums.GroupBatchStatus; +import zw.qantra.tm.domain.models.GroupBatch; +import zw.qantra.tm.domain.models.GroupBatchItem; +import zw.qantra.tm.domain.repositories.GroupBatchItemRepository; +import zw.qantra.tm.domain.repositories.GroupBatchRepository; +import zw.qantra.tm.exceptions.ApiException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class BatchReportService { + + private static final Set REPORTABLE_STATUSES = Set.of( + GroupBatchStatus.COMPLETED, + GroupBatchStatus.PARTIAL, + GroupBatchStatus.FAILED + ); + + private static final String REPORT_FILENAME_PREFIX = "batch-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; + + @Value("${app.base-url:http://localhost:6950}") + private String baseUrl; + + private final GroupBatchRepository groupBatchRepository; + private final GroupBatchItemRepository groupBatchItemRepository; + + @Transactional + public BatchReportResult generateBatchReport(UUID batchId, String userId) { + GroupBatch batch = groupBatchRepository.findByIdAndUserId(batchId, userId) + .orElseThrow(() -> new ApiException("Group batch not found")); + + if (!REPORTABLE_STATUSES.contains(batch.getStatus())) { + throw new ApiException( + "Batch report can only be generated for completed batches. Current status: " + batch.getStatus() + ); + } + + // If report already exists, return the existing file URL + if (batch.getResultFileUrl() != null && !batch.getResultFileUrl().isEmpty()) { + return new BatchReportResult(batch.getResultFileUrl(), true); + } + + List items = groupBatchItemRepository.findAllByGroupBatchId(batch.getId()); + + byte[] fileBytes; + try (Workbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Batch Report"); + + // Styles + CellStyle headerStyle = workbook.createCellStyle(); + headerStyle.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex()); + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + headerStyle.setFont(createHeaderFont(workbook)); + headerStyle.setAlignment(HorizontalAlignment.CENTER); + headerStyle.setVerticalAlignment(VerticalAlignment.CENTER); + + CellStyle titleStyle = workbook.createCellStyle(); + titleStyle.setFont(createDataFont(workbook)); + titleStyle.setAlignment(HorizontalAlignment.CENTER); + titleStyle.setVerticalAlignment(VerticalAlignment.CENTER); + + CellStyle dataStyle = workbook.createCellStyle(); + dataStyle.setFont(createDataFont(workbook)); + 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 statusSuccessStyle = workbook.createCellStyle(); + statusSuccessStyle.cloneStyleFrom(dataStyle); + statusSuccessStyle.setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex()); + statusSuccessStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + CellStyle statusFailedStyle = workbook.createCellStyle(); + statusFailedStyle.cloneStyleFrom(dataStyle); + statusFailedStyle.setFillForegroundColor(IndexedColors.ROSE.getIndex()); + statusFailedStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + CellStyle statusPendingStyle = workbook.createCellStyle(); + statusPendingStyle.cloneStyleFrom(dataStyle); + statusPendingStyle.setFillForegroundColor(IndexedColors.LIGHT_YELLOW.getIndex()); + statusPendingStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + int rowNum = 0; + + // Title row + Row titleRow = sheet.createRow(rowNum++); + 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)); + titleRow.setHeightInPoints(30); + rowNum++; + + // Batch summary + writeSummaryRow(sheet, rowNum++, "Status:", batch.getStatus().name(), dataStyle); + writeSummaryRow(sheet, rowNum++, "Total Items:", String.valueOf(batch.getCount() != null ? batch.getCount() : 0), dataStyle); + writeSummaryRow(sheet, rowNum++, "Successful:", String.valueOf(batch.getSuccessfulCount() != null ? batch.getSuccessfulCount() : 0), dataStyle); + writeSummaryRow(sheet, rowNum++, "Failed:", String.valueOf(batch.getFailedCount() != null ? batch.getFailedCount() : 0), dataStyle); + + if (batch.getCompletedAt() != null) { + writeSummaryRow(sheet, rowNum++, "Completed At:", + batch.getCompletedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), dataStyle); + } + + writeSummaryRow(sheet, rowNum++, "Currency:", batch.getCurrency() != null ? batch.getCurrency() : "N/A", dataStyle); + + if (batch.getValue() != null) { + writeSummaryRow(sheet, rowNum++, "Total Value:", batch.getValue().toString(), dataStyle); + } + + // Blank row + rowNum++; + + // Header row + String[] headers = { + "#", "Recipient Name", "Phone", "Amount", + "Status", "Confirm Status", "Integration Status", + "Transaction Ref", "Confirm Error", "Integration Error" + }; + + 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 (GroupBatchItem item : items) { + Row row = sheet.createRow(rowNum++); + + Cell numCell = row.createCell(0); + numCell.setCellValue(index++); + numCell.setCellStyle(dataStyle); + + Cell nameCell = row.createCell(1); + nameCell.setCellValue(item.getRecipientName() != null ? item.getRecipientName() : ""); + nameCell.setCellStyle(dataStyle); + + Cell phoneCell = row.createCell(2); + phoneCell.setCellValue(item.getRecipientPhone() != null ? item.getRecipientPhone() : ""); + phoneCell.setCellStyle(dataStyle); + + Cell amountCell = row.createCell(3); + amountCell.setCellValue(item.getAmount() != null ? item.getAmount().doubleValue() : 0.0); + amountCell.setCellStyle(currencyStyle); + + Cell statusCell = row.createCell(4); + statusCell.setCellValue(item.getStatus() != null ? item.getStatus().name() : ""); + if (item.getStatus() == GroupBatchItemStatus.INTEGRATED) { + statusCell.setCellStyle(statusSuccessStyle); + } else if (item.getStatus() == GroupBatchItemStatus.FAILED) { + statusCell.setCellStyle(statusFailedStyle); + } else { + statusCell.setCellStyle(statusPendingStyle); + } + + Cell confirmStatusCell = row.createCell(5); + confirmStatusCell.setCellValue(item.getConfirmStatus() != null ? item.getConfirmStatus().name() : ""); + confirmStatusCell.setCellStyle(dataStyle); + + Cell integrationStatusCell = row.createCell(6); + integrationStatusCell.setCellValue(item.getIntegrationStatus() != null ? item.getIntegrationStatus().name() : ""); + integrationStatusCell.setCellStyle(dataStyle); + + Cell refCell = row.createCell(7); + if (item.getTransaction() != null && item.getTransaction().getReference() != null) { + refCell.setCellValue(item.getTransaction().getReference()); + } else { + refCell.setCellValue(""); + } + refCell.setCellStyle(dataStyle); + + Cell confirmErrCell = row.createCell(8); + confirmErrCell.setCellValue(item.getConfirmError() != null ? item.getConfirmError() : ""); + confirmErrCell.setCellStyle(dataStyle); + + Cell integrationErrCell = row.createCell(9); + integrationErrCell.setCellValue(item.getIntegrationError() != null ? item.getIntegrationError() : ""); + integrationErrCell.setCellStyle(dataStyle); + } + + // Auto-size columns + for (int i = 0; i < headers.length; i++) { + sheet.autoSizeColumn(i); + } + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + workbook.write(outputStream); + fileBytes = outputStream.toByteArray(); + } catch (IOException e) { + throw new RuntimeException("Failed to generate batch report", e); + } + + // Save to disk + String filename = REPORT_FILENAME_PREFIX + batchId + REPORT_FILENAME_SUFFIX; + Path reportsDir = Path.of(reportsStoragePath).toAbsolutePath(); + Path filePath = reportsDir.resolve(filename); + + try { + Files.createDirectories(reportsDir); + try (OutputStream os = Files.newOutputStream(filePath)) { + os.write(fileBytes); + } + } catch (IOException e) { + throw new RuntimeException("Failed to persist batch report file", e); + } + + // Persist the URL + String fileUrl = baseUrl + "/api/reports/" + filename; + batch.setResultFileUrl(fileUrl); + groupBatchRepository.save(batch); + + return new BatchReportResult(fileUrl, false, fileBytes); + } + + private void writeSummaryRow(Sheet sheet, int rowNum, String label, String value, CellStyle style) { + Row row = sheet.getRow(rowNum); + if (row == null) { + row = sheet.createRow(rowNum); + } + Cell labelCell = row.createCell(0); + labelCell.setCellValue(label); + labelCell.setCellStyle(style); + + Cell valueCell = row.createCell(1); + valueCell.setCellValue(value); + valueCell.setCellStyle(style); + } + + private Font createHeaderFont(Workbook workbook) { + Font font = workbook.createFont(); + font.setBold(true); + font.setFontHeightInPoints(HEADER_FONT_SIZE); + font.setColor(IndexedColors.WHITE.getIndex()); + return font; + } + + private Font createDataFont(Workbook workbook) { + Font font = workbook.createFont(); + font.setFontHeightInPoints(DATA_FONT_SIZE); + font.setColor(IndexedColors.BLACK.getIndex()); + return font; + } + + /** + * Result of a batch report generation request. + */ + public record BatchReportResult(String fileUrl, boolean alreadyExisted, byte... fileBytes) { + + public BatchReportResult(String fileUrl, boolean alreadyExisted) { + this(fileUrl, alreadyExisted, (byte[]) null); + } + + public boolean isNewlyGenerated() { + return fileBytes != null && fileBytes.length > 0; + } + } +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/services/GroupBatchService.java b/src/main/java/zw/qantra/tm/domain/services/GroupBatchService.java new file mode 100644 index 0000000..0de57df --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/GroupBatchService.java @@ -0,0 +1,343 @@ +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); + future.get(); + return getBatch(batchId, userId); + } 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); + } + +} diff --git a/src/main/java/zw/qantra/tm/domain/services/GroupBatchWorkflowService.java b/src/main/java/zw/qantra/tm/domain/services/GroupBatchWorkflowService.java new file mode 100644 index 0000000..44d6d9a --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/GroupBatchWorkflowService.java @@ -0,0 +1,322 @@ +package zw.qantra.tm.domain.services; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +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.RecipientRepository; +import zw.qantra.tm.domain.services.processors.workflows.handlers.*; +import zw.qantra.tm.exceptions.ApiException; +import zw.qantra.tm.utils.Utils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +public class GroupBatchWorkflowService { + private final GroupBatchRepository groupBatchRepository; + private final GroupBatchItemRepository groupBatchItemRepository; + private final RecipientRepository recipientRepository; + private final TransactionService transactionService; + private final ConfirmHandler confirmHandler; + private final GatewayPaymentHandler gatewayPaymentHandler; + private final PollStatusHandler pollStatusHandler; + private final IntegrationHandler integrationHandler; + private final CalculateChargesHandler calculateChargesHandler; + private final CleanupHandler cleanUpHandler; + + @Transactional + public GroupBatch processConfirm(UUID batchId) { + GroupBatch batch = getBatchById(batchId); + List acceptableStatuses = List.of( + GroupBatchStatus.CREATED, + GroupBatchStatus.CONFIRMING, + GroupBatchStatus.CONFIRM_FAILED); + + if (!acceptableStatuses.contains(batch.getStatus())) { + return batch; + } + + batch.setStatus(GroupBatchStatus.CONFIRMING); + batch.setConfirmStartedAt(LocalDateTime.now()); + groupBatchRepository.save(batch); + + Transaction template = Utils.fromJson(batch.getTransactionTemplate(), Transaction.class); + if (template == null) { + throw new ApiException("Batch transaction template is missing"); + } + + List items = groupBatchItemRepository.findAllByGroupBatchId(batchId); + List> futures = new ArrayList<>(); + for (GroupBatchItem item : items) { + futures.add(CompletableFuture.runAsync(() -> confirmItem(batch, item, template))); + } + futures.forEach(CompletableFuture::join); + + long confirmSuccessCount = groupBatchItemRepository.countByGroupBatchIdAndConfirmStatus( + batchId, GroupBatchItemConfirmStatus.CONFIRMED); + + if (confirmSuccessCount == 0) { + batch.setStatus(GroupBatchStatus.CONFIRM_FAILED); + } else if (confirmSuccessCount == items.size()) { + batch.setStatus(GroupBatchStatus.CONFIRMED_ALL); + } else { + batch.setStatus(GroupBatchStatus.CONFIRMED_PARTIAL); + } + + recalculateStats(batch); + + return groupBatchRepository.save(batch); + } + + @Transactional + public GroupBatch processRequest(UUID batchId, AuthType authType) throws Exception { + GroupBatch batch = getBatchById(batchId); + List acceptableStatuses = List.of( + GroupBatchStatus.CONFIRMED_ALL, + GroupBatchStatus.CONFIRMED_PARTIAL, + GroupBatchStatus.REQUEST_FAILED); + if (!acceptableStatuses.contains(batch.getStatus())) { + throw new ApiException("Batch request step requires a successful confirm step"); + } + + batch.setStatus(GroupBatchStatus.REQUESTING); + batch.setRequestStartedAt(LocalDateTime.now()); + groupBatchRepository.save(batch); + + List confirmedItems = groupBatchItemRepository.findAllByGroupBatchIdAndConfirmStatus( + batchId, GroupBatchItemConfirmStatus.CONFIRMED); + if (confirmedItems.isEmpty()) { + throw new ApiException("No confirmed batch items available for payment request"); + } + + Transaction template = Utils.fromJson(batch.getTransactionTemplate(), Transaction.class); + if (template == null) { + throw new ApiException("Batch transaction template is missing"); + } + + BigDecimal total = confirmedItems.stream() + .map(item -> item.getAmount() == null ? BigDecimal.ZERO : item.getAmount()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + Transaction transaction = Utils.deepCopy(template, Transaction.class); + transaction.setId(null); + transaction.setPartyType(PartyType.GROUP); + transaction.setType(RequestType.REQUEST); + transaction.setAuthType(authType == null ? AuthType.REMOTE : authType); + transaction.setAmount(total); + transaction.setTotalAmount(total); + transaction.setReference("batch-" + batch.getId()); + transaction.setUserId(batch.getUserId()); + transaction.setPaymentProcessorLabel(batch.getPaymentProcessorLabel()); + transaction.setPaymentProcessorName(batch.getPaymentProcessorName()); + transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage()); + + transaction = calculateChargesHandler.process(transaction); + transaction = (Transaction) cleanUpHandler.process(transaction); + Transaction result = (Transaction) gatewayPaymentHandler.process(transaction); + + batch.setPaymentTransactionId(result.getId()); + batch.setPaymentReference(result.getPaymentProcessorRef()); + batch.setStatus(result.getPaymentStatus() == Status.FAILED + ? GroupBatchStatus.REQUEST_FAILED + : GroupBatchStatus.REQUESTED); + + return groupBatchRepository.save(batch); + } + + @Transactional + public GroupBatch processPoll(UUID batchId) throws Exception { + GroupBatch batch = getBatchById(batchId); + if (batch.getStatus() != GroupBatchStatus.REQUESTED && batch.getStatus() != GroupBatchStatus.POLL_FAILED) { + throw new ApiException("Batch poll step requires a successful request step"); + } + if (batch.getPaymentTransactionId() == null) { + throw new ApiException("Batch payment transaction was not initialized"); + } + + batch.setStatus(GroupBatchStatus.POLLING); + batch.setPollStartedAt(LocalDateTime.now()); + groupBatchRepository.save(batch); + + Transaction paymentTransaction = transactionService.findById(batch.getPaymentTransactionId()); + + try { + paymentTransaction = (Transaction) pollStatusHandler.process(paymentTransaction); + } catch (Exception e) { + batch.setStatus(GroupBatchStatus.POLL_FAILED); + groupBatchRepository.save(batch); + throw e; + } + + if (paymentTransaction.getPollingStatus() == Status.SUCCESS) { + batch.setStatus(GroupBatchStatus.POLL_SUCCESS); + return groupBatchRepository.save(batch); + } + + batch.setStatus(GroupBatchStatus.POLL_FAILED); + return groupBatchRepository.save(batch); + } + + @Transactional + public GroupBatch processIntegration(UUID batchId) { + GroupBatch batch = getBatchById(batchId); + if (batch.getStatus() != GroupBatchStatus.POLL_SUCCESS) { + throw new ApiException("Batch integration step requires a successful poll step"); + } + + batch.setStatus(GroupBatchStatus.INTEGRATING); + batch.setIntegrationStartedAt(LocalDateTime.now()); + groupBatchRepository.save(batch); + + List items = groupBatchItemRepository.findAllByGroupBatchId(batchId); + List> futures = new ArrayList<>(); + for (GroupBatchItem item : items) { + futures.add(CompletableFuture.runAsync(() -> integrateItem(item))); + } + futures.forEach(CompletableFuture::join); + + long integrated = groupBatchItemRepository.countByGroupBatchIdAndIntegrationStatus( + batchId, GroupBatchItemIntegrationStatus.INTEGRATED); + long failed = groupBatchItemRepository.countByGroupBatchIdAndIntegrationStatus( + batchId, GroupBatchItemIntegrationStatus.FAILED); + + if (integrated == 0 && failed > 0) { + batch.setStatus(GroupBatchStatus.FAILED); + } else if (failed > 0) { + batch.setStatus(GroupBatchStatus.PARTIAL); + } else { + batch.setStatus(GroupBatchStatus.COMPLETED); + } + + batch.setCompletedAt(LocalDateTime.now()); + + recalculateStats(batch); + + return groupBatchRepository.save(batch); + } + + private void confirmItem(GroupBatch batch, GroupBatchItem item, Transaction template) { + GroupBatchItem mutableItem = groupBatchItemRepository.findById(item.getId()).orElse(item); + try { + Transaction transaction = Utils.deepCopy(template, Transaction.class); + transaction.setId(null); + transaction.setUserId(batch.getUserId()); + transaction.setType(RequestType.CONFIRM); + transaction.setPartyType(PartyType.BATCH); + transaction.setAuthType(AuthType.REMOTE); // default to remote for now + transaction.setAmount(mutableItem.getAmount()); + transaction.setTotalAmount(mutableItem.getAmount()); + + Recipient recipient = recipientRepository.findById(mutableItem.getRecipientId()) + .orElseThrow(() -> new ApiException("Recipient not found for batch item")); + transaction.setCreditName(recipient.getName()); + transaction.setCreditPhone(recipient.getPhoneNumber()); + transaction.setCreditAccount(recipient.getAccount()); + transaction.setPaymentProcessorLabel(batch.getPaymentProcessorLabel()); + transaction.setPaymentProcessorName(batch.getPaymentProcessorName()); + transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage()); + + transaction = calculateChargesHandler.process(transaction); + transaction = (Transaction) cleanUpHandler.process(transaction); + Transaction result = (Transaction) confirmHandler.process(transaction); + mutableItem.setTransaction(result); + + if (result.getConfirmationStatus() == Status.SUCCESS || result.getStatus() == Status.SUCCESS) { + mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.CONFIRMED); + mutableItem.setStatus(GroupBatchItemStatus.CONFIRMED); + mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.PENDING); + mutableItem.setConfirmError(null); + } else { + mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED); + mutableItem.setStatus(GroupBatchItemStatus.FAILED); + mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.SKIPPED); + mutableItem.setConfirmError(result.getErrorMessage()); + } + } catch (Exception e) { + mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED); + mutableItem.setStatus(GroupBatchItemStatus.FAILED); + mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.SKIPPED); + mutableItem.setConfirmError(e.getMessage()); + } + + groupBatchItemRepository.save(mutableItem); + } + + private void integrateItem(GroupBatchItem item) { + GroupBatchItem mutableItem = groupBatchItemRepository.findById(item.getId()).orElse(item); + if (mutableItem.getConfirmStatus() != GroupBatchItemConfirmStatus.CONFIRMED) { + mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.SKIPPED); + mutableItem.setStatus(GroupBatchItemStatus.SKIPPED); + groupBatchItemRepository.save(mutableItem); + return; + } + + if (mutableItem.getTransaction() == null) { + mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.FAILED); + mutableItem.setStatus(GroupBatchItemStatus.FAILED); + mutableItem.setIntegrationError("Missing transaction for integration"); + groupBatchItemRepository.save(mutableItem); + return; + } + + try { + Transaction transaction = transactionService.findById(mutableItem.getTransaction().getId()); + transaction.setPaymentStatus(Status.SUCCESS); + transaction.setPollingStatus(Status.SUCCESS); + transactionService.save(transaction); + + integrationHandler.process(transaction); + mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.INTEGRATED); + mutableItem.setStatus(GroupBatchItemStatus.INTEGRATED); + mutableItem.setIntegrationError(null); + } catch (Exception e) { + mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.FAILED); + mutableItem.setStatus(GroupBatchItemStatus.FAILED); + mutableItem.setIntegrationError(e.getMessage()); + } + + groupBatchItemRepository.save(mutableItem); + } + + private void recalculateStats(GroupBatch batch) { + List items = groupBatchItemRepository.findAllByGroupBatchId(batch.getId()); + + long successfulCount = items.stream() + .filter(item -> item.getStatus() == GroupBatchItemStatus.INTEGRATED + || (item.getIntegrationStatus() == GroupBatchItemIntegrationStatus.INTEGRATED) + || (item.getConfirmStatus() == GroupBatchItemConfirmStatus.CONFIRMED + && item.getIntegrationStatus() != GroupBatchItemIntegrationStatus.FAILED)) + .count(); + + long failedCount = items.stream() + .filter(item -> item.getStatus() == GroupBatchItemStatus.FAILED + || item.getIntegrationStatus() == GroupBatchItemIntegrationStatus.FAILED + || (item.getConfirmStatus() == GroupBatchItemConfirmStatus.FAILED + && item.getIntegrationStatus() != GroupBatchItemIntegrationStatus.INTEGRATED)) + .count(); + + BigDecimal totalValue = items.stream() + .map(item -> item.getAmount() == null ? BigDecimal.ZERO : item.getAmount()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + batch.setCount(items.size()); + batch.setValue(totalValue); + batch.setSuccessfulCount((int) successfulCount); + batch.setFailedCount((int) failedCount); + } + + public GroupBatch getBatchById(UUID batchId) { + return groupBatchRepository.findById(batchId) + .orElseThrow(() -> new ApiException("Group batch not found")); + } +} + diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/PollCronWorkflow.java b/src/main/java/zw/qantra/tm/domain/services/JobService.java similarity index 69% rename from src/main/java/zw/qantra/tm/domain/services/processors/workflows/PollCronWorkflow.java rename to src/main/java/zw/qantra/tm/domain/services/JobService.java index 0651623..a8c1380 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/PollCronWorkflow.java +++ b/src/main/java/zw/qantra/tm/domain/services/JobService.java @@ -1,54 +1,39 @@ -package zw.qantra.tm.domain.services.processors.workflows; +package zw.qantra.tm.domain.services; import io.nflow.engine.service.WorkflowInstanceService; -import io.nflow.engine.workflow.curated.CronWorkflow; -import io.nflow.engine.workflow.definition.*; import io.nflow.engine.workflow.instance.WorkflowInstance; import io.nflow.engine.workflow.instance.WorkflowInstanceAction; -import org.joda.time.DateTime; -import org.joda.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.jobrunr.jobs.context.JobRunrDashboardLogger; import org.slf4j.Logger; -import org.springframework.beans.factory.annotation.Autowired; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import zw.qantra.tm.domain.dtos.StateResponse; import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.models.Transaction; -import zw.qantra.tm.domain.services.TransactionService; +import zw.qantra.tm.domain.services.processors.workflows.WorkflowUtils; import zw.qantra.tm.utils.LogUtils; import zw.qantra.tm.utils.Utils; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.concurrent.CompletableFuture; -import static io.nflow.engine.workflow.definition.NextAction.moveToStateAfter; import static org.joda.time.DateTime.now; -import static org.joda.time.Period.hours; @Component -public class PollCronWorkflow extends CronWorkflow { - Logger log = org.slf4j.LoggerFactory.getLogger(PollCronWorkflow.class); +@RequiredArgsConstructor +public class JobService { + Logger log = new JobRunrDashboardLogger(LoggerFactory.getLogger(JobService.class)); - @Autowired - private TransactionService transactionService; - @Autowired - private WorkflowInstanceService workflowInstanceService; - @Autowired - private WorkflowUtils workflowUtils; + private final WorkflowInstanceService workflowInstanceService; + private final WorkflowUtils workflowUtils; + private final TransactionService transactionService; - public static final String TYPE = "transactionPollCronWorkflow"; - - protected PollCronWorkflow() { - super(TYPE, new WorkflowSettings.Builder() - .setHistoryDeletableAfter(hours(1)) - .setDeleteHistoryCondition(() -> true).build()); - } - - public NextAction doWork(StateExecution execution) { + public void checkOrphanedTransactions() { List transactions = transactionService.getTransactionRepository() - .findAllByConfirmationStatusAndIntegrationStatus(Status.SUCCESS, Status.PENDING); + .findAllByPaymentStatusAndPollingStatus(Status.SUCCESS, Status.PENDING); if (!transactions.isEmpty()) log.info("Transaction count {}", transactions.size()); @@ -118,27 +103,11 @@ public class PollCronWorkflow extends CronWorkflow { } }catch (Exception e) { - log.error(e.getMessage()); + log.debug(e.getMessage()); } } log.trace("Polling complete. {} transactions processed, {} successful", attempts, success); - Map map = new HashMap<>(); - map.put("attempts", attempts.toString()); - map.put("success", success.toString()); - execution.setVariable(LocalDateTime.now().toString(), map); - return moveToStateAfter(WAIT_FOR_WORK_TO_FINISH, now().plusMinutes(2), - String.format("Cron complete on %s transactions", transactions.size())); - } - - @Override - protected boolean handleFailureImpl(StateExecution execution) { - return super.handleFailureImpl(execution); - } - - @Override - protected DateTime waitForWorkToFinishImpl(StateExecution execution) { - return super.waitForWorkToFinishImpl(execution); } } diff --git a/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java b/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java new file mode 100644 index 0000000..8e932bc --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java @@ -0,0 +1,158 @@ +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 zw.qantra.tm.domain.models.Recipient; +import zw.qantra.tm.domain.models.RecipientGroup; +import zw.qantra.tm.domain.models.RecipientGroupMember; +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 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; + + @Transactional + public RecipientGroup create(RecipientGroup group, List recipients) { + RecipientGroup savedGroup = recipientGroupRepository.save(group); + + if (recipients != null && !recipients.isEmpty()) { + addMembers(savedGroup.getId(), savedGroup.getUserId(), recipients); + } + + return savedGroup; + } + + @Transactional + public List addMembers(UUID groupId, String userId, List recipients) { + get(groupId, userId); + List added = new ArrayList<>(); + 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()) + .recipient(recipient) + .account(recipient.getAccount()) + .userId(userId) + .build(); + added.add(recipientGroupMemberRepository.save(member)); + } + return added; + } + + public Page findAll(Specification spec, Pageable pageable) { + return recipientGroupRepository.findAll(spec, pageable); + } + + public List list(String userId) { + return recipientGroupRepository.findAllByUserId(userId); + } + + public RecipientGroup get(UUID groupId, String userId) { + return recipientGroupRepository.findByIdAndUserId(groupId, userId) + .orElseThrow(() -> new ApiException("Recipient group not found")); + } + + public RecipientGroup update(UUID groupId, String userId, String name, String description) { + RecipientGroup group = get(groupId, userId); + group.setName(name); + group.setDescription(description); + return recipientGroupRepository.save(group); + } + + @Transactional + public void delete(UUID groupId, String userId) { + RecipientGroup group = get(groupId, userId); + 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, String userId, List recipientIds) { + get(groupId, userId); + List added = new ArrayList<>(); + for (UUID recipientId : recipientIds) { + Recipient recipient = recipientRepository.findById(recipientId) + .orElseThrow(() -> new ApiException("Recipient not found: " + recipientId)); + if (!userId.equals(recipient.getUserId())) { + throw new ApiException("Recipient does not belong to user"); + } + if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipientId)) { + continue; + } + RecipientGroupMember member = RecipientGroupMember.builder() + .recipientGroupId(groupId) + .recipientUid(recipientId) + .recipient(recipient) + .account(recipient.getAccount()) + .userId(userId) + .build(); + added.add(recipientGroupMemberRepository.save(member)); + } + return added; + } + + private Recipient resolveOrCreateRecipient(Recipient incomingRecipient, String fallbackUserId) { + if (incomingRecipient == null || incomingRecipient.getAccount() == null || incomingRecipient.getAccount().isBlank()) { + throw new ApiException("Recipient account is required"); + } + + String recipientUserId = incomingRecipient.getUserId() == null || incomingRecipient.getUserId().isBlank() + ? fallbackUserId + : incomingRecipient.getUserId(); + + List existing = recipientRepository.findByAccountAndUserId(incomingRecipient.getAccount(), recipientUserId); + if (!existing.isEmpty()) { + return existing.get(0); + } + + incomingRecipient.setId(null); + incomingRecipient.setUserId(recipientUserId); + return recipientRepository.save(incomingRecipient); + } + + @Transactional + public void removeMember(UUID groupId, UUID recipientId, String userId) { + get(groupId, userId); + 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, String userId) { + get(groupId, userId); + return recipientGroupMemberRepository.findAllByRecipientGroupId(groupId); + } + + public Page findAllMembers(Specification spec, Pageable pageable) { + return recipientGroupMemberRepository.findAll(spec, pageable); + } +} + diff --git a/src/main/java/zw/qantra/tm/domain/services/VelocityPaymentService.java b/src/main/java/zw/qantra/tm/domain/services/VelocityPaymentService.java index e526fa4..130fcde 100644 --- a/src/main/java/zw/qantra/tm/domain/services/VelocityPaymentService.java +++ b/src/main/java/zw/qantra/tm/domain/services/VelocityPaymentService.java @@ -45,7 +45,6 @@ public class VelocityPaymentService { private static final String VELOCITY_API_UPDATE_WORKFLOW_PATH = "/sales-orders/update-workflow/{traceId}"; private static final String VELOCITY_API_CUSTOMERS = "/customers"; - public VelocityCustomerResponseDto createCustomer(RegisterRequest registerRequest) { VelocityCustomerDto payload = VelocityCustomerDto.builder() .name(registerRequest.getFirstName() + " " + registerRequest.getLastName()) 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 da56d7d..d165537 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 @@ -50,7 +50,7 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter // 4 USD Non-VAT (Utilitiies) if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount(), "3")) { transaction.setStatus(Status.FAILED); - transaction.setResponseCode("92"); + transaction.setResponseCode("98"); transaction.setErrorMessage("There's a technical issue on our end. 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 2bda39e..912f72f 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 @@ -47,7 +47,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa // do this if we not simulating success, if simulating success, we skip balance check and just return success if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){ transaction.setStatus(Status.SUCCESS); - transaction.setResponseCode("00"); + transaction.setResponseCode("02"); return transaction; } @@ -62,7 +62,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa Map customerData = checkCustomer(token, transaction); if (customerData == null) { transaction.setStatus(Status.FAILED); - transaction.setResponseCode("91"); + transaction.setResponseCode("90"); transaction.setErrorMessage("Customer not found or invalid"); 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 c2a632e..605c8ac 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 @@ -56,7 +56,7 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){ transaction.setStatus(Status.SUCCESS); - transaction.setResponseCode("responseCode"); + transaction.setResponseCode("02"); transaction.setCreditRef("Simulated Integration Success"); transaction.setErrorMessage(null); return transaction; diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/integrations/StewardIntegrationProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/integrations/StewardIntegrationProcessor.java index 8884987..bc23120 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/integrations/StewardIntegrationProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/integrations/StewardIntegrationProcessor.java @@ -59,7 +59,7 @@ public class StewardIntegrationProcessor implements TransactionProcessorInterfac if(settingService.getBooleanSetting("SIMULATE_STEWARD_SUCCESS")){ transaction.setStatus(Status.SUCCESS); - transaction.setResponseCode("responseCode"); + transaction.setResponseCode("02"); transaction.setCreditRef("Simulated Integration Success"); transaction.setErrorMessage(null); return transaction; diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/payments/EcocashRemotePaymentProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/payments/EcocashRemotePaymentProcessor.java index 1febe40..a357c02 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/payments/EcocashRemotePaymentProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/payments/EcocashRemotePaymentProcessor.java @@ -43,7 +43,7 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf if(settingService.getBooleanSetting("SIMULATE_ECOCASH_SUCCESS")) { transaction.setStatus(Status.SUCCESS); - transaction.setResponseCode("00"); + transaction.setResponseCode("02"); transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10)); transaction.setDebitRef(RandomStringUtils.randomNumeric(10)); return transaction; @@ -74,7 +74,7 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf if(settingService.getBooleanSetting("SIMULATE_ECOCASH_SUCCESS")) { transaction.setStatus(Status.SUCCESS); - transaction.setResponseCode("00"); + transaction.setResponseCode("02"); transaction.setTargetUrl("/home"); return transaction; } diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/payments/MPGSWebPaymentProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/payments/MPGSWebPaymentProcessor.java index ffbf084..fe268f2 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/payments/MPGSWebPaymentProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/payments/MPGSWebPaymentProcessor.java @@ -53,7 +53,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface { if(settingService.getBooleanSetting("SIMULATE_MPGS_SUCCESS")) { transaction.setStatus(Status.SUCCESS); - transaction.setResponseCode("00"); + transaction.setResponseCode("02"); transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10)); transaction.setDebitRef(RandomStringUtils.randomNumeric(10)); return transaction; @@ -80,7 +80,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface { if(settingService.getBooleanSetting("SIMULATE_MPGS_SUCCESS")) { transaction.setStatus(Status.SUCCESS); - transaction.setResponseCode("00"); + transaction.setResponseCode("02"); transaction.setTargetUrl("/home"); return transaction; } diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/BatchWorkflow.java b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/BatchWorkflow.java new file mode 100644 index 0000000..36c437f --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/BatchWorkflow.java @@ -0,0 +1,101 @@ +package zw.qantra.tm.domain.services.processors.workflows; + +import io.nflow.engine.workflow.curated.State; +import io.nflow.engine.workflow.definition.NextAction; +import io.nflow.engine.workflow.definition.StateExecution; +import io.nflow.engine.workflow.definition.WorkflowDefinition; +import io.nflow.engine.workflow.definition.WorkflowStateType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import zw.qantra.tm.domain.enums.AuthType; +import zw.qantra.tm.domain.models.GroupBatch; +import zw.qantra.tm.domain.services.GroupBatchWorkflowService; + +import java.util.UUID; + +@Component +@Slf4j +public class BatchWorkflow extends WorkflowDefinition { + public static final String TYPE = "batchWorkflow"; + + public static final State CONFIRM = new State("confirm", WorkflowStateType.start); + public static final State CONFIRM_SUCCESS = new State("confirmSuccess", WorkflowStateType.manual); + public static final State REQUEST = new State("request"); + public static final State REQUEST_SUCCESS = new State("requestSuccess", WorkflowStateType.manual); + public static final State POLL = new State("poll"); + public static final State INTEGRATION = new State("integration"); + public static final State FAILED = new State("failed", WorkflowStateType.manual); + public static final State DONE = new State("done", WorkflowStateType.end); + + @Autowired + private GroupBatchWorkflowService groupBatchWorkflowService; + + public BatchWorkflow() { + super(TYPE, CONFIRM, FAILED); + + permit(CONFIRM, CONFIRM_SUCCESS, FAILED); + permit(CONFIRM_SUCCESS, REQUEST); + permit(REQUEST, REQUEST_SUCCESS, FAILED); + permit(REQUEST_SUCCESS, POLL); + permit(POLL, INTEGRATION, FAILED); + permit(INTEGRATION, DONE, FAILED); + } + + public NextAction confirm(StateExecution execution) { + try { + UUID batchId = UUID.fromString(execution.getVariable("batchId", String.class)); + GroupBatch batch = groupBatchWorkflowService.processConfirm(batchId); + execution.setVariable("batchStatus", batch.getStatus().name()); + return NextAction.moveToState(CONFIRM_SUCCESS, "Batch confirm complete"); + } catch (Exception e) { + e.printStackTrace(); + return NextAction.moveToState(FAILED, e.getMessage()); + } + } + + public void confirmSuccess(StateExecution execution) { + log.info("Batch confirm paused for request trigger"); + } + + public NextAction request(StateExecution execution) { + try { + UUID batchId = UUID.fromString(execution.getVariable("batchId", String.class)); + String authTypeValue = execution.getVariable("authType", String.class); + AuthType authType = authTypeValue == null ? AuthType.WEB : AuthType.valueOf(authTypeValue); + GroupBatch batch = groupBatchWorkflowService.processRequest(batchId, authType); + execution.setVariable("batchStatus", batch.getStatus().name()); + execution.setVariable("body", batch.getPaymentTransactionId()); + return NextAction.moveToState(REQUEST_SUCCESS, "Batch request complete"); + } catch (Exception e) { + return NextAction.moveToState(FAILED, e.getMessage()); + } + } + + public void requestSuccess(StateExecution execution) { + log.info("Batch request paused for poll trigger"); + } + + public NextAction poll(StateExecution execution) { + try { + UUID batchId = UUID.fromString(execution.getVariable("batchId", String.class)); + GroupBatch batch = groupBatchWorkflowService.processPoll(batchId); + execution.setVariable("batchStatus", batch.getStatus().name()); + return NextAction.moveToState(INTEGRATION, "Batch poll complete"); + } catch (Exception e) { + return NextAction.moveToState(FAILED, e.getMessage()); + } + } + + public NextAction integration(StateExecution execution) { + try { + UUID batchId = UUID.fromString(execution.getVariable("batchId", String.class)); + GroupBatch batch = groupBatchWorkflowService.processIntegration(batchId); + execution.setVariable("batchStatus", batch.getStatus().name()); + return NextAction.moveToState(DONE, "Batch integration complete"); + } catch (Exception e) { + return NextAction.moveToState(FAILED, e.getMessage()); + } + } +} + diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/PurchaseInvoiceWorkflow.java b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/PurchaseInvoiceWorkflow.java deleted file mode 100644 index aa111ac..0000000 --- a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/PurchaseInvoiceWorkflow.java +++ /dev/null @@ -1,82 +0,0 @@ -package zw.qantra.tm.domain.services.processors.workflows; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.nflow.engine.workflow.curated.State; -import io.nflow.engine.workflow.definition.NextAction; -import io.nflow.engine.workflow.definition.StateExecution; -import io.nflow.engine.workflow.definition.WorkflowDefinition; -import io.nflow.engine.workflow.definition.WorkflowStateType; -import lombok.extern.slf4j.Slf4j; -import org.joda.time.DateTime; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import zw.qantra.tm.domain.models.Transaction; -import zw.qantra.tm.domain.services.processors.workflows.handlers.HandlerInterface; -import zw.qantra.tm.domain.services.processors.workflows.handlers.WorkflowHandlerFactory; -import zw.qantra.tm.utils.Utils; - -import java.util.LinkedHashMap; - -@Component -@Slf4j -public class PurchaseInvoiceWorkflow extends WorkflowDefinition { - @Autowired - private WorkflowHandlerFactory workflowHandlerFactory; - @Autowired - private ObjectMapper objectMapper; - - public static final String TYPE = "purchaseInvoiceWorkflow"; - - public static final State PURCHASE_INVOICE = new State("purchaseInvoice", WorkflowStateType.start); - public static final State PURCHASE_INVOICE_PAYMENT = new State("purchaseInvoicePayment"); - - public static final State TRAN_FAILED = new State("failed", WorkflowStateType.manual); - public static final State DONE = new State("done", WorkflowStateType.end); - - public PurchaseInvoiceWorkflow() { - super(TYPE, PURCHASE_INVOICE, TRAN_FAILED); - - permit(PURCHASE_INVOICE, PURCHASE_INVOICE_PAYMENT); - permit(PURCHASE_INVOICE_PAYMENT, DONE); - } - - public NextAction purchaseInvoice(StateExecution execution) throws Exception { - try { - LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); - byte[] json = objectMapper.writeValueAsBytes(map); - Transaction transaction = objectMapper.readValue(json, Transaction.class); - - HandlerInterface handlerInterface = - (HandlerInterface) workflowHandlerFactory.getWorkflowHandler("purchaseInvoice"); - transaction = (Transaction) handlerInterface.process(transaction); - - execution.setVariable("body", Utils.toJson(transaction)); - return NextAction.moveToState(PURCHASE_INVOICE_PAYMENT, - "Purchase invoice successful - id: " + transaction.getErpPurchaseRef()); - - }catch (Exception e) { - e.printStackTrace(); - return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); - } - } - - public NextAction purchaseInvoicePayment(StateExecution execution) { - try { - LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); - byte[] json = objectMapper.writeValueAsBytes(map); - Transaction transaction = objectMapper.readValue(json, Transaction.class); - - HandlerInterface handlerInterface = - (HandlerInterface) workflowHandlerFactory.getWorkflowHandler("purchaseInvoicePayment"); - transaction = (Transaction) handlerInterface.process(transaction); - - execution.setVariable("body", Utils.toJson(transaction)); - - return NextAction.moveToState(DONE, - "Purchase invoice payment generated - id: " + transaction.getErpPurchasePaymentRef()); - }catch (Exception e) { - e.printStackTrace(); - return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); - } - } -} diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/RegistrationWorkflow.java b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/RegistrationWorkflow.java index df0a4a5..72999bd 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/RegistrationWorkflow.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/RegistrationWorkflow.java @@ -117,8 +117,8 @@ public class RegistrationWorkflow extends WorkflowDefinition { // Generate OTP Otp otp = otpService.generateOtp(username, "REGISTRATION"); - String string = String.format("Your Peak verification code is: %s", otp.getOtp()); - emailService.sendSimpleMessage(email, "Peak verification code", string); + String string = String.format("Your Velocity Pay verification code is: %s", otp.getOtp()); + emailService.sendSimpleMessage(email, "Velocity Pay verification code", string); // LinkedHashMap response = restService.postAs(infobipUrl, string, headers, LinkedHashMap.class); log.info("otp generated successfully: {}", otp.getOtp()); diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/SalesInvoiceWorkflow.java b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/SalesInvoiceWorkflow.java deleted file mode 100644 index 6e82751..0000000 --- a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/SalesInvoiceWorkflow.java +++ /dev/null @@ -1,107 +0,0 @@ -package zw.qantra.tm.domain.services.processors.workflows; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.nflow.engine.workflow.curated.State; -import io.nflow.engine.workflow.definition.NextAction; -import io.nflow.engine.workflow.definition.StateExecution; -import io.nflow.engine.workflow.definition.WorkflowDefinition; -import io.nflow.engine.workflow.definition.WorkflowStateType; -import lombok.extern.slf4j.Slf4j; -import org.joda.time.DateTime; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import zw.qantra.tm.domain.models.Transaction; -import zw.qantra.tm.domain.services.TransactionService; -import zw.qantra.tm.domain.services.processors.workflows.handlers.HandlerInterface; -import zw.qantra.tm.domain.services.processors.workflows.handlers.WorkflowHandlerFactory; -import zw.qantra.tm.utils.Utils; - -import java.util.LinkedHashMap; - -@Component -@Slf4j -public class SalesInvoiceWorkflow extends WorkflowDefinition { - @Autowired - private WorkflowHandlerFactory workflowHandlerFactory; - @Autowired - private TransactionService transactionService; - @Autowired - private ObjectMapper objectMapper; - - public static final String TYPE = "salesInvoiceWorkflow"; - - public static final State SALES_INVOICE = new State("salesInvoice", WorkflowStateType.start); - public static final State JOURNAL_ENTRY = new State("journalEntry"); - public static final State SALES_PAYMENT = new State("salesPayment"); - - public static final State TRAN_FAILED = new State("failed", WorkflowStateType.manual); - public static final State DONE = new State("done", WorkflowStateType.end); - - SalesInvoiceWorkflow() { - super(TYPE, SALES_INVOICE, TRAN_FAILED); - - permit(SALES_INVOICE, JOURNAL_ENTRY); - permit(JOURNAL_ENTRY, SALES_PAYMENT); - permit(SALES_PAYMENT, DONE); - } - - public NextAction salesInvoice(StateExecution execution) { - try { - // cannot cast direct to Transaction object because of some jackson mapper hullabaloo :( - LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); - byte[] json = objectMapper.writeValueAsBytes(map); - Transaction transaction = objectMapper.readValue(json, Transaction.class); - - HandlerInterface handlerInterface = - (HandlerInterface) workflowHandlerFactory.getWorkflowHandler("salesInvoice"); - transaction = (Transaction) handlerInterface.process(transaction); - - execution.setVariable("body", Utils.toJson(transaction)); - return NextAction.moveToState(JOURNAL_ENTRY, - "Sales invoice generated - id:" + transaction.getErpSalesRef()); - }catch (Exception e) { - e.printStackTrace(); - return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); - } - } - - public NextAction journalEntry(StateExecution execution) { - try { - LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); - byte[] json = objectMapper.writeValueAsBytes(map); - Transaction transaction = objectMapper.readValue(json, Transaction.class); - - HandlerInterface handlerInterface = - (HandlerInterface) workflowHandlerFactory.getWorkflowHandler("journalEntry"); - transaction = (Transaction) handlerInterface.process(transaction); - - execution.setVariable("body", Utils.toJson(transaction)); - return NextAction.moveToState(SALES_PAYMENT, - "Journal Entry successful - id: " + transaction.getErpJournalRef()); - }catch (Exception e) { - e.printStackTrace(); - return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); - } - } - - public NextAction salesPayment(StateExecution execution) { - try { - LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class); - byte[] json = objectMapper.writeValueAsBytes(map); - Transaction transaction = objectMapper.readValue(json, Transaction.class); - - HandlerInterface handlerInterface = - (HandlerInterface) workflowHandlerFactory.getWorkflowHandler("salesPayment"); - transaction = (Transaction) handlerInterface.process(transaction); - - execution.setVariable("body", Utils.toJson(transaction)); - - return NextAction.moveToState(DONE, - "Sales payment successful - id: " + transaction.getErpSalesPaymentRef()); - }catch (Exception e) { - e.printStackTrace(); - return NextAction.retryAfter(new DateTime().plusMinutes(1), e.getMessage()); - } - } - -} diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/StartupApplicationListener.java b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/StartupApplicationListener.java index 0fa8848..f37d259 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/workflows/StartupApplicationListener.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/workflows/StartupApplicationListener.java @@ -1,14 +1,15 @@ package zw.qantra.tm.domain.services.processors.workflows; -import io.nflow.engine.service.WorkflowInstanceService; -import io.nflow.engine.workflow.instance.WorkflowInstanceFactory; import lombok.RequiredArgsConstructor; +import org.jobrunr.scheduling.BackgroundJob; +import org.jobrunr.scheduling.cron.Cron; import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationListener; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.stereotype.Component; +import zw.qantra.tm.domain.services.JobService; @Component @RequiredArgsConstructor @@ -16,18 +17,12 @@ public class StartupApplicationListener implements ApplicationListener - - - - - - - - - - - - - - - - - - + + +