Compare commits

30 Commits

Author SHA1 Message Date
d53a427a5a minor fixes 2026-07-04 20:14:00 +02:00
525f466541 minor fixes 2026-07-04 19:08:36 +02:00
b983a9c301 updating hot logic for zesa 2026-07-01 15:52:17 +02:00
858639987b updating currency conversion logic 2026-07-01 15:32:26 +02:00
a2c04e4139 updating currency conversion logic 2026-07-01 15:28:02 +02:00
443e8fd21b adding batch it to batch trans 2026-07-01 13:41:44 +02:00
ee594cb276 fixing report downloads 2026-07-01 13:03:13 +02:00
e634ff11c7 status fixes 2026-06-29 17:46:34 +02:00
1e261dc3fe updating status changes 2026-06-29 17:11:32 +02:00
f5cc3a7bfa updating status changes 2026-06-29 16:36:30 +02:00
5d923b954e updating status changes 2026-06-29 16:00:24 +02:00
545ce6c704 updating status changes 2026-06-29 15:46:43 +02:00
51259aab33 updating status changes 2026-06-29 15:07:45 +02:00
09165b64e5 bug fix 2026-06-28 16:11:41 +02:00
8ff6e5f8ea bug fixes 2026-06-25 01:08:55 +02:00
dc0c42d12e bug fixes 2026-06-25 00:39:58 +02:00
db1fe2672a added uptime monitoring 2026-06-24 00:30:07 +02:00
a84a1000a8 minor fixes 2026-06-23 00:22:47 +02:00
299cdf6e35 completed migration from user to workspace 2026-06-22 22:11:20 +02:00
598287b0cc polishing the workspace migration 2026-06-22 13:40:58 +02:00
Prince
caf4e4bc89 added version control & cloudflare cache clearing 2026-06-21 00:25:15 +02:00
Prince
a367acd81d fixing issues with tests 2026-06-20 02:51:45 +02:00
Prince
4d0e18c1a6 added batch file upload feature 2026-06-20 02:33:37 +02:00
Prince
dcb917e899 changin batch integration to asynch 2026-06-17 21:38:55 +02:00
Prince
6c3ef60665 update file storage path 2026-06-17 15:09:28 +02:00
Prince
6ccded7fce added zwg support 2026-06-17 14:43:56 +02:00
Prince
0eda74414e updates on batch logic 2026-06-17 01:11:48 +02:00
Prince
5d3c9a46a3 improved polling logi 2026-06-12 23:48:47 +02:00
Prince
573e4b154c added model indexes 2026-06-12 22:33:58 +02:00
Prince
c6ce11026c added sms integration 2026-06-12 11:56:25 +02:00
117 changed files with 4979 additions and 421 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ build/
.vscode/ .vscode/
logs/ logs/
data/ data/
reports/

View File

@@ -2,6 +2,13 @@ FROM 144.91.121.112:8082/java-17
RUN mkdir /app RUN mkdir /app
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
# Install fontconfig and fonts for Apache POI XSSFWorkbook font rendering
RUN apt-get update && apt-get install -y --no-install-recommends \
fontconfig \
fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
RUN mvn clean compile -P production package RUN mvn clean compile -P production package
ENTRYPOINT ["/usr/bin/dumb-init", "--"] ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["java", "-Dspring.profiles.active=nflow.db.postgresql", "-jar", "/app/target/tm-0.0.1-RELEASE.jar"] CMD ["java", "-Djava.awt.headless=true", "-Dspring.profiles.active=nflow.db.postgresql", "-jar", "/app/target/tm-0.0.1-RELEASE.jar"]

12
pom.xml
View File

@@ -83,11 +83,6 @@
<artifactId>jakarta.persistence-api</artifactId> <artifactId>jakarta.persistence-api</artifactId>
<version>3.1.0</version> <version>3.1.0</version>
</dependency> </dependency>
<dependency>
<groupId>com.googlecode.libphonenumber</groupId>
<artifactId>libphonenumber</artifactId>
<version>8.12.10</version>
</dependency>
<dependency> <dependency>
<groupId>com.google.code.gson</groupId> <groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId> <artifactId>gson</artifactId>
@@ -167,12 +162,7 @@
<dependency> <dependency>
<groupId>com.googlecode.libphonenumber</groupId> <groupId>com.googlecode.libphonenumber</groupId>
<artifactId>libphonenumber</artifactId> <artifactId>libphonenumber</artifactId>
<version>8.13.36</version> <!-- Use the latest version --> <version>8.13.36</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
<version>3.1.5</version>
</dependency> </dependency>
<!-- Maven --> <!-- Maven -->
<dependency> <dependency>

View File

@@ -1,17 +1,16 @@
package zw.qantra.tm; package zw.qantra.tm;
import io.nflow.rest.config.RestConfiguration;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import com.google.gson.Gson; import com.google.gson.Gson;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication @SpringBootApplication
@Import(RestConfiguration.class)
@EnableCaching @EnableCaching
@EnableAsync @EnableAsync
public class TmApplication { public class TmApplication {
@@ -24,4 +23,14 @@ public class TmApplication {
public Gson gson() { public Gson gson() {
return new Gson(); return new Gson();
} }
}
/**
* Conditionally imports nFlow REST configuration only when NOT running the "test" profile.
* This prevents nFlow's PostgreSQL-dependent beans from loading in tests that use H2.
*/
@Configuration
@Profile("!test")
@org.springframework.context.annotation.Import(io.nflow.rest.config.RestConfiguration.class)
static class NflowRestConfig {
}
}

View File

@@ -8,11 +8,13 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import javax.sql.DataSource; import javax.sql.DataSource;
@Configuration @Configuration
@Profile("!test")
public class NflowDatabaseConfig extends PgDatabaseConfiguration { public class NflowDatabaseConfig extends PgDatabaseConfiguration {
@Bean @Bean
@@ -31,4 +33,4 @@ public class NflowDatabaseConfig extends PgDatabaseConfiguration {
.initializeDataSourceBuilder() .initializeDataSourceBuilder()
.build(); .build();
} }
} }

View File

@@ -35,12 +35,12 @@ public class SecurityConfig {
.requestMatchers("/test/**").permitAll() .requestMatchers("/test/**").permitAll()
.requestMatchers("/nflow/**").permitAll() .requestMatchers("/nflow/**").permitAll()
.requestMatchers("/auth/**").permitAll() .requestMatchers("/auth/**").permitAll()
.requestMatchers("/public/**").permitAll() .requestMatchers("/public/**").permitAll()
.requestMatchers("/explorer/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.authenticationProvider(authenticationProvider(userService)) .authenticationProvider(authenticationProvider(userService))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
.formLogin(withDefaults());
return http.build(); return http.build();
} }

View File

