Compare commits

...

55 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
Prince
8a9d58fe05 updates to wallet functionality 2026-06-11 23:52:20 +02:00
Prince
0dce7f51dc adding citywallet support 2026-06-11 16:50:41 +02:00
Prince
68ed2650e8 adding citywallet support 2026-06-11 16:34:01 +02:00
Prince
fadb1e3f8d bug fix 2026-06-10 09:50:52 +02:00
Prince
7ded6b2861 removing currency display for meters 2026-06-08 21:36:31 +02:00
24f435a09f completed group batch feature 2026-06-08 09:18:06 +02:00
c87f1e5e23 usability fixes 2026-06-01 22:55:05 +02:00
461dc7e7e4 implement zesa currency swap feature 2026-06-01 20:34:48 +02:00
92276c07d8 bug fixes 2026-06-01 14:54:30 +02:00
cc1169f09c bug fixes 2026-06-01 13:27:57 +02:00
155 changed files with 7702 additions and 800 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

4
.gitignore vendored
View File

@@ -31,4 +31,6 @@ build/
### VS Code ###
.vscode/
logs/
logs/
data/
reports/

View File

@@ -1,7 +1,18 @@
FROM 144.91.121.112:8082/java-17
FROM vkhoza/java-17
RUN mkdir /app
COPY . /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
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]

32
pom.xml
View File

@@ -83,11 +83,6 @@
<artifactId>jakarta.persistence-api</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>com.googlecode.libphonenumber</groupId>
<artifactId>libphonenumber</artifactId>
<version>8.12.10</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
@@ -107,6 +102,11 @@
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-joda</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
@@ -162,12 +162,7 @@
<dependency>
<groupId>com.googlecode.libphonenumber</groupId>
<artifactId>libphonenumber</artifactId>
<version>8.13.36</version> <!-- Use the latest version -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
<version>3.1.5</version>
<version>8.13.36</version>
</dependency>
<!-- Maven -->
<dependency>
@@ -183,7 +178,20 @@
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>
<dependency>
<groupId>org.jobrunr</groupId>
<artifactId>jobrunr-spring-boot-3-starter</artifactId>
<version>8.6.1</version>
</dependency>
<!-- Apache POI for XLSX generation -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
</dependencies>
<build>
<plugins>

View File

@@ -1,17 +1,16 @@
package zw.qantra.tm;
import io.nflow.rest.config.RestConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import com.google.gson.Gson;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@Import(RestConfiguration.class)
@EnableCaching
@EnableAsync
public class TmApplication {
@@ -24,4 +23,14 @@ public class TmApplication {
public Gson 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

@@ -27,4 +27,19 @@ public class DatabaseConfiguration {
.initializeDataSourceBuilder()
.build();
}
// JobRunr DataSource Properties (H2)
@Bean
@ConfigurationProperties(prefix = "jobrunr.datasource")
public DataSourceProperties jobrunrDataSourceProperties() {
return new DataSourceProperties();
}
// JobRunr DataSource using H2
@Bean
public DataSource jobrunrDataSource() {
return jobrunrDataSourceProperties()
.initializeDataSourceBuilder()
.build();
}
}

View File

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

View File

@@ -0,0 +1,22 @@
package zw.qantra.tm.configs;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.file.Path;
@Configuration
public class ReportsResourceConfig implements WebMvcConfigurer {
@Value("${app.reports.storage-path:./reports}")
private String reportsStoragePath;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
Path reportsDir = Path.of(reportsStoragePath).toAbsolutePath();
registry.addResourceHandler("/reports/**")
.addResourceLocations("file:" + reportsDir + "/");
}
}

View File

