completed group batch feature

This commit is contained in:
2026-06-08 09:18:06 +02:00
parent c87f1e5e23
commit 24f435a09f
60 changed files with 2006 additions and 323 deletions

BIN
data/jobrunr.mv.db Normal file

Binary file not shown.

20
pom.xml
View File

@@ -107,6 +107,11 @@
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-joda</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
@@ -183,7 +188,20 @@
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>
<dependency>
<groupId>org.jobrunr</groupId>
<artifactId>jobrunr-spring-boot-3-starter</artifactId>
<version>8.6.1</version>
</dependency>
<!-- Apache POI for XLSX generation -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
</dependencies>
<build>
<plugins>

View File

@@ -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();
}
}

View File

@@ -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 + "/");
}
}

View File

@@ -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()

View File

@@ -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<GroupBatch> spec, Pageable pageable){
return ResponseEntity.ok(groupBatchService.findAll(spec, pageable));
}
@PostMapping
public ResponseEntity<GroupBatch> create(@RequestBody GroupBatchCreateRequest request) {
return ResponseEntity.ok(groupBatchService.createBatch(request));
}
@PutMapping("/{batchId}")
public ResponseEntity<GroupBatch> update(@PathVariable UUID batchId,
@RequestBody GroupBatchUpdateRequest request) {
request.setId(batchId);
return ResponseEntity.ok(groupBatchService.updateBatch(request));
}
@PostMapping("/{batchId}/confirm")
public ResponseEntity<GroupBatch> 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<GroupBatch> 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<GroupBatchItem> spec, Pageable pageable) {
return ResponseEntity.ok(groupBatchService.getBatchItems(spec, pageable));
}
@DeleteMapping("/{batchId}")
public ResponseEntity<Void> deleteBatch(@PathVariable UUID batchId,
@RequestParam String userId) {
groupBatchService.deleteBatch(batchId, userId);
return ResponseEntity.noContent().build();
}
@GetMapping("/{batchId}/report")
public ResponseEntity<Map<String, Object>> 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()
));
}
}

View File

@@ -22,9 +22,17 @@ public class RecipientController {
private final RecipientService recipientService;
@GetMapping
public ResponseEntity<List<Recipient>> getAllRecipients() {
return ResponseEntity.ok(recipientService.getAllRecipients());
@GetMapping("/search")
public ResponseEntity<List<Recipient>> 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<Recipient> 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<List<Recipient>> 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<Recipient> specification) {
return ResponseEntity.ok(recipientService.searchRecipients(specification));
}
}
}

View File

@@ -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<RecipientGroup> spec, Pageable pageable) {
return ResponseEntity.ok(recipientGroupService.findAll(spec, pageable));
}
@PostMapping
public ResponseEntity<RecipientGroup> 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<RecipientGroup> update(@PathVariable UUID groupId,
@RequestBody RecipientGroupUpdateRequest request) {
return ResponseEntity.ok(recipientGroupService.update(
groupId,
request.getUserId(),
request.getName(),
request.getDescription()));
}
@DeleteMapping("/delete/{groupId}")
public ResponseEntity<Void> delete(@PathVariable UUID groupId,
@RequestParam String userId) {
recipientGroupService.delete(groupId, userId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{groupId}/members")
public ResponseEntity<List<RecipientGroupMember>> addMembersByIds(@PathVariable UUID groupId,
@RequestBody GroupMemberRequest request) {
return ResponseEntity.ok(recipientGroupService.addMembersByIds(
groupId,
request.getUserId(),
request.getRecipientIds()));
}
@DeleteMapping("/{groupId}/members/{recipientId}")
public ResponseEntity<Void> 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<RecipientGroupMember> spec, Pageable pageable) {
return ResponseEntity.ok(recipientGroupService.findAllMembers(spec, pageable));
}
}

View File

@@ -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),
})

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<UUID> recipientIds;
}

View File

@@ -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<Recipient> recipients;
}

View File

@@ -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;
}

View File

@@ -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")

View File

@@ -0,0 +1,8 @@
package zw.qantra.tm.domain.enums;
public enum GroupBatchItemConfirmStatus {
PENDING,
CONFIRMED,
FAILED
}

View File

@@ -0,0 +1,9 @@
package zw.qantra.tm.domain.enums;
public enum GroupBatchItemIntegrationStatus {
PENDING,
INTEGRATED,
FAILED,
SKIPPED
}

View File