@@ -6,7 +6,12 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.dtos.erp.VelocityAccountDto; import zw.qantra.tm.domain.dtos.erp.VelocityAccountDto;
import zw.qantra.tm.domain.dtos.erp.VelocityStatementDto; import zw.qantra.tm.domain.dtos.erp.VelocityStatementDto;
import zw.qantra.tm.domain.models.Workspace;
import zw.qantra.tm.domain.services.VelocityAccountService; import zw.qantra.tm.domain.services.VelocityAccountService;
import zw.qantra.tm.domain.services.WorkspaceService;
import java.util.Map;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/accounts") @RequestMapping("/accounts")
@@ -14,31 +19,39 @@ import zw.qantra.tm.domain.services.VelocityAccountService;
public class AccountController { public class AccountController {
private final VelocityAccountService velocityAccountService; private final VelocityAccountService velocityAccountService;
private final WorkspaceService workspaceService;
@GetMapping("/phone/{currencyPhoneConcat}") @GetMapping("/{workspaceId}/balance/{currency}")
@PreAuthorize("hasRole('ACCOUNT_READ')") public ResponseEntity getAccount(
public ResponseEntity<VelocityAccountDto> getAccountByPhone( @PathVariable UUID workspaceId,
@PathVariable String currencyPhoneConcat) { @PathVariable String currency
) {
try { try {
Workspace workspace = workspaceService.getWorkspace(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found"));
String currencyPhoneConcat = currency + workspace.getPhone();
VelocityAccountDto account = velocityAccountService.getAccountByPhone(currencyPhoneConcat); VelocityAccountDto account = velocityAccountService.getAccountByPhone(currencyPhoneConcat);
return ResponseEntity.ok(account); return ResponseEntity.ok(account);
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} }
} }
@GetMapping("/phone/{currencyPhoneConcat}/statement") @GetMapping("/{workspaceId}/statement/{currency}")
@PreAuthorize("hasRole('ACCOUNT_READ')") public ResponseEntity getStatement(
public ResponseEntity<VelocityStatementDto> getStatementByPhone( @PathVariable UUID workspaceId,
@PathVariable String currencyPhoneConcat, @PathVariable String currency,
@RequestParam String startDate, @RequestParam String startDate,
@RequestParam String endDate) { @RequestParam String endDate) {
try { try {
Workspace workspace = workspaceService.getWorkspace(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found"));
String currencyPhoneConcat = currency + workspace.getPhone();
VelocityStatementDto statement = velocityAccountService.getStatementByPhone( VelocityStatementDto statement = velocityAccountService.getStatementByPhone(
currencyPhoneConcat, startDate, endDate); currencyPhoneConcat, startDate, endDate);
return ResponseEntity.ok(statement); return ResponseEntity.ok(statement);
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} }
} }
} }

View File

@@ -0,0 +1,57 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.AppVersion;
import zw.qantra.tm.domain.services.AppVersionService;
import java.util.Optional;
@RestController
@RequestMapping("/public/app-version")
@RequiredArgsConstructor
public class AppVersionController {
private final AppVersionService appVersionService;
/**
* Client checks if a newer app build is available.
* Pass the current build number as a query param.
*/
@GetMapping("/check/app")
public ResponseEntity<AppVersion> checkAppUpdate(@RequestParam Integer currentBuildNumber) {
Optional<AppVersion> update = appVersionService.checkForAppUpdate(currentBuildNumber);
return update.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
/**
* Client checks if a newer web build is available.
* Pass the current build number as a query param.
*/
@GetMapping("/check/web")
public ResponseEntity<AppVersion> checkWebUpdate(@RequestParam Integer currentBuildNumber) {
Optional<AppVersion> update = appVersionService.checkForWebUpdate(currentBuildNumber);
return update.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
/**
* Get the latest version record.
*/
@GetMapping("/latest")
public ResponseEntity<AppVersion> getLatestVersion() {
Optional<AppVersion> latest = appVersionService.getLatestVersion();
return latest.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
/**
* Persist a new version record (internal/admin use).
*/
@PostMapping
public ResponseEntity<AppVersion> save(@RequestBody AppVersion appVersion) {
return ResponseEntity.ok(appVersionService.save(appVersion));
}
}

View File

@@ -0,0 +1,26 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import zw.qantra.tm.domain.dtos.CloudflarePurgeResponse;
import zw.qantra.tm.domain.services.CloudflareCacheService;
@Slf4j
@RestController
@RequestMapping("/public/cache/cloudflare")
@RequiredArgsConstructor
public class CloudflareCacheController {
private final CloudflareCacheService cloudflareCacheService;
@PostMapping("/clear")
public ResponseEntity<CloudflarePurgeResponse> clearCache() {
log.info("Received request to clear Cloudflare cache");
CloudflarePurgeResponse response = cloudflareCacheService.purgeEverything();
return ResponseEntity.ok(response);
}
}

View File

@@ -2,6 +2,7 @@ package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal; 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.And;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec; import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -9,6 +10,7 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.dtos.GroupBatchCreateRequest; import zw.qantra.tm.domain.dtos.GroupBatchCreateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchItemUpdateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchUpdateRequest; import zw.qantra.tm.domain.dtos.GroupBatchUpdateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchTriggerRequest; import zw.qantra.tm.domain.dtos.GroupBatchTriggerRequest;
import zw.qantra.tm.domain.models.GroupBatch; import zw.qantra.tm.domain.models.GroupBatch;
@@ -16,11 +18,12 @@ import zw.qantra.tm.domain.models.GroupBatchItem;
import zw.qantra.tm.domain.services.BatchReportService; import zw.qantra.tm.domain.services.BatchReportService;
import zw.qantra.tm.domain.services.GroupBatchService; import zw.qantra.tm.domain.services.GroupBatchService;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@RequestMapping("/public/group-batches") @RequestMapping("/group-batches")
@RequiredArgsConstructor @RequiredArgsConstructor
public class GroupBatchController { public class GroupBatchController {
private final GroupBatchService groupBatchService; private final GroupBatchService groupBatchService;
@@ -30,7 +33,7 @@ public class GroupBatchController {
public ResponseEntity findAll( public ResponseEntity findAll(
@And({ @And({
@Spec(path = "recipientGroupId", defaultVal = "null", spec = Equal.class), @Spec(path = "recipientGroupId", defaultVal = "null", spec = Equal.class),
@Spec(path = "userId", spec = Equal.class), @Spec(path = "workspaceId", spec = Equal.class),
@Spec(path = "status", spec = Equal.class), @Spec(path = "status", spec = Equal.class),
@Spec(path = "currency", spec = Equal.class), @Spec(path = "currency", spec = Equal.class),
})Specification<GroupBatch> spec, Pageable pageable){ })Specification<GroupBatch> spec, Pageable pageable){
@@ -54,7 +57,7 @@ public class GroupBatchController {
@RequestBody GroupBatchTriggerRequest request) { @RequestBody GroupBatchTriggerRequest request) {
return ResponseEntity.ok(groupBatchService.triggerConfirm( return ResponseEntity.ok(groupBatchService.triggerConfirm(
batchId, batchId,
request.getUserId(), request.getWorkspaceId(),
request.getIdempotencyKey())); request.getIdempotencyKey()));
} }
@@ -63,7 +66,7 @@ public class GroupBatchController {
@RequestBody GroupBatchTriggerRequest request) { @RequestBody GroupBatchTriggerRequest request) {
Object response = groupBatchService.triggerRequest( Object response = groupBatchService.triggerRequest(
batchId, batchId,
request.getUserId(), request.getWorkspaceId(),
request.getAuthType(), request.getAuthType(),
request.getIdempotencyKey()); request.getIdempotencyKey());
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
@@ -74,14 +77,14 @@ public class GroupBatchController {
@RequestBody GroupBatchTriggerRequest request) { @RequestBody GroupBatchTriggerRequest request) {
return ResponseEntity.ok(groupBatchService.triggerPoll( return ResponseEntity.ok(groupBatchService.triggerPoll(
batchId, batchId,
request.getUserId(), request.getWorkspaceId(),
request.getIdempotencyKey())); request.getIdempotencyKey()));
} }
@GetMapping("/{batchId}") @GetMapping("/{batchId}")
public ResponseEntity<GroupBatch> getBatch(@PathVariable UUID batchId, public ResponseEntity<GroupBatch> getBatch(@PathVariable UUID batchId,
@RequestParam String userId) { @RequestParam UUID workspaceId) {
return ResponseEntity.ok(groupBatchService.getBatch(batchId, userId)); return ResponseEntity.ok(groupBatchService.getBatch(batchId, workspaceId));
} }
@GetMapping("/items") @GetMapping("/items")
@@ -89,21 +92,34 @@ public class GroupBatchController {
@And({ @And({
@Spec(path = "groupBatchId", defaultVal = "null", spec = Equal.class), @Spec(path = "groupBatchId", defaultVal = "null", spec = Equal.class),
@Spec(path = "recipientId", spec = Equal.class), @Spec(path = "recipientId", spec = Equal.class),
@Spec(path = "recipientName", spec = EqualIgnoreCase.class),
@Spec(path = "recipientAccount", spec = Equal.class),
@Spec(path = "status", spec = Equal.class),
}) Specification<GroupBatchItem> spec, Pageable pageable) { }) Specification<GroupBatchItem> spec, Pageable pageable) {
return ResponseEntity.ok(groupBatchService.getBatchItems(spec, pageable)); return ResponseEntity.ok(groupBatchService.getBatchItems(spec, pageable));
} }
@PutMapping("/{batchId}/items")
public ResponseEntity<List<GroupBatchItem>> updateBatchItems(
@PathVariable UUID batchId,
@RequestBody GroupBatchItemUpdateRequest.ListWrapper wrapper) {
return ResponseEntity.ok(groupBatchService.updateBatchItems(
batchId,
wrapper.getWorkspaceId(),
wrapper.getItems()));
}
@DeleteMapping("/{batchId}") @DeleteMapping("/{batchId}")
public ResponseEntity<Void> deleteBatch(@PathVariable UUID batchId, public ResponseEntity<Void> deleteBatch(@PathVariable UUID batchId,
@RequestParam String userId) { @RequestParam UUID workspaceId) {
groupBatchService.deleteBatch(batchId, userId); groupBatchService.deleteBatch(batchId, workspaceId);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@GetMapping("/{batchId}/report") @GetMapping("/{batchId}/report")
public ResponseEntity<Map<String, Object>> downloadBatchReport(@PathVariable UUID batchId, public ResponseEntity<Map<String, Object>> downloadBatchReport(@PathVariable UUID batchId,
@RequestParam String userId) { @RequestParam UUID workspaceId) {
BatchReportService.BatchReportResult result = batchReportService.generateBatchReport(batchId, userId); BatchReportService.BatchReportResult result = batchReportService.generateBatchReport(batchId, workspaceId);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"fileUrl", result.fileUrl(), "fileUrl", result.fileUrl(),
"alreadyExisted", result.alreadyExisted() "alreadyExisted", result.alreadyExisted()

View File

@@ -1,6 +1,13 @@
package zw.qantra.tm.domain.controllers; package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal;
import net.kaczmarzyk.spring.data.jpa.domain.In;
import net.kaczmarzyk.spring.data.jpa.web.annotation.And;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.PaymentProcessor; import zw.qantra.tm.domain.models.PaymentProcessor;
@@ -18,8 +25,13 @@ public class PaymentProcessorController {
private final PaymentProcessorService paymentProcessorService; private final PaymentProcessorService paymentProcessorService;
@GetMapping @GetMapping
public ResponseEntity<List<PaymentProcessor>> getAllPaymentProcessors() { public ResponseEntity getAllPaymentProcessors(
return ResponseEntity.ok(paymentProcessorService.getPaymentProcessorRepository().findAll()); @And({
@Spec(path = "label", spec = Equal.class),
@Spec(path = "label", params = "labelIn", paramSeparator = ',', spec = In.class),
@Spec(path = "currency", spec = Equal.class),
})Specification<PaymentProcessor> specification, Pageable pageable) {
return ResponseEntity.ok(paymentProcessorService.getAllPaymentProcessors(specification, pageable));
} }
@GetMapping("/{id}") @GetMapping("/{id}")

View File

@@ -34,9 +34,9 @@ public class ProviderController {
return ResponseEntity.ok(providerService.getProviderProducts(id.toString())); return ResponseEntity.ok(providerService.getProviderProducts(id.toString()));
} }
@GetMapping("/{id}/products/{productId}") @GetMapping("/{id}/products/{productName}")
public ResponseEntity getProducts(@PathVariable UUID id, @PathVariable UUID productId) { public ResponseEntity getProducts(@PathVariable UUID id, @PathVariable String productName) {
return ResponseEntity.ok(providerService.getProviderProduct(id, productId)); return ResponseEntity.ok(providerService.getProviderProduct(id, productName));
} }
@GetMapping("/client/{clientId}") @GetMapping("/client/{clientId}")

View File

@@ -2,9 +2,8 @@ package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal; import net.kaczmarzyk.spring.data.jpa.domain.Equal;
import net.kaczmarzyk.spring.data.jpa.domain.Like;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec; import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Or; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -22,22 +21,23 @@ public class RecipientController {
private final RecipientService recipientService; private final RecipientService recipientService;
@GetMapping("/search") @GetMapping
public ResponseEntity<List<Recipient>> searchRecipients( public ResponseEntity searchRecipients(
@And({ @And({
@Spec(path = "name", spec = Equal.class), @Spec(path = "name", spec = Equal.class),
@Spec(path = "email", spec = Equal.class), @Spec(path = "email", spec = Equal.class),
@Spec(path = "phoneNumber", spec = Equal.class), @Spec(path = "phoneNumber", spec = Equal.class),
@Spec(path = "account", spec = Equal.class), @Spec(path = "account", spec = Equal.class),
@Spec(path = "latestProviderLabel", spec = Equal.class), @Spec(path = "latestProviderLabel", spec = Equal.class),
@Spec(path = "userId", defaultVal = "null", spec = Equal.class) @Spec(path = "userId", spec = Equal.class),
}) Specification<Recipient> specification) { @Spec(path = "workspaceId", defaultVal = "null", spec = Equal.class)
return ResponseEntity.ok(recipientService.searchRecipients(specification)); }) Specification<Recipient> specification, Pageable pageable) {
return ResponseEntity.ok(recipientService.findAllRecipients(specification, pageable));
} }
@GetMapping("/{account}/user/{userId}") @GetMapping("/{account}/workspace/{workspaceId}")
public ResponseEntity<Recipient> getRecipient(@PathVariable String account, @PathVariable String userId) { public ResponseEntity<Recipient> getRecipient(@PathVariable String account, @PathVariable UUID workspaceId) {
List<Recipient> recipients = recipientService.getRecipient(account, userId); List<Recipient> recipients = recipientService.getRecipient(account, workspaceId);
if(recipients.size() > 0){ if(recipients.size() > 0){
return ResponseEntity.ok(recipients.get(0)); return ResponseEntity.ok(recipients.get(0));
} }

View File

@@ -8,8 +8,12 @@ import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import org.hibernate.query.Page; import org.hibernate.query.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import zw.qantra.tm.domain.dtos.GroupCreateFromFileRequest;
import zw.qantra.tm.domain.dtos.GroupCreateFromFileResult;
import zw.qantra.tm.domain.dtos.GroupMemberRequest; import zw.qantra.tm.domain.dtos.GroupMemberRequest;
import zw.qantra.tm.domain.dtos.RecipientGroupCreateRequest; import zw.qantra.tm.domain.dtos.RecipientGroupCreateRequest;
import zw.qantra.tm.domain.dtos.RecipientGroupUpdateRequest; import zw.qantra.tm.domain.dtos.RecipientGroupUpdateRequest;
@@ -21,7 +25,7 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@RequestMapping("/public/recipient-groups") @RequestMapping("/recipient-groups")
@RequiredArgsConstructor @RequiredArgsConstructor
public class RecipientGroupController { public class RecipientGroupController {
private final RecipientGroupService recipientGroupService; private final RecipientGroupService recipientGroupService;
@@ -30,7 +34,7 @@ public class RecipientGroupController {
public ResponseEntity findAll( public ResponseEntity findAll(
@And({ @And({
@Spec(path = "name", spec = EqualIgnoreCase.class), @Spec(path = "name", spec = EqualIgnoreCase.class),
@Spec(path = "userId", defaultVal = "null", spec = Equal.class) @Spec(path = "workspaceId", defaultVal = "null", spec = Equal.class)
})Specification<RecipientGroup> spec, Pageable pageable) { })Specification<RecipientGroup> spec, Pageable pageable) {
return ResponseEntity.ok(recipientGroupService.findAll(spec, pageable)); return ResponseEntity.ok(recipientGroupService.findAll(spec, pageable));
} }
@@ -40,25 +44,48 @@ public class RecipientGroupController {
RecipientGroup group = RecipientGroup.builder() RecipientGroup group = RecipientGroup.builder()
.name(request.getName()) .name(request.getName())
.description(request.getDescription()) .description(request.getDescription())
.workspaceId(request.getWorkspaceId())
.userId(request.getUserId()) .userId(request.getUserId())
.build(); .build();
return ResponseEntity.ok(recipientGroupService.create(group, request.getRecipients())); return ResponseEntity.ok(recipientGroupService.create(group, request.getRecipients()));
} }
@PostMapping(value = "/create-from-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<GroupCreateFromFileResult> createFromFile(
@RequestParam("file") MultipartFile file,
@RequestParam("groupName") String groupName,
@RequestParam(value = "description", required = false) String description,
@RequestParam(value = "requestedBy", required = false) String requestedBy,
@RequestParam(value = "currency", required = false) String currency,
@RequestParam("userId") UUID userId,
@RequestParam("workspaceId") UUID workspaceId
) {
GroupCreateFromFileRequest request = new GroupCreateFromFileRequest();
request.setGroupName(groupName.trim());
request.setDescription(description.trim());
request.setRequestedBy(requestedBy);
request.setCurrency(currency);
request.setWorkspaceId(workspaceId);
request.setUserId(userId);
return ResponseEntity.ok(recipientGroupService.createFromFile(file, request));
}
@PutMapping("/update/{groupId}") @PutMapping("/update/{groupId}")
public ResponseEntity<RecipientGroup> update(@PathVariable UUID groupId, public ResponseEntity<RecipientGroup> update(@PathVariable UUID groupId,
@RequestBody RecipientGroupUpdateRequest request) { @RequestBody RecipientGroupUpdateRequest request) {
return ResponseEntity.ok(recipientGroupService.update( return ResponseEntity.ok(recipientGroupService.update(
groupId, groupId,
request.getUserId(), request.getWorkspaceId(),
request.getName(), request.getName(),
request.getDescription())); request.getDescription()));
} }
@DeleteMapping("/delete/{groupId}") @DeleteMapping("/delete/{groupId}")
public ResponseEntity<Void> delete(@PathVariable UUID groupId, public ResponseEntity<Void> delete(@PathVariable UUID groupId,
@RequestParam String userId) { @RequestParam UUID workspaceId) {
recipientGroupService.delete(groupId, userId); recipientGroupService.delete(groupId, workspaceId);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@@ -67,15 +94,15 @@ public class RecipientGroupController {
@RequestBody GroupMemberRequest request) { @RequestBody GroupMemberRequest request) {
return ResponseEntity.ok(recipientGroupService.addMembersByIds( return ResponseEntity.ok(recipientGroupService.addMembersByIds(
groupId, groupId,
request.getUserId(), request.getWorkspaceId(),
request.getRecipientIds())); request.getRecipientIds()));
} }
@DeleteMapping("/{groupId}/members/{recipientId}") @DeleteMapping("/{groupId}/members/{recipientId}")
public ResponseEntity<Void> removeMember(@PathVariable UUID groupId, public ResponseEntity<Void> removeMember(@PathVariable UUID groupId,
@PathVariable UUID recipientId, @PathVariable UUID recipientId,
@RequestParam String userId) { @RequestParam UUID workspaceId) {
recipientGroupService.removeMember(groupId, recipientId, userId); recipientGroupService.removeMember(groupId, recipientId, workspaceId);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }

View File

@@ -10,16 +10,18 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.enums.RequestType; import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.EmailService; import zw.qantra.tm.domain.services.NotificationService;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.WorkspaceMigrationService;
@RestController @RestController
@RequestMapping("/test") @RequestMapping("/test")
@CrossOrigin(origins = "*") @CrossOrigin(origins = "*")
@RequiredArgsConstructor @RequiredArgsConstructor
public class TestController { public class TestController {
private final EmailService emailService; private final NotificationService notificationService;
private final TransactionService transactionService; private final TransactionService transactionService;
private final WorkspaceMigrationService workspaceMigrationService;
private static final Logger logger = LoggerFactory.getLogger(TestController.class); private static final Logger logger = LoggerFactory.getLogger(TestController.class);
@@ -44,7 +46,7 @@ public class TestController {
@GetMapping("/hello/{email}") @GetMapping("/hello/{email}")
public ResponseEntity<String> helloEndpoint(@PathVariable String email) { public ResponseEntity<String> helloEndpoint(@PathVariable String email) {
emailService.sendSimpleMessage(email, "Hello", "Hello World!"); notificationService.sendEmail(email, "Hello", "Hello World!");
return ResponseEntity.ok("Hello World!"); return ResponseEntity.ok("Hello World!");
} }
@@ -59,4 +61,10 @@ public class TestController {
public ResponseEntity<String> publicEndpoint() { public ResponseEntity<String> publicEndpoint() {
return ResponseEntity.ok("This is a public endpoint - no authentication required!"); return ResponseEntity.ok("This is a public endpoint - no authentication required!");
} }
@PostMapping("/migrate-workspaces")
public ResponseEntity<WorkspaceMigrationService.MigrationResult> migrateWorkspaces() {
WorkspaceMigrationService.MigrationResult result = workspaceMigrationService.migrate();
return ResponseEntity.ok(result);
}
} }

View File

@@ -0,0 +1,87 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.TransactionReportService;
import zw.qantra.tm.domain.services.TransactionReportService.TransactionReportFileResult;
import zw.qantra.tm.domain.services.TransactionReportService.TransactionReportSummary;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/public/reports/transactions")
@RequiredArgsConstructor
public class TransactionReportController {
private final TransactionReportService transactionReportService;
/**
* Generates a transaction report filtered by date range.
* Transactions are automatically filtered to only include
* partyType = INDIVIDUAL or GROUP.
*
* @param startDate start of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param endDate end of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param workspaceId optional workspace ID filter
* @return list of matching transactions
*/
@GetMapping
public ResponseEntity<List<Transaction>> getTransactionReport(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String workspaceId) {
List<Transaction> transactions = transactionReportService.generateReport(startDate, endDate, workspaceId);
return ResponseEntity.ok(transactions);
}
/**
* Generates a transaction report summary with counts and totals,
* filtered by date range and default party type filter.
*
* @param startDate start of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param endDate end of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param workspaceId optional workspace ID filter
* @return report summary with aggregated data
*/
@GetMapping("/summary")
public ResponseEntity<TransactionReportSummary> getTransactionReportSummary(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String workspaceId) {
TransactionReportSummary summary = transactionReportService.generateReportSummary(startDate, endDate, workspaceId);
return ResponseEntity.ok(summary);
}
/**
* Downloads a transaction report as an Excel file (.xlsx).
* The report is filtered by date range, with a default filter for
* partyType = INDIVIDUAL or GROUP.
*
* @param startDate start of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param endDate end of the date range (inclusive), format: yyyy-MM-ddTHH:mm:ss
* @param workspaceId optional workspace ID filter
* @return the generated Excel file as a downloadable resource
*/
@GetMapping("/download")
public ResponseEntity<Resource> downloadTransactionReport(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
@RequestParam(required = false) String workspaceId) {
TransactionReportFileResult reportFile = transactionReportService.generateReportFile(startDate, endDate, workspaceId);
ByteArrayResource resource = new ByteArrayResource(reportFile.fileBytes());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + reportFile.filename() + "\"")
.body(resource);
}
}

View File

@@ -10,6 +10,9 @@ import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import io.nflow.engine.workflow.instance.WorkflowInstanceFactory; import io.nflow.engine.workflow.instance.WorkflowInstanceFactory;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal; import net.kaczmarzyk.spring.data.jpa.domain.Equal;
import net.kaczmarzyk.spring.data.jpa.domain.EqualIgnoreCase;
import net.kaczmarzyk.spring.data.jpa.domain.In;
import net.kaczmarzyk.spring.data.jpa.domain.LikeIgnoreCase;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Or; import net.kaczmarzyk.spring.data.jpa.web.annotation.Or;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec; import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Conjunction; import net.kaczmarzyk.spring.data.jpa.web.annotation.Conjunction;
@@ -56,7 +59,8 @@ public class TransactonController {
@Conjunction(value = { @Conjunction(value = {
@Or({ @Or({
@Spec(path = "status", spec = Equal.class), @Spec(path = "status", spec = Equal.class),
@Spec(path = "createdAt", spec = Equal.class), @Spec(path = "createdAt", spec = Equal.class),
@Spec(path = "billName", spec = LikeIgnoreCase.class),
@Spec(path = "providerLabel", spec = Equal.class), @Spec(path = "providerLabel", spec = Equal.class),
@Spec(path = "billClientId", spec = Equal.class), @Spec(path = "billClientId", spec = Equal.class),
@Spec(path = "productUid", spec = Equal.class), @Spec(path = "productUid", spec = Equal.class),
@@ -72,20 +76,22 @@ public class TransactonController {
@Spec(path = "amount", spec = Equal.class), @Spec(path = "amount", spec = Equal.class),
@Spec(path = "reference", spec = Equal.class), @Spec(path = "reference", spec = Equal.class),
@Spec(path = "totalAmount", spec = Equal.class), @Spec(path = "totalAmount", spec = Equal.class),
@Spec(path = "partyType", spec = Equal.class), @Spec(path = "partyType", params = "partyType", paramSeparator = ',', spec = In.class),
@Spec(path = "sdkActionId", spec = Equal.class), @Spec(path = "sdkActionId", spec = Equal.class),
@Spec(path = "paymentProcessorLabel", spec = Equal.class), @Spec(path = "paymentProcessorLabel", spec = Equal.class),
}) })
}, and = { }, and = {
@Spec(path = "type", spec = Equal.class), @Spec(path = "type", spec = Equal.class),
@Spec(path = "userId", defaultVal = "null", spec = Equal.class), @Spec(path = "workspaceId", defaultVal = "null", spec = Equal.class),
}) Specification<Transaction> specification, Pageable pageable) { }) Specification<Transaction> specification, Pageable pageable) {
return ResponseEntity.ok(transactionService.findAll(specification, pageable)); return ResponseEntity.ok(transactionService.findAll(specification, pageable));
} }
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<Transaction> getById(@PathVariable UUID id) { public ResponseEntity<Transaction> getById(
return ResponseEntity.ok(transactionService.findById(id)); @PathVariable UUID id,
@RequestParam UUID workspaceId) {
return ResponseEntity.ok(transactionService.getTransaction(id, workspaceId));
} }
@GetMapping("/integration/{id}") @GetMapping("/integration/{id}")
@@ -192,8 +198,8 @@ public class TransactonController {
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(TransactionWorkflow.TYPE) .setType(TransactionWorkflow.TYPE)
.setExternalId(transaction.getUserId() + "-" + new Instant().getMillis()) .setExternalId(transaction.getWorkspaceId() + "-" + new Instant().getMillis())
.setBusinessKey(transaction.getUserId()) .setBusinessKey(transaction.getWorkspaceId().toString())
.putStateVariable("confirmation", Utils.toJson(transaction)) .putStateVariable("confirmation", Utils.toJson(transaction))
.setNextActivation(DateTime.now()) .setNextActivation(DateTime.now())
.build(); .build();

View File

@@ -0,0 +1,30 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.models.Uptime;
import zw.qantra.tm.domain.services.UptimeService;
import java.util.UUID;
@RestController
@RequestMapping("/public/uptimes")
@RequiredArgsConstructor
public class UptimeController {
private final UptimeService uptimeService;
@GetMapping
public ResponseEntity getAllUptimes(Pageable pageable) {
return ResponseEntity.ok(uptimeService.getAllUptimes(pageable));
}
@GetMapping("/{id}")
public ResponseEntity getUptime(@PathVariable UUID id) {
return uptimeService.getUptime(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,79 @@
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.domain.LikeIgnoreCase;
import net.kaczmarzyk.spring.data.jpa.web.annotation.And;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Join;
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.models.User;
import zw.qantra.tm.domain.services.UserService;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity findAllUsers(
@Join(path = "workspaces", alias = "w")
@And({
@Spec(path = "username", defaultVal = "null", spec = LikeIgnoreCase.class),
@Spec(path = "email", spec = EqualIgnoreCase.class),
@Spec(path = "firstName", spec = LikeIgnoreCase.class),
@Spec(path = "lastName", spec = LikeIgnoreCase.class),
@Spec(path = "phone", spec = EqualIgnoreCase.class),
@Spec(path = "w.id", params = "workspaceId", spec = Equal.class)
}) Specification<User> spec, Pageable pageable) {
return ResponseEntity.ok(userService.findAllUsers(spec, pageable));
}
@GetMapping("/workspace")
public ResponseEntity searchAllUsers(
@Join(path = "workspaces", alias = "w")
@And({
@Spec(path = "username", spec = LikeIgnoreCase.class),
@Spec(path = "email", spec = EqualIgnoreCase.class),
@Spec(path = "firstName", spec = LikeIgnoreCase.class),
@Spec(path = "lastName", spec = LikeIgnoreCase.class),
@Spec(path = "phone", spec = EqualIgnoreCase.class),
@Spec(path = "w.id", params = "workspaceId", defaultVal = "null", spec = Equal.class)
}) Specification<User> spec, Pageable pageable) {
return ResponseEntity.ok(userService.findAllUsers(spec, pageable));
}
@GetMapping("/{id}")
public ResponseEntity getUser(@PathVariable UUID id) {
return userService.getUser(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/{id}")
public ResponseEntity updateUser(@PathVariable UUID id, @RequestBody User user) {
try {
User updated = userService.updateUser(id, user);
return ResponseEntity.ok(updated);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{id}")
public ResponseEntity deleteUser(@PathVariable UUID id) {
if (userService.deleteUser(id)) {
return ResponseEntity.ok(Map.of("message", "User deleted successfully"));
}
return ResponseEntity.notFound().build();
}
}

View File

@@ -0,0 +1,85 @@
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.Join;
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.models.Workspace;
import zw.qantra.tm.domain.services.WorkspaceService;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/workspaces")
@RequiredArgsConstructor
public class WorkspaceController {
private final WorkspaceService workspaceService;
@GetMapping
public ResponseEntity getAllWorkspaces(
@Join(path = "users", alias = "u")
@And({
@Spec(path = "name", spec = EqualIgnoreCase.class),
@Spec(path = "u.id", params = "userId", defaultVal = "null", spec = Equal.class)
}) Specification<Workspace> spec, Pageable pageable) {
return ResponseEntity.ok(workspaceService.getAllWorkspaces(spec, pageable));
}
@GetMapping("/{id}")
public ResponseEntity getWorkspace(@PathVariable UUID id) {
return workspaceService.getWorkspace(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/search")
public ResponseEntity searchByName(@RequestParam String name) {
return ResponseEntity.ok(workspaceService.searchByName(name));
}
@PostMapping
public ResponseEntity createWorkspace(@RequestBody Workspace workspace) {
return ResponseEntity.ok(workspaceService.createWorkspace(workspace));
}
@PutMapping("/{id}")
public ResponseEntity updateWorkspace(@PathVariable UUID id, @RequestBody Workspace workspace) {
Workspace updated = workspaceService.updateWorkspace(id, workspace);
if (updated != null) {
return ResponseEntity.ok(updated);
}
return ResponseEntity.notFound().build();
}
@PostMapping("/{workspaceId}/users/{userId}")
public ResponseEntity associateUser(@PathVariable UUID workspaceId, @PathVariable UUID userId) {
return workspaceService.associateUser(workspaceId, userId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{workspaceId}/users/{userId}")
public ResponseEntity disassociateUser(@PathVariable UUID workspaceId, @PathVariable UUID userId) {
return workspaceService.disassociateUser(workspaceId, userId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{workspaceId}/users")
public ResponseEntity getWorkspaceUsers(@PathVariable UUID workspaceId) {
return ResponseEntity.ok(workspaceService.getWorkspaceUsers(workspaceId));
}
@GetMapping("/by-user/{userId}")
public ResponseEntity getUserWorkspaces(@PathVariable UUID userId) {
return ResponseEntity.ok(workspaceService.getUserWorkspaces(userId));
}
}

View File

@@ -0,0 +1,33 @@
package zw.qantra.tm.domain.dtos;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CloudflarePurgeResponse {
private boolean success;
private List<CloudflareError> errors;
private List<String> messages;
private CloudflareResult result;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CloudflareError {
private int code;
private String message;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CloudflareResult {
private String id;
}
}

View File

@@ -11,6 +11,7 @@ import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupBatchCreateRequest { public class GroupBatchCreateRequest {
private UUID recipientGroupId; private UUID recipientGroupId;
private UUID workspaceId;
private String userId; private String userId;
private String description; private String description;
private String requestedBy; private String requestedBy;
@@ -21,4 +22,3 @@ public class GroupBatchCreateRequest {
private String paymentProcessorImage; private String paymentProcessorImage;
private Transaction transactionTemplate; private Transaction transactionTemplate;
} }

View File

@@ -0,0 +1,30 @@
package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupBatchItemUpdateRequest {
private UUID id;
private BigDecimal amount;
private String recipientName;
private String recipientPhone;
private String recipientAccount;
private String billClientId;
private String billName;
private String billProductName;
private String providerImage;
private String providerLabel;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class ListWrapper {
private List<GroupBatchItemUpdateRequest> items;
private UUID workspaceId;
}
}

View File

@@ -3,9 +3,11 @@ package zw.qantra.tm.domain.dtos;
import lombok.Data; import lombok.Data;
import zw.qantra.tm.domain.enums.AuthType; import zw.qantra.tm.domain.enums.AuthType;
import java.util.UUID;
@Data @Data
public class GroupBatchTriggerRequest { public class GroupBatchTriggerRequest {
private String userId; private UUID workspaceId;
private String idempotencyKey; private String idempotencyKey;
private AuthType authType; private AuthType authType;
} }

View File

@@ -11,7 +11,7 @@ import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupBatchUpdateRequest { public class GroupBatchUpdateRequest {
private UUID id; private UUID id;
private String userId; private UUID workspaceId;
private String description; private String description;
private String requestedBy; private String requestedBy;
private String currency; private String currency;

View File

@@ -0,0 +1,25 @@
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 GroupCreateFromFileRequest {
private String groupName;
private String groupDescription;
private String description;
private String requestedBy;
private String currency;
private BigDecimal amount;
private String paymentProcessorLabel;
private String paymentProcessorName;
private String paymentProcessorImage;
private UUID userId;
private UUID workspaceId;
private Transaction transactionTemplate;
}

View File

@@ -0,0 +1,29 @@
package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import zw.qantra.tm.domain.models.GroupBatch;
import java.util.List;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupCreateFromFileResult {
private GroupBatch batch;
private int totalRows;
private int successCount;
private int errorCount;
private List<RowError> errors;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class RowError {
private int lineNumber;
private String account;
private String name;
private String message;
}
}

View File

@@ -9,7 +9,7 @@ import java.util.UUID;
@Data @Data
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupMemberRequest { public class GroupMemberRequest {
private String userId; private UUID workspaceId;
private List<UUID> recipientIds; private List<UUID> recipientIds;
} }

View File

@@ -14,7 +14,7 @@ import java.util.UUID;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public class SBZProductDto { public class ProductDto {
private String name; private String name;
private String description; private String description;
private String displayName; private String displayName;

View File

@@ -5,6 +5,7 @@ import lombok.Data;
import zw.qantra.tm.domain.models.Recipient; import zw.qantra.tm.domain.models.Recipient;
import java.util.List; import java.util.List;
import java.util.UUID;
@Data @Data
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@@ -12,6 +13,7 @@ public class RecipientGroupCreateRequest {
private String name; private String name;
private String description; private String description;
private String userId; private String userId;
private UUID workspaceId;
private List<Recipient> recipients; private List<Recipient> recipients;
} }

View File

@@ -3,11 +3,13 @@ package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data; import lombok.Data;
import java.util.UUID;
@Data @Data
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public class RecipientGroupUpdateRequest { public class RecipientGroupUpdateRequest {
private String name; private String name;
private String description; private String description;
private String userId; private UUID workspaceId;
} }

View File

@@ -0,0 +1,14 @@
package zw.qantra.tm.domain.dtos.sms;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SmsApiResponse {
private int status;
private String result;
}

View File

@@ -0,0 +1,18 @@
package zw.qantra.tm.domain.dtos.sms;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SmsBatchRequest {
private String batchReference;
private List<SmsMessage> smsList;
}

View File

@@ -0,0 +1,17 @@
package zw.qantra.tm.domain.dtos.sms;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SmsMessage {
private String reference;
private String message;
private String msisdn;
}

View File

@@ -1,12 +1,7 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import jakarta.persistence.Column; import jakarta.persistence.*;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -17,15 +12,21 @@ import zw.qantra.tm.domain.enums.RequestType;
@Getter @Getter
@Setter @Setter
@Entity @Entity
@Table(name = "additional_data", indexes = {
@Index(name = "idx_additional_data_txn_id", columnList = "transaction_id"),
@Index(name = "idx_additional_data_txn_type", columnList = "transaction_id, request_type"),
})
@Builder @Builder
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class AdditionalData extends BaseEntity { public class AdditionalData extends BaseEntity {
@Column(name = "transaction_id")
private String transactionId; private String transactionId;
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String jsonString; private String jsonString;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "request_type")
private RequestType requestType; private RequestType requestType;
} }

View File

@@ -0,0 +1,19 @@
package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity;
import lombok.*;
import java.sql.Timestamp;
@Getter
@Setter
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AppVersion extends BaseEntity{
private String description;
private Integer appBuildNumber;
private Integer webBuildNumber;
private Timestamp timestamp;
}

View File

@@ -1,10 +1,13 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
@Entity @Entity
@Table(name = "category", indexes = {
@Index(name = "idx_category_name", columnList = "name"),
})
@Setter @Setter
@Getter @Getter
@Builder @Builder

View File

@@ -12,6 +12,10 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
@Entity @Entity
@Table(name = "charge", indexes = {
@Index(name = "idx_charge_currency", columnList = "currency"),
@Index(name = "idx_charge_label_currency", columnList = "charge_label, currency"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -32,6 +36,7 @@ public class Charge extends BaseEntity {
private BigDecimal maximumAmount; private BigDecimal maximumAmount;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "charge_label")
private ChargeLabelEnum chargeLabel; private ChargeLabelEnum chargeLabel;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private CurrencyType currency; private CurrencyType currency;

View File

@@ -7,6 +7,9 @@ import zw.qantra.tm.domain.enums.ChargeConditionType;
import java.util.List; import java.util.List;
@Entity @Entity
@Table(name = "charge_condition", indexes = {
@Index(name = "idx_charge_condition_codes", columnList = "codes"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder

View File

@@ -1,15 +1,19 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity; import jakarta.persistence.*;
import lombok.*; import lombok.*;
@Entity @Entity
@Table(name = "currency", indexes = {
@Index(name = "idx_currency_iso_code", columnList = "iso_code"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class Currency extends BaseEntity { public class Currency extends BaseEntity {
@Column(name = "iso_code")
private String isoCode; private String isoCode;
private String name; private String name;
private String displayName; private String displayName;

View File

@@ -1,10 +1,7 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.Column; import jakarta.persistence.*;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -18,6 +15,13 @@ import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@Table(name = "group_batch", indexes = {
@Index(name = "idx_group_batch_workspace_id", columnList = "workspace_id"),
@Index(name = "idx_group_batch_status", columnList = "status"),
@Index(name = "idx_group_batch_recipient_group_id", columnList = "recipient_group_id"),
@Index(name = "idx_group_batch_created_at", columnList = "created_at"),
@Index(name = "idx_group_batch_payment_ref", columnList = "payment_reference"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -26,7 +30,11 @@ import java.util.UUID;
@SQLRestriction("deleted = false") @SQLRestriction("deleted = false")
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupBatch extends BaseEntity { public class GroupBatch extends BaseEntity {
@Column(name = "recipient_group_id")
private UUID recipientGroupId; private UUID recipientGroupId;
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId; private String userId;
private String description; private String description;
private String requestedBy; private String requestedBy;
@@ -35,8 +43,11 @@ public class GroupBatch extends BaseEntity {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private GroupBatchStatus status; private GroupBatchStatus status;
@Column(name = "payment_reference")
private String paymentReference; private String paymentReference;
@Column(name = "payment_transaction_id")
private UUID paymentTransactionId; private UUID paymentTransactionId;
@Column(name = "workflow_id")
private Long workflowId; private Long workflowId;
private String confirmIdempotencyKey; private String confirmIdempotencyKey;

View File

@@ -15,6 +15,12 @@ import java.math.BigDecimal;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@Table(name = "group_batch_item", indexes = {
@Index(name = "idx_gbi_batch_id", columnList = "group_batch_id"),
@Index(name = "idx_gbi_batch_confirm", columnList = "group_batch_id, confirm_status"),
@Index(name = "idx_gbi_batch_integ", columnList = "group_batch_id, integration_status"),
@Index(name = "idx_gbi_txn_id", columnList = "transaction_id"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -22,19 +28,26 @@ import java.util.UUID;
@NoArgsConstructor @NoArgsConstructor
@SQLRestriction("deleted = false") @SQLRestriction("deleted = false")
public class GroupBatchItem extends BaseEntity { public class GroupBatchItem extends BaseEntity {
@Column(name = "group_batch_id")
private UUID groupBatchId; private UUID groupBatchId;
@Column(name = "recipient_id")
private UUID recipientId; private UUID recipientId;
@Column(name = "recipient_name")
private String recipientName; private String recipientName;
@Column(name = "recipient_phone")
private String recipientPhone; private String recipientPhone;
private String recipientAccount;
private BigDecimal amount; private BigDecimal amount;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private GroupBatchItemStatus status; private GroupBatchItemStatus status;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "confirm_status")
private GroupBatchItemConfirmStatus confirmStatus; private GroupBatchItemConfirmStatus confirmStatus;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "integration_status")
private GroupBatchItemIntegrationStatus integrationStatus; private GroupBatchItemIntegrationStatus integrationStatus;
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
@@ -46,5 +59,12 @@ public class GroupBatchItem extends BaseEntity {
@ManyToOne @ManyToOne
@JoinColumn(name = "transaction_id") @JoinColumn(name = "transaction_id")
private Transaction transaction; private Transaction transaction;
private String billClientId;
private String billName;
private String billProductName;
private String providerImage;
private String providerLabel;
private String creditAddress;
} }

View File

@@ -1,13 +1,14 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity; import jakarta.persistence.*;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.*; import lombok.*;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
@Entity @Entity
@Table(name = "otp", indexes = {
@Index(name = "idx_otp_username_type", columnList = "username, otp_type"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -17,6 +18,8 @@ public class Otp extends BaseEntity{
private String username; private String username;
private String otp; private String otp;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "otp_status")
private Status otpStatus; private Status otpStatus;
@Column(name = "otp_type")
private String otpType; private String otpType;
} }

View File

@@ -1,11 +1,14 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import jakarta.persistence.Column; import jakarta.persistence.*;
import jakarta.persistence.Entity;
import lombok.*; import lombok.*;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
import zw.qantra.tm.domain.enums.CurrencyType;
@Entity @Entity
@Table(name = "payment_processor", indexes = {
@Index(name = "idx_payment_processor_label", columnList = "label"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -19,6 +22,8 @@ public class PaymentProcessor extends BaseEntity {
private String type; private String type;
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String image; private String image;
@Enumerated(EnumType.STRING)
private CurrencyType currency;
private String accountFieldName; private String accountFieldName;
private String accountFieldLabel; private String accountFieldLabel;
private String authType; private String authType;

View File

@@ -1,15 +1,16 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import jakarta.persistence.Column; import jakarta.persistence.*;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.*; import lombok.*;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
import zw.qantra.tm.domain.enums.CurrencyType; import zw.qantra.tm.domain.enums.CurrencyType;
@Entity @Entity
@Table(name = "provider", indexes = {
@Index(name = "idx_provider_category", columnList = "category"),
@Index(name = "idx_provider_client_id", columnList = "client_id"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -17,6 +18,7 @@ import zw.qantra.tm.domain.enums.CurrencyType;
@NoArgsConstructor @NoArgsConstructor
@SQLRestriction("deleted = false") @SQLRestriction("deleted = false")
public class Provider extends BaseEntity { public class Provider extends BaseEntity {
@Column(name = "client_id")
private String clientId; private String clientId;
private String label; private String label;
private String name; private String name;
@@ -32,6 +34,7 @@ public class Provider extends BaseEntity {
private Boolean requiresAmount; private Boolean requiresAmount;
private Boolean requiresPhone; private Boolean requiresPhone;
private Boolean requiresAccount; private Boolean requiresAccount;
private Boolean requiresProducts;
private double percentageCommission; private double percentageCommission;
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String meta; private String meta;

View File

@@ -2,12 +2,20 @@ package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.Entity; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
import java.util.UUID;
@Entity @Entity
@Table(name = "recipient", indexes = {
@Index(name = "idx_recipient_account_workspace", columnList = "account, workspace_id"),
@Index(name = "idx_recipient_workspace_id", columnList = "workspace_id"),
@Index(name = "idx_recipient_phone", columnList = "phone_number"),
@Index(name = "idx_recipient_email", columnList = "email"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -18,10 +26,15 @@ import org.hibernate.annotations.SQLRestriction;
public class Recipient extends BaseEntity { public class Recipient extends BaseEntity {
private String name; private String name;
private String email; private String email;
@Column(name = "phone_number")
private String phoneNumber; private String phoneNumber;
private String address; private String address;
private String account; private String account;
private String initials; private String initials;
@Column(name = "latest_provider_label")
private String latestProviderLabel; private String latestProviderLabel;
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId; private String userId;
} }

View File

@@ -1,7 +1,7 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.Entity; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -9,7 +9,12 @@ import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
import java.util.UUID;
@Entity @Entity
@Table(name = "recipient_group", indexes = {
@Index(name = "idx_recipient_group_workspace_id", columnList = "workspace_id"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -20,6 +25,9 @@ import org.hibernate.annotations.SQLRestriction;
public class RecipientGroup extends BaseEntity { public class RecipientGroup extends BaseEntity {
private String name; private String name;
private String description; private String description;
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId; private String userId;
} }

View File

@@ -1,9 +1,7 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.Entity; import jakarta.persistence.*;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -14,6 +12,10 @@ import org.hibernate.annotations.SQLRestriction;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@Table(name = "recipient_group_member", indexes = {
@Index(name = "idx_rgm_group_id", columnList = "recipient_group_id"),
@Index(name = "idx_rgm_group_recipient", columnList = "recipient_group_id, recipient_id"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -22,12 +24,17 @@ import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@SQLRestriction("deleted = false") @SQLRestriction("deleted = false")
public class RecipientGroupMember extends BaseEntity { public class RecipientGroupMember extends BaseEntity {
@Column(name = "recipient_group_id")
private UUID recipientGroupId; private UUID recipientGroupId;
@ManyToOne @ManyToOne
@JoinColumn(name = "recipient_id") @JoinColumn(name = "recipient_id")
private Recipient recipient; private Recipient recipient;
@Column(name = "recipient_uid")
private UUID recipientUid; private UUID recipientUid;
private String account; private String account;
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId; private String userId;
} }

View File

@@ -1,6 +1,6 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -8,12 +8,17 @@ import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "setting", indexes = {
@Index(name = "idx_setting_name", columnList = "setting_name"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class Setting extends BaseEntity { public class Setting extends BaseEntity {
@Column(name = "setting_name")
private String settingName; private String settingName;
@Column(name = "setting_value")
private String settingValue; private String settingValue;
} }

View File

@@ -12,6 +12,18 @@ import java.math.BigDecimal;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@Table(name = "transaction", indexes = {
@Index(name = "idx_transaction_trace_type", columnList = "trace, type"),
@Index(name = "idx_transaction_reference", columnList = "reference"),
@Index(name = "idx_transaction_workspace_id", columnList = "workspace_id"),
@Index(name = "idx_transaction_confirm_pay_integ", columnList = "confirmation_status, payment_status, integration_status"),
@Index(name = "idx_transaction_pay_poll", columnList = "payment_status, polling_status"),
@Index(name = "idx_transaction_status", columnList = "status"),
@Index(name = "idx_transaction_created_at", columnList = "created_at"),
@Index(name = "idx_transaction_debit_phone", columnList = "debit_phone"),
@Index(name = "idx_transaction_credit_phone", columnList = "credit_phone"),
@Index(name = "idx_transaction_workflow_id", columnList = "workflow_id"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -19,10 +31,16 @@ import java.util.UUID;
@NoArgsConstructor @NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@SQLRestriction("deleted = false")
public class Transaction extends BaseEntity { public class Transaction extends BaseEntity {
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId; private String userId;
private String username;
private String trace; private String trace;
private String region; private String region;
@Enumerated(EnumType.STRING)
private PartyType partyType; private PartyType partyType;
private BigDecimal amount; private BigDecimal amount;
private BigDecimal charge; private BigDecimal charge;
@@ -39,6 +57,7 @@ public class Transaction extends BaseEntity {
private String rrn; private String rrn;
private String channelName; private String channelName;
private String channel; private String channel;
@Column(name = "debit_phone")
private String debitPhone; private String debitPhone;
private String debitAccount; private String debitAccount;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@@ -47,6 +66,7 @@ public class Transaction extends BaseEntity {
private String debitCard; private String debitCard;
private String debitRef; private String debitRef;
private String debitEmail; private String debitEmail;
@Column(name = "credit_phone")
private String creditPhone; private String creditPhone;
private String creditAccount; private String creditAccount;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@@ -56,6 +76,8 @@ public class Transaction extends BaseEntity {
private String creditCard; private String creditCard;
private String creditRef; private String creditRef;
private String creditEmail; private String creditEmail;
private String creditAddress;
private String creditVoucher;
private String billClientId; private String billClientId;
private String clientSecret; private String clientSecret;
private String aggregatorId; private String aggregatorId;
@@ -65,21 +87,26 @@ public class Transaction extends BaseEntity {
private String paymentProcessorName; private String paymentProcessorName;
private String providerImage; private String providerImage;
private String paymentProcessorImage; private String paymentProcessorImage;
private UUID batchId;
private String batchDescription;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Status authorizationStatus; private Status authorizationStatus;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "confirmation_status")
private Status confirmationStatus; private Status confirmationStatus;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "payment_status")
private Status paymentStatus; private Status paymentStatus;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "integration_status")
private Status integrationStatus; private Status integrationStatus;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Status reversalStatus; private Status reversalStatus;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "polling_status")
private Status pollingStatus; private Status pollingStatus;
private String orderId; private String orderId;
private String orderTrace; private String orderTrace;
private String orderTransactionTrace; private String orderTransactionTrace;
@@ -92,7 +119,7 @@ public class Transaction extends BaseEntity {
private String erpPurchasePaymentRef; private String erpPurchasePaymentRef;
private String erpCommissionJournalRef; private String erpCommissionJournalRef;
@Column(nullable = true) @Column(name = "workflow_id", nullable = true)
private long workflowId; private long workflowId;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)

View File

@@ -2,10 +2,7 @@ package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.Column; import jakarta.persistence.*;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.EnumType;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -14,6 +11,9 @@ import lombok.Setter;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
@Entity @Entity
@Table(name = "transaction_event", indexes = {
@Index(name = "idx_txn_event_txn_id", columnList = "transaction_id"),
})
@Getter @Getter
@Setter @Setter
@Builder @Builder
@@ -21,6 +21,7 @@ import zw.qantra.tm.domain.enums.Status;
@NoArgsConstructor @NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public class TransactionEvent extends BaseEntity { public class TransactionEvent extends BaseEntity {
@Column(name = "transaction_id")
private String transactionId; private String transactionId;
private String event; private String event;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)

View File

@@ -0,0 +1,24 @@
package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
@Data
@EqualsAndHashCode(callSuper = true)
@SQLRestriction("deleted = false")
public class Uptime extends BaseEntity {
private String name;
private boolean status;
}

View File

@@ -1,5 +1,6 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -9,10 +10,14 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
@Entity @Entity
@Table(name = "users") @Table(name = "users", indexes = {
@Index(name = "idx_user_phone", columnList = "phone"),
})
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@SQLRestriction("deleted = false") @SQLRestriction("deleted = false")
@@ -20,7 +25,8 @@ public class User extends BaseEntity implements UserDetails {
@Column(unique = true, nullable = false) @Column(unique = true, nullable = false)
private String username; private String username;
@JsonIgnore
@Column(nullable = false) @Column(nullable = false)
private String password; private String password;
@@ -48,6 +54,10 @@ public class User extends BaseEntity implements UserDetails {
@Column(name = "is_credentials_non_expired") @Column(name = "is_credentials_non_expired")
private boolean credentialsNonExpired = true; private boolean credentialsNonExpired = true;
@JsonIgnore
@ManyToMany(mappedBy = "users", fetch = FetchType.LAZY)
private Set<Workspace> workspaces = new HashSet<>();
@Override @Override
public Collection<? extends GrantedAuthority> getAuthorities() { public Collection<? extends GrantedAuthority> getAuthorities() {

View File

@@ -0,0 +1,39 @@
package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.SQLRestriction;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "workspace", indexes = {
@Index(name = "idx_workspace_name", columnList = "name"),
})
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@SQLRestriction("deleted = false")
public class Workspace extends BaseEntity {
@Column(nullable = false)
private String name;
private String description;
private String phone;
private String email;
@JsonIgnore
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "workspace_users",
joinColumns = @JoinColumn(name = "workspace_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
@Builder.Default
private Set<User> users = new HashSet<>();
}

View File

@@ -0,0 +1,18 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.AppVersion;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface AppVersionRepository extends JpaRepository<AppVersion, UUID> {
Optional<AppVersion> findTopByOrderByAppBuildNumberDesc();
Optional<AppVersion> findTopByOrderByWebBuildNumberDesc();
Optional<AppVersion> findTopByOrderByTimestampDesc();
}

View File

@@ -5,11 +5,13 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.GroupBatch; import zw.qantra.tm.domain.models.GroupBatch;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
public interface GroupBatchRepository extends JpaRepository<GroupBatch, UUID>, JpaSpecificationExecutor<GroupBatch> { public interface GroupBatchRepository extends JpaRepository<GroupBatch, UUID>, JpaSpecificationExecutor<GroupBatch> {
Optional<GroupBatch> findByIdAndUserId(UUID id, String userId); Optional<GroupBatch> findByIdAndWorkspaceId(UUID id, UUID workspaceId);
List<GroupBatch> findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
} }

View File

@@ -1,12 +1,16 @@
package zw.qantra.tm.domain.repositories; package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.PaymentProcessor; import zw.qantra.tm.domain.models.PaymentProcessor;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
public interface PaymentProcessorRepository extends JpaRepository<PaymentProcessor, UUID> { public interface PaymentProcessorRepository extends JpaRepository<PaymentProcessor, UUID>,
JpaSpecificationExecutor<PaymentProcessor> {
boolean existsByLabel(String label); boolean existsByLabel(String label);
boolean existsByLabelAndCurrency(String label, CurrencyType currencyType);
} }

View File

@@ -3,6 +3,7 @@ package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.Provider; import zw.qantra.tm.domain.models.Provider;
import java.util.List; import java.util.List;
@@ -12,5 +13,7 @@ import java.util.UUID;
public interface ProviderRepository extends JpaRepository<Provider, UUID>, JpaSpecificationExecutor<Provider> { public interface ProviderRepository extends JpaRepository<Provider, UUID>, JpaSpecificationExecutor<Provider> {
List<Provider> findByCategory(String category); List<Provider> findByCategory(String category);
Provider findByClientId(String clientId); Provider findByClientId(String clientId);
Provider findByLabel(String label);
Provider findByLabelAndCurrency(String label, CurrencyType currency);
boolean existsByClientId(String clientId); boolean existsByClientId(String clientId);
} }

View File

@@ -13,5 +13,6 @@ public interface RecipientGroupMemberRepository extends JpaRepository<RecipientG
List<RecipientGroupMember> findAllByRecipientGroupId(UUID recipientGroupId); List<RecipientGroupMember> findAllByRecipientGroupId(UUID recipientGroupId);
boolean existsByRecipientGroupIdAndRecipientId(UUID recipientGroupId, UUID recipientId); boolean existsByRecipientGroupIdAndRecipientId(UUID recipientGroupId, UUID recipientId);
void deleteAllByRecipientGroupId(UUID recipientGroupId); void deleteAllByRecipientGroupId(UUID recipientGroupId);
List<RecipientGroupMember> findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
} }

View File

@@ -11,7 +11,8 @@ import java.util.UUID;
@Repository @Repository
public interface RecipientGroupRepository extends JpaRepository<RecipientGroup, UUID>, JpaSpecificationExecutor<RecipientGroup> { public interface RecipientGroupRepository extends JpaRepository<RecipientGroup, UUID>, JpaSpecificationExecutor<RecipientGroup> {
List<RecipientGroup> findAllByUserId(String userId); List<RecipientGroup> findAllByWorkspaceId(UUID workspaceId);
Optional<RecipientGroup> findByIdAndUserId(UUID id, String userId); Optional<RecipientGroup> findByIdAndWorkspaceId(UUID id, UUID workspaceId);
List<RecipientGroup> findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
} }

View File

@@ -13,5 +13,6 @@ public interface RecipientRepository extends JpaRepository<Recipient, UUID>, Jpa
List<Recipient> findByNameContainingIgnoreCase(String name); List<Recipient> findByNameContainingIgnoreCase(String name);
List<Recipient> findByEmailContainingIgnoreCase(String email); List<Recipient> findByEmailContainingIgnoreCase(String email);
List<Recipient> findByPhoneNumberContaining(String phoneNumber); List<Recipient> findByPhoneNumberContaining(String phoneNumber);
List<Recipient> findByAccountAndUserId(String account, String userId); List<Recipient> findByAccountAndWorkspaceId(String account, UUID workspaceId);
} List<Recipient> findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
}

View File

@@ -16,7 +16,10 @@ import java.util.UUID;
public interface TransactionRepository extends JpaRepository<Transaction, UUID>, JpaSpecificationExecutor<Transaction> { public interface TransactionRepository extends JpaRepository<Transaction, UUID>, JpaSpecificationExecutor<Transaction> {
Transaction findByTraceAndType(String trace, RequestType type); Transaction findByTraceAndType(String trace, RequestType type);
Optional<Transaction> findByReference(String reference); Optional<Transaction> findByReference(String reference);
List<Transaction> findAllByUserId(String uid); List<Transaction> findAllByUserId(String userId);
Optional<Transaction> findByIdAndWorkspaceId(UUID id, UUID workspaceId);
List<Transaction> findAllByWorkspaceId(UUID workspaceId);
List<Transaction> findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
List<Transaction> findAllByConfirmationStatusAndPaymentStatusAndIntegrationStatus( List<Transaction> findAllByConfirmationStatusAndPaymentStatusAndIntegrationStatus(
Status s1, Status s2, Status s3); Status s1, Status s2, Status s3);
List<Transaction> findAllByPaymentStatusAndPollingStatus(Status s1, Status s2); List<Transaction> findAllByPaymentStatusAndPollingStatus(Status s1, Status s2);

View File

@@ -0,0 +1,12 @@
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.Uptime;
import java.util.UUID;
@Repository
public interface UptimeRepository extends JpaRepository<Uptime, UUID>, JpaSpecificationExecutor<Uptime> {
}

View File

@@ -1,6 +1,7 @@
package zw.qantra.tm.domain.repositories; package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.User; import zw.qantra.tm.domain.models.User;
@@ -8,7 +9,7 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
Optional<User> findById(UUID uid); Optional<User> findById(UUID uid);
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email); Optional<User> findByEmail(String email);

View File

@@ -0,0 +1,14 @@
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.Workspace;
import java.util.List;
import java.util.UUID;
@Repository
public interface WorkspaceRepository extends JpaRepository<Workspace, UUID>, JpaSpecificationExecutor<Workspace> {
List<Workspace> findByNameContainingIgnoreCase(String name);
}

View File

@@ -0,0 +1,56 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.AppVersion;
import zw.qantra.tm.domain.repositories.AppVersionRepository;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AppVersionService {
private final AppVersionRepository appVersionRepository;
/**
* Checks if a newer app build number exists than the one provided by the client.
*
* @param currentBuildNumber the build number the client currently has
* @return the latest AppVersion if a newer one exists, empty otherwise
*/
public Optional<AppVersion> checkForAppUpdate(Integer currentBuildNumber) {
Optional<AppVersion> latest = appVersionRepository.findTopByOrderByAppBuildNumberDesc();
if (latest.isPresent() && latest.get().getAppBuildNumber() > currentBuildNumber) {
return latest;
}
return Optional.empty();
}
/**
* Checks if a newer web build number exists than the one provided by the client.
*
* @param currentBuildNumber the build number the client currently has
* @return the latest AppVersion if a newer one exists, empty otherwise
*/
public Optional<AppVersion> checkForWebUpdate(Integer currentBuildNumber) {
Optional<AppVersion> latest = appVersionRepository.findTopByOrderByWebBuildNumberDesc();
if (latest.isPresent() && latest.get().getWebBuildNumber() > currentBuildNumber) {
return latest;
}
return Optional.empty();
}
/**
* Returns the latest AppVersion record regardless of build number.
*
* @return the latest AppVersion by timestamp
*/
public Optional<AppVersion> getLatestVersion() {
return appVersionRepository.findTopByOrderByTimestampDesc();
}
public AppVersion save(AppVersion appVersion) {
return appVersionRepository.save(appVersion);
}
}

View File

@@ -52,8 +52,8 @@ public class BatchReportService {
private final GroupBatchItemRepository groupBatchItemRepository; private final GroupBatchItemRepository groupBatchItemRepository;
@Transactional @Transactional
public BatchReportResult generateBatchReport(UUID batchId, String userId) { public BatchReportResult generateBatchReport(UUID batchId, UUID workspaceId) {
GroupBatch batch = groupBatchRepository.findByIdAndUserId(batchId, userId) GroupBatch batch = groupBatchRepository.findByIdAndWorkspaceId(batchId, workspaceId)
.orElseThrow(() -> new ApiException("Group batch not found")); .orElseThrow(() -> new ApiException("Group batch not found"));
if (!REPORTABLE_STATUSES.contains(batch.getStatus())) { if (!REPORTABLE_STATUSES.contains(batch.getStatus())) {
@@ -121,7 +121,7 @@ public class BatchReportService {
Cell titleCell = titleRow.createCell(0); Cell titleCell = titleRow.createCell(0);
titleCell.setCellValue("Batch Report - " + (batch.getDescription() != null ? batch.getDescription() : batch.getId().toString())); titleCell.setCellValue("Batch Report - " + (batch.getDescription() != null ? batch.getDescription() : batch.getId().toString()));
titleCell.setCellStyle(titleStyle); titleCell.setCellStyle(titleStyle);
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 9)); sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 11));
titleRow.setHeightInPoints(30); titleRow.setHeightInPoints(30);
rowNum++; rowNum++;
@@ -149,7 +149,8 @@ public class BatchReportService {
String[] headers = { String[] headers = {
"#", "Recipient Name", "Phone", "Amount", "#", "Recipient Name", "Phone", "Amount",
"Status", "Confirm Status", "Integration Status", "Status", "Confirm Status", "Integration Status",
"Transaction Ref", "Confirm Error", "Integration Error" "Transaction Ref", "Confirm Error", "Integration Error",
"Credit Address", "Credit Voucher"
}; };
Row headerRow = sheet.createRow(rowNum++); Row headerRow = sheet.createRow(rowNum++);
@@ -214,6 +215,22 @@ public class BatchReportService {
Cell integrationErrCell = row.createCell(9); Cell integrationErrCell = row.createCell(9);
integrationErrCell.setCellValue(item.getIntegrationError() != null ? item.getIntegrationError() : ""); integrationErrCell.setCellValue(item.getIntegrationError() != null ? item.getIntegrationError() : "");
integrationErrCell.setCellStyle(dataStyle); integrationErrCell.setCellStyle(dataStyle);
Cell creditAddressCell = row.createCell(10);
if (item.getTransaction() != null && item.getTransaction().getCreditAddress() != null) {
creditAddressCell.setCellValue(item.getTransaction().getCreditAddress());
} else {
creditAddressCell.setCellValue("");
}
creditAddressCell.setCellStyle(dataStyle);
Cell creditVoucherCell = row.createCell(11);
if (item.getTransaction() != null && item.getTransaction().getCreditVoucher() != null) {
creditVoucherCell.setCellValue(item.getTransaction().getCreditVoucher());
} else {
creditVoucherCell.setCellValue("");
}
creditVoucherCell.setCellStyle(dataStyle);
} }
// Auto-size columns // Auto-size columns

View File

@@ -0,0 +1,58 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.dtos.CloudflarePurgeResponse;
import zw.qantra.tm.rest.RestService;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class CloudflareCacheService {
private static final Logger logger = LoggerFactory.getLogger(CloudflareCacheService.class);
private static final String CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
private final RestService restService;
@Value("${cloudflare-token}")
private String cloudflareToken;
@Value("${cloudflare-zone-id}")
private String cloudflareZoneId;
/**
* Purges the entire Cloudflare cache for the configured zone.
*
* @return CloudflarePurgeResponse containing success status and any errors
*/
public CloudflarePurgeResponse purgeEverything() {
logger.info("Initiating Cloudflare purge_everything for zone: {}", cloudflareZoneId);
String path = CLOUDFLARE_API_BASE + "/zones/" + cloudflareZoneId + "/purge_cache";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(cloudflareToken);
Map<String, Boolean> payload = Map.of("purge_everything", true);
CloudflarePurgeResponse response = restService.postWithExternalAuth(
path, payload, headers, CloudflarePurgeResponse.class
);
if (response != null && response.isSuccess()) {
logger.info("Cloudflare cache purged successfully for zone: {}", cloudflareZoneId);
} else if (response != null) {
logger.error("Cloudflare cache purge failed for zone: {}. Errors: {}",
cloudflareZoneId, response.getErrors());
}
return response;
}
}

View File

@@ -1,19 +1,31 @@
package zw.qantra.tm.domain.services; package zw.qantra.tm.domain.services;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/**
* @deprecated Use {@link NotificationService} instead for all email sending.
* Email functionality has been moved to NotificationService which supports
* pretty HTML templates.
*/
@Deprecated
@Component @Component
public class EmailService { public class EmailService {
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
@Autowired @Autowired
private JavaMailSender emailSender; private JavaMailSender emailSender;
@Async @Async
@Deprecated
public void sendSimpleMessage( public void sendSimpleMessage(
String to, String subject, String text) { String to, String subject, String text) {
log.warn("EmailService.sendSimpleMessage() is deprecated. Use NotificationService instead.");
SimpleMailMessage message = new SimpleMailMessage(); SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("noreply@qantra.co.zw"); message.setFrom("noreply@qantra.co.zw");
message.setTo(to); message.setTo(to);

View File

@@ -13,13 +13,10 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.dtos.GroupBatchCreateRequest; import zw.qantra.tm.domain.dtos.GroupBatchCreateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchItemUpdateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchUpdateRequest; import zw.qantra.tm.domain.dtos.GroupBatchUpdateRequest;
import zw.qantra.tm.domain.dtos.StateResponse; import zw.qantra.tm.domain.dtos.StateResponse;
import zw.qantra.tm.domain.enums.AuthType; import zw.qantra.tm.domain.enums.*;
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.models.*;
import zw.qantra.tm.domain.repositories.GroupBatchItemRepository; import zw.qantra.tm.domain.repositories.GroupBatchItemRepository;
import zw.qantra.tm.domain.repositories.GroupBatchRepository; import zw.qantra.tm.domain.repositories.GroupBatchRepository;
@@ -30,10 +27,13 @@ import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
@@ -43,7 +43,8 @@ public class GroupBatchService {
private static final String BATCH_WORKFLOW_TYPE = "batchWorkflow"; private static final String BATCH_WORKFLOW_TYPE = "batchWorkflow";
private static final Set<GroupBatchStatus> UPDATABLE_STATUSES = Set.of( private static final Set<GroupBatchStatus> UPDATABLE_STATUSES = Set.of(
GroupBatchStatus.CREATED GroupBatchStatus.CREATED,
GroupBatchStatus.CONFIRM_FAILED
); );
private static final Set<GroupBatchStatus> DELETABLE_STATUSES = Set.of( private static final Set<GroupBatchStatus> DELETABLE_STATUSES = Set.of(
@@ -66,21 +67,19 @@ public class GroupBatchService {
@Transactional @Transactional
public GroupBatch createBatch(GroupBatchCreateRequest request) { public GroupBatch createBatch(GroupBatchCreateRequest request) {
RecipientGroup group = recipientGroupService.get( RecipientGroup group = recipientGroupService.get(
request.getRecipientGroupId(), request.getUserId()); request.getRecipientGroupId(), request.getWorkspaceId());
List<RecipientGroupMember> members = recipientGroupService.members( List<RecipientGroupMember> members = recipientGroupService.members(
group.getId(), request.getUserId()); group.getId(), request.getWorkspaceId());
if (members.isEmpty()) { if (members.isEmpty()) {
throw new ApiException("Recipient group has no members"); throw new ApiException("Recipient group has no members");
} }
if (request.getTransactionTemplate() == null) {
throw new ApiException("transactionTemplate is required");
}
GroupBatch batch = GroupBatch.builder() GroupBatch batch = GroupBatch.builder()
.recipientGroupId(group.getId()) .recipientGroupId(group.getId())
.userId(request.getUserId()) .userId(request.getUserId())
.workspaceId(request.getWorkspaceId())
.description(request.getDescription()) .description(request.getDescription())
.requestedBy(request.getRequestedBy()) .requestedBy(request.getRequestedBy())
.currency(request.getCurrency()) .currency(request.getCurrency())
@@ -109,6 +108,7 @@ public class GroupBatchService {
.recipientId(recipient.getId()) .recipientId(recipient.getId())
.recipientName(recipient.getName()) .recipientName(recipient.getName())
.recipientPhone(recipient.getPhoneNumber()) .recipientPhone(recipient.getPhoneNumber())
.recipientAccount(recipient.getAccount())
.amount(defaultAmount) .amount(defaultAmount)
.status(GroupBatchItemStatus.NEW) .status(GroupBatchItemStatus.NEW)
.confirmStatus(GroupBatchItemConfirmStatus.PENDING) .confirmStatus(GroupBatchItemConfirmStatus.PENDING)
@@ -127,8 +127,8 @@ public class GroupBatchService {
return groupBatchRepository.save(savedBatch); return groupBatchRepository.save(savedBatch);
} }
public GroupBatch triggerConfirm(UUID batchId, String userId, String idempotencyKey) { public GroupBatch triggerConfirm(UUID batchId, UUID workspaceId, String idempotencyKey) {
GroupBatch batch = getBatch(batchId, userId); GroupBatch batch = getBatch(batchId, workspaceId);
if (batch.getConfirmIdempotencyKey() != null && batch.getConfirmIdempotencyKey().equals(idempotencyKey)) { if (batch.getConfirmIdempotencyKey() != null && batch.getConfirmIdempotencyKey().equals(idempotencyKey)) {
return batch; return batch;
@@ -141,7 +141,7 @@ public class GroupBatchService {
if (batch.getWorkflowId() == null) { if (batch.getWorkflowId() == null) {
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(BatchWorkflow.TYPE) .setType(BatchWorkflow.TYPE)
.setExternalId(batch.getUserId() + "-batch-" + new Instant().getMillis()) .setExternalId(batch.getWorkspaceId() + "-batch-" + new Instant().getMillis())
.setBusinessKey(batch.getId().toString()) .setBusinessKey(batch.getId().toString())
.putStateVariable("batchId", Utils.toJson(batch.getId().toString())) .putStateVariable("batchId", Utils.toJson(batch.getId().toString()))
.setNextActivation(DateTime.now()) .setNextActivation(DateTime.now())
@@ -171,9 +171,9 @@ public class GroupBatchService {
return batch; return batch;
} }
public Object triggerRequest(UUID batchId, String userId, public Object triggerRequest(UUID batchId, UUID workspaceId,
AuthType authType, String idempotencyKey) { AuthType authType, String idempotencyKey) {
GroupBatch batch = getBatch(batchId, userId); GroupBatch batch = getBatch(batchId, workspaceId);
if (batch.getRequestIdempotencyKey() != null && batch.getRequestIdempotencyKey().equals(idempotencyKey)) { if (batch.getRequestIdempotencyKey() != null && batch.getRequestIdempotencyKey().equals(idempotencyKey)) {
return batch; return batch;
@@ -207,7 +207,7 @@ public class GroupBatchService {
try { try {
CompletableFuture<Object> future = workflowUtils CompletableFuture<Object> future = workflowUtils
.waitForState(batch.getWorkflowId(), new String[]{"requestSuccess", "done", "failed"}, .waitForState(batch.getWorkflowId(), new String[]{"requestSuccess", "done", "failed"},
15000, 50); 45000, 50);
StateResponse response = (StateResponse) future.get(); StateResponse response = (StateResponse) future.get();
String id = response.getBody().toString(); // this returns Batch Transaction ID String id = response.getBody().toString(); // this returns Batch Transaction ID
String cleaned = id.replaceAll("^\"|\"$", ""); String cleaned = id.replaceAll("^\"|\"$", "");
@@ -218,8 +218,8 @@ public class GroupBatchService {
} }
public Object triggerPoll(UUID batchId, String userId, String idempotencyKey) { public Object triggerPoll(UUID batchId, UUID workspaceId, String idempotencyKey) {
GroupBatch batch = getBatch(batchId, userId); GroupBatch batch = getBatch(batchId, workspaceId);
if (batch.getPollIdempotencyKey() != null && batch.getPollIdempotencyKey().equals(idempotencyKey)) { if (batch.getPollIdempotencyKey() != null && batch.getPollIdempotencyKey().equals(idempotencyKey)) {
return batch; return batch;
@@ -232,6 +232,7 @@ public class GroupBatchService {
throw new ApiException("Batch workflow has not been started. Trigger confirm first."); throw new ApiException("Batch workflow has not been started. Trigger confirm first.");
} }
// STEP 1: TRIGGER POLL SYNCHRONOUSLY
WorkflowInstance update = new WorkflowInstance.Builder() WorkflowInstance update = new WorkflowInstance.Builder()
.setId(batch.getWorkflowId()) .setId(batch.getWorkflowId())
.setState("poll") .setState("poll")
@@ -250,20 +251,42 @@ public class GroupBatchService {
workflowInstanceService.updateWorkflowInstance(update, action); workflowInstanceService.updateWorkflowInstance(update, action);
try { try {
// wait for poll to finish
CompletableFuture<Object> future = workflowUtils CompletableFuture<Object> future = workflowUtils
.waitForState(batch.getWorkflowId(), new String[]{"done", "failed"}, .waitForState(batch.getWorkflowId(), new String[]{"pollSuccess", "failed"},
15000, 50); 45000, 50);
StateResponse response = (StateResponse) future.get(); StateResponse response = (StateResponse) future.get();
String id = response.getBody().toString(); // this returns Batch Transaction ID String id = response.getBody().toString(); // this returns Batch Transaction ID
String cleaned = id.replaceAll("^\"|\"$", ""); String cleaned = id.replaceAll("^\"|\"$", "");
return transactionService.findById(UUID.fromString(cleaned)); Transaction transaction = transactionService.findById(UUID.fromString(cleaned));
// if failed then return
if(!transaction.getPollingStatus().equals(Status.SUCCESS)) {
return transaction;
}
// STEP 2: TRIGGER INTEGRATION ASYNCHRONOUSLY
WorkflowInstance integrationUpdate = new WorkflowInstance.Builder()
.setId(batch.getWorkflowId())
.setState("integration")
.setNextActivation(DateTime.now())
.setStateText("Resumed from network trigger")
.build();
WorkflowInstanceAction integrationAction = new WorkflowInstanceAction.Builder(integrationUpdate)
.setType(WorkflowInstanceAction.WorkflowActionType.externalChange)
.setExecutionEnd(DateTime.now())
.build();
workflowInstanceService.updateWorkflowInstance(integrationUpdate, integrationAction);
return transaction;
} catch (InterruptedException | ExecutionException e) { } catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
public GroupBatch getBatch(UUID batchId, String userId) { public GroupBatch getBatch(UUID batchId, UUID workspaceId) {
return groupBatchRepository.findByIdAndUserId(batchId, userId) return groupBatchRepository.findByIdAndWorkspaceId(batchId, workspaceId)
.orElseThrow(() -> new ApiException("Group batch not found")); .orElseThrow(() -> new ApiException("Group batch not found"));
} }
@@ -277,7 +300,7 @@ public class GroupBatchService {
@Transactional @Transactional
public GroupBatch updateBatch(GroupBatchUpdateRequest request) { public GroupBatch updateBatch(GroupBatchUpdateRequest request) {
GroupBatch batch = getBatch(request.getId(), request.getUserId()); GroupBatch batch = getBatch(request.getId(), request.getWorkspaceId());
if (!UPDATABLE_STATUSES.contains(batch.getStatus())) { if (!UPDATABLE_STATUSES.contains(batch.getStatus())) {
throw new ApiException( throw new ApiException(
@@ -328,8 +351,82 @@ public class GroupBatchService {
} }
@Transactional @Transactional
public void deleteBatch(UUID batchId, String userId) { public List<GroupBatchItem> updateBatchItems(UUID batchId, UUID workspaceId,
GroupBatch batch = getBatch(batchId, userId); List<GroupBatchItemUpdateRequest> items) {
GroupBatch batch = getBatch(batchId, workspaceId);
if (!UPDATABLE_STATUSES.contains(batch.getStatus())) {
throw new ApiException(
"Batch items cannot be updated at current status: " + batch.getStatus()
+ ". Only batches in CREATED status can have items updated."
);
}
List<GroupBatchItem> existingItems = groupBatchItemRepository.findAllByGroupBatchId(batchId);
Map<UUID, GroupBatchItem> itemMap = existingItems.stream()
.collect(Collectors.toMap(GroupBatchItem::getId, item -> item));
List<GroupBatchItem> updatedItems = new ArrayList<>();
for (GroupBatchItemUpdateRequest update : items) {
if (update.getId() == null) {
throw new ApiException("Item id is required for each item update");
}
GroupBatchItem item = itemMap.get(update.getId());
if (item == null) {
throw new ApiException("Group batch item not found: " + update.getId());
}
if (update.getAmount() != null) {
item.setAmount(update.getAmount());
}
if (update.getRecipientName() != null) {
item.setRecipientName(update.getRecipientName());
}
if (update.getRecipientPhone() != null) {
item.setRecipientPhone(update.getRecipientPhone());
}
if (update.getRecipientAccount() != null) {
item.setRecipientAccount(update.getRecipientAccount());
}
if (update.getBillClientId() != null) {
item.setBillClientId(update.getBillClientId());
}
if (update.getBillName() != null) {
item.setBillName(update.getBillName());
}
if (update.getBillProductName() != null) {
item.setBillProductName(update.getBillProductName());
}
if (update.getProviderImage() != null) {
item.setProviderImage(update.getProviderImage());
}
if (update.getProviderLabel() != null) {
item.setProviderLabel(update.getProviderLabel());
}
updatedItems.add(groupBatchItemRepository.save(item));
}
// Recalculate batch totals if any amount changed
boolean amountChanged = items.stream().anyMatch(u -> u.getAmount() != null);
if (amountChanged) {
List<GroupBatchItem> allItems = groupBatchItemRepository.findAllByGroupBatchId(batchId);
BigDecimal newTotalValue = allItems.stream()
.map(GroupBatchItem::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
batch.setValue(newTotalValue);
batch.setCount(allItems.size());
groupBatchRepository.save(batch);
}
return updatedItems;
}
@Transactional
public void deleteBatch(UUID batchId, UUID workspaceId) {
GroupBatch batch = getBatch(batchId, workspaceId);
if (!DELETABLE_STATUSES.contains(batch.getStatus())) { if (!DELETABLE_STATUSES.contains(batch.getStatus())) {
throw new ApiException( throw new ApiException(

View File

@@ -10,6 +10,7 @@ import zw.qantra.tm.domain.repositories.GroupBatchRepository;
import zw.qantra.tm.domain.repositories.RecipientRepository; import zw.qantra.tm.domain.repositories.RecipientRepository;
import zw.qantra.tm.domain.services.processors.workflows.handlers.*; import zw.qantra.tm.domain.services.processors.workflows.handlers.*;
import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.SecurityUtils;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -32,6 +33,7 @@ public class GroupBatchWorkflowService {
private final IntegrationHandler integrationHandler; private final IntegrationHandler integrationHandler;
private final CalculateChargesHandler calculateChargesHandler; private final CalculateChargesHandler calculateChargesHandler;
private final CleanupHandler cleanUpHandler; private final CleanupHandler cleanUpHandler;
private final UserService userService;
@Transactional @Transactional
public GroupBatch processConfirm(UUID batchId) { public GroupBatch processConfirm(UUID batchId) {
@@ -49,15 +51,10 @@ public class GroupBatchWorkflowService {
batch.setConfirmStartedAt(LocalDateTime.now()); batch.setConfirmStartedAt(LocalDateTime.now());
groupBatchRepository.save(batch); 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<GroupBatchItem> items = groupBatchItemRepository.findAllByGroupBatchId(batchId);
List<CompletableFuture<Void>> futures = new ArrayList<>(); List<CompletableFuture<Void>> futures = new ArrayList<>();
for (GroupBatchItem item : items) { for (GroupBatchItem item : items) {
futures.add(CompletableFuture.runAsync(() -> confirmItem(batch, item, template))); futures.add(CompletableFuture.runAsync(() -> confirmItem(batch, item)));
} }
futures.forEach(CompletableFuture::join); futures.forEach(CompletableFuture::join);
@@ -98,17 +95,18 @@ public class GroupBatchWorkflowService {
throw new ApiException("No confirmed batch items available for payment request"); 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() BigDecimal total = confirmedItems.stream()
.map(item -> item.getAmount() == null ? BigDecimal.ZERO : item.getAmount()) .map(item -> item.getAmount() == null ? BigDecimal.ZERO : item.getAmount())
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
User user = userService.getUserRepository().findById(UUID.fromString(batch.getUserId())).orElseThrow();
Transaction transaction = Utils.deepCopy(template, Transaction.class); Transaction transaction = new Transaction();
transaction.setId(null); transaction.setId(null);
transaction.setDebitPhone(user.getPhone());
transaction.setDebitName(user.getFirstName());
transaction.setDebitCurrency(CurrencyType.valueOf(batch.getCurrency()));
transaction.setRegion("ZW");
transaction.setPartyType(PartyType.GROUP); transaction.setPartyType(PartyType.GROUP);
transaction.setType(RequestType.REQUEST); transaction.setType(RequestType.REQUEST);
transaction.setAuthType(authType == null ? AuthType.REMOTE : authType); transaction.setAuthType(authType == null ? AuthType.REMOTE : authType);
@@ -116,9 +114,13 @@ public class GroupBatchWorkflowService {
transaction.setTotalAmount(total); transaction.setTotalAmount(total);
transaction.setReference("batch-" + batch.getId()); transaction.setReference("batch-" + batch.getId());
transaction.setUserId(batch.getUserId()); transaction.setUserId(batch.getUserId());
transaction.setWorkspaceId(batch.getWorkspaceId());
transaction.setPaymentProcessorLabel(batch.getPaymentProcessorLabel()); transaction.setPaymentProcessorLabel(batch.getPaymentProcessorLabel());
transaction.setPaymentProcessorName(batch.getPaymentProcessorName()); transaction.setPaymentProcessorName(batch.getPaymentProcessorName());
transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage()); transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage());
transaction.setBatchId(batch.getId());
transaction.setBatchDescription(batch.getDescription());
transaction.setBillName("Batch - " + batch.getDescription());
transaction = calculateChargesHandler.process(transaction); transaction = calculateChargesHandler.process(transaction);
transaction = (Transaction) cleanUpHandler.process(transaction); transaction = (Transaction) cleanUpHandler.process(transaction);
@@ -194,6 +196,9 @@ public class GroupBatchWorkflowService {
batch.setStatus(GroupBatchStatus.PARTIAL); batch.setStatus(GroupBatchStatus.PARTIAL);
} else { } else {
batch.setStatus(GroupBatchStatus.COMPLETED); batch.setStatus(GroupBatchStatus.COMPLETED);
Transaction paymentTransaction = transactionService.findById(batch.getPaymentTransactionId());
paymentTransaction.setIntegrationStatus(Status.SUCCESS);
transactionService.save(paymentTransaction);
} }
batch.setCompletedAt(LocalDateTime.now()); batch.setCompletedAt(LocalDateTime.now());
@@ -203,12 +208,27 @@ public class GroupBatchWorkflowService {
return groupBatchRepository.save(batch); return groupBatchRepository.save(batch);
} }
private void confirmItem(GroupBatch batch, GroupBatchItem item, Transaction template) { private void confirmItem(GroupBatch batch, GroupBatchItem item) {
GroupBatchItem mutableItem = groupBatchItemRepository.findById(item.getId()).orElse(item); GroupBatchItem mutableItem = groupBatchItemRepository.findById(item.getId()).orElse(item);
try { try {
Transaction transaction = Utils.deepCopy(template, Transaction.class); User user = userService.getUserRepository().findById(UUID.fromString(batch.getUserId())).orElseThrow();
Transaction transaction = new Transaction();
transaction.setId(null); transaction.setId(null);
transaction.setDebitPhone(user.getPhone());
transaction.setDebitName(user.getFirstName());
transaction.setDebitCurrency(CurrencyType.valueOf(batch.getCurrency()));
transaction.setRegion("ZW");
transaction.setBillClientId(item.getBillClientId());
transaction.setBillName(item.getBillName());
transaction.setBillProductName(item.getBillProductName());
transaction.setProviderImage(item.getProviderImage());
transaction.setProviderLabel(item.getProviderLabel());
transaction.setUserId(batch.getUserId()); transaction.setUserId(batch.getUserId());
transaction.setBatchId(batch.getId());
transaction.setUsername(user.getUsername());
transaction.setWorkspaceId(batch.getWorkspaceId());
transaction.setType(RequestType.CONFIRM); transaction.setType(RequestType.CONFIRM);
transaction.setPartyType(PartyType.BATCH); transaction.setPartyType(PartyType.BATCH);
transaction.setAuthType(AuthType.REMOTE); // default to remote for now transaction.setAuthType(AuthType.REMOTE); // default to remote for now
@@ -233,6 +253,8 @@ public class GroupBatchWorkflowService {
mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.CONFIRMED); mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.CONFIRMED);
mutableItem.setStatus(GroupBatchItemStatus.CONFIRMED); mutableItem.setStatus(GroupBatchItemStatus.CONFIRMED);
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.PENDING); mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.PENDING);
if(result.getCreditAddress() != null)
mutableItem.setCreditAddress(result.getCreditAddress());
mutableItem.setConfirmError(null); mutableItem.setConfirmError(null);
} else { } else {
mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED); mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED);
@@ -241,6 +263,7 @@ public class GroupBatchWorkflowService {
mutableItem.setConfirmError(result.getErrorMessage()); mutableItem.setConfirmError(result.getErrorMessage());
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace();
mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED); mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED);
mutableItem.setStatus(GroupBatchItemStatus.FAILED); mutableItem.setStatus(GroupBatchItemStatus.FAILED);
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.SKIPPED); mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.SKIPPED);
@@ -278,6 +301,7 @@ public class GroupBatchWorkflowService {
mutableItem.setStatus(GroupBatchItemStatus.INTEGRATED); mutableItem.setStatus(GroupBatchItemStatus.INTEGRATED);
mutableItem.setIntegrationError(null); mutableItem.setIntegrationError(null);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace();
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.FAILED); mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.FAILED);
mutableItem.setStatus(GroupBatchItemStatus.FAILED); mutableItem.setStatus(GroupBatchItemStatus.FAILED);
mutableItem.setIntegrationError(e.getMessage()); mutableItem.setIntegrationError(e.getMessage());

View File

@@ -9,6 +9,7 @@ import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.LinkedList; import java.util.LinkedList;
@@ -21,6 +22,7 @@ public class HotRechargeBalanceService {
Logger logger = LoggerFactory.getLogger(HotRechargeBalanceService.class); Logger logger = LoggerFactory.getLogger(HotRechargeBalanceService.class);
private final RestService restService; private final RestService restService;
private final SettingService settingService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl; private String hotApiUrl;
@@ -28,6 +30,10 @@ public class HotRechargeBalanceService {
@Cacheable(value = "hotrechargeBalance", key = "#token + '_' + #amount.toString()") @Cacheable(value = "hotrechargeBalance", key = "#token + '_' + #amount.toString()")
public boolean checkBalance(String token, BigDecimal amount, String accId) { public boolean checkBalance(String token, BigDecimal amount, String accId) {
try { try {
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
return true;
}
String balanceUrl = hotApiUrl + "/account/balance/" + accId; String balanceUrl = hotApiUrl + "/account/balance/" + accId;
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token); headers.setBearerAuth(token);

View File

@@ -4,6 +4,7 @@ import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.instance.WorkflowInstance; import io.nflow.engine.workflow.instance.WorkflowInstance;
import io.nflow.engine.workflow.instance.WorkflowInstanceAction; import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jobrunr.jobs.context.JobRunrDashboardLogger; import org.jobrunr.jobs.context.JobRunrDashboardLogger;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -22,10 +23,10 @@ import java.util.concurrent.CompletableFuture;
import static org.joda.time.DateTime.now; import static org.joda.time.DateTime.now;
@Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class JobService { public class JobService {
Logger log = new JobRunrDashboardLogger(LoggerFactory.getLogger(JobService.class));
private final WorkflowInstanceService workflowInstanceService; private final WorkflowInstanceService workflowInstanceService;
private final WorkflowUtils workflowUtils; private final WorkflowUtils workflowUtils;

View File

@@ -0,0 +1,137 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import jakarta.mail.internet.MimeMessage;
import zw.qantra.tm.domain.dtos.sms.SmsApiResponse;
import zw.qantra.tm.domain.dtos.sms.SmsBatchRequest;
import zw.qantra.tm.domain.dtos.sms.SmsMessage;
import zw.qantra.tm.rest.RestService;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class NotificationService {
private static final Logger logger = LoggerFactory.getLogger(NotificationService.class);
private final RestService restService;
private final JavaMailSender emailSender;
@Value("${zss.sms.api.url}")
private String smsApiUrl;
@Value("${zss.sms.api.auth}")
private String smsApiAuth;
@Value("${sms.debug}")
private Boolean smsDebug;
@Async
public void sendEmail(String to, String subject, String text) {
sendEmailWithHtml(to, subject, text, null);
}
@Async
public void sendEmailWithHtml(String to, String subject, String htmlContent) {
sendEmailWithHtml(to, subject, null, htmlContent);
}
@Async
public void sendEmailWithHtml(String to, String subject, String text, String htmlContent) {
try {
MimeMessage message = emailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom("noreply@qantra.co.zw");
helper.setTo(to);
helper.setSubject(subject);
if (htmlContent != null) {
helper.setText(text != null ? text : "", htmlContent);
} else if (text != null) {
helper.setText(text);
}
emailSender.send(message);
logger.info("Email sent to {} - Subject: {}", to, subject);
} catch (Exception e) {
logger.error("Failed to send email to {}: {}", to, e.getMessage(), e);
}
}
@Async
public void sendOtpEmail(String to, String username, String otpCode) {
try {
// Load and populate the HTML template
ClassPathResource resource = new ClassPathResource("templates/email-otp.html");
String htmlTemplate;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
htmlTemplate = reader.lines().collect(Collectors.joining("\n"));
}
// Simple placeholder replacement
String htmlContent = htmlTemplate
.replace("{{username}}", username != null ? username : "")
.replace("{{otpCode}}", otpCode != null ? otpCode : "");
MimeMessage message = emailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom("noreply@qantra.co.zw");
helper.setTo(to);
helper.setSubject("Velocity Pay verification code");
helper.setText("Your Velocity Pay verification code is: " + otpCode, htmlContent);
emailSender.send(message);
logger.info("OTP email sent to {} for user: {}", to, username);
} catch (Exception e) {
logger.error("Failed to send OTP email to {}: {}", to, e.getMessage(), e);
}
}
@Async
public void sendSms(String to, String message) {
if(smsDebug) {
logger.info("Sending SMS to {}: {}", to, message);
return;
}
try {
SmsMessage smsMessage = SmsMessage.builder()
.reference(UUID.randomUUID().toString())
.message(message)
.msisdn(to)
.build();
SmsBatchRequest batchRequest = SmsBatchRequest.builder()
.batchReference(UUID.randomUUID().toString())
.smsList(Collections.singletonList(smsMessage))
.build();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", smsApiAuth);
SmsApiResponse response = restService.postWithExternalAuth(
smsApiUrl,
batchRequest,
headers,
SmsApiResponse.class
);
logger.info("SMS sent to {} - Status: {}, Result: {}", to, response.getStatus(), response.getResult());
} catch (Exception e) {
logger.error("Failed to send SMS to {}: {}", to, e.getMessage(), e);
}
}
}

View File

@@ -1,7 +1,11 @@
package zw.qantra.tm.domain.services; package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor; 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.stereotype.Service;
import zw.qantra.tm.domain.models.PaymentProcessor;
import zw.qantra.tm.domain.repositories.PaymentProcessorRepository; import zw.qantra.tm.domain.repositories.PaymentProcessorRepository;
@Service @Service
@@ -12,4 +16,8 @@ public class PaymentProcessorService {
public PaymentProcessorRepository getPaymentProcessorRepository() { public PaymentProcessorRepository getPaymentProcessorRepository() {
return paymentProcessorRepository; return paymentProcessorRepository;
} }
public Page<PaymentProcessor> getAllPaymentProcessors(Specification<PaymentProcessor> specification, Pageable pageable) {
return paymentProcessorRepository.findAll(specification, pageable);
}
} }

View File

@@ -6,16 +6,13 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper; import zw.qantra.tm.domain.dtos.ProductDto;
import zw.qantra.tm.domain.dtos.SBZProductDto;
import zw.qantra.tm.domain.models.Provider; import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.repositories.ProviderRepository; import zw.qantra.tm.domain.repositories.ProviderRepository;
import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.exceptions.ApiException;
@@ -35,61 +32,41 @@ public class ProviderService {
private final RestService restService; private final RestService restService;
private final HotRechargeTokenService hotRechargeTokenService; private final HotRechargeTokenService hotRechargeTokenService;
private Map<String, List<ProductDto>> providerProductsCache = new HashMap<>();
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl; private String hotApiUrl;
@Value("${sbz.aggregator.url}") @Value("${sbz.aggregator.url}")
private String aggregatorUrl; private String aggregatorUrl;
public SBZProductDto getProviderProduct(UUID providerId, UUID productId) { public ProductDto getProviderProduct(UUID providerId, String productName) {
return getProviderProducts(providerId.toString()).stream() List<ProductDto> products = getProviderProducts(providerId.toString());
.filter(product -> product.getUid().equals(productId)) ProductDto productResult = products.stream()
.filter(product -> product.getName().equals(productName))
.findFirst() .findFirst()
.orElse(null); .orElse(null);
log.info("Product: {}", productResult);
return productResult;
} }
@Deprecated public List<ProductDto> getProviderProducts(String providerUid) {
public List<SBZProductDto> getProviderProducts(UUID providerId) {
String token = restService.getMerchantToken();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
headers.setBearerAuth(token);
LinkedHashMap response;
try { try {
response = this.restService.getAs( List<ProductDto> cachedProducts = providerProductsCache.get(providerUid);
aggregatorUrl + "/merchant/aggregation/products?size=20&clientUid=" + providerId.toString(), if (cachedProducts != null) {
headers, log.info("Returning cached products for provider: {}, number of products: {}", providerUid,
LinkedHashMap.class cachedProducts.size());
); return cachedProducts;
} catch (Exception e) { }
log.error("Error fetching products for provider " + providerId, e);
return Collections.emptyList();
}
LinkedHashMap body = (LinkedHashMap) response.get("body");
List<LinkedHashMap> content = (List<LinkedHashMap>) body.get("content");
List<SBZProductDto> products = new ArrayList<>();
for (LinkedHashMap product : content) {
ObjectMapper mapper = new ObjectMapper();
SBZProductDto productDto = mapper.convertValue(product, SBZProductDto.class);
products.add(productDto);
}
return products;
}
// todo: figure out caching for different providers
// @Cacheable(value = "providerProducts", key = "#providerUid")
public List<SBZProductDto> getProviderProducts(String providerUid) {
try {
Provider provider = providerRepository.findById(UUID.fromString(providerUid)).orElse(null); Provider provider = providerRepository.findById(UUID.fromString(providerUid)).orElse(null);
if (provider == null) { if (provider == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
if(provider.getRequiresProducts().equals(false)){
return Collections.emptyList();
}
Map<String, String> meta = Utils.fromJson(provider.getMeta(), Map.class); Map<String, String> meta = Utils.fromJson(provider.getMeta(), Map.class);
String providerId = meta.get("hotProductId"); String providerId = meta.get("hotProductId");
@@ -107,25 +84,28 @@ public class ProviderService {
} }
List stock = (List) response.get("stock"); List stock = (List) response.get("stock");
List<SBZProductDto> products = new ArrayList<>(); List<ProductDto> products = new ArrayList<>();
for (Object item : stock) { for (Object item : stock) {
LinkedHashMap<String, Object> stockItem = (LinkedHashMap<String, Object>) item; LinkedHashMap<String, Object> stockItem = (LinkedHashMap<String, Object>) item;
int stockProviderId = (int) stockItem.get("productId"); // providerId is referred to as productId in the stock response int stockProviderId = (int) stockItem.get("productId"); // providerId is referred to as productId in the stock response
if (String.valueOf(stockProviderId).equals(providerId)) { if (String.valueOf(stockProviderId).equals(providerId)) {
log.info("Found matching provider in stock: " + stockItem);
SBZProductDto sbzProductDto = new SBZProductDto(); ProductDto productDto = new ProductDto();
sbzProductDto.setName((String) stockItem.get("name")); productDto.setName((String) stockItem.get("name"));
sbzProductDto.setDescription((String) stockItem.get("description")); productDto.setDescription((String) stockItem.get("description"));
sbzProductDto.setDisplayName((String) stockItem.get("name")); productDto.setDisplayName((String) stockItem.get("name"));
sbzProductDto.setDefaultAmount(new BigDecimal(stockItem.get("amount").toString())); productDto.setDefaultAmount(new BigDecimal(stockItem.get("amount").toString()));
sbzProductDto.setRequiresAmount(true); // always true for now productDto.setRequiresAmount(true); // always true for now
sbzProductDto.setUid(UUID.fromString(Utils.getUid())); productDto.setUid(UUID.fromString(Utils.getUid()));
sbzProductDto.setExternalId((String) stockItem.get("productCode")); productDto.setExternalId((String) stockItem.get("productCode"));
products.add(sbzProductDto); products.add(productDto);
} }
} }
providerProductsCache.put(providerUid, products);
log.info("Cached products for provider: {}, number of products: {}", providerUid, products.size());
log.info("Provider products cache size: {}", providerProductsCache.size());
return products; return products;
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.error("Error fetching products: {}", providerUid); log.error("Error fetching products: {}", providerUid);
@@ -144,19 +124,17 @@ public class ProviderService {
return meta.get("hotProductId"); return meta.get("hotProductId");
} }
public String getExternalId(String providerId, String productUuid) { public String getExternalId(String providerId, String productName) {
try { try {
UUID productUid = UUID.fromString(productUuid); List<ProductDto> products = getProviderProducts(providerId);
List<SBZProductDto> products = getProviderProducts(providerId);
return products.stream() return products.stream()
.filter(product -> product.getUid().equals(productUid)) .filter(product -> product.getName().equals(productName))
.map(SBZProductDto::getExternalId) .map(ProductDto::getExternalId)
.findFirst() .findFirst()
.orElse(null); .orElse(null);
} catch (IllegalArgumentException e) { } catch (Exception e) {
log.error("Invalid UUID format: " + productUuid, e); log.error("Failed to fetch external Id: " + productName, e);
return null; return null;
} }
} }

View File

@@ -6,14 +6,25 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.models.Recipient; import org.springframework.web.multipart.MultipartFile;
import zw.qantra.tm.domain.models.RecipientGroup; import zw.qantra.tm.domain.dtos.GroupCreateFromFileRequest;
import zw.qantra.tm.domain.models.RecipientGroupMember; import zw.qantra.tm.domain.dtos.GroupCreateFromFileResult;
import zw.qantra.tm.domain.dtos.ProductDto;
import zw.qantra.tm.domain.enums.*;
import zw.qantra.tm.domain.models.*;
import zw.qantra.tm.domain.repositories.GroupBatchItemRepository;
import zw.qantra.tm.domain.repositories.GroupBatchRepository;
import zw.qantra.tm.domain.repositories.RecipientGroupMemberRepository; import zw.qantra.tm.domain.repositories.RecipientGroupMemberRepository;
import zw.qantra.tm.domain.repositories.RecipientGroupRepository; import zw.qantra.tm.domain.repositories.RecipientGroupRepository;
import zw.qantra.tm.domain.repositories.RecipientRepository; import zw.qantra.tm.domain.repositories.RecipientRepository;
import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -24,35 +35,376 @@ public class RecipientGroupService {
private final RecipientGroupRepository recipientGroupRepository; private final RecipientGroupRepository recipientGroupRepository;
private final RecipientGroupMemberRepository recipientGroupMemberRepository; private final RecipientGroupMemberRepository recipientGroupMemberRepository;
private final RecipientRepository recipientRepository; private final RecipientRepository recipientRepository;
private final GroupBatchRepository groupBatchRepository;
private final GroupBatchItemRepository groupBatchItemRepository;
private final ProviderService providerService;
/**
* Creates a recipient group, creates a batch from it, then updates batch items
* with bill/provider fields from the CSV file, and returns the new batch
* along with any per-row errors.
*
* CSV file structure: name, account, phone, billName, billProductName
*
* Steps:
* 1. Parse CSV rows, collecting per-row validation errors
* 2. Create recipient group from all valid rows
* 3. Create group batch from the group
* 4. Update batch items with billName & billProductName from CSV, skipping rows
* where the provider cannot be resolved and recording those errors
* 5. Return the batch + any row-level errors
*/
@Transactional
public GroupCreateFromFileResult createFromFile(MultipartFile file, GroupCreateFromFileRequest request) {
ParseResult parseResult = parseCsv(file);
if(!parseResult.parseErrors.isEmpty()) {
return GroupCreateFromFileResult.builder()
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.errorCount(parseResult.parseErrors.size())
.errors(parseResult.parseErrors)
.build();
}
if (parseResult.rows.isEmpty()) {
throw new ApiException("CSV file has no valid data rows after validation");
}
// Step 1: build recipient list from valid CSV rows only
List<Recipient> recipients = new ArrayList<>();
for (CsvRow row : parseResult.rows) {
Recipient recipient = Recipient.builder()
.name(row.name)
.account(row.account)
.phoneNumber(row.phone)
.userId(request.getUserId().toString())
.workspaceId(request.getWorkspaceId())
.build();
recipients.add(recipient);
}
// Step 2: create recipient group with recipients
RecipientGroup group = RecipientGroup.builder()
.name(request.getGroupName())
.description(request.getDescription())
.userId(request.getUserId().toString())
.workspaceId(request.getWorkspaceId())
.build();
RecipientGroup savedGroup = recipientGroupRepository.save(group);
List<RecipientGroupMember> members = addMembers(savedGroup.getId(), request.getWorkspaceId(), recipients);
// Step 3: create group batch (inline to avoid circular dependency with GroupBatchService)
GroupBatch batch = createBatchInline(savedGroup.getId(), request.getWorkspaceId(), request, members);
// Step 4: update batch items with bill/provider fields from CSV
List<GroupBatchItem> batchItems = groupBatchItemRepository.findAllByGroupBatchId(batch.getId());
List<GroupBatchItemUpdate> itemUpdates = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> rowErrors = new ArrayList<>(parseResult.parseErrors);
for (int i = 0; i < batchItems.size() && i < parseResult.rows.size(); i++) {
GroupBatchItem batchItem = batchItems.get(i);
CsvRow row = parseResult.rows.get(i);
// Attempt to resolve provider by label
Provider provider = providerService.getProviderRepository().findByLabelAndCurrency(
row.billLabel, CurrencyType.valueOf(request.getCurrency()));
if (provider == null) {
rowErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(row.lineNumber)
.account(row.account)
.name(row.name)
.message("Provider not found for bill label: " + row.billLabel)
.build());
// Skip this row - item remains with default values from batch creation
continue;
}
itemUpdates.add(new GroupBatchItemUpdate(batchItem.getId(), row, provider));
}
// Apply successful item updates directly
if (!itemUpdates.isEmpty()) {
List<GroupCreateFromFileResult.RowError> providerErrors = updateBatchItemsInline(batch.getId(), itemUpdates);
if (!providerErrors.isEmpty()) {
return GroupCreateFromFileResult.builder()
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.errorCount(providerErrors.size())
.errors(providerErrors)
.build();
}
}
// Return the updated batch + errors
GroupBatch resultBatch = groupBatchRepository.findByIdAndWorkspaceId(batch.getId(), request.getWorkspaceId())
.orElseThrow(() -> new ApiException("Batch not found after creation"));
return GroupCreateFromFileResult.builder()
.batch(resultBatch)
.totalRows(parseResult.rows.size() + parseResult.parseErrors.size())
.successCount(itemUpdates.size())
.errorCount(rowErrors.size())
.errors(rowErrors)
.build();
}
/**
* Create a GroupBatch and its items directly (mirrors GroupBatchService.createBatch logic
* but without circular dependency).
*/
private GroupBatch createBatchInline(UUID recipientGroupId, UUID workspaceId,
GroupCreateFromFileRequest request,
List<RecipientGroupMember> members) {
GroupBatch batch = GroupBatch.builder()
.recipientGroupId(recipientGroupId)
.userId(request.getUserId().toString())
.workspaceId(workspaceId)
.description(request.getDescription())
.requestedBy(request.getRequestedBy())
.currency(request.getCurrency())
.status(GroupBatchStatus.CREATED)
.transactionTemplate(Utils.toJson(request.getTransactionTemplate()))
.build();
GroupBatch savedBatch = groupBatchRepository.save(batch);
int itemCount = 0;
for (RecipientGroupMember member : members) {
Recipient recipient = recipientRepository.findById(member.getRecipientUid())
.orElseThrow(() -> new ApiException("Recipient not found: " + member.getRecipientUid()));
GroupBatchItem item = GroupBatchItem.builder()
.groupBatchId(savedBatch.getId())
.recipientId(recipient.getId())
.recipientName(recipient.getName())
.recipientPhone(recipient.getPhoneNumber())
.recipientAccount(recipient.getAccount())
.status(GroupBatchItemStatus.NEW)
.confirmStatus(GroupBatchItemConfirmStatus.PENDING)
.integrationStatus(GroupBatchItemIntegrationStatus.PENDING)
.build();
itemCount++;
groupBatchItemRepository.save(item);
}
savedBatch.setCount(itemCount);
savedBatch.setValue(BigDecimal.ZERO);
savedBatch.setSuccessfulCount(0);
savedBatch.setFailedCount(0);
return groupBatchRepository.save(savedBatch);
}
/**
* Update batch items with CSV data directly (mirrors GroupBatchService.updateBatchItems logic
* but without circular dependency).
*/
private List<GroupCreateFromFileResult.RowError> updateBatchItemsInline(UUID batchId, List<GroupBatchItemUpdate> updates) {
List<GroupBatchItem> existingItems = groupBatchItemRepository.findAllByGroupBatchId(batchId);
java.util.Map<UUID, GroupBatchItem> itemMap = new java.util.HashMap<>();
for (GroupBatchItem item : existingItems) {
itemMap.put(item.getId(), item);
}
List<GroupBatchItem> updatedItems = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> providerErrors = new ArrayList<>();
for (GroupBatchItemUpdate update : updates) {
GroupBatchItem item = itemMap.get(update.batchItemId);
if (item == null) {
continue;
}
try {
item.setRecipientName(update.row.name);
item.setRecipientPhone(update.row.phone);
item.setRecipientAccount(update.row.account);
item.setBillName(update.provider.getName());
item.setBillProductName(update.row.billProductName);
item.setProviderLabel(update.row.billLabel);
item.setProviderImage(update.provider.getImage());
item.setBillClientId(update.provider.getClientId());
if(update.row.billProductName.isBlank() && update.row.amount.compareTo(BigDecimal.ZERO) != 0) {
item.setAmount(update.row.amount);
} else if (!update.row.billProductName.isBlank()) {
ProductDto productDto = providerService.getProviderProduct(
update.provider.getId(), update.row.billProductName);
if( productDto == null) {
throw new ApiException("Product not found for provider: " + update.provider.getName() +
", product name: " + update.row.billProductName);
}
item.setAmount(productDto.getDefaultAmount());
}
updatedItems.add(groupBatchItemRepository.save(item));
// Recalculate batch totals
BigDecimal newTotalValue = updatedItems.stream()
.map(GroupBatchItem::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
GroupBatch batch = groupBatchRepository.findById(batchId)
.orElseThrow(() -> new ApiException("Batch not found"));
batch.setValue(newTotalValue);
batch.setCount(updatedItems.size());
groupBatchRepository.save(batch);
} catch (Exception e) {
e.printStackTrace();
providerErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(update.row.lineNumber)
.account(update.row.account)
.name(update.row.name)
.message("Failed to update batch item: " + e.getMessage())
.build());
}
}
return providerErrors;
}
private record GroupBatchItemUpdate(UUID batchItemId, CsvRow row, Provider provider) {}
/**
* Parse CSV rows, collecting validation errors per row rather than failing fast.
*/
private ParseResult parseCsv(MultipartFile file) {
List<CsvRow> rows = new ArrayList<>();
List<GroupCreateFromFileResult.RowError> parseErrors = new ArrayList<>();
try (Reader reader = new InputStreamReader(file.getInputStream());
BufferedReader br = new BufferedReader(reader)) {
String headerLine = br.readLine(); // skip header
if (headerLine == null) {
throw new ApiException("CSV file is empty");
}
String line;
int lineNum = 1;
while ((line = br.readLine()) != null) {
lineNum++;
if (line.isBlank()) {
continue;
}
String[] parts = parseCsvLine(line);
if (parts.length < 5) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.message("Invalid CSV format: expected at least 5 columns (name, account, phone, billName, billProductName) but got " + parts.length)
.build());
continue;
}
String name = parts[0].trim();
String account = parts[1].trim();
String phone = parts[2].trim();
String billLabel = parts[3].trim();
String billProductName = parts[4].trim();
String amount = parts[5].trim();
if (name.isEmpty() || account.isEmpty()) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.account(account)
.name(name)
.message("name and account are required")
.build());
continue;
}
if(amount.isEmpty() && billProductName.isEmpty()) {
parseErrors.add(GroupCreateFromFileResult.RowError.builder()
.lineNumber(lineNum)
.account(account)
.name(name)
.message("amount or billProductName is required")
.build());
continue;
}
CsvRow row = new CsvRow();
row.lineNumber = lineNum;
row.name = name;
row.account = account;
row.phone = phone;
row.billLabel = billLabel;
row.billProductName = billProductName;
row.amount = !amount.isEmpty() ? new BigDecimal(amount) : BigDecimal.ZERO;
rows.add(row);
}
if (rows.isEmpty() && parseErrors.isEmpty()) {
throw new ApiException("CSV file has no data rows");
}
} catch (IOException e) {
throw new ApiException("Failed to read CSV file: " + e.getMessage());
}
return new ParseResult(rows, parseErrors);
}
/**
* Parse a CSV line respecting simple double-quoted fields.
*/
private String[] parseCsvLine(String line) {
List<String> fields = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '"') {
inQuotes = !inQuotes;
} else if (c == ',' && !inQuotes) {
fields.add(current.toString());
current = new StringBuilder();
} else {
current.append(c);
}
}
fields.add(current.toString());
return fields.toArray(new String[0]);
}
private static class CsvRow {
int lineNumber;
String name;
String account;
String phone;
String billLabel;
String billProductName;
BigDecimal amount;
}
private record ParseResult(List<CsvRow> rows, List<GroupCreateFromFileResult.RowError> parseErrors) {}
@Transactional @Transactional
public RecipientGroup create(RecipientGroup group, List<Recipient> recipients) { public RecipientGroup create(RecipientGroup group, List<Recipient> recipients) {
RecipientGroup savedGroup = recipientGroupRepository.save(group); RecipientGroup savedGroup = recipientGroupRepository.save(group);
if (recipients != null && !recipients.isEmpty()) { if (recipients != null && !recipients.isEmpty()) {
addMembers(savedGroup.getId(), savedGroup.getUserId(), recipients); addMembers(savedGroup.getId(), savedGroup.getWorkspaceId(), recipients);
} }
return savedGroup; return savedGroup;
} }
@Transactional @Transactional
public List<RecipientGroupMember> addMembers(UUID groupId, String userId, List<Recipient> recipients) { public List<RecipientGroupMember> addMembers(UUID groupId, UUID workspaceId, List<Recipient> recipients) {
get(groupId, userId); get(groupId, workspaceId);
List<RecipientGroupMember> added = new ArrayList<>(); List<RecipientGroupMember> added = new ArrayList<>();
for (Recipient incomingRecipient : recipients) { for (Recipient incomingRecipient : recipients) {
Recipient recipient = resolveOrCreateRecipient(incomingRecipient, userId); Recipient recipient = resolveOrCreateRecipient(incomingRecipient, workspaceId);
if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipient.getId())) {
continue;
}
RecipientGroupMember member = RecipientGroupMember.builder() RecipientGroupMember member = RecipientGroupMember.builder()
.recipientGroupId(groupId) .recipientGroupId(groupId)
.recipientUid(recipient.getId()) .recipientUid(recipient.getId())
.recipient(recipient) .recipient(recipient)
.account(recipient.getAccount()) .account(recipient.getAccount())
.userId(userId) .workspaceId(workspaceId)
.userId(incomingRecipient.getUserId())
.build(); .build();
added.add(recipientGroupMemberRepository.save(member)); added.add(recipientGroupMemberRepository.save(member));
} }
@@ -63,25 +415,25 @@ public class RecipientGroupService {
return recipientGroupRepository.findAll(spec, pageable); return recipientGroupRepository.findAll(spec, pageable);
} }
public List<RecipientGroup> list(String userId) { public List<RecipientGroup> list(UUID workspaceId) {
return recipientGroupRepository.findAllByUserId(userId); return recipientGroupRepository.findAllByWorkspaceId(workspaceId);
} }
public RecipientGroup get(UUID groupId, String userId) { public RecipientGroup get(UUID groupId, UUID workspaceId) {
return recipientGroupRepository.findByIdAndUserId(groupId, userId) return recipientGroupRepository.findByIdAndWorkspaceId(groupId, workspaceId)
.orElseThrow(() -> new ApiException("Recipient group not found")); .orElseThrow(() -> new ApiException("Recipient group not found"));
} }
public RecipientGroup update(UUID groupId, String userId, String name, String description) { public RecipientGroup update(UUID groupId, UUID workspaceId, String name, String description) {
RecipientGroup group = get(groupId, userId); RecipientGroup group = get(groupId, workspaceId);
group.setName(name); group.setName(name);
group.setDescription(description); group.setDescription(description);
return recipientGroupRepository.save(group); return recipientGroupRepository.save(group);
} }
@Transactional @Transactional
public void delete(UUID groupId, String userId) { public void delete(UUID groupId, UUID workspaceId) {
RecipientGroup group = get(groupId, userId); RecipientGroup group = get(groupId, workspaceId);
group.setDeleted(true); group.setDeleted(true);
recipientGroupRepository.save(group); recipientGroupRepository.save(group);
@@ -91,14 +443,14 @@ public class RecipientGroupService {
} }
@Transactional @Transactional
public List<RecipientGroupMember> addMembersByIds(UUID groupId, String userId, List<UUID> recipientIds) { public List<RecipientGroupMember> addMembersByIds(UUID groupId, UUID workspaceId, List<UUID> recipientIds) {
get(groupId, userId); get(groupId, workspaceId);
List<RecipientGroupMember> added = new ArrayList<>(); List<RecipientGroupMember> added = new ArrayList<>();
for (UUID recipientId : recipientIds) { for (UUID recipientId : recipientIds) {
Recipient recipient = recipientRepository.findById(recipientId) Recipient recipient = recipientRepository.findById(recipientId)
.orElseThrow(() -> new ApiException("Recipient not found: " + recipientId)); .orElseThrow(() -> new ApiException("Recipient not found: " + recipientId));
if (!userId.equals(recipient.getUserId())) { if (!workspaceId.equals(recipient.getWorkspaceId())) {
throw new ApiException("Recipient does not belong to user"); throw new ApiException("Recipient does not belong to workspace");
} }
if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipientId)) { if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipientId)) {
continue; continue;
@@ -108,35 +460,38 @@ public class RecipientGroupService {
.recipientUid(recipientId) .recipientUid(recipientId)
.recipient(recipient) .recipient(recipient)
.account(recipient.getAccount()) .account(recipient.getAccount())
.userId(userId) .workspaceId(workspaceId)
.build(); .build();
added.add(recipientGroupMemberRepository.save(member)); added.add(recipientGroupMemberRepository.save(member));
} }
return added; return added;
} }
private Recipient resolveOrCreateRecipient(Recipient incomingRecipient, String fallbackUserId) { private Recipient resolveOrCreateRecipient(Recipient incomingRecipient, UUID fallbackWorkspaceId) {
if (incomingRecipient == null || incomingRecipient.getAccount() == null || incomingRecipient.getAccount().isBlank()) { if (incomingRecipient == null || incomingRecipient.getAccount() == null || incomingRecipient.getAccount().isBlank()) {
throw new ApiException("Recipient account is required"); throw new ApiException("Recipient account is required");
} }
String recipientUserId = incomingRecipient.getUserId() == null || incomingRecipient.getUserId().isBlank() UUID recipientWorkspaceId = incomingRecipient.getWorkspaceId() == null
? fallbackUserId ? fallbackWorkspaceId
: incomingRecipient.getUserId(); : incomingRecipient.getWorkspaceId();
List<Recipient> existing = recipientRepository.findByAccountAndUserId(incomingRecipient.getAccount(), recipientUserId); List<Recipient> existing = recipientRepository.findByAccountAndWorkspaceId(incomingRecipient.getAccount(), recipientWorkspaceId);
if (!existing.isEmpty()) { if (!existing.isEmpty()) {
return existing.get(0); Recipient recipient = existing.get(0);
recipient.setName(incomingRecipient.getName());
recipient.setPhoneNumber(incomingRecipient.getPhoneNumber());
return recipientRepository.save(recipient);
} }
incomingRecipient.setId(null); incomingRecipient.setId(null);
incomingRecipient.setUserId(recipientUserId); incomingRecipient.setWorkspaceId(recipientWorkspaceId);
return recipientRepository.save(incomingRecipient); return recipientRepository.save(incomingRecipient);
} }
@Transactional @Transactional
public void removeMember(UUID groupId, UUID recipientId, String userId) { public void removeMember(UUID groupId, UUID recipientId, UUID workspaceId) {
get(groupId, userId); get(groupId, workspaceId);
List<RecipientGroupMember> members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId); List<RecipientGroupMember> members = recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
RecipientGroupMember target = members.stream() RecipientGroupMember target = members.stream()
.filter(item -> recipientId.equals(item.getRecipientUid())) .filter(item -> recipientId.equals(item.getRecipientUid()))
@@ -146,13 +501,12 @@ public class RecipientGroupService {
recipientGroupMemberRepository.save(target); recipientGroupMemberRepository.save(target);
} }
public List<RecipientGroupMember> members(UUID groupId, String userId) { public List<RecipientGroupMember> members(UUID groupId, UUID workspaceId) {
get(groupId, userId); get(groupId, workspaceId);
return recipientGroupMemberRepository.findAllByRecipientGroupId(groupId); return recipientGroupMemberRepository.findAllByRecipientGroupId(groupId);
} }
public Page<RecipientGroupMember> findAllMembers(Specification<RecipientGroupMember> spec, Pageable pageable) { public Page<RecipientGroupMember> findAllMembers(Specification<RecipientGroupMember> spec, Pageable pageable) {
return recipientGroupMemberRepository.findAll(spec, pageable); return recipientGroupMemberRepository.findAll(spec, pageable);
} }
} }

View File

@@ -2,13 +2,14 @@ package zw.qantra.tm.domain.services;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; 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.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.Recipient; import zw.qantra.tm.domain.models.Recipient;
import zw.qantra.tm.domain.repositories.RecipientRepository; import zw.qantra.tm.domain.repositories.RecipientRepository;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Service @Service
@@ -21,8 +22,8 @@ public class RecipientService {
return recipientRepository.findAll(); return recipientRepository.findAll();
} }
public List<Recipient> getRecipient(String account, String userId) { public List<Recipient> getRecipient(String account, UUID workspaceId) {
return recipientRepository.findByAccountAndUserId(account, userId); return recipientRepository.findByAccountAndWorkspaceId(account, workspaceId);
} }
public Recipient save(Recipient recipient) { public Recipient save(Recipient recipient) {
@@ -45,7 +46,7 @@ public class RecipientService {
return false; return false;
} }
public List<Recipient> searchRecipients(Specification<Recipient> specification) { public Page<Recipient> findAllRecipients(Specification<Recipient> specification, Pageable pageable) {
return recipientRepository.findAll(specification); return recipientRepository.findAll(specification, pageable);
} }
} }

View File

@@ -0,0 +1,347 @@
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.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.PartyType;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.repositories.TransactionRepository;
import jakarta.persistence.criteria.Predicate;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TransactionReportService {
private static final String REPORT_FILENAME_PREFIX = "transaction-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;
private final TransactionRepository transactionRepository;
/**
* Generates a transaction report filtered by date range and party type.
* The default filter restricts results to transactions where partyType is
* either INDIVIDUAL or GROUP (excluding BATCH).
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @return list of transactions matching the criteria
*/
public List<Transaction> generateReport(LocalDateTime startDate, LocalDateTime endDate) {
return generateReport(startDate, endDate, null);
}
/**
* Generates a transaction report filtered by date range, party type, and an
* optional workspace ID.
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @param workspaceId optional workspace ID to further filter transactions
* @return list of transactions matching the criteria
*/
public List<Transaction> generateReport(LocalDateTime startDate, LocalDateTime endDate,
String workspaceId) {
Specification<Transaction> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// Default filter: partyType must be INDIVIDUAL or GROUP
predicates.add(root.get("partyType").in(PartyType.INDIVIDUAL, PartyType.GROUP));
// Date range filter on createdAt
if (startDate != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
}
if (endDate != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
}
// Optional workspace filter
if (workspaceId != null && !workspaceId.isEmpty()) {
predicates.add(cb.equal(root.get("workspaceId").as(String.class), workspaceId));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
return transactionRepository.findAll(spec);
}
/**
* Generates a transaction report and returns a summary with counts and totals.
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @return a report summary containing transactions and aggregated data
*/
public TransactionReportSummary generateReportSummary(LocalDateTime startDate, LocalDateTime endDate) {
return generateReportSummary(startDate, endDate, null);
}
/**
* Generates a transaction report summary with an optional workspace filter.
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @param workspaceId optional workspace ID to further filter transactions
* @return a report summary containing transactions and aggregated data
*/
public TransactionReportSummary generateReportSummary(LocalDateTime startDate, LocalDateTime endDate,
String workspaceId) {
List<Transaction> transactions = generateReport(startDate, endDate, workspaceId);
int totalCount = transactions.size();
int individualCount = 0;
int groupCount = 0;
for (Transaction txn : transactions) {
if (txn.getPartyType() == PartyType.INDIVIDUAL) {
individualCount++;
} else if (txn.getPartyType() == PartyType.GROUP) {
groupCount++;
}
}
return new TransactionReportSummary(
transactions,
totalCount,
individualCount,
groupCount,
startDate,
endDate
);
}
/**
* Generates an Excel workbook from a transaction report summary and returns the bytes.
*
* @param summary the report summary with transaction data
* @return byte array of the Excel file
*/
public byte[] generateExcelBytes(TransactionReportSummary summary) {
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
try (Workbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("Transaction Report");
// Styles
CellStyle titleStyle = workbook.createCellStyle();
Font titleFont = workbook.createFont();
titleFont.setBold(true);
titleFont.setFontHeightInPoints(TITLE_FONT_SIZE);
titleStyle.setFont(titleFont);
titleStyle.setAlignment(HorizontalAlignment.CENTER);
titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle headerStyle = workbook.createCellStyle();
headerStyle.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
Font headerFont = workbook.createFont();
headerFont.setBold(true);
headerFont.setFontHeightInPoints(HEADER_FONT_SIZE);
headerFont.setColor(IndexedColors.WHITE.getIndex());
headerStyle.setFont(headerFont);
headerStyle.setAlignment(HorizontalAlignment.CENTER);
headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle dataStyle = workbook.createCellStyle();
Font dataFont = workbook.createFont();
dataFont.setFontHeightInPoints(DATA_FONT_SIZE);
dataStyle.setFont(dataFont);
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 summaryLabelStyle = workbook.createCellStyle();
summaryLabelStyle.setFont(dataFont);
summaryLabelStyle.setAlignment(HorizontalAlignment.RIGHT);
summaryLabelStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle summaryValueStyle = workbook.createCellStyle();
Font summaryValueFont = workbook.createFont();
summaryValueFont.setBold(true);
summaryValueFont.setFontHeightInPoints(DATA_FONT_SIZE);
summaryValueStyle.setFont(summaryValueFont);
summaryValueStyle.setAlignment(HorizontalAlignment.LEFT);
summaryValueStyle.setVerticalAlignment(VerticalAlignment.CENTER);
int rowNum = 0;
// Title row
Row titleRow = sheet.createRow(rowNum++);
Cell titleCell = titleRow.createCell(0);
String title = String.format("Transaction Report (%s - %s)",
summary.startDate().format(dateFormatter),
summary.endDate().format(dateFormatter));
titleCell.setCellValue(title);
titleCell.setCellStyle(titleStyle);
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 11));
titleRow.setHeightInPoints(30);
rowNum++;
// Summary rows
writeSummaryRow(sheet, rowNum++, "Total Transactions:", String.valueOf(summary.totalCount()), summaryLabelStyle, summaryValueStyle);
writeSummaryRow(sheet, rowNum++, "INDIVIDUAL:", String.valueOf(summary.individualCount()), summaryLabelStyle, summaryValueStyle);
writeSummaryRow(sheet, rowNum++, "GROUP:", String.valueOf(summary.groupCount()), summaryLabelStyle, summaryValueStyle);
rowNum++;
// Header row
String[] headers = {
"#", "Reference", "Party Type", "Amount", "Charge",
"Total Amount", "Currency", "Payment Status", "Integration Status", "Payment Processor Ref",
"Username", "Credit Phone",
"Velocity Ref", "Credit Name", "Provider", "Error Message", "Date", "Batch Name"
};
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 (Transaction txn : summary.transactions()) {
Row row = sheet.createRow(rowNum++);
writeCell(row, 0, index++, dataStyle);
writeCell(row, 1, txn.getReference() != null ? txn.getReference() : "", dataStyle);
writeCell(row, 2, txn.getPartyType() != null ? txn.getPartyType().name() : "", dataStyle);
writeCellAmount(row, 3, txn.getAmount(), currencyStyle);
writeCellAmount(row, 4, txn.getGatewayCharge(), currencyStyle);
writeCellAmount(row, 5, txn.getTotalAmount(), currencyStyle);
writeCell(row, 6, txn.getDebitCurrency().name(), dataStyle);
writeCell(row, 7, txn.getPollingStatus() != null ? txn.getPollingStatus().name() : "", dataStyle);
writeCell(row, 8, txn.getIntegrationStatus() != null ? txn.getIntegrationStatus().name() : "", dataStyle);
writeCell(row, 9, txn.getPaymentProcessorRef() != null ? txn.getPaymentProcessorRef() : "", dataStyle);
writeCell(row, 10, txn.getUsername() != null ? txn.getUsername() : txn.getDebitPhone(), dataStyle);
writeCell(row, 11, txn.getCreditPhone() != null ? txn.getCreditPhone() : "", dataStyle);
writeCell(row, 12, txn.getErpSalesPaymentRef() != null ? txn.getErpSalesPaymentRef() : "", dataStyle);
writeCell(row, 13, txn.getCreditName() != null ? txn.getCreditName() : "", dataStyle);
writeCell(row, 14, txn.getProviderLabel() != null ? txn.getProviderLabel() : "", dataStyle);
writeCell(row, 15, txn.getErrorMessage() != null ? txn.getErrorMessage() : "", dataStyle);
writeCell(row, 16, txn.getCreatedAt() != null ? txn.getCreatedAt().format(dateFormatter) : "", dataStyle);
writeCell(row, 17, txn.getBatchDescription() != null ? txn.getBatchDescription() : "", dataStyle);
}
// Auto-size columns
for (int i = 0; i < headers.length; i++) {
sheet.autoSizeColumn(i);
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException e) {
throw new RuntimeException("Failed to generate transaction report Excel file", e);
}
}
/**
* Generates a transaction report Excel file, saves it to disk, and returns the file URL.
*
* @param startDate the start of the date range (inclusive)
* @param endDate the end of the date range (inclusive)
* @param workspaceId optional workspace ID filter
* @return result containing the file URL and byte contents
*/
public TransactionReportFileResult generateReportFile(LocalDateTime startDate, LocalDateTime endDate,
String workspaceId) {
TransactionReportSummary summary = generateReportSummary(startDate, endDate, workspaceId);
byte[] fileBytes = generateExcelBytes(summary);
String datePart = startDate.format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
String filename = REPORT_FILENAME_PREFIX + datePart + REPORT_FILENAME_SUFFIX;
java.nio.file.Path reportsDir = java.nio.file.Path.of(reportsStoragePath).toAbsolutePath();
java.nio.file.Path filePath = reportsDir.resolve(filename);
try {
java.nio.file.Files.createDirectories(reportsDir);
try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(filePath)) {
os.write(fileBytes);
}
} catch (IOException e) {
throw new RuntimeException("Failed to persist transaction report file", e);
}
return new TransactionReportFileResult(filename, fileBytes);
}
private void writeSummaryRow(Sheet sheet, int rowNum, String label, String value,
CellStyle labelStyle, CellStyle valueStyle) {
Row row = sheet.createRow(rowNum);
Cell labelCell = row.createCell(0);
labelCell.setCellValue(label);
labelCell.setCellStyle(labelStyle);
Cell valueCell = row.createCell(1);
valueCell.setCellValue(value);
valueCell.setCellStyle(valueStyle);
}
private void writeCell(Row row, int col, String value, CellStyle style) {
Cell cell = row.createCell(col);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private void writeCell(Row row, int col, int value, CellStyle style) {
Cell cell = row.createCell(col);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private void writeCellAmount(Row row, int col, java.math.BigDecimal value, CellStyle style) {
Cell cell = row.createCell(col);
cell.setCellValue(value != null ? value.doubleValue() : 0.0);
cell.setCellStyle(style);
}
/**
* Result of a transaction report file generation request.
*/
public record TransactionReportFileResult(
String filename,
byte[] fileBytes
) {}
/**
* Summary of a transaction report.
*/
public record TransactionReportSummary(
List<Transaction> transactions,
int totalCount,
int individualCount,
int groupCount,
LocalDateTime startDate,
LocalDateTime endDate
) {}
}

View File

@@ -38,25 +38,44 @@ public class TransactionService {
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
Currency currency = currencyService.findCurrencyByIsoCode(transaction.getCreditCurrency().name()); Currency debitCurrency = currencyService.findCurrencyByIsoCode(transaction.getDebitCurrency().name());
Currency creditCurrency = currencyService.findCurrencyByIsoCode(transaction.getCreditCurrency().name());
BigDecimal bankRate = new BigDecimal(currency.getBankRate()); BigDecimal debitBankRate = new BigDecimal(debitCurrency.getBankRate());
BigDecimal creditAmount = transaction.getAmount().multiply(bankRate).setScale(4, RoundingMode.HALF_EVEN); BigDecimal creditBankRate = new BigDecimal(creditCurrency.getBankRate());
BigDecimal bankRate = creditBankRate.divide(debitBankRate, 4, RoundingMode.HALF_UP);
BigDecimal creditAmount = transaction.getAmount().multiply(bankRate).setScale(4, RoundingMode.HALF_UP);
transaction.setCreditAmount(creditAmount); transaction.setCreditAmount(creditAmount);
return creditAmount; return creditAmount;
} }
public void reassignTransactions(String oldUid, String newUid){ public void reassignTransactions(UUID oldWorkspaceId, UUID newWorkspaceId){
List<Transaction> transactions = transactionRepository.findAllByUserId(oldUid); List<Transaction> transactions = transactionRepository.findAllByWorkspaceId(oldWorkspaceId);
transactions.forEach(transaction -> { transactions.forEach(transaction -> {
transaction.setUserId(newUid); transaction.setWorkspaceId(newWorkspaceId);
transactionRepository.save(transaction); transactionRepository.save(transaction);
}); });
} }
public void reassignTransactions(String oldUserId, String newUserId){
List<Transaction> transactions = transactionRepository.findAllByUserId(oldUserId);
transactions.forEach(transaction -> {
transaction.setUserId(newUserId);
transactionRepository.save(transaction);
});
}
public Transaction getTransaction(UUID id, UUID workspaceId) {
return transactionRepository.findByIdAndWorkspaceId(id, workspaceId)
.orElseThrow(() -> new ApiException("Transaction not found"));
}
public Transaction findById(UUID id) { public Transaction findById(UUID id) {
Transaction transaction = transactionRepository.findById(id).orElseThrow(() -> new ApiException("Transaction not found for ID: " + id)); Transaction transaction = transactionRepository.findById(id).orElseThrow(() ->
new ApiException("Transaction not found for ID: " + id));
try { try {
AdditionalData additionalData = additionalDataService.getAdditionalDataRepository() AdditionalData additionalData = additionalDataService.getAdditionalDataRepository()

View File

@@ -0,0 +1,48 @@
package zw.qantra.tm.domain.services;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.Uptime;
import zw.qantra.tm.domain.repositories.UptimeRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Getter
@Service
@RequiredArgsConstructor
public class UptimeService {
private final UptimeRepository uptimeRepository;
public Page<Uptime> getAllUptimes(Pageable pageable) {
return uptimeRepository.findAll(pageable);
}
public Optional<Uptime> getUptime(UUID id) {
return uptimeRepository.findById(id);
}
public Uptime createUptime(Uptime uptime) {
return uptimeRepository.save(uptime);
}
public Uptime updateUptime(UUID id, Uptime uptime) {
if (uptimeRepository.existsById(id)) {
uptime.setId(id);
return uptimeRepository.save(uptime);
}
return null;
}
public boolean deleteUptime(UUID id) {
if (uptimeRepository.existsById(id)) {
uptimeRepository.deleteById(id);
return true;
}
return false;
}
}

View File

@@ -2,18 +2,27 @@ package zw.qantra.tm.domain.services;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; 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.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.dtos.AuthResponse; import zw.qantra.tm.domain.dtos.AuthResponse;
import zw.qantra.tm.domain.dtos.LoginRequest; import zw.qantra.tm.domain.dtos.LoginRequest;
import zw.qantra.tm.domain.dtos.RegisterRequest; import zw.qantra.tm.domain.dtos.RegisterRequest;
import zw.qantra.tm.domain.models.User; import zw.qantra.tm.domain.models.User;
import zw.qantra.tm.domain.models.Workspace;
import zw.qantra.tm.domain.repositories.UserRepository; import zw.qantra.tm.domain.repositories.UserRepository;
import zw.qantra.tm.utils.JwtUtils; import zw.qantra.tm.utils.JwtUtils;
import java.util.Optional;
import java.util.UUID;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserService implements UserDetailsService { public class UserService implements UserDetailsService {
@@ -23,6 +32,7 @@ public class UserService implements UserDetailsService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils; private final JwtUtils jwtUtils;
private final TransactionService transactionService; private final TransactionService transactionService;
private final WorkspaceService workspaceService;
public User fetchUserByUsername(String username) { public User fetchUserByUsername(String username) {
return userRepository.findByUsername(username) return userRepository.findByUsername(username)
@@ -83,4 +93,52 @@ public class UserService implements UserDetailsService {
return new AuthResponse(token, "Bearer", user.getUsername(), return new AuthResponse(token, "Bearer", user.getUsername(),
user.getEmail(), user.getPhone(), user.getFirstName(), user.getLastName(), user.getId()); user.getEmail(), user.getPhone(), user.getFirstName(), user.getLastName(), user.getId());
} }
public Page<User> findAllUsers(Specification<User> spec, Pageable pageable) {
return userRepository.findAll(spec, pageable);
}
public Optional<User> getUser(UUID id) {
return userRepository.findById(id);
}
@Transactional
public User createUser(User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setDeleted(false);
return userRepository.save(user);
}
@Transactional
public User updateUser(UUID id, User userDetails) {
return userRepository.findById(id)
.map(existingUser -> {
existingUser.setFirstName(userDetails.getFirstName());
existingUser.setLastName(userDetails.getLastName());
existingUser.setEmail(userDetails.getEmail());
existingUser.setPhone(userDetails.getPhone());
existingUser.setPhotoUrl(userDetails.getPhotoUrl());
existingUser.setWorkflowId(userDetails.getWorkflowId());
existingUser.setEnabled(userDetails.isEnabled());
existingUser.setAccountNonExpired(userDetails.isAccountNonExpired());
existingUser.setAccountNonLocked(userDetails.isAccountNonLocked());
existingUser.setCredentialsNonExpired(userDetails.isCredentialsNonExpired());
if (userDetails.getPassword() != null && !userDetails.getPassword().isEmpty()) {
existingUser.setPassword(passwordEncoder.encode(userDetails.getPassword()));
}
return userRepository.save(existingUser);
})
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));
}
@Transactional
public boolean deleteUser(UUID id) {
return userRepository.findById(id)
.map(user -> {
user.setDeleted(true);
userRepository.save(user);
return true;
})
.orElse(false);
}
} }

View File

@@ -16,7 +16,9 @@ import zw.qantra.tm.domain.dtos.RegisterRequest;
import zw.qantra.tm.domain.dtos.erp.*; import zw.qantra.tm.domain.dtos.erp.*;
import zw.qantra.tm.domain.enums.CurrencyType; import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.GroupBatchItem;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.repositories.GroupBatchItemRepository;
import zw.qantra.tm.domain.repositories.TransactionRepository; import zw.qantra.tm.domain.repositories.TransactionRepository;
import zw.qantra.tm.configs.VelocityApiProperties; import zw.qantra.tm.configs.VelocityApiProperties;
import zw.qantra.tm.exceptions.AlreadyExistsException; import zw.qantra.tm.exceptions.AlreadyExistsException;
@@ -27,12 +29,14 @@ import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
@Service @Service
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
public class VelocityPaymentService { public class VelocityPaymentService {
private final TransactionRepository transactionRepository; private final TransactionRepository transactionRepository;
private final GroupBatchItemRepository groupBatchItemRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final RestService restService; private final RestService restService;
private final VelocityApiProperties velocityApiProperties; private final VelocityApiProperties velocityApiProperties;
@@ -101,19 +105,47 @@ public class VelocityPaymentService {
public Transaction createSalesOrder(Transaction transaction) { public Transaction createSalesOrder(Transaction transaction) {
BigDecimal amount = transaction.getTotalAmount(); BigDecimal amount = transaction.getTotalAmount();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
List<VelocitySalesOrderDto.ItemDto> items;
if (transaction.getBatchId() != null) {
// Fetch GroupBatchItems for this batch and summarize by providerLabel
List<GroupBatchItem> batchItems = groupBatchItemRepository.findAllByGroupBatchId(transaction.getBatchId());
Map<String, BigDecimal> totalsByProvider = batchItems.stream()
.filter(item -> item.getProviderLabel() != null && item.getAmount() != null)
.collect(Collectors.groupingBy(
GroupBatchItem::getProviderLabel,
Collectors.mapping(
GroupBatchItem::getAmount,
Collectors.reducing(BigDecimal.ZERO, BigDecimal::add)
)
));
items = totalsByProvider.entrySet().stream()
.map(entry -> VelocitySalesOrderDto.ItemDto.builder()
.name(transaction.getBillName())
.itemCode("VLT-" + entry.getKey())
.qty(BigDecimal.ONE)
.unitPrice(entry.getValue())
.amount(entry.getValue())
.build())
.collect(Collectors.toList());
} else {
items = Collections.singletonList(VelocitySalesOrderDto.ItemDto.builder()
.name(transaction.getBillName())
.itemCode("VLT-" + transaction.getProviderLabel())
.qty(BigDecimal.ONE)
.unitPrice(amount)
.amount(amount)
.build());
}
VelocitySalesOrderDto payload = VelocitySalesOrderDto.builder() VelocitySalesOrderDto payload = VelocitySalesOrderDto.builder()
.currencyCodeString(transaction.getDebitCurrency().name()) .currencyCodeString(transaction.getDebitCurrency().name())
.orderDate(LocalDate.now().format(fmt)) .orderDate(LocalDate.now().format(fmt))
.dueDate(LocalDate.now().plusDays(7).format(fmt)) .dueDate(LocalDate.now().plusDays(7).format(fmt))
.notes(transaction.getReference()) .notes("Transaction trace - " + transaction.getTrace())
.authorized(Boolean.TRUE) .authorized(Boolean.TRUE)
.items(Collections.singletonList(VelocitySalesOrderDto.ItemDto.builder() .items(items)
.name(transaction.getBillName())
.itemCode("VLT-" + transaction.getProviderLabel())
.qty(BigDecimal.ONE)
.unitPrice(amount)
.amount(amount)
.build()))
.charges(Collections.emptyList()) .charges(Collections.emptyList())
.build(); .build();
@@ -149,7 +181,7 @@ public class VelocityPaymentService {
? "VMC" : transaction.getPaymentProcessorLabel(); ? "VMC" : transaction.getPaymentProcessorLabel();
VelocityTransactionDto payload = VelocityTransactionDto.builder() VelocityTransactionDto payload = VelocityTransactionDto.builder()
.salesOrderId(transaction.getOrderId()) .salesOrderId(transaction.getOrderId())
.amount(transaction.getTotalAmount()) .amount(transaction.getCreditAmount())
.debitPhone(transaction.getDebitPhone()) .debitPhone(transaction.getDebitPhone())
.debitRegion("ZW") .debitRegion("ZW")
.type("REQUEST") .type("REQUEST")
@@ -164,7 +196,8 @@ public class VelocityPaymentService {
if ("MPGS".equalsIgnoreCase(transaction.getPaymentProcessorLabel())) { if ("MPGS".equalsIgnoreCase(transaction.getPaymentProcessorLabel())) {
payload.setCancelUrl(velocityApiProperties.getCancelUrl() + "/" + transaction.getOrderId()); payload.setCancelUrl(velocityApiProperties.getCancelUrl() + "/" + transaction.getOrderId());
payload.setSuccessUrl(velocityApiProperties.getSuccessUrl() + "/" + transaction.getOrderId()); // not sending successUrl for now
// payload.setSuccessUrl(velocityApiProperties.getSuccessUrl() + "/" + transaction.getOrderId());
} }
try { try {
@@ -184,6 +217,7 @@ public class VelocityPaymentService {
String redirectUrl = body.containsKey("redirectUrl") ? (String) body.get("redirectUrl") : null; String redirectUrl = body.containsKey("redirectUrl") ? (String) body.get("redirectUrl") : null;
if ("SUCCESS".equalsIgnoreCase(transactionStatus)) { if ("SUCCESS".equalsIgnoreCase(transactionStatus)) {
transaction.setStatus(Status.SUCCESS);
transaction.setErpSalesPaymentRef(transactionName); transaction.setErpSalesPaymentRef(transactionName);
transaction.setOrderTransactionTrace(tranTrace); transaction.setOrderTransactionTrace(tranTrace);
transaction.setOrderTransactionId(tranId); transaction.setOrderTransactionId(tranId);
@@ -225,7 +259,6 @@ public class VelocityPaymentService {
throw new IllegalStateException("Error initiating payment: " + errorMessage); throw new IllegalStateException("Error initiating payment: " + errorMessage);
} }
transaction.setStatus(Status.valueOf((String) body.get("status"))); transaction.setStatus(Status.valueOf((String) body.get("status")));
transaction.setPaymentStatus(Status.valueOf((String) body.get("paymentStatus")));
transaction.setPollingStatus(Status.valueOf((String) body.get("pollStatus"))); transaction.setPollingStatus(Status.valueOf((String) body.get("pollStatus")));
transaction.setDebitRef((String) body.get("debitRef")); transaction.setDebitRef((String) body.get("debitRef"));
transaction.setPaymentProcessorRef((String) body.get("paymentProcessorRef")); transaction.setPaymentProcessorRef((String) body.get("paymentProcessorRef"));
@@ -259,6 +292,11 @@ public class VelocityPaymentService {
@Async @Async
public void updateSalesOrderWorkflow(Transaction transaction) { public void updateSalesOrderWorkflow(Transaction transaction) {
if(transaction.getOrderTrace() == null) {
log.error("Order trace is null for transaction: {}", transaction.getTrace());
return;
}
LinkedHashMap response = makeRequest( LinkedHashMap response = makeRequest(
HttpMethod.PUT, HttpMethod.PUT,
VELOCITY_API_UPDATE_WORKFLOW_PATH.replace("{traceId}", transaction.getOrderTrace()), VELOCITY_API_UPDATE_WORKFLOW_PATH.replace("{traceId}", transaction.getOrderTrace()),

View File

@@ -0,0 +1,203 @@
package zw.qantra.tm.domain.services;
import lombok.Builder;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.models.*;
import zw.qantra.tm.domain.repositories.*;
import java.util.*;
@Service
@RequiredArgsConstructor
public class WorkspaceMigrationService {
private static final Logger log = LoggerFactory.getLogger(WorkspaceMigrationService.class);
private final UserRepository userRepository;
private final WorkspaceRepository workspaceRepository;
private final TransactionRepository transactionRepository;
private final RecipientRepository recipientRepository;
private final RecipientGroupRepository recipientGroupRepository;
private final RecipientGroupMemberRepository recipientGroupMemberRepository;
private final GroupBatchRepository groupBatchRepository;
@Transactional
public MigrationResult migrate() {
Map<String, Integer> recordsUpdated = new LinkedHashMap<>();
// Step 1: Create workspaces for users without one
int workspacesCreated = createWorkspacesForUsers();
// Build a userId → workspaceId lookup map
Map<String, UUID> userToWorkspace = buildUserToWorkspaceMap();
// Step 2: Backfill workspaceId on all 5 entities
recordsUpdated.put("Transaction", backfillTransactions(userToWorkspace));
recordsUpdated.put("Recipient", backfillRecipients(userToWorkspace));
recordsUpdated.put("RecipientGroup", backfillRecipientGroups(userToWorkspace));
recordsUpdated.put("RecipientGroupMember", backfillRecipientGroupMembers(userToWorkspace));
recordsUpdated.put("GroupBatch", backfillGroupBatches(userToWorkspace));
return MigrationResult.builder()
.workspacesCreated(workspacesCreated)
.recordsUpdated(recordsUpdated)
.build();
}
/**
* Step 1: Identify users without any workspace and create a default workspace for each.
* Returns the number of workspaces created.
*/
private int createWorkspacesForUsers() {
List<User> allUsers = userRepository.findAll();
int created = 0;
for (User user : allUsers) {
if (user.getWorkspaces() == null || user.getWorkspaces().isEmpty()) {
Workspace workspace = Workspace.builder()
.name(user.getUsername() + "'s Workspace")
.description("Default workspace for " + user.getUsername())
.users(new HashSet<>())
.build();
workspace.getUsers().add(user);
workspaceRepository.save(workspace);
created++;
log.info("Created workspace '{}' for user '{}'", workspace.getName(), user.getUsername());
}
}
log.info("Workspaces created: {}", created);
return created;
}
/**
* Build a lookup map: userId (String) → workspaceId (UUID).
* Each user is mapped to their first workspace.
*/
private Map<String, UUID> buildUserToWorkspaceMap() {
Map<String, UUID> map = new HashMap<>();
List<User> allUsers = userRepository.findAll();
for (User user : allUsers) {
if (user.getWorkspaces() != null && !user.getWorkspaces().isEmpty()) {
Workspace firstWorkspace = user.getWorkspaces().iterator().next();
map.put(user.getId().toString(), firstWorkspace.getId());
}
}
log.info("Built user→workspace map with {} entries", map.size());
return map;
}
/**
* Backfill workspaceId on Transaction records that have userId but null workspaceId.
*/
private int backfillTransactions(Map<String, UUID> userToWorkspace) {
List<Transaction> records = transactionRepository.findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
int updated = 0;
for (Transaction record : records) {
UUID workspaceId = userToWorkspace.get(record.getUserId());
if (workspaceId != null) {
record.setWorkspaceId(workspaceId);
transactionRepository.save(record);
updated++;
}
}
log.info("Transactions backfilled: {}/{}", updated, records.size());
return updated;
}
/**
* Backfill workspaceId on Recipient records that have userId but null workspaceId.
*/
private int backfillRecipients(Map<String, UUID> userToWorkspace) {
List<Recipient> records = recipientRepository.findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
int updated = 0;
for (Recipient record : records) {
UUID workspaceId = userToWorkspace.get(record.getUserId());
if (workspaceId != null) {
record.setWorkspaceId(workspaceId);
recipientRepository.save(record);
updated++;
}
}
log.info("Recipients backfilled: {}/{}", updated, records.size());
return updated;
}
/**
* Backfill workspaceId on RecipientGroup records that have userId but null workspaceId.
*/
private int backfillRecipientGroups(Map<String, UUID> userToWorkspace) {
List<RecipientGroup> records = recipientGroupRepository.findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
int updated = 0;
for (RecipientGroup record : records) {
UUID workspaceId = userToWorkspace.get(record.getUserId());
if (workspaceId != null) {
record.setWorkspaceId(workspaceId);
recipientGroupRepository.save(record);
updated++;
}
}
log.info("RecipientGroups backfilled: {}/{}", updated, records.size());
return updated;
}
/**
* Backfill workspaceId on RecipientGroupMember records that have userId but null workspaceId.
*/
private int backfillRecipientGroupMembers(Map<String, UUID> userToWorkspace) {
List<RecipientGroupMember> records = recipientGroupMemberRepository.findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
int updated = 0;
for (RecipientGroupMember record : records) {
UUID workspaceId = userToWorkspace.get(record.getUserId());
if (workspaceId != null) {
record.setWorkspaceId(workspaceId);
recipientGroupMemberRepository.save(record);
updated++;
}
}
log.info("RecipientGroupMembers backfilled: {}/{}", updated, records.size());
return updated;
}
/**
* Backfill workspaceId on GroupBatch records that have userId but null workspaceId.
*/
private int backfillGroupBatches(Map<String, UUID> userToWorkspace) {
List<GroupBatch> records = groupBatchRepository.findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
int updated = 0;
for (GroupBatch record : records) {
UUID workspaceId = userToWorkspace.get(record.getUserId());
if (workspaceId != null) {
record.setWorkspaceId(workspaceId);
groupBatchRepository.save(record);
updated++;
}
}
log.info("GroupBatches backfilled: {}/{}", updated, records.size());
return updated;
}
@Data
@Builder
public static class MigrationResult {
private int workspacesCreated;
private Map<String, Integer> recordsUpdated;
}
}

View File

@@ -0,0 +1,106 @@
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.User;
import zw.qantra.tm.domain.models.Workspace;
import zw.qantra.tm.domain.repositories.UserRepository;
import zw.qantra.tm.domain.repositories.WorkspaceRepository;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.SecurityUtils;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class WorkspaceService {
private final WorkspaceRepository workspaceRepository;
private final UserRepository userRepository;
public Page<Workspace> getAllWorkspaces(Specification<Workspace> specification, Pageable pageable) {
return workspaceRepository.findAll(specification, pageable);
}
public Optional<Workspace> getWorkspace(UUID id) {
return workspaceRepository.findById(id);
}
public List<Workspace> searchByName(String name) {
return workspaceRepository.findByNameContainingIgnoreCase(name);
}
public Workspace createWorkspace(Workspace workspace) {
return workspaceRepository.save(workspace);
}
public Workspace updateWorkspace(UUID id, Workspace workspace) {
if (workspaceRepository.existsById(id)) {
workspace.setId(id);
return workspaceRepository.save(workspace);
}
return null;
}
public boolean deleteWorkspace(UUID id) {
if (workspaceRepository.existsById(id)) {
workspaceRepository.deleteById(id);
return true;
}
return false;
}
@Transactional
public Optional<Workspace> associateUser(UUID workspaceId, UUID userId) {
Workspace workspace = workspaceRepository.findById(workspaceId).orElse(null);
User user = userRepository.findById(userId).orElse(null);
if (workspace == null || user == null) {
return Optional.empty();
}
workspace.getUsers().add(user);
workspaceRepository.save(workspace);
return Optional.of(workspace);
}
@Transactional
public Optional<Workspace> disassociateUser(UUID workspaceId, UUID userId) {
// Prevent a user from disassociating themselves from their own workspace
String currentUsername = SecurityUtils.getCurrentUsername();
if (currentUsername != null) {
User currentUser = userRepository.findByUsername(currentUsername).orElse(null);
if (currentUser != null && currentUser.getId().equals(userId)) {
throw new ApiException("You cannot remove yourself from the workspace");
}
}
Workspace workspace = workspaceRepository.findById(workspaceId).orElse(null);
User user = userRepository.findById(userId).orElse(null);
if (workspace == null || user == null) {
return Optional.empty();
}
workspace.getUsers().remove(user);
workspaceRepository.save(workspace);
return Optional.of(workspace);
}
@Transactional(readOnly = true)
public Set<User> getWorkspaceUsers(UUID workspaceId) {
return workspaceRepository.findById(workspaceId)
.map(Workspace::getUsers)
.orElse(Set.of());
}
@Transactional(readOnly = true)
public Set<Workspace> getUserWorkspaces(UUID userId) {
return userRepository.findById(userId)
.map(User::getWorkspaces)
.orElse(Set.of());
}
}

View File

@@ -51,7 +51,7 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter
if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount(), "3")) { if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount(), "3")) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("98"); transaction.setResponseCode("98");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later."); transaction.setErrorMessage("We can't process that transaction right now. Please try again later.");
return transaction; return transaction;
} }
} }

View File

@@ -39,27 +39,14 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl; private String hotApiUrl;
String token;
@Override @Override
public Object process(Transaction transaction) throws Exception { public Object process(Transaction transaction) throws Exception {
logger.info("Processing ZESA confirmation from HotRecharge"); logger.info("Processing ZESA confirmation from HotRecharge");
try { try {
// do this if we not simulating success, if simulating success, we skip balance check and just return success Map<String, Object> customerData = checkCustomer(transaction);
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("02");
return transaction;
}
String token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("91");
transaction.setErrorMessage("Failed to authenticate with HotRecharge");
return transaction;
}
Map<String, Object> customerData = checkCustomer(token, transaction);
if (customerData == null) { if (customerData == null) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("90"); transaction.setResponseCode("90");
@@ -84,15 +71,17 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
transaction.setCreditAmount(transactionService.calculateCreditAmount(transaction)); transaction.setCreditAmount(transactionService.calculateCreditAmount(transaction));
// token should've been updated by checkCustomer
if (!hotRechargeBalanceService.checkBalance(token, transaction.getCreditAmount(), accId)) { if (!hotRechargeBalanceService.checkBalance(token, transaction.getCreditAmount(), accId)) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92"); transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later."); transaction.setErrorMessage("We can't process that transaction right now. Please try again later.");
return transaction; return transaction;
} }
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00"); transaction.setResponseCode("00");
transaction.setCreditPhone(transaction.getDebitPhone());
List<Map<String, String>> additionalDataList = buildAdditionalData(customerData); List<Map<String, String>> additionalDataList = buildAdditionalData(customerData);
transaction.setAdditionalData(additionalDataList); transaction.setAdditionalData(additionalDataList);
@@ -104,6 +93,17 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
.build(); .build();
additionalDataService.getAdditionalDataRepository().save(additionalData); additionalDataService.getAdditionalDataRepository().save(additionalData);
// update customer address in creditAddress field
Map<String, Object> details = (Map<String, Object>) customerData.get("details");
if (details != null) {
for (Map.Entry<String, Object> entry : details.entrySet()) {
if (entry.getKey().equalsIgnoreCase("AccountName")) {
transaction.setCreditAddress(entry.getValue().toString());
break;
}
}
}
} catch (Exception e) { } catch (Exception e) {
logger.error("Error processing ZESA confirmation: " + e.getMessage(), e); logger.error("Error processing ZESA confirmation: " + e.getMessage(), e);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
@@ -122,7 +122,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
Map<String, Object> details = (Map<String, Object>) customerData.get("details"); Map<String, Object> details = (Map<String, Object>) customerData.get("details");
if (details != null) { if (details != null) {
for (Map.Entry<String, Object> entry : details.entrySet()) { for (Map.Entry<String, Object> entry : details.entrySet()) {
// don't show customer ZWG value. It leads to confusion // don't show customer ZWG value. It causes confusion
if(entry.getValue().toString().equals("ZWG")) if(entry.getValue().toString().equals("ZWG"))
continue; continue;
additionalDataList.add(Map.of("name", entry.getKey(), "value", entry.getValue().toString())); additionalDataList.add(Map.of("name", entry.getKey(), "value", entry.getValue().toString()));
@@ -150,7 +150,25 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
return currency == null ? "" : currency.toString().trim(); return currency == null ? "" : currency.toString().trim();
} }
private Map<String, Object> checkCustomer(String token, Transaction transaction) { private Map<String, Object> checkCustomer(Transaction transaction) {
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
logger.info("Simulating HotRecharge success");
return Utils.fromJson("{\n" +
" \"accountNumber\": \"07088597534\",\n" +
" \"details\": {\n" +
" \"AccountName\": \"CHIRINDO LETWINA\\n2144 MARLBOROUGH\",\n" +
" \"Status\": \"Active\",\n" +
" \"Currency\": \"ZWG\"\n" +
" }\n" +
"}", LinkedHashMap.class);
}
token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
throw new ApiException("Failed to authenticate with HotRecharge");
}
try { try {
String hotProductId = providerService.getProviderId(transaction.getBillClientId()); String hotProductId = providerService.getProviderId(transaction.getBillClientId());

View File

@@ -14,6 +14,7 @@ import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.RequestType; import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData; import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.*; import zw.qantra.tm.domain.services.*;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
@@ -54,29 +55,12 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
return transaction; return transaction;
} }
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("02");
transaction.setCreditRef("Simulated Integration Success");
transaction.setErrorMessage(null);
return transaction;
}
try { try {
logger.info("Processing HotRecharge integration"); logger.info("Processing HotRecharge integration");
String token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("91");
transaction.setErrorMessage("Failed to authenticate with HotRecharge");
return transaction;
}
// todo: a balance check might be necessary here. Removing // todo: a balance check might be necessary here. Removing
// for now so not to spam hotrecharge with requests // for now so not to spam hotrecharge with requests
LinkedHashMap<String, Object> rechargeResponse = performRecharge(token, transaction); LinkedHashMap<String, Object> rechargeResponse = performRecharge(transaction);
if (rechargeResponse == null) { if (rechargeResponse == null) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
@@ -104,6 +88,18 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
.jsonString(Utils.toJson(additionalDataList)) .jsonString(Utils.toJson(additionalDataList))
.build(); .build();
additionalDataService.getAdditionalDataRepository().save(additionalData); additionalDataService.getAdditionalDataRepository().save(additionalData);
// if provider is zesa, update customer token in creditVoucher field
String providerId = providerService.getProviderId(transaction.getBillClientId());
if(providerId.equals("41") || providerId.equals("24")) {
for (Map.Entry<String, Object> entry : rechargeData.entrySet()) {
if (entry.getKey().equalsIgnoreCase("Token")) {
transaction.setCreditVoucher(entry.getValue().toString());
break;
}
}
}
} }
} else { } else {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
@@ -124,8 +120,23 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
} }
private LinkedHashMap<String, Object> performRecharge(String token, Transaction transaction) { private LinkedHashMap<String, Object> performRecharge(Transaction transaction) {
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
try {
Thread.sleep(3);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
logger.info("Simulating HotRecharge success");
return getZesaPayload();
}
try { try {
String token = hotRechargeTokenService.getToken();
if (token == null || token.isEmpty()) {
throw new Exception("Failed to authenticate with HotRecharge");
}
String rechargeUrl = hotApiUrl + "/products/recharge"; String rechargeUrl = hotApiUrl + "/products/recharge";
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token); headers.setBearerAuth(token);
@@ -137,30 +148,26 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
request.put("ProductId", providerId); request.put("ProductId", providerId);
request.put("Target", transaction.getCreditAccount()); request.put("Target", transaction.getCreditAccount());
request.put("Amount", transaction.getCreditAmount()); request.put("Amount", transaction.getCreditAmount());
request.put("CustomerSMS", "%COMPANYNAME% topped up your account with $%AMOUNT%.");
// 41 = zesa
if(providerId.equals("41")) {
request.put("CustomerSMS", "A ZETDC token was purchased for " +
"%ACOUNTNAME% (%METERNUMBER%) that resulted in %KWH% units.");
}
else {
request.put("CustomerSMS", "%COMPANYNAME% topped up your account with $%AMOUNT%.");
}
// do this for zesa only // do this for zesa only
if(providerId.equals("41")) { if(providerId.equals("41") || providerId.equals("24")) {
// update product to zwg product id // override sms for zesa
request.put("CustomerSMS", "A ZETDC token was purchased for " +
"%ACCOUNTNAME% (%METERNUMBER%) that resulted in %KWH% units.");
// if necessary, update product to zwg product id
if(transaction.getCreditCurrency().equals(CurrencyType.ZWG)) { if(transaction.getCreditCurrency().equals(CurrencyType.ZWG)) {
String zwgProviderId = providerService.getProviderId(ZESA_ZWG_CLIENT); String zwgProviderId = providerService.getProviderId(ZESA_ZWG_CLIENT);
request.put("ProductId", zwgProviderId); request.put("ProductId", zwgProviderId);
} }
List<Map<String, String>> rechargeOptions = new ArrayList<>(); List<Map<String, String>> rechargeOptions = new ArrayList<>();
if (transaction.getDebitPhone() != null) { if (transaction.getCreditPhone() != null) {
Map<String, String> option = new LinkedHashMap<>(); Map<String, String> option = new LinkedHashMap<>();
option.put("Name", "NotifyNumber"); option.put("Name", "NotifyNumber");
option.put("ParameterType", "String"); option.put("ParameterType", "String");
option.put("Value", transaction.getDebitPhone()); option.put("Value", transaction.getCreditPhone());
rechargeOptions.add(option); rechargeOptions.add(option);
} }
if (!rechargeOptions.isEmpty()) { if (!rechargeOptions.isEmpty()) {
@@ -168,15 +175,19 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
} }
} }
// do this for Econet bundles // do this for providers with bundles
if(providerId.equals("111")) { Provider provider = providerService.getProviderRepository()
String productCode = providerService.getExternalId(providerId, transaction.getProductUid()); .findByClientId(transaction.getBillClientId());
if(provider.getRequiresProducts().equals(true)) {
String productCode = providerService.getExternalId(
provider.getId().toString(), transaction.getBillProductName());
List<Map<String, String>> rechargeOptions = new ArrayList<>(); List<Map<String, String>> rechargeOptions = new ArrayList<>();
Map<String, String> option = new LinkedHashMap<>(); Map<String, String> option = new LinkedHashMap<>();
option.put("Name", "ProductCode"); option.put("Name", "ProductCode");
option.put("ParameterType", "String"); option.put("ParameterType", "String");
option.put("Value", productCode); option.put("Value", productCode);
rechargeOptions.add(option); rechargeOptions.add(option);
request.put("RechargeOptions", rechargeOptions);
} }
logger.info("Sending recharge request: {}", request); logger.info("Sending recharge request: {}", request);
@@ -229,6 +240,32 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
return additionalDataList; return additionalDataList;
} }
private LinkedHashMap<String, Object> getZesaPayload() {
return Utils.fromJson("{\n" +
" \"successful\": true,\n" +
" \"rechargeId\": 1.9080854E8,\n" +
" \"amount\": 265.0,\n" +
" \"discount\": 1.2,\n" +
" \"balance\": {\n" +
" \"accountTypeId\": 2.0,\n" +
" \"name\": \"Utility ZWG\",\n" +
" \"balance\": 3306.32\n" +
" },\n" +
" \"message\": \"Transaction processed successfully\",\n" +
" \"rechargeData\": {\n" +
" \"Network\": \"ZESA\",\n" +
" \"Target\": \"07088597534\",\n" +
" \"Cost\": \"261.8200\",\n" +
" \"Token\": \"6616 0517 2063 6675 8254\",\n" +
" \"Units\": \"40.92\",\n" +
" \"NetAmount\": \"250\",\n" +
" \"TaxAmount\": \"0\",\n" +
" \"Levy\": \"15\",\n" +
" \"Arrears\": \"0\"\n" +
" }\n" +
"}", LinkedHashMap.class);
}
@Override @Override
public Object reverse(Transaction transaction) { public Object reverse(Transaction transaction) {
return transaction; return transaction;

View File

@@ -4,7 +4,6 @@ import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
@@ -12,11 +11,6 @@ import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.VelocityPaymentService; import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@Service("REQUEST_WALLET_REMOTE") @Service("REQUEST_WALLET_REMOTE")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -32,6 +26,7 @@ public class CityWalletRemotePaymentProcessor implements TransactionProcessorInt
if(settingService.getBooleanSetting("SIMULATE_WALLET_SUCCESS")) { if(settingService.getBooleanSetting("SIMULATE_WALLET_SUCCESS")) {
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setPaymentStatus(Status.SUCCESS);
transaction.setResponseCode("02"); transaction.setResponseCode("02");
transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10)); transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10));
transaction.setDebitRef(RandomStringUtils.randomNumeric(10)); transaction.setDebitRef(RandomStringUtils.randomNumeric(10));
@@ -40,6 +35,8 @@ public class CityWalletRemotePaymentProcessor implements TransactionProcessorInt
try { try {
transaction = velocityPaymentService.processPayment(transaction); transaction = velocityPaymentService.processPayment(transaction);
transaction.setPaymentStatus(Status.SUCCESS);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);
@@ -63,6 +60,7 @@ public class CityWalletRemotePaymentProcessor implements TransactionProcessorInt
if(settingService.getBooleanSetting("SIMULATE_WALLET_SUCCESS")) { if(settingService.getBooleanSetting("SIMULATE_WALLET_SUCCESS")) {
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setPollingStatus(Status.SUCCESS);
transaction.setResponseCode("02"); transaction.setResponseCode("02");
transaction.setTargetUrl("/home"); transaction.setTargetUrl("/home");
return transaction; return transaction;

View File

@@ -7,7 +7,6 @@ import java.nio.charset.StandardCharsets;
import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -17,7 +16,6 @@ import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.VelocityPaymentService; import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
@Service("REQUEST_ECOCASH_REMOTE") @Service("REQUEST_ECOCASH_REMOTE")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -33,6 +31,7 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
if(settingService.getBooleanSetting("SIMULATE_ECOCASH_SUCCESS")) { if(settingService.getBooleanSetting("SIMULATE_ECOCASH_SUCCESS")) {
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setPaymentStatus(Status.SUCCESS);
transaction.setResponseCode("02"); transaction.setResponseCode("02");
transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10)); transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10));
transaction.setDebitRef(RandomStringUtils.randomNumeric(10)); transaction.setDebitRef(RandomStringUtils.randomNumeric(10));
@@ -41,6 +40,8 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
try { try {
transaction = velocityPaymentService.processPayment(transaction); transaction = velocityPaymentService.processPayment(transaction);
transaction.setPaymentStatus(Status.SUCCESS);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);
@@ -64,6 +65,7 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
if(settingService.getBooleanSetting("SIMULATE_ECOCASH_SUCCESS")) { if(settingService.getBooleanSetting("SIMULATE_ECOCASH_SUCCESS")) {
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setPollingStatus(Status.SUCCESS);
transaction.setResponseCode("02"); transaction.setResponseCode("02");
transaction.setTargetUrl("/home"); transaction.setTargetUrl("/home");
return transaction; return transaction;

View File

@@ -4,28 +4,17 @@ import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.dtos.SdkActionDto;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.SettingService; import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.VelocityPaymentService; import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Random;
@Service("REQUEST_MPGS_WEB") @Service("REQUEST_MPGS_WEB")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -41,6 +30,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
if(settingService.getBooleanSetting("SIMULATE_MPGS_SUCCESS")) { if(settingService.getBooleanSetting("SIMULATE_MPGS_SUCCESS")) {
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setPaymentStatus(Status.SUCCESS);
transaction.setResponseCode("02"); transaction.setResponseCode("02");
transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10)); transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10));
transaction.setDebitRef(RandomStringUtils.randomNumeric(10)); transaction.setDebitRef(RandomStringUtils.randomNumeric(10));
@@ -50,6 +40,10 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
try { try {
// velocity expects VMC // velocity expects VMC
transaction = velocityPaymentService.processPayment(transaction); transaction = velocityPaymentService.processPayment(transaction);
// payment status comes back as pending for VMC,
// set it to SUCCESS
transaction.setPaymentStatus(Status.SUCCESS);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);

View File

@@ -24,6 +24,7 @@ public class BatchWorkflow extends WorkflowDefinition {
public static final State REQUEST = new State("request"); public static final State REQUEST = new State("request");
public static final State REQUEST_SUCCESS = new State("requestSuccess", WorkflowStateType.manual); public static final State REQUEST_SUCCESS = new State("requestSuccess", WorkflowStateType.manual);
public static final State POLL = new State("poll"); public static final State POLL = new State("poll");
public static final State POLL_SUCCESS = new State("pollSuccess", WorkflowStateType.manual);
public static final State INTEGRATION = new State("integration"); public static final State INTEGRATION = new State("integration");
public static final State FAILED = new State("failed", WorkflowStateType.manual); public static final State FAILED = new State("failed", WorkflowStateType.manual);
public static final State DONE = new State("done", WorkflowStateType.end); public static final State DONE = new State("done", WorkflowStateType.end);
@@ -81,12 +82,16 @@ public class BatchWorkflow extends WorkflowDefinition {
UUID batchId = UUID.fromString(execution.getVariable("batchId", String.class)); UUID batchId = UUID.fromString(execution.getVariable("batchId", String.class));
GroupBatch batch = groupBatchWorkflowService.processPoll(batchId); GroupBatch batch = groupBatchWorkflowService.processPoll(batchId);
execution.setVariable("batchStatus", batch.getStatus().name()); execution.setVariable("batchStatus", batch.getStatus().name());
return NextAction.moveToState(INTEGRATION, "Batch poll complete"); return NextAction.moveToState(POLL_SUCCESS, "Batch poll complete");
} catch (Exception e) { } catch (Exception e) {
return NextAction.moveToState(FAILED, e.getMessage()); return NextAction.moveToState(FAILED, e.getMessage());
} }
} }
public void pollSuccess(StateExecution execution) {
log.info("Batch poll succeeded, paused for integration trigger");
}
public NextAction integration(StateExecution execution) { public NextAction integration(StateExecution execution) {
try { try {
UUID batchId = UUID.fromString(execution.getVariable("batchId", String.class)); UUID batchId = UUID.fromString(execution.getVariable("batchId", String.class));

View File

@@ -18,14 +18,12 @@ import zw.qantra.tm.domain.dtos.*;
import zw.qantra.tm.domain.dtos.erp.VelocityCustomerResponseDto; import zw.qantra.tm.domain.dtos.erp.VelocityCustomerResponseDto;
import zw.qantra.tm.domain.models.Otp; import zw.qantra.tm.domain.models.Otp;
import zw.qantra.tm.domain.models.User; import zw.qantra.tm.domain.models.User;
import zw.qantra.tm.domain.services.EmailService; import zw.qantra.tm.domain.models.Workspace;
import zw.qantra.tm.domain.services.OtpService; import zw.qantra.tm.domain.services.*;
import zw.qantra.tm.domain.services.UserService;
import io.nflow.engine.workflow.curated.State; import io.nflow.engine.workflow.curated.State;
import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.exceptions.AlreadyExistsException; import zw.qantra.tm.exceptions.AlreadyExistsException;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
@@ -48,9 +46,11 @@ public class RegistrationWorkflow extends WorkflowDefinition {
@Autowired @Autowired
private RestService restService; private RestService restService;
@Autowired @Autowired
private EmailService emailService; private NotificationService notificationService;
@Autowired @Autowired
private VelocityPaymentService velocityPaymentService; private VelocityPaymentService velocityPaymentService;
@Autowired
private WorkspaceService workspaceService;
public static final String TYPE = "registrationWorkflow"; public static final String TYPE = "registrationWorkflow";
@@ -60,6 +60,7 @@ public class RegistrationWorkflow extends WorkflowDefinition {
public static final State VERIFY_OTP = new State("verifyOtp"); public static final State VERIFY_OTP = new State("verifyOtp");
public static final State RESEND_OTP = new State("resendOtp", WorkflowStateType.manual); public static final State RESEND_OTP = new State("resendOtp", WorkflowStateType.manual);
public static final State OTP_SUCCESS = new State("otpSuccess"); public static final State OTP_SUCCESS = new State("otpSuccess");
public static final State CREATE_WORKSPACE = new State("createWorkspace");
public static final State UPDATE_ERP = new State("updateErp"); public static final State UPDATE_ERP = new State("updateErp");
public static final State FAILED = new State("failed", WorkflowStateType.manual); public static final State FAILED = new State("failed", WorkflowStateType.manual);
public static final State DONE = new State("done", WorkflowStateType.end); public static final State DONE = new State("done", WorkflowStateType.end);
@@ -72,7 +73,8 @@ public class RegistrationWorkflow extends WorkflowDefinition {
permit(SEND_OTP, VERIFY_OTP); permit(SEND_OTP, VERIFY_OTP);
permit(RESEND_OTP, VERIFY_OTP); permit(RESEND_OTP, VERIFY_OTP);
permit(VERIFY_OTP, OTP_SUCCESS, FAILED); permit(VERIFY_OTP, OTP_SUCCESS, FAILED);
permit(OTP_SUCCESS, UPDATE_ERP, FAILED); permit(OTP_SUCCESS, CREATE_WORKSPACE, FAILED);
permit(CREATE_WORKSPACE, UPDATE_ERP, FAILED);
permit(UPDATE_ERP, DONE); permit(UPDATE_ERP, DONE);
} }
@@ -117,9 +119,9 @@ public class RegistrationWorkflow extends WorkflowDefinition {
// Generate OTP // Generate OTP
Otp otp = otpService.generateOtp(username, "REGISTRATION"); Otp otp = otpService.generateOtp(username, "REGISTRATION");
String string = String.format("Your Velocity Pay verification code is: %s", otp.getOtp()); notificationService.sendOtpEmail(email, username, otp.getOtp());
emailService.sendSimpleMessage(email, "Velocity Pay verification code", string); notificationService.sendSms(registerRequest.getPhone(),
// LinkedHashMap response = restService.postAs(infobipUrl, string, headers, LinkedHashMap.class); "Your Velocity Pay verification code is: " + otp.getOtp());
log.info("otp generated successfully: {}", otp.getOtp()); log.info("otp generated successfully: {}", otp.getOtp());
} catch (Exception e) { } catch (Exception e) {
@@ -142,10 +144,12 @@ public class RegistrationWorkflow extends WorkflowDefinition {
public void resendOtp(StateExecution execution) { public void resendOtp(StateExecution execution) {
OtpRequest otpRequest = execution.getVariable("resend", OtpRequest.class); OtpRequest otpRequest = execution.getVariable("resend", OtpRequest.class);
Otp otp = otpService.generateOtp(otpRequest.getUsername(), "REGISTRATION"); Otp otp = otpService.generateOtp(otpRequest.getUsername(), "REGISTRATION");
String string = String.format("Your Velocity Pay verification code is: %s", otp.getOtp()); // The username field in OtpRequest may actually contain the email; use it directly
emailService.sendSimpleMessage(otpRequest.getUsername(), "Velocity Pay verification code", string); notificationService.sendOtpEmail(otpRequest.getUsername(), otpRequest.getUsername(), otp.getOtp());
notificationService.sendSms(otpRequest.getPhone(),
"Your Velocity Pay verification code is: " + otp.getOtp());
log.info("otp generated successfully: {}", otp.getOtp()); log.info("otp resent successfully: {}", otp.getOtp());
} }
public NextAction otpSuccess(StateExecution execution) { public NextAction otpSuccess(StateExecution execution) {
@@ -165,9 +169,28 @@ public class RegistrationWorkflow extends WorkflowDefinition {
return NextAction.moveToState(FAILED, "Failed to register user: " + e.getMessage()); return NextAction.moveToState(FAILED, "Failed to register user: " + e.getMessage());
} }
return NextAction.moveToState(UPDATE_ERP, "User creation successfully for: " + request.getUsername()); return NextAction.moveToState(CREATE_WORKSPACE, "User creation successfully for: " + request.getUsername());
} }
public NextAction createWorkspace(StateExecution execution) {
log.info("Begin create workspace");
RegisterRequest registerRequest = execution.getVariable("registerRequest", RegisterRequest.class);
Workspace workspace = new Workspace();
workspace.setName(registerRequest.getFirstName() + "'s Workspace");
workspace.setDescription("Default Workspace");
workspace.setPhone(registerRequest.getPhone());
workspace.setEmail(registerRequest.getEmail());
workspace = workspaceService.createWorkspace(workspace);
User user = userService.fetchUserByUsername(registerRequest.getUsername());
workspace.setUsers(Set.of(user));
workspaceService.updateWorkspace(workspace.getId(), workspace);
log.info("Workspace created successfully");
return NextAction.moveToState(UPDATE_ERP, "Workspace creation successfully");
}
public NextAction updateErp(StateExecution execution) { public NextAction updateErp(StateExecution execution) {
RegisterRequest registerRequest = execution.getVariable("registerRequest", RegisterRequest.class); RegisterRequest registerRequest = execution.getVariable("registerRequest", RegisterRequest.class);

View File

@@ -26,12 +26,12 @@ public class WorkflowUtils {
CompletableFuture<Object> future = new CompletableFuture<>(); CompletableFuture<Object> future = new CompletableFuture<>();
while(!future.isDone()){ // check if the future is done while(!future.isDone()){ // check if the future is done
WorkflowInstance instance = workflowInstanceService.getWorkflowInstance(
workflowId,
EnumSet.of(ACTIONS, ACTION_STATE_VARIABLES, CURRENT_STATE_VARIABLES),
1L);
try { try {
Thread.sleep(pollingInterval); Thread.sleep(pollingInterval);
WorkflowInstance instance = workflowInstanceService.getWorkflowInstance(
workflowId,
EnumSet.of(ACTIONS, ACTION_STATE_VARIABLES, CURRENT_STATE_VARIABLES),
1L);
List<String> stateList = Arrays.stream(states).toList(); List<String> stateList = Arrays.stream(states).toList();
List<WorkflowInstance.WorkflowInstanceStatus> statusList = List<WorkflowInstance.WorkflowInstanceStatus> statusList =
Arrays.asList(WorkflowInstance.WorkflowInstanceStatus.manual, Arrays.asList(WorkflowInstance.WorkflowInstanceStatus.manual,
@@ -40,13 +40,13 @@ public class WorkflowUtils {
statusList.contains(instance.status)){ statusList.contains(instance.status)){
future.complete(getStateResponse(instance, "success")); future.complete(getStateResponse(instance, "success"));
} }
timeout -= pollingInterval;
if(timeout <= 0){
future.complete(getStateResponse(instance, "Request timed out"));
}
} catch (InterruptedException e) { } catch (InterruptedException e) {
future.completeExceptionally(e); future.completeExceptionally(e);
} }
timeout -= pollingInterval;
if(timeout <= 0){
future.complete(getStateResponse(instance, "Request timed out"));
}
} }
return future; return future;

View File

@@ -6,13 +6,17 @@ import com.google.i18n.phonenumbers.Phonenumber;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.PartyType; import zw.qantra.tm.domain.enums.PartyType;
import zw.qantra.tm.domain.enums.RequestType; import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Provider; import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.models.User;
import zw.qantra.tm.domain.services.ProviderService; import zw.qantra.tm.domain.services.ProviderService;
import zw.qantra.tm.domain.services.UserService;
import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
@@ -24,6 +28,7 @@ public class CleanupHandler implements HandlerInterface {
private final Logger logger = LoggerFactory.getLogger(CleanupHandler.class); private final Logger logger = LoggerFactory.getLogger(CleanupHandler.class);
private final ProviderService providerService; private final ProviderService providerService;
private final UserService userService;
@Override @Override
public Object process(Transaction transaction) throws Exception { public Object process(Transaction transaction) throws Exception {
@@ -39,21 +44,28 @@ public class CleanupHandler implements HandlerInterface {
transaction.setPartyType(PartyType.INDIVIDUAL); transaction.setPartyType(PartyType.INDIVIDUAL);
} }
if(transaction.getUsername() == null) {
userService.getUserRepository().findById(UUID.fromString(transaction.getUserId()))
.ifPresent(user -> transaction.setUsername(user.getUsername()));
}
// UPDATE TRAN WITH CONFIRMATION HANDLER // UPDATE TRAN WITH CONFIRMATION HANDLER
// ======================= // =======================
Provider provider = providerService.getProviderRepository() if(transaction.getType().equals(RequestType.CONFIRM)) {
.findByClientId(transaction.getBillClientId()); Provider provider = providerService.getProviderRepository()
if(provider == null){ .findByClientId(transaction.getBillClientId());
throw new ApiException("Provider not found for billClientId: " + transaction.getBillClientId()); if(provider == null){
} throw new ApiException("Provider not found for billClientId: " + transaction.getBillClientId());
String label = }
transaction.getType() + "_" + String label =
provider.getLabel() + "_" + transaction.getType() + "_" +
provider.getIntegrationProcessorLabel(); provider.getLabel() + "_" +
provider.getIntegrationProcessorLabel();
transaction.setConfirmationProcessorLabel(label); transaction.setConfirmationProcessorLabel(label);
transaction.setProviderLabel(provider.getLabel()); transaction.setProviderLabel(provider.getLabel());
logger.info("label: {}", label); logger.info("label: {}", label);
}
// FORMAT FIELDS CORRECTLY // FORMAT FIELDS CORRECTLY
// ======================= // =======================
@@ -82,7 +94,7 @@ public class CleanupHandler implements HandlerInterface {
} }
if(creditPhoneNumber != null && !creditPhoneNumber.isEmpty()){ if(creditPhoneNumber != null && !creditPhoneNumber.isEmpty()){
transaction.setCreditAccount(Utils.formatPhoneNumber(creditPhoneNumber, "ZW")); transaction.setCreditPhone(Utils.formatPhoneNumber(creditPhoneNumber, "ZW"));
} }
return transaction; return transaction;

View File

@@ -35,6 +35,9 @@ public class GatewayPaymentHandler implements HandlerInterface {
new ApiException("Transaction not found for ID: " + incomingTransaction.getId())); new ApiException("Transaction not found for ID: " + incomingTransaction.getId()));
transaction.setType(incomingTransaction.getType()); // type should be REQUEST here transaction.setType(incomingTransaction.getType()); // type should be REQUEST here
transaction.setAuthType(incomingTransaction.getAuthType()); transaction.setAuthType(incomingTransaction.getAuthType());
transaction.setErrorMessage(null);
transaction.setSystemErrorMessage(null);
transaction.setStatus(Status.PROCESSING);
} }
// if we've done this before then return // if we've done this before then return
@@ -58,9 +61,9 @@ public class GatewayPaymentHandler implements HandlerInterface {
try { try {
// MAIN TRANSACTION LOGIC! // MAIN TRANSACTION LOGIC!
transactionProcessorInterface.process(transaction); transaction = (Transaction) transactionProcessorInterface.process(transaction);
transaction.setPaymentStatus(transaction.getStatus());
transaction.setPollingStatus(Status.PENDING); transaction.setPollingStatus(Status.PENDING);
transactionService.save(transaction);
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); e.printStackTrace();

View File

@@ -1,6 +1,7 @@
package zw.qantra.tm.domain.services.processors.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -13,6 +14,7 @@ import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.exceptions.ApiException; import zw.qantra.tm.exceptions.ApiException;
@Slf4j
@Service("pollStatus") @Service("pollStatus")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PollStatusHandler implements HandlerInterface { public class PollStatusHandler implements HandlerInterface {
@@ -24,7 +26,14 @@ public class PollStatusHandler implements HandlerInterface {
@Override @Override
public Object process(Transaction transaction) throws Exception { public Object process(Transaction transaction) throws Exception {
Logger logger = LoggerFactory.getLogger(TransactionService.class);
// don't poll unless payment was success
if(transaction.getPaymentStatus() != Status.SUCCESS) {
transaction.setPollingStatus(Status.PENDING);
transaction.setErrorMessage("Transaction not yet successful");
transactionService.save(transaction);
return transaction;
}
// if the transaction is already successful, return it // if the transaction is already successful, return it
if(transaction.getPollingStatus() == Status.SUCCESS){ if(transaction.getPollingStatus() == Status.SUCCESS){
@@ -40,23 +49,21 @@ public class PollStatusHandler implements HandlerInterface {
// no persistence happens during polling unless the transaction status changes // no persistence happens during polling unless the transaction status changes
String label = "REQUEST_" + transaction.getPaymentProcessorLabel() + "_" + transaction.getAuthType(); String label = "REQUEST_" + transaction.getPaymentProcessorLabel() + "_" + transaction.getAuthType();
logger.info("label: {}", label); log.info("label: {}", label);
TransactionProcessorInterface transactionProcessorInterface = TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label); (TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(label);
// find out if the gateway tran was successful // find out if the gateway tran was successful
transactionProcessorInterface.poll(transaction); transaction = (Transaction) transactionProcessorInterface.poll(transaction);
transactionEvent.setStatus(transaction.getStatus()); transactionEvent.setStatus(transaction.getStatus());
transactionEvent.setMessage(transaction.getErrorMessage()); transactionEvent.setMessage(transaction.getErrorMessage());
transactionEventService.save(transactionEvent); transactionEventService.save(transactionEvent);
transaction.setPollingStatus(transaction.getStatus());
transaction.setPaymentStatus(transaction.getStatus());
// if transaction wasn't successful, throw and halt workflow // if transaction wasn't successful, throw and halt workflow
if(transaction.getStatus() != Status.SUCCESS){ if(transaction.getPollingStatus() != Status.SUCCESS){
log.info("Verification pending: {}", transaction.getErrorMessage());
transactionService.save(transaction); transactionService.save(transaction);
throw new ApiException("Verification pending: " + transaction.getErrorMessage() ); throw new ApiException("Verification pending: " + transaction.getErrorMessage() );
}else { }else {

View File

@@ -21,7 +21,7 @@ public class RecipientsUpdateHandler implements HandlerInterface {
try { try {
if(transaction.getStatus() == Status.SUCCESS){ if(transaction.getStatus() == Status.SUCCESS){
List<Recipient> recipients = recipientService.getRecipient(transaction.getCreditAccount(), transaction.getUserId()); List<Recipient> recipients = recipientService.getRecipient(transaction.getCreditAccount(), transaction.getWorkspaceId());
Recipient recipient = null; Recipient recipient = null;
if(!recipients.isEmpty()){ if(!recipients.isEmpty()){
@@ -30,6 +30,7 @@ public class RecipientsUpdateHandler implements HandlerInterface {
recipient = new Recipient(); recipient = new Recipient();
recipient.setAccount(transaction.getCreditAccount()); recipient.setAccount(transaction.getCreditAccount());
recipient.setUserId(transaction.getUserId()); recipient.setUserId(transaction.getUserId());
recipient.setWorkspaceId(transaction.getWorkspaceId());
} }
recipient.setLatestProviderLabel(transaction.getProviderLabel()); recipient.setLatestProviderLabel(transaction.getProviderLabel());

View File

@@ -3,6 +3,7 @@ package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.PaymentProcessor; import zw.qantra.tm.domain.models.PaymentProcessor;
import zw.qantra.tm.domain.repositories.PaymentProcessorRepository; import zw.qantra.tm.domain.repositories.PaymentProcessorRepository;
@@ -33,6 +34,18 @@ public class PaymentProcessorSeeder implements Seeder {
.accountFieldLabel("Wallet Phone Number") .accountFieldLabel("Wallet Phone Number")
.description("Pay using your CityWallet") .description("Pay using your CityWallet")
.authType("REMOTE") .authType("REMOTE")
.currency(CurrencyType.USD)
.build(),
PaymentProcessor.builder()
.name("CityWallet")
.label("WALLET")
.type("GATEWAY")
.image("city.png")
.accountFieldName("phoneNumber")
.accountFieldLabel("Wallet Phone Number")
.description("Pay using your CityWallet")
.authType("REMOTE")
.currency(CurrencyType.ZWG)
.build(), .build(),
PaymentProcessor.builder() PaymentProcessor.builder()
.name("Ecocash") .name("Ecocash")
@@ -43,6 +56,7 @@ public class PaymentProcessorSeeder implements Seeder {
.accountFieldLabel("EcoCash Phone Number") .accountFieldLabel("EcoCash Phone Number")
.description("Pay using your mobile wallet") .description("Pay using your mobile wallet")
.authType("REMOTE") .authType("REMOTE")
.currency(CurrencyType.USD)
.build(), .build(),
PaymentProcessor.builder() PaymentProcessor.builder()
.name("Visa/MasterCard") .name("Visa/MasterCard")
@@ -53,11 +67,12 @@ public class PaymentProcessorSeeder implements Seeder {
.accountFieldLabel("Card Number") .accountFieldLabel("Card Number")
.description("Pay using your international card") .description("Pay using your international card")
.authType("WEB") .authType("WEB")
.currency(CurrencyType.USD)
.build() .build()
); );
for (PaymentProcessor processor : processors) { for (PaymentProcessor processor : processors) {
if (paymentProcessorRepository.existsByLabel(processor.getLabel())) { if (paymentProcessorRepository.existsByLabelAndCurrency(processor.getLabel(), processor.getCurrency())) {
log.info("PaymentProcessor '{}' already exists, skipping.", processor.getLabel()); log.info("PaymentProcessor '{}' already exists, skipping.", processor.getLabel());
} else { } else {
paymentProcessorRepository.save(processor); paymentProcessorRepository.save(processor);

View File

@@ -41,10 +41,31 @@ public class ProviderSeeder implements Seeder {
.requiresAccount(false) .requiresAccount(false)
.requiresAmount(true) .requiresAmount(true)
.requiresPhone(true) .requiresPhone(true)
.requiresProducts(false)
.priority(2) .priority(2)
.meta("{\"hotProductId\": \"101\"}") .meta("{\"hotProductId\": \"101\"}")
.uid(Utils.getUid()) .uid(Utils.getUid())
.build(), .build(),
Provider.builder()
.name("Econet Airtime")
.description("Econet Airtime")
.clientId("econet_airtime_zwg")
.currency(CurrencyType.ZWG)
.label("ECONET")
.category("AIRTIME")
.image("econet.png")
.externalId("78f1f497-c9eb-401c-b22c-884756e68e41")
.integrationProcessorLabel("HOT")
.accountFieldName("Phone Number")
.percentageCommission(5)
.requiresAccount(false)
.requiresAmount(true)
.requiresPhone(true)
.requiresProducts(false)
.priority(2)
.meta("{\"hotProductId\": \"7\"}")
.uid(Utils.getUid())
.build(),
Provider.builder() Provider.builder()
.name("NetOne Airtime") .name("NetOne Airtime")
.description("NetOne Airtime") .description("NetOne Airtime")
@@ -60,6 +81,7 @@ public class ProviderSeeder implements Seeder {
.requiresAccount(false) .requiresAccount(false)
.requiresAmount(true) .requiresAmount(true)
.requiresPhone(true) .requiresPhone(true)
.requiresProducts(true)
.uid(Utils.getUid()) .uid(Utils.getUid())
.priority(3) .priority(3)
.meta("{\"hotProductId\": \"35\"}") .meta("{\"hotProductId\": \"35\"}")
@@ -79,6 +101,7 @@ public class ProviderSeeder implements Seeder {
.requiresAccount(true) .requiresAccount(true)
.requiresAmount(true) .requiresAmount(true)
.requiresPhone(true) .requiresPhone(true)
.requiresProducts(false)
.priority(1) .priority(1)
.meta("{\"hotProductId\": \"24\"}") .meta("{\"hotProductId\": \"24\"}")
.uid(Utils.getUid()) .uid(Utils.getUid())
@@ -98,6 +121,7 @@ public class ProviderSeeder implements Seeder {
.requiresAccount(true) .requiresAccount(true)
.requiresAmount(true) .requiresAmount(true)
.requiresPhone(true) .requiresPhone(true)
.requiresProducts(false)
.priority(1) .priority(1)
.meta("{\"hotProductId\": \"41\"}") .meta("{\"hotProductId\": \"41\"}")
.uid(Utils.getUid()) .uid(Utils.getUid())
@@ -117,9 +141,31 @@ public class ProviderSeeder implements Seeder {
.requiresAccount(false) .requiresAccount(false)
.requiresAmount(false) .requiresAmount(false)
.requiresPhone(true) .requiresPhone(true)
.requiresProducts(true)
.priority(4) .priority(4)
.meta("{\"hotProductId\": \"111\"}") .meta("{\"hotProductId\": \"111\"}")
.uid(Utils.getUid()) .uid(Utils.getUid())
.build(),
Provider.builder()
.name("Econet Bundles")
.description("Econet Bundles")
.clientId("econet_bundles_zwg")
.currency(CurrencyType.ZWG)
.label("ECONET_BUNDLES")
.category("AIRTIME")
.image("econet.png")
.externalId("287863e2-a791-4106-b629-79dadc9a6780")
.integrationProcessorLabel("HOT")
.accountFieldName("Phone Number")
.percentageCommission(5)
.requiresAccount(false)
.requiresAmount(false)
.requiresPhone(true)
.requiresProducts(true)
.requiresProducts(true)
.priority(4)
.meta("{\"hotProductId\": \"16\"}")
.uid(Utils.getUid())
.build() .build()
); );

Some files were not shown because too many files have changed in this diff Show More