@@ -30,16 +30,17 @@ public class SecurityConfig {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/configs/seed/**").permitAll()
.requestMatchers("/test/**").permitAll()
.requestMatchers("/reports/**").permitAll()
.requestMatchers("/configs/seed/**").permitAll()
.requestMatchers("/test/**").permitAll()
.requestMatchers("/nflow/**").permitAll()
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/public/**").permitAll()
.requestMatchers("/public/**").permitAll()
.requestMatchers("/explorer/**").permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider(userService))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin(withDefaults());
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

View File

@@ -0,0 +1,57 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.dtos.erp.VelocityAccountDto;
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.WorkspaceService;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/accounts")
@RequiredArgsConstructor
public class AccountController {
private final VelocityAccountService velocityAccountService;
private final WorkspaceService workspaceService;
@GetMapping("/{workspaceId}/balance/{currency}")
public ResponseEntity getAccount(
@PathVariable UUID workspaceId,
@PathVariable String currency
) {
try {
Workspace workspace = workspaceService.getWorkspace(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found"));
String currencyPhoneConcat = currency + workspace.getPhone();
VelocityAccountDto account = velocityAccountService.getAccountByPhone(currencyPhoneConcat);
return ResponseEntity.ok(account);
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/{workspaceId}/statement/{currency}")
public ResponseEntity getStatement(
@PathVariable UUID workspaceId,
@PathVariable String currency,
@RequestParam String startDate,
@RequestParam String endDate) {
try {
Workspace workspace = workspaceService.getWorkspace(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found"));
String currencyPhoneConcat = currency + workspace.getPhone();
VelocityStatementDto statement = velocityAccountService.getStatementByPhone(
currencyPhoneConcat, startDate, endDate);
return ResponseEntity.ok(statement);
} catch (Exception e) {
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

@@ -0,0 +1,129 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal;
import net.kaczmarzyk.spring.data.jpa.domain.EqualIgnoreCase;
import net.kaczmarzyk.spring.data.jpa.web.annotation.And;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.dtos.GroupBatchCreateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchItemUpdateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchUpdateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchTriggerRequest;
import zw.qantra.tm.domain.models.GroupBatch;
import zw.qantra.tm.domain.models.GroupBatchItem;
import zw.qantra.tm.domain.services.BatchReportService;
import zw.qantra.tm.domain.services.GroupBatchService;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/group-batches")
@RequiredArgsConstructor
public class GroupBatchController {
private final GroupBatchService groupBatchService;
private final BatchReportService batchReportService;
@GetMapping
public ResponseEntity findAll(
@And({
@Spec(path = "recipientGroupId", defaultVal = "null", spec = Equal.class),
@Spec(path = "workspaceId", spec = Equal.class),
@Spec(path = "status", spec = Equal.class),
@Spec(path = "currency", spec = Equal.class),
})Specification<GroupBatch> spec, Pageable pageable){
return ResponseEntity.ok(groupBatchService.findAll(spec, pageable));
}
@PostMapping
public ResponseEntity<GroupBatch> create(@RequestBody GroupBatchCreateRequest request) {
return ResponseEntity.ok(groupBatchService.createBatch(request));
}
@PutMapping("/{batchId}")
public ResponseEntity<GroupBatch> update(@PathVariable UUID batchId,
@RequestBody GroupBatchUpdateRequest request) {
request.setId(batchId);
return ResponseEntity.ok(groupBatchService.updateBatch(request));
}
@PostMapping("/{batchId}/confirm")
public ResponseEntity<GroupBatch> confirm(@PathVariable UUID batchId,
@RequestBody GroupBatchTriggerRequest request) {
return ResponseEntity.ok(groupBatchService.triggerConfirm(
batchId,
request.getWorkspaceId(),
request.getIdempotencyKey()));
}
@PostMapping("/{batchId}/request")
public ResponseEntity request(@PathVariable UUID batchId,
@RequestBody GroupBatchTriggerRequest request) {
Object response = groupBatchService.triggerRequest(
batchId,
request.getWorkspaceId(),
request.getAuthType(),
request.getIdempotencyKey());
return ResponseEntity.ok(response);
}
@PostMapping("/{batchId}/poll")
public ResponseEntity poll(@PathVariable UUID batchId,
@RequestBody GroupBatchTriggerRequest request) {
return ResponseEntity.ok(groupBatchService.triggerPoll(
batchId,
request.getWorkspaceId(),
request.getIdempotencyKey()));
}
@GetMapping("/{batchId}")
public ResponseEntity<GroupBatch> getBatch(@PathVariable UUID batchId,
@RequestParam UUID workspaceId) {
return ResponseEntity.ok(groupBatchService.getBatch(batchId, workspaceId));
}
@GetMapping("/items")
public ResponseEntity getBatchItems(
@And({
@Spec(path = "groupBatchId", defaultVal = "null", 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) {
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}")
public ResponseEntity<Void> deleteBatch(@PathVariable UUID batchId,
@RequestParam UUID workspaceId) {
groupBatchService.deleteBatch(batchId, workspaceId);
return ResponseEntity.noContent().build();
}
@GetMapping("/{batchId}/report")
public ResponseEntity<Map<String, Object>> downloadBatchReport(@PathVariable UUID batchId,
@RequestParam UUID workspaceId) {
BatchReportService.BatchReportResult result = batchReportService.generateBatchReport(batchId, workspaceId);
return ResponseEntity.ok(Map.of(
"fileUrl", result.fileUrl(),
"alreadyExisted", result.alreadyExisted()
));
}
}

View File

@@ -1,6 +1,13 @@
package zw.qantra.tm.domain.controllers;
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.web.bind.annotation.*;
import zw.qantra.tm.domain.models.PaymentProcessor;
@@ -18,8 +25,13 @@ public class PaymentProcessorController {
private final PaymentProcessorService paymentProcessorService;
@GetMapping
public ResponseEntity<List<PaymentProcessor>> getAllPaymentProcessors() {
return ResponseEntity.ok(paymentProcessorService.getPaymentProcessorRepository().findAll());
public ResponseEntity getAllPaymentProcessors(
@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}")

View File

@@ -23,7 +23,8 @@ public class ProviderController {
@GetMapping
public ResponseEntity getProviders(
@And({
@Spec(path = "category", spec = Equal.class)
@Spec(path = "category", spec = Equal.class),
@Spec(path = "currency", spec = Equal.class),
}) Specification<Provider> spec, Pageable pageable) {
return ResponseEntity.ok(providerService.findAll(spec, pageable));
}
@@ -33,9 +34,9 @@ public class ProviderController {
return ResponseEntity.ok(providerService.getProviderProducts(id.toString()));
}
@GetMapping("/{id}/products/{productId}")
public ResponseEntity getProducts(@PathVariable UUID id, @PathVariable UUID productId) {
return ResponseEntity.ok(providerService.getProviderProduct(id, productId));
@GetMapping("/{id}/products/{productName}")
public ResponseEntity getProducts(@PathVariable UUID id, @PathVariable String productName) {
return ResponseEntity.ok(providerService.getProviderProduct(id, productName));
}
@GetMapping("/client/{clientId}")
@@ -71,4 +72,10 @@ public class ProviderController {
}
return ResponseEntity.notFound().build();
}
@DeleteMapping
public ResponseEntity evictCache() {
providerService.evictProviderProductsCache();
return ResponseEntity.ok("Cache evicted");
}
}

View File

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

View File

@@ -0,0 +1,117 @@
package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor;
import net.kaczmarzyk.spring.data.jpa.domain.Equal;
import net.kaczmarzyk.spring.data.jpa.domain.EqualIgnoreCase;
import net.kaczmarzyk.spring.data.jpa.web.annotation.And;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec;
import org.hibernate.query.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.RecipientGroupCreateRequest;
import zw.qantra.tm.domain.dtos.RecipientGroupUpdateRequest;
import zw.qantra.tm.domain.models.RecipientGroup;
import zw.qantra.tm.domain.models.RecipientGroupMember;
import zw.qantra.tm.domain.services.RecipientGroupService;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/recipient-groups")
@RequiredArgsConstructor
public class RecipientGroupController {
private final RecipientGroupService recipientGroupService;
@GetMapping
public ResponseEntity findAll(
@And({
@Spec(path = "name", spec = EqualIgnoreCase.class),
@Spec(path = "workspaceId", defaultVal = "null", spec = Equal.class)
})Specification<RecipientGroup> spec, Pageable pageable) {
return ResponseEntity.ok(recipientGroupService.findAll(spec, pageable));
}
@PostMapping
public ResponseEntity<RecipientGroup> create(@RequestBody RecipientGroupCreateRequest request) {
RecipientGroup group = RecipientGroup.builder()
.name(request.getName())
.description(request.getDescription())
.workspaceId(request.getWorkspaceId())
.userId(request.getUserId())
.build();
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}")
public ResponseEntity<RecipientGroup> update(@PathVariable UUID groupId,
@RequestBody RecipientGroupUpdateRequest request) {
return ResponseEntity.ok(recipientGroupService.update(
groupId,
request.getWorkspaceId(),
request.getName(),
request.getDescription()));
}
@DeleteMapping("/delete/{groupId}")
public ResponseEntity<Void> delete(@PathVariable UUID groupId,
@RequestParam UUID workspaceId) {
recipientGroupService.delete(groupId, workspaceId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{groupId}/members")
public ResponseEntity<List<RecipientGroupMember>> addMembersByIds(@PathVariable UUID groupId,
@RequestBody GroupMemberRequest request) {
return ResponseEntity.ok(recipientGroupService.addMembersByIds(
groupId,
request.getWorkspaceId(),
request.getRecipientIds()));
}
@DeleteMapping("/{groupId}/members/{recipientId}")
public ResponseEntity<Void> removeMember(@PathVariable UUID groupId,
@PathVariable UUID recipientId,
@RequestParam UUID workspaceId) {
recipientGroupService.removeMember(groupId, recipientId, workspaceId);
return ResponseEntity.noContent().build();
}
@GetMapping("/members")
public ResponseEntity members(
@And({
@Spec(path = "recipientGroupId", spec = Equal.class),
}) Specification<RecipientGroupMember> spec, Pageable pageable) {
return ResponseEntity.ok(recipientGroupService.findAllMembers(spec, pageable));
}
}

View File

@@ -10,16 +10,18 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.enums.RequestType;
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.WorkspaceMigrationService;
@RestController
@RequestMapping("/test")
@CrossOrigin(origins = "*")
@RequiredArgsConstructor
public class TestController {
private final EmailService emailService;
private final NotificationService notificationService;
private final TransactionService transactionService;
private final WorkspaceMigrationService workspaceMigrationService;
private static final Logger logger = LoggerFactory.getLogger(TestController.class);
@@ -44,7 +46,7 @@ public class TestController {
@GetMapping("/hello/{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!");
}
@@ -59,4 +61,10 @@ public class TestController {
public ResponseEntity<String> publicEndpoint() {
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 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.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.Spec;
import net.kaczmarzyk.spring.data.jpa.web.annotation.Conjunction;
@@ -27,6 +30,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.services.processors.payments.CityWalletRemotePaymentProcessor;
import zw.qantra.tm.domain.services.processors.workflows.TransactionWorkflow;
import zw.qantra.tm.domain.services.processors.workflows.WorkflowUtils;
import zw.qantra.tm.utils.LogUtils;
@@ -48,13 +52,15 @@ public class TransactonController {
private final WorkflowInstanceFactory workflowInstanceFactory;
private final WorkflowUtils workflowUtils;
private final ObjectStringMapper objectStringMapper;
private final CityWalletRemotePaymentProcessor cityWalletRemotePaymentProcessor;
@GetMapping
public ResponseEntity<Page<Transaction>> getAll(
@Conjunction(value = {
@Or({
@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 = "billClientId", spec = Equal.class),
@Spec(path = "productUid", spec = Equal.class),
@@ -70,20 +76,22 @@ public class TransactonController {
@Spec(path = "amount", spec = Equal.class),
@Spec(path = "reference", spec = Equal.class),
@Spec(path = "totalAmount", spec = Equal.class),
@Spec(path = "authType", spec = Equal.class),
@Spec(path = "partyType", params = "partyType", paramSeparator = ',', spec = In.class),
@Spec(path = "sdkActionId", spec = Equal.class),
@Spec(path = "paymentProcessorLabel", spec = Equal.class),
})
}, and = {
@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) {
return ResponseEntity.ok(transactionService.findAll(specification, pageable));
}
@GetMapping("/{id}")
public ResponseEntity<Transaction> getById(@PathVariable UUID id) {
return ResponseEntity.ok(transactionService.findById(id));
public ResponseEntity<Transaction> getById(
@PathVariable UUID id,
@RequestParam UUID workspaceId) {
return ResponseEntity.ok(transactionService.getTransaction(id, workspaceId));
}
@GetMapping("/integration/{id}")
@@ -190,8 +198,8 @@ public class TransactonController {
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(TransactionWorkflow.TYPE)
.setExternalId(transaction.getUserId() + "-" + new Instant().getMillis())
.setBusinessKey(transaction.getUserId())
.setExternalId(transaction.getWorkspaceId() + "-" + new Instant().getMillis())
.setBusinessKey(transaction.getWorkspaceId().toString())
.putStateVariable("confirmation", Utils.toJson(transaction))
.setNextActivation(DateTime.now())
.build();
@@ -206,6 +214,16 @@ public class TransactonController {
return ResponseEntity.ok(response);
}
@PutMapping("/{id}/velocity-otp")
public ResponseEntity<Transaction> updateOtp(@PathVariable UUID id, @RequestBody Transaction transaction) {
Transaction fetchTransaction = transactionService.findById(id);
if (fetchTransaction == null) {
return ResponseEntity.notFound().build();
}
fetchTransaction.setVerificationCode(transaction.getVerificationCode());
return ResponseEntity.ok(cityWalletRemotePaymentProcessor.updateVelocityTransactionOtp(fetchTransaction));
}
@PutMapping("/{id}")
public ResponseEntity<Transaction> update(@PathVariable UUID id, @RequestBody Transaction transaction) {
if (!transactionService.getTransactionRepository().existsById(id)) {

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

@@ -0,0 +1,24 @@
package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import zw.qantra.tm.domain.models.Transaction;
import java.math.BigDecimal;
import java.util.UUID;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupBatchCreateRequest {
private UUID recipientGroupId;
private UUID workspaceId;
private String userId;
private String description;
private String requestedBy;
private String currency;
private BigDecimal amount;
private String paymentProcessorLabel;
private String paymentProcessorName;
private String paymentProcessorImage;
private Transaction transactionTemplate;
}

View File

@@ -0,0 +1,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

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

View File

@@ -0,0 +1,23 @@
package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import zw.qantra.tm.domain.models.Transaction;
import java.math.BigDecimal;
import java.util.UUID;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupBatchUpdateRequest {
private UUID id;
private UUID workspaceId;
private String description;
private String requestedBy;
private String currency;
private BigDecimal amount;
private String paymentProcessorLabel;
private String paymentProcessorName;
private String paymentProcessorImage;
private Transaction transactionTemplate;
}

View File

@@ -0,0 +1,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

@@ -0,0 +1,15 @@
package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.util.List;
import java.util.UUID;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupMemberRequest {
private UUID workspaceId;
private List<UUID> recipientIds;
}

View File

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

View File

@@ -0,0 +1,19 @@
package zw.qantra.tm.domain.dtos;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import zw.qantra.tm.domain.models.Recipient;
import java.util.List;
import java.util.UUID;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RecipientGroupCreateRequest {
private String name;
private String description;
private String userId;
private UUID workspaceId;
private List<Recipient> recipients;
}

View File

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

View File

@@ -21,8 +21,6 @@ public class RegisterRequest {
@NotBlank(message = "First name is required")
private String firstName;
@NotBlank(message = "Last name is required")
private String lastName;
@NotBlank(message = "Phone is required")

View File

@@ -0,0 +1,33 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class VelocityAccountDto {
private String name;
private String description;
private String accountNumber;
private String alternateAccountNumber;
private String parentAccountId;
private String type;
private String category;
private String ledger;
private BigDecimal balance;
private String partyType;
private String party;
private Object currency;
private String customerStringId;
private String companyStringId;
}

View File

@@ -0,0 +1,54 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class VelocityStatementDto {
private BigDecimal startBalance;
private BigDecimal endBalance;
private List<StatementItem> transactions;
private String accountName;
private String accountNumber;
private String startDate;
private String endDate;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class StatementItem {
private String name;
private String narration;
private String party;
private String company;
private String notes;
private Timestamp postingDate;
private BigDecimal amount;
private String account;
private String accountName;
private String currency;
private String reference;
private TransactionSide type;
private BigDecimal balance;
}
public enum TransactionSide {
DEBIT, CREDIT
}
}

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

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
package zw.qantra.tm.domain.enums;
public enum GroupBatchStatus {
CREATED,
CONFIRMING,
CONFIRMED_ALL,
CONFIRMED_PARTIAL,
CONFIRM_FAILED,
REQUESTING,
REQUESTED,
REQUEST_FAILED,
POLLING,
POLL_SUCCESS,
POLL_FAILED,
INTEGRATING,
COMPLETED,
PARTIAL,
FAILED
}

View File

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

View File

@@ -1,12 +1,7 @@
package zw.qantra.tm.domain.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -17,15 +12,21 @@ import zw.qantra.tm.domain.enums.RequestType;
@Getter
@Setter
@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
@AllArgsConstructor
@NoArgsConstructor
public class AdditionalData extends BaseEntity {
@Column(name = "transaction_id")
private String transactionId;
@Column(columnDefinition = "TEXT")
private String jsonString;
@Enumerated(EnumType.STRING)
@Column(name = "request_type")
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;
import jakarta.persistence.Entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.SQLRestriction;
@Entity
@Table(name = "category", indexes = {
@Index(name = "idx_category_name", columnList = "name"),
})
@Setter
@Getter
@Builder

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;
import zw.qantra.tm.domain.enums.GroupBatchStatus;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
@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
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@SQLRestriction("deleted = false")
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupBatch extends BaseEntity {
@Column(name = "recipient_group_id")
private UUID recipientGroupId;
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId;
private String description;
private String requestedBy;
private String currency;
@Enumerated(EnumType.STRING)
private GroupBatchStatus status;
@Column(name = "payment_reference")
private String paymentReference;
@Column(name = "payment_transaction_id")
private UUID paymentTransactionId;
@Column(name = "workflow_id")
private Long workflowId;
private String confirmIdempotencyKey;
private String requestIdempotencyKey;
private String pollIdempotencyKey;
private String paymentProcessorLabel;
private String paymentProcessorName;
private String paymentProcessorImage;
@Column(columnDefinition = "TEXT")
private String resultFileUrl;
private LocalDateTime confirmStartedAt;
private LocalDateTime requestStartedAt;
private LocalDateTime pollStartedAt;
private LocalDateTime integrationStartedAt;
private LocalDateTime completedAt;
@Column(columnDefinition = "TEXT")
private String transactionTemplate;
private Integer count;
private BigDecimal value;
private Integer successfulCount;
private Integer failedCount;
}

View File

@@ -0,0 +1,70 @@
package zw.qantra.tm.domain.models;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;
import zw.qantra.tm.domain.enums.GroupBatchItemConfirmStatus;
import zw.qantra.tm.domain.enums.GroupBatchItemIntegrationStatus;
import zw.qantra.tm.domain.enums.GroupBatchItemStatus;
import java.math.BigDecimal;
import java.util.UUID;
@Entity
@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
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@SQLRestriction("deleted = false")
public class GroupBatchItem extends BaseEntity {
@Column(name = "group_batch_id")
private UUID groupBatchId;
@Column(name = "recipient_id")
private UUID recipientId;
@Column(name = "recipient_name")
private String recipientName;
@Column(name = "recipient_phone")
private String recipientPhone;
private String recipientAccount;
private BigDecimal amount;
@Enumerated(EnumType.STRING)
private GroupBatchItemStatus status;
@Enumerated(EnumType.STRING)
@Column(name = "confirm_status")
private GroupBatchItemConfirmStatus confirmStatus;
@Enumerated(EnumType.STRING)
@Column(name = "integration_status")
private GroupBatchItemIntegrationStatus integrationStatus;
@Column(columnDefinition = "TEXT")
private String confirmError;
@Column(columnDefinition = "TEXT")
private String integrationError;
@ManyToOne
@JoinColumn(name = "transaction_id")
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;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import zw.qantra.tm.domain.enums.Status;
@Entity
@Table(name = "otp", indexes = {
@Index(name = "idx_otp_username_type", columnList = "username, otp_type"),
})
@Getter
@Setter
@Builder
@@ -15,8 +17,11 @@ import zw.qantra.tm.domain.enums.Status;
@NoArgsConstructor
public class Otp extends BaseEntity{
private String username;
@JsonIgnore
private String otp;
@Enumerated(EnumType.STRING)
@Column(name = "otp_status")
private Status otpStatus;
@Column(name = "otp_type")
private String otpType;
}

View File

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

View File

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

View File

@@ -1,23 +1,40 @@
package zw.qantra.tm.domain.models;
import jakarta.persistence.Entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.hibernate.annotations.SQLRestriction;
import java.util.UUID;
@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
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@SQLRestriction("deleted = false")
public class Recipient extends BaseEntity {
private String name;
private String email;
@Column(name = "phone_number")
private String phoneNumber;
private String address;
private String account;
private String initials;
@Column(name = "latest_provider_label")
private String latestProviderLabel;
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId;
}

View File

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

View File

@@ -0,0 +1,40 @@
package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;
import java.util.UUID;
@Entity
@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
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@SQLRestriction("deleted = false")
public class RecipientGroupMember extends BaseEntity {
@Column(name = "recipient_group_id")
private UUID recipientGroupId;
@ManyToOne
@JoinColumn(name = "recipient_id")
private Recipient recipient;
@Column(name = "recipient_uid")
private UUID recipientUid;
private String account;
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId;
}

View File

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

View File

@@ -1,28 +1,47 @@
package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.data.domain.Persistable;
import zw.qantra.tm.domain.enums.AuthType;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.enums.*;
import java.math.BigDecimal;
import java.util.UUID;
@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
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
@SQLRestriction("deleted = false")
public class Transaction extends BaseEntity {
@Column(name = "workspace_id")
private UUID workspaceId;
@Column(name = "user_id")
private String userId;
private String username;
private String trace;
private String region;
@Enumerated(EnumType.STRING)
private PartyType partyType;
private BigDecimal amount;
private BigDecimal charge;
private BigDecimal gatewayCharge;
@@ -38,6 +57,7 @@ public class Transaction extends BaseEntity {
private String rrn;
private String channelName;
private String channel;
@Column(name = "debit_phone")
private String debitPhone;
private String debitAccount;
@Enumerated(EnumType.STRING)
@@ -46,14 +66,19 @@ public class Transaction extends BaseEntity {
private String debitCard;
private String debitRef;
private String debitEmail;
@Column(name = "credit_phone")
private String creditPhone;
private String creditAccount;
@Enumerated(EnumType.STRING)
private CurrencyType creditCurrency;
private BigDecimal creditAmount;
private BigDecimal creditTotalAmount;
private String creditName;
private String creditCard;
private String creditRef;
private String creditEmail;
private String creditAddress;
private String creditVoucher;
private String billClientId;
private String clientSecret;
private String aggregatorId;
@@ -63,24 +88,30 @@ public class Transaction extends BaseEntity {
private String paymentProcessorName;
private String providerImage;
private String paymentProcessorImage;
private UUID batchId;
private String batchDescription;
@Enumerated(EnumType.STRING)
private Status authorizationStatus;
@Enumerated(EnumType.STRING)
@Column(name = "confirmation_status")
private Status confirmationStatus;
@Enumerated(EnumType.STRING)
@Column(name = "payment_status")
private Status paymentStatus;
@Enumerated(EnumType.STRING)
@Column(name = "integration_status")
private Status integrationStatus;
@Enumerated(EnumType.STRING)
private Status reversalStatus;
@Enumerated(EnumType.STRING)
@Column(name = "polling_status")
private Status pollingStatus;
private String orderId;
private String orderTrace;
private String orderTransactionTrace;
private String orderTransactionId;
private String erpSalesRef;
private String erpJournalRef;
@@ -89,7 +120,7 @@ public class Transaction extends BaseEntity {
private String erpPurchasePaymentRef;
private String erpCommissionJournalRef;
@Column(nullable = true)
@Column(name = "workflow_id", nullable = true)
private long workflowId;
@Enumerated(EnumType.STRING)
@@ -107,6 +138,8 @@ public class Transaction extends BaseEntity {
@Column(columnDefinition = "TEXT")
private String targetUrl;
private String sdkActionId;
private String verificationCode;
private Integer retries;
@Transient
private Object additionalData;

View File

@@ -2,10 +2,7 @@ package zw.qantra.tm.domain.models;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.EnumType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -14,6 +11,9 @@ import lombok.Setter;
import zw.qantra.tm.domain.enums.Status;
@Entity
@Table(name = "transaction_event", indexes = {
@Index(name = "idx_txn_event_txn_id", columnList = "transaction_id"),
})
@Getter
@Setter
@Builder
@@ -21,6 +21,7 @@ import zw.qantra.tm.domain.enums.Status;
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class TransactionEvent extends BaseEntity {
@Column(name = "transaction_id")
private String transactionId;
private String event;
@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;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -9,10 +10,14 @@ 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
@Table(name = "users")
@Table(name = "users", indexes = {
@Index(name = "idx_user_phone", columnList = "phone"),
})
@Data
@EqualsAndHashCode(callSuper = true)
@SQLRestriction("deleted = false")
@@ -20,7 +25,8 @@ public class User extends BaseEntity implements UserDetails {
@Column(unique = true, nullable = false)
private String username;
@JsonIgnore
@Column(nullable = false)
private String password;
@@ -48,6 +54,10 @@ public class User extends BaseEntity implements UserDetails {
@Column(name = "is_credentials_non_expired")
private boolean credentialsNonExpired = true;
@JsonIgnore
@ManyToMany(mappedBy = "users", fetch = FetchType.LAZY)
private Set<Workspace> workspaces = new HashSet<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {

View File

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

View File

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

View File

@@ -15,4 +15,5 @@ public interface ChargeRepository extends JpaRepository<Charge, UUID> {
List<Charge> findAllByCurrency(CurrencyType currency);
boolean existsByChargeLabelAndCurrency(ChargeLabelEnum chargeLabel, CurrencyType currency);
boolean existsByChargeLabelAndCurrencyAndPercentageRate(ChargeLabelEnum chargeLabel, CurrencyType currency, BigDecimal percentageRate);
boolean existsByChargeLabelAndCurrencyAndDescription(ChargeLabelEnum chargeLabel, CurrencyType currency, String description);
}

View File

@@ -4,8 +4,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.Currency;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface CurrencyRepository extends JpaRepository<Currency, UUID> {
}
Optional<Currency> findByIsoCode(String isoCode);
}

View File

@@ -0,0 +1,20 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.enums.GroupBatchItemConfirmStatus;
import zw.qantra.tm.domain.enums.GroupBatchItemIntegrationStatus;
import zw.qantra.tm.domain.models.GroupBatchItem;
import java.util.List;
import java.util.UUID;
@Repository
public interface GroupBatchItemRepository extends JpaRepository<GroupBatchItem, UUID>, JpaSpecificationExecutor<GroupBatchItem> {
List<GroupBatchItem> findAllByGroupBatchId(UUID groupBatchId);
List<GroupBatchItem> findAllByGroupBatchIdAndConfirmStatus(UUID groupBatchId, GroupBatchItemConfirmStatus status);
long countByGroupBatchIdAndConfirmStatus(UUID groupBatchId, GroupBatchItemConfirmStatus status);
long countByGroupBatchIdAndIntegrationStatus(UUID groupBatchId, GroupBatchItemIntegrationStatus status);
}

View File

@@ -0,0 +1,17 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.GroupBatch;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface GroupBatchRepository extends JpaRepository<GroupBatch, UUID>, JpaSpecificationExecutor<GroupBatch> {
Optional<GroupBatch> findByIdAndWorkspaceId(UUID id, UUID workspaceId);
List<GroupBatch> findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
}

View File

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

View File

@@ -0,0 +1,18 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.RecipientGroupMember;
import java.util.List;
import java.util.UUID;
@Repository
public interface RecipientGroupMemberRepository extends JpaRepository<RecipientGroupMember, UUID>, JpaSpecificationExecutor<RecipientGroupMember> {
List<RecipientGroupMember> findAllByRecipientGroupId(UUID recipientGroupId);
boolean existsByRecipientGroupIdAndRecipientId(UUID recipientGroupId, UUID recipientId);
void deleteAllByRecipientGroupId(UUID recipientGroupId);
List<RecipientGroupMember> findAllByWorkspaceIdIsNullAndUserIdIsNotNull();
}

View File

@@ -0,0 +1,18 @@
package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.RecipientGroup;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface RecipientGroupRepository extends JpaRepository<RecipientGroup, UUID>, JpaSpecificationExecutor<RecipientGroup> {
List<RecipientGroup> findAllByWorkspaceId(UUID workspaceId);
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> findByEmailContainingIgnoreCase(String email);
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> {
Transaction findByTraceAndType(String trace, RequestType type);
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(
Status s1, Status s2, Status s3);
List<Transaction> findAllByConfirmationStatusAndIntegrationStatus(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;
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.User;
@@ -8,7 +9,7 @@ import java.util.Optional;
import java.util.UUID;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
Optional<User> findById(UUID uid);
Optional<User> findByUsername(String username);
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

@@ -0,0 +1,312 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.enums.GroupBatchItemStatus;
import zw.qantra.tm.domain.enums.GroupBatchStatus;
import zw.qantra.tm.domain.models.GroupBatch;
import zw.qantra.tm.domain.models.GroupBatchItem;
import zw.qantra.tm.domain.repositories.GroupBatchItemRepository;
import zw.qantra.tm.domain.repositories.GroupBatchRepository;
import zw.qantra.tm.exceptions.ApiException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class BatchReportService {
private static final Set<GroupBatchStatus> REPORTABLE_STATUSES = Set.of(
GroupBatchStatus.COMPLETED,
GroupBatchStatus.PARTIAL,
GroupBatchStatus.FAILED
);
private static final String REPORT_FILENAME_PREFIX = "batch-report-";
private static final String REPORT_FILENAME_SUFFIX = ".xlsx";
private static final short DATA_FONT_SIZE = 12;
private static final short HEADER_FONT_SIZE = 14;
private static final short TITLE_FONT_SIZE = 16;
@Value("${app.reports.storage-path:./reports}")
private String reportsStoragePath;
@Value("${app.base-url:http://localhost:6950}")
private String baseUrl;
private final GroupBatchRepository groupBatchRepository;
private final GroupBatchItemRepository groupBatchItemRepository;
@Transactional
public BatchReportResult generateBatchReport(UUID batchId, UUID workspaceId) {
GroupBatch batch = groupBatchRepository.findByIdAndWorkspaceId(batchId, workspaceId)
.orElseThrow(() -> new ApiException("Group batch not found"));
if (!REPORTABLE_STATUSES.contains(batch.getStatus())) {
throw new ApiException(
"Batch report can only be generated for completed batches. Current status: " + batch.getStatus()
);
}
// If report already exists, return the existing file URL
if (batch.getResultFileUrl() != null && !batch.getResultFileUrl().isEmpty()) {
return new BatchReportResult(batch.getResultFileUrl(), true);
}
List<GroupBatchItem> items = groupBatchItemRepository.findAllByGroupBatchId(batch.getId());
byte[] fileBytes;
try (Workbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("Batch Report");
// Styles
CellStyle headerStyle = workbook.createCellStyle();
headerStyle.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerStyle.setFont(createHeaderFont(workbook));
headerStyle.setAlignment(HorizontalAlignment.CENTER);
headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle titleStyle = workbook.createCellStyle();
titleStyle.setFont(createDataFont(workbook));
titleStyle.setAlignment(HorizontalAlignment.CENTER);
titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle dataStyle = workbook.createCellStyle();
dataStyle.setFont(createDataFont(workbook));
dataStyle.setAlignment(HorizontalAlignment.CENTER);
dataStyle.setVerticalAlignment(VerticalAlignment.CENTER);
dataStyle.setBorderTop(BorderStyle.THIN);
dataStyle.setBorderBottom(BorderStyle.THIN);
dataStyle.setBorderLeft(BorderStyle.THIN);
dataStyle.setBorderRight(BorderStyle.THIN);
CellStyle currencyStyle = workbook.createCellStyle();
currencyStyle.cloneStyleFrom(dataStyle);
currencyStyle.setDataFormat(workbook.createDataFormat().getFormat("#,##0.00"));
CellStyle statusSuccessStyle = workbook.createCellStyle();
statusSuccessStyle.cloneStyleFrom(dataStyle);
statusSuccessStyle.setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex());
statusSuccessStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
CellStyle statusFailedStyle = workbook.createCellStyle();
statusFailedStyle.cloneStyleFrom(dataStyle);
statusFailedStyle.setFillForegroundColor(IndexedColors.ROSE.getIndex());
statusFailedStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
CellStyle statusPendingStyle = workbook.createCellStyle();
statusPendingStyle.cloneStyleFrom(dataStyle);
statusPendingStyle.setFillForegroundColor(IndexedColors.LIGHT_YELLOW.getIndex());
statusPendingStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
int rowNum = 0;
// Title row
Row titleRow = sheet.createRow(rowNum++);
Cell titleCell = titleRow.createCell(0);
titleCell.setCellValue("Batch Report - " + (batch.getDescription() != null ? batch.getDescription() : batch.getId().toString()));
titleCell.setCellStyle(titleStyle);
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 11));
titleRow.setHeightInPoints(30);
rowNum++;
// Batch summary
writeSummaryRow(sheet, rowNum++, "Status:", batch.getStatus().name(), dataStyle);
writeSummaryRow(sheet, rowNum++, "Total Items:", String.valueOf(batch.getCount() != null ? batch.getCount() : 0), dataStyle);
writeSummaryRow(sheet, rowNum++, "Successful:", String.valueOf(batch.getSuccessfulCount() != null ? batch.getSuccessfulCount() : 0), dataStyle);
writeSummaryRow(sheet, rowNum++, "Failed:", String.valueOf(batch.getFailedCount() != null ? batch.getFailedCount() : 0), dataStyle);
if (batch.getCompletedAt() != null) {
writeSummaryRow(sheet, rowNum++, "Completed At:",
batch.getCompletedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), dataStyle);
}
writeSummaryRow(sheet, rowNum++, "Currency:", batch.getCurrency() != null ? batch.getCurrency() : "N/A", dataStyle);
if (batch.getValue() != null) {
writeSummaryRow(sheet, rowNum++, "Total Value:", batch.getValue().toString(), dataStyle);
}
// Blank row
rowNum++;
// Header row
String[] headers = {
"#", "Recipient Name", "Phone", "Amount",
"Status", "Confirm Status", "Integration Status",
"Transaction Ref", "Confirm Error", "Integration Error",
"Credit Address", "Credit Voucher"
};
Row headerRow = sheet.createRow(rowNum++);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
headerRow.setHeightInPoints(25);
// Data rows
int index = 1;
for (GroupBatchItem item : items) {
Row row = sheet.createRow(rowNum++);
Cell numCell = row.createCell(0);
numCell.setCellValue(index++);
numCell.setCellStyle(dataStyle);
Cell nameCell = row.createCell(1);
nameCell.setCellValue(item.getRecipientName() != null ? item.getRecipientName() : "");
nameCell.setCellStyle(dataStyle);
Cell phoneCell = row.createCell(2);
phoneCell.setCellValue(item.getRecipientPhone() != null ? item.getRecipientPhone() : "");
phoneCell.setCellStyle(dataStyle);
Cell amountCell = row.createCell(3);
amountCell.setCellValue(item.getAmount() != null ? item.getAmount().doubleValue() : 0.0);
amountCell.setCellStyle(currencyStyle);
Cell statusCell = row.createCell(4);
statusCell.setCellValue(item.getStatus() != null ? item.getStatus().name() : "");
if (item.getStatus() == GroupBatchItemStatus.INTEGRATED) {
statusCell.setCellStyle(statusSuccessStyle);
} else if (item.getStatus() == GroupBatchItemStatus.FAILED) {
statusCell.setCellStyle(statusFailedStyle);
} else {
statusCell.setCellStyle(statusPendingStyle);
}
Cell confirmStatusCell = row.createCell(5);
confirmStatusCell.setCellValue(item.getConfirmStatus() != null ? item.getConfirmStatus().name() : "");
confirmStatusCell.setCellStyle(dataStyle);
Cell integrationStatusCell = row.createCell(6);
integrationStatusCell.setCellValue(item.getIntegrationStatus() != null ? item.getIntegrationStatus().name() : "");
integrationStatusCell.setCellStyle(dataStyle);
Cell refCell = row.createCell(7);
if (item.getTransaction() != null && item.getTransaction().getReference() != null) {
refCell.setCellValue(item.getTransaction().getReference());
} else {
refCell.setCellValue("");
}
refCell.setCellStyle(dataStyle);
Cell confirmErrCell = row.createCell(8);
confirmErrCell.setCellValue(item.getConfirmError() != null ? item.getConfirmError() : "");
confirmErrCell.setCellStyle(dataStyle);
Cell integrationErrCell = row.createCell(9);
integrationErrCell.setCellValue(item.getIntegrationError() != null ? item.getIntegrationError() : "");
integrationErrCell.setCellStyle(dataStyle);
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
for (int i = 0; i < headers.length; i++) {
sheet.autoSizeColumn(i);
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
fileBytes = outputStream.toByteArray();
} catch (IOException e) {
throw new RuntimeException("Failed to generate batch report", e);
}
// Save to disk
String filename = REPORT_FILENAME_PREFIX + batchId + REPORT_FILENAME_SUFFIX;
Path reportsDir = Path.of(reportsStoragePath).toAbsolutePath();
Path filePath = reportsDir.resolve(filename);
try {
Files.createDirectories(reportsDir);
try (OutputStream os = Files.newOutputStream(filePath)) {
os.write(fileBytes);
}
} catch (IOException e) {
throw new RuntimeException("Failed to persist batch report file", e);
}
// Persist the URL
String fileUrl = baseUrl + "/api/reports/" + filename;
batch.setResultFileUrl(fileUrl);
groupBatchRepository.save(batch);
return new BatchReportResult(fileUrl, false, fileBytes);
}
private void writeSummaryRow(Sheet sheet, int rowNum, String label, String value, CellStyle style) {
Row row = sheet.getRow(rowNum);
if (row == null) {
row = sheet.createRow(rowNum);
}
Cell labelCell = row.createCell(0);
labelCell.setCellValue(label);
labelCell.setCellStyle(style);
Cell valueCell = row.createCell(1);
valueCell.setCellValue(value);
valueCell.setCellStyle(style);
}
private Font createHeaderFont(Workbook workbook) {
Font font = workbook.createFont();
font.setBold(true);
font.setFontHeightInPoints(HEADER_FONT_SIZE);
font.setColor(IndexedColors.WHITE.getIndex());
return font;
}
private Font createDataFont(Workbook workbook) {
Font font = workbook.createFont();
font.setFontHeightInPoints(DATA_FONT_SIZE);
font.setColor(IndexedColors.BLACK.getIndex());
return font;
}
/**
* Result of a batch report generation request.
*/
public record BatchReportResult(String fileUrl, boolean alreadyExisted, byte... fileBytes) {
public BatchReportResult(String fileUrl, boolean alreadyExisted) {
this(fileUrl, alreadyExisted, (byte[]) null);
}
public boolean isNewlyGenerated() {
return fileBytes != null && fileBytes.length > 0;
}
}
}