@@ -0,0 +1,11 @@
package zw.qantra.tm.domain.enums;
public enum GroupBatchItemStatus {
NEW,
CONFIRMED,
READY_FOR_INTEGRATION,
INTEGRATED,
FAILED,
SKIPPED
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
package zw.qantra.tm.domain.enums;
public enum PartyType {
INDIVIDUAL,
GROUP,
BATCH
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<GroupBatchItem, UUID>, JpaSpecificationExecutor<GroupBatchItem> {
List<GroupBatchItem> findAllByGroupBatchId(UUID groupBatchId);
List<GroupBatchItem> findAllByGroupBatchIdAndConfirmStatus(UUID groupBatchId, GroupBatchItemConfirmStatus status);
long countByGroupBatchIdAndConfirmStatus(UUID groupBatchId, GroupBatchItemConfirmStatus status);
long countByGroupBatchIdAndIntegrationStatus(UUID groupBatchId, GroupBatchItemIntegrationStatus status);
}

View File

@@ -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<GroupBatch, UUID>, JpaSpecificationExecutor<GroupBatch> {
Optional<GroupBatch> findByIdAndUserId(UUID id, String userId);
}

View File

@@ -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<RecipientGroupMember, UUID>, JpaSpecificationExecutor<RecipientGroupMember> {
List<RecipientGroupMember> findAllByRecipientGroupId(UUID recipientGroupId);
boolean existsByRecipientGroupIdAndRecipientId(UUID recipientGroupId, UUID recipientId);
void deleteAllByRecipientGroupId(UUID recipientGroupId);
}

View File

@@ -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<RecipientGroup, UUID>, JpaSpecificationExecutor<RecipientGroup> {
List<RecipientGroup> findAllByUserId(String userId);
Optional<RecipientGroup> findByIdAndUserId(UUID id, String userId);
}

View File

@@ -19,5 +19,5 @@ public interface TransactionRepository extends JpaRepository<Transaction, UUID>,
List<Transaction> findAllByUserId(String uid);
List<Transaction> findAllByConfirmationStatusAndPaymentStatusAndIntegrationStatus(
Status s1, Status s2, Status s3);
List<Transaction> findAllByConfirmationStatusAndIntegrationStatus(Status s1, Status s2);
List<Transaction> findAllByPaymentStatusAndPollingStatus(Status s1, Status s2);
}

View File

@@ -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<GroupBatchStatus> 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<GroupBatchItem> 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;
}
}
}

View File

@@ -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<GroupBatchStatus> UPDATABLE_STATUSES = Set.of(
GroupBatchStatus.CREATED
);
private static final Set<GroupBatchStatus> 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<RecipientGroupMember> 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<Object> 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<Object> 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<GroupBatchItem> getBatchItems(Specification<GroupBatchItem> spec, Pageable pageable) {
return groupBatchItemRepository.findAll(spec, pageable);
}
public Page<GroupBatch> findAll(Specification<GroupBatch> 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<GroupBatchItem> 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);
}
}

View File

@@ -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<GroupBatchStatus> 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<GroupBatchItem> items = groupBatchItemRepository.findAllByGroupBatchId(batchId);
List<CompletableFuture<Void>> 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<GroupBatchStatus> 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<GroupBatchItem> 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<GroupBatchItem> items = groupBatchItemRepository.findAllByGroupBatchId(batchId);
List<CompletableFuture<Void>> 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<GroupBatchItem> 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"));
}
}

View File

@@ -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<Transaction> 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<String, String> 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);
}
}

View File

@@ -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<Recipient> recipients) {
RecipientGroup savedGroup = recipientGroupRepository.save(group);
if (recipients != null && !recipients.isEmpty()) {
addMembers(savedGroup.getId(), savedGroup.getUserId(), recipients);
}
return savedGroup;
}
@Transactional
public List<RecipientGroupMember> addMembers(UUID groupId, String userId, List<Recipient> recipients) {
get(groupId, userId);
List<RecipientGroupMember> 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<RecipientGroup> findAll(Specification<RecipientGroup> spec, Pageable pageable) {
return recipientGroupRepository.findAll(spec, pageable);
}
public List<RecipientGroup> 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<RecipientGroupMember> members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
members.forEach(member -> member.setDeleted(true));
recipientGroupMemberRepository.saveAll(members);
}
@Transactional
public List<RecipientGroupMember> addMembersByIds(UUID groupId, String userId, List<UUID> recipientIds) {
get(groupId, userId);
List<RecipientGroupMember> 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<Recipient> 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<RecipientGroupMember> members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
RecipientGroupMember target = members.stream()
.filter(item -> recipientId.equals(item.getRecipientUid()))
.findFirst()
.orElseThrow(() -> new ApiException("Group member not found"));
target.setDeleted(true);
recipientGroupMemberRepository.save(target);
}
public List<RecipientGroupMember> members(UUID groupId, String userId) {
get(groupId, userId);
return recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
}
public Page<RecipientGroupMember> findAllMembers(Specification<RecipientGroupMember> spec, Pageable pageable) {
return recipientGroupMemberRepository.findAll(spec, pageable);
}
}

