Compare commits

45 Commits

Author SHA1 Message Date
aad925bc54 reverting back to dockerfile build 2026-07-12 23:15:57 +02:00
5974f86e29 reverting back to dockerfile build 2026-07-12 23:15:24 +02:00
a53744d949 added nixpacks configs 2026-07-12 23:03:59 +02:00
0d2b5b3c22 added nixpacks configs 2026-07-12 22:57:41 +02:00
5726c65e15 dokploy build configs 2026-07-12 22:39:56 +02:00
be0f0ae49c bug fix 2026-07-09 23:06:06 +02:00
ba796527ae compose fix 2026-07-09 23:03:12 +02:00
1de89364be added openwa failure notifications 2026-07-09 22:35:49 +02:00
fbc3967215 added openwa failure notifications 2026-07-09 21:19:43 +02:00
94a30633ca improving otp sec 2026-07-09 18:38:52 +02:00
5d3cfde7f6 Merge remote-tracking branch 'refs/remotes/origin/release/0.0.2-RELEASE' into release/0.0.2-RELEASE 2026-07-06 12:54:27 +02:00
root
b9efd83762 added zb keys 2026-07-06 12:50:52 +02:00
220c19123f updating sms vendor 2026-07-06 12:17:19 +02:00
a4d210393e updating credit amount functionality 2026-07-05 00:01:19 +02:00
6d48472425 updating credit amount functionality 2026-07-04 23:56:46 +02:00
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
125 changed files with 5175 additions and 442 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

1
.gitignore vendored
View File

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

View File

@@ -1,7 +1,18 @@
FROM 144.91.121.112:8082/java-17 FROM vkhoza/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"]
EXPOSE 6950
EXPOSE 8058
CMD ["java", "-Djava.awt.headless=true", "-Dspring.profiles.active=nflow.db.postgresql", "-jar", "/app/target/tm-0.0.1-RELEASE.jar"]

14
compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
velocity-pay-backend:
build:
context: .
dockerfile: Dockerfile
image: velocity-pay-backend:0.0.2-RELEASE
restart: always
container_name: velocity-pay-backend
ports:
- 8058:8058
- 6950:6950
network_mode: host
environment:
- TZ=Africa/Harare

18
keys/velocityafrica.csr Normal file
View File

@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIC7TCCAdUCAQAwgacxCzAJBgNVBAYTAlpXMQ8wDQYDVQQIDAZIYXJhcmUxDzAN
BgNVBAcMBkhhcmFyZTEYMBYGA1UECgwPV2FsbGV0IEV4Y2hhbmdlMRkwFwYDVQQL
DBBEaWdpdGFsIFBheW1lbnRzMR0wGwYDVQQDDBQqLnZlbG9jaXR5YWZyaWNhLm5l
dDEiMCAGCSqGSIb3DQEJARYTdnVzYWtob3phQGdtYWlsLmNvbTCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBALl1Pvoh+NUK9A8KlwgLkAIiDs3l7Kbs2Zt5
FZvyOgpjD6Cl/IJy7DC8oJ7Ty+eJkoVKPjyX7urnf1wQgstQMgryA7YMVnQPcYJe
Zm3px+lPhbkEEeVc5veNktCqL590LLMrL6IZbGR1Xh8Phd7niLn1+o+ZebgWH6XD
nIb8JFrBTy5ZigU/dGBpII/NhciIVQDF4TPy5DwfNIBAAKZdp/CiYpTFN0iV7/UA
0g43zfS3CbuEK+Mj6BkkSquWHFd76W58BAhDa2S50sd6VF+jq4EFCdWmJXmxVmPI
rxUMFi9DbI7Cd9DB56pkfnSIYx0t60SjXKy3gkNdB6zcNcFZ860CAwEAAaAAMA0G
CSqGSIb3DQEBCwUAA4IBAQBK4YsEvIQolqsVeqUSxUforA1qo/iKhK+yetlQ8YdN
hR/v+4fIIoaIZLjdSTOLDTDIJ3AUBGlKvF7mf64X+JQpyQVqUIx58S92Zr645lfU
LADsliDXCNQp0nzWDavAC9HvbA4ibl+Z1X+z/uojf3fPZEmAli2howpWYiLIsQSj
quS5L3kkv8XhYBnE0h3L4GL3kp7bFAMRzJq3392pU3xAGiJhBS3oKyBel8mfWrTE
MQDsFgK6bjy2JWRgmb5lqKSV1KlqbG5zmBkSgFcEkrB9tm7DtQbo5oPSDKO5otWf
Wn40iNGAnSRhsc0jjXR1s1oqGQh7SrEhOvm9JI76LfGh
-----END CERTIFICATE REQUEST-----