View File

@@ -0,0 +1,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

@@ -2,7 +2,9 @@ package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.models.Currency;
import zw.qantra.tm.domain.repositories.CurrencyRepository;
import zw.qantra.tm.exceptions.ApiException;
@Service
@RequiredArgsConstructor
@@ -12,4 +14,9 @@ public class CurrencyService {
public CurrencyRepository getCurrencyRepository() {
return currencyRepository;
}
}
public Currency findCurrencyByIsoCode(String isoCode) {
return currencyRepository.findByIsoCode(isoCode)
.orElseThrow(() -> new ApiException("Currency not found for ISO code: " + isoCode));
}
}

View File

@@ -1,19 +1,31 @@
package zw.qantra.tm.domain.services;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
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
public class EmailService {
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
@Autowired
private JavaMailSender emailSender;
@Async
@Deprecated
public void sendSimpleMessage(
String to, String subject, String text) {
log.warn("EmailService.sendSimpleMessage() is deprecated. Use NotificationService instead.");
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("noreply@qantra.co.zw");
message.setTo(to);

View File

@@ -0,0 +1,442 @@
package zw.qantra.tm.domain.services;
import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.instance.WorkflowInstance;
import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import io.nflow.engine.workflow.instance.WorkflowInstanceFactory;
import lombok.RequiredArgsConstructor;
import org.joda.time.DateTime;
import org.joda.time.Instant;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.dtos.GroupBatchCreateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchItemUpdateRequest;
import zw.qantra.tm.domain.dtos.GroupBatchUpdateRequest;
import zw.qantra.tm.domain.dtos.StateResponse;
import zw.qantra.tm.domain.enums.*;
import zw.qantra.tm.domain.models.*;
import zw.qantra.tm.domain.repositories.GroupBatchItemRepository;
import zw.qantra.tm.domain.repositories.GroupBatchRepository;
import zw.qantra.tm.domain.repositories.RecipientRepository;
import zw.qantra.tm.domain.services.processors.workflows.BatchWorkflow;
import zw.qantra.tm.domain.services.processors.workflows.WorkflowUtils;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@Service
@RequiredArgsConstructor
public class GroupBatchService {
private static final String BATCH_WORKFLOW_TYPE = "batchWorkflow";
private static final Set<GroupBatchStatus> UPDATABLE_STATUSES = Set.of(
GroupBatchStatus.CREATED,
GroupBatchStatus.CONFIRM_FAILED
);
private static final Set<GroupBatchStatus> DELETABLE_STATUSES = Set.of(
GroupBatchStatus.CREATED,
GroupBatchStatus.CONFIRMING,
GroupBatchStatus.CONFIRMED_ALL,
GroupBatchStatus.CONFIRMED_PARTIAL,
GroupBatchStatus.CONFIRM_FAILED
);
private final RecipientGroupService recipientGroupService;
private final RecipientRepository recipientRepository;
private final GroupBatchRepository groupBatchRepository;
private final GroupBatchItemRepository groupBatchItemRepository;
private final WorkflowInstanceFactory workflowInstanceFactory;
private final WorkflowInstanceService workflowInstanceService;
private final WorkflowUtils workflowUtils;
private final TransactionService transactionService;
@Transactional
public GroupBatch createBatch(GroupBatchCreateRequest request) {
RecipientGroup group = recipientGroupService.get(
request.getRecipientGroupId(), request.getWorkspaceId());
List<RecipientGroupMember> members = recipientGroupService.members(
group.getId(), request.getWorkspaceId());
if (members.isEmpty()) {
throw new ApiException("Recipient group has no members");
}
GroupBatch batch = GroupBatch.builder()
.recipientGroupId(group.getId())
.userId(request.getUserId())
.workspaceId(request.getWorkspaceId())
.description(request.getDescription())
.requestedBy(request.getRequestedBy())
.currency(request.getCurrency())
.status(GroupBatchStatus.CREATED)
.paymentProcessorLabel(request.getPaymentProcessorLabel())
.paymentProcessorName(request.getPaymentProcessorName())
.paymentProcessorImage(request.getPaymentProcessorImage())
.transactionTemplate(Utils.toJson(request.getTransactionTemplate()))
.build();
GroupBatch savedBatch = groupBatchRepository.save(batch);
BigDecimal defaultAmount = request.getAmount() == null
? request.getTransactionTemplate().getAmount()
: request.getAmount();
BigDecimal totalValue = BigDecimal.ZERO;
int itemCount = 0;
for (RecipientGroupMember member : members) {
Recipient recipient = recipientRepository.findById(member.getRecipientUid())
.orElseThrow(() -> new ApiException("Recipient not found: " + member.getRecipientUid()));
GroupBatchItem item = GroupBatchItem.builder()
.groupBatchId(savedBatch.getId())
.recipientId(recipient.getId())
.recipientName(recipient.getName())
.recipientPhone(recipient.getPhoneNumber())
.recipientAccount(recipient.getAccount())
.amount(defaultAmount)
.status(GroupBatchItemStatus.NEW)
.confirmStatus(GroupBatchItemConfirmStatus.PENDING)
.integrationStatus(GroupBatchItemIntegrationStatus.PENDING)
.build();
totalValue = totalValue.add(defaultAmount);
itemCount++;
groupBatchItemRepository.save(item);
}
savedBatch.setCount(itemCount);
savedBatch.setValue(totalValue);
savedBatch.setSuccessfulCount(0);
savedBatch.setFailedCount(0);
return groupBatchRepository.save(savedBatch);
}
public GroupBatch triggerConfirm(UUID batchId, UUID workspaceId, String idempotencyKey) {
GroupBatch batch = getBatch(batchId, workspaceId);
if (batch.getConfirmIdempotencyKey() != null && batch.getConfirmIdempotencyKey().equals(idempotencyKey)) {
return batch;
}
batch.setConfirmIdempotencyKey(idempotencyKey);
batch.setStatus(GroupBatchStatus.CONFIRMING);
groupBatchRepository.save(batch);
if (batch.getWorkflowId() == null) {
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(BatchWorkflow.TYPE)
.setExternalId(batch.getWorkspaceId() + "-batch-" + new Instant().getMillis())
.setBusinessKey(batch.getId().toString())
.putStateVariable("batchId", Utils.toJson(batch.getId().toString()))
.setNextActivation(DateTime.now())
.build();
long workflowId = workflowInstanceService.insertWorkflowInstance(instance);
batch.setWorkflowId(workflowId);
return groupBatchRepository.save(batch);
}
WorkflowInstance update = new WorkflowInstance.Builder()
.setId(batch.getWorkflowId())
.setState("confirm")
.setNextActivation(DateTime.now())
.setStateVariables(new HashMap<>() {{
put("batchId", Utils.toJson(batch.getId().toString()));
}})
.setStateText("Resumed from network trigger")
.build();
WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update)
.setType(WorkflowInstanceAction.WorkflowActionType.externalChange)
.setExecutionEnd(DateTime.now())
.build();
workflowInstanceService.updateWorkflowInstance(update, action);
return batch;
}
public Object triggerRequest(UUID batchId, UUID workspaceId,
AuthType authType, String idempotencyKey) {
GroupBatch batch = getBatch(batchId, workspaceId);
if (batch.getRequestIdempotencyKey() != null && batch.getRequestIdempotencyKey().equals(idempotencyKey)) {
return batch;
}
batch.setRequestIdempotencyKey(idempotencyKey);
groupBatchRepository.save(batch);
if (batch.getWorkflowId() == null) {
throw new ApiException("Batch workflow has not been started. Trigger confirm first.");
}
WorkflowInstance update = new WorkflowInstance.Builder()
.setId(batch.getWorkflowId())
.setState("request")
.setNextActivation(DateTime.now())
.setStateVariables(new HashMap<>() {{
put("batchId", Utils.toJson(batch.getId().toString()));
put("authType", Utils.toJson((authType == null ? AuthType.REMOTE : authType).name()));
}})
.setStateText("Resumed from network trigger")
.build();
WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update)
.setType(WorkflowInstanceAction.WorkflowActionType.externalChange)
.setExecutionEnd(DateTime.now())
.build();
workflowInstanceService.updateWorkflowInstance(update, action);
try {
CompletableFuture<Object> future = workflowUtils
.waitForState(batch.getWorkflowId(), new String[]{"requestSuccess", "done", "failed"},
45000, 50);
StateResponse response = (StateResponse) future.get();
String id = response.getBody().toString(); // this returns Batch Transaction ID
String cleaned = id.replaceAll("^\"|\"$", "");
return transactionService.findById(UUID.fromString(cleaned));
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
public Object triggerPoll(UUID batchId, UUID workspaceId, String idempotencyKey) {
GroupBatch batch = getBatch(batchId, workspaceId);
if (batch.getPollIdempotencyKey() != null && batch.getPollIdempotencyKey().equals(idempotencyKey)) {
return batch;
}
batch.setPollIdempotencyKey(idempotencyKey);
groupBatchRepository.save(batch);
if (batch.getWorkflowId() == null) {
throw new ApiException("Batch workflow has not been started. Trigger confirm first.");
}
// STEP 1: TRIGGER POLL SYNCHRONOUSLY
WorkflowInstance update = new WorkflowInstance.Builder()
.setId(batch.getWorkflowId())
.setState("poll")
.setNextActivation(DateTime.now())
.setStateVariables(new HashMap<>() {{
put("batchId", Utils.toJson(batch.getId().toString()));
}})
.setStateText("Resumed from network trigger")
.build();
WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update)
.setType(WorkflowInstanceAction.WorkflowActionType.externalChange)
.setExecutionEnd(DateTime.now())
.build();
workflowInstanceService.updateWorkflowInstance(update, action);
try {
// wait for poll to finish
CompletableFuture<Object> future = workflowUtils
.waitForState(batch.getWorkflowId(), new String[]{"pollSuccess", "failed"},
45000, 50);
StateResponse response = (StateResponse) future.get();
String id = response.getBody().toString(); // this returns Batch Transaction ID
String cleaned = id.replaceAll("^\"|\"$", "");
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) {
throw new RuntimeException(e);
}
}
public GroupBatch getBatch(UUID batchId, UUID workspaceId) {
return groupBatchRepository.findByIdAndWorkspaceId(batchId, workspaceId)
.orElseThrow(() -> new ApiException("Group batch not found"));
}
public Page<GroupBatchItem> getBatchItems(Specification<GroupBatchItem> spec, Pageable pageable) {
return groupBatchItemRepository.findAll(spec, pageable);
}
public Page<GroupBatch> findAll(Specification<GroupBatch> spec, Pageable pageable) {
return groupBatchRepository.findAll(spec, pageable);
}
@Transactional
public GroupBatch updateBatch(GroupBatchUpdateRequest request) {
GroupBatch batch = getBatch(request.getId(), request.getWorkspaceId());
if (!UPDATABLE_STATUSES.contains(batch.getStatus())) {
throw new ApiException(
"Batch cannot be updated at current status: " + batch.getStatus()
+ ". Only batches in CREATED status can be updated."
);
}
if (request.getDescription() != null) {
batch.setDescription(request.getDescription());
}
if (request.getRequestedBy() != null) {
batch.setRequestedBy(request.getRequestedBy());
}
if (request.getCurrency() != null) {
batch.setCurrency(request.getCurrency());
}
if (request.getPaymentProcessorLabel() != null) {
batch.setPaymentProcessorLabel(request.getPaymentProcessorLabel());
}
if (request.getPaymentProcessorName() != null) {
batch.setPaymentProcessorName(request.getPaymentProcessorName());
}
if (request.getPaymentProcessorImage() != null) {
batch.setPaymentProcessorImage(request.getPaymentProcessorImage());
}
if (request.getTransactionTemplate() != null) {
batch.setTransactionTemplate(Utils.toJson(request.getTransactionTemplate()));
}
if (request.getAmount() != null) {
BigDecimal newAmount = request.getAmount();
BigDecimal newTotalValue = BigDecimal.ZERO;
int newCount = 0;
List<GroupBatchItem> items = groupBatchItemRepository.findAllByGroupBatchId(batch.getId());
for (GroupBatchItem item : items) {
item.setAmount(newAmount);
groupBatchItemRepository.save(item);
newTotalValue = newTotalValue.add(newAmount);
newCount++;
}
batch.setValue(newTotalValue);
batch.setCount(newCount);
}
return groupBatchRepository.save(batch);
}
@Transactional
public List<GroupBatchItem> updateBatchItems(UUID batchId, UUID workspaceId,
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())) {
throw new ApiException(
"Batch cannot be deleted at current status: " + batch.getStatus()
+ ". Only batches up to the confirmed stage can be deleted."
);
}
batch.setDeleted(true);
groupBatchRepository.save(batch);
}
}

