diff --git a/data/jobrunr.mv.db b/data/jobrunr.mv.db new file mode 100644 index 0000000..dc305ea Binary files /dev/null and b/data/jobrunr.mv.db differ diff --git a/pom.xml b/pom.xml index 1dc43bd..a5ac904 100644 --- a/pom.xml +++ b/pom.xml @@ -107,6 +107,11 @@ com.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 0000000..4c7a1af Binary files /dev/null and b/reports/batch-report-b4b19592-6d9d-4892-a82e-be7c0810fa6a.xlsx differ diff --git a/reports/batch-report-bffebe3e-1d84-48be-acd4-5213ef61fb4f.xlsx b/reports/batch-report-bffebe3e-1d84-48be-acd4-5213ef61fb4f.xlsx new file mode 100644 index 0000000..493991e Binary files /dev/null and b/reports/batch-report-bffebe3e-1d84-48be-acd4-5213ef61fb4f.xlsx differ 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 0000000..49e4d27 Binary files /dev/null and b/reports/batch-report-e72b4959-fd95-4227-a9b4-1754c89231e9.xlsx differ diff --git a/src/main/java/zw/qantra/tm/configs/DatabaseConfiguration.java b/src/main/java/zw/qantra/tm/configs/DatabaseConfiguration.java index 525a98d..e31c34f 100644 --- a/src/main/java/zw/qantra/tm/configs/DatabaseConfiguration.java +++ b/src/main/java/zw/qantra/tm/configs/DatabaseConfiguration.java @@ -27,4 +27,19 @@ public class DatabaseConfiguration { .initializeDataSourceBuilder() .build(); } + + // JobRunr DataSource Properties (H2) + @Bean + @ConfigurationProperties(prefix = "jobrunr.datasource") + public DataSourceProperties jobrunrDataSourceProperties() { + return new DataSourceProperties(); + } + + // JobRunr DataSource using H2 + @Bean + public DataSource jobrunrDataSource() { + return jobrunrDataSourceProperties() + .initializeDataSourceBuilder() + .build(); + } } \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/configs/ReportsResourceConfig.java b/src/main/java/zw/qantra/tm/configs/ReportsResourceConfig.java new file mode 100644 index 0000000..e8dccbd --- /dev/null +++ b/src/main/java/zw/qantra/tm/configs/ReportsResourceConfig.java @@ -0,0 +1,22 @@ +package zw.qantra.tm.configs; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.nio.file.Path; + +@Configuration +public class ReportsResourceConfig implements WebMvcConfigurer { + + @Value("${app.reports.storage-path:./reports}") + private String reportsStoragePath; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + Path reportsDir = Path.of(reportsStoragePath).toAbsolutePath(); + registry.addResourceHandler("/reports/**") + .addResourceLocations("file:" + reportsDir + "/"); + } +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/configs/SecurityConfig.java b/src/main/java/zw/qantra/tm/configs/SecurityConfig.java index fbc73e6..0425f29 100644 --- a/src/main/java/zw/qantra/tm/configs/SecurityConfig.java +++ b/src/main/java/zw/qantra/tm/configs/SecurityConfig.java @@ -30,8 +30,9 @@ public class SecurityConfig { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> 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 - - - - - - - - - - - - - - - - - - + + +