28
keys/velocityafrica.key Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC5dT76IfjVCvQP
CpcIC5ACIg7N5eym7NmbeRWb8joKYw+gpfyCcuwwvKCe08vniZKFSj48l+7q539c
EILLUDIK8gO2DFZ0D3GCXmZt6cfpT4W5BBHlXOb3jZLQqi+fdCyzKy+iGWxkdV4f
D4Xe54i59fqPmXm4Fh+lw5yG/CRawU8uWYoFP3RgaSCPzYXIiFUAxeEz8uQ8HzSA
QACmXafwomKUxTdIle/1ANION830twm7hCvjI+gZJEqrlhxXe+lufAQIQ2tkudLH
elRfo6uBBQnVpiV5sVZjyK8VDBYvQ2yOwnfQweeqZH50iGMdLetEo1yst4JDXQes
3DXBWfOtAgMBAAECggEAE8cSIUCwNaAGaPg7JQt+nwucwo5rj12g0zi1ejz5vZPU
3c/1vr+cupHTGz0A+L8uFfLV8kZNn1VyupG0MRRrUcl/ZdGpApWHCF0i1UPiItpt
K07ozan7o5jAeGo9y+ZqweywFrxZV9lAYVWZdC0C4rn9r4Sxe1U4qMfUkHyhL+BQ
o2Ot2qxfHbbV/mNZcSlQHLByRu1tuVT8TxS5ZOqdu0ar8QnyoHr9tk6+x6WgCONr
K/Hvjej9BL6KrB2/vK52A8PclPJAqb8w5W8c0jQHhS9dAJV5t1KHXAejBz396vn3
PowurUTyu7RrVVK49j9/q0utPl2tosTOhPNobzY6wQKBgQD8iCyuCLMawh84i8qq
SFqyNI0bc2nWuF74QrCc/3NScUdOM8oMzUd5vskEyAa3yl1Xf0Ffg/nMO70E/3Ra
rvLoFnfS/WXHrY8TkSpNI6TY/gtf/F8LOW2HZemWSBZ4Q/iatfTX+gL/3cZFE9F6
dAbA8wcmGNR3GBKbDorxKhSv3QKBgQC8AUKI5ijfmqCnilqNrpiegxcAdGT8au1W
uIW9CjdXS9c1D/+cPFGRc+vLIqWUE7vStkSl6rzY4Va8uqh7t8P9MksE8ru8QcbD
Sq2RwO1vBPnNmb7eS/ooyRbYFOXlrSx7mntPkXUZE/c7Lpqe7XxeC+R/wsZc8fyd
H8RXlCH+EQKBgQCJNhBYy19NpbZDCimnFkM5WrZk+PPD+E+Zwtqlo2Lso6Ijyp35
ELzKWLBuiaSkzi6jcQB7PoFGyBqvEYIOF8rh8ftPR+G6egIolpKOcVSLlpVlpQGb
Xqu1itk9j5Ot6vi0TzNm+nAQyFaHh/ok99AHnMUAnTMsNoc9esxhtwSgfQKBgAoF
MyuiwMjyL9/eIudsR9wi9oXirkM5276EhTRHKB3x/91nJeOI33wWJpokFZkn2Bnp
9wy0zy3sDLhwZmx5peuH07HG0jS9eL+N6mRbWQxSwpGvwRmOCR5FYX9Q1dQA/97y
o+HgydH1W3b/C+SumbNVhO8+TV5E04b4W3mjfpBRAoGBAJMnFV0ivN+TN4/czmAx
4JulU6mh0qCfax6tMbx5RiXmlHitAXQvPooFpzItLWlrRnXj6aHHqXOs5uH0kspj
4pTnDGnQO9VxhRziEjPIAVS6vFntxOs0zshaROreLcaVoDSkmz6YkFTeiSbtzumo
rGOjofBa6j9pYfi9YCi8nSnK
-----END PRIVATE KEY-----

35
nixpacks.toml Normal file
View File