View File

@@ -0,0 +1,345 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import zw.qantra.tm.domain.enums.*;
import zw.qantra.tm.domain.models.*;
import zw.qantra.tm.domain.repositories.GroupBatchItemRepository;
import zw.qantra.tm.domain.repositories.GroupBatchRepository;
import zw.qantra.tm.domain.repositories.RecipientRepository;
import zw.qantra.tm.domain.services.processors.workflows.handlers.*;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.SecurityUtils;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Service
@RequiredArgsConstructor
public class GroupBatchWorkflowService {
private final GroupBatchRepository groupBatchRepository;
private final GroupBatchItemRepository groupBatchItemRepository;
private final RecipientRepository recipientRepository;
private final TransactionService transactionService;
private final ConfirmHandler confirmHandler;
private final GatewayPaymentHandler gatewayPaymentHandler;
private final PollStatusHandler pollStatusHandler;
private final IntegrationHandler integrationHandler;
private final CalculateChargesHandler calculateChargesHandler;
private final CleanupHandler cleanUpHandler;
private final UserService userService;
@Transactional
public GroupBatch processConfirm(UUID batchId) {
GroupBatch batch = getBatchById(batchId);
List<GroupBatchStatus> acceptableStatuses = List.of(
GroupBatchStatus.CREATED,
GroupBatchStatus.CONFIRMING,
GroupBatchStatus.CONFIRM_FAILED);
if (!acceptableStatuses.contains(batch.getStatus())) {
return batch;
}
batch.setStatus(GroupBatchStatus.CONFIRMING);
batch.setConfirmStartedAt(LocalDateTime.now());
groupBatchRepository.save(batch);
List<GroupBatchItem> items = groupBatchItemRepository.findAllByGroupBatchId(batchId);
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (GroupBatchItem item : items) {
futures.add(CompletableFuture.runAsync(() -> confirmItem(batch, item)));
}
futures.forEach(CompletableFuture::join);
long confirmSuccessCount = groupBatchItemRepository.countByGroupBatchIdAndConfirmStatus(
batchId, GroupBatchItemConfirmStatus.CONFIRMED);
if (confirmSuccessCount == 0) {
batch.setStatus(GroupBatchStatus.CONFIRM_FAILED);
} else if (confirmSuccessCount == items.size()) {
batch.setStatus(GroupBatchStatus.CONFIRMED_ALL);
} else {
batch.setStatus(GroupBatchStatus.CONFIRMED_PARTIAL);
}
recalculateStats(batch);
return groupBatchRepository.save(batch);
}
@Transactional
public GroupBatch processRequest(UUID batchId, AuthType authType) throws Exception {
GroupBatch batch = getBatchById(batchId);
List<GroupBatchStatus> acceptableStatuses = List.of(
GroupBatchStatus.CONFIRMED_ALL,
GroupBatchStatus.CONFIRMED_PARTIAL,
GroupBatchStatus.REQUEST_FAILED);
if (!acceptableStatuses.contains(batch.getStatus())) {
throw new ApiException("Batch request step requires a successful confirm step");
}
batch.setStatus(GroupBatchStatus.REQUESTING);
batch.setRequestStartedAt(LocalDateTime.now());
groupBatchRepository.save(batch);
List<GroupBatchItem> confirmedItems = groupBatchItemRepository.findAllByGroupBatchIdAndConfirmStatus(
batchId, GroupBatchItemConfirmStatus.CONFIRMED);
if (confirmedItems.isEmpty()) {
throw new ApiException("No confirmed batch items available for payment request");
}
BigDecimal total = confirmedItems.stream()
.map(item -> item.getAmount() == null ? BigDecimal.ZERO : item.getAmount())
.reduce(BigDecimal.ZERO, BigDecimal::add);
User user = userService.getUserRepository().findById(UUID.fromString(batch.getUserId())).orElseThrow();
Transaction transaction = new Transaction();
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.setType(RequestType.REQUEST);
transaction.setAuthType(authType == null ? AuthType.REMOTE : authType);
transaction.setAmount(total);
transaction.setTotalAmount(total);
transaction.setReference("batch-" + batch.getId());
transaction.setUserId(batch.getUserId());
transaction.setWorkspaceId(batch.getWorkspaceId());
transaction.setPaymentProcessorLabel(batch.getPaymentProcessorLabel());
transaction.setPaymentProcessorName(batch.getPaymentProcessorName());
transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage());
transaction.setBatchId(batch.getId());
transaction.setBatchDescription(batch.getDescription());
transaction.setBillName("Batch - " + batch.getDescription());
transaction = calculateChargesHandler.process(transaction);
transaction = (Transaction) cleanUpHandler.process(transaction);
Transaction result = (Transaction) gatewayPaymentHandler.process(transaction);
batch.setPaymentTransactionId(result.getId());
batch.setPaymentReference(result.getPaymentProcessorRef());
batch.setStatus(result.getPaymentStatus() == Status.FAILED
? GroupBatchStatus.REQUEST_FAILED
: GroupBatchStatus.REQUESTED);
return groupBatchRepository.save(batch);
}
public GroupBatch processPoll(UUID batchId) throws Exception {
GroupBatch batch = getBatchById(batchId);
if (batch.getStatus() != GroupBatchStatus.REQUESTED && batch.getStatus() != GroupBatchStatus.POLL_FAILED) {
throw new ApiException("Batch poll step requires a successful request step");
}
if (batch.getPaymentTransactionId() == null) {
throw new ApiException("Batch payment transaction was not initialized");
}
batch.setStatus(GroupBatchStatus.POLLING);
batch.setPollStartedAt(LocalDateTime.now());
groupBatchRepository.save(batch);
Transaction paymentTransaction = transactionService.findById(batch.getPaymentTransactionId());
try {
paymentTransaction = (Transaction) pollStatusHandler.process(paymentTransaction);
} catch (Exception e) {
batch.setStatus(GroupBatchStatus.POLL_FAILED);
groupBatchRepository.save(batch);
throw e;
}
if (paymentTransaction.getPollingStatus() == Status.SUCCESS) {
batch.setStatus(GroupBatchStatus.POLL_SUCCESS);
return groupBatchRepository.save(batch);
}
batch.setStatus(GroupBatchStatus.POLL_FAILED);
return groupBatchRepository.save(batch);
}
@Transactional
public GroupBatch processIntegration(UUID batchId) {
GroupBatch batch = getBatchById(batchId);
if (batch.getStatus() != GroupBatchStatus.POLL_SUCCESS) {
throw new ApiException("Batch integration step requires a successful poll step");
}
batch.setStatus(GroupBatchStatus.INTEGRATING);
batch.setIntegrationStartedAt(LocalDateTime.now());
groupBatchRepository.save(batch);
List<GroupBatchItem> items = groupBatchItemRepository.findAllByGroupBatchId(batchId);
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (GroupBatchItem item : items) {
futures.add(CompletableFuture.runAsync(() -> integrateItem(item)));
}
futures.forEach(CompletableFuture::join);
long integrated = groupBatchItemRepository.countByGroupBatchIdAndIntegrationStatus(
batchId, GroupBatchItemIntegrationStatus.INTEGRATED);
long failed = groupBatchItemRepository.countByGroupBatchIdAndIntegrationStatus(
batchId, GroupBatchItemIntegrationStatus.FAILED);
if (integrated == 0 && failed > 0) {
batch.setStatus(GroupBatchStatus.FAILED);
} else if (failed > 0) {
batch.setStatus(GroupBatchStatus.PARTIAL);
} else {
batch.setStatus(GroupBatchStatus.COMPLETED);
Transaction paymentTransaction = transactionService.findById(batch.getPaymentTransactionId());
paymentTransaction.setIntegrationStatus(Status.SUCCESS);
transactionService.save(paymentTransaction);
}
batch.setCompletedAt(LocalDateTime.now());
recalculateStats(batch);
return groupBatchRepository.save(batch);
}
private void confirmItem(GroupBatch batch, GroupBatchItem item) {
GroupBatchItem mutableItem = groupBatchItemRepository.findById(item.getId()).orElse(item);
try {
User user = userService.getUserRepository().findById(UUID.fromString(batch.getUserId())).orElseThrow();
Transaction transaction = new Transaction();
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.setBatchId(batch.getId());
transaction.setUsername(user.getUsername());
transaction.setWorkspaceId(batch.getWorkspaceId());
transaction.setType(RequestType.CONFIRM);
transaction.setPartyType(PartyType.BATCH);
transaction.setAuthType(AuthType.REMOTE); // default to remote for now
transaction.setAmount(mutableItem.getAmount());
transaction.setTotalAmount(mutableItem.getAmount());
Recipient recipient = recipientRepository.findById(mutableItem.getRecipientId())
.orElseThrow(() -> new ApiException("Recipient not found for batch item"));
transaction.setCreditName(recipient.getName());
transaction.setCreditPhone(recipient.getPhoneNumber());
transaction.setCreditAccount(recipient.getAccount());
transaction.setPaymentProcessorLabel(batch.getPaymentProcessorLabel());
transaction.setPaymentProcessorName(batch.getPaymentProcessorName());
transaction.setPaymentProcessorImage(batch.getPaymentProcessorImage());
transaction = calculateChargesHandler.process(transaction);
transaction = (Transaction) cleanUpHandler.process(transaction);
Transaction result = (Transaction) confirmHandler.process(transaction);
mutableItem.setTransaction(result);
if (result.getConfirmationStatus() == Status.SUCCESS || result.getStatus() == Status.SUCCESS) {
mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.CONFIRMED);
mutableItem.setStatus(GroupBatchItemStatus.CONFIRMED);
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.PENDING);
if(result.getCreditAddress() != null)
mutableItem.setCreditAddress(result.getCreditAddress());
mutableItem.setConfirmError(null);
} else {
mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED);
mutableItem.setStatus(GroupBatchItemStatus.FAILED);
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.SKIPPED);
mutableItem.setConfirmError(result.getErrorMessage());
}
} catch (Exception e) {
e.printStackTrace();
mutableItem.setConfirmStatus(GroupBatchItemConfirmStatus.FAILED);
mutableItem.setStatus(GroupBatchItemStatus.FAILED);
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.SKIPPED);
mutableItem.setConfirmError(e.getMessage());
}
groupBatchItemRepository.save(mutableItem);
}
private void integrateItem(GroupBatchItem item) {
GroupBatchItem mutableItem = groupBatchItemRepository.findById(item.getId()).orElse(item);
if (mutableItem.getConfirmStatus() != GroupBatchItemConfirmStatus.CONFIRMED) {
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.SKIPPED);
mutableItem.setStatus(GroupBatchItemStatus.SKIPPED);
groupBatchItemRepository.save(mutableItem);
return;
}
if (mutableItem.getTransaction() == null) {
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.FAILED);
mutableItem.setStatus(GroupBatchItemStatus.FAILED);
mutableItem.setIntegrationError("Missing transaction for integration");
groupBatchItemRepository.save(mutableItem);
return;
}
try {
Transaction transaction = transactionService.findById(mutableItem.getTransaction().getId());
transaction.setPaymentStatus(Status.SUCCESS);
transaction.setPollingStatus(Status.SUCCESS);
transactionService.save(transaction);
integrationHandler.process(transaction);
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.INTEGRATED);
mutableItem.setStatus(GroupBatchItemStatus.INTEGRATED);
mutableItem.setIntegrationError(null);
} catch (Exception e) {
e.printStackTrace();
mutableItem.setIntegrationStatus(GroupBatchItemIntegrationStatus.FAILED);
mutableItem.setStatus(GroupBatchItemStatus.FAILED);
mutableItem.setIntegrationError(e.getMessage());
}
groupBatchItemRepository.save(mutableItem);
}
private void recalculateStats(GroupBatch batch) {
List<GroupBatchItem> items = groupBatchItemRepository.findAllByGroupBatchId(batch.getId());
long successfulCount = items.stream()
.filter(item -> item.getStatus() == GroupBatchItemStatus.INTEGRATED
|| (item.getIntegrationStatus() == GroupBatchItemIntegrationStatus.INTEGRATED)
|| (item.getConfirmStatus() == GroupBatchItemConfirmStatus.CONFIRMED
&& item.getIntegrationStatus() != GroupBatchItemIntegrationStatus.FAILED))
.count();
long failedCount = items.stream()
.filter(item -> item.getStatus() == GroupBatchItemStatus.FAILED
|| item.getIntegrationStatus() == GroupBatchItemIntegrationStatus.FAILED
|| (item.getConfirmStatus() == GroupBatchItemConfirmStatus.FAILED
&& item.getIntegrationStatus() != GroupBatchItemIntegrationStatus.INTEGRATED))
.count();
BigDecimal totalValue = items.stream()
.map(item -> item.getAmount() == null ? BigDecimal.ZERO : item.getAmount())
.reduce(BigDecimal.ZERO, BigDecimal::add);
batch.setCount(items.size());
batch.setValue(totalValue);
batch.setSuccessfulCount((int) successfulCount);
batch.setFailedCount((int) failedCount);
}
public GroupBatch getBatchById(UUID batchId) {
return groupBatchRepository.findById(batchId)
.orElseThrow(() -> new ApiException("Group batch not found"));
}
}