View File

@@ -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())

View File

@@ -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;
}

View File

@@ -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<String, Object> 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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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());
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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());

View File

@@ -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());
}
}
}

View File

@@ -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<ContextRe
private final Logger log = LoggerFactory.getLogger(this.getClass());
private final WorkflowInstanceService workflowInstanceService;
private final WorkflowInstanceFactory workflowInstanceFactory;
private final JobService jobService;
@Override public void onApplicationEvent(ContextRefreshedEvent event) {
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(PollCronWorkflow.TYPE)
.setExternalId("PollCronWorkflow")
.setNextActivation(DateTime.now())
.build();
log.info("Background Job Enqueued: {}", DateTime.now());
long workflowId = workflowInstanceService.insertWorkflowInstance(instance);
log.info("Cron workflow ID {} started", workflowId);
BackgroundJob.scheduleRecurrently(
"orphan-transactions-manager", Cron.every5minutes(), jobService::checkOrphanedTransactions);
}
}

View File

@@ -1,12 +1,10 @@
package zw.qantra.tm.domain.services.processors.workflows;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import io.nflow.engine.service.WorkflowInstanceService;
@@ -18,6 +16,7 @@ import zw.qantra.tm.utils.Utils;
import static io.nflow.engine.service.WorkflowInstanceInclude.*;
@Slf4j
@Component
@RequiredArgsConstructor
public class WorkflowUtils {
@@ -60,10 +59,21 @@ public class WorkflowUtils {
}catch (IndexOutOfBoundsException exception){
action = null;
}
String bodyStr = instance.getStateVariable("body");
Object body = bodyStr;
try {
Object parsed = Utils.fromJson(bodyStr, Map.class);
if (parsed != null) {
body = parsed;
}
} catch (Exception e) {
log.debug("Error parsing state variable 'body' as JSON: {}", e.getMessage());
}
return StateResponse.builder()
.state(instance.state)
.status(instance.status.toString())
.body(Utils.fromJson(instance.getStateVariable("body"), Map.class))
.body(body)
.message(action != null ? action.stateText : null)
.workflowId(instance.id.toString())
.externalId(instance.externalId)

View File

@@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.PartyType;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Provider;
@@ -28,12 +29,14 @@ public class CleanupHandler implements HandlerInterface {
public Object process(Transaction transaction) throws Exception {
// MAKE SURE IMPORTANT FIELDS ARE SET
// =======================
if(transaction.getType().equals(RequestType.CONFIRM)) {
transaction.setTrace(Utils.getUid());
transaction.setReference(UUID.randomUUID().toString());
transaction.setConfirmationStatus(Status.PENDING);
transaction.setPaymentStatus(Status.PENDING);
transaction.setIntegrationStatus(Status.PENDING);
transaction.setTrace(Utils.getUid());
transaction.setReference(UUID.randomUUID().toString());
transaction.setConfirmationStatus(Status.PENDING);
transaction.setPaymentStatus(Status.PENDING);
transaction.setIntegrationStatus(Status.PENDING);
if(transaction.getPartyType() == null) {
transaction.setPartyType(PartyType.INDIVIDUAL);
}
// UPDATE TRAN WITH CONFIRMATION HANDLER

View File

@@ -106,8 +106,8 @@ public class IntegrationHandler implements HandlerInterface {
lock.add(uid);
}
private boolean removeLock(String uid) {
return lock.remove(uid);
private void removeLock(String uid) {
lock.remove(uid);
}
private boolean isLocked(String uid) {

View File

@@ -10,11 +10,13 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import lombok.extern.slf4j.Slf4j;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
@Slf4j
public class Utils {
public static String formatPhoneNumber(String phoneNumber, String region) {
@@ -76,7 +78,7 @@ public class Utils {
try {
obj = mapper.readValue(json, clazz);
} catch (JsonProcessingException e) {
e.printStackTrace();
log.debug("Error deserializing JSON: {}", e.getMessage());
}
return (T) obj;

View File

@@ -38,9 +38,20 @@ powertel.clientid=powertel_zesa
ecocash.apikey=OUzGezwTgktLg_9v0I48I6WUM4LVLGFl
ecocash.url=https://developers.ecocash.co.zw/api/ecocash_pay/api/v2/payment/instant/c2b/sandbox
velocity.api.base-url=https://api.velocityafrica.net
velocity.api.api-key=z5WdSlItIhYL6nd7rxzf-jFdKN3jJyL1LubhDFBvXmc
#velocity.api.base-url=https://api.velocityafrica.net
#velocity.api.api-key=z5WdSlItIhYL6nd7rxzf-jFdKN3jJyL1LubhDFBvXmc
#velocity.api.base-url=http://localhost:9005
#velocity.api.api-key=gH13nirZdUQdqC9impgzTzqEqv-jdbLOTx_HD5fQr_A
velocity.api.cancel-url=http://localhost:9005/poll
velocity.api.success-url=http://localhost:9005/poll
velocity.api.success-url=http://localhost:9005/poll
jobrunr.background-job-server.enabled=true
jobrunr.dashboard.enabled=true
jobrunr.dashboard.port=8058
jobrunr.database.datasource=jobrunrDataSource
jobrunr.datasource.url=jdbc:h2:file:./data/jobrunr;DB_CLOSE_ON_EXIT=FALSE;AUTO_RECONNECT=TRUE
jobrunr.datasource.driver-class-name=org.h2.Driver
jobrunr.datasource.username=sa
jobrunr.datasource.password=
app.base-url=http://localhost:6950

View File

@@ -39,4 +39,13 @@ ecocash.apikey=OUzGezwTgktLg_9v0I48I6WUM4LVLGFl
ecocash.url=https://developers.ecocash.co.zw/api/ecocash_pay/api/v2/payment/instant/c2b/sandbox
velocity.api.cancel-url=http://localhost:9005/poll
velocity.api.success-url=http://localhost:9005/poll
velocity.api.success-url=http://localhost:9005/poll
jobrunr.background-job-server.enabled=true
jobrunr.dashboard.enabled=true
jobrunr.dashboard.port=8058
jobrunr.database.datasource=jobrunrDataSource
jobrunr.datasource.url=jdbc:h2:file:./data/jobrunr;DB_CLOSE_ON_EXIT=FALSE;AUTO_RECONNECT=TRUE
jobrunr.datasource.driver-class-name=org.h2.Driver
jobrunr.datasource.username=sa
jobrunr.datasource.password=

View File

@@ -69,4 +69,18 @@ hot.cache.duration.minutes=5
velocity.api.base-url=https://api.velocityafrica.net
velocity.api.api-key=cHE08ADmiATtf8VYgA59-wKVKVKj2WusKDAaaRLDsG8
velocity.api.cancel-url=https://pay.velocityafrica.net/poll
velocity.api.success-url=https://pay.velocityafrica.net/poll
velocity.api.success-url=https://pay.velocityafrica.net/poll
jobrunr.background-job-server.enabled=true
jobrunr.dashboard.enabled=true
jobrunr.dashboard.port=8058
jobrunr.database.datasource=jobrunrDataSource
jobrunr.datasource.url=jdbc:h2:file:./data/jobrunr;DB_CLOSE_ON_EXIT=FALSE;AUTO_RECONNECT=TRUE
jobrunr.datasource.driver-class-name=org.h2.Driver
jobrunr.datasource.username=sa
jobrunr.datasource.password=
# Batch report storage
app.reports.storage-path=./reports
app.base-url=https://pay.velocityafrica.net

View File

@@ -1,23 +1,8 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" xmlns:pro="http://www.liquibase.org/xml/ns/pro" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet author="khoza (generated)" id="1780329348054-3">
<addColumn tableName="currency">
<column name="bank_rate" type="varchar(255)"/>
</addColumn>
</changeSet>
<changeSet author="khoza (generated)" id="1780329348054-4">
<addColumn tableName="transaction">
<column name="credit_amount" type="numeric(38, 2)"/>
</addColumn>
</changeSet>
<changeSet author="khoza (generated)" id="1780329348054-5">
<addColumn tableName="provider">
<column name="currency" type="varchar(255)"/>
</addColumn>
</changeSet>
<changeSet author="khoza (generated)" id="1780329348054-6">
<addColumn tableName="currency">
<column name="forecast_rate" type="varchar(255)"/>
<changeSet author="khoza (generated)" id="1780675189842-3">
<addColumn tableName="group_batch">
<column name="result_file_url" type="TEXT"/>
</addColumn>
</changeSet>
</databaseChangeLog>