@@ -0,0 +1,35 @@
# Nixpacks configuration for velocity-backend-spring-boot
# Replicates the behavior of the existing Dockerfile for Dokploy deployment
[phases]
[phases.setup]
nixPkgs = [
"fontconfig",
"dejavu_fonts",
"maven",
]
[phases.build]
# Override default Maven command to use the production profile
# Equivalent to: RUN mvn clean compile -P production package
cmds = [
"mvn clean compile -P production package"
]
[start]
# Equivalent to: CMD ["java", "-Djava.awt.headless=true", "-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"
[variables]
# Use Java 17 as defined in pom.xml properties
JAVA_VERSION = "17"
[env]
# List of environment variables the application expects
# These can be overridden in Dokploy's environment settings
SPRING_PROFILES_ACTIVE = "nflow.db.postgresql"
JAVA_OPTS = "-Djava.awt.headless=true"
[deploy]
# Ports exposed by the application (from compose.yml)
ports = [8058, 6950]

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

View File

@@ -36,11 +36,11 @@ public class SecurityConfig {
.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;
@@ -57,6 +60,7 @@ public class TransactonController {
@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

@@ -0,0 +1,19 @@
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 SmsPopCampaignRequest {
private String name;
private String message;
private String sender_id;
@Builder.Default
private String contact_import_method = "manual";
private String manual_contacts;
}

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,15 @@
package zw.qantra.tm.domain.models; package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.EnumType; import jakarta.persistence.*;
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
@@ -15,8 +17,11 @@ import zw.qantra.tm.domain.enums.Status;
@NoArgsConstructor @NoArgsConstructor
public class Otp extends BaseEntity{ public class Otp extends BaseEntity{
private String username; private String username;
@JsonIgnore
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,15 +66,19 @@ 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)
private CurrencyType creditCurrency; private CurrencyType creditCurrency;
private BigDecimal creditAmount; private BigDecimal creditAmount;
private BigDecimal creditTotalAmount;
private String creditName; private String creditName;
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 +88,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 +120,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")
@@ -21,6 +26,7 @@ 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;
@@ -49,6 +55,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() {
return List.of(new SimpleGrantedAuthority("ROLE_USER")); return List.of(new SimpleGrantedAuthority("ROLE_USER"));

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,8 +16,11 @@ 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> findAllByPollingStatus(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;
@@ -34,7 +35,7 @@ public class JobService {
public void checkOrphanedTransactions() { public void checkOrphanedTransactions() {
List<Transaction> transactions = transactionService.getTransactionRepository() List<Transaction> transactions = transactionService.getTransactionRepository()
.findAllByPaymentStatusAndPollingStatus(Status.SUCCESS, Status.PENDING); .findAllByPollingStatus(Status.PENDING);
if (!transactions.isEmpty()) if (!transactions.isEmpty())
log.info("Transaction count {}", transactions.size()); log.info("Transaction count {}", transactions.size());
@@ -46,8 +47,13 @@ public class JobService {
int maxRetries = settingService.getIntegerSetting("MAX_RETRIES"); int maxRetries = settingService.getIntegerSetting("MAX_RETRIES");
int retries = transaction.getRetries() == null ? 0 : transaction.getRetries(); int retries = transaction.getRetries() == null ? 0 : transaction.getRetries();
if (retries >= maxRetries) if (retries >= maxRetries) {
break; transaction.setPaymentStatus(Status.FAILED);
transaction.setPollingStatus(Status.FAILED);
transaction.setErrorMessage("Maximum retries reached");
transactionService.save(transaction);
continue;
}
transaction.setRetries(retries + 1); transaction.setRetries(retries + 1);
// polling logic // polling logic
@@ -105,8 +111,8 @@ public class JobService {
success++; success++;
}else { }else {
transaction.setPollingStatus(Status.FAILED); transaction.setPollingStatus(Status.PENDING);
transaction.setIntegrationStatus(Status.FAILED); transaction.setIntegrationStatus(Status.PENDING);
transactionService.save(transaction); transactionService.save(transaction);
} }

View File

@@ -0,0 +1,155 @@
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.SmsPopCampaignRequest;
import zw.qantra.tm.rest.RestService;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
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("${sms-pop.url}")
private String smsPopUrl;
@Value("${sms-pop.apikey}")
private String smsPopApiKey;
@Value("${sms.debug}")
private Boolean smsDebug;
@Value("${wa.api.url}")
private String waApiUrl;
@Value("${wa.api.key}")
private String waApiKey;
@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 sendWA(String chatId, String text) {
try {
Map<String, String> payload = new HashMap<>();
payload.put("chatId", String.format("%s@c.us", chatId));
payload.put("text", text);
HttpHeaders headers = new HttpHeaders();
headers.set("X-API-Key", waApiKey);
restService.postWithExternalAuth(waApiUrl, payload, headers, Object.class);
logger.info("WA message sent to {}: {}", chatId, text);
} catch (Exception e) {
logger.error("Failed to send WA message to {}: {}", chatId, e.getMessage(), e);
}
}
@Async
public void sendSms(String to, String message) {
if(smsDebug) {
logger.info("Sending SMS to {}: {}", to, message);
return;
}
try {
String phoneNumber = to;
if (phoneNumber.startsWith("+")) {
phoneNumber = phoneNumber.substring(1);
}
SmsPopCampaignRequest campaignRequest = SmsPopCampaignRequest.builder()
.name("Velocity Payments")
.message(message)
.sender_id("Velocity")
.contact_import_method("manual")
.manual_contacts(phoneNumber)
.build();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + smsPopApiKey);
restService.postAs(smsPopUrl, campaignRequest, headers, Object.class);
logger.info("SMS sent to {} via SMSPOP", to);
} 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,8 +501,8 @@ 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);
} }
@@ -155,4 +510,3 @@ public class RecipientGroupService {
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

@@ -15,6 +15,7 @@ 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.enums.CurrencyType;
import zw.qantra.tm.domain.enums.RequestType; import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.models.AdditionalData; import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Currency; import zw.qantra.tm.domain.models.Currency;
@@ -33,30 +34,46 @@ public class TransactionService {
private final AdditionalDataService additionalDataService; private final AdditionalDataService additionalDataService;
private final CurrencyService currencyService; private final CurrencyService currencyService;
public BigDecimal calculateCreditAmount(Transaction transaction) { public BigDecimal calculateCurrencyExchangeValue(CurrencyType from, CurrencyType to, BigDecimal amount) {
if (transaction.getCreditCurrency() == null) { if (to == null) {
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
Currency currency = currencyService.findCurrencyByIsoCode(transaction.getCreditCurrency().name()); Currency debitCurrency = currencyService.findCurrencyByIsoCode(from.name());
Currency creditCurrency = currencyService.findCurrencyByIsoCode(to.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());
transaction.setCreditAmount(creditAmount); BigDecimal bankRate = creditBankRate.divide(debitBankRate, 4, RoundingMode.HALF_UP);
return creditAmount;
return amount.multiply(bankRate).setScale(4, RoundingMode.HALF_UP);
} }
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");
VelocitySalesOrderDto payload = VelocitySalesOrderDto.builder()
.currencyCodeString(transaction.getDebitCurrency().name()) List<VelocitySalesOrderDto.ItemDto> items;
.orderDate(LocalDate.now().format(fmt)) if (transaction.getBatchId() != null) {
.dueDate(LocalDate.now().plusDays(7).format(fmt)) // Fetch GroupBatchItems for this batch and summarize by providerLabel
.notes(transaction.getReference()) List<GroupBatchItem> batchItems = groupBatchItemRepository.findAllByGroupBatchId(transaction.getBatchId());
.authorized(Boolean.TRUE) Map<String, BigDecimal> totalsByProvider = batchItems.stream()
.items(Collections.singletonList(VelocitySalesOrderDto.ItemDto.builder() .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()) .name(transaction.getBillName())
.itemCode("VLT-" + transaction.getProviderLabel()) .itemCode("VLT-" + transaction.getProviderLabel())
.qty(BigDecimal.ONE) .qty(BigDecimal.ONE)
.unitPrice(amount) .unitPrice(amount)
.amount(amount) .amount(amount)
.build())) .build());
}
VelocitySalesOrderDto payload = VelocitySalesOrderDto.builder()
.currencyCodeString(transaction.getDebitCurrency().name())
.orderDate(LocalDate.now().format(fmt))
.dueDate(LocalDate.now().plusDays(7).format(fmt))
.notes("Transaction trace - " + transaction.getTrace())
.authorized(Boolean.TRUE)
.items(items)
.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.getCreditTotalAmount())
.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,13 +51,14 @@ 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;
} }
} }
transaction.setCreditCurrency(CurrencyType.USD); transaction.setCreditCurrency(transaction.getDebitCurrency());
transaction.setCreditAmount(transaction.getAmount()); transaction.setCreditAmount(transaction.getAmount());
transaction.setCreditTotalAmount(transaction.getTotalAmount());
transaction.setCreditAccount(Utils.formatPhoneNumber(transaction.getCreditAccount(), "ZW")); transaction.setCreditAccount(Utils.formatPhoneNumber(transaction.getCreditAccount(), "ZW"));
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00"); transaction.setResponseCode("00");

View File

@@ -5,14 +5,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.CurrencyType; 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;
@@ -20,7 +17,6 @@ import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal;
import java.util.*; import java.util.*;
@Service("CONFIRM_ZESA_HOT") @Service("CONFIRM_ZESA_HOT")
@@ -39,27 +35,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");
@@ -82,17 +65,26 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
transaction.setCreditCurrency(CurrencyType.USD); transaction.setCreditCurrency(CurrencyType.USD);
} }
transaction.setCreditAmount(transactionService.calculateCreditAmount(transaction)); transaction.setCreditAmount(transactionService.calculateCurrencyExchangeValue(
transaction.getDebitCurrency(),
transaction.getCreditCurrency(),
transaction.getAmount()));
transaction.setCreditTotalAmount(transactionService.calculateCurrencyExchangeValue(
transaction.getDebitCurrency(),
transaction.getCreditCurrency(),
transaction.getTotalAmount()));
// 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 +96,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 +125,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 +153,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 { try {
Thread.sleep(3);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
logger.info("Simulating HotRecharge success");
return getZesaPayload();
}
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());
// 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%."); 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,9 +35,12 @@ 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);
transaction.setPaymentStatus(Status.FAILED);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage()); transaction.setSystemErrorMessage("Network error: " + e.getMessage());
@@ -63,6 +61,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;
@@ -73,6 +72,7 @@ public class CityWalletRemotePaymentProcessor implements TransactionProcessorInt
} 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);
transaction.setPollingStatus(Status.FAILED);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage()); transaction.setSystemErrorMessage("Network error: " + e.getMessage());

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,10 +40,13 @@ 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);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setPaymentStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage()); transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again."); transaction.setErrorMessage("Payment failed. Please try again.");
@@ -64,6 +66,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;
@@ -75,6 +78,7 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
e.printStackTrace(); e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setPollingStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage()); transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again."); transaction.setErrorMessage("Payment failed. Please try again.");

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,10 +40,15 @@ 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);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setPaymentStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage()); transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again"); transaction.setErrorMessage("Payment failed. Please try again");
@@ -68,6 +63,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.setPollingStatus(Status.SUCCESS);
transaction.setResponseCode("02"); transaction.setResponseCode("02");
transaction.setTargetUrl("/home"); transaction.setTargetUrl("/home");
return transaction; return transaction;
@@ -80,6 +76,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
e.printStackTrace(); e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setPollingStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
transaction.setSystemErrorMessage("Network error: " + e.getMessage()); transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again"); transaction.setErrorMessage("Payment failed. Please try again");

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); String message = "Your Velocity Pay verification code is: " + otp.getOtp();
// LinkedHashMap response = restService.postAs(infobipUrl, string, headers, LinkedHashMap.class); notificationService.sendSms(registerRequest.getPhone(), message);
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());
String message = "Your Velocity Pay verification code is: " + otp.getOtp();
notificationService.sendSms(otpRequest.getPhone(), message);
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
try {
Thread.sleep(pollingInterval);
WorkflowInstance instance = workflowInstanceService.getWorkflowInstance( WorkflowInstance instance = workflowInstanceService.getWorkflowInstance(
workflowId, workflowId,
EnumSet.of(ACTIONS, ACTION_STATE_VARIABLES, CURRENT_STATE_VARIABLES), EnumSet.of(ACTIONS, ACTION_STATE_VARIABLES, CURRENT_STATE_VARIABLES),
1L); 1L);
try {
Thread.sleep(pollingInterval);
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"));
} }
} catch (InterruptedException e) {
future.completeExceptionally(e);
}
timeout -= pollingInterval; timeout -= pollingInterval;
if(timeout <= 0){ if(timeout <= 0){
future.complete(getStateResponse(instance, "Request timed out")); future.complete(getStateResponse(instance, "Request timed out"));
} }
} catch (InterruptedException e) {
future.completeExceptionally(e);
}
} }
return future; return future;

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