View File

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

View File

@@ -34,52 +34,6 @@ public class HotRechargeTokenService {
private long cacheExpiryTime;
public synchronized String getToken() {
if (isCacheValid()) {
logger.info("Cache is valid, attempting token refresh");
return refreshCachedToken();
}
return fetchNewToken();
}
private boolean isCacheValid() {
return cachedToken != null && cachedRefreshToken != null && System.currentTimeMillis() < cacheExpiryTime;
}
private synchronized String refreshCachedToken() {
if (!isCacheValid()) {
return fetchNewToken();
}
try {
logger.info("Refreshing HotRecharge token via refresh endpoint");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
Map<String, String> payload = Map.of(
"token", cachedToken,
"refreshToken", cachedRefreshToken
);
LinkedHashMap<String, Object> response = restService.postAs(hotAuthUrl + "/identity/refresh", payload, headers, LinkedHashMap.class);
cachedToken = (String) response.get("token");
cachedRefreshToken = (String) response.get("refreshToken");
cacheExpiryTime = System.currentTimeMillis() + (cacheDurationMinutes * 60 * 1000L);
logger.info("HotRecharge token refreshed via refresh endpoint, cache extended by {} minutes", cacheDurationMinutes);
return cachedToken;
} catch (Exception e) {
logger.error("Failed to refresh HotRecharge token: " + e.getMessage(), e);
// Fall back to login on refresh failure
return fetchNewToken();
}
}
private synchronized String fetchNewToken() {
if (isCacheValid()) {
return refreshCachedToken();
}
try {
logger.info("Fetching new HotRecharge token via login");
HttpHeaders headers = new HttpHeaders();
@@ -99,9 +53,33 @@ public class HotRechargeTokenService {
logger.info("New HotRecharge token cached, cache expires in {} minutes", cacheDurationMinutes);
return cachedToken;
} catch (Exception e) {
logger.error("Failed to fetch HotRecharge token: " + e.getMessage(), e);
cachedToken = null;
cachedRefreshToken = null;
logger.error("Failed to fetch HotRecharge token: {}", e.getMessage(), e);
logger.info("Trying to refresh token");
return refreshCachedToken();
}
}
private synchronized String refreshCachedToken() {
try {
logger.info("Refreshing HotRecharge token via refresh endpoint");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
Map<String, String> payload = Map.of(
"token", cachedToken,
"refreshToken", cachedRefreshToken
);
LinkedHashMap<String, Object> response = restService.postAs(hotAuthUrl + "/identity/refresh", payload, headers, LinkedHashMap.class);
cachedToken = (String) response.get("token");
cachedRefreshToken = (String) response.get("refreshToken");
cacheExpiryTime = System.currentTimeMillis() + (cacheDurationMinutes * 60 * 1000L);
logger.info("HotRecharge token refreshed via refresh endpoint, cache extended by {} minutes", cacheDurationMinutes);
return cachedToken;
} catch (Exception e) {
logger.error("Failed to refresh HotRecharge token: {}", e.getMessage(), e);
return null;
}
}

View File

@@ -1,54 +1,41 @@
package zw.qantra.tm.domain.services.processors.workflows;
package zw.qantra.tm.domain.services;
import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.curated.CronWorkflow;
import io.nflow.engine.workflow.definition.*;
import io.nflow.engine.workflow.instance.WorkflowInstance;
import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import org.joda.time.DateTime;
import org.joda.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jobrunr.jobs.context.JobRunrDashboardLogger;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.dtos.StateResponse;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.processors.workflows.WorkflowUtils;
import zw.qantra.tm.utils.LogUtils;
import zw.qantra.tm.utils.Utils;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static io.nflow.engine.workflow.definition.NextAction.moveToStateAfter;
import static org.joda.time.DateTime.now;
import static org.joda.time.Period.hours;
@Slf4j
@Component
public class PollCronWorkflow extends CronWorkflow {
Logger log = org.slf4j.LoggerFactory.getLogger(PollCronWorkflow.class);
@RequiredArgsConstructor
public class JobService {
@Autowired
private TransactionService transactionService;
@Autowired
private WorkflowInstanceService workflowInstanceService;
@Autowired
private WorkflowUtils workflowUtils;
private final WorkflowInstanceService workflowInstanceService;
private final WorkflowUtils workflowUtils;
private final TransactionService transactionService;
private final SettingService settingService;
public static final String TYPE = "transactionPollCronWorkflow";
protected PollCronWorkflow() {
super(TYPE, new WorkflowSettings.Builder()
.setHistoryDeletableAfter(hours(1))
.setDeleteHistoryCondition(() -> true).build());
}
public NextAction doWork(StateExecution execution) {
public void checkOrphanedTransactions() {
List<Transaction> transactions = transactionService.getTransactionRepository()
.findAllByConfirmationStatusAndIntegrationStatus(Status.SUCCESS, Status.PENDING);
.findAllByPollingStatus(Status.PENDING);
if (!transactions.isEmpty())
log.info("Transaction count {}", transactions.size());
@@ -57,6 +44,18 @@ public class PollCronWorkflow extends CronWorkflow {
for (Transaction transaction: transactions) {
attempts++;
try {
int maxRetries = settingService.getIntegerSetting("MAX_RETRIES");
int retries = transaction.getRetries() == null ? 0 : transaction.getRetries();
if (retries >= maxRetries) {
transaction.setPaymentStatus(Status.FAILED);
transaction.setPollingStatus(Status.FAILED);
transaction.setErrorMessage("Maximum retries reached");
transactionService.save(transaction);
continue;
}
transaction.setRetries(retries + 1);
// polling logic
WorkflowInstance update = new WorkflowInstance.Builder()
.setId(transaction.getWorkflowId())
@@ -112,33 +111,17 @@ public class PollCronWorkflow extends CronWorkflow {
success++;
}else {
transaction.setPollingStatus(Status.FAILED);
transaction.setIntegrationStatus(Status.FAILED);
transaction.setPollingStatus(Status.PENDING);
transaction.setIntegrationStatus(Status.PENDING);
transactionService.save(transaction);
}
}catch (Exception e) {
log.error(e.getMessage());
log.debug(e.getMessage());
}
}
log.trace("Polling complete. {} transactions processed, {} successful", attempts, success);
Map<String, String> map = new HashMap<>();
map.put("attempts", attempts.toString());
map.put("success", success.toString());
execution.setVariable(LocalDateTime.now().toString(), map);
return moveToStateAfter(WAIT_FOR_WORK_TO_FINISH, now().plusMinutes(2),
String.format("Cron complete on %s transactions", transactions.size()));
}
@Override
protected boolean handleFailureImpl(StateExecution execution) {
return super.handleFailureImpl(execution);
}
@Override
protected DateTime waitForWorkToFinishImpl(StateExecution execution) {
return super.waitForWorkToFinishImpl(execution);
}
}

View File

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

View File

@@ -5,16 +5,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import zw.qantra.tm.domain.dtos.SBZProductDto;
import zw.qantra.tm.domain.dtos.ProductDto;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.repositories.ProviderRepository;
import zw.qantra.tm.exceptions.ApiException;
@@ -34,60 +32,41 @@ public class ProviderService {
private final RestService restService;
private final HotRechargeTokenService hotRechargeTokenService;
private Map<String, List<ProductDto>> providerProductsCache = new HashMap<>();
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
@Value("${sbz.aggregator.url}")
private String aggregatorUrl;
public SBZProductDto getProviderProduct(UUID providerId, UUID productId) {
return getProviderProducts(providerId.toString()).stream()
.filter(product -> product.getUid().equals(productId))
public ProductDto getProviderProduct(UUID providerId, String productName) {
List<ProductDto> products = getProviderProducts(providerId.toString());
ProductDto productResult = products.stream()
.filter(product -> product.getName().equals(productName))
.findFirst()
.orElse(null);
log.info("Product: {}", productResult);
return productResult;
}
@Deprecated
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;
public List<ProductDto> getProviderProducts(String providerUid) {
try {
response = this.restService.getAs(
aggregatorUrl + "/merchant/aggregation/products?size=20&clientUid=" + providerId.toString(),
headers,
LinkedHashMap.class
);
} catch (Exception e) {
log.error("Error fetching products for provider " + providerId, e);
return Collections.emptyList();
}
List<ProductDto> cachedProducts = providerProductsCache.get(providerUid);
if (cachedProducts != null) {
log.info("Returning cached products for provider: {}, number of products: {}", providerUid,
cachedProducts.size());
return cachedProducts;
}
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;
}
@Cacheable(value = "providerProducts", key = "#providerUid")
public List<SBZProductDto> getProviderProducts(String providerUid) {
try {
Provider provider = providerRepository.findById(UUID.fromString(providerUid)).orElse(null);
if (provider == null) {
return Collections.emptyList();
}
if(provider.getRequiresProducts().equals(false)){
return Collections.emptyList();
}
Map<String, String> meta = Utils.fromJson(provider.getMeta(), Map.class);
String providerId = meta.get("hotProductId");
@@ -100,32 +79,36 @@ public class ProviderService {
try {
response = restService.getAs(stockUrl, headers, LinkedHashMap.class);
} catch (Exception e) {
log.error("Error fetching products for provider " + providerId, e);
log.error("Error fetching products for provider " + providerId);
return Collections.emptyList();
}
List stock = (List) response.get("stock");
List<SBZProductDto> products = new ArrayList<>();
List<ProductDto> products = new ArrayList<>();
for (Object item : stock) {
LinkedHashMap<String, Object> stockItem = (LinkedHashMap<String, Object>) item;
int stockProviderId = (int) stockItem.get("productId"); // providerId is referred to as productId in the stock response
if (String.valueOf(stockProviderId).equals(providerId)) {
log.info("Found matching provider in stock: " + stockItem);
SBZProductDto sbzProductDto = new SBZProductDto();
sbzProductDto.setName((String) stockItem.get("name"));
sbzProductDto.setDescription((String) stockItem.get("description"));
sbzProductDto.setDisplayName((String) stockItem.get("name"));
sbzProductDto.setDefaultAmount(new BigDecimal(stockItem.get("amount").toString()));
sbzProductDto.setRequiresAmount(true); // always true for now
sbzProductDto.setUid(UUID.fromString(Utils.getUid()));
sbzProductDto.setExternalId((String) stockItem.get("productCode"));
products.add(sbzProductDto);
ProductDto productDto = new ProductDto();
productDto.setName((String) stockItem.get("name"));
productDto.setDescription((String) stockItem.get("description"));
productDto.setDisplayName((String) stockItem.get("name"));
productDto.setDefaultAmount(new BigDecimal(stockItem.get("amount").toString()));
productDto.setRequiresAmount(true); // always true for now
productDto.setUid(UUID.fromString(Utils.getUid()));
productDto.setExternalId((String) stockItem.get("productCode"));
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;
} catch (IllegalArgumentException e) {
log.error("Error fetching products: {}", providerUid);
return Collections.emptyList();
}
}
@@ -141,29 +124,23 @@ public class ProviderService {
return meta.get("hotProductId");
}
public String getExternalId(String providerId, String productUuid) {
public String getExternalId(String providerId, String productName) {
try {
UUID productUid = UUID.fromString(productUuid);
List<SBZProductDto> products = getProviderProducts(providerId);
List<ProductDto> products = getProviderProducts(providerId);
return products.stream()
.filter(product -> product.getUid().equals(productUid))
.map(SBZProductDto::getExternalId)
.filter(product -> product.getName().equals(productName))
.map(ProductDto::getExternalId)
.findFirst()
.orElse(null);
} catch (IllegalArgumentException e) {
log.error("Invalid UUID format: " + productUuid, e);
} catch (Exception e) {
log.error("Failed to fetch external Id: " + productName, e);
return null;
}
}
public Object getProviderByClientId(String clientId) {
Provider provider = providerRepository.findByClientId(clientId);
if (provider != null) {
return ((List)getProviders(Collections.singletonList(provider))).stream().findFirst().orElse(null);
}
return null;
return providerRepository.findByClientId(clientId);
}
public Object findAll(Specification<Provider> specification, Pageable pageable) {
@@ -237,4 +214,17 @@ public class ProviderService {
}
return false;
}
// Evict ALL providerProducts cache entries
@CacheEvict(value = "providerProducts", allEntries = true)
public void evictProviderProductsCache() {
// empty - annotation handles the eviction
}
// Or evict a SINGLE entry by key
@CacheEvict(value = "providerProducts", key = "#providerUid")
public void evictProviderProductCache(String providerUid) {
// only evicts cache for this specific providerUid
}
}

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