From a9cf785169b6dfa9f3ab732a3a242813f3046415 Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 00:24:05 -0500 Subject: [PATCH 01/54] chore: update .gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 67045665db..ee69a9ac18 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,9 @@ dist # TernJS port file .tern-port + +# IntelliJ +.idea/ + +# Quarkus +.quarkus/ From f4a2acdc68832dbedcd5d4f3613e23f2c7266f2e Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 00:28:27 -0500 Subject: [PATCH 02/54] chore: update docker-compose.yml Add Redis, Zookeeper, Kafka, Schema Registry, and microservices with health checks --- docker-compose.yml | 151 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 142 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..73e1ed45e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,158 @@ -version: "3.7" services: + redis: + image: redis:8 + container_name: yape-redis + ports: + - "6379:6379" + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + volumes: + - redis_data:/data + postgres: image: postgres:14 + container_name: yape-postgres ports: - "5432:5432" environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: yape_transactions + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + zookeeper: - image: confluentinc/cp-zookeeper:5.5.3 + image: confluentinc/cp-zookeeper:7.9.0 + container_name: yape-zookeeper environment: ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + healthcheck: + test: [ "CMD", "nc", "-z", "localhost", "2181" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + kafka: - image: confluentinc/cp-enterprise-kafka:5.5.3 - depends_on: [zookeeper] + image: confluentinc/cp-enterprise-kafka:7.9.0 + container_name: yape-kafka + depends_on: + zookeeper: + condition: service_healthy + ports: + - "9092:9092" environment: - KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT - KAFKA_BROKER_ID: 1 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_JMX_PORT: 9991 + healthcheck: + test: [ "CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092" ] + interval: 10s + timeout: 10s + retries: 5 + networks: + - yape-network + + schema-registry: + image: confluentinc/cp-schema-registry:7.9.0 + container_name: yape-schema-registry + depends_on: + kafka: + condition: service_healthy ports: - - 9092:9092 + - "8081:8081" + environment: + SCHEMA_REGISTRY_HOST_NAME: schema-registry + SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: kafka:29092 + SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081 + SCHEMA_REGISTRY_KAFKASTORE_TOPIC: _schemas + SCHEMA_REGISTRY_KAFKASTORE_REPLICATION_FACTOR: 1 + SCHEMA_REGISTRY_DEBUG: "false" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8081/subjects" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + + ms-transaction: + build: + context: ./ms-transaction + dockerfile: devops/docker/Dockerfile.jvm + container_name: yape-ms-transaction + ports: + - "18080:18080" + environment: + QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://postgres:5432/yape_transactions + QUARKUS_DATASOURCE_USERNAME: postgres + QUARKUS_DATASOURCE_PASSWORD: postgres + KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_healthy + schema-registry: + condition: service_healthy + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:18080/ms-transaction/health" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + + ms-anti-fraud: + build: + context: ./ms-anti-fraud + dockerfile: devops/docker/Dockerfile.jvm + container_name: yape-ms-anti-fraud + ports: + - "18081:18081" + environment: + KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + depends_on: + kafka: + condition: service_healthy + schema-registry: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:18081/ms-anti-fraud/health" ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + +volumes: + postgres_data: + redis_data: + +networks: + yape-network: + driver: bridge \ No newline at end of file From 81a3dc6e5c7d798bd7860e65244ae3a50eabc33f Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 00:35:52 -0500 Subject: [PATCH 03/54] chore: initialize ms-transaction --- ms-transaction/.dockerignore | 5 + ms-transaction/.gitignore | 50 ++ ms-transaction/README.md | 62 +++ ms-transaction/checkstyle-suppressions.xml | 7 + ms-transaction/checkstyle.xml | 482 ++++++++++++++++++ ms-transaction/devops/docker/Dockerfile.jvm | 32 ++ ms-transaction/pom.xml | 319 ++++++++++++ ms-transaction/spotbugs-exclude.xml | 6 + ms-transaction/src/main/avro/Event.avsc | 19 + .../com/yape/services/GreetingResource.java | 23 + .../src/main/resources/application-local.yml | 91 ++++ .../src/main/resources/application.yml | 15 + 12 files changed, 1111 insertions(+) create mode 100644 ms-transaction/.dockerignore create mode 100644 ms-transaction/.gitignore create mode 100644 ms-transaction/README.md create mode 100644 ms-transaction/checkstyle-suppressions.xml create mode 100644 ms-transaction/checkstyle.xml create mode 100644 ms-transaction/devops/docker/Dockerfile.jvm create mode 100644 ms-transaction/pom.xml create mode 100644 ms-transaction/spotbugs-exclude.xml create mode 100644 ms-transaction/src/main/avro/Event.avsc create mode 100644 ms-transaction/src/main/java/com/yape/services/GreetingResource.java create mode 100644 ms-transaction/src/main/resources/application-local.yml create mode 100644 ms-transaction/src/main/resources/application.yml diff --git a/ms-transaction/.dockerignore b/ms-transaction/.dockerignore new file mode 100644 index 0000000000..94810d006e --- /dev/null +++ b/ms-transaction/.dockerignore @@ -0,0 +1,5 @@ +* +!target/*-runner +!target/*-runner.jar +!target/lib/* +!target/quarkus-app/* \ No newline at end of file diff --git a/ms-transaction/.gitignore b/ms-transaction/.gitignore new file mode 100644 index 0000000000..1cf10f5273 --- /dev/null +++ b/ms-transaction/.gitignore @@ -0,0 +1,50 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ +# TLS Certificates +.certs/ + +# Maven Wrapper +mvnw +mvnw.cmd +.mvn diff --git a/ms-transaction/README.md b/ms-transaction/README.md new file mode 100644 index 0000000000..29bc064b43 --- /dev/null +++ b/ms-transaction/README.md @@ -0,0 +1,62 @@ +# ms-transaction + +This project uses Quarkus, the Supersonic Subatomic Java Framework. + +If you want to learn more about Quarkus, please visit its website: . + +## Running the application in dev mode + +You can run your application in dev mode that enables live coding using: + +```shell script +./mvnw quarkus:dev +``` + +> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at . + +## Packaging and running the application + +The application can be packaged using: + +```shell script +./mvnw package +``` + +It produces the `quarkus-run.jar` file in the `target/quarkus-app/` directory. +Be aware that it’s not an _über-jar_ as the dependencies are copied into the `target/quarkus-app/lib/` directory. + +The application is now runnable using `java -jar target/quarkus-app/quarkus-run.jar`. + +If you want to build an _über-jar_, execute the following command: + +```shell script +./mvnw package -Dquarkus.package.jar.type=uber-jar +``` + +The application, packaged as an _über-jar_, is now runnable using `java -jar target/*-runner.jar`. + +## Creating a native executable + +You can create a native executable using: + +```shell script +./mvnw package -Dnative +``` + +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: + +```shell script +./mvnw package -Dnative -Dquarkus.native.container-build=true +``` + +You can then execute your native executable with: `./target/ms-transaction-1.0.0-SNAPSHOT-runner` + +If you want to learn more about building native executables, please consult . + +## Provided Code + +### REST + +Easily start your REST Web Services + +[Related guide section...](https://quarkus.io/guides/getting-started-reactive#reactive-jax-rs-resources) diff --git a/ms-transaction/checkstyle-suppressions.xml b/ms-transaction/checkstyle-suppressions.xml new file mode 100644 index 0000000000..2161f901d9 --- /dev/null +++ b/ms-transaction/checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/ms-transaction/checkstyle.xml b/ms-transaction/checkstyle.xml new file mode 100644 index 0000000000..745a2ada9e --- /dev/null +++ b/ms-transaction/checkstyle.xml @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ms-transaction/devops/docker/Dockerfile.jvm b/ms-transaction/devops/docker/Dockerfile.jvm new file mode 100644 index 0000000000..4a63aa4a9c --- /dev/null +++ b/ms-transaction/devops/docker/Dockerfile.jvm @@ -0,0 +1,32 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./mvnw package +# +# Then, build the image with: +# +# docker build -f devops/docker/Dockerfile.jvm -t quarkus/ms-transaction-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 18081:18081 quarkus/ms-transaction-jvm +# +### +FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 + +ENV LANGUAGE='en_US:en' + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 18080 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/ms-transaction/pom.xml b/ms-transaction/pom.xml new file mode 100644 index 0000000000..e221e95622 --- /dev/null +++ b/ms-transaction/pom.xml @@ -0,0 +1,319 @@ + + + 4.0.0 + + + 3.14.1 + 21 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.31.1 + true + 3.5.4 + 7.9.0 + 5.10.0 + 0.8.12 + 3.6.0 + 12.1.2 + 4.8.6.6 + + + com.yape.services + ms-transaction + 1.0.0-SNAPSHOT + quarkus + + MS Transaction + Microservice for handling transactions + 2026 + + + Yape + https://www.yape.com.pe + + + + + jcarripa + Juda Carrillo + jbcp2006@gmail.com + America/Lima + + Tech Lead + Senior Backend Developer + + + + + + + confluent + https://packages.confluent.io/maven/ + + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-config-yaml + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-hibernate-validator + + + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-hibernate-orm-panache + + + + + io.quarkus + quarkus-messaging-kafka + + + + io.quarkus + quarkus-avro + + + io.quarkus + quarkus-confluent-registry-avro + + + io.confluent + kafka-avro-serializer + ${confluent.version} + + + + + io.quarkus + quarkus-smallrye-graphql + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + + + + + + + + io.github.kobylynskyi + graphql-codegen-maven-plugin + ${graphql.codegen.version} + + + + generate + + + + + + src/main/resources/schemas/schema.graphqls + src/main/resources/schemas/queries.graphqls + src/main/resources/schemas/mutations.graphqls + + ${project.build.directory}/generated-sources/graphql + true + true + com.yape.services.transaction.graphql.model + com.yape.services.transaction.graphql.api + true + true + @jakarta.validation.constraints.NotNull + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + @{argLine} + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + @{argLine} + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + + checkstyle.xml + checkstyle-suppressions.xml + true + true + true + + + + validate + validate + + check + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-plugin.version} + + spotbugs-exclude.xml + Max + Low + + + + spotbugs-check + verify + + check + + + + + + + + + + native + + + native + + + + false + false + true + + + + diff --git a/ms-transaction/spotbugs-exclude.xml b/ms-transaction/spotbugs-exclude.xml new file mode 100644 index 0000000000..150e3d46e1 --- /dev/null +++ b/ms-transaction/spotbugs-exclude.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/ms-transaction/src/main/avro/Event.avsc b/ms-transaction/src/main/avro/Event.avsc new file mode 100644 index 0000000000..f3194d2a46 --- /dev/null +++ b/ms-transaction/src/main/avro/Event.avsc @@ -0,0 +1,19 @@ +{ + "type": "record", + "name": "Event", + "namespace": "ccom.yape.services.transaction.avro", + "fields": [ + { + "name": "id", + "type": "string" + }, + { + "name": "type", + "type": "string" + }, + { + "name": "timestamp", + "type": "long" + } + ] +} \ No newline at end of file diff --git a/ms-transaction/src/main/java/com/yape/services/GreetingResource.java b/ms-transaction/src/main/java/com/yape/services/GreetingResource.java new file mode 100644 index 0000000000..88b979f671 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/GreetingResource.java @@ -0,0 +1,23 @@ +package com.yape.services; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +/** + * A simple REST endpoint that returns a greeting message. + */ + +@Path("/hello") +public class GreetingResource { + + /** + * A GET endpoint that returns a plain text greeting message. + */ + @GET + @Produces(MediaType.TEXT_PLAIN) + public String hello() { + return "Hello from Quarkus REST"; + } +} diff --git a/ms-transaction/src/main/resources/application-local.yml b/ms-transaction/src/main/resources/application-local.yml new file mode 100644 index 0000000000..fcb4acda75 --- /dev/null +++ b/ms-transaction/src/main/resources/application-local.yml @@ -0,0 +1,91 @@ +# This configuration is used for local development environment. +# Profile: local (activate with -Dquarkus.profile=local) + +# External Service Hosts +postgresql-host: "127.0.0.1" +postgresql-port: 5432 +postgresql-user: "postgres" +postgresql-pass: "postgres" + +event-hubs-host: "127.0.0.1" +schema-registry-host: "127.0.0.1" + +# Quarkus Configuration +quarkus: + + # Datasource Configuration (PostgreSQL) + datasource: + db-kind: postgresql + username: "${postgresql-user}" + password: "${postgresql-pass}" + jdbc: + url: "jdbc:postgresql://${postgresql-host}:${postgresql-port}/postgres" + # Connection pool settings + max-size: 16 + min-size: 4 + initial-size: 4 + idle-removal-interval: 2M + max-lifetime: 30M + + # Hibernate ORM Configuration + hibernate-orm: + database: + # Options: none, create, drop-and-create, drop, update, validate + generation: drop-and-create + log: + sql: true + format-sql: true + + devservices: + enabled: false + + # Logging Configuration + log: + level: INFO + category: + "com.yape": + level: DEBUG + "org.hibernate.SQL": + level: DEBUG + "org.hibernate.type.descriptor.sql": + level: TRACE + "io.smallrye.reactive.messaging": + level: DEBUG + +# Kafka / Event Streaming Configuration +kafka: + bootstrap: + servers: "${event-hubs-host}:9092" + schema: + registry: + url: "http://${schema-registry-host}:8081" + +# MicroProfile Reactive Messaging Configuration +mp: + messaging: + outgoing: + kafka-out: + connector: smallrye-kafka + topic: test-event + key: + serializer: org.apache.kafka.common.serialization.StringSerializer + value: + serializer: io.confluent.kafka.serializers.KafkaAvroSerializer + + incoming: + kafka-in: + connector: smallrye-kafka + topic: test-event + group: + id: ms-transaction-group + auto: + offset: + reset: earliest + failure-strategy: ignore + key: + deserializer: org.apache.kafka.common.serialization.StringDeserializer + value: + deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer + specific: + avro: + reader: true diff --git a/ms-transaction/src/main/resources/application.yml b/ms-transaction/src/main/resources/application.yml new file mode 100644 index 0000000000..c3d15b400d --- /dev/null +++ b/ms-transaction/src/main/resources/application.yml @@ -0,0 +1,15 @@ +quarkus: + application: + name: ms-transaction + smallrye-health: + root-path: /health + ui: + enabled: false + http: + root-path: /ms-transaction + port: 18080 + +info: + project: + artifact: ${project.artifactId} + version: ${project.version} \ No newline at end of file From 7e3def7cca5ed6be6c522627858619a3f8f545b1 Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 01:08:33 -0500 Subject: [PATCH 04/54] chore: initialize ms-transaction --- ms-anti-fraud/.dockerignore | 5 + ms-anti-fraud/.gitignore | 50 ++ ms-anti-fraud/README.md | 62 +++ ms-anti-fraud/checkstyle-suppressions.xml | 7 + ms-anti-fraud/checkstyle.xml | 482 ++++++++++++++++++ ms-anti-fraud/devops/docker/Dockerfile.jvm | 32 ++ ms-anti-fraud/pom.xml | 273 ++++++++++ ms-anti-fraud/spotbugs-exclude.xml | 6 + ms-anti-fraud/src/main/avro/Event.avsc | 19 + .../com/yape/services/GreetingResource.java | 24 + .../src/main/resources/application-local.yml | 62 +++ .../src/main/resources/application.yml | 15 + 12 files changed, 1037 insertions(+) create mode 100644 ms-anti-fraud/.dockerignore create mode 100644 ms-anti-fraud/.gitignore create mode 100644 ms-anti-fraud/README.md create mode 100644 ms-anti-fraud/checkstyle-suppressions.xml create mode 100644 ms-anti-fraud/checkstyle.xml create mode 100644 ms-anti-fraud/devops/docker/Dockerfile.jvm create mode 100644 ms-anti-fraud/pom.xml create mode 100644 ms-anti-fraud/spotbugs-exclude.xml create mode 100644 ms-anti-fraud/src/main/avro/Event.avsc create mode 100644 ms-anti-fraud/src/main/java/com/yape/services/GreetingResource.java create mode 100644 ms-anti-fraud/src/main/resources/application-local.yml create mode 100644 ms-anti-fraud/src/main/resources/application.yml diff --git a/ms-anti-fraud/.dockerignore b/ms-anti-fraud/.dockerignore new file mode 100644 index 0000000000..94810d006e --- /dev/null +++ b/ms-anti-fraud/.dockerignore @@ -0,0 +1,5 @@ +* +!target/*-runner +!target/*-runner.jar +!target/lib/* +!target/quarkus-app/* \ No newline at end of file diff --git a/ms-anti-fraud/.gitignore b/ms-anti-fraud/.gitignore new file mode 100644 index 0000000000..1cf10f5273 --- /dev/null +++ b/ms-anti-fraud/.gitignore @@ -0,0 +1,50 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ +# TLS Certificates +.certs/ + +# Maven Wrapper +mvnw +mvnw.cmd +.mvn diff --git a/ms-anti-fraud/README.md b/ms-anti-fraud/README.md new file mode 100644 index 0000000000..5d06ff7672 --- /dev/null +++ b/ms-anti-fraud/README.md @@ -0,0 +1,62 @@ +# ms-anti-fraud + +This project uses Quarkus, the Supersonic Subatomic Java Framework. + +If you want to learn more about Quarkus, please visit its website: . + +## Running the application in dev mode + +You can run your application in dev mode that enables live coding using: + +```shell script +./mvnw quarkus:dev +``` + +> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at . + +## Packaging and running the application + +The application can be packaged using: + +```shell script +./mvnw package +``` + +It produces the `quarkus-run.jar` file in the `target/quarkus-app/` directory. +Be aware that it’s not an _über-jar_ as the dependencies are copied into the `target/quarkus-app/lib/` directory. + +The application is now runnable using `java -jar target/quarkus-app/quarkus-run.jar`. + +If you want to build an _über-jar_, execute the following command: + +```shell script +./mvnw package -Dquarkus.package.jar.type=uber-jar +``` + +The application, packaged as an _über-jar_, is now runnable using `java -jar target/*-runner.jar`. + +## Creating a native executable + +You can create a native executable using: + +```shell script +./mvnw package -Dnative +``` + +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: + +```shell script +./mvnw package -Dnative -Dquarkus.native.container-build=true +``` + +You can then execute your native executable with: `./target/ms-anti-fraud-1.0.0-SNAPSHOT-runner` + +If you want to learn more about building native executables, please consult . + +## Provided Code + +### REST + +Easily start your REST Web Services + +[Related guide section...](https://quarkus.io/guides/getting-started-reactive#reactive-jax-rs-resources) diff --git a/ms-anti-fraud/checkstyle-suppressions.xml b/ms-anti-fraud/checkstyle-suppressions.xml new file mode 100644 index 0000000000..2161f901d9 --- /dev/null +++ b/ms-anti-fraud/checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/ms-anti-fraud/checkstyle.xml b/ms-anti-fraud/checkstyle.xml new file mode 100644 index 0000000000..745a2ada9e --- /dev/null +++ b/ms-anti-fraud/checkstyle.xml @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ms-anti-fraud/devops/docker/Dockerfile.jvm b/ms-anti-fraud/devops/docker/Dockerfile.jvm new file mode 100644 index 0000000000..97416e8460 --- /dev/null +++ b/ms-anti-fraud/devops/docker/Dockerfile.jvm @@ -0,0 +1,32 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./mvnw package +# +# Then, build the image with: +# +# docker build -f devops/docker/Dockerfile.jvm -t quarkus/ms-anti-fraud-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 18081:18081 quarkus/ms-anti-fraud-jvm +# +### +FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 + +ENV LANGUAGE='en_US:en' + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 18081 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/ms-anti-fraud/pom.xml b/ms-anti-fraud/pom.xml new file mode 100644 index 0000000000..e1986a9f1a --- /dev/null +++ b/ms-anti-fraud/pom.xml @@ -0,0 +1,273 @@ + + + 4.0.0 + + + 3.14.1 + 21 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.31.1 + true + 3.5.4 + 7.9.0 + 0.8.12 + 3.6.0 + 12.1.2 + 4.8.6.6 + + + com.yape.services + ms-anti-fraud + 1.0.0-SNAPSHOT + quarkus + + MS Anti Fraud + Microservice for processing and validating transactions + 2026 + + + Yape + https://www.yape.com.pe + + + + + jcarripa + Juda Carrillo + jbcp2006@gmail.com + America/Lima + + Tech Lead + Senior Backend Developer + + + + + + + confluent + https://packages.confluent.io/maven/ + + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-config-yaml + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-hibernate-validator + + + + + io.quarkus + quarkus-messaging-kafka + + + + io.quarkus + quarkus-avro + + + io.quarkus + quarkus-confluent-registry-avro + + + io.confluent + kafka-avro-serializer + ${confluent.version} + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + @{argLine} + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + @{argLine} + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + + checkstyle.xml + checkstyle-suppressions.xml + true + true + true + + + + validate + validate + + check + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-plugin.version} + + spotbugs-exclude.xml + Max + Low + + + + spotbugs-check + verify + + check + + + + + + + + + + native + + + native + + + + false + false + true + + + + diff --git a/ms-anti-fraud/spotbugs-exclude.xml b/ms-anti-fraud/spotbugs-exclude.xml new file mode 100644 index 0000000000..150e3d46e1 --- /dev/null +++ b/ms-anti-fraud/spotbugs-exclude.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/ms-anti-fraud/src/main/avro/Event.avsc b/ms-anti-fraud/src/main/avro/Event.avsc new file mode 100644 index 0000000000..f3194d2a46 --- /dev/null +++ b/ms-anti-fraud/src/main/avro/Event.avsc @@ -0,0 +1,19 @@ +{ + "type": "record", + "name": "Event", + "namespace": "ccom.yape.services.transaction.avro", + "fields": [ + { + "name": "id", + "type": "string" + }, + { + "name": "type", + "type": "string" + }, + { + "name": "timestamp", + "type": "long" + } + ] +} \ No newline at end of file diff --git a/ms-anti-fraud/src/main/java/com/yape/services/GreetingResource.java b/ms-anti-fraud/src/main/java/com/yape/services/GreetingResource.java new file mode 100644 index 0000000000..5379fd9b8a --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/GreetingResource.java @@ -0,0 +1,24 @@ +package com.yape.services; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +/** + * A simple REST endpoint that returns a greeting message. + */ +@Path("/hello") +public class GreetingResource { + + /** + * Handles HTTP GET requests to the /hello endpoint. + * + * @return A plain text greeting message. + */ + @GET + @Produces(MediaType.TEXT_PLAIN) + public String hello() { + return "Hello from Quarkus REST"; + } +} diff --git a/ms-anti-fraud/src/main/resources/application-local.yml b/ms-anti-fraud/src/main/resources/application-local.yml new file mode 100644 index 0000000000..140eb1d9b4 --- /dev/null +++ b/ms-anti-fraud/src/main/resources/application-local.yml @@ -0,0 +1,62 @@ +# This configuration is used for local development environment. +# Profile: local (activate with -Dquarkus.profile=local) + +# External Service Hosts +event-hubs-host: "127.0.0.1" +schema-registry-host: "127.0.0.1" + +# Quarkus Configuration +quarkus: + devservices: + enabled: false + + # Logging Configuration + log: + level: INFO + category: + "com.yape": + level: DEBUG + "org.hibernate.SQL": + level: DEBUG + "org.hibernate.type.descriptor.sql": + level: TRACE + "io.smallrye.reactive.messaging": + level: DEBUG + +# Kafka / Event Streaming Configuration +kafka: + bootstrap: + servers: "${event-hubs-host}:9092" + schema: + registry: + url: "http://${schema-registry-host}:8081" + +# MicroProfile Reactive Messaging Configuration +mp: + messaging: + outgoing: + kafka-out: + connector: smallrye-kafka + topic: test-event + key: + serializer: org.apache.kafka.common.serialization.StringSerializer + value: + serializer: io.confluent.kafka.serializers.KafkaAvroSerializer + + incoming: + kafka-in: + connector: smallrye-kafka + topic: test-event + group: + id: ms-transaction-group + auto: + offset: + reset: earliest + failure-strategy: ignore + key: + deserializer: org.apache.kafka.common.serialization.StringDeserializer + value: + deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer + specific: + avro: + reader: true diff --git a/ms-anti-fraud/src/main/resources/application.yml b/ms-anti-fraud/src/main/resources/application.yml new file mode 100644 index 0000000000..fe01969797 --- /dev/null +++ b/ms-anti-fraud/src/main/resources/application.yml @@ -0,0 +1,15 @@ +quarkus: + application: + name: ms-anti-fraud + smallrye-health: + root-path: /health + ui: + enabled: false + http: + root-path: /ms-anti-fraud + port: 18081 + +info: + project: + artifact: ${project.artifactId} + version: ${project.version} \ No newline at end of file From 1e6d85f5da36fab92ba81a8a320762f1c4ed1fa1 Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 01:14:57 -0500 Subject: [PATCH 05/54] Update ms-transaction/src/main/resources/application-local.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-transaction/src/main/resources/application-local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-transaction/src/main/resources/application-local.yml b/ms-transaction/src/main/resources/application-local.yml index fcb4acda75..4baec81abb 100644 --- a/ms-transaction/src/main/resources/application-local.yml +++ b/ms-transaction/src/main/resources/application-local.yml @@ -31,7 +31,7 @@ quarkus: hibernate-orm: database: # Options: none, create, drop-and-create, drop, update, validate - generation: drop-and-create + generation: update log: sql: true format-sql: true From e3f576597fc281b95bf4e83f0ec901daf7a4ace6 Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 01:15:18 -0500 Subject: [PATCH 06/54] Update ms-transaction/devops/docker/Dockerfile.jvm Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-transaction/devops/docker/Dockerfile.jvm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-transaction/devops/docker/Dockerfile.jvm b/ms-transaction/devops/docker/Dockerfile.jvm index 4a63aa4a9c..68aa749985 100644 --- a/ms-transaction/devops/docker/Dockerfile.jvm +++ b/ms-transaction/devops/docker/Dockerfile.jvm @@ -11,7 +11,7 @@ # # Then run the container using: # -# docker run -i --rm -p 18081:18081 quarkus/ms-transaction-jvm +# docker run -i --rm -p 18080:18080 quarkus/ms-transaction-jvm # ### FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 From b29c49b6ff151dbe2c5ef0b5412821780e97d23c Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 01:15:32 -0500 Subject: [PATCH 07/54] Update ms-anti-fraud/src/main/avro/Event.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-anti-fraud/src/main/avro/Event.avsc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-anti-fraud/src/main/avro/Event.avsc b/ms-anti-fraud/src/main/avro/Event.avsc index f3194d2a46..3ac33864d0 100644 --- a/ms-anti-fraud/src/main/avro/Event.avsc +++ b/ms-anti-fraud/src/main/avro/Event.avsc @@ -1,7 +1,7 @@ { "type": "record", "name": "Event", - "namespace": "ccom.yape.services.transaction.avro", + "namespace": "com.yape.services.antifraud.avro", "fields": [ { "name": "id", From f6913c66c1e2027148b5ca0fa265a0f59a2780d6 Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 01:15:41 -0500 Subject: [PATCH 08/54] Update ms-anti-fraud/src/main/resources/application-local.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-anti-fraud/src/main/resources/application-local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-anti-fraud/src/main/resources/application-local.yml b/ms-anti-fraud/src/main/resources/application-local.yml index 140eb1d9b4..a9bf7430ef 100644 --- a/ms-anti-fraud/src/main/resources/application-local.yml +++ b/ms-anti-fraud/src/main/resources/application-local.yml @@ -48,7 +48,7 @@ mp: connector: smallrye-kafka topic: test-event group: - id: ms-transaction-group + id: ms-anti-fraud-group auto: offset: reset: earliest From d74c79a06f34f54b6324ffa971b6724b1f03101d Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 01:19:00 -0500 Subject: [PATCH 09/54] chore: add GraphQL sample schemas --- .../src/main/resources/schemas/mutations.graphqls | 15 +++++++++++++++ .../src/main/resources/schemas/queries.graphqls | 5 +++++ .../src/main/resources/schemas/schema.graphqls | 13 +++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 ms-transaction/src/main/resources/schemas/mutations.graphqls create mode 100644 ms-transaction/src/main/resources/schemas/queries.graphqls create mode 100644 ms-transaction/src/main/resources/schemas/schema.graphqls diff --git a/ms-transaction/src/main/resources/schemas/mutations.graphqls b/ms-transaction/src/main/resources/schemas/mutations.graphqls new file mode 100644 index 0000000000..514b44e997 --- /dev/null +++ b/ms-transaction/src/main/resources/schemas/mutations.graphqls @@ -0,0 +1,15 @@ +type Mutation { + createUser(input: CreateUserInput!): User + updateUser(id: ID!, input: UpdateUserInput!): User + deleteUser(id: ID!): Boolean +} + +input CreateUserInput { + name: String! + email: String! +} + +input UpdateUserInput { + name: String + email: String +} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/schemas/queries.graphqls b/ms-transaction/src/main/resources/schemas/queries.graphqls new file mode 100644 index 0000000000..c044909782 --- /dev/null +++ b/ms-transaction/src/main/resources/schemas/queries.graphqls @@ -0,0 +1,5 @@ +type Query { + getUserById(id: ID!): User + listUsers: [User!]! + getPostById(id: ID!): Post +} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/schemas/schema.graphqls b/ms-transaction/src/main/resources/schemas/schema.graphqls new file mode 100644 index 0000000000..c26dd5a02f --- /dev/null +++ b/ms-transaction/src/main/resources/schemas/schema.graphqls @@ -0,0 +1,13 @@ +type User { + id: ID! + name: String! + email: String! + posts: [Post!]! +} + +type Post { + id: ID! + title: String! + content: String! + author: User! +} \ No newline at end of file From e451bb73582bbb8e4eecb278b656385299bec2ab Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 01:19:45 -0500 Subject: [PATCH 10/54] chore: correct namespace in Event.avsc --- ms-transaction/src/main/avro/Event.avsc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-transaction/src/main/avro/Event.avsc b/ms-transaction/src/main/avro/Event.avsc index f3194d2a46..abd3d6cdb7 100644 --- a/ms-transaction/src/main/avro/Event.avsc +++ b/ms-transaction/src/main/avro/Event.avsc @@ -1,7 +1,7 @@ { "type": "record", "name": "Event", - "namespace": "ccom.yape.services.transaction.avro", + "namespace": "com.yape.services.transaction.avro", "fields": [ { "name": "id", From e8b4cc46bc9d3d0db92a3e2b316aa589201dca24 Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 01:20:36 -0500 Subject: [PATCH 11/54] chore: update PostgreSQL connection URL --- ms-transaction/src/main/resources/application-local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-transaction/src/main/resources/application-local.yml b/ms-transaction/src/main/resources/application-local.yml index 4baec81abb..cb67bd792b 100644 --- a/ms-transaction/src/main/resources/application-local.yml +++ b/ms-transaction/src/main/resources/application-local.yml @@ -19,7 +19,7 @@ quarkus: username: "${postgresql-user}" password: "${postgresql-pass}" jdbc: - url: "jdbc:postgresql://${postgresql-host}:${postgresql-port}/postgres" + url: "jdbc:postgresql://${postgresql-host}:${postgresql-port}/yape_transactions" # Connection pool settings max-size: 16 min-size: 4 From 8ba064482bb413be42a78aff17d7a8717bdb920e Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 03:07:04 -0500 Subject: [PATCH 12/54] chore: remove GraphQL schema examples --- .../src/main/resources/schemas/mutations.graphqls | 15 --------------- .../src/main/resources/schemas/queries.graphqls | 5 ----- .../src/main/resources/schemas/schema.graphqls | 13 ------------- 3 files changed, 33 deletions(-) delete mode 100644 ms-transaction/src/main/resources/schemas/mutations.graphqls delete mode 100644 ms-transaction/src/main/resources/schemas/queries.graphqls delete mode 100644 ms-transaction/src/main/resources/schemas/schema.graphqls diff --git a/ms-transaction/src/main/resources/schemas/mutations.graphqls b/ms-transaction/src/main/resources/schemas/mutations.graphqls deleted file mode 100644 index 514b44e997..0000000000 --- a/ms-transaction/src/main/resources/schemas/mutations.graphqls +++ /dev/null @@ -1,15 +0,0 @@ -type Mutation { - createUser(input: CreateUserInput!): User - updateUser(id: ID!, input: UpdateUserInput!): User - deleteUser(id: ID!): Boolean -} - -input CreateUserInput { - name: String! - email: String! -} - -input UpdateUserInput { - name: String - email: String -} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/schemas/queries.graphqls b/ms-transaction/src/main/resources/schemas/queries.graphqls deleted file mode 100644 index c044909782..0000000000 --- a/ms-transaction/src/main/resources/schemas/queries.graphqls +++ /dev/null @@ -1,5 +0,0 @@ -type Query { - getUserById(id: ID!): User - listUsers: [User!]! - getPostById(id: ID!): Post -} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/schemas/schema.graphqls b/ms-transaction/src/main/resources/schemas/schema.graphqls deleted file mode 100644 index c26dd5a02f..0000000000 --- a/ms-transaction/src/main/resources/schemas/schema.graphqls +++ /dev/null @@ -1,13 +0,0 @@ -type User { - id: ID! - name: String! - email: String! - posts: [Post!]! -} - -type Post { - id: ID! - title: String! - content: String! - author: User! -} \ No newline at end of file From f6db08e29f3313eb6f92babbbe410c6e055db00b Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 03:11:59 -0500 Subject: [PATCH 13/54] chore: Add global GraphQL directive and scalar definitions --- .../graphql-client/directive.graphqls | 18 ++++++++++++++++++ .../resources/graphql-client/scalar.graphqls | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 ms-transaction/src/main/resources/graphql-client/directive.graphqls create mode 100644 ms-transaction/src/main/resources/graphql-client/scalar.graphqls diff --git a/ms-transaction/src/main/resources/graphql-client/directive.graphqls b/ms-transaction/src/main/resources/graphql-client/directive.graphqls new file mode 100644 index 0000000000..7800b2848f --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/directive.graphqls @@ -0,0 +1,18 @@ +""" +Indicates that the field requires authentication +""" +directive @authenticated on FIELD_DEFINITION + +""" +Indicates the maximum allowed value for a field or argument +""" +directive @Max( + value : BigDecimal = 2147483647 +) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + +""" +Indicates the minimum allowed value for a field or argument +""" +directive @Min( + value : BigDecimal = -2147483648 +) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/scalar.graphqls b/ms-transaction/src/main/resources/graphql-client/scalar.graphqls new file mode 100644 index 0000000000..8966b0ff32 --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/scalar.graphqls @@ -0,0 +1,17 @@ +""" +Universal Unique Identifier (UUID) +Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +""" +scalar UUID + +""" +Timestamp in ISO 8601 format +Format: yyyy-MM-dd'T'HH:mm:ss.SSSZ +""" +scalar DateTime + +""" +Monetary value with decimal precision +Example: 1500.50 +""" +scalar BigDecimal \ No newline at end of file From f2e91d859b9be41656775447248ce28b8374ee41 Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 03:12:15 -0500 Subject: [PATCH 14/54] chore: Add GraphQL schemas for transaction domain --- .../graphql-client/transaction/enums.graphqls | 34 ++++++++ .../transaction/inputs.graphqls | 30 +++++++ .../transaction/mutations.graphqls | 33 +++++++ .../transaction/queries.graphqls | 28 ++++++ .../transaction/subscriptions.graphqls | 26 ++++++ .../graphql-client/transaction/types.graphqls | 85 +++++++++++++++++++ 6 files changed, 236 insertions(+) create mode 100644 ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls create mode 100644 ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls create mode 100644 ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls create mode 100644 ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls create mode 100644 ms-transaction/src/main/resources/graphql-client/transaction/subscriptions.graphqls create mode 100644 ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls new file mode 100644 index 0000000000..6332e02991 --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls @@ -0,0 +1,34 @@ +# ============================================================================= +# Transaction Domain - Enum Definitions +# ============================================================================= + +""" +Status of a transaction in the anti-fraud validation flow. +""" +enum TransactionStatus { + """Transaction created, pending anti-fraud validation""" + PENDING + + """Transaction approved by the anti-fraud system""" + APPROVED + + """Transaction rejected by the anti-fraud system (e.g., value > 1000)""" + REJECTED +} + +""" +Types of fund transfers supported by the system. +""" +enum TransferType { + """Transfer between accounts of the same user""" + INTERNAL + + """Transfer to an account of a different user within the same bank""" + INTRABANK + + """Transfer to an account in a different bank""" + INTERBANK + + """International transfer""" + INTERNATIONAL +} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls new file mode 100644 index 0000000000..9f16400aca --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls @@ -0,0 +1,30 @@ +# ============================================================================= +# Transaction Domain - Input Definitions +# ============================================================================= + +""" +Input for creating a new financial transaction. +""" +input CreateTransactionInput { + """Identifier of the debit account (source)""" + accountExternalIdDebit: UUID! + + """Identifier of the credit account (destination)""" + accountExternalIdCredit: UUID! + + """ + Transfer type identifier: + - 1: INTERNAL + - 2: INTRABANK + - 3: INTERBANK + - 4: INTERNATIONAL + """ + transferTypeId: Int! + + """ + Transaction amount. + - Minimum: 0.10 + - Maximum: 1000 (values > 1000 are rejected by anti-fraud) + """ + value: BigDecimal! @Max(value: 1000) @Min(value: 0.10) +} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls new file mode 100644 index 0000000000..3e22d4e255 --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls @@ -0,0 +1,33 @@ +# ============================================================================= +# Transaction Domain - Mutation Definitions +# ============================================================================= + +""" +Root Mutation type for transaction operations. +""" +type Mutation { + """ + Creates a new financial transaction. + + The transaction is created with PENDING status and sent to the + anti-fraud service for validation. Transactions with value > 1000 + will be automatically rejected. + + Security: + - Requires authentication + + Validation rules: + - accountExternalIdDebit and accountExternalIdCredit must be different + - value must be between 0.10 and 1000 + - transferTypeId must be valid (1-4) + + Possible errors: + - UNAUTHORIZED: Invalid or expired token + - BAD_REQUEST: Invalid input data + - VALIDATION_ERROR: Business rule violation + """ + createTransaction( + """Transaction data""" + input: CreateTransactionInput! + ): CreatedTransaction! @authenticated +} diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls new file mode 100644 index 0000000000..7a9c8cc6a3 --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls @@ -0,0 +1,28 @@ +# ============================================================================= +# Transaction Domain - Query Definitions +# ============================================================================= + +""" +Root Query type for transaction-related operations. +""" +type Query { + """ + Retrieves a transaction by its external identifier. + + Security: + - Requires authentication + + Possible errors: + - NOT_FOUND: Transaction does not exist + - UNAUTHORIZED: Invalid or expired token + """ + transaction( + """Unique external identifier of the transaction""" + transactionExternalId: UUID! + ): Transaction @authenticated + + """ + Retrieves all available transfer types. + """ + transferTypes: [TransactionType!]! +} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/subscriptions.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/subscriptions.graphqls new file mode 100644 index 0000000000..d72b2b565d --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/subscriptions.graphqls @@ -0,0 +1,26 @@ +# ============================================================================= +# Transaction Domain - Subscription Definitions +# ============================================================================= + +""" +Root Subscription type for real-time transaction updates. +""" +type Subscription { + """ + Subscribe to status changes of a specific transaction. + + Enables real-time notifications when the anti-fraud service + updates the transaction status. + + Security: + - Requires authentication via token in WebSocket connection payload + + Emitted events: + - PENDING → APPROVED: Transaction approved by anti-fraud + - PENDING → REJECTED: Transaction rejected by anti-fraud (value > 1000) + """ + transactionStatusChanged( + """Identifier of the transaction to observe""" + transactionExternalId: UUID! + ): TransactionStatusChangedEvent! @authenticated +} diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls new file mode 100644 index 0000000000..6a32452b99 --- /dev/null +++ b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls @@ -0,0 +1,85 @@ +# ============================================================================= +# Transaction Domain - Type Definitions +# ============================================================================= + +""" +Represents a financial transaction in the system. +""" +type Transaction { + """Unique external identifier of the transaction""" + transactionExternalId: UUID! + + """Identifier of the debit account (source)""" + accountExternalIdDebit: UUID! + + """Identifier of the credit account (destination)""" + accountExternalIdCredit: UUID! + + """Type of the transaction""" + transactionType: TransactionType! + + """Current status of the transaction""" + transactionStatus: TransactionStatusDetail! + + """Amount of the transaction""" + value: BigDecimal! + + """Date and time of the transaction creation""" + createdAt: DateTime! + + """Date and time of the last update""" + updatedAt: DateTime! +} + +""" +Response type for createTransaction mutation. +""" +type CreatedTransaction { + """The created transaction""" + transaction: Transaction +} + +""" +Details about a transaction type (e.g., INTERNAL, INTRABANK). +""" +type TransactionType { + """Identifier of the type""" + id: Int! + + """Name of the transaction type""" + name: String! + + """Description of the type""" + description: String +} + +""" +Details about a transaction status. +""" +type TransactionStatusDetail { + """Name of the status (PENDING, APPROVED, REJECTED)""" + name: TransactionStatus! + + """Description of the status""" + description: String + + """Date when this status was assigned""" + assignedAt: DateTime +} + +""" +Event emitted when a transaction status changes (for Subscriptions). +""" +type TransactionStatusChangedEvent { + """The updated transaction""" + transaction: Transaction! + + """Previous status""" + previousStatus: TransactionStatus! + + """New status""" + newStatus: TransactionStatus! + + """Date and time of the change""" + changedAt: DateTime! +} From b3d3d3a77528048e9f1f2554dc1b4d1e988822a2 Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 03:20:50 -0500 Subject: [PATCH 15/54] chore: update GraphQL schema configuration in pom.xml --- ms-transaction/pom.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ms-transaction/pom.xml b/ms-transaction/pom.xml index e221e95622..4f3685da3e 100644 --- a/ms-transaction/pom.xml +++ b/ms-transaction/pom.xml @@ -129,7 +129,7 @@ io.quarkus - quarkus-junit5 + quarkus-junit test @@ -170,11 +170,11 @@ - - src/main/resources/schemas/schema.graphqls - src/main/resources/schemas/queries.graphqls - src/main/resources/schemas/mutations.graphqls - + + ${project.basedir}/src/main/resources/graphql-client + .*\.graphqls$ + true + ${project.build.directory}/generated-sources/graphql true true From f163d4016e96b9c23e7eb2f0f0180fb9bf535eef Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 03:30:29 -0500 Subject: [PATCH 16/54] Update ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/resources/graphql-client/transaction/types.graphqls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls index 6a32452b99..f7432759c9 100644 --- a/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls @@ -36,7 +36,7 @@ Response type for createTransaction mutation. """ type CreatedTransaction { """The created transaction""" - transaction: Transaction + transaction: Transaction! } """ From 8fcaac3e5a3657a72b745f34eeb4b939e2b9a6ac Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 03:34:27 -0500 Subject: [PATCH 17/54] Update ms-transaction/src/main/resources/graphql-client/directive.graphqls Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/main/resources/graphql-client/directive.graphqls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ms-transaction/src/main/resources/graphql-client/directive.graphqls b/ms-transaction/src/main/resources/graphql-client/directive.graphqls index 7800b2848f..7ed0b85272 100644 --- a/ms-transaction/src/main/resources/graphql-client/directive.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/directive.graphqls @@ -7,12 +7,12 @@ directive @authenticated on FIELD_DEFINITION Indicates the maximum allowed value for a field or argument """ directive @Max( - value : BigDecimal = 2147483647 + value : BigDecimal = 2147483647.00 ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION """ Indicates the minimum allowed value for a field or argument """ directive @Min( - value : BigDecimal = -2147483648 + value : BigDecimal = -2147483648.00 ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION \ No newline at end of file From bf9c18b6c8b85a2e11ec897d00b07b85fa88c434 Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 03:35:37 -0500 Subject: [PATCH 18/54] Update ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../graphql-client/transaction/enums.graphqls | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls index 6332e02991..7cc2c9e1d1 100644 --- a/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls @@ -14,21 +14,4 @@ enum TransactionStatus { """Transaction rejected by the anti-fraud system (e.g., value > 1000)""" REJECTED -} - -""" -Types of fund transfers supported by the system. -""" -enum TransferType { - """Transfer between accounts of the same user""" - INTERNAL - - """Transfer to an account of a different user within the same bank""" - INTRABANK - - """Transfer to an account in a different bank""" - INTERBANK - - """International transfer""" - INTERNATIONAL } \ No newline at end of file From 88daae84edbffb74431454659564d535d4480aaf Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 04:31:57 -0500 Subject: [PATCH 19/54] chore: remove AVRO schema example --- ms-anti-fraud/src/main/avro/Event.avsc | 19 ------------------- ms-transaction/src/main/avro/Event.avsc | 19 ------------------- 2 files changed, 38 deletions(-) delete mode 100644 ms-anti-fraud/src/main/avro/Event.avsc delete mode 100644 ms-transaction/src/main/avro/Event.avsc diff --git a/ms-anti-fraud/src/main/avro/Event.avsc b/ms-anti-fraud/src/main/avro/Event.avsc deleted file mode 100644 index 3ac33864d0..0000000000 --- a/ms-anti-fraud/src/main/avro/Event.avsc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "record", - "name": "Event", - "namespace": "com.yape.services.antifraud.avro", - "fields": [ - { - "name": "id", - "type": "string" - }, - { - "name": "type", - "type": "string" - }, - { - "name": "timestamp", - "type": "long" - } - ] -} \ No newline at end of file diff --git a/ms-transaction/src/main/avro/Event.avsc b/ms-transaction/src/main/avro/Event.avsc deleted file mode 100644 index abd3d6cdb7..0000000000 --- a/ms-transaction/src/main/avro/Event.avsc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "record", - "name": "Event", - "namespace": "com.yape.services.transaction.avro", - "fields": [ - { - "name": "id", - "type": "string" - }, - { - "name": "type", - "type": "string" - }, - { - "name": "timestamp", - "type": "long" - } - ] -} \ No newline at end of file From 36565cba9bf5b9453378ebc89f8ca774a5936d5c Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 04:32:12 -0500 Subject: [PATCH 20/54] chore: add AVRO schemas for transaction events --- .../main/avro/TransactionCreatedEvent.avsc | 128 ++++++++++++++++++ .../avro/TransactionStatusUpdatedEvent.avsc | 127 +++++++++++++++++ .../main/avro/TransactionCreatedEvent.avsc | 128 ++++++++++++++++++ .../avro/TransactionStatusUpdatedEvent.avsc | 127 +++++++++++++++++ 4 files changed, 510 insertions(+) create mode 100644 ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc create mode 100644 ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc create mode 100644 ms-transaction/src/main/avro/TransactionCreatedEvent.avsc create mode 100644 ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc diff --git a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc new file mode 100644 index 0000000000..d48d7548fd --- /dev/null +++ b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc @@ -0,0 +1,128 @@ +{ + "type": "record", + "name": "TransactionCreatedEvent", + "namespace": "com.yape.transaction.events", + "doc": "Emitted event when a new transaction is created. Published to the topic 'transaction.created' for the Anti-Fraud service to validate the transaction.", + "fields": [ + { + "name": "metadata", + "type": { + "type": "record", + "name": "EventMetadata", + "namespace": "com.yape.common.events", + "doc": "Standard metadata for all system events", + "fields": [ + { + "name": "eventId", + "type": "string", + "doc": "Unique identifier of the event (UUID v4)" + }, + { + "name": "eventType", + "type": "string", + "doc": "Type of the event (e.g., TRANSACTION_CREATED, TRANSACTION_STATUS_UPDATED)" + }, + { + "name": "eventTimestamp", + "type": "string", + "doc": "Timestamp of event creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + }, + { + "name": "source", + "type": "string", + "doc": "Service originating the event (e.g., ms-transaction, ms-anti-fraud)" + }, + { + "name": "version", + "type": "string", + "default": "1.0.0", + "doc": "Version of the event schema" + }, + { + "name": "requestId", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Original Request-Id of the HTTP request that triggered the event" + } + ] + }, + "doc": "Event metadata" + }, + { + "name": "payload", + "type": { + "type": "record", + "name": "TransactionCreatedPayload", + "doc": "Payload of the created transaction", + "fields": [ + { + "name": "transactionExternalId", + "type": "string", + "doc": "Unique external identifier of the transaction (UUID v4)" + }, + { + "name": "accountExternalIdDebit", + "type": "string", + "doc": "Debit account external identifier (source)" + }, + { + "name": "accountExternalIdCredit", + "type": "string", + "doc": "Credit account external identifier (source)" + }, + { + "name": "transferTypeId", + "type": "int", + "doc": "Transfer type identifier. (1: INTERNAL, 2: INTRABANK, 3: INTERBANK, 4: INTERNATIONAL)" + }, + { + "name": "transferType", + "type": { + "type": "enum", + "name": "TransferType", + "namespace": "com.yape.transaction.events.enums", + "doc": "Transfer types supported by the system", + "symbols": [ + "INTERNAL", + "INTRABANK", + "INTERBANK", + "INTERNATIONAL" + ] + }, + "doc": "Transfer type as enum" + }, + { + "name": "value", + "type": "string", + "doc": "Transaction monetary value as string to preserve precision" + }, + { + "name": "status", + "type": { + "type": "enum", + "name": "TransactionStatus", + "namespace": "com.yape.transaction.events.enums", + "doc": "Possible statuses of a financial transaction", + "symbols": [ + "PENDING", + "APPROVED", + "REJECTED" + ], + "default": "PENDING" + }, + "doc": "Current status of the transaction (always PENDING upon creation)" + }, + { + "name": "createdAt", + "type": "string", + "doc": "Timestamp of transaction creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + } + ] + }, + "doc": "Payload of the created transaction" + } + ] +} \ No newline at end of file diff --git a/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc new file mode 100644 index 0000000000..d5f80d667a --- /dev/null +++ b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -0,0 +1,127 @@ +{ + "type": "record", + "name": "TransactionStatusUpdatedEvent", + "namespace": "com.yape.transaction.events", + "doc": "Emitted event when Anti-Fraud service updates the status of a transaction. Published to the topic 'transaction.status' for ms-transaction to update the status in the database.", + "fields": [ + { + "name": "metadata", + "type": { + "type": "record", + "name": "EventMetadata", + "namespace": "com.yape.common.events", + "doc": "Standard metadata for all system events", + "fields": [ + { + "name": "eventId", + "type": "string", + "doc": "Unique identifier of the event (UUID v4)" + }, + { + "name": "eventType", + "type": "string", + "doc": "Type of the event (e.g., TRANSACTION_CREATED, TRANSACTION_STATUS_UPDATED)" + }, + { + "name": "eventTimestamp", + "type": "string", + "doc": "Timestamp of event creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + }, + { + "name": "source", + "type": "string", + "doc": "Service originating the event (e.g., ms-transaction, ms-anti-fraud)" + }, + { + "name": "version", + "type": "string", + "default": "1.0.0", + "doc": "Version of the event schema" + }, + { + "name": "requestId", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Original Request-Id of the HTTP request that triggered the event" + } + ] + }, + "doc": "Event metadata" + }, + { + "name": "payload", + "type": { + "type": "record", + "name": "TransactionStatusUpdatedPayload", + "doc": "Payload with the update data", + "fields": [ + { + "name": "transactionExternalId", + "type": "string", + "doc": "Unique external identifier of the transaction (UUID v4)" + }, + { + "name": "previousStatus", + "type": { + "type": "enum", + "name": "TransactionStatus", + "namespace": "com.yape.transaction.events.enums", + "doc": "Possible previous status of the transaction", + "symbols": [ + "PENDING", + "APPROVED", + "REJECTED" + ], + "default": "PENDING" + }, + "doc": "Previous status of the transaction" + }, + { + "name": "newStatus", + "type": "com.yape.transaction.events.enums.TransactionStatus", + "doc": "New status of the transaction" + }, + { + "name": "value", + "type": "string", + "doc": "Monetary value of the transaction as a string to preserve precision" + }, + { + "name": "validationResult", + "type": { + "type": "record", + "name": "ValidationResult", + "doc": "Details of the anti-fraud validation result", + "fields": [ + { + "name": "isValid", + "type": "boolean", + "doc": "Indicates if the transaction passed the anti-fraud validation" + }, + { + "name": "ruleCode", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Code of the applied rule (e.g., MAX_AMOUNT_EXCEEDED)" + } + ] + }, + "doc": "Details of the anti-fraud validation result" + }, + { + "name": "processedAt", + "type": "string", + "doc": "Timestamp when the validation was processed in ISO 8601 format" + } + ] + }, + "doc": "Payload with the update data" + } + ] +} \ No newline at end of file diff --git a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc new file mode 100644 index 0000000000..d48d7548fd --- /dev/null +++ b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc @@ -0,0 +1,128 @@ +{ + "type": "record", + "name": "TransactionCreatedEvent", + "namespace": "com.yape.transaction.events", + "doc": "Emitted event when a new transaction is created. Published to the topic 'transaction.created' for the Anti-Fraud service to validate the transaction.", + "fields": [ + { + "name": "metadata", + "type": { + "type": "record", + "name": "EventMetadata", + "namespace": "com.yape.common.events", + "doc": "Standard metadata for all system events", + "fields": [ + { + "name": "eventId", + "type": "string", + "doc": "Unique identifier of the event (UUID v4)" + }, + { + "name": "eventType", + "type": "string", + "doc": "Type of the event (e.g., TRANSACTION_CREATED, TRANSACTION_STATUS_UPDATED)" + }, + { + "name": "eventTimestamp", + "type": "string", + "doc": "Timestamp of event creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + }, + { + "name": "source", + "type": "string", + "doc": "Service originating the event (e.g., ms-transaction, ms-anti-fraud)" + }, + { + "name": "version", + "type": "string", + "default": "1.0.0", + "doc": "Version of the event schema" + }, + { + "name": "requestId", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Original Request-Id of the HTTP request that triggered the event" + } + ] + }, + "doc": "Event metadata" + }, + { + "name": "payload", + "type": { + "type": "record", + "name": "TransactionCreatedPayload", + "doc": "Payload of the created transaction", + "fields": [ + { + "name": "transactionExternalId", + "type": "string", + "doc": "Unique external identifier of the transaction (UUID v4)" + }, + { + "name": "accountExternalIdDebit", + "type": "string", + "doc": "Debit account external identifier (source)" + }, + { + "name": "accountExternalIdCredit", + "type": "string", + "doc": "Credit account external identifier (source)" + }, + { + "name": "transferTypeId", + "type": "int", + "doc": "Transfer type identifier. (1: INTERNAL, 2: INTRABANK, 3: INTERBANK, 4: INTERNATIONAL)" + }, + { + "name": "transferType", + "type": { + "type": "enum", + "name": "TransferType", + "namespace": "com.yape.transaction.events.enums", + "doc": "Transfer types supported by the system", + "symbols": [ + "INTERNAL", + "INTRABANK", + "INTERBANK", + "INTERNATIONAL" + ] + }, + "doc": "Transfer type as enum" + }, + { + "name": "value", + "type": "string", + "doc": "Transaction monetary value as string to preserve precision" + }, + { + "name": "status", + "type": { + "type": "enum", + "name": "TransactionStatus", + "namespace": "com.yape.transaction.events.enums", + "doc": "Possible statuses of a financial transaction", + "symbols": [ + "PENDING", + "APPROVED", + "REJECTED" + ], + "default": "PENDING" + }, + "doc": "Current status of the transaction (always PENDING upon creation)" + }, + { + "name": "createdAt", + "type": "string", + "doc": "Timestamp of transaction creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + } + ] + }, + "doc": "Payload of the created transaction" + } + ] +} \ No newline at end of file diff --git a/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc new file mode 100644 index 0000000000..d5f80d667a --- /dev/null +++ b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -0,0 +1,127 @@ +{ + "type": "record", + "name": "TransactionStatusUpdatedEvent", + "namespace": "com.yape.transaction.events", + "doc": "Emitted event when Anti-Fraud service updates the status of a transaction. Published to the topic 'transaction.status' for ms-transaction to update the status in the database.", + "fields": [ + { + "name": "metadata", + "type": { + "type": "record", + "name": "EventMetadata", + "namespace": "com.yape.common.events", + "doc": "Standard metadata for all system events", + "fields": [ + { + "name": "eventId", + "type": "string", + "doc": "Unique identifier of the event (UUID v4)" + }, + { + "name": "eventType", + "type": "string", + "doc": "Type of the event (e.g., TRANSACTION_CREATED, TRANSACTION_STATUS_UPDATED)" + }, + { + "name": "eventTimestamp", + "type": "string", + "doc": "Timestamp of event creation in ISO 8601 format (yyyy-MM-dd'T'HH:mm:ss.SSSZ)" + }, + { + "name": "source", + "type": "string", + "doc": "Service originating the event (e.g., ms-transaction, ms-anti-fraud)" + }, + { + "name": "version", + "type": "string", + "default": "1.0.0", + "doc": "Version of the event schema" + }, + { + "name": "requestId", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Original Request-Id of the HTTP request that triggered the event" + } + ] + }, + "doc": "Event metadata" + }, + { + "name": "payload", + "type": { + "type": "record", + "name": "TransactionStatusUpdatedPayload", + "doc": "Payload with the update data", + "fields": [ + { + "name": "transactionExternalId", + "type": "string", + "doc": "Unique external identifier of the transaction (UUID v4)" + }, + { + "name": "previousStatus", + "type": { + "type": "enum", + "name": "TransactionStatus", + "namespace": "com.yape.transaction.events.enums", + "doc": "Possible previous status of the transaction", + "symbols": [ + "PENDING", + "APPROVED", + "REJECTED" + ], + "default": "PENDING" + }, + "doc": "Previous status of the transaction" + }, + { + "name": "newStatus", + "type": "com.yape.transaction.events.enums.TransactionStatus", + "doc": "New status of the transaction" + }, + { + "name": "value", + "type": "string", + "doc": "Monetary value of the transaction as a string to preserve precision" + }, + { + "name": "validationResult", + "type": { + "type": "record", + "name": "ValidationResult", + "doc": "Details of the anti-fraud validation result", + "fields": [ + { + "name": "isValid", + "type": "boolean", + "doc": "Indicates if the transaction passed the anti-fraud validation" + }, + { + "name": "ruleCode", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Code of the applied rule (e.g., MAX_AMOUNT_EXCEEDED)" + } + ] + }, + "doc": "Details of the anti-fraud validation result" + }, + { + "name": "processedAt", + "type": "string", + "doc": "Timestamp when the validation was processed in ISO 8601 format" + } + ] + }, + "doc": "Payload with the update data" + } + ] +} \ No newline at end of file From 516f7722feed8a60fd290161824e72c4477438f8 Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 04:46:38 -0500 Subject: [PATCH 21/54] Update ms-transaction/src/main/avro/TransactionCreatedEvent.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-transaction/src/main/avro/TransactionCreatedEvent.avsc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc index d48d7548fd..8959a892f3 100644 --- a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc +++ b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc @@ -71,7 +71,7 @@ { "name": "accountExternalIdCredit", "type": "string", - "doc": "Credit account external identifier (source)" + "doc": "Credit account external identifier (destination)" }, { "name": "transferTypeId", From b252ebd1cf26fc4891442c012264f61f23c4827a Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 04:47:08 -0500 Subject: [PATCH 22/54] Update ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc index d48d7548fd..8959a892f3 100644 --- a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc +++ b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc @@ -71,7 +71,7 @@ { "name": "accountExternalIdCredit", "type": "string", - "doc": "Credit account external identifier (source)" + "doc": "Credit account external identifier (destination)" }, { "name": "transferTypeId", From 798cc8b2427128ef9ab038a80baa997eb14e6fff Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 11:08:52 -0500 Subject: [PATCH 23/54] Update ms-transaction/src/main/avro/TransactionCreatedEvent.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-transaction/src/main/avro/TransactionCreatedEvent.avsc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc index 8959a892f3..99327183c1 100644 --- a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc +++ b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc @@ -110,8 +110,7 @@ "PENDING", "APPROVED", "REJECTED" - ], - "default": "PENDING" + ] }, "doc": "Current status of the transaction (always PENDING upon creation)" }, From ffd24d15652d9f8291309a74baa769812f8d9397 Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 11:09:49 -0500 Subject: [PATCH 24/54] Update ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc index d5f80d667a..3d9cc0d3dc 100644 --- a/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc +++ b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -74,8 +74,7 @@ "PENDING", "APPROVED", "REJECTED" - ], - "default": "PENDING" + ] }, "doc": "Previous status of the transaction" }, From d6cf2a0eb60e04913ea8296560aaf6c819113b1e Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 11:11:17 -0500 Subject: [PATCH 25/54] Update ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc index d5f80d667a..000337773a 100644 --- a/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc +++ b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -69,7 +69,7 @@ "type": "enum", "name": "TransactionStatus", "namespace": "com.yape.transaction.events.enums", - "doc": "Possible previous status of the transaction", + "doc": "Possible statuses of a financial transaction", "symbols": [ "PENDING", "APPROVED", From 9d456d5cdf51753de79a85c283d5cc005bfc2401 Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 11:13:00 -0500 Subject: [PATCH 26/54] Update ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc index 8959a892f3..99327183c1 100644 --- a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc +++ b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc @@ -110,8 +110,7 @@ "PENDING", "APPROVED", "REJECTED" - ], - "default": "PENDING" + ] }, "doc": "Current status of the transaction (always PENDING upon creation)" }, From 67e553a83c8af819f061bd847c97b9645f271d6d Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 11:13:20 -0500 Subject: [PATCH 27/54] Update ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc index 3d9cc0d3dc..84977f6c9c 100644 --- a/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc +++ b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -69,7 +69,7 @@ "type": "enum", "name": "TransactionStatus", "namespace": "com.yape.transaction.events.enums", - "doc": "Possible previous status of the transaction", + "doc": "Possible statuses of a financial transaction", "symbols": [ "PENDING", "APPROVED", From 98df5113062949c7ac8a0323972599dbef2afc73 Mon Sep 17 00:00:00 2001 From: Juda Carrillo Date: Thu, 29 Jan 2026 11:15:22 -0500 Subject: [PATCH 28/54] Update ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/main/avro/TransactionStatusUpdatedEvent.avsc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc index 000337773a..84977f6c9c 100644 --- a/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc +++ b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -74,8 +74,7 @@ "PENDING", "APPROVED", "REJECTED" - ], - "default": "PENDING" + ] }, "doc": "Previous status of the transaction" }, From 949961e5b8f61766f8b71e2a3de6d54a2427e724 Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 11:19:41 -0500 Subject: [PATCH 29/54] chore: remove transferType field from TransactionCreatedEvent AVRO schema --- .../src/main/avro/TransactionCreatedEvent.avsc | 16 ---------------- .../src/main/avro/TransactionCreatedEvent.avsc | 16 ---------------- 2 files changed, 32 deletions(-) diff --git a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc index 99327183c1..e3c57516da 100644 --- a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc +++ b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc @@ -78,22 +78,6 @@ "type": "int", "doc": "Transfer type identifier. (1: INTERNAL, 2: INTRABANK, 3: INTERBANK, 4: INTERNATIONAL)" }, - { - "name": "transferType", - "type": { - "type": "enum", - "name": "TransferType", - "namespace": "com.yape.transaction.events.enums", - "doc": "Transfer types supported by the system", - "symbols": [ - "INTERNAL", - "INTRABANK", - "INTERBANK", - "INTERNATIONAL" - ] - }, - "doc": "Transfer type as enum" - }, { "name": "value", "type": "string", diff --git a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc index 99327183c1..e3c57516da 100644 --- a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc +++ b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc @@ -78,22 +78,6 @@ "type": "int", "doc": "Transfer type identifier. (1: INTERNAL, 2: INTRABANK, 3: INTERBANK, 4: INTERNATIONAL)" }, - { - "name": "transferType", - "type": { - "type": "enum", - "name": "TransferType", - "namespace": "com.yape.transaction.events.enums", - "doc": "Transfer types supported by the system", - "symbols": [ - "INTERNAL", - "INTRABANK", - "INTERBANK", - "INTERNATIONAL" - ] - }, - "doc": "Transfer type as enum" - }, { "name": "value", "type": "string", From 919c9dd141d06a0961aa3228d48a97af341342d3 Mon Sep 17 00:00:00 2001 From: Juda Date: Thu, 29 Jan 2026 11:44:55 -0500 Subject: [PATCH 30/54] chore: update quarkus-junit artifact in pom.xml --- ms-anti-fraud/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ms-anti-fraud/pom.xml b/ms-anti-fraud/pom.xml index e1986a9f1a..23ee07a92a 100644 --- a/ms-anti-fraud/pom.xml +++ b/ms-anti-fraud/pom.xml @@ -112,7 +112,7 @@ io.quarkus - quarkus-junit5 + quarkus-junit test From be1cb6088267db3c4c66c21c1e6a471ec7357319 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 00:51:42 -0500 Subject: [PATCH 31/54] chore: add Lombok, Redisson, Jackson datatype and Flyway dependencies --- ms-transaction/pom.xml | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/ms-transaction/pom.xml b/ms-transaction/pom.xml index 4f3685da3e..5cda70cab1 100644 --- a/ms-transaction/pom.xml +++ b/ms-transaction/pom.xml @@ -19,6 +19,8 @@ 3.6.0 12.1.2 4.8.6.6 + 1.18.42 + 3.43.0 com.yape.services @@ -90,7 +92,21 @@ quarkus-hibernate-validator - + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + io.quarkus quarkus-jdbc-postgresql @@ -100,6 +116,19 @@ quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-flyway + + + + + org.redisson + redisson-quarkus-30 + ${redisson.version} + + io.quarkus @@ -177,7 +206,6 @@ ${project.build.directory}/generated-sources/graphql true - true com.yape.services.transaction.graphql.model com.yape.services.transaction.graphql.api true @@ -191,6 +219,13 @@ ${compiler-plugin.version} true + + + org.projectlombok + lombok + ${lombok.version} + + From 57ba42c3eebb06055fad0c477ac8c5db2fd80997 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:55:30 -0500 Subject: [PATCH 32/54] feat(ms-transaction): add domain layer with models, repositories and service interfaces --- .../transaction/domain/model/Transaction.java | 29 +++++++++++++ .../domain/model/TransactionStatus.java | 21 ++++++++++ .../domain/model/TransferType.java | 21 ++++++++++ .../repository/TransactionRepository.java | 37 +++++++++++++++++ .../TransactionStatusRepository.java | 27 ++++++++++++ .../repository/TransferTypeRepository.java | 27 ++++++++++++ .../service/TransactionCacheService.java | 39 ++++++++++++++++++ .../service/TransactionEventPublisher.java | 17 ++++++++ .../service/TransferTypeCacheService.java | 41 +++++++++++++++++++ 9 files changed, 259 insertions(+) create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/model/Transaction.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransactionStatus.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransferType.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionRepository.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionStatusRepository.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransferTypeRepository.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionCacheService.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionEventPublisher.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransferTypeCacheService.java diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/Transaction.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/Transaction.java new file mode 100644 index 0000000000..c68a964d33 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/Transaction.java @@ -0,0 +1,29 @@ +package com.yape.services.transaction.domain.model; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Domain model representing a transaction. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Transaction { + private Long transactionId; + private UUID transactionExternalId; + private UUID accountExternalIdDebit; + private UUID accountExternalIdCredit; + private Integer transferTypeId; + private Integer transactionStatusId; + private BigDecimal value; + private LocalDateTime createdAt; +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransactionStatus.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransactionStatus.java new file mode 100644 index 0000000000..0de7707ed6 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransactionStatus.java @@ -0,0 +1,21 @@ +package com.yape.services.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Domain model representing a transaction status. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionStatus { + private Integer transactionStatusId; + private String code; + private String name; +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransferType.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransferType.java new file mode 100644 index 0000000000..956ed74b40 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/model/TransferType.java @@ -0,0 +1,21 @@ +package com.yape.services.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Domain model representing a transfer type. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransferType { + private Integer transferTypeId; + private String code; + private String name; +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionRepository.java new file mode 100644 index 0000000000..d46e19b2fb --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionRepository.java @@ -0,0 +1,37 @@ +package com.yape.services.transaction.domain.repository; + +import com.yape.services.transaction.domain.model.Transaction; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository interface for managing transactions. + */ +public interface TransactionRepository { + + /** + * Saves a transaction to the repository. + * + * @param tx the transaction to save + * @return the saved transaction + */ + Transaction save(Transaction tx); + + /** + * Finds a transaction by its external ID. + * + * @param externalId the external ID of the transaction + * @return an Optional containing the found transaction, or empty if not found + */ + Optional findByExternalId(UUID externalId); + + /** + * Updates the status of a transaction. + * + * @param externalId the external ID of the transaction + * @param newStatusId the new status ID to set + * @return the number of updated records + */ + int updateStatus(UUID externalId, Integer newStatusId); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionStatusRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionStatusRepository.java new file mode 100644 index 0000000000..29cfc36442 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransactionStatusRepository.java @@ -0,0 +1,27 @@ +package com.yape.services.transaction.domain.repository; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import java.util.Optional; + +/** + * Repository interface for transaction status data operations. + */ +public interface TransactionStatusRepository { + + /** + * Finds a transaction status by its code. + * + * @param code the code of the transaction status + * @return an Optional containing the transaction status, or empty if not found + */ + Optional findByCode(String code); + + /** + * Finds a transaction status by its ID. + * + * @param id the ID of the transaction status + * @return an Optional containing the transaction status, or empty if not found + */ + Optional findById(Integer id); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransferTypeRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransferTypeRepository.java new file mode 100644 index 0000000000..f8d00da35f --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/repository/TransferTypeRepository.java @@ -0,0 +1,27 @@ +package com.yape.services.transaction.domain.repository; + +import com.yape.services.transaction.domain.model.TransferType; +import java.util.List; +import java.util.Optional; + +/** + * Repository interface for transfer type data operations. + */ +public interface TransferTypeRepository { + + /** + * Finds a transfer type by its ID. + * + * @param transferTypeId the ID of the transfer type + * @return an Optional containing the transfer type, or empty if not found + */ + Optional findById(Integer transferTypeId); + + /** + * Retrieves all transfer types. + * + * @return a list of all transfer types + */ + List findAll(); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionCacheService.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionCacheService.java new file mode 100644 index 0000000000..c18f2e1502 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionCacheService.java @@ -0,0 +1,39 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.transaction.domain.model.Transaction; +import java.util.Optional; +import java.util.UUID; + +/** + * Service interface for managing transaction cache operations. + */ +public interface TransactionCacheService { + + /** + * Save a transaction to the cache. + * + * @param transaction The transaction to save. + * @param statusCode The status code of the transaction. + */ + void saveTransaction(Transaction transaction, String statusCode); + + /** + * Retrieve a transaction from the cache by its ID. + * + * @param externalId The ID of the transaction to retrieve. + * @return An Optional containing the Transaction if found, or empty if not found. + */ + Optional getTransactionByExternalId(UUID externalId); + + /** + * Update the status of a transaction in the cache. + * + * @param externalId The external ID of the transaction to update. + * @param newStatusId The new status ID to set for the transaction. + * @param newStatusCode The new status to set for the transaction. + */ + void updateTransactionStatus(UUID externalId, + Integer newStatusId, + String newStatusCode); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionEventPublisher.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionEventPublisher.java new file mode 100644 index 0000000000..b45277332d --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransactionEventPublisher.java @@ -0,0 +1,17 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.transaction.events.TransactionCreatedEvent; + +/** + * Interface for publishing transaction-related events. + */ +public interface TransactionEventPublisher { + + /** + * Publishes a TransactionCreatedEvent. + * + * @param event the event to publish + */ + void publishTransactionCreated(TransactionCreatedEvent event); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransferTypeCacheService.java b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransferTypeCacheService.java new file mode 100644 index 0000000000..411c17b5f9 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/domain/service/TransferTypeCacheService.java @@ -0,0 +1,41 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.transaction.domain.model.TransferType; +import java.util.List; +import java.util.Optional; + +/** + * Service interface for managing transfer type cache operations. + */ +public interface TransferTypeCacheService { + + /** + * Save a transfer type to the cache. + * + * @param transferType the transfer type to save + */ + void save(TransferType transferType); + + /** + * Save all transfer types to the cache. + * + * @param transferTypes the list of transfer types to save + */ + void saveAll(List transferTypes); + + /** + * Retrieve a transfer type from the cache by its ID. + * + * @param transferTypeId the ID of the transfer type + * @return an Optional containing the TransferType if found + */ + Optional findById(Integer transferTypeId); + + /** + * Retrieve all transfer types from the cache. + * + * @return an Optional containing the list if cache is populated + */ + Optional> findAll(); + +} From 9d034ab29e3ebd085a9a2df768580c0f1a153e2d Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:55:40 -0500 Subject: [PATCH 33/54] feat(ms-transaction): add infrastructure layer with persistence, cache and messaging --- .../cache/TransactionCacheServiceImpl.java | 110 ++++++++++++++++++ .../cache/TransferTypeCacheServiceImpl.java | 94 +++++++++++++++ .../config/TransactionCacheConfig.java | 65 +++++++++++ .../config/TransferTypeCacheConfig.java | 34 ++++++ .../KafkaTransactionEventPublisher.java | 52 +++++++++ .../KafkaTransactionStatusConsumer.java | 54 +++++++++ .../persistence/TransactionPersistence.java | 83 +++++++++++++ .../TransactionStatusPersistence.java | 56 +++++++++ .../persistence/TransferTypePersistence.java | 59 ++++++++++ .../persistence/entity/TransactionEntity.java | 54 +++++++++ .../entity/TransactionStatusEntity.java | 32 +++++ .../entity/TransferTypeEntity.java | 32 +++++ .../TransactionPostgresRepository.java | 48 ++++++++ .../TransactionStatusPostgresRepository.java | 34 ++++++ .../TransferTypePostgresRepository.java | 34 ++++++ 15 files changed, 841 insertions(+) create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImpl.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImpl.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransactionCacheConfig.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransferTypeCacheConfig.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisher.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumer.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistence.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistence.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistence.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionEntity.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionStatusEntity.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransferTypeEntity.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepository.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepository.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepository.java diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImpl.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImpl.java new file mode 100644 index 0000000000..382726fa96 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImpl.java @@ -0,0 +1,110 @@ +package com.yape.services.transaction.infrastructure.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.yape.services.shared.util.CacheKeyUtils; +import com.yape.services.shared.util.Constants; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import com.yape.services.transaction.infrastructure.config.TransactionCacheConfig; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.jboss.logging.Logger; +import org.redisson.api.RMapCache; +import org.redisson.api.RedissonClient; +import org.redisson.codec.TypedJsonJacksonCodec; + +/** + * Redis implementation of TransactionCacheService using Redisson RMapCache. + */ +@ApplicationScoped +public class TransactionCacheServiceImpl implements TransactionCacheService { + + private static final Logger LOGGER = Logger.getLogger(TransactionCacheServiceImpl.class); + + private final RMapCache transactionCache; + private final TransactionCacheConfig cacheConfig; + + /** + * Constructor for TransactionCacheServiceImpl. + * + * @param redissonClient the Redisson client + * @param cacheConfig the cache configuration + */ + @Inject + public TransactionCacheServiceImpl(RedissonClient redissonClient, + TransactionCacheConfig cacheConfig) { + ObjectMapper mapper = createObjectMapper(); + TypedJsonJacksonCodec codec = + new TypedJsonJacksonCodec(String.class, Transaction.class, mapper); + this.transactionCache = redissonClient.getMapCache(cacheConfig.mapName(), codec); + this.cacheConfig = cacheConfig; + } + + private static ObjectMapper createObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } + + @Override + public void saveTransaction(Transaction transaction, String statusCode) { + String key = buildKey(transaction.getTransactionExternalId()); + long ttl = getTtlForStatus(statusCode); + + transactionCache.put(key, transaction, ttl, TimeUnit.SECONDS); + LOGGER.infof("Transaction cached with key: %s, TTL: %d seconds", key, ttl); + } + + @Override + public Optional getTransactionByExternalId(UUID externalId) { + String key = buildKey(externalId); + Transaction transaction = transactionCache.get(key); + + if (transaction != null) { + LOGGER.infof("Transaction found in cache with key: %s", key); + return Optional.of(transaction); + } + + LOGGER.infof("Transaction not found in cache with key: %s", key); + return Optional.empty(); + } + + @Override + public void updateTransactionStatus(UUID externalId, + Integer newStatusId, + String newStatusCode) { + String key = buildKey(externalId); + Transaction transaction = transactionCache.get(key); + + if (transaction == null) { + LOGGER.warnf("Transaction not found in cache for status update: %s", key); + return; + } + + transaction.setTransactionStatusId(newStatusId); + long ttl = getTtlForStatus(newStatusCode); + + transactionCache.put(key, transaction, ttl, TimeUnit.SECONDS); + LOGGER.infof("Transaction status updated to %s with new TTL: %d seconds", newStatusCode, ttl); + } + + private String buildKey(UUID transactionExternalId) { + return CacheKeyUtils.buildKey(cacheConfig.prefix(), transactionExternalId.toString()); + } + + private long getTtlForStatus(String status) { + return switch (status) { + case Constants.TRANSACTION_STATUS_PENDING -> cacheConfig.ttl().pending(); + case Constants.TRANSACTION_STATUS_APPROVED -> cacheConfig.ttl().approved(); + case Constants.TRANSACTION_STATUS_REJECTED -> cacheConfig.ttl().rejected(); + default -> throw new IllegalStateException("Unexpected value: " + status); + }; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImpl.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImpl.java new file mode 100644 index 0000000000..3c9bba0d4d --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImpl.java @@ -0,0 +1,94 @@ +package com.yape.services.transaction.infrastructure.cache; + +import com.yape.services.shared.util.CacheKeyUtils; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.service.TransferTypeCacheService; +import com.yape.services.transaction.infrastructure.config.TransferTypeCacheConfig; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.jboss.logging.Logger; +import org.redisson.api.RMapCache; +import org.redisson.api.RedissonClient; +import org.redisson.codec.TypedJsonJacksonCodec; + +/** + * Redis implementation of TransferTypeCacheService using Redisson RMapCache. + * Uses cache-aside pattern for transfer type data. + */ +@ApplicationScoped +public class TransferTypeCacheServiceImpl implements TransferTypeCacheService { + + private static final Logger LOGGER = Logger.getLogger(TransferTypeCacheServiceImpl.class); + + private final RMapCache transferTypeCache; + private final TransferTypeCacheConfig cacheConfig; + + /** + * Constructor for TransferTypeCacheServiceImpl. + * + * @param redissonClient the Redisson client + * @param cacheConfig the cache configuration + */ + @Inject + public TransferTypeCacheServiceImpl(RedissonClient redissonClient, + TransferTypeCacheConfig cacheConfig) { + TypedJsonJacksonCodec codec = new TypedJsonJacksonCodec(String.class, TransferType.class); + this.transferTypeCache = redissonClient.getMapCache(cacheConfig.mapName(), codec); + this.cacheConfig = cacheConfig; + } + + @Override + public void save(TransferType transferType) { + String key = buildKey(transferType.getTransferTypeId()); + transferTypeCache.put(key, transferType, cacheConfig.ttl(), TimeUnit.SECONDS); + LOGGER.infof("TransferType cached with key: %s, TTL: %d seconds", key, cacheConfig.ttl()); + } + + @Override + public void saveAll(List transferTypes) { + transferTypes.forEach(this::save); + LOGGER.infof("Cached %d transfer types", transferTypes.size()); + } + + @Override + public Optional findById(Integer transferTypeId) { + String key = buildKey(transferTypeId); + TransferType transferType = transferTypeCache.get(key); + + if (transferType != null) { + LOGGER.infof("Cache HIT for transfer type key: %s", key); + return Optional.of(transferType); + } + + LOGGER.infof("Cache MISS for transfer type key: %s", key); + return Optional.empty(); + } + + @Override + public Optional> findAll() { + if (transferTypeCache.isEmpty()) { + LOGGER.info("Cache MISS for all transfer types"); + return Optional.empty(); + } + + List transferTypes = transferTypeCache.values().stream() + .collect(Collectors.toMap( + TransferType::getTransferTypeId, + t -> t, + (existing, replacement) -> existing)) + .values() + .stream() + .toList(); + LOGGER.infof("Cache HIT for all transfer types, count: %d", transferTypes.size()); + return Optional.of(transferTypes); + } + + private String buildKey(Integer transferTypeId) { + return CacheKeyUtils.buildKey(cacheConfig.prefix(), String.valueOf(transferTypeId)); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransactionCacheConfig.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransactionCacheConfig.java new file mode 100644 index 0000000000..e013c844ae --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransactionCacheConfig.java @@ -0,0 +1,65 @@ +package com.yape.services.transaction.infrastructure.config; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithName; + +/** + * Configuration for transaction cache settings. + */ +@ConfigMapping(prefix = "application.cache.transaction") +public interface TransactionCacheConfig { + + /** + * Gets the map name for the transaction cache. + * + * @return the name of the cache map + */ + @WithName("map-name") + String mapName(); + + /** + * Gets the cache key prefix. + * + * @return the prefix for cache keys + */ + String prefix(); + + /** + * Gets the TTL configuration. + * + * @return the TTL settings + */ + Ttl ttl(); + + /** + * TTL configuration for different transaction statuses. + */ + interface Ttl { + + /** + * TTL in seconds for pending transactions. + * + * @return TTL in seconds + */ + @WithName("pending") + long pending(); + + /** + * TTL in seconds for approved transactions. + * + * @return TTL in seconds + */ + @WithName("approved") + long approved(); + + /** + * TTL in seconds for rejected transactions. + * + * @return TTL in seconds + */ + @WithName("rejected") + long rejected(); + + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransferTypeCacheConfig.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransferTypeCacheConfig.java new file mode 100644 index 0000000000..df94daaffe --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/config/TransferTypeCacheConfig.java @@ -0,0 +1,34 @@ +package com.yape.services.transaction.infrastructure.config; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithName; + +/** + * Configuration for transfer type cache settings. + */ +@ConfigMapping(prefix = "application.cache.transfer-type") +public interface TransferTypeCacheConfig { + + /** + * Gets the RMapCache name. + * + * @return the map name + */ + @WithName("map-name") + String mapName(); + + /** + * Gets the cache key prefix. + * + * @return the prefix for cache keys + */ + String prefix(); + + /** + * Gets the TTL in seconds. + * + * @return TTL in seconds + */ + long ttl(); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisher.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisher.java new file mode 100644 index 0000000000..42a394098f --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisher.java @@ -0,0 +1,52 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import com.yape.services.transaction.domain.service.TransactionEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.jboss.logging.Logger; + +/** + * Kafka implementation of TransactionEventPublisher. + * Publishes transaction events to Kafka topics using MicroProfile Reactive Messaging. + */ +@ApplicationScoped +public class KafkaTransactionEventPublisher implements TransactionEventPublisher { + + private static final Logger LOGGER = Logger.getLogger(KafkaTransactionEventPublisher.class); + + private final Emitter> transactionCreatedEmitter; + + /** + * Constructor for KafkaTransactionEventPublisher. + * + * @param transactionCreatedEmitter the emitter for transaction created events + */ + @Inject + public KafkaTransactionEventPublisher( + @Channel("transaction-producer") + Emitter> transactionCreatedEmitter + ) { + this.transactionCreatedEmitter = transactionCreatedEmitter; + } + + @Override + public void publishTransactionCreated(TransactionCreatedEvent event) { + String key = event.getPayload().getTransactionExternalId(); + + LOGGER.infof("Publishing TransactionCreatedEvent with key: %s", key); + + transactionCreatedEmitter.send(Record.of(key, event)) + .whenComplete((result, error) -> { + if (error != null) { + LOGGER.errorf(error, "Failed to publish TransactionCreatedEvent with key: %s", key); + } else { + LOGGER.infof("Successfully published TransactionCreatedEvent with key: %s", key); + } + }); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumer.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumer.java new file mode 100644 index 0000000000..701967d71f --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumer.java @@ -0,0 +1,54 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import com.yape.services.transaction.application.usecase.UpdateTransactionStatusUseCase; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.jboss.logging.Logger; + +/** + * Kafka consumer for transaction status updated events. + * Listens to the 'transaction.status' topic and delegates to the update use case. + */ +@ApplicationScoped +public class KafkaTransactionStatusConsumer { + + private static final Logger LOGGER = Logger.getLogger(KafkaTransactionStatusConsumer.class); + + private final UpdateTransactionStatusUseCase updateTransactionStatusUseCase; + + /** + * Constructor for KafkaTransactionStatusConsumer. + * + * @param updateTransactionStatusUseCase the use case for updating transaction status + */ + @Inject + public KafkaTransactionStatusConsumer( + UpdateTransactionStatusUseCase updateTransactionStatusUseCase) { + this.updateTransactionStatusUseCase = updateTransactionStatusUseCase; + } + + /** + * Consumes transaction status updated events from Kafka. + * + * @param kafkaRecord the Kafka record containing the transaction status updated event + */ + @Incoming("transaction-status-consumer") + public void consume(Record kafkaRecord) { + String key = kafkaRecord.key(); + TransactionStatusUpdatedEvent event = kafkaRecord.value(); + + LOGGER.infof("Received TransactionStatusUpdatedEvent with key: %s", key); + + try { + updateTransactionStatusUseCase.execute(event); + LOGGER.infof("Successfully processed TransactionStatusUpdatedEvent with key: %s", key); + } catch (Exception e) { + LOGGER.errorf(e, "Error processing TransactionStatusUpdatedEvent with key: %s", key); + throw e; + } + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistence.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistence.java new file mode 100644 index 0000000000..d3d4d50e86 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistence.java @@ -0,0 +1,83 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransactionPostgresRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.Optional; +import java.util.UUID; + +/** + * Persistence implementation for TransactionRepository using PostgreSQL. + */ +@ApplicationScoped +public class TransactionPersistence implements TransactionRepository { + + private final TransactionPostgresRepository repository; + + /** + * Constructor for TransactionPersistence. + * + * @param repository the PostgreSQL repository for transactions + */ + @Inject + public TransactionPersistence(TransactionPostgresRepository repository) { + this.repository = repository; + } + + @Override + @Transactional(Transactional.TxType.REQUIRED) + public Transaction save(Transaction tx) { + TransactionEntity entity = toEntity(tx); + TransactionEntity savedEntity = repository.save(entity); + return toDomain(savedEntity); + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public Optional findByExternalId(UUID externalId) { + return Optional.ofNullable(repository.findByTransactionExternalId(externalId)) + .map(TransactionPersistence::toDomain); + } + + private static TransactionEntity toEntity(Transaction domain) { + if (domain == null) { + return null; + } + + TransactionEntity entity = new TransactionEntity(); + entity.setTransactionExternalId(domain.getTransactionExternalId()); + entity.setAccountExternalIdDebit(domain.getAccountExternalIdDebit()); + entity.setAccountExternalIdCredit(domain.getAccountExternalIdCredit()); + entity.setTransferTypeId(domain.getTransferTypeId()); + entity.setTransactionStatusId(domain.getTransactionStatusId()); + entity.setValue(domain.getValue()); + return entity; + } + + private static Transaction toDomain(TransactionEntity entity) { + if (entity == null) { + return null; + } + + return Transaction.builder() + .transactionExternalId(entity.getTransactionExternalId()) + .accountExternalIdDebit(entity.getAccountExternalIdDebit()) + .accountExternalIdCredit(entity.getAccountExternalIdCredit()) + .transferTypeId(entity.getTransferTypeId()) + .transactionStatusId(entity.getTransactionStatusId()) + .value(entity.getValue()) + .createdAt(entity.getCreatedAt()) + .build(); + } + + @Override + @Transactional(Transactional.TxType.REQUIRED) + public int updateStatus(UUID externalId, Integer newStatusId) { + return repository.updateStatusByExternalId(externalId, newStatusId); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistence.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistence.java new file mode 100644 index 0000000000..5d5574a1c7 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistence.java @@ -0,0 +1,56 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionStatusEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransactionStatusPostgresRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.Optional; + +/** + * Persistence implementation for TransactionStatus. + */ +@ApplicationScoped +public class TransactionStatusPersistence implements TransactionStatusRepository { + + private final TransactionStatusPostgresRepository repository; + + /** + * Constructor for TransactionStatusPersistence. + * + * @param repository the PostgreSQL repository for transaction statuses + */ + @Inject + public TransactionStatusPersistence(TransactionStatusPostgresRepository repository) { + this.repository = repository; + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public Optional findByCode(String code) { + return Optional.ofNullable(repository.findByCode(code)) + .map(TransactionStatusPersistence::toDomain); + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public Optional findById(Integer id) { + return Optional.ofNullable(repository.findById(id)) + .map(TransactionStatusPersistence::toDomain); + } + + private static TransactionStatus toDomain(TransactionStatusEntity entity) { + if (entity == null) { + return null; + } + + return TransactionStatus.builder() + .transactionStatusId(entity.getTransactionStatusId()) + .code(entity.getCode()) + .name(entity.getName()) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistence.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistence.java new file mode 100644 index 0000000000..f75c1f4070 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistence.java @@ -0,0 +1,59 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.repository.TransferTypeRepository; +import com.yape.services.transaction.infrastructure.persistence.entity.TransferTypeEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransferTypePostgresRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Optional; + +/** + * Persistence implementation for TransferTypeRepository using PostgreSQL. + */ +@ApplicationScoped +public class TransferTypePersistence implements TransferTypeRepository { + + private final TransferTypePostgresRepository repository; + + /** + * Constructor for TransferTypePersistence. + * + * @param repository the PostgreSQL repository for transfer types + */ + @Inject + public TransferTypePersistence(TransferTypePostgresRepository repository) { + this.repository = repository; + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public Optional findById(Integer transferTypeId) { + return Optional.ofNullable(repository.findByTransferTypeId(transferTypeId)) + .map(TransferTypePersistence::toDomain); + } + + @Override + @Transactional(Transactional.TxType.SUPPORTS) + public List findAll() { + List entities = repository.findAllTransferTypes(); + return entities.stream() + .map(TransferTypePersistence::toDomain) + .toList(); + } + + private static TransferType toDomain(TransferTypeEntity entity) { + if (entity == null) { + return null; + } + + return TransferType.builder() + .transferTypeId(entity.getTransferTypeId()) + .code(entity.getCode()) + .name(entity.getName()) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionEntity.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionEntity.java new file mode 100644 index 0000000000..6222c7b1bb --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionEntity.java @@ -0,0 +1,54 @@ +package com.yape.services.transaction.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +/** + * Entity class representing a transaction in the database. + */ +@Entity +@Table(name = "transaction") +@Getter +@Setter +public class TransactionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "transaction_id") + private Integer transactionId; + + @Column(name = "transaction_external_id", nullable = false, unique = true) + private UUID transactionExternalId; + + @Column(name = "account_external_id_debit", nullable = false) + private UUID accountExternalIdDebit; + + @Column(name = "account_external_id_credit", nullable = false) + private UUID accountExternalIdCredit; + + @Column(name = "transfer_type_id", nullable = false) + private Integer transferTypeId; + + @Column(name = "transaction_status_id", nullable = false) + private Integer transactionStatusId; + + @Column(nullable = false, precision = 19, scale = 4) + private BigDecimal value; + + // @Column(name = "created_at", insertable = false, updatable = false) + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionStatusEntity.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionStatusEntity.java new file mode 100644 index 0000000000..1c83e366f0 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransactionStatusEntity.java @@ -0,0 +1,32 @@ +package com.yape.services.transaction.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +/** + * Entity class representing a transaction status in the transaction service. + */ +@Entity +@Table(name = "transaction_status") +@Getter +@Setter +public class TransactionStatusEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "transaction_status_id") + private Integer transactionStatusId; + + @Column(name = "code", unique = true, nullable = false, length = 20) + private String code; + + @Column(name = "name", nullable = false, length = 50) + private String name; + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransferTypeEntity.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransferTypeEntity.java new file mode 100644 index 0000000000..eb74c30f68 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/entity/TransferTypeEntity.java @@ -0,0 +1,32 @@ +package com.yape.services.transaction.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +/** + * Entity class representing a transfer type in the transaction service. + */ +@Entity +@Table(name = "transfer_type") +@Getter +@Setter +public class TransferTypeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "transfer_type_id") + private Integer transferTypeId; + + @Column(name = "code", unique = true, nullable = false, length = 20) + private String code; + + @Column(name = "name", nullable = false, length = 50) + private String name; + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepository.java new file mode 100644 index 0000000000..7fb2870ebd --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepository.java @@ -0,0 +1,48 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.UUID; + +/** + * Repository for transaction entities in PostgreSQL using Panache. + */ +@ApplicationScoped +public class TransactionPostgresRepository + implements PanacheRepositoryBase { + + /** + * Finds a transaction entity by its external ID. + * + * @param transactionExternalId the external ID of the transaction + * @return the transaction entity + */ + public TransactionEntity findByTransactionExternalId(UUID transactionExternalId) { + return find("transactionExternalId", transactionExternalId).firstResult(); + } + + /** + * Saves a transaction entity. + * + * @param entity the entity to save + * @return the saved entity + */ + public TransactionEntity save(TransactionEntity entity) { + persist(entity); + return entity; + } + + /** + * Updates the status of a transaction by its external ID. + * + * @param transactionExternalId the external ID of the transaction + * @param newStatusId the new status ID to set + * @return the number of updated records + */ + public int updateStatusByExternalId(UUID transactionExternalId, Integer newStatusId) { + return update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, transactionExternalId); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepository.java new file mode 100644 index 0000000000..be7ce5ba85 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepository.java @@ -0,0 +1,34 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionStatusEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Repository for transaction status entities in PostgreSQL using Panache. + */ +@ApplicationScoped +public class TransactionStatusPostgresRepository + implements PanacheRepositoryBase { + + /** + * Finds a transaction status entity by its code. + * + * @param code the code of the transaction status + * @return the transaction status entity + */ + public TransactionStatusEntity findByCode(String code) { + return find("code", code).firstResult(); + } + + /** + * Finds a transaction status entity by its ID. + * + * @param id the ID of the transaction status + * @return the transaction status entity + */ + public TransactionStatusEntity findById(Integer id) { + return find("transactionStatusId", id).firstResult(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepository.java b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepository.java new file mode 100644 index 0000000000..54b617d448 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepository.java @@ -0,0 +1,34 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransferTypeEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * Repository for transfer type entities in PostgreSQL using Panache. + */ +@ApplicationScoped +public class TransferTypePostgresRepository + implements PanacheRepositoryBase { + + /** + * Finds a transfer type entity by its transfer ID. + * + * @param transferTypeId the ID of the transfer type + * @return the transfer type entity + */ + public TransferTypeEntity findByTransferTypeId(Integer transferTypeId) { + return find("transferTypeId", transferTypeId).firstResult(); + } + + /** + * Finds all transfer type entities. + * + * @return list of all transfer type entities + */ + public List findAllTransferTypes() { + return listAll(); + } + +} From 53208f090f604498b77e602373acbcb09be0f987 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:55:58 -0500 Subject: [PATCH 34/54] feat(ms-transaction): add application layer with commands, queries, mappers and use cases --- README_V2.md | 0 .../command/CreateTransactionCommand.java | 16 ++ .../CreateTransactionCommandHandler.java | 63 +++++++ .../application/dto/RequestMetaData.java | 11 ++ .../mapper/GraphqlTransactionMapper.java | 101 ++++++++++ .../application/mapper/TransactionMapper.java | 64 +++++++ .../mapper/TransferTypeMapper.java | 26 +++ .../query/TransactionQueryHandler.java | 79 ++++++++ .../query/TransactionStatusQueryHandler.java | 50 +++++ .../query/TransferTypeQueryHandler.java | 76 ++++++++ .../usecase/CreateTransactionUseCase.java | 174 ++++++++++++++++++ .../usecase/GetTransactionUseCase.java | 91 +++++++++ .../usecase/GetTransferTypesUseCase.java | 47 +++++ .../UpdateTransactionStatusUseCase.java | 86 +++++++++ 14 files changed, 884 insertions(+) create mode 100644 README_V2.md create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommand.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandler.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/dto/RequestMetaData.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapper.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransactionMapper.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransferTypeMapper.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionQueryHandler.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandler.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransferTypeQueryHandler.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCase.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransactionUseCase.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransferTypesUseCase.java create mode 100644 ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCase.java diff --git a/README_V2.md b/README_V2.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommand.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommand.java new file mode 100644 index 0000000000..6e0bee1a57 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommand.java @@ -0,0 +1,16 @@ +package com.yape.services.transaction.application.command; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Command to create a new transaction. + */ +public record CreateTransactionCommand( + UUID accountExternalIdDebit, + UUID accountExternalIdCredit, + Integer transferTypeId, + Integer transactionStatusId, + String transactionStatusCode, + BigDecimal value +) {} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandler.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandler.java new file mode 100644 index 0000000000..26b3f275dc --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandler.java @@ -0,0 +1,63 @@ +package com.yape.services.transaction.application.command; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Command handler for creating a transaction. + */ +@ApplicationScoped +public class CreateTransactionCommandHandler { + + private static final Logger LOGGER = Logger.getLogger(CreateTransactionCommandHandler.class); + + private final TransactionRepository repository; + private final TransactionCacheService cacheService; + + /** + * Constructor for CreateTransactionCommandHandler. + * + * @param repository the repository for managing transactions + * @param cacheService the cache service for transactions + */ + public CreateTransactionCommandHandler(TransactionRepository repository, + TransactionCacheService cacheService) { + this.repository = repository; + this.cacheService = cacheService; + } + + /** + * Handles the creation of a transaction. + * + * @param command the command containing transaction data + * @return the created transaction + */ + public Transaction handle(CreateTransactionCommand command) { + LOGGER.info("Handling transaction creation command"); + + Transaction tx = buildTransaction(command); + Transaction savedTx = repository.save(tx); + LOGGER.infof("Transaction created with ID: %s", savedTx.getTransactionExternalId()); + + cacheService.saveTransaction(savedTx, command.transactionStatusCode()); + LOGGER.info("Transaction cached successfully"); + + return savedTx; + } + + private Transaction buildTransaction(CreateTransactionCommand command) { + return Transaction.builder() + .transactionExternalId(UUID.randomUUID()) + .accountExternalIdDebit(command.accountExternalIdDebit()) + .accountExternalIdCredit(command.accountExternalIdCredit()) + .transferTypeId(command.transferTypeId()) + .transactionStatusId(command.transactionStatusId()) + .value(command.value()) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/dto/RequestMetaData.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/dto/RequestMetaData.java new file mode 100644 index 0000000000..d03c5e5019 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/dto/RequestMetaData.java @@ -0,0 +1,11 @@ +package com.yape.services.transaction.application.dto; + +/** + * Data transfer object for request metadata. + */ +public record RequestMetaData( + String authorization, + String requestId, + String requestDate +) { +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapper.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapper.java new file mode 100644 index 0000000000..d4b0d62bf6 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapper.java @@ -0,0 +1,101 @@ +package com.yape.services.transaction.application.mapper; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.graphql.model.TransactionType; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.Named; + +/** + * Mapper for converting domain Transaction to GraphQL Transaction model using MapStruct. + */ +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI) +public interface GraphqlTransactionMapper { + + /** + * Maps Transaction domain model to GraphQL Transaction response. + * + * @param tx the domain transaction + * @param transferType the transfer type + * @param txStatus the transaction status + * @return the GraphQL transaction model + */ + @Mapping(target = "transactionExternalId", + source = "tx.transactionExternalId", + qualifiedByName = "uuidToString") + @Mapping(target = "transactionType", + source = "transferType", + qualifiedByName = "toTransactionType") + @Mapping(target = "transactionStatus", + source = "txStatus", + qualifiedByName = "toGraphqlStatus") + @Mapping(target = "value", + source = "tx.value", + qualifiedByName = "bigDecimalToString") + @Mapping(target = "createdAt", + source = "tx.createdAt", + qualifiedByName = "formatDate") + com.yape.services.transaction.graphql.model.Transaction toGraphqlModel( + Transaction tx, + TransferType transferType, + TransactionStatus txStatus + ); + + /** + * Converts UUID to String. + */ + @Named("uuidToString") + default String uuidToString(UUID uuid) { + return uuid != null ? uuid.toString() : null; + } + + /** + * Converts BigDecimal to String. + */ + @Named("bigDecimalToString") + default String bigDecimalToString(java.math.BigDecimal value) { + return value != null ? value.toPlainString() : null; + } + + /** + * Formats LocalDateTime to ISO date string. + */ + @Named("formatDate") + default String formatDate(LocalDateTime dateTime) { + return dateTime != null ? dateTime.format(DateTimeFormatter.ISO_DATE) : null; + } + + /** + * Converts TransferType to GraphQL TransactionType. + */ + @Named("toTransactionType") + default TransactionType toTransactionType(TransferType transferType) { + if (transferType == null) { + return null; + } + return TransactionType.builder() + .setName(transferType.getName()) + .build(); + } + + /** + * Converts domain TransactionStatus to GraphQL TransactionStatus. + */ + @Named("toGraphqlStatus") + default com.yape.services.transaction.graphql.model.TransactionStatus toGraphqlStatus( + TransactionStatus txStatus) { + if (txStatus == null) { + return null; + } + return com.yape.services.transaction.graphql.model.TransactionStatus.builder() + .setName(txStatus.getName()) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransactionMapper.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransactionMapper.java new file mode 100644 index 0000000000..3f6cde9c1d --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransactionMapper.java @@ -0,0 +1,64 @@ +package com.yape.services.transaction.application.mapper; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.events.TransactionCreatedPayload; +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +/** + * Mapper for transaction Avro events. + * Note: This mapper handles Avro-specific builders which are not suitable for MapStruct. + */ +@ApplicationScoped +public class TransactionMapper { + + private static final String EVENT_TYPE = "TRANSACTION_CREATED"; + private static final String EVENT_SOURCE = "ms-transaction"; + private static final String EVENT_VERSION = "1.0.0"; + + /** + * Maps Transaction and RequestMetaData to TransactionCreatedEvent. + * + * @param tx the transaction + * @param txStatus the transaction status + * @param metaData the request metadata + * @return the transaction created event + */ + public TransactionCreatedEvent toTransactionCreatedEvent(Transaction tx, + TransactionStatus txStatus, + RequestMetaData metaData) { + String timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now()); + + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType(EVENT_TYPE) + .setEventTimestamp(timestamp) + .setSource(EVENT_SOURCE) + .setVersion(EVENT_VERSION) + .setRequestId(metaData != null ? metaData.requestId() : null) + .build(); + + TransactionCreatedPayload payload = TransactionCreatedPayload.newBuilder() + .setTransactionExternalId(tx.getTransactionExternalId().toString()) + .setAccountExternalIdDebit(tx.getAccountExternalIdDebit().toString()) + .setAccountExternalIdCredit(tx.getAccountExternalIdCredit().toString()) + .setTransferTypeId(tx.getTransferTypeId()) + .setValue(tx.getValue().toPlainString()) + .setStatus(com.yape.services.transaction.events.enums.TransactionStatus + .valueOf(txStatus.getCode())) + .setCreatedAt(timestamp) + .build(); + + return TransactionCreatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransferTypeMapper.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransferTypeMapper.java new file mode 100644 index 0000000000..5932149b45 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/mapper/TransferTypeMapper.java @@ -0,0 +1,26 @@ +package com.yape.services.transaction.application.mapper; + +import com.yape.services.transaction.domain.model.TransferType; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +/** + * Mapper for TransferType entities using MapStruct. + */ +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI) +public interface TransferTypeMapper { + + /** + * Converts a domain TransferType model to a GraphQL TransferType model. + * + * @param transferType the domain TransferType model + * @return the GraphQL TransferType model + */ + @Mapping(target = "transferTypeId", + expression = "java(String.valueOf(transferType.getTransferTypeId()))") + @Mapping(target = "name", source = "name") + com.yape.services.transaction.graphql.model.TransferType toGraphqlModel( + TransferType transferType); + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionQueryHandler.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionQueryHandler.java new file mode 100644 index 0000000000..9be45231db --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionQueryHandler.java @@ -0,0 +1,79 @@ +package com.yape.services.transaction.application.query; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Query handler for transaction related operations. + */ +@ApplicationScoped +public class TransactionQueryHandler { + + private static final Logger LOGGER = Logger.getLogger(TransactionQueryHandler.class); + + private final TransactionRepository transactionRepository; + private final TransactionStatusRepository transactionStatusRepository; + private final TransactionCacheService cacheService; + + /** + * Constructor for TransactionQueryHandler. + * + * @param transactionRepository the repository for transaction data + * @param transactionStatusRepository the repository for transaction status data + * @param cacheService the cache service for transactions + */ + public TransactionQueryHandler(TransactionRepository transactionRepository, + TransactionStatusRepository transactionStatusRepository, + TransactionCacheService cacheService) { + this.transactionRepository = transactionRepository; + this.transactionStatusRepository = transactionStatusRepository; + this.cacheService = cacheService; + } + + /** + * Retrieves a transaction by its external ID. + * Implements cache-aside pattern: check cache first, if miss read from DB and populate cache. + * + * @param externalId the external ID of the transaction + * @return an Optional containing the transaction, or empty if not found + */ + public Optional getTransactionByExternalId(UUID externalId) { + LOGGER.infof("Fetching transaction with external ID: %s", externalId); + + Optional cachedTransaction = cacheService.getTransactionByExternalId(externalId); + if (cachedTransaction.isPresent()) { + return cachedTransaction; + } + + LOGGER.info("Cache miss - reading from database"); + Optional transaction = transactionRepository.findByExternalId(externalId); + + transaction.ifPresent(this::cacheTransactionWithStatus); + + return transaction; + } + + /** + * Caches a transaction with the appropriate TTL based on its status. + * + * @param transaction the transaction to cache + */ + private void cacheTransactionWithStatus(Transaction transaction) { + transactionStatusRepository.findById(transaction.getTransactionStatusId()) + .ifPresentOrElse( + status -> { + cacheService.saveTransaction(transaction, status.getCode()); + LOGGER.infof("Transaction cached with status: %s", status.getCode()); + }, + () -> LOGGER.warnf("Could not cache transaction - status not found for ID: %d", + transaction.getTransactionStatusId()) + ); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandler.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandler.java new file mode 100644 index 0000000000..9297531de0 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandler.java @@ -0,0 +1,50 @@ +package com.yape.services.transaction.application.query; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; +import org.jboss.logging.Logger; + +/** + * Query handler for transaction status related operations. + */ +@ApplicationScoped +public class TransactionStatusQueryHandler { + + private static final Logger LOGGER = Logger.getLogger(TransactionStatusQueryHandler.class); + + private final TransactionStatusRepository transactionStatusRepository; + + /** + * Constructor for TransactionStatusQueryHandler. + * + * @param transactionStatusRepository the repository for transaction status data + */ + public TransactionStatusQueryHandler(TransactionStatusRepository transactionStatusRepository) { + this.transactionStatusRepository = transactionStatusRepository; + } + + /** + * Retrieves a transaction status by its code. + * + * @param code the code of the transaction status + * @return an Optional containing the transaction status, or empty if not found + */ + public Optional getTransactionStatusByCode(String code) { + LOGGER.infof("Fetching transaction status with code: %s", code); + return transactionStatusRepository.findByCode(code); + } + + /** + * Retrieves a transaction status by its ID. + * + * @param id the ID of the transaction status + * @return an Optional containing the transaction status, or empty if not found + */ + public Optional getTransactionStatusById(Integer id) { + LOGGER.infof("Fetching transaction status with ID: %d", id); + return transactionStatusRepository.findById(id); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransferTypeQueryHandler.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransferTypeQueryHandler.java new file mode 100644 index 0000000000..6a9d978608 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/query/TransferTypeQueryHandler.java @@ -0,0 +1,76 @@ +package com.yape.services.transaction.application.query; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.repository.TransferTypeRepository; +import com.yape.services.transaction.domain.service.TransferTypeCacheService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.Optional; +import org.jboss.logging.Logger; + +/** + * Query handler for transfer type related operations. + * Implements cache-aside pattern for improved performance. + */ +@ApplicationScoped +public class TransferTypeQueryHandler { + + private static final Logger LOGGER = Logger.getLogger(TransferTypeQueryHandler.class); + + private final TransferTypeRepository transferTypeRepository; + private final TransferTypeCacheService cacheService; + + /** + * Constructor for TransferTypeQueryHandler. + * + * @param transferTypeRepository the repository for transfer type data + * @param cacheService the cache service for transfer types + */ + @Inject + public TransferTypeQueryHandler(TransferTypeRepository transferTypeRepository, + TransferTypeCacheService cacheService) { + this.transferTypeRepository = transferTypeRepository; + this.cacheService = cacheService; + } + + /** + * Retrieves a transfer type by its ID using cache-aside pattern. + * First checks cache, if miss reads from DB and populates cache. + * + * @param transferTypeId the ID of the transfer type + * @return an Optional containing the transfer type, or empty if not found + */ + public Optional getTransferTypeById(Integer transferTypeId) { + LOGGER.infof("Fetching transfer type with ID: %s", transferTypeId); + + Optional cachedTransferType = cacheService.findById(transferTypeId); + if (cachedTransferType.isPresent()) { + return cachedTransferType; + } + + LOGGER.info("Reading from database and caching"); + Optional transferType = transferTypeRepository.findById(transferTypeId); + transferType.ifPresent(cacheService::save); + return transferType; + } + + /** + * Retrieves all transfer types using cache-aside pattern. + * First checks cache, if miss reads from DB and populates cache. + * + * @return list of all transfer types + */ + public List getAll() { + LOGGER.info("Fetching all transfer types"); + + return cacheService.findAll() + .orElseGet(() -> { + LOGGER.info("Reading all from database and caching"); + List transferTypes = transferTypeRepository.findAll(); + cacheService.saveAll(transferTypes); + return transferTypes; + }); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCase.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCase.java new file mode 100644 index 0000000000..c036d6a239 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCase.java @@ -0,0 +1,174 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.shared.exception.BusinessException; +import com.yape.services.shared.exception.ErrorCode; +import com.yape.services.shared.exception.ResourceNotFoundException; +import com.yape.services.shared.exception.ValidationException; +import com.yape.services.shared.util.Constants; +import com.yape.services.transaction.application.command.CreateTransactionCommand; +import com.yape.services.transaction.application.command.CreateTransactionCommandHandler; +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.application.mapper.GraphqlTransactionMapper; +import com.yape.services.transaction.application.mapper.TransactionMapper; +import com.yape.services.transaction.application.query.TransactionStatusQueryHandler; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.service.TransactionEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.graphql.model.CreateTransaction; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Use case for creating a transaction. + */ +@ApplicationScoped +public class CreateTransactionUseCase { + + private static final BigDecimal MIN_AMOUNT = BigDecimal.valueOf(0.00); + private static final Logger LOGGER = Logger.getLogger(CreateTransactionUseCase.class); + + private final TransactionEventPublisher transactionEventPublisher; + private final TransactionMapper transactionMapper; + private final GraphqlTransactionMapper graphqlTransactionMapper; + private final CreateTransactionCommandHandler createTransactionCommandHandler; + private final TransferTypeQueryHandler transferTypeQueryHandler; + private final TransactionStatusQueryHandler transactionStatusQueryHandler; + + /** + * Constructor for CreateTransactionUseCase. + * + * @param createTransactionCommandHandler the command handler for creating transactions + * @param transactionEventPublisher the event publisher for transaction events + * @param transactionMapper the mapper for transaction events + * @param graphqlTransactionMapper the mapper for GraphQL responses + * @param transferTypeQueryHandler the query handler for transfer types + * @param transactionStatusQueryHandler the query handler for transaction statuses + */ + public CreateTransactionUseCase(CreateTransactionCommandHandler createTransactionCommandHandler, + TransactionEventPublisher transactionEventPublisher, + TransactionMapper transactionMapper, + GraphqlTransactionMapper graphqlTransactionMapper, + TransferTypeQueryHandler transferTypeQueryHandler, + TransactionStatusQueryHandler transactionStatusQueryHandler) { + this.createTransactionCommandHandler = createTransactionCommandHandler; + this.transactionEventPublisher = transactionEventPublisher; + this.transactionMapper = transactionMapper; + this.graphqlTransactionMapper = graphqlTransactionMapper; + this.transferTypeQueryHandler = transferTypeQueryHandler; + this.transactionStatusQueryHandler = transactionStatusQueryHandler; + } + + /** + * Executes the use case to create a transaction. + * + * @param input the input data for creating a transaction + * @param metaData the request metadata + * @return the created transaction details + */ + public com.yape.services.transaction.graphql.model.Transaction execute( + @NotNull CreateTransaction input, + RequestMetaData metaData + ) { + BigDecimal value = parseAndValidateAmount(input.getValue()); + TransferType transferType = getTransferType(input.getTransferTypeId()); + TransactionStatus status = getPendingStatus(); + + CreateTransactionCommand command = buildCommand(input, transferType, status, value); + Transaction savedTransaction = createTransactionCommandHandler.handle(command); + + publishTransactionCreatedEvent(savedTransaction, status, metaData); + + return graphqlTransactionMapper.toGraphqlModel(savedTransaction, transferType, status); + } + + private BigDecimal parseAndValidateAmount(String value) { + if (value == null || value.isBlank()) { + LOGGER.error("Transaction value is null or empty"); + throw new ValidationException(ErrorCode.VALIDATION_ERROR, + "value", "Transaction value is required"); + } + + BigDecimal amount; + try { + amount = new BigDecimal(value); + } catch (NumberFormatException e) { + LOGGER.errorf("Invalid transaction value format: %s", value); + throw new ValidationException(ErrorCode.INVALID_FORMAT, + "value", "Invalid transaction value format"); + } + + if (amount.compareTo(MIN_AMOUNT) < 0) { + LOGGER.errorf("Transaction value %s is below minimum %s", amount, MIN_AMOUNT); + throw new ValidationException(ErrorCode.AMOUNT_BELOW_MINIMUM, + "value", "Transaction value is below the minimum allowed"); + } + return amount; + } + + private TransferType getTransferType(int transferTypeId) { + return transferTypeQueryHandler.getTransferTypeById(transferTypeId) + .orElseThrow(() -> { + LOGGER.errorf("Invalid transfer type ID: %d", transferTypeId); + return new ResourceNotFoundException(ErrorCode.TRANSFER_TYPE_NOT_FOUND, + "TransferType", String.valueOf(transferTypeId)); + }); + } + + private TransactionStatus getPendingStatus() { + return transactionStatusQueryHandler + .getTransactionStatusByCode(Constants.TRANSACTION_STATUS_PENDING) + .orElseThrow(() -> { + LOGGER.error("PENDING transaction status not found in database"); + return new BusinessException(ErrorCode.CONFIGURATION_ERROR, + "PENDING status configuration missing"); + }); + } + + private CreateTransactionCommand buildCommand(CreateTransaction input, + TransferType transferType, + TransactionStatus status, + BigDecimal value) { + UUID debitAccountId = parseUuid(input.getAccountExternalIdDebit(), "accountExternalIdDebit"); + UUID creditAccountId = parseUuid(input.getAccountExternalIdCredit(), "accountExternalIdCredit"); + + return new CreateTransactionCommand( + debitAccountId, + creditAccountId, + transferType.getTransferTypeId(), + status.getTransactionStatusId(), + status.getCode(), + value + ); + } + + private UUID parseUuid(String value, String fieldName) { + if (value == null || value.isBlank()) { + LOGGER.errorf("Field %s is null or empty", fieldName); + throw new ValidationException(ErrorCode.VALIDATION_ERROR, + fieldName, fieldName + " is required"); + } + + try { + return UUID.fromString(value); + } catch (IllegalArgumentException e) { + LOGGER.errorf("Invalid UUID format for %s: %s", fieldName, value); + throw new ValidationException(ErrorCode.INVALID_FORMAT, + fieldName, "Invalid UUID format for " + fieldName); + } + } + + private void publishTransactionCreatedEvent(Transaction transaction, + TransactionStatus status, + RequestMetaData metaData) { + TransactionCreatedEvent event = transactionMapper + .toTransactionCreatedEvent(transaction, status, metaData); + transactionEventPublisher.publishTransactionCreated(event); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransactionUseCase.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransactionUseCase.java new file mode 100644 index 0000000000..2425bd9443 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransactionUseCase.java @@ -0,0 +1,91 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.shared.exception.ErrorCode; +import com.yape.services.shared.exception.ResourceNotFoundException; +import com.yape.services.transaction.application.mapper.GraphqlTransactionMapper; +import com.yape.services.transaction.application.query.TransactionQueryHandler; +import com.yape.services.transaction.application.query.TransactionStatusQueryHandler; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.graphql.model.Transaction; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Use case for retrieving a transaction. + */ +@ApplicationScoped +public class GetTransactionUseCase { + + private static final Logger LOGGER = Logger.getLogger(GetTransactionUseCase.class); + + private final TransactionQueryHandler transactionQueryHandler; + private final TransferTypeQueryHandler transferTypeQueryHandler; + private final TransactionStatusQueryHandler transactionStatusQueryHandler; + private final GraphqlTransactionMapper mapper; + + /** + * Constructor for GetTransactionUseCase. + * + * @param transactionQueryHandler the handler for querying transactions + * @param transferTypeQueryHandler the handler for querying transfer types + * @param transactionStatusQueryHandler the handler for querying transaction statuses + * @param mapper the mapper for converting transaction data + * + */ + @Inject + public GetTransactionUseCase(TransactionQueryHandler transactionQueryHandler, + TransferTypeQueryHandler transferTypeQueryHandler, + TransactionStatusQueryHandler transactionStatusQueryHandler, + GraphqlTransactionMapper mapper) { + this.transactionQueryHandler = transactionQueryHandler; + this.transferTypeQueryHandler = transferTypeQueryHandler; + this.transactionStatusQueryHandler = transactionStatusQueryHandler; + this.mapper = mapper; + } + + /** + * Executes the use case to retrieve a transaction by its external ID. + * + * @param transactionExternalId the external ID of the transaction + * @return the retrieved transaction + */ + public Transaction execute(String transactionExternalId) { + LOGGER.infof("Executing GetTransactionUseCase for transaction ID: %s", transactionExternalId); + UUID externalId = UUID.fromString(transactionExternalId); + + var transaction = transactionQueryHandler.getTransactionByExternalId(externalId) + .orElseThrow(() -> { + LOGGER.errorf("Transaction not found with external ID: %s", transactionExternalId); + return new ResourceNotFoundException(ErrorCode.TRANSACTION_NOT_FOUND, + "Transaction", transactionExternalId); + }); + + var transferType = getTransferType(transaction.getTransferTypeId()); + var transactionStatus = getTransactionStatus(transaction.getTransactionStatusId()); + + return mapper.toGraphqlModel(transaction, transferType, transactionStatus); + } + + private TransferType getTransferType(int transferTypeId) { + return transferTypeQueryHandler.getTransferTypeById(transferTypeId) + .orElseThrow(() -> { + LOGGER.errorf("Invalid transfer type ID: %d", transferTypeId); + return new ResourceNotFoundException(ErrorCode.TRANSFER_TYPE_NOT_FOUND, + "TransferType", String.valueOf(transferTypeId)); + }); + } + + private TransactionStatus getTransactionStatus(int transactionStatusId) { + return transactionStatusQueryHandler.getTransactionStatusById(transactionStatusId) + .orElseThrow(() -> { + LOGGER.errorf("Invalid transaction status ID: %d", transactionStatusId); + return new ResourceNotFoundException(ErrorCode.TRANSACTION_STATUS_NOT_FOUND, + "TransactionStatus", String.valueOf(transactionStatusId)); + }); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransferTypesUseCase.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransferTypesUseCase.java new file mode 100644 index 0000000000..d641479208 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/GetTransferTypesUseCase.java @@ -0,0 +1,47 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.transaction.application.mapper.TransferTypeMapper; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.TransferType; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import org.jboss.logging.Logger; + +/** + * Use case for retrieving transfer types. + */ +@ApplicationScoped +public class GetTransferTypesUseCase { + + private static final Logger LOGGER = Logger.getLogger(GetTransferTypesUseCase.class); + + private final TransferTypeQueryHandler transferTypeQueryHandler; + private final TransferTypeMapper transferTypeMapper; + + /** + * Constructor for GetTransferTypesUseCase. + * + * @param transferTypeQueryHandler the query handler for transfer types + * @param transferTypeMapper the mapper for transfer types + */ + public GetTransferTypesUseCase(TransferTypeQueryHandler transferTypeQueryHandler, + TransferTypeMapper transferTypeMapper) { + this.transferTypeQueryHandler = transferTypeQueryHandler; + this.transferTypeMapper = transferTypeMapper; + } + + /** + * Executes the use case to retrieve transfer types. + * + * @return a list of transfer types + */ + public List execute() { + LOGGER.info("Executing GetTransferTypesUseCase"); + List transferTypes = transferTypeQueryHandler.getAll(); + + return transferTypes.stream() + .map(transferTypeMapper::toGraphqlModel) + .toList(); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCase.java b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCase.java new file mode 100644 index 0000000000..7fd3fdb72b --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCase.java @@ -0,0 +1,86 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Use case for updating a transaction status based on anti-fraud validation results. + * Updates both PostgreSQL and Redis cache. + */ +@ApplicationScoped +public class UpdateTransactionStatusUseCase { + + private static final Logger LOGGER = Logger.getLogger(UpdateTransactionStatusUseCase.class); + + private final TransactionRepository transactionRepository; + private final TransactionStatusRepository transactionStatusRepository; + private final TransactionCacheService transactionCacheService; + + /** + * Constructor for UpdateTransactionStatusUseCase. + * + * @param transactionRepository repository for transaction persistence + * @param transactionStatusRepository repository for transaction status lookup + * @param transactionCacheService service for cache operations + */ + @Inject + public UpdateTransactionStatusUseCase(TransactionRepository transactionRepository, + TransactionStatusRepository transactionStatusRepository, + TransactionCacheService transactionCacheService) { + this.transactionRepository = transactionRepository; + this.transactionStatusRepository = transactionStatusRepository; + this.transactionCacheService = transactionCacheService; + } + + /** + * Executes the transaction status update. + * + * @param event the transaction status updated event from anti-fraud service + */ + @Transactional + public void execute(TransactionStatusUpdatedEvent event) { + TransactionStatusUpdatedPayload payload = event.getPayload(); + String transactionExternalIdStr = payload.getTransactionExternalId(); + UUID transactionExternalId = UUID.fromString(transactionExternalIdStr); + String newStatusCode = payload.getNewStatus().name(); + + LOGGER.infof("Processing status update for transaction: %s, new status: %s", + transactionExternalIdStr, newStatusCode); + + TransactionStatus newStatus = transactionStatusRepository.findByCode(newStatusCode) + .orElseThrow(() -> { + LOGGER.errorf("Transaction status not found for code: %s", newStatusCode); + return new IllegalStateException("Transaction status not found: " + newStatusCode); + }); + + var updatedRows = transactionRepository.updateStatus(transactionExternalId, + newStatus.getTransactionStatusId()); + + if (updatedRows == 0) { + LOGGER.warnf("No transaction found to update with external ID: %s", transactionExternalIdStr); + return; + } + + LOGGER.infof("Transaction %s status updated in database to: %s", + transactionExternalIdStr, newStatusCode); + + transactionCacheService.updateTransactionStatus( + transactionExternalId, + newStatus.getTransactionStatusId(), + newStatusCode + ); + + LOGGER.infof("Transaction %s status updated in cache to: %s", + transactionExternalIdStr, newStatusCode); + } + +} From 07c2a18ea7d66c9c130d905f5254095d69e47cb9 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:56:17 -0500 Subject: [PATCH 35/54] feat(ms-transaction): add shared layer with exceptions and utilities --- .../shared/exception/BusinessException.java | 51 +++++++++++++++++ .../services/shared/exception/ErrorCode.java | 39 +++++++++++++ .../exception/ResourceNotFoundException.java | 55 +++++++++++++++++++ .../shared/exception/ValidationException.java | 51 +++++++++++++++++ .../services/shared/util/CacheKeyUtils.java | 22 ++++++++ .../yape/services/shared/util/Constants.java | 18 ++++++ 6 files changed, 236 insertions(+) create mode 100644 ms-transaction/src/main/java/com/yape/services/shared/exception/BusinessException.java create mode 100644 ms-transaction/src/main/java/com/yape/services/shared/exception/ErrorCode.java create mode 100644 ms-transaction/src/main/java/com/yape/services/shared/exception/ResourceNotFoundException.java create mode 100644 ms-transaction/src/main/java/com/yape/services/shared/exception/ValidationException.java create mode 100644 ms-transaction/src/main/java/com/yape/services/shared/util/CacheKeyUtils.java create mode 100644 ms-transaction/src/main/java/com/yape/services/shared/util/Constants.java diff --git a/ms-transaction/src/main/java/com/yape/services/shared/exception/BusinessException.java b/ms-transaction/src/main/java/com/yape/services/shared/exception/BusinessException.java new file mode 100644 index 0000000000..72fce551f4 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/exception/BusinessException.java @@ -0,0 +1,51 @@ +package com.yape.services.shared.exception; + +import java.io.Serial; +import lombok.Getter; + +/** + * Base exception for business-related errors in the application. + * All custom exceptions should extend this class to ensure consistent error handling. + */ +@Getter +public class BusinessException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final ErrorCode errorCode; + + /** + * Constructs a new BusinessException with the specified error code and message. + * + * @param errorCode the error code representing the type of error + * @param message the detail message + */ + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + /** + * Constructs a new BusinessException with the specified error code, message, and cause. + * + * @param errorCode the error code representing the type of error + * @param message the detail message + * @param cause the cause of the exception + */ + public BusinessException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + /** + * Constructs a new BusinessException with the specified error code using its default message. + * + * @param errorCode the error code representing the type of error + */ + public BusinessException(ErrorCode errorCode) { + super(errorCode.getDefaultMessage()); + this.errorCode = errorCode; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/exception/ErrorCode.java b/ms-transaction/src/main/java/com/yape/services/shared/exception/ErrorCode.java new file mode 100644 index 0000000000..6338bf3aad --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/exception/ErrorCode.java @@ -0,0 +1,39 @@ +package com.yape.services.shared.exception; + +import lombok.Getter; + +/** + * Enumeration of error codes for GraphQL responses. + * Provides standardized error classification across the application. + */ +@Getter +public enum ErrorCode { + + // Validation errors (400) + VALIDATION_ERROR("VALIDATION_ERROR", "Validation failed"), + INVALID_INPUT("INVALID_INPUT", "Invalid input provided"), + INVALID_FORMAT("INVALID_FORMAT", "Invalid data format"), + + // Not found errors (404) + RESOURCE_NOT_FOUND("RESOURCE_NOT_FOUND", "Resource not found"), + TRANSACTION_NOT_FOUND("TRANSACTION_NOT_FOUND", "Transaction not found"), + TRANSFER_TYPE_NOT_FOUND("TRANSFER_TYPE_NOT_FOUND", "Transfer type not found"), + TRANSACTION_STATUS_NOT_FOUND("TRANSACTION_STATUS_NOT_FOUND", "Transaction status not found"), + + // Business logic errors (422) + BUSINESS_ERROR("BUSINESS_ERROR", "Business rule violation"), + AMOUNT_BELOW_MINIMUM("AMOUNT_BELOW_MINIMUM", "Amount is below the minimum allowed"), + + // Internal errors (500) + INTERNAL_ERROR("INTERNAL_ERROR", "Internal server error"), + CONFIGURATION_ERROR("CONFIGURATION_ERROR", "Configuration error"); + + private final String code; + private final String defaultMessage; + + ErrorCode(String code, String defaultMessage) { + this.code = code; + this.defaultMessage = defaultMessage; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/exception/ResourceNotFoundException.java b/ms-transaction/src/main/java/com/yape/services/shared/exception/ResourceNotFoundException.java new file mode 100644 index 0000000000..b4fd2147c6 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/exception/ResourceNotFoundException.java @@ -0,0 +1,55 @@ +package com.yape.services.shared.exception; + +import java.io.Serial; +import lombok.Getter; + +/** + * Exception thrown when a requested resource cannot be found. + * Used for entities that don't exist in the database or cache. + */ +@Getter +public class ResourceNotFoundException extends BusinessException { + + @Serial + private static final long serialVersionUID = 1L; + + private final String resourceType; + private final String resourceId; + + /** + * Constructs a ResourceNotFoundException with a custom message. + * + * @param message the detail message + */ + public ResourceNotFoundException(String message) { + super(ErrorCode.RESOURCE_NOT_FOUND, message); + this.resourceType = null; + this.resourceId = null; + } + + /** + * Constructs a ResourceNotFoundException for a specific resource. + * + * @param errorCode the specific error code + * @param resourceType the type of resource not found + * @param resourceId the identifier of the resource + */ + public ResourceNotFoundException(ErrorCode errorCode, String resourceType, String resourceId) { + super(errorCode, String.format("%s not found with ID: %s", resourceType, resourceId)); + this.resourceType = resourceType; + this.resourceId = resourceId; + } + + /** + * Constructs a ResourceNotFoundException with a specific error code and message. + * + * @param errorCode the specific error code + * @param message the detail message + */ + public ResourceNotFoundException(ErrorCode errorCode, String message) { + super(errorCode, message); + this.resourceType = null; + this.resourceId = null; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/exception/ValidationException.java b/ms-transaction/src/main/java/com/yape/services/shared/exception/ValidationException.java new file mode 100644 index 0000000000..41e1f079c7 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/exception/ValidationException.java @@ -0,0 +1,51 @@ +package com.yape.services.shared.exception; + +import java.io.Serial; +import lombok.Getter; + +/** + * Exception thrown when input validation fails. + * Used for invalid parameters, missing required fields, or format errors. + */ +@Getter +public class ValidationException extends BusinessException { + + @Serial + private static final long serialVersionUID = 1L; + + private final String fieldName; + + /** + * Constructs a ValidationException with a custom message. + * + * @param message the detail message describing the validation error + */ + public ValidationException(String message) { + super(ErrorCode.VALIDATION_ERROR, message); + this.fieldName = null; + } + + /** + * Constructs a ValidationException with a specific error code and message. + * + * @param errorCode the specific error code + * @param message the detail message + */ + public ValidationException(ErrorCode errorCode, String message) { + super(errorCode, message); + this.fieldName = null; + } + + /** + * Constructs a ValidationException for a specific field. + * + * @param errorCode the specific error code + * @param fieldName the name of the field that failed validation + * @param message the detail message + */ + public ValidationException(ErrorCode errorCode, String fieldName, String message) { + super(errorCode, message); + this.fieldName = fieldName; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/util/CacheKeyUtils.java b/ms-transaction/src/main/java/com/yape/services/shared/util/CacheKeyUtils.java new file mode 100644 index 0000000000..bff35255e5 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/util/CacheKeyUtils.java @@ -0,0 +1,22 @@ +package com.yape.services.shared.util; + +/** + * Utility class for cache key operations. + */ +public final class CacheKeyUtils { + + private CacheKeyUtils() { + } + + /** + * Builds a cache key with prefix and value. + * + * @param prefix the key prefix + * @param value the value to append + * @return the cache key + */ + public static String buildKey(String prefix, String value) { + return prefix + value; + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/shared/util/Constants.java b/ms-transaction/src/main/java/com/yape/services/shared/util/Constants.java new file mode 100644 index 0000000000..c88e3ba82b --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/shared/util/Constants.java @@ -0,0 +1,18 @@ +package com.yape.services.shared.util; + +/** + * Constants used across the application. + */ +public class Constants { + + private Constants() { + } + + public static final String REQUEST_ID = "Request-ID"; + public static final String REQUEST_DATE = "Request-Date"; + + public static final String TRANSACTION_STATUS_PENDING = "PENDING"; + public static final String TRANSACTION_STATUS_APPROVED = "APPROVED"; + public static final String TRANSACTION_STATUS_REJECTED = "REJECTED"; + +} From 575a5762e9e1fb5827785c4c67ddc582944f6f18 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:56:24 -0500 Subject: [PATCH 36/54] feat(ms-transaction): add GraphQL resolvers for mutations and queries --- .../expose/graphql/MutationResolverImpl.java | 56 +++++++++++++++++++ .../expose/graphql/QueryResolverImpl.java | 48 ++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 ms-transaction/src/main/java/com/yape/services/expose/graphql/MutationResolverImpl.java create mode 100644 ms-transaction/src/main/java/com/yape/services/expose/graphql/QueryResolverImpl.java diff --git a/ms-transaction/src/main/java/com/yape/services/expose/graphql/MutationResolverImpl.java b/ms-transaction/src/main/java/com/yape/services/expose/graphql/MutationResolverImpl.java new file mode 100644 index 0000000000..250a9062b2 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/expose/graphql/MutationResolverImpl.java @@ -0,0 +1,56 @@ +package com.yape.services.expose.graphql; + +import com.yape.services.shared.util.Constants; +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.application.usecase.CreateTransactionUseCase; +import com.yape.services.transaction.graphql.api.MutationResolver; +import com.yape.services.transaction.graphql.model.CreateTransaction; +import com.yape.services.transaction.graphql.model.Transaction; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import jakarta.inject.Inject; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Mutation; + +/** + * GraphQL resolver for transaction-related operations. + */ +@GraphQLApi +public class MutationResolverImpl implements MutationResolver { + + private final CreateTransactionUseCase createTransactionUseCase; + private final CurrentVertxRequest currentVertxRequest; + + /** + * Constructor for TransactionResolver. + * + * @param createTransactionUseCase the service handling transaction logic + * @param currentVertxRequest the current Vert.x request context + */ + @Inject + public MutationResolverImpl(CreateTransactionUseCase createTransactionUseCase, + CurrentVertxRequest currentVertxRequest) { + this.createTransactionUseCase = createTransactionUseCase; + this.currentVertxRequest = currentVertxRequest; + } + + /** + * Creates a new transaction. + * + * @param input the input data for creating a transaction + * @return the created transaction details + */ + @Mutation("createTransaction") + @Override + public Transaction createTransaction(@NotNull CreateTransaction input) { + var req = currentVertxRequest.getCurrent().request(); + + String authHeader = req.getHeader("Authorization"); + String requestId = req.getHeader(Constants.REQUEST_ID); + String requestDate = req.getHeader(Constants.REQUEST_DATE); + + RequestMetaData metadata = new RequestMetaData(authHeader, requestId, requestDate); + return createTransactionUseCase.execute(input, metadata); + } + +} diff --git a/ms-transaction/src/main/java/com/yape/services/expose/graphql/QueryResolverImpl.java b/ms-transaction/src/main/java/com/yape/services/expose/graphql/QueryResolverImpl.java new file mode 100644 index 0000000000..57f72aa337 --- /dev/null +++ b/ms-transaction/src/main/java/com/yape/services/expose/graphql/QueryResolverImpl.java @@ -0,0 +1,48 @@ +package com.yape.services.expose.graphql; + +import com.yape.services.transaction.application.usecase.GetTransactionUseCase; +import com.yape.services.transaction.application.usecase.GetTransferTypesUseCase; +import com.yape.services.transaction.graphql.api.QueryResolver; +import com.yape.services.transaction.graphql.model.Transaction; +import com.yape.services.transaction.graphql.model.TransferType; +import jakarta.inject.Inject; +import java.util.List; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Name; +import org.eclipse.microprofile.graphql.Query; + +/** + * GraphQL resolver for transaction-related queries. + */ +@GraphQLApi +public class QueryResolverImpl implements QueryResolver { + + private final GetTransferTypesUseCase getTransferTypesUseCase; + private final GetTransactionUseCase getTransactionUseCase; + + /** + * Constructor for TransactionQueryResolver. + * + * @param getTransferTypesUseCase the service handling transfer type logic + * @param getTransactionUseCase the service handling transaction retrieval logic + */ + @Inject + public QueryResolverImpl(GetTransferTypesUseCase getTransferTypesUseCase, + GetTransactionUseCase getTransactionUseCase) { + this.getTransferTypesUseCase = getTransferTypesUseCase; + this.getTransactionUseCase = getTransactionUseCase; + } + + @Query("transaction") + @Override + public Transaction transaction(@Name("transactionExternalId") String transactionExternalId) { + return getTransactionUseCase.execute(transactionExternalId); + } + + @Query("transferTypes") + @Override + public List transferTypes() { + return getTransferTypesUseCase.execute(); + } + +} From 6e92aa2ee26589f867f3c816670058ed5e584e5e Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:56:33 -0500 Subject: [PATCH 37/54] feat(ms-transaction): add Flyway database migrations for transaction tables --- .../db/migration/V1.0__create_tables.sql | 34 +++++++++++++++++++ .../db/migration/V1.1__insert_tables.sql | 9 +++++ .../db/migration/V1.2__create_indexes.sql | 5 +++ 3 files changed, 48 insertions(+) create mode 100644 ms-transaction/src/main/resources/db/migration/V1.0__create_tables.sql create mode 100644 ms-transaction/src/main/resources/db/migration/V1.1__insert_tables.sql create mode 100644 ms-transaction/src/main/resources/db/migration/V1.2__create_indexes.sql diff --git a/ms-transaction/src/main/resources/db/migration/V1.0__create_tables.sql b/ms-transaction/src/main/resources/db/migration/V1.0__create_tables.sql new file mode 100644 index 0000000000..fa402f6a09 --- /dev/null +++ b/ms-transaction/src/main/resources/db/migration/V1.0__create_tables.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS transaction_status +( + transaction_status_id SERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transfer_type +( + transfer_type_id SERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transaction +( + transaction_id SERIAL PRIMARY KEY, + transaction_external_id UUID NOT NULL UNIQUE, + account_external_id_debit UUID NOT NULL, + account_external_id_credit UUID NOT NULL, + transfer_type_id INT NOT NULL, + transaction_status_id INT NOT NULL, + value DECIMAL(19, 4) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_transfer_type FOREIGN KEY (transfer_type_id) + REFERENCES transfer_type (transfer_type_id), + CONSTRAINT fk_transaction_status FOREIGN KEY (transaction_status_id) + REFERENCES transaction_status (transaction_status_id), + CONSTRAINT chk_value CHECK (value > 0) +); \ No newline at end of file diff --git a/ms-transaction/src/main/resources/db/migration/V1.1__insert_tables.sql b/ms-transaction/src/main/resources/db/migration/V1.1__insert_tables.sql new file mode 100644 index 0000000000..1bc844f4fc --- /dev/null +++ b/ms-transaction/src/main/resources/db/migration/V1.1__insert_tables.sql @@ -0,0 +1,9 @@ +INSERT INTO transaction_status (code, name) +VALUES ('PENDING', 'Pendiente'), + ('APPROVED', 'Aprobado'), + ('REJECTED', 'Rechazado'); + +INSERT INTO transfer_type (transfer_type_id, code, name) +VALUES (1, 'TRANSFER', 'Transferencia'), + (2, 'PAYMENT', 'Pago'), + (3, 'DEPOSIT', 'Depósito'); diff --git a/ms-transaction/src/main/resources/db/migration/V1.2__create_indexes.sql b/ms-transaction/src/main/resources/db/migration/V1.2__create_indexes.sql new file mode 100644 index 0000000000..12ee13ca36 --- /dev/null +++ b/ms-transaction/src/main/resources/db/migration/V1.2__create_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS idx_transaction_status_code ON transaction_status (code); + +CREATE INDEX IF NOT EXISTS idx_transfer_type_id ON transfer_type (transfer_type_id); + +CREATE INDEX IF NOT EXISTS idx_transaction_external_id ON transaction (transaction_external_id); \ No newline at end of file From adac30f13bc88a93c4288c99258844084eccbf9e Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:56:44 -0500 Subject: [PATCH 38/54] feat(ms-transaction): update GraphQL schema with transaction types, inputs, queries and mutations --- .../graphql-client/directive.graphqls | 12 ----- .../resources/graphql-client/scalar.graphqls | 6 +++ .../graphql-client/transaction/enums.graphqls | 17 ------- .../transaction/inputs.graphqls | 19 +++----- .../transaction/mutations.graphqls | 13 +---- .../transaction/queries.graphqls | 8 +--- .../graphql-client/transaction/types.graphqls | 47 ++++++------------- 7 files changed, 30 insertions(+), 92 deletions(-) delete mode 100644 ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls diff --git a/ms-transaction/src/main/resources/graphql-client/directive.graphqls b/ms-transaction/src/main/resources/graphql-client/directive.graphqls index 7ed0b85272..75a96a9b14 100644 --- a/ms-transaction/src/main/resources/graphql-client/directive.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/directive.graphqls @@ -1,15 +1,3 @@ -""" -Indicates that the field requires authentication -""" -directive @authenticated on FIELD_DEFINITION - -""" -Indicates the maximum allowed value for a field or argument -""" -directive @Max( - value : BigDecimal = 2147483647.00 -) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION - """ Indicates the minimum allowed value for a field or argument """ diff --git a/ms-transaction/src/main/resources/graphql-client/scalar.graphqls b/ms-transaction/src/main/resources/graphql-client/scalar.graphqls index 8966b0ff32..c64c0eb0f7 100644 --- a/ms-transaction/src/main/resources/graphql-client/scalar.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/scalar.graphqls @@ -10,6 +10,12 @@ Format: yyyy-MM-dd'T'HH:mm:ss.SSSZ """ scalar DateTime +""" +Date in ISO 8601 format +Format: yyyy-MM-dd +""" +scalar Date + """ Monetary value with decimal precision Example: 1500.50 diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls deleted file mode 100644 index 7cc2c9e1d1..0000000000 --- a/ms-transaction/src/main/resources/graphql-client/transaction/enums.graphqls +++ /dev/null @@ -1,17 +0,0 @@ -# ============================================================================= -# Transaction Domain - Enum Definitions -# ============================================================================= - -""" -Status of a transaction in the anti-fraud validation flow. -""" -enum TransactionStatus { - """Transaction created, pending anti-fraud validation""" - PENDING - - """Transaction approved by the anti-fraud system""" - APPROVED - - """Transaction rejected by the anti-fraud system (e.g., value > 1000)""" - REJECTED -} \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls index 9f16400aca..d4d0efc455 100644 --- a/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls @@ -5,26 +5,19 @@ """ Input for creating a new financial transaction. """ -input CreateTransactionInput { - """Identifier of the debit account (source)""" +input CreateTransaction { + """Identifier of the debit account""" accountExternalIdDebit: UUID! - """Identifier of the credit account (destination)""" + """Identifier of the credit account""" accountExternalIdCredit: UUID! - """ - Transfer type identifier: - - 1: INTERNAL - - 2: INTRABANK - - 3: INTERBANK - - 4: INTERNATIONAL - """ + """Transfer type identifier (category: TRANSFER, PAYMENT, DEPOSIT)""" transferTypeId: Int! """ Transaction amount. - - Minimum: 0.10 - - Maximum: 1000 (values > 1000 are rejected by anti-fraud) + - Minimum: 0 """ - value: BigDecimal! @Max(value: 1000) @Min(value: 0.10) + value: BigDecimal! @Min(value: 0) } \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls index 3e22d4e255..cfe81b43cf 100644 --- a/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls @@ -13,21 +13,12 @@ type Mutation { anti-fraud service for validation. Transactions with value > 1000 will be automatically rejected. - Security: - - Requires authentication - - Validation rules: - - accountExternalIdDebit and accountExternalIdCredit must be different - - value must be between 0.10 and 1000 - - transferTypeId must be valid (1-4) - Possible errors: - - UNAUTHORIZED: Invalid or expired token - BAD_REQUEST: Invalid input data - VALIDATION_ERROR: Business rule violation """ createTransaction( """Transaction data""" - input: CreateTransactionInput! - ): CreatedTransaction! @authenticated + input: CreateTransaction! + ): Transaction! } diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls index 7a9c8cc6a3..bfd5ba74bf 100644 --- a/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls @@ -9,20 +9,16 @@ type Query { """ Retrieves a transaction by its external identifier. - Security: - - Requires authentication - Possible errors: - NOT_FOUND: Transaction does not exist - - UNAUTHORIZED: Invalid or expired token """ transaction( """Unique external identifier of the transaction""" transactionExternalId: UUID! - ): Transaction @authenticated + ): Transaction """ Retrieves all available transfer types. """ - transferTypes: [TransactionType!]! + transferTypes: [TransferType!]! } \ No newline at end of file diff --git a/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls index f7432759c9..f687375ddd 100644 --- a/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls +++ b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls @@ -9,62 +9,43 @@ type Transaction { """Unique external identifier of the transaction""" transactionExternalId: UUID! - """Identifier of the debit account (source)""" - accountExternalIdDebit: UUID! - - """Identifier of the credit account (destination)""" - accountExternalIdCredit: UUID! - """Type of the transaction""" transactionType: TransactionType! """Current status of the transaction""" - transactionStatus: TransactionStatusDetail! + transactionStatus: TransactionStatus! """Amount of the transaction""" value: BigDecimal! """Date and time of the transaction creation""" - createdAt: DateTime! - - """Date and time of the last update""" - updatedAt: DateTime! + createdAt: Date! } """ -Response type for createTransaction mutation. +Details about a transfer type. """ -type CreatedTransaction { - """The created transaction""" - transaction: Transaction! +type TransferType { + """Identifier of the transfer type""" + transferTypeId: ID! + """Name of the transfer type""" + name: String! } """ -Details about a transaction type (e.g., INTERNAL, INTRABANK). +Details about a transaction type. """ type TransactionType { - """Identifier of the type""" - id: Int! - """Name of the transaction type""" name: String! - - """Description of the type""" - description: String } """ Details about a transaction status. """ -type TransactionStatusDetail { - """Name of the status (PENDING, APPROVED, REJECTED)""" - name: TransactionStatus! - - """Description of the status""" - description: String - - """Date when this status was assigned""" - assignedAt: DateTime +type TransactionStatus { + """Name of the status""" + name: String! } """ @@ -75,10 +56,10 @@ type TransactionStatusChangedEvent { transaction: Transaction! """Previous status""" - previousStatus: TransactionStatus! + previousStatus: String! """New status""" - newStatus: TransactionStatus! + newStatus: String! """Date and time of the change""" changedAt: DateTime! From dc11a09002d48435dc758698a8e45b3073436983 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:56:50 -0500 Subject: [PATCH 39/54] feat(ms-transaction): update Avro schemas for transaction events --- .../src/main/avro/TransactionCreatedEvent.avsc | 12 ++++++------ .../src/main/avro/TransactionStatusUpdatedEvent.avsc | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc index e3c57516da..dd825a7498 100644 --- a/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc +++ b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc @@ -1,7 +1,7 @@ { "type": "record", "name": "TransactionCreatedEvent", - "namespace": "com.yape.transaction.events", + "namespace": "com.yape.services.transaction.events", "doc": "Emitted event when a new transaction is created. Published to the topic 'transaction.created' for the Anti-Fraud service to validate the transaction.", "fields": [ { @@ -9,7 +9,7 @@ "type": { "type": "record", "name": "EventMetadata", - "namespace": "com.yape.common.events", + "namespace": "com.yape.services.common.events", "doc": "Standard metadata for all system events", "fields": [ { @@ -66,17 +66,17 @@ { "name": "accountExternalIdDebit", "type": "string", - "doc": "Debit account external identifier (source)" + "doc": "Debit account external identifier" }, { "name": "accountExternalIdCredit", "type": "string", - "doc": "Credit account external identifier (destination)" + "doc": "Credit account external identifier" }, { "name": "transferTypeId", "type": "int", - "doc": "Transfer type identifier. (1: INTERNAL, 2: INTRABANK, 3: INTERBANK, 4: INTERNATIONAL)" + "doc": "Transfer type identifier." }, { "name": "value", @@ -88,7 +88,7 @@ "type": { "type": "enum", "name": "TransactionStatus", - "namespace": "com.yape.transaction.events.enums", + "namespace": "com.yape.services.transaction.events.enums", "doc": "Possible statuses of a financial transaction", "symbols": [ "PENDING", diff --git a/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc index 84977f6c9c..049e9b81b1 100644 --- a/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc +++ b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -1,7 +1,7 @@ { "type": "record", "name": "TransactionStatusUpdatedEvent", - "namespace": "com.yape.transaction.events", + "namespace": "com.yape.services.transaction.events", "doc": "Emitted event when Anti-Fraud service updates the status of a transaction. Published to the topic 'transaction.status' for ms-transaction to update the status in the database.", "fields": [ { @@ -9,7 +9,7 @@ "type": { "type": "record", "name": "EventMetadata", - "namespace": "com.yape.common.events", + "namespace": "com.yape.services.common.events", "doc": "Standard metadata for all system events", "fields": [ { @@ -68,7 +68,7 @@ "type": { "type": "enum", "name": "TransactionStatus", - "namespace": "com.yape.transaction.events.enums", + "namespace": "com.yape.services.transaction.events.enums", "doc": "Possible statuses of a financial transaction", "symbols": [ "PENDING", @@ -80,7 +80,7 @@ }, { "name": "newStatus", - "type": "com.yape.transaction.events.enums.TransactionStatus", + "type": "com.yape.services.transaction.events.enums.TransactionStatus", "doc": "New status of the transaction" }, { From aad55914c96cd57dadbe28b06029d55e00563a8d Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:56:57 -0500 Subject: [PATCH 40/54] feat(ms-transaction): update application configuration for Kafka, Redis and database --- .../src/main/resources/application-local.yml | 81 ++++++++++++++----- .../src/main/resources/application.yml | 10 +++ 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/ms-transaction/src/main/resources/application-local.yml b/ms-transaction/src/main/resources/application-local.yml index cb67bd792b..1696cd2dd6 100644 --- a/ms-transaction/src/main/resources/application-local.yml +++ b/ms-transaction/src/main/resources/application-local.yml @@ -1,14 +1,18 @@ # This configuration is used for local development environment. # Profile: local (activate with -Dquarkus.profile=local) -# External Service Hosts -postgresql-host: "127.0.0.1" -postgresql-port: 5432 -postgresql-user: "postgres" -postgresql-pass: "postgres" +# External Service Hosts (use environment variables with defaults for local dev) +postgresql-host: "${POSTGRES_HOST:127.0.0.1}" +postgresql-port: "${POSTGRES_PORT:5432}" +postgresql-user: "${POSTGRES_USER:postgres}" +postgresql-pass: "${POSTGRES_PASSWORD:postgres}" -event-hubs-host: "127.0.0.1" -schema-registry-host: "127.0.0.1" +event-hubs-host: "${KAFKA_HOST:127.0.0.1}" +schema-registry-host: "${SCHEMA_REGISTRY_HOST:127.0.0.1}" + +redis-host: "${REDIS_HOST:127.0.0.1}" +redis-port: "${REDIS_PORT:6379}" +redis-pass: "${REDIS_PASSWORD:}" # Quarkus Configuration quarkus: @@ -27,14 +31,22 @@ quarkus: idle-removal-interval: 2M max-lifetime: 30M + # Redis Configuration + redisson: + threads: 16 + netty-threads: 32 + single-server-config: + address: "redis://${redis-host}:${redis-port}" + password: + + flyway: + locations: classpath:db/migration + baseline-on-migrate: true + # Hibernate ORM Configuration hibernate-orm: - database: - # Options: none, create, drop-and-create, drop, update, validate - generation: update log: - sql: true - format-sql: true + sql: false devservices: enabled: false @@ -46,11 +58,34 @@ quarkus: "com.yape": level: DEBUG "org.hibernate.SQL": - level: DEBUG - "org.hibernate.type.descriptor.sql": - level: TRACE + level: ERROR "io.smallrye.reactive.messaging": - level: DEBUG + level: ERROR + + # SmallRye GraphQL Configuration + smallrye-graphql: + error-extension-fields: + - exception + - classification + - code + show-runtime-exception-message: + - com.yape.services.shared.exception.BusinessException + - com.yape.services.shared.exception.ValidationException + - com.yape.services.shared.exception.ResourceNotFoundException + +application: + cache: + transaction: + map-name: "transactions" + prefix: "transaction:" + ttl: + pending: 300 + approved: 3600 + rejected: 3600 + transfer-type: + map-name: "transfer-types" + prefix: "transfer_type:" + ttl: 86400 # Kafka / Event Streaming Configuration kafka: @@ -64,18 +99,21 @@ kafka: mp: messaging: outgoing: - kafka-out: + transaction-producer: connector: smallrye-kafka - topic: test-event + topic: transaction.created key: serializer: org.apache.kafka.common.serialization.StringSerializer value: serializer: io.confluent.kafka.serializers.KafkaAvroSerializer + schema: + registry: + url: "http://${schema-registry-host}:8081" incoming: - kafka-in: + transaction-status-consumer: connector: smallrye-kafka - topic: test-event + topic: transaction.status group: id: ms-transaction-group auto: @@ -86,6 +124,9 @@ mp: deserializer: org.apache.kafka.common.serialization.StringDeserializer value: deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer + schema: + registry: + url: "http://${schema-registry-host}:8081" specific: avro: reader: true diff --git a/ms-transaction/src/main/resources/application.yml b/ms-transaction/src/main/resources/application.yml index c3d15b400d..2662a4a99f 100644 --- a/ms-transaction/src/main/resources/application.yml +++ b/ms-transaction/src/main/resources/application.yml @@ -8,6 +8,16 @@ quarkus: http: root-path: /ms-transaction port: 18080 + profile: local + smallrye-graphql: + error-extension-fields: + - exception + - classification + - code + show-runtime-exception-message: + - com.yape.services.shared.exception.BusinessException + - com.yape.services.shared.exception.ValidationException + - com.yape.services.shared.exception.ResourceNotFoundException info: project: From 5608af54f73c1fe4058e5e765bcbe59501d5bf2c Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:57:05 -0500 Subject: [PATCH 41/54] test(ms-transaction): add unit tests for all layers --- .../graphql/MutationResolverImplTest.java | 144 +++++++++ .../expose/graphql/QueryResolverImplTest.java | 126 ++++++++ .../CreateTransactionCommandHandlerTest.java | 147 +++++++++ .../mapper/GraphqlTransactionMapperTest.java | 167 ++++++++++ .../mapper/TransactionMapperTest.java | 186 +++++++++++ .../query/TransactionQueryHandlerTest.java | 173 +++++++++++ .../TransactionStatusQueryHandlerTest.java | 141 +++++++++ .../query/TransferTypeQueryHandlerTest.java | 169 ++++++++++ .../usecase/CreateTransactionUseCaseTest.java | 291 ++++++++++++++++++ .../usecase/GetTransactionUseCaseTest.java | 181 +++++++++++ .../UpdateTransactionStatusUseCaseTest.java | 201 ++++++++++++ .../TransactionCacheServiceImplTest.java | 193 ++++++++++++ .../TransferTypeCacheServiceImplTest.java | 201 ++++++++++++ .../KafkaTransactionEventPublisherTest.java | 126 ++++++++ .../KafkaTransactionStatusConsumerTest.java | 156 ++++++++++ .../TransactionPersistenceTest.java | 190 ++++++++++++ .../TransactionStatusPersistenceTest.java | 144 +++++++++ .../TransferTypePersistenceTest.java | 178 +++++++++++ .../TransactionPostgresRepositoryTest.java | 200 ++++++++++++ ...ansactionStatusPostgresRepositoryTest.java | 184 +++++++++++ .../TransferTypePostgresRepositoryTest.java | 183 +++++++++++ 21 files changed, 3681 insertions(+) create mode 100644 ms-transaction/src/test/java/com/yape/services/expose/graphql/MutationResolverImplTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/expose/graphql/QueryResolverImplTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandlerTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapperTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/TransactionMapperTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionQueryHandlerTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandlerTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransferTypeQueryHandlerTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCaseTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/GetTransactionUseCaseTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCaseTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImplTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImplTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisherTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumerTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistenceTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistenceTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistenceTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepositoryTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepositoryTest.java create mode 100644 ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepositoryTest.java diff --git a/ms-transaction/src/test/java/com/yape/services/expose/graphql/MutationResolverImplTest.java b/ms-transaction/src/test/java/com/yape/services/expose/graphql/MutationResolverImplTest.java new file mode 100644 index 0000000000..b749b19047 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/expose/graphql/MutationResolverImplTest.java @@ -0,0 +1,144 @@ +package com.yape.services.expose.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.application.usecase.CreateTransactionUseCase; +import com.yape.services.transaction.graphql.model.CreateTransaction; +import com.yape.services.transaction.graphql.model.Transaction; +import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MutationResolverImplTest { + + @Mock + private CreateTransactionUseCase createTransactionUseCase; + @Mock + private CurrentVertxRequest currentVertxRequest; + @Mock + private RoutingContext routingContext; + @Mock + private HttpServerRequest httpServerRequest; + + @Captor + private ArgumentCaptor metaDataCaptor; + + private MutationResolverImpl resolver; + + @BeforeEach + void setUp() { + resolver = new MutationResolverImpl(createTransactionUseCase, currentVertxRequest); + } + + @Nested + @DisplayName("createTransaction") + class CreateTransactionTests { + + @Test + @DisplayName("should delegate to use case and return created transaction") + void shouldDelegateToUseCaseAndReturnCreatedTransaction() { + // Arrange + CreateTransaction input = createInput(); + Transaction expectedTransaction = createTransaction(); + + when(currentVertxRequest.getCurrent()).thenReturn(routingContext); + when(routingContext.request()).thenReturn(httpServerRequest); + when(httpServerRequest.getHeader("Authorization")).thenReturn("Bearer token"); + when(httpServerRequest.getHeader("Request-ID")).thenReturn("req-123"); + when(httpServerRequest.getHeader("Request-Date")).thenReturn("2024-01-01"); + when(createTransactionUseCase.execute(eq(input), any(RequestMetaData.class))) + .thenReturn(expectedTransaction); + + // Act + Transaction result = resolver.createTransaction(input); + + // Assert + assertEquals(expectedTransaction, result); + verify(createTransactionUseCase).execute(eq(input), any(RequestMetaData.class)); + } + + @Test + @DisplayName("should extract headers and pass as metadata") + void shouldExtractHeadersAndPassAsMetadata() { + // Arrange + CreateTransaction input = createInput(); + Transaction expectedTransaction = createTransaction(); + + when(currentVertxRequest.getCurrent()).thenReturn(routingContext); + when(routingContext.request()).thenReturn(httpServerRequest); + when(httpServerRequest.getHeader("Authorization")).thenReturn("Bearer my-token"); + when(httpServerRequest.getHeader("Request-ID")).thenReturn("request-456"); + when(httpServerRequest.getHeader("Request-Date")).thenReturn("2024-02-15"); + when(createTransactionUseCase.execute(eq(input), metaDataCaptor.capture())) + .thenReturn(expectedTransaction); + + // Act + resolver.createTransaction(input); + + // Assert + RequestMetaData capturedMetaData = metaDataCaptor.getValue(); + assertEquals("Bearer my-token", capturedMetaData.authorization()); + assertEquals("request-456", capturedMetaData.requestId()); + assertEquals("2024-02-15", capturedMetaData.requestDate()); + } + + @Test + @DisplayName("should handle missing headers") + void shouldHandleMissingHeaders() { + // Arrange + CreateTransaction input = createInput(); + Transaction expectedTransaction = createTransaction(); + + when(currentVertxRequest.getCurrent()).thenReturn(routingContext); + when(routingContext.request()).thenReturn(httpServerRequest); + when(httpServerRequest.getHeader("Authorization")).thenReturn(null); + when(httpServerRequest.getHeader("Request-ID")).thenReturn(null); + when(httpServerRequest.getHeader("Request-Date")).thenReturn(null); + when(createTransactionUseCase.execute(eq(input), metaDataCaptor.capture())) + .thenReturn(expectedTransaction); + + // Act + Transaction result = resolver.createTransaction(input); + + // Assert + assertEquals(expectedTransaction, result); + RequestMetaData capturedMetaData = metaDataCaptor.getValue(); + assertNull(capturedMetaData.authorization()); + assertNull(capturedMetaData.requestId()); + assertNull(capturedMetaData.requestDate()); + } + } + + private CreateTransaction createInput() { + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit(UUID.randomUUID().toString()); + input.setAccountExternalIdCredit(UUID.randomUUID().toString()); + input.setTransferTypeId(1); + input.setValue("100.00"); + return input; + } + + private Transaction createTransaction() { + Transaction tx = new Transaction(); + tx.setTransactionExternalId(UUID.randomUUID().toString()); + tx.setValue("100.00"); + return tx; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/expose/graphql/QueryResolverImplTest.java b/ms-transaction/src/test/java/com/yape/services/expose/graphql/QueryResolverImplTest.java new file mode 100644 index 0000000000..247f800163 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/expose/graphql/QueryResolverImplTest.java @@ -0,0 +1,126 @@ +package com.yape.services.expose.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.application.usecase.GetTransactionUseCase; +import com.yape.services.transaction.application.usecase.GetTransferTypesUseCase; +import com.yape.services.transaction.graphql.model.Transaction; +import com.yape.services.transaction.graphql.model.TransferType; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class QueryResolverImplTest { + + @Mock + private GetTransferTypesUseCase getTransferTypesUseCase; + @Mock + private GetTransactionUseCase getTransactionUseCase; + + private QueryResolverImpl resolver; + + private static final String TRANSACTION_EXTERNAL_ID = UUID.randomUUID().toString(); + + @BeforeEach + void setUp() { + resolver = new QueryResolverImpl(getTransferTypesUseCase, getTransactionUseCase); + } + + @Nested + @DisplayName("transaction") + class TransactionQueryTests { + + @Test + @DisplayName("should delegate to use case and return transaction") + void shouldDelegateToUseCaseAndReturnTransaction() { + // Arrange + Transaction expectedTransaction = createTransaction(); + when(getTransactionUseCase.execute(TRANSACTION_EXTERNAL_ID)) + .thenReturn(expectedTransaction); + + // Act + Transaction result = resolver.transaction(TRANSACTION_EXTERNAL_ID); + + // Assert + assertEquals(expectedTransaction, result); + verify(getTransactionUseCase).execute(TRANSACTION_EXTERNAL_ID); + } + + @Test + @DisplayName("should pass transaction ID to use case") + void shouldPassTransactionIdToUseCase() { + // Arrange + String specificId = "specific-transaction-id"; + Transaction expectedTransaction = createTransaction(); + when(getTransactionUseCase.execute(specificId)) + .thenReturn(expectedTransaction); + + // Act + resolver.transaction(specificId); + + // Assert + verify(getTransactionUseCase).execute(specificId); + } + } + + @Nested + @DisplayName("transferTypes") + class TransferTypesQueryTests { + + @Test + @DisplayName("should delegate to use case and return transfer types") + void shouldDelegateToUseCaseAndReturnTransferTypes() { + // Arrange + List expectedTypes = List.of( + createTransferType(1, "INTERNAL"), + createTransferType(2, "EXTERNAL") + ); + when(getTransferTypesUseCase.execute()).thenReturn(expectedTypes); + + // Act + List result = resolver.transferTypes(); + + // Assert + assertEquals(2, result.size()); + assertEquals(expectedTypes, result); + verify(getTransferTypesUseCase).execute(); + } + + @Test + @DisplayName("should return empty list when no transfer types exist") + void shouldReturnEmptyListWhenNoTransferTypesExist() { + // Arrange + when(getTransferTypesUseCase.execute()).thenReturn(List.of()); + + // Act + List result = resolver.transferTypes(); + + // Assert + assertTrue(result.isEmpty()); + } + } + + private Transaction createTransaction() { + Transaction tx = new Transaction(); + tx.setTransactionExternalId(TRANSACTION_EXTERNAL_ID); + tx.setValue("100.00"); + return tx; + } + + private TransferType createTransferType(int id, String name) { + TransferType type = new TransferType(); + type.setTransferTypeId(String.valueOf(id)); + type.setName(name); + return type; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandlerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandlerTest.java new file mode 100644 index 0000000000..3373a74330 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/command/CreateTransactionCommandHandlerTest.java @@ -0,0 +1,147 @@ +package com.yape.services.transaction.application.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import java.math.BigDecimal; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateTransactionCommandHandler") +class CreateTransactionCommandHandlerTest { + + @Mock + private TransactionRepository repository; + @Mock + private TransactionCacheService cacheService; + + @Captor + private ArgumentCaptor transactionCaptor; + + private CreateTransactionCommandHandler handler; + + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final int TRANSFER_TYPE_ID = 1; + private static final int TRANSACTION_STATUS_ID = 1; + private static final String STATUS_CODE = "PENDING"; + private static final BigDecimal VALUE = new BigDecimal("150.75"); + + @BeforeEach + void setUp() { + handler = new CreateTransactionCommandHandler(repository, cacheService); + } + + @Test + @DisplayName("should create transaction and save to repository") + void shouldCreateTransactionAndSaveToRepository() { + // Arrange + CreateTransactionCommand command = createCommand(); + Transaction savedTransaction = createSavedTransaction(); + + when(repository.save(any(Transaction.class))).thenReturn(savedTransaction); + + // Act + Transaction result = handler.handle(command); + + // Assert + assertEquals(savedTransaction, result); + verify(repository).save(transactionCaptor.capture()); + + Transaction capturedTransaction = transactionCaptor.getValue(); + assertEquals(DEBIT_ACCOUNT_ID, capturedTransaction.getAccountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID, capturedTransaction.getAccountExternalIdCredit()); + assertEquals(TRANSFER_TYPE_ID, capturedTransaction.getTransferTypeId()); + assertEquals(TRANSACTION_STATUS_ID, capturedTransaction.getTransactionStatusId()); + assertEquals(0, capturedTransaction.getValue().compareTo(VALUE)); + assertNotNull(capturedTransaction.getTransactionExternalId()); + } + + @Test + @DisplayName("should cache transaction after saving") + void shouldCacheTransactionAfterSaving() { + // Arrange + CreateTransactionCommand command = createCommand(); + Transaction savedTransaction = createSavedTransaction(); + + when(repository.save(any(Transaction.class))).thenReturn(savedTransaction); + + // Act + handler.handle(command); + + // Assert + verify(cacheService).saveTransaction(savedTransaction, STATUS_CODE); + } + + @Test + @DisplayName("should generate unique transaction external ID") + void shouldGenerateUniqueTransactionExternalId() { + // Arrange + CreateTransactionCommand command = createCommand(); + Transaction savedTransaction = createSavedTransaction(); + + when(repository.save(any(Transaction.class))).thenReturn(savedTransaction); + + // Act + handler.handle(command); + + // Assert + verify(repository).save(transactionCaptor.capture()); + Transaction capturedTransaction = transactionCaptor.getValue(); + assertNotNull(capturedTransaction.getTransactionExternalId()); + } + + @Test + @DisplayName("should return saved transaction from repository") + void shouldReturnSavedTransactionFromRepository() { + // Arrange + CreateTransactionCommand command = createCommand(); + Transaction savedTransaction = createSavedTransaction(); + UUID expectedExternalId = savedTransaction.getTransactionExternalId(); + + when(repository.save(any(Transaction.class))).thenReturn(savedTransaction); + + // Act + Transaction result = handler.handle(command); + + // Assert + assertEquals(expectedExternalId, result.getTransactionExternalId()); + assertEquals(0, result.getValue().compareTo(VALUE)); + } + + private CreateTransactionCommand createCommand() { + return new CreateTransactionCommand( + DEBIT_ACCOUNT_ID, + CREDIT_ACCOUNT_ID, + TRANSFER_TYPE_ID, + TRANSACTION_STATUS_ID, + STATUS_CODE, + VALUE + ); + } + + private Transaction createSavedTransaction() { + return Transaction.builder() + .transactionExternalId(UUID.randomUUID()) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(TRANSFER_TYPE_ID) + .transactionStatusId(TRANSACTION_STATUS_ID) + .value(VALUE) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapperTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapperTest.java new file mode 100644 index 0000000000..a3436952bb --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/GraphqlTransactionMapperTest.java @@ -0,0 +1,167 @@ +package com.yape.services.transaction.application.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.graphql.model.TransactionType; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("GraphqlTransactionMapper") +class GraphqlTransactionMapperTest { + + private GraphqlTransactionMapper mapper; + + @BeforeEach + void setUp() { + mapper = new GraphqlTransactionMapperImpl(); + } + + @Test + @DisplayName("should convert UUID to string") + void shouldConvertUuidToString() { + // Arrange + UUID uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + + // Act + String result = mapper.uuidToString(uuid); + + // Assert + assertEquals("123e4567-e89b-12d3-a456-426614174000", result); + } + + @Test + @DisplayName("should return null for null UUID") + void shouldReturnNullForNullUuid() { + // Act + String result = mapper.uuidToString(null); + + // Assert + assertNull(result); + } + + @ParameterizedTest(name = "{index} => value={0}, expected={1}") + @MethodSource("bigDecimalToStringProvider") + @DisplayName("should convert BigDecimal to string (parameterized)") + void shouldConvertBigDecimalToString(BigDecimal value, String expected) { + // Act + String result = mapper.bigDecimalToString(value); + // Assert + assertEquals(expected, result); + } + + static Stream bigDecimalToStringProvider() { + return Stream.of( + Arguments.of(new BigDecimal("1234.56"), "1234.56"), + Arguments.of(new BigDecimal("999999999999.99"), "999999999999.99"), + Arguments.of(null, null), + Arguments.of(new BigDecimal("1000"), "1000") + ); + } + + @Test + @DisplayName("should format LocalDateTime to ISO date") + void shouldFormatLocalDateTimeToIsoDate() { + // Arrange + LocalDateTime dateTime = LocalDateTime.of(2024, 6, 15, 10, 30, 45); + + // Act + String result = mapper.formatDate(dateTime); + + // Assert + assertEquals("2024-06-15", result); + } + + @Test + @DisplayName("should return null for null LocalDateTime") + void shouldReturnNullForNullLocalDateTime() { + // Act + String result = mapper.formatDate(null); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("should format date with single digit month and day") + void shouldFormatDateWithSingleDigitMonthAndDay() { + // Arrange + LocalDateTime dateTime = LocalDateTime.of(2024, 1, 5, 8, 15); + + // Act + String result = mapper.formatDate(dateTime); + + // Assert + assertEquals("2024-01-05", result); + } + + @Test + @DisplayName("should convert TransferType to TransactionType") + void shouldConvertTransferTypeToTransactionType() { + // Arrange + TransferType transferType = TransferType.builder() + .transferTypeId(1) + .code("INTERNAL") + .name("Internal Transfer") + .build(); + + // Act + TransactionType result = mapper.toTransactionType(transferType); + + // Assert + assertNotNull(result); + assertEquals("Internal Transfer", result.getName()); + } + + @Test + @DisplayName("should return null for null TransferType") + void shouldReturnNullForNullTransferType() { + // Act + TransactionType result = mapper.toTransactionType(null); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("should convert domain TransactionStatus to GraphQL TransactionStatus") + void shouldConvertDomainStatusToGraphqlStatus() { + // Arrange + TransactionStatus txStatus = TransactionStatus.builder() + .transactionStatusId(1) + .code("PENDING") + .name("Pending") + .build(); + + // Act + com.yape.services.transaction.graphql.model.TransactionStatus result = + mapper.toGraphqlStatus(txStatus); + + // Assert + assertNotNull(result); + assertEquals("Pending", result.getName()); + } + + @Test + @DisplayName("should return null for null TransactionStatus") + void shouldReturnNullForNullTransactionStatus() { + // Act + com.yape.services.transaction.graphql.model.TransactionStatus result = + mapper.toGraphqlStatus(null); + + // Assert + assertNull(result); + } + +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/TransactionMapperTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/TransactionMapperTest.java new file mode 100644 index 0000000000..ff7f2260e1 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/mapper/TransactionMapperTest.java @@ -0,0 +1,186 @@ +package com.yape.services.transaction.application.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import java.math.BigDecimal; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("TransactionMapper") +class TransactionMapperTest { + + private TransactionMapper mapper; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final BigDecimal VALUE = new BigDecimal("250.75"); + + @BeforeEach + void setUp() { + mapper = new TransactionMapper(); + } + + @Test + @DisplayName("should map transaction to event with all fields") + void shouldMapTransactionToEventWithAllFields() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertNotNull(event); + assertNotNull(event.getMetadata()); + assertNotNull(event.getPayload()); + // Verify metadata + assertNotNull(event.getMetadata().getEventId()); + assertEquals("TRANSACTION_CREATED", event.getMetadata().getEventType()); + assertEquals("ms-transaction", event.getMetadata().getSource()); + assertEquals("1.0.0", event.getMetadata().getVersion()); + assertEquals("req-123", event.getMetadata().getRequestId()); + assertNotNull(event.getMetadata().getEventTimestamp()); + // Verify payload + assertEquals( + TRANSACTION_EXTERNAL_ID.toString(), + event.getPayload().getTransactionExternalId() + ); + assertEquals(DEBIT_ACCOUNT_ID.toString(), event.getPayload().getAccountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID.toString(), + event.getPayload().getAccountExternalIdCredit()); + assertEquals(1, event.getPayload().getTransferTypeId()); + assertEquals("250.75", event.getPayload().getValue()); + assertEquals("PENDING", event.getPayload().getStatus().name()); + } + + @Test + @DisplayName("should handle null request metadata") + void shouldHandleNullRequestMetadata() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, null); + + // Assert + assertNotNull(event); + assertNull(event.getMetadata().getRequestId()); + } + + @Test + @DisplayName("should map APPROVED status correctly") + void shouldMapApprovedStatusCorrectly() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = TransactionStatus.builder() + .transactionStatusId(2) + .code("APPROVED") + .name("Approved") + .build(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertEquals("APPROVED", event.getPayload().getStatus().name()); + } + + @Test + @DisplayName("should map REJECTED status correctly") + void shouldMapRejectedStatusCorrectly() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = TransactionStatus.builder() + .transactionStatusId(3) + .code("REJECTED") + .name("Rejected") + .build(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertEquals("REJECTED", event.getPayload().getStatus().name()); + } + + @Test + @DisplayName("should generate unique event ID for each mapping") + void shouldGenerateUniqueEventIdForEachMapping() { + // Arrange + Transaction transaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event1 = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + TransactionCreatedEvent event2 = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertNotNull(event1.getMetadata().getEventId()); + assertNotNull(event2.getMetadata().getEventId()); + assertNotEquals(event1.getMetadata().getEventId(), event2.getMetadata().getEventId()); + } + + @Test + @DisplayName("should format value as plain string without scientific notation") + void shouldFormatValueAsPlainString() { + // Arrange + Transaction transaction = Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(1) + .transactionStatusId(1) + .value(new BigDecimal("1234567.89")) + .build(); + TransactionStatus status = createPendingStatus(); + RequestMetaData metaData = new RequestMetaData("Bearer token", "req-123", "2024-01-01"); + + // Act + TransactionCreatedEvent event = mapper + .toTransactionCreatedEvent(transaction, status, metaData); + + // Assert + assertEquals("1234567.89", event.getPayload().getValue()); + } + + private Transaction createTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(1) + .transactionStatusId(1) + .value(VALUE) + .build(); + } + + private TransactionStatus createPendingStatus() { + return TransactionStatus.builder() + .transactionStatusId(1) + .code("PENDING") + .name("Pending") + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionQueryHandlerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionQueryHandlerTest.java new file mode 100644 index 0000000000..8bc0eb907b --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionQueryHandlerTest.java @@ -0,0 +1,173 @@ +package com.yape.services.transaction.application.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionQueryHandlerTest { + + @Mock + private TransactionRepository transactionRepository; + @Mock + private TransactionStatusRepository transactionStatusRepository; + @Mock + private TransactionCacheService cacheService; + + private TransactionQueryHandler handler; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final int TRANSACTION_STATUS_ID = 1; + + @BeforeEach + void setUp() { + handler = new TransactionQueryHandler( + transactionRepository, + transactionStatusRepository, + cacheService + ); + } + + @Test + @DisplayName("should return transaction from cache when present") + void shouldReturnTransactionFromCacheWhenPresent() { + // Arrange + Transaction cachedTransaction = createTransaction(); + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(cachedTransaction)); + + // Act + Optional result = handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(cachedTransaction, result.get()); + verify(transactionRepository, never()).findByExternalId(TRANSACTION_EXTERNAL_ID); + } + + @Test + @DisplayName("should fetch from database on cache miss") + void shouldFetchFromDatabaseOnCacheMiss() { + // Arrange + Transaction dbTransaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(dbTransaction)); + when(transactionStatusRepository.findById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.of(status)); + + // Act + Optional result = handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(dbTransaction, result.get()); + verify(transactionRepository).findByExternalId(TRANSACTION_EXTERNAL_ID); + } + + @Test + @DisplayName("should cache transaction after fetching from database") + void shouldCacheTransactionAfterFetchingFromDatabase() { + // Arrange + Transaction dbTransaction = createTransaction(); + TransactionStatus status = createPendingStatus(); + + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(dbTransaction)); + when(transactionStatusRepository.findById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.of(status)); + + // Act + handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + verify(cacheService).saveTransaction(dbTransaction, "PENDING"); + } + + @Test + @DisplayName("should return empty when transaction not found in cache or database") + void shouldReturnEmptyWhenTransactionNotFound() { + // Arrange + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isEmpty()); + verify(cacheService, never()).saveTransaction( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } + + @Test + @DisplayName("should not cache when status not found") + void shouldNotCacheWhenStatusNotFound() { + // Arrange + Transaction dbTransaction = createTransaction(); + + when(cacheService.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(dbTransaction)); + when(transactionStatusRepository.findById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + verify(cacheService, never()).saveTransaction( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } + + private Transaction createTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .transferTypeId(1) + .transactionStatusId(TRANSACTION_STATUS_ID) + .value(new BigDecimal("100.00")) + .createdAt(LocalDateTime.now()) + .build(); + } + + private TransactionStatus createPendingStatus() { + return TransactionStatus.builder() + .transactionStatusId(TRANSACTION_STATUS_ID) + .code("PENDING") + .name("Pending") + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandlerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandlerTest.java new file mode 100644 index 0000000000..aa934a554b --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransactionStatusQueryHandlerTest.java @@ -0,0 +1,141 @@ +package com.yape.services.transaction.application.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionStatusQueryHandlerTest { + + @Mock + private TransactionStatusRepository transactionStatusRepository; + + private TransactionStatusQueryHandler handler; + + @BeforeEach + void setUp() { + handler = new TransactionStatusQueryHandler(transactionStatusRepository); + } + + @Test + @DisplayName("should return status when found by code") + void shouldReturnStatusWhenFoundByCode() { + // Arrange + String code = "PENDING"; + TransactionStatus expectedStatus = createStatus(1, code, "Pending"); + when(transactionStatusRepository.findByCode(code)) + .thenReturn(Optional.of(expectedStatus)); + + // Act + Optional result = handler.getTransactionStatusByCode(code); + + // Assert + assertTrue(result.isPresent()); + assertEquals(code, result.get().getCode()); + verify(transactionStatusRepository).findByCode(code); + } + + @Test + @DisplayName("should return empty when status not found by code") + void shouldReturnEmptyWhenStatusNotFoundByCode() { + // Arrange + String code = "UNKNOWN"; + when(transactionStatusRepository.findByCode(code)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransactionStatusByCode(code); + + // Assert + assertFalse(result.isPresent()); + verify(transactionStatusRepository).findByCode(code); + } + + @Test + @DisplayName("should find APPROVED status") + void shouldFindApprovedStatus() { + // Arrange + String code = "APPROVED"; + TransactionStatus expectedStatus = createStatus(2, code, "Approved"); + when(transactionStatusRepository.findByCode(code)) + .thenReturn(Optional.of(expectedStatus)); + + // Act + Optional result = handler.getTransactionStatusByCode(code); + + // Assert + assertTrue(result.isPresent()); + assertEquals("APPROVED", result.get().getCode()); + } + + @Test + @DisplayName("should find REJECTED status") + void shouldFindRejectedStatus() { + // Arrange + String code = "REJECTED"; + TransactionStatus expectedStatus = createStatus(3, code, "Rejected"); + when(transactionStatusRepository.findByCode(code)) + .thenReturn(Optional.of(expectedStatus)); + + // Act + Optional result = handler.getTransactionStatusByCode(code); + + // Assert + assertTrue(result.isPresent()); + assertEquals("REJECTED", result.get().getCode()); + } + + @Test + @DisplayName("should return status when found by ID") + void shouldReturnStatusWhenFoundById() { + // Arrange + Integer id = 1; + TransactionStatus expectedStatus = createStatus(id, "PENDING", "Pending"); + when(transactionStatusRepository.findById(id)) + .thenReturn(Optional.of(expectedStatus)); + + // Act + Optional result = handler.getTransactionStatusById(id); + + // Assert + assertTrue(result.isPresent()); + assertEquals(id, result.get().getTransactionStatusId()); + verify(transactionStatusRepository).findById(id); + } + + @Test + @DisplayName("should return empty when status not found by ID") + void shouldReturnEmptyWhenStatusNotFoundById() { + // Arrange + Integer id = 999; + when(transactionStatusRepository.findById(id)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransactionStatusById(id); + + // Assert + assertFalse(result.isPresent()); + verify(transactionStatusRepository).findById(id); + } + + private TransactionStatus createStatus(int id, String code, String name) { + return TransactionStatus.builder() + .transactionStatusId(id) + .code(code) + .name(name) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransferTypeQueryHandlerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransferTypeQueryHandlerTest.java new file mode 100644 index 0000000000..2dc4057394 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/query/TransferTypeQueryHandlerTest.java @@ -0,0 +1,169 @@ +package com.yape.services.transaction.application.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.repository.TransferTypeRepository; +import com.yape.services.transaction.domain.service.TransferTypeCacheService; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransferTypeQueryHandlerTest { + + @Mock + private TransferTypeRepository transferTypeRepository; + @Mock + private TransferTypeCacheService cacheService; + + private TransferTypeQueryHandler handler; + + private static final int TRANSFER_TYPE_ID = 1; + + @BeforeEach + void setUp() { + handler = new TransferTypeQueryHandler(transferTypeRepository, cacheService); + } + + @Test + @DisplayName("should return transfer type from cache when present") + void shouldReturnTransferTypeFromCacheWhenPresent() { + // Arrange + TransferType cachedTransferType = createTransferType(TRANSFER_TYPE_ID); + when(cacheService.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(cachedTransferType)); + + // Act + Optional result = handler.getTransferTypeById(TRANSFER_TYPE_ID); + + // Assert + assertTrue(result.isPresent()); + assertSame(cachedTransferType, result.get()); + verify(transferTypeRepository, never()).findById(TRANSFER_TYPE_ID); + } + + @Test + @DisplayName("should fetch from database on cache miss") + void shouldFetchFromDatabaseOnCacheMiss() { + // Arrange + TransferType dbTransferType = createTransferType(TRANSFER_TYPE_ID); + when(cacheService.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + when(transferTypeRepository.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(dbTransferType)); + + // Act + Optional result = handler.getTransferTypeById(TRANSFER_TYPE_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(dbTransferType, result.get()); + verify(transferTypeRepository).findById(TRANSFER_TYPE_ID); + } + + @Test + @DisplayName("should cache transfer type after fetching from database") + void shouldCacheTransferTypeAfterFetchingFromDatabase() { + // Arrange + TransferType dbTransferType = createTransferType(TRANSFER_TYPE_ID); + when(cacheService.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + when(transferTypeRepository.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(dbTransferType)); + + // Act + handler.getTransferTypeById(TRANSFER_TYPE_ID); + + // Assert + verify(cacheService).save(dbTransferType); + } + + @Test + @DisplayName("should return empty when transfer type not found") + void shouldReturnEmptyWhenTransferTypeNotFound() { + // Arrange + when(cacheService.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + when(transferTypeRepository.findById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + + // Act + Optional result = handler.getTransferTypeById(TRANSFER_TYPE_ID); + + // Assert + assertFalse(result.isPresent()); + verify(cacheService, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("should return transfer types from cache when present") + void shouldReturnTransferTypesFromCacheWhenPresent() { + // Arrange + List cachedTypes = List.of( + createTransferType(1), + createTransferType(2) + ); + when(cacheService.findAll()).thenReturn(Optional.of(cachedTypes)); + + // Act + List result = handler.getAll(); + + // Assert + assertEquals(2, result.size()); + assertEquals(cachedTypes, result); + verify(transferTypeRepository, never()).findAll(); + } + + @Test + @DisplayName("should cache all transfer types after fetching from database") + void shouldCacheAllTransferTypesAfterFetchingFromDatabase() { + // Arrange + List dbTypes = List.of( + createTransferType(1), + createTransferType(2) + ); + when(cacheService.findAll()).thenReturn(Optional.empty()); + when(transferTypeRepository.findAll()).thenReturn(dbTypes); + + // Act + handler.getAll(); + + // Assert + verify(cacheService).saveAll(dbTypes); + } + + @Test + @DisplayName("should return empty list when no transfer types found") + void shouldReturnEmptyListWhenNoTransferTypesFound() { + // Arrange + when(cacheService.findAll()).thenReturn(Optional.empty()); + when(transferTypeRepository.findAll()).thenReturn(List.of()); + + // Act + List result = handler.getAll(); + + // Assert + assertTrue(result.isEmpty()); + verify(cacheService).saveAll(List.of()); + } + + private TransferType createTransferType(int id) { + return TransferType.builder() + .transferTypeId(id) + .code("TRANSFER_" + id) + .name("Transfer Type " + id) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCaseTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCaseTest.java new file mode 100644 index 0000000000..c759da37f8 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/CreateTransactionUseCaseTest.java @@ -0,0 +1,291 @@ +package com.yape.services.transaction.application.usecase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.shared.exception.BusinessException; +import com.yape.services.shared.exception.ResourceNotFoundException; +import com.yape.services.shared.exception.ValidationException; +import com.yape.services.transaction.application.command.CreateTransactionCommand; +import com.yape.services.transaction.application.command.CreateTransactionCommandHandler; +import com.yape.services.transaction.application.dto.RequestMetaData; +import com.yape.services.transaction.application.mapper.GraphqlTransactionMapper; +import com.yape.services.transaction.application.mapper.TransactionMapper; +import com.yape.services.transaction.application.query.TransactionStatusQueryHandler; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.domain.service.TransactionEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.graphql.model.CreateTransaction; +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreateTransactionUseCaseTest { + + @Mock + private CreateTransactionCommandHandler commandHandler; + @Mock + private TransactionEventPublisher eventPublisher; + @Mock + private TransactionMapper transactionMapper; + @Mock + private GraphqlTransactionMapper graphqlMapper; + @Mock + private TransferTypeQueryHandler transferTypeQueryHandler; + @Mock + private TransactionStatusQueryHandler statusQueryHandler; + + @Captor + private ArgumentCaptor commandCaptor; + + private CreateTransactionUseCase useCase; + + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final int TRANSFER_TYPE_ID = 1; + private static final String VALID_AMOUNT = "100.50"; + + @BeforeEach + void setUp() { + useCase = new CreateTransactionUseCase( + commandHandler, + eventPublisher, + transactionMapper, + graphqlMapper, + transferTypeQueryHandler, + statusQueryHandler + ); + } + + @Test + @DisplayName("should create transaction successfully with valid input") + void shouldCreateTransactionSuccessfully() { + // Arrange + CreateTransaction input = createValidInput(); + RequestMetaData metaData = createMetaData(); + TransferType transferType = createTransferType(); + TransactionStatus pendingStatus = createPendingStatus(); + Transaction savedTransaction = createSavedTransaction(); + TransactionCreatedEvent event = createEvent(); + var expectedGraphqlResponse = createGraphqlTransaction(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(statusQueryHandler.getTransactionStatusByCode("PENDING")) + .thenReturn(Optional.of(pendingStatus)); + when(commandHandler.handle(any(CreateTransactionCommand.class))) + .thenReturn(savedTransaction); + when(transactionMapper.toTransactionCreatedEvent(savedTransaction, pendingStatus, metaData)) + .thenReturn(event); + when(graphqlMapper.toGraphqlModel(savedTransaction, transferType, pendingStatus)) + .thenReturn(expectedGraphqlResponse); + + // Act + var result = useCase.execute(input, metaData); + + // Assert + assertEquals(expectedGraphqlResponse, result); + verify(commandHandler).handle(commandCaptor.capture()); + verify(eventPublisher).publishTransactionCreated(event); + + CreateTransactionCommand capturedCommand = commandCaptor.getValue(); + assertEquals(DEBIT_ACCOUNT_ID, capturedCommand.accountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID, capturedCommand.accountExternalIdCredit()); + assertEquals(new BigDecimal(VALID_AMOUNT), capturedCommand.value()); + } + + @Test + @DisplayName("should throw ResourceNotFoundException when transfer type not found") + void shouldThrowWhenTransferTypeNotFound() { + // Arrange + CreateTransaction input = createValidInput(); + RequestMetaData metaData = createMetaData(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(ResourceNotFoundException.class, () -> useCase.execute(input, metaData)); + + verify(commandHandler, never()).handle(any()); + verify(eventPublisher, never()).publishTransactionCreated(any()); + } + + @Test + @DisplayName("should throw BusinessException when PENDING status not configured") + void shouldThrowWhenPendingStatusNotConfigured() { + // Arrange + CreateTransaction input = createValidInput(); + RequestMetaData metaData = createMetaData(); + TransferType transferType = createTransferType(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(statusQueryHandler.getTransactionStatusByCode("PENDING")) + .thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(BusinessException.class, () -> useCase.execute(input, metaData)); + + verify(commandHandler, never()).handle(any()); + } + + @ParameterizedTest + @MethodSource("invalidAmountsProvider") + @DisplayName("should throw ValidationException for invalid amounts") + void shouldThrowValidationExceptionForInvalidAmounts(String invalidAmount) { + // Arrange + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit(DEBIT_ACCOUNT_ID.toString()); + input.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()); + input.setTransferTypeId(TRANSFER_TYPE_ID); + input.setValue(invalidAmount); + RequestMetaData metaData = createMetaData(); + // Act & Assert + assertThrows(ValidationException.class, () -> useCase.execute(input, metaData)); + } + + static Stream invalidAmountsProvider() { + return Stream.of(null, " ", "invalid-amount", "-10.00"); + } + + @Test + @DisplayName("should throw ValidationException when debit account ID is null") + void shouldThrowWhenDebitAccountIdIsNull() { + // Arrange + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit(null); + input.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()); + input.setTransferTypeId(TRANSFER_TYPE_ID); + input.setValue(VALID_AMOUNT); + + RequestMetaData metaData = createMetaData(); + TransferType transferType = createTransferType(); + TransactionStatus pendingStatus = createPendingStatus(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(statusQueryHandler.getTransactionStatusByCode("PENDING")) + .thenReturn(Optional.of(pendingStatus)); + + // Act & Assert + assertThrows(ValidationException.class, () -> useCase.execute(input, metaData)); + } + + @Test + @DisplayName("should throw ValidationException when debit account ID has invalid UUID format") + void shouldThrowWhenDebitAccountIdHasInvalidFormat() { + // Arrange + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit("invalid-uuid"); + input.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()); + input.setTransferTypeId(TRANSFER_TYPE_ID); + input.setValue(VALID_AMOUNT); + + RequestMetaData metaData = createMetaData(); + TransferType transferType = createTransferType(); + TransactionStatus pendingStatus = createPendingStatus(); + + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(statusQueryHandler.getTransactionStatusByCode("PENDING")) + .thenReturn(Optional.of(pendingStatus)); + + // Act & Assert + assertThrows(ValidationException.class, () -> useCase.execute(input, metaData)); + } + + private CreateTransaction createValidInput() { + CreateTransaction input = new CreateTransaction(); + input.setAccountExternalIdDebit(DEBIT_ACCOUNT_ID.toString()); + input.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()); + input.setTransferTypeId(TRANSFER_TYPE_ID); + input.setValue(VALID_AMOUNT); + return input; + } + + private TransferType createTransferType() { + return TransferType.builder() + .transferTypeId(TRANSFER_TYPE_ID) + .code("TRANSFER") + .name("Transfer") + .build(); + } + + private TransactionStatus createPendingStatus() { + return TransactionStatus.builder() + .transactionStatusId(1) + .code("PENDING") + .name("Pending") + .build(); + } + + private Transaction createSavedTransaction() { + return Transaction.builder() + .transactionExternalId(UUID.randomUUID()) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(TRANSFER_TYPE_ID) + .transactionStatusId(1) + .value(new BigDecimal(VALID_AMOUNT)) + .build(); + } + + private TransactionCreatedEvent createEvent() { + com.yape.services.common.events.EventMetadata metadata = + com.yape.services.common.events.EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_CREATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-transaction") + .setVersion("1.0.0") + .setRequestId("request-123") + .build(); + + com.yape.services.transaction.events.TransactionCreatedPayload payload = + com.yape.services.transaction.events.TransactionCreatedPayload.newBuilder() + .setTransactionExternalId(UUID.randomUUID().toString()) + .setAccountExternalIdDebit(DEBIT_ACCOUNT_ID.toString()) + .setAccountExternalIdCredit(CREDIT_ACCOUNT_ID.toString()) + .setTransferTypeId(TRANSFER_TYPE_ID) + .setValue(VALID_AMOUNT) + .setStatus(com.yape.services.transaction.events.enums.TransactionStatus.PENDING) + .setCreatedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionCreatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } + + private com.yape.services.transaction.graphql.model.Transaction createGraphqlTransaction() { + var tx = new com.yape.services.transaction.graphql.model.Transaction(); + tx.setTransactionExternalId(UUID.randomUUID().toString()); + tx.setValue(VALID_AMOUNT); + return tx; + } + + private RequestMetaData createMetaData() { + return new RequestMetaData("Bearer token", "request-123", "2024-01-01"); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/GetTransactionUseCaseTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/GetTransactionUseCaseTest.java new file mode 100644 index 0000000000..975f6b6de4 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/GetTransactionUseCaseTest.java @@ -0,0 +1,181 @@ +package com.yape.services.transaction.application.usecase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.yape.services.shared.exception.ResourceNotFoundException; +import com.yape.services.transaction.application.mapper.GraphqlTransactionMapper; +import com.yape.services.transaction.application.query.TransactionQueryHandler; +import com.yape.services.transaction.application.query.TransactionStatusQueryHandler; +import com.yape.services.transaction.application.query.TransferTypeQueryHandler; +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.model.TransferType; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetTransactionUseCaseTest { + + @Mock + private TransactionQueryHandler transactionQueryHandler; + @Mock + private TransferTypeQueryHandler transferTypeQueryHandler; + @Mock + private TransactionStatusQueryHandler transactionStatusQueryHandler; + @Mock + private GraphqlTransactionMapper mapper; + + private GetTransactionUseCase useCase; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final int TRANSFER_TYPE_ID = 1; + private static final int TRANSACTION_STATUS_ID = 1; + + @BeforeEach + void setUp() { + useCase = new GetTransactionUseCase( + transactionQueryHandler, + transferTypeQueryHandler, + transactionStatusQueryHandler, + mapper + ); + } + + @Test + @DisplayName("should retrieve transaction successfully") + void shouldRetrieveTransactionSuccessfully() { + // Arrange + String externalIdStr = TRANSACTION_EXTERNAL_ID.toString(); + Transaction transaction = createTransaction(); + TransferType transferType = createTransferType(); + TransactionStatus status = createTransactionStatus(); + var expectedResult = createGraphqlTransaction(); + + when(transactionQueryHandler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(transaction)); + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(transactionStatusQueryHandler.getTransactionStatusById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.of(status)); + when(mapper.toGraphqlModel(transaction, transferType, status)) + .thenReturn(expectedResult); + + // Act + var result = useCase.execute(externalIdStr); + + // Assert + assertEquals(expectedResult, result); + } + + @Test + @DisplayName("should throw ResourceNotFoundException when transaction not found") + void shouldThrowWhenTransactionNotFound() { + // Arrange + String externalIdStr = TRANSACTION_EXTERNAL_ID.toString(); + + when(transactionQueryHandler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.empty()); + + // Act / Assert + var exception = assertThrows(ResourceNotFoundException.class, + () -> useCase.execute(externalIdStr)); + var expectedMessage = "Transaction not found with ID: " + TRANSACTION_EXTERNAL_ID; + assertEquals(expectedMessage, exception.getMessage()); + } + + @Test + @DisplayName("should throw ResourceNotFoundException when transfer type not found") + void shouldThrowWhenTransferTypeNotFound() { + // Arrange + String externalIdStr = TRANSACTION_EXTERNAL_ID.toString(); + Transaction transaction = createTransaction(); + + when(transactionQueryHandler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(transaction)); + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.empty()); + + // Act / Assert + var exception = assertThrows(ResourceNotFoundException.class, + () -> useCase.execute(externalIdStr)); + var expectedMessage = "TransferType not found with ID: " + TRANSFER_TYPE_ID; + assertEquals(expectedMessage, exception.getMessage()); + } + + @Test + @DisplayName("should throw ResourceNotFoundException when transaction status not found") + void shouldThrowWhenTransactionStatusNotFound() { + // Arrange + String externalIdStr = TRANSACTION_EXTERNAL_ID.toString(); + Transaction transaction = createTransaction(); + TransferType transferType = createTransferType(); + + when(transactionQueryHandler.getTransactionByExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(Optional.of(transaction)); + when(transferTypeQueryHandler.getTransferTypeById(TRANSFER_TYPE_ID)) + .thenReturn(Optional.of(transferType)); + when(transactionStatusQueryHandler.getTransactionStatusById(TRANSACTION_STATUS_ID)) + .thenReturn(Optional.empty()); + + // Act / Assert + var exception = assertThrows(ResourceNotFoundException.class, () -> + useCase.execute(externalIdStr)); + var expectedMessage = "TransactionStatus not found with ID: " + TRANSACTION_STATUS_ID; + assertEquals(expectedMessage, exception.getMessage()); + } + + @Test + @DisplayName("should throw IllegalArgumentException when UUID format is invalid") + void shouldThrowWhenUuidFormatIsInvalid() { + // Arrange + String invalidUuid = "not-a-valid-uuid"; + + // Act / Assert + assertThrows(IllegalArgumentException.class, () -> useCase.execute(invalidUuid)); + } + + private Transaction createTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .transferTypeId(TRANSFER_TYPE_ID) + .transactionStatusId(TRANSACTION_STATUS_ID) + .value(new BigDecimal("100.00")) + .createdAt(LocalDateTime.now()) + .build(); + } + + private TransferType createTransferType() { + return TransferType.builder() + .transferTypeId(TRANSFER_TYPE_ID) + .code("TRANSFER") + .name("Transfer") + .build(); + } + + private TransactionStatus createTransactionStatus() { + return TransactionStatus.builder() + .transactionStatusId(TRANSACTION_STATUS_ID) + .code("PENDING") + .name("Pending") + .build(); + } + + private com.yape.services.transaction.graphql.model.Transaction createGraphqlTransaction() { + var tx = new com.yape.services.transaction.graphql.model.Transaction(); + tx.setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()); + tx.setValue("100.00"); + return tx; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCaseTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCaseTest.java new file mode 100644 index 0000000000..1949179c74 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/application/usecase/UpdateTransactionStatusUseCaseTest.java @@ -0,0 +1,201 @@ +package com.yape.services.transaction.application.usecase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.domain.repository.TransactionRepository; +import com.yape.services.transaction.domain.repository.TransactionStatusRepository; +import com.yape.services.transaction.domain.service.TransactionCacheService; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UpdateTransactionStatusUseCaseTest { + + @Mock + private TransactionRepository transactionRepository; + @Mock + private TransactionStatusRepository transactionStatusRepository; + @Mock + private TransactionCacheService transactionCacheService; + + private UpdateTransactionStatusUseCase useCase; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final int APPROVED_STATUS_ID = 2; + + @BeforeEach + void setUp() { + useCase = new UpdateTransactionStatusUseCase( + transactionRepository, + transactionStatusRepository, + transactionCacheService + ); + } + + @Test + @DisplayName("should update transaction status in database and cache") + void shouldUpdateTransactionStatusSuccessfully() { + // Arrange + TransactionStatusUpdatedEvent event = createEvent( + com.yape.services.transaction.events.enums.TransactionStatus.APPROVED + ); + TransactionStatus approvedStatus = createApprovedStatus(); + + when(transactionStatusRepository.findByCode("APPROVED")) + .thenReturn(Optional.of(approvedStatus)); + when(transactionRepository.updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID)) + .thenReturn(1); + + // Act + useCase.execute(event); + + // Assert + verify(transactionRepository).updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID); + verify(transactionCacheService).updateTransactionStatus( + TRANSACTION_EXTERNAL_ID, + APPROVED_STATUS_ID, + "APPROVED" + ); + } + + @Test + @DisplayName("should update to REJECTED status successfully") + void shouldUpdateToRejectedStatusSuccessfully() { + // Arrange + TransactionStatusUpdatedEvent event = createEvent( + com.yape.services.transaction.events.enums.TransactionStatus.REJECTED + ); + TransactionStatus rejectedStatus = TransactionStatus.builder() + .transactionStatusId(3) + .code("REJECTED") + .name("Rejected") + .build(); + + when(transactionStatusRepository.findByCode("REJECTED")) + .thenReturn(Optional.of(rejectedStatus)); + when(transactionRepository.updateStatus(TRANSACTION_EXTERNAL_ID, 3)) + .thenReturn(1); + + // Act + useCase.execute(event); + + // Assert + verify(transactionRepository).updateStatus(TRANSACTION_EXTERNAL_ID, 3); + verify(transactionCacheService).updateTransactionStatus( + TRANSACTION_EXTERNAL_ID, + 3, + "REJECTED" + ); + } + + @Test + @DisplayName("should throw IllegalStateException when status not found") + void shouldThrowWhenStatusNotFound() { + // Arrange + TransactionStatusUpdatedEvent event = createEvent( + com.yape.services.transaction.events.enums.TransactionStatus.APPROVED + ); + + when(transactionStatusRepository.findByCode("APPROVED")) + .thenReturn(Optional.empty()); + + // Act / Assert + IllegalStateException thrownException = null; + try { + useCase.execute(event); + } catch (IllegalStateException e) { + thrownException = e; + } + + assertNotNull(thrownException); + var expectedMessage = "Transaction status not found: APPROVED"; + assertEquals(expectedMessage, thrownException.getMessage()); + + verify(transactionRepository, never()) + .updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID); + verify(transactionCacheService, never()).updateTransactionStatus( + TRANSACTION_EXTERNAL_ID, + APPROVED_STATUS_ID, + "APPROVED" + ); + } + + @Test + @DisplayName("should not update cache when no rows updated in database") + void shouldNotUpdateCacheWhenNoRowsUpdated() { + // Arrange + TransactionStatusUpdatedEvent event = createEvent( + com.yape.services.transaction.events.enums.TransactionStatus.APPROVED + ); + TransactionStatus approvedStatus = createApprovedStatus(); + + when(transactionStatusRepository.findByCode("APPROVED")) + .thenReturn(Optional.of(approvedStatus)); + when(transactionRepository.updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID)) + .thenReturn(0); + + // Act + useCase.execute(event); + + // Assert + verify(transactionRepository).updateStatus(TRANSACTION_EXTERNAL_ID, APPROVED_STATUS_ID); + verify(transactionCacheService, never()).updateTransactionStatus( + TRANSACTION_EXTERNAL_ID, + APPROVED_STATUS_ID, + "APPROVED" + ); + } + + private TransactionStatusUpdatedEvent createEvent( + com.yape.services.transaction.events.enums.TransactionStatus newStatus) { + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_STATUS_UPDATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-anti-fraud") + .setVersion("1.0.0") + .setRequestId("request-123") + .build(); + + TransactionStatusUpdatedPayload payload = TransactionStatusUpdatedPayload.newBuilder() + .setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()) + .setPreviousStatus(com.yape.services.transaction.events.enums.TransactionStatus.PENDING) + .setNewStatus(newStatus) + .setValue("100.00") + .setValidationResult( + com.yape.services.transaction.events.ValidationResult.newBuilder() + .setIsValid(true) + .setRuleCode(null) + .build() + ) + .setProcessedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionStatusUpdatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } + + private TransactionStatus createApprovedStatus() { + return TransactionStatus.builder() + .transactionStatusId(APPROVED_STATUS_ID) + .code("APPROVED") + .name("Approved") + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImplTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImplTest.java new file mode 100644 index 0000000000..740a431eef --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransactionCacheServiceImplTest.java @@ -0,0 +1,193 @@ +package com.yape.services.transaction.infrastructure.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.infrastructure.config.TransactionCacheConfig; +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RMapCache; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.Codec; + +@ExtendWith(MockitoExtension.class) +class TransactionCacheServiceImplTest { + + @Mock + private RedissonClient redissonClient; + @Mock + private TransactionCacheConfig cacheConfig; + @Mock + private TransactionCacheConfig.Ttl ttlConfig; + @Mock + private RMapCache mapCache; + + private TransactionCacheServiceImpl cacheService; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final String MAP_NAME = "transactions"; + private static final String PREFIX = "transaction:"; + private static final long PENDING_TTL = 300L; + private static final long APPROVED_TTL = 3600L; + private static final long REJECTED_TTL = 3600L; + + @BeforeEach + void setUp() { + lenient().when(cacheConfig.mapName()).thenReturn(MAP_NAME); + lenient().when(cacheConfig.prefix()).thenReturn(PREFIX); + lenient().when(cacheConfig.ttl()).thenReturn(ttlConfig); + lenient().doReturn(mapCache).when(redissonClient).getMapCache(anyString(), any(Codec.class)); + + cacheService = new TransactionCacheServiceImpl(redissonClient, cacheConfig); + } + + @Test + @DisplayName("should save transaction with PENDING TTL") + void shouldSaveTransactionWithPendingTtl() { + // Arrange + Transaction transaction = createTransaction(); + when(ttlConfig.pending()).thenReturn(PENDING_TTL); + + // Act + cacheService.saveTransaction(transaction, "PENDING"); + + // Assert + String expectedKey = PREFIX + TRANSACTION_EXTERNAL_ID; + verify(mapCache).put(expectedKey, transaction, PENDING_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("should save transaction with APPROVED TTL") + void shouldSaveTransactionWithApprovedTtl() { + // Arrange + Transaction transaction = createTransaction(); + when(ttlConfig.approved()).thenReturn(APPROVED_TTL); + + // Act + cacheService.saveTransaction(transaction, "APPROVED"); + + // Assert + String expectedKey = PREFIX + TRANSACTION_EXTERNAL_ID; + verify(mapCache).put(expectedKey, transaction, APPROVED_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("should save transaction with REJECTED TTL") + void shouldSaveTransactionWithRejectedTtl() { + // Arrange + Transaction transaction = createTransaction(); + when(ttlConfig.rejected()).thenReturn(REJECTED_TTL); + + // Act + cacheService.saveTransaction(transaction, "REJECTED"); + + // Assert + String expectedKey = PREFIX + TRANSACTION_EXTERNAL_ID; + verify(mapCache).put(expectedKey, transaction, REJECTED_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("should throw exception for unknown status") + void shouldThrowExceptionForUnknownStatus() { + // Arrange + Transaction transaction = createTransaction(); + + // Act/Then + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> cacheService.saveTransaction(transaction, "UNKNOWN") + ); + assertTrue(thrown.getMessage().contains("Unexpected value")); + } + + @Test + @DisplayName("should return transaction when found in cache") + void shouldReturnTransactionWhenFoundInCache() { + // Arrange + Transaction transaction = createTransaction(); + String key = PREFIX + TRANSACTION_EXTERNAL_ID; + when(mapCache.get(key)).thenReturn(transaction); + + // Act + Optional result = cacheService + .getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(transaction, result.get()); + } + + @Test + @DisplayName("should return empty when not found in cache") + void shouldReturnEmptyWhenNotFoundInCache() { + // Arrange + String key = PREFIX + TRANSACTION_EXTERNAL_ID; + when(mapCache.get(key)).thenReturn(null); + + // Act + Optional result = cacheService + .getTransactionByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should update status and TTL when transaction exists in cache") + void shouldUpdateStatusAndTtlWhenTransactionExists() { + // Arrange + Transaction transaction = createTransaction(); + String key = PREFIX + TRANSACTION_EXTERNAL_ID; + when(mapCache.get(key)).thenReturn(transaction); + when(ttlConfig.approved()).thenReturn(APPROVED_TTL); + + // Act + cacheService.updateTransactionStatus(TRANSACTION_EXTERNAL_ID, 2, "APPROVED"); + + // Assert + assertEquals(2, transaction.getTransactionStatusId()); + verify(mapCache).put(key, transaction, APPROVED_TTL, TimeUnit.SECONDS); + } + + @Test + @DisplayName("should not update when transaction not in cache") + void shouldNotUpdateWhenTransactionNotInCache() { + // Arrange + String key = PREFIX + TRANSACTION_EXTERNAL_ID; + when(mapCache.get(key)).thenReturn(null); + + // Act + cacheService.updateTransactionStatus(TRANSACTION_EXTERNAL_ID, 2, "APPROVED"); + + // Assert + verify(mapCache, never()).put(anyString(), any(), anyLong(), any()); + } + + private Transaction createTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .transferTypeId(1) + .transactionStatusId(1) + .value(new BigDecimal("100.00")) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImplTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImplTest.java new file mode 100644 index 0000000000..687319841c --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/cache/TransferTypeCacheServiceImplTest.java @@ -0,0 +1,201 @@ +package com.yape.services.transaction.infrastructure.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.infrastructure.config.TransferTypeCacheConfig; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RMapCache; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.Codec; + +@ExtendWith(MockitoExtension.class) +class TransferTypeCacheServiceImplTest { + + @Mock + private RedissonClient redissonClient; + @Mock + private TransferTypeCacheConfig cacheConfig; + @Mock + private RMapCache mapCache; + + private TransferTypeCacheServiceImpl cacheService; + + private static final String MAP_NAME = "transfer-types"; + private static final String PREFIX = "transfer_type:"; + private static final long TTL = 86400L; + + @BeforeEach + void setUp() { + lenient().when(cacheConfig.mapName()).thenReturn(MAP_NAME); + lenient().when(cacheConfig.prefix()).thenReturn(PREFIX); + lenient().doReturn(mapCache).when(redissonClient).getMapCache(anyString(), any(Codec.class)); + + cacheService = new TransferTypeCacheServiceImpl(redissonClient, cacheConfig); + } + + @Nested + @DisplayName("save") + class SaveTests { + + @Test + @DisplayName("should save transfer type with correct TTL") + void shouldSaveTransferTypeWithCorrectTtl() { + // Arrange + TransferType transferType = createTransferType(1); + when(cacheConfig.ttl()).thenReturn(TTL); + + // Act + cacheService.save(transferType); + + // Assert + String expectedKey = PREFIX + "1"; + verify(mapCache).put(expectedKey, transferType, TTL, TimeUnit.SECONDS); + } + } + + @Nested + @DisplayName("saveAll") + class SaveAllTests { + + @Test + @DisplayName("should save all transfer types") + void shouldSaveAllTransferTypes() { + // Arrange + List transferTypes = List.of( + createTransferType(1), + createTransferType(2), + createTransferType(3) + ); + when(cacheConfig.ttl()).thenReturn(TTL); + + // Act + cacheService.saveAll(transferTypes); + + // Assert + verify(mapCache, times(3)).put(any(), any(), eq(TTL), eq(TimeUnit.SECONDS)); + } + + @Test + @DisplayName("should handle empty list") + void shouldHandleEmptyList() { + // Arrange + List transferTypes = List.of(); + + // Act + cacheService.saveAll(transferTypes); + + // Assert + verify(mapCache, times(0)).put(any(), any(), anyLong(), any()); + } + } + + @Test + @DisplayName("should return transfer type when found in cache") + void shouldReturnTransferTypeWhenFoundInCache() { + // Arrange + TransferType transferType = createTransferType(1); + String key = PREFIX + "1"; + when(mapCache.get(key)).thenReturn(transferType); + + // Act + Optional result = cacheService.findById(1); + + // Assert + assertTrue(result.isPresent()); + assertEquals(transferType, result.get()); + } + + @Test + @DisplayName("should return empty when not found in cache") + void shouldReturnEmptyWhenNotFoundInCache() { + // Arrange + String key = PREFIX + "1"; + when(mapCache.get(key)).thenReturn(null); + + // Act + Optional result = cacheService.findById(1); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should return all transfer types when cache is not empty") + void shouldReturnAllTransferTypesWhenCacheNotEmpty() { + // Arrange + TransferType type1 = createTransferType(1); + TransferType type2 = createTransferType(2); + Collection values = List.of(type1, type2); + + when(mapCache.isEmpty()).thenReturn(false); + when(mapCache.values()).thenReturn(values); + + // Act + Optional> result = cacheService.findAll(); + + // Assert + assertTrue(result.isPresent()); + assertEquals(2, result.get().size()); + } + + @Test + @DisplayName("should return empty when cache is empty") + void shouldReturnEmptyWhenCacheIsEmpty() { + // Arrange + when(mapCache.isEmpty()).thenReturn(true); + + // Act + Optional> result = cacheService.findAll(); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should deduplicate transfer types by ID") + void shouldDeduplicateTransferTypesById() { + // Arrange + TransferType type1 = createTransferType(1); + TransferType type1Duplicate = createTransferType(1); + TransferType type2 = createTransferType(2); + Collection values = List.of(type1, type1Duplicate, type2); + + when(mapCache.isEmpty()).thenReturn(false); + when(mapCache.values()).thenReturn(values); + + // Act + Optional> result = cacheService.findAll(); + + // Assert + assertTrue(result.isPresent()); + assertEquals(2, result.get().size()); + } + + private TransferType createTransferType(int id) { + return TransferType.builder() + .transferTypeId(id) + .code("TYPE_" + id) + .name("Transfer Type " + id) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisherTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisherTest.java new file mode 100644 index 0000000000..cc5c435e7e --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionEventPublisherTest.java @@ -0,0 +1,126 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.events.TransactionCreatedPayload; +import com.yape.services.transaction.events.enums.TransactionStatus; +import io.smallrye.reactive.messaging.kafka.Record; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class KafkaTransactionEventPublisherTest { + + @Mock + private Emitter> emitter; + + private KafkaTransactionEventPublisher publisher; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + publisher = new KafkaTransactionEventPublisher(emitter); + } + + @Test + @DisplayName("should send event to Kafka with transaction external ID as key") + void shouldSendEventToKafkaWithCorrectKey() { + // Arrange + TransactionCreatedEvent event = createEvent(); + CompletableFuture future = CompletableFuture.completedFuture(null); + + when(emitter.send(argThat((Record eventRecord) -> + eventRecord != null && eventRecord.key().equals(TRANSACTION_EXTERNAL_ID.toString()) + ))).thenReturn(future); + + // Act + publisher.publishTransactionCreated(event); + + // Assert + verify(emitter).send(argThat((Record eventRecord) -> + eventRecord.key().equals(TRANSACTION_EXTERNAL_ID.toString()) + && eventRecord.value().equals(event) + )); + } + + @Test + @DisplayName("should send event with correct payload") + void shouldSendEventWithCorrectPayload() { + // Arrange + TransactionCreatedEvent event = createEvent(); + CompletableFuture future = CompletableFuture.completedFuture(null); + + when(emitter.send((Record) argThat(Objects::nonNull))) + .thenReturn(future); + + // Act + publisher.publishTransactionCreated(event); + + // Assert + verify(emitter).send(argThat((Record eventRecord) -> { + TransactionCreatedEvent sentEvent = eventRecord.value(); + assertEquals(TRANSACTION_EXTERNAL_ID.toString(), + sentEvent.getPayload().getTransactionExternalId()); + assertEquals(TransactionStatus.PENDING, sentEvent.getPayload().getStatus()); + return true; + })); + } + + @Test + @DisplayName("should handle failed send gracefully") + void shouldHandleFailedSendGracefully() { + // Arrange + TransactionCreatedEvent event = createEvent(); + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Kafka unavailable")); + + when(emitter.send((Record) argThat(Objects::nonNull))) + .thenReturn(failedFuture); + + // Act + publisher.publishTransactionCreated(event); + + // Assert + verify(emitter).send((Record) argThat(Objects::nonNull)); + } + + private TransactionCreatedEvent createEvent() { + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_CREATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-transaction") + .setVersion("1.0.0") + .setRequestId("request-123") + .build(); + + TransactionCreatedPayload payload = TransactionCreatedPayload.newBuilder() + .setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()) + .setAccountExternalIdDebit(UUID.randomUUID().toString()) + .setAccountExternalIdCredit(UUID.randomUUID().toString()) + .setTransferTypeId(1) + .setValue("100.00") + .setStatus(TransactionStatus.PENDING) + .setCreatedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionCreatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumerTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumerTest.java new file mode 100644 index 0000000000..a9cc2dc8fa --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusConsumerTest.java @@ -0,0 +1,156 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.application.usecase.UpdateTransactionStatusUseCase; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import com.yape.services.transaction.events.ValidationResult; +import com.yape.services.transaction.events.enums.TransactionStatus; +import io.smallrye.reactive.messaging.kafka.Record; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class KafkaTransactionStatusConsumerTest { + + @Mock + private UpdateTransactionStatusUseCase updateTransactionStatusUseCase; + + private KafkaTransactionStatusConsumer consumer; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final String MESSAGE_KEY = TRANSACTION_EXTERNAL_ID.toString(); + + @BeforeEach + void setUp() { + consumer = new KafkaTransactionStatusConsumer(updateTransactionStatusUseCase); + } + + @Test + @DisplayName("should delegate to use case with event from record") + void shouldDelegateToUseCaseWithEventFromRecord() { + // Given + TransactionStatusUpdatedEvent event = createEvent(TransactionStatus.APPROVED); + Record eventRecord = Record.of(MESSAGE_KEY, event); + + // When + consumer.consume(eventRecord); + + // Then + verify(updateTransactionStatusUseCase).execute(event); + } + + @Test + @DisplayName("should handle REJECTED status events") + void shouldHandleRejectedStatusEvents() { + // Given + TransactionStatusUpdatedEvent event = createEvent(TransactionStatus.REJECTED); + Record eventRecord = Record.of(MESSAGE_KEY, event); + + // When + consumer.consume(eventRecord); + + // Then + verify(updateTransactionStatusUseCase).execute(event); + } + + @Test + @DisplayName("should rethrow exception when use case fails") + void shouldRethrowExceptionWhenUseCaseFails() { + // Given + TransactionStatusUpdatedEvent event = createEvent(TransactionStatus.APPROVED); + Record eventRecord = Record.of(MESSAGE_KEY, event); + RuntimeException expectedException = new RuntimeException("Database error"); + + doThrow(expectedException).when(updateTransactionStatusUseCase).execute(event); + + // When/Then + RuntimeException exception = + assertThrows(RuntimeException.class, () -> consumer.consume(eventRecord)); + assertEquals("Database error", exception.getMessage()); + } + + @Test + @DisplayName("should process event with null request ID in metadata") + void shouldProcessEventWithNullRequestId() { + // Given + TransactionStatusUpdatedEvent event = createEventWithNullRequestId(); + Record eventRecord = Record.of(MESSAGE_KEY, event); + + // When + consumer.consume(eventRecord); + + // Then + verify(updateTransactionStatusUseCase).execute(event); + } + + private TransactionStatusUpdatedEvent createEvent(TransactionStatus newStatus) { + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_STATUS_UPDATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-anti-fraud") + .setVersion("1.0.0") + .setRequestId("request-123") + .build(); + + ValidationResult validationResult = ValidationResult.newBuilder() + .setIsValid(newStatus == TransactionStatus.APPROVED) + .setRuleCode(newStatus == TransactionStatus.REJECTED ? "MAX_AMOUNT_EXCEEDED" : null) + .build(); + + TransactionStatusUpdatedPayload payload = TransactionStatusUpdatedPayload.newBuilder() + .setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()) + .setPreviousStatus(TransactionStatus.PENDING) + .setNewStatus(newStatus) + .setValue("100.00") + .setValidationResult(validationResult) + .setProcessedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionStatusUpdatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } + + private TransactionStatusUpdatedEvent createEventWithNullRequestId() { + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType("TRANSACTION_STATUS_UPDATED") + .setEventTimestamp("2024-01-01T00:00:00.000+0000") + .setSource("ms-anti-fraud") + .setVersion("1.0.0") + .setRequestId(null) + .build(); + + ValidationResult validationResult = ValidationResult.newBuilder() + .setIsValid(true) + .setRuleCode(null) + .build(); + + TransactionStatusUpdatedPayload payload = TransactionStatusUpdatedPayload.newBuilder() + .setTransactionExternalId(TRANSACTION_EXTERNAL_ID.toString()) + .setPreviousStatus(TransactionStatus.PENDING) + .setNewStatus(TransactionStatus.APPROVED) + .setValue("100.00") + .setValidationResult(validationResult) + .setProcessedAt("2024-01-01T00:00:00.000+0000") + .build(); + + return TransactionStatusUpdatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(payload) + .build(); + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistenceTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistenceTest.java new file mode 100644 index 0000000000..856bc9529b --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionPersistenceTest.java @@ -0,0 +1,190 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.Transaction; +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransactionPostgresRepository; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionPersistenceTest { + + @Mock + private TransactionPostgresRepository repository; + + @Captor + private ArgumentCaptor entityCaptor; + + private TransactionPersistence persistence; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final BigDecimal VALUE = new BigDecimal("500.00"); + + @BeforeEach + void setUp() { + persistence = new TransactionPersistence(repository); + } + + @Test + @DisplayName("should convert domain to entity and save") + void shouldConvertDomainToEntityAndSave() { + // Arrange + TransactionEntity savedEntity = createEntity(); + when(repository.save(any(TransactionEntity.class))).thenReturn(savedEntity); + + // Act + Transaction transaction = createDomainTransaction(); + persistence.save(transaction); + + // Assert + verify(repository).save(entityCaptor.capture()); + TransactionEntity capturedEntity = entityCaptor.getValue(); + + assertEquals(TRANSACTION_EXTERNAL_ID, capturedEntity.getTransactionExternalId()); + assertEquals(DEBIT_ACCOUNT_ID, capturedEntity.getAccountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID, capturedEntity.getAccountExternalIdCredit()); + assertEquals(1, capturedEntity.getTransferTypeId()); + assertEquals(1, capturedEntity.getTransactionStatusId()); + assertEquals(0, capturedEntity.getValue().compareTo(VALUE)); + } + + @Test + @DisplayName("should return domain object from saved entity") + void shouldReturnDomainObjectFromSavedEntity() { + // Arrange + Transaction transaction = createDomainTransaction(); + TransactionEntity savedEntity = createEntity(); + when(repository.save(any(TransactionEntity.class))).thenReturn(savedEntity); + + // Act + Transaction result = persistence.save(transaction); + + // Assert + assertEquals(TRANSACTION_EXTERNAL_ID, result.getTransactionExternalId()); + assertEquals(0, result.getValue().compareTo(VALUE)); + } + + @Test + @DisplayName("should return transaction when found") + void shouldReturnTransactionWhenFound() { + // Arrange + TransactionEntity entity = createEntity(); + when(repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(entity); + + // Act + Optional result = persistence.findByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(TRANSACTION_EXTERNAL_ID, result.get().getTransactionExternalId()); + assertEquals(0, result.get().getValue().compareTo(VALUE)); + } + + @Test + @DisplayName("should return empty when not found") + void shouldReturnEmptyWhenNotFound() { + // Arrange + when(repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(null); + + // Act + Optional result = persistence.findByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should map all entity fields to domain") + void shouldMapAllEntityFieldsToDomain() { + // Arrange + TransactionEntity entity = createEntity(); + entity.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30)); + when(repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID)) + .thenReturn(entity); + + // Act + Optional result = persistence.findByExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertTrue(result.isPresent()); + Transaction tx = result.get(); + assertEquals(TRANSACTION_EXTERNAL_ID, tx.getTransactionExternalId()); + assertEquals(DEBIT_ACCOUNT_ID, tx.getAccountExternalIdDebit()); + assertEquals(CREDIT_ACCOUNT_ID, tx.getAccountExternalIdCredit()); + assertEquals(1, tx.getTransferTypeId()); + assertEquals(1, tx.getTransactionStatusId()); + assertEquals(0, tx.getValue().compareTo(VALUE)); + assertEquals(LocalDateTime.of(2024, 1, 15, 10, 30), tx.getCreatedAt()); + } + + @Test + @DisplayName("should delegate to repository and return updated count") + void shouldDelegateToRepositoryAndReturnUpdatedCount() { + // Arrange + when(repository.updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, 2)) + .thenReturn(1); + + // Act + int result = persistence.updateStatus(TRANSACTION_EXTERNAL_ID, 2); + + // Assert + assertEquals(1, result); + verify(repository).updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, 2); + } + + @Test + @DisplayName("should return zero when no rows updated") + void shouldReturnZeroWhenNoRowsUpdated() { + // Arrange + when(repository.updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, 2)) + .thenReturn(0); + + // Act + int result = persistence.updateStatus(TRANSACTION_EXTERNAL_ID, 2); + + // Assert + assertEquals(0, result); + } + + private Transaction createDomainTransaction() { + return Transaction.builder() + .transactionExternalId(TRANSACTION_EXTERNAL_ID) + .accountExternalIdDebit(DEBIT_ACCOUNT_ID) + .accountExternalIdCredit(CREDIT_ACCOUNT_ID) + .transferTypeId(1) + .transactionStatusId(1) + .value(VALUE) + .build(); + } + + private TransactionEntity createEntity() { + TransactionEntity entity = new TransactionEntity(); + entity.setTransactionExternalId(TRANSACTION_EXTERNAL_ID); + entity.setAccountExternalIdDebit(DEBIT_ACCOUNT_ID); + entity.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID); + entity.setTransferTypeId(1); + entity.setTransactionStatusId(1); + entity.setValue(VALUE); + return entity; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistenceTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistenceTest.java new file mode 100644 index 0000000000..4c0ed0492a --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransactionStatusPersistenceTest.java @@ -0,0 +1,144 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransactionStatus; +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionStatusEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransactionStatusPostgresRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionStatusPersistenceTest { + + @Mock + private TransactionStatusPostgresRepository repository; + + private TransactionStatusPersistence persistence; + + private static final Integer STATUS_ID = 1; + private static final String STATUS_CODE = "PENDING"; + private static final String STATUS_NAME = "Pending"; + + @BeforeEach + void setUp() { + persistence = new TransactionStatusPersistence(repository); + } + + @Test + @DisplayName("should return transaction status when found by code") + void shouldReturnTransactionStatusWhenFoundByCode() { + // Arrange + TransactionStatusEntity entity = createEntity(); + when(repository.findByCode(STATUS_CODE)).thenReturn(entity); + + // Act + Optional result = persistence.findByCode(STATUS_CODE); + + // Assert + assertTrue(result.isPresent()); + assertEquals(STATUS_ID, result.get().getTransactionStatusId()); + assertEquals(STATUS_CODE, result.get().getCode()); + assertEquals(STATUS_NAME, result.get().getName()); + verify(repository).findByCode(STATUS_CODE); + } + + @Test + @DisplayName("should return empty when not found by code") + void shouldReturnEmptyWhenNotFoundByCode() { + // Arrange + when(repository.findByCode(STATUS_CODE)).thenReturn(null); + + // Act + Optional result = persistence.findByCode(STATUS_CODE); + + // Assert + assertTrue(result.isEmpty()); + verify(repository).findByCode(STATUS_CODE); + } + + @Test + @DisplayName("should handle different status codes") + void shouldHandleDifferentStatusCodes() { + // Arrange + String approvedCode = "APPROVED"; + TransactionStatusEntity entity = new TransactionStatusEntity(); + entity.setTransactionStatusId(2); + entity.setCode(approvedCode); + entity.setName("Approved"); + when(repository.findByCode(approvedCode)).thenReturn(entity); + + // Act + Optional result = persistence.findByCode(approvedCode); + + // Assert + assertTrue(result.isPresent()); + assertEquals(approvedCode, result.get().getCode()); + } + + @Test + @DisplayName("should return transaction status when found by ID") + void shouldReturnTransactionStatusWhenFoundById() { + // Arrange + TransactionStatusEntity entity = createEntity(); + when(repository.findById(STATUS_ID)).thenReturn(entity); + + // Act + Optional result = persistence.findById(STATUS_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(STATUS_ID, result.get().getTransactionStatusId()); + assertEquals(STATUS_CODE, result.get().getCode()); + assertEquals(STATUS_NAME, result.get().getName()); + verify(repository).findById(STATUS_ID); + } + + @Test + @DisplayName("should return empty when not found by ID") + void shouldReturnEmptyWhenNotFoundById() { + // Arrange + when(repository.findById(STATUS_ID)).thenReturn(null); + + // Act + Optional result = persistence.findById(STATUS_ID); + + // Assert + assertTrue(result.isEmpty()); + verify(repository).findById(STATUS_ID); + } + + @Test + @DisplayName("should map all entity fields to domain") + void shouldMapAllEntityFieldsToDomain() { + // Arrange + TransactionStatusEntity entity = createEntity(); + when(repository.findById(STATUS_ID)).thenReturn(entity); + + // Act + Optional result = persistence.findById(STATUS_ID); + + // Assert + assertTrue(result.isPresent()); + TransactionStatus status = result.get(); + assertEquals(entity.getTransactionStatusId(), status.getTransactionStatusId()); + assertEquals(entity.getCode(), status.getCode()); + assertEquals(entity.getName(), status.getName()); + } + + private TransactionStatusEntity createEntity() { + TransactionStatusEntity entity = new TransactionStatusEntity(); + entity.setTransactionStatusId(STATUS_ID); + entity.setCode(STATUS_CODE); + entity.setName(STATUS_NAME); + return entity; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistenceTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistenceTest.java new file mode 100644 index 0000000000..c16b314a5d --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/TransferTypePersistenceTest.java @@ -0,0 +1,178 @@ +package com.yape.services.transaction.infrastructure.persistence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.domain.model.TransferType; +import com.yape.services.transaction.infrastructure.persistence.entity.TransferTypeEntity; +import com.yape.services.transaction.infrastructure.persistence.repository.TransferTypePostgresRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransferTypePersistenceTest { + + @Mock + private TransferTypePostgresRepository repository; + + private TransferTypePersistence persistence; + + private static final Integer TRANSFER_TYPE_ID = 1; + private static final String TRANSFER_TYPE_CODE = "INTERNAL"; + private static final String TRANSFER_TYPE_NAME = "Internal Transfer"; + + @BeforeEach + void setUp() { + persistence = new TransferTypePersistence(repository); + } + + @Test + @DisplayName("should return transfer type when found by ID") + void shouldReturnTransferTypeWhenFoundById() { + // Given + TransferTypeEntity entity = createEntity(); + when(repository.findByTransferTypeId(TRANSFER_TYPE_ID)).thenReturn(entity); + + // When + Optional result = persistence.findById(TRANSFER_TYPE_ID); + + // Then + assertTrue(result.isPresent()); + assertEquals(TRANSFER_TYPE_ID, result.get().getTransferTypeId()); + assertEquals(TRANSFER_TYPE_CODE, result.get().getCode()); + assertEquals(TRANSFER_TYPE_NAME, result.get().getName()); + verify(repository).findByTransferTypeId(TRANSFER_TYPE_ID); + } + + @Test + @DisplayName("should return empty when not found by ID") + void shouldReturnEmptyWhenNotFoundById() { + // Given + when(repository.findByTransferTypeId(TRANSFER_TYPE_ID)).thenReturn(null); + + // When + Optional result = persistence.findById(TRANSFER_TYPE_ID); + + // Then + assertTrue(result.isEmpty()); + verify(repository).findByTransferTypeId(TRANSFER_TYPE_ID); + } + + @Test + @DisplayName("should map all entity fields to domain") + void shouldMapAllEntityFieldsToDomain() { + // Given + TransferTypeEntity entity = createEntity(); + when(repository.findByTransferTypeId(TRANSFER_TYPE_ID)).thenReturn(entity); + + // When + Optional result = persistence.findById(TRANSFER_TYPE_ID); + + // Then + assertTrue(result.isPresent()); + TransferType type = result.get(); + assertEquals(entity.getTransferTypeId(), type.getTransferTypeId()); + assertEquals(entity.getCode(), type.getCode()); + assertEquals(entity.getName(), type.getName()); + } + + @Test + @DisplayName("should return all transfer types") + void shouldReturnAllTransferTypes() { + // Given + List entities = List.of( + createEntity(1, "INTERNAL", "Internal Transfer"), + createEntity(2, "EXTERNAL", "External Transfer"), + createEntity(3, "INTERBANK", "Interbank Transfer") + ); + when(repository.findAllTransferTypes()).thenReturn(entities); + + // When + List result = persistence.findAll(); + + // Then + assertEquals(3, result.size()); + assertEquals("INTERNAL", result.get(0).getCode()); + assertEquals("EXTERNAL", result.get(1).getCode()); + assertEquals("INTERBANK", result.get(2).getCode()); + verify(repository).findAllTransferTypes(); + } + + @Test + @DisplayName("should return empty list when no transfer types exist") + void shouldReturnEmptyListWhenNoTransferTypesExist() { + // Given + when(repository.findAllTransferTypes()).thenReturn(List.of()); + + // When + List result = persistence.findAll(); + + // Then + assertTrue(result.isEmpty()); + verify(repository).findAllTransferTypes(); + } + + @Test + @DisplayName("should map all entities to domain objects") + void shouldMapAllEntitiesToDomainObjects() { + // Given + List entities = List.of( + createEntity(1, "INTERNAL", "Internal Transfer"), + createEntity(2, "EXTERNAL", "External Transfer") + ); + when(repository.findAllTransferTypes()).thenReturn(entities); + + // When + List result = persistence.findAll(); + + // Then + assertEquals(2, result.size()); + result.forEach(type -> { + assertNotNull(type.getTransferTypeId()); + assertNotNull(type.getCode()); + assertNotNull(type.getName()); + }); + } + + @Test + @DisplayName("should preserve order of transfer types") + void shouldPreserveOrderOfTransferTypes() { + // Given + List entities = List.of( + createEntity(3, "THIRD", "Third"), + createEntity(1, "FIRST", "First"), + createEntity(2, "SECOND", "Second") + ); + when(repository.findAllTransferTypes()).thenReturn(entities); + + // When + List result = persistence.findAll(); + + // Then + assertEquals(3, result.size()); + assertEquals(3, result.get(0).getTransferTypeId()); + assertEquals(1, result.get(1).getTransferTypeId()); + assertEquals(2, result.get(2).getTransferTypeId()); + } + + private TransferTypeEntity createEntity() { + return createEntity(TRANSFER_TYPE_ID, TRANSFER_TYPE_CODE, TRANSFER_TYPE_NAME); + } + + private TransferTypeEntity createEntity(Integer id, String code, String name) { + TransferTypeEntity entity = new TransferTypeEntity(); + entity.setTransferTypeId(id); + entity.setCode(code); + entity.setName(name); + return entity; + } +} diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepositoryTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepositoryTest.java new file mode 100644 index 0000000000..925e78314f --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionPostgresRepositoryTest.java @@ -0,0 +1,200 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionEntity; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransactionPostgresRepositoryTest { + + private TransactionPostgresRepository repository; + + private static final UUID TRANSACTION_EXTERNAL_ID = UUID.randomUUID(); + private static final UUID DEBIT_ACCOUNT_ID = UUID.randomUUID(); + private static final UUID CREDIT_ACCOUNT_ID = UUID.randomUUID(); + private static final BigDecimal VALUE = new BigDecimal("1000.00"); + + @BeforeEach + void setUp() { + repository = spy(new TransactionPostgresRepository()); + } + + @Test + @DisplayName("should return entity when found by external ID") + @SuppressWarnings("unchecked") + void shouldReturnEntityWhenFoundByExternalId() { + // Arrange + TransactionEntity entity = createEntity(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository) + .find("transactionExternalId", TRANSACTION_EXTERNAL_ID); + doReturn(entity).when(query).firstResult(); + + // Act + TransactionEntity result = repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertNotNull(result); + assertEquals(TRANSACTION_EXTERNAL_ID, result.getTransactionExternalId()); + verify(repository).find("transactionExternalId", TRANSACTION_EXTERNAL_ID); + } + + @Test + @DisplayName("should return null when not found by external ID") + @SuppressWarnings("unchecked") + void shouldReturnNullWhenNotFoundByExternalId() { + // Arrange + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository) + .find("transactionExternalId", TRANSACTION_EXTERNAL_ID); + doReturn(null).when(query).firstResult(); + + // Act + TransactionEntity result = repository.findByTransactionExternalId(TRANSACTION_EXTERNAL_ID); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("should use correct field name for query") + @SuppressWarnings("unchecked") + void shouldUseCorrectFieldNameForQuery() { + // Arrange + UUID specificId = UUID.randomUUID(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionExternalId", specificId); + doReturn(null).when(query).firstResult(); + + // Act + repository.findByTransactionExternalId(specificId); + + // Assert + verify(repository).find("transactionExternalId", specificId); + } + + @Test + @DisplayName("should persist entity and return it") + void shouldPersistEntityAndReturnIt() { + // Arrange + TransactionEntity entity = createEntity(); + doAnswer(invocation -> null).when(repository).persist(any(TransactionEntity.class)); + + // Act + TransactionEntity result = repository.save(entity); + + // Assert + assertEquals(entity, result); + verify(repository).persist(entity); + } + + @Test + @DisplayName("should call persist with correct entity") + void shouldCallPersistWithCorrectEntity() { + // Arrange + TransactionEntity entity = createEntity(); + doAnswer(invocation -> null).when(repository).persist(any(TransactionEntity.class)); + + // Act + repository.save(entity); + + // Assert + verify(repository).persist(entity); + } + + @Test + @DisplayName("should preserve all entity fields after save") + void shouldPreserveAllEntityFieldsAfterSave() { + // Arrange + TransactionEntity entity = createEntity(); + entity.setCreatedAt(LocalDateTime.now()); + doAnswer(invocation -> null).when(repository).persist(any(TransactionEntity.class)); + + // Act + TransactionEntity result = repository.save(entity); + + // Assert + assertEquals(entity.getTransactionExternalId(), result.getTransactionExternalId()); + assertEquals(entity.getAccountExternalIdDebit(), result.getAccountExternalIdDebit()); + assertEquals(entity.getAccountExternalIdCredit(), result.getAccountExternalIdCredit()); + assertEquals(entity.getValue(), result.getValue()); + } + + @Test + @DisplayName("should update status and return count") + void shouldUpdateStatusAndReturnCount() { + // Arrange + Integer newStatusId = 2; + doReturn(1).when(repository) + .update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, TRANSACTION_EXTERNAL_ID); + + // Act + int result = repository.updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, newStatusId); + + // Assert + assertEquals(1, result); + } + + @Test + @DisplayName("should return zero when no rows updated") + void shouldReturnZeroWhenNoRowsUpdated() { + // Arrange + Integer newStatusId = 2; + UUID nonExistentId = UUID.randomUUID(); + doReturn(0).when(repository) + .update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, nonExistentId); + + // Act + int result = repository.updateStatusByExternalId(nonExistentId, newStatusId); + + // Assert + assertEquals(0, result); + } + + @Test + @DisplayName("should use correct update query format") + void shouldUseCorrectUpdateQueryFormat() { + // Arrange + Integer newStatusId = 3; + doReturn(1).when(repository) + .update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, TRANSACTION_EXTERNAL_ID); + + // Act + repository.updateStatusByExternalId(TRANSACTION_EXTERNAL_ID, newStatusId); + + // Assert + verify(repository).update("transactionStatusId = ?1 where transactionExternalId = ?2", + newStatusId, TRANSACTION_EXTERNAL_ID); + } + + private TransactionEntity createEntity() { + TransactionEntity entity = new TransactionEntity(); + entity.setTransactionExternalId(TRANSACTION_EXTERNAL_ID); + entity.setAccountExternalIdDebit(DEBIT_ACCOUNT_ID); + entity.setAccountExternalIdCredit(CREDIT_ACCOUNT_ID); + entity.setTransferTypeId(1); + entity.setTransactionStatusId(1); + entity.setValue(VALUE); + return entity; + } +} \ No newline at end of file diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepositoryTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepositoryTest.java new file mode 100644 index 0000000000..3b109c8c43 --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransactionStatusPostgresRepositoryTest.java @@ -0,0 +1,184 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransactionStatusEntity; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TransactionStatusPostgresRepository") +class TransactionStatusPostgresRepositoryTest { + + private TransactionStatusPostgresRepository repository; + + private static final Integer STATUS_ID = 1; + private static final String STATUS_CODE = "PENDING"; + private static final String STATUS_NAME = "Pending"; + + @BeforeEach + void setUp() { + repository = spy(new TransactionStatusPostgresRepository()); + } + + @Test + @DisplayName("should return entity when found by code") + @SuppressWarnings("unchecked") + void shouldReturnEntityWhenFoundByCode() { + // Arrange + TransactionStatusEntity entity = createEntity(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("code", STATUS_CODE); + doReturn(entity).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findByCode(STATUS_CODE); + + // Assert + assertEquals(STATUS_ID, result.getTransactionStatusId()); + assertEquals(STATUS_CODE, result.getCode()); + assertEquals(STATUS_NAME, result.getName()); + } + + @Test + @DisplayName("should return null when not found by code") + @SuppressWarnings("unchecked") + void shouldReturnNullWhenNotFoundByCode() { + // Arrange + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("code", STATUS_CODE); + doReturn(null).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findByCode(STATUS_CODE); + + // Assert + assertEquals(null, result); + } + + @Test + @DisplayName("should use correct field name for query") + @SuppressWarnings("unchecked") + void shouldUseCorrectFieldNameForQuery() { + // Arrange + String specificCode = "APPROVED"; + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("code", specificCode); + doReturn(null).when(query).firstResult(); + + // Act + repository.findByCode(specificCode); + + // Assert + verify(repository).find("code", specificCode); + } + + @Test + @DisplayName("should handle different status codes") + @SuppressWarnings("unchecked") + void shouldHandleDifferentStatusCodes() { + // Arrange + TransactionStatusEntity approvedEntity = createEntity(2, "APPROVED", "Approved"); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("code", "APPROVED"); + doReturn(approvedEntity).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findByCode("APPROVED"); + + // Assert + assertEquals("APPROVED", result.getCode()); + assertEquals("Approved", result.getName()); + } + + @Test + @DisplayName("should return entity when found by ID") + @SuppressWarnings("unchecked") + void shouldReturnEntityWhenFoundById() { + // Arrange + TransactionStatusEntity entity = createEntity(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionStatusId", STATUS_ID); + doReturn(entity).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findById(STATUS_ID); + + // Assert + assertEquals(STATUS_ID, result.getTransactionStatusId()); + assertEquals(STATUS_CODE, result.getCode()); + assertEquals(STATUS_NAME, result.getName()); + } + + @Test + @DisplayName("should return null when not found by ID") + @SuppressWarnings("unchecked") + void shouldReturnNullWhenNotFoundById() { + // Arrange + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionStatusId", STATUS_ID); + doReturn(null).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findById(STATUS_ID); + + // Assert + assertEquals(null, result); + } + + @Test + @DisplayName("should use correct field name for ID query") + @SuppressWarnings("unchecked") + void shouldUseCorrectFieldNameForIdQuery() { + // Arrange + Integer specificId = 3; + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionStatusId", specificId); + doReturn(null).when(query).firstResult(); + + // Act + repository.findById(specificId); + + // Assert + verify(repository).find("transactionStatusId", specificId); + } + + @Test + @DisplayName("should return all entity fields when found") + @SuppressWarnings("unchecked") + void shouldReturnAllEntityFieldsWhenFound() { + // Arrange + TransactionStatusEntity entity = createEntity(3, "REJECTED", "Rejected"); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transactionStatusId", 3); + doReturn(entity).when(query).firstResult(); + + // Act + TransactionStatusEntity result = repository.findById(3); + + // Assert + assertEquals(3, result.getTransactionStatusId()); + assertEquals("REJECTED", result.getCode()); + assertEquals("Rejected", result.getName()); + } + + private TransactionStatusEntity createEntity() { + return createEntity(STATUS_ID, STATUS_CODE, STATUS_NAME); + } + + private TransactionStatusEntity createEntity(Integer id, String code, String name) { + TransactionStatusEntity entity = new TransactionStatusEntity(); + entity.setTransactionStatusId(id); + entity.setCode(code); + entity.setName(name); + return entity; + } +} \ No newline at end of file diff --git a/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepositoryTest.java b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepositoryTest.java new file mode 100644 index 0000000000..7c9e41be0c --- /dev/null +++ b/ms-transaction/src/test/java/com/yape/services/transaction/infrastructure/persistence/repository/TransferTypePostgresRepositoryTest.java @@ -0,0 +1,183 @@ +package com.yape.services.transaction.infrastructure.persistence.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import com.yape.services.transaction.infrastructure.persistence.entity.TransferTypeEntity; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TransferTypePostgresRepositoryTest { + + private TransferTypePostgresRepository repository; + + private static final Integer TRANSFER_TYPE_ID = 1; + private static final String TRANSFER_TYPE_CODE = "INTERNAL"; + private static final String TRANSFER_TYPE_NAME = "Internal Transfer"; + + @BeforeEach + void setUp() { + repository = spy(new TransferTypePostgresRepository()); + } + + @Test + @DisplayName("should return entity when found by ID") + @SuppressWarnings("unchecked") + void shouldReturnEntityWhenFoundById() { + // Arrange + TransferTypeEntity entity = createEntity(); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transferTypeId", TRANSFER_TYPE_ID); + doReturn(entity).when(query).firstResult(); + + // Act + TransferTypeEntity result = repository.findByTransferTypeId(TRANSFER_TYPE_ID); + + // Assert + assertNotNull(result); + assertEquals(TRANSFER_TYPE_ID, result.getTransferTypeId()); + assertEquals(TRANSFER_TYPE_CODE, result.getCode()); + assertEquals(TRANSFER_TYPE_NAME, result.getName()); + } + + @Test + @DisplayName("should return null when not found by ID") + @SuppressWarnings("unchecked") + void shouldReturnNullWhenNotFoundById() { + // Arrange + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transferTypeId", TRANSFER_TYPE_ID); + doReturn(null).when(query).firstResult(); + + // Act + TransferTypeEntity result = repository.findByTransferTypeId(TRANSFER_TYPE_ID); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("should use correct field name for query") + @SuppressWarnings("unchecked") + void shouldUseCorrectFieldNameForQuery() { + // Arrange + Integer specificId = 99; + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transferTypeId", specificId); + doReturn(null).when(query).firstResult(); + + // Act + repository.findByTransferTypeId(specificId); + + // Assert + verify(repository).find("transferTypeId", specificId); + } + + @Test + @DisplayName("should return entity with all fields mapped") + @SuppressWarnings("unchecked") + void shouldReturnEntityWithAllFieldsMapped() { + // Arrange + TransferTypeEntity entity = createEntity(2, "EXTERNAL", "External Transfer"); + PanacheQuery query = mock(PanacheQuery.class); + doReturn(query).when(repository).find("transferTypeId", 2); + doReturn(entity).when(query).firstResult(); + + // Act + TransferTypeEntity result = repository.findByTransferTypeId(2); + + // Assert + assertEquals(2, result.getTransferTypeId()); + assertEquals("EXTERNAL", result.getCode()); + assertEquals("External Transfer", result.getName()); + } + + @Test + @DisplayName("should return all transfer types") + void shouldReturnAllTransferTypes() { + // Arrange + List entities = List.of( + createEntity(1, "INTERNAL", "Internal Transfer"), + createEntity(2, "EXTERNAL", "External Transfer"), + createEntity(3, "INTERBANK", "Interbank Transfer") + ); + doReturn(entities).when(repository).listAll(); + + // Act + List result = repository.findAllTransferTypes(); + + // Assert + assertEquals(3, result.size()); + verify(repository).listAll(); + } + + @Test + @DisplayName("should return empty list when no transfer types exist") + void shouldReturnEmptyListWhenNoTransferTypesExist() { + // Arrange + doReturn(List.of()).when(repository).listAll(); + + // Act + List result = repository.findAllTransferTypes(); + + // Assert + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should preserve order of results") + void shouldPreserveOrderOfResults() { + // Arrange + List entities = List.of( + createEntity(3, "THIRD", "Third Type"), + createEntity(1, "FIRST", "First Type"), + createEntity(2, "SECOND", "Second Type") + ); + doReturn(entities).when(repository).listAll(); + + // Act + List result = repository.findAllTransferTypes(); + + // Assert + assertEquals(3, result.get(0).getTransferTypeId()); + assertEquals(1, result.get(1).getTransferTypeId()); + assertEquals(2, result.get(2).getTransferTypeId()); + } + + @Test + @DisplayName("should delegate to listAll method") + void shouldDelegateToListAllMethod() { + // Arrange + doReturn(List.of()).when(repository).listAll(); + + // Act + repository.findAllTransferTypes(); + + // Assert + verify(repository).listAll(); + } + + private TransferTypeEntity createEntity() { + return createEntity(TRANSFER_TYPE_ID, TRANSFER_TYPE_CODE, TRANSFER_TYPE_NAME); + } + + private TransferTypeEntity createEntity(Integer id, String code, String name) { + TransferTypeEntity entity = new TransferTypeEntity(); + entity.setTransferTypeId(id); + entity.setCode(code); + entity.setName(name); + return entity; + } +} From d617de42fecd3dea94719206a9977f90245fcbcf Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:57:19 -0500 Subject: [PATCH 42/54] chore(ms-transaction): update pom.xml dependencies and remove GreetingResource --- ms-transaction/pom.xml | 35 +++++++++++++++++++ .../com/yape/services/GreetingResource.java | 23 ------------ 2 files changed, 35 insertions(+), 23 deletions(-) delete mode 100644 ms-transaction/src/main/java/com/yape/services/GreetingResource.java diff --git a/ms-transaction/pom.xml b/ms-transaction/pom.xml index 5cda70cab1..97d171c74f 100644 --- a/ms-transaction/pom.xml +++ b/ms-transaction/pom.xml @@ -21,6 +21,8 @@ 4.8.6.6 1.18.42 3.43.0 + 1.6.3 + 0.2.0 com.yape.services @@ -100,6 +102,13 @@ provided + + + org.mapstruct + mapstruct + ${mapstruct.version} + + com.fasterxml.jackson.datatype @@ -161,6 +170,11 @@ quarkus-junit test + + io.quarkus + quarkus-junit5-mockito + test + io.rest-assured rest-assured @@ -225,7 +239,20 @@ lombok ${lombok.version} + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok-mapstruct-binding + ${lombok.mapstruct.binding} + + + -Amapstruct.defaultComponentModel=jakarta-cdi + @@ -266,6 +293,14 @@ org.jacoco jacoco-maven-plugin ${jacoco.version} + + + **/transaction/graphql/** + **/persistence/entity/** + **/TransferTypeMapperImpl.* + **/GraphqlTransactionMapperImpl.* + + prepare-agent diff --git a/ms-transaction/src/main/java/com/yape/services/GreetingResource.java b/ms-transaction/src/main/java/com/yape/services/GreetingResource.java deleted file mode 100644 index 88b979f671..0000000000 --- a/ms-transaction/src/main/java/com/yape/services/GreetingResource.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.yape.services; - -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; - -/** - * A simple REST endpoint that returns a greeting message. - */ - -@Path("/hello") -public class GreetingResource { - - /** - * A GET endpoint that returns a plain text greeting message. - */ - @GET - @Produces(MediaType.TEXT_PLAIN) - public String hello() { - return "Hello from Quarkus REST"; - } -} From 0f518d6f55c185abcdcb6ab2dba6e686db08481c Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:57:29 -0500 Subject: [PATCH 43/54] feat(ms-anti-fraud): add domain and application layers for transaction validation --- .../usecase/ValidateTransactionUseCase.java | 118 ++++++++++++++++++ .../domain/model/ValidationResult.java | 43 +++++++ .../service/AntiFraudValidationService.java | 44 +++++++ .../TransactionStatusEventPublisher.java | 17 +++ .../KafkaTransactionCreatedConsumer.java | 52 ++++++++ .../KafkaTransactionStatusPublisher.java | 54 ++++++++ 6 files changed, 328 insertions(+) create mode 100644 ms-anti-fraud/src/main/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCase.java create mode 100644 ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/model/ValidationResult.java create mode 100644 ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/AntiFraudValidationService.java create mode 100644 ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/TransactionStatusEventPublisher.java create mode 100644 ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumer.java create mode 100644 ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisher.java diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCase.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCase.java new file mode 100644 index 0000000000..8aa9869aa1 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCase.java @@ -0,0 +1,118 @@ +package com.yape.services.transaction.application.usecase; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.common.util.Constants; +import com.yape.services.transaction.domain.service.AntiFraudValidationService; +import com.yape.services.transaction.domain.service.TransactionStatusEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.events.TransactionCreatedPayload; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import com.yape.services.transaction.events.ValidationResult; +import com.yape.services.transaction.events.enums.TransactionStatus; +import jakarta.enterprise.context.ApplicationScoped; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Use case for validating a transaction against anti-fraud rules. + * Receives a TransactionCreatedEvent, validates it, and publishes + * a TransactionStatusUpdatedEvent with the result. + */ +@ApplicationScoped +public class ValidateTransactionUseCase { + + private static final Logger LOGGER = Logger.getLogger(ValidateTransactionUseCase.class); + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + .withZone(ZoneOffset.UTC); + + private final AntiFraudValidationService antiFraudValidationService; + private final TransactionStatusEventPublisher eventPublisher; + + /** + * Constructor for ValidateTransactionUseCase. + * + * @param antiFraudValidationService the service for validating transactions + * @param eventPublisher the publisher for transaction status events + */ + public ValidateTransactionUseCase(AntiFraudValidationService antiFraudValidationService, + TransactionStatusEventPublisher eventPublisher) { + this.antiFraudValidationService = antiFraudValidationService; + this.eventPublisher = eventPublisher; + } + + /** + * Executes the anti-fraud validation for a transaction. + * + * @param event the transaction created event to validate + */ + public void execute(TransactionCreatedEvent event) { + TransactionCreatedPayload payload = event.getPayload(); + String transactionExternalId = payload.getTransactionExternalId(); + + LOGGER.infof("Processing transaction for anti-fraud validation: %s", transactionExternalId); + + BigDecimal transactionValue = new BigDecimal(payload.getValue()); + com.yape.services.transaction.domain.model.ValidationResult result = + antiFraudValidationService.validate(transactionValue); + + TransactionStatus newStatus = result.isValid() + ? TransactionStatus.APPROVED + : TransactionStatus.REJECTED; + + TransactionStatusUpdatedEvent statusUpdatedEvent = buildStatusUpdatedEvent( + event, + payload, + result, + newStatus + ); + + eventPublisher.publishStatusUpdated(statusUpdatedEvent); + + LOGGER.infof("Transaction %s validation completed with status: %s", + transactionExternalId, newStatus); + } + + private TransactionStatusUpdatedEvent buildStatusUpdatedEvent( + TransactionCreatedEvent originalEvent, + TransactionCreatedPayload payload, + com.yape.services.transaction.domain.model.ValidationResult result, + TransactionStatus newStatus) { + + String requestId = originalEvent.getMetadata().getRequestId(); + + EventMetadata metadata = EventMetadata.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setEventType(Constants.EVENT_TYPE_TRANSACTION_STATUS_UPDATED) + .setEventTimestamp(ISO_FORMATTER.format(Instant.now())) + .setSource(Constants.EVENT_SOURCE) + .setVersion(Constants.SCHEMA_VERSION) + .setRequestId(requestId) + .build(); + + ValidationResult validationResult = ValidationResult.newBuilder() + .setIsValid(result.isValid()) + .setRuleCode(result.getRuleCode()) + .build(); + + TransactionStatusUpdatedPayload statusPayload = TransactionStatusUpdatedPayload.newBuilder() + .setTransactionExternalId(payload.getTransactionExternalId()) + .setPreviousStatus(TransactionStatus.PENDING) + .setNewStatus(newStatus) + .setValue(payload.getValue()) + .setValidationResult(validationResult) + .setProcessedAt(ISO_FORMATTER.format(Instant.now())) + .build(); + + return TransactionStatusUpdatedEvent.newBuilder() + .setMetadata(metadata) + .setPayload(statusPayload) + .build(); + } + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/model/ValidationResult.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/model/ValidationResult.java new file mode 100644 index 0000000000..9cccef4e6e --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/model/ValidationResult.java @@ -0,0 +1,43 @@ +package com.yape.services.transaction.domain.model; + +/** + * Result of the anti-fraud validation process. + */ +public class ValidationResult { + + private final boolean valid; + private final String ruleCode; + + private ValidationResult(boolean valid, String ruleCode) { + this.valid = valid; + this.ruleCode = ruleCode; + } + + /** + * Creates a validation result indicating approval. + * + * @return ValidationResult indicating the transaction is approved + */ + public static ValidationResult approved() { + return new ValidationResult(true, null); + } + + /** + * Creates a validation result indicating rejection. + * + * @param ruleCode the code of the rule that caused rejection + * @return ValidationResult indicating the transaction is rejected + */ + public static ValidationResult rejected(String ruleCode) { + return new ValidationResult(false, ruleCode); + } + + public boolean isValid() { + return valid; + } + + public String getRuleCode() { + return ruleCode; + } + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/AntiFraudValidationService.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/AntiFraudValidationService.java new file mode 100644 index 0000000000..07b2bd7fb6 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/AntiFraudValidationService.java @@ -0,0 +1,44 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.common.util.Constants; +import com.yape.services.transaction.domain.model.ValidationResult; +import jakarta.enterprise.context.ApplicationScoped; +import java.math.BigDecimal; +import org.jboss.logging.Logger; + +/** + * Domain service responsible for anti-fraud transaction validation. + * Implements business rules to determine if a transaction should be approved or rejected. + */ +@ApplicationScoped +public class AntiFraudValidationService { + + private static final Logger LOGGER = Logger.getLogger(AntiFraudValidationService.class); + + /** + * Maximum allowed transaction amount. + * Transactions exceeding this value will be rejected. + */ + private static final BigDecimal MAX_TRANSACTION_AMOUNT = new BigDecimal("1000"); + + /** + * Validates a transaction based on anti-fraud rules. + * + * @param transactionValue the monetary value of the transaction + * @return ValidationResult indicating whether the transaction is approved or rejected + */ + public ValidationResult validate(BigDecimal transactionValue) { + LOGGER.debugf("Validating transaction with value: %s", transactionValue); + + // Rule: Reject transactions with value greater than 1000 + if (transactionValue.compareTo(MAX_TRANSACTION_AMOUNT) > 0) { + LOGGER.infof("Transaction rejected: value %s exceeds maximum allowed %s", + transactionValue, MAX_TRANSACTION_AMOUNT); + return ValidationResult.rejected(Constants.RULE_MAX_AMOUNT_EXCEEDED); + } + + LOGGER.debugf("Transaction approved with value: %s", transactionValue); + return ValidationResult.approved(); + } + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/TransactionStatusEventPublisher.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/TransactionStatusEventPublisher.java new file mode 100644 index 0000000000..e8ec97f2b8 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/domain/service/TransactionStatusEventPublisher.java @@ -0,0 +1,17 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; + +/** + * Interface for publishing transaction status update events. + */ +public interface TransactionStatusEventPublisher { + + /** + * Publishes a transaction status updated event. + * + * @param event the event to publish + */ + void publishStatusUpdated(TransactionStatusUpdatedEvent event); + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumer.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumer.java new file mode 100644 index 0000000000..d16f9ffd84 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumer.java @@ -0,0 +1,52 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import com.yape.services.transaction.application.usecase.ValidateTransactionUseCase; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.jboss.logging.Logger; + +/** + * Kafka consumer for transaction created events. + * Listens to the 'transaction.created' topic and delegates to the validation use case. + */ +@ApplicationScoped +public class KafkaTransactionCreatedConsumer { + + private static final Logger LOGGER = Logger.getLogger(KafkaTransactionCreatedConsumer.class); + + private final ValidateTransactionUseCase validateTransactionUseCase; + + /** + * Constructor for KafkaTransactionCreatedConsumer. + * + * @param validateTransactionUseCase the use case for validating transactions + */ + @Inject + public KafkaTransactionCreatedConsumer(ValidateTransactionUseCase validateTransactionUseCase) { + this.validateTransactionUseCase = validateTransactionUseCase; + } + + /** + * Consumes transaction created events from Kafka. + * + * @param kafkaRecord the Kafka record containing the transaction created event + */ + @Incoming("transaction-created-consumer") + public void consume(Record kafkaRecord) { + String key = kafkaRecord.key(); + TransactionCreatedEvent event = kafkaRecord.value(); + + LOGGER.infof("Received TransactionCreatedEvent with key: %s", key); + + try { + validateTransactionUseCase.execute(event); + } catch (Exception e) { + LOGGER.errorf(e, "Error processing TransactionCreatedEvent with key: %s", key); + throw e; + } + } + +} diff --git a/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisher.java b/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisher.java new file mode 100644 index 0000000000..b05297c520 --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisher.java @@ -0,0 +1,54 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import com.yape.services.transaction.domain.service.TransactionStatusEventPublisher; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.jboss.logging.Logger; + +/** + * Kafka implementation of TransactionStatusEventPublisher. + * Publishes transaction status update events to Kafka topics. + */ +@ApplicationScoped +public class KafkaTransactionStatusPublisher implements TransactionStatusEventPublisher { + + private static final Logger LOGGER = Logger.getLogger(KafkaTransactionStatusPublisher.class); + + private final Emitter> statusUpdatedEmitter; + + /** + * Constructor for KafkaTransactionStatusPublisher. + * + * @param statusUpdatedEmitter the emitter for transaction status updated events + */ + @Inject + public KafkaTransactionStatusPublisher( + @Channel("transaction-status-producer") + Emitter> statusUpdatedEmitter + ) { + this.statusUpdatedEmitter = statusUpdatedEmitter; + } + + @Override + public void publishStatusUpdated(TransactionStatusUpdatedEvent event) { + String key = event.getPayload().getTransactionExternalId(); + + LOGGER.infof("Publishing TransactionStatusUpdatedEvent with key: %s, status: %s", + key, event.getPayload().getNewStatus()); + + statusUpdatedEmitter.send(Record.of(key, event)) + .whenComplete((result, error) -> { + if (error != null) { + LOGGER.errorf(error, + "Failed to publish TransactionStatusUpdatedEvent with key: %s", key); + } else { + LOGGER.infof("Successfully published TransactionStatusUpdatedEvent with key: %s", key); + } + }); + } + +} From 2772d8d4ce19f0b52b9d37df23171e083634d347 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:57:45 -0500 Subject: [PATCH 44/54] feat(ms-anti-fraud): add common utilities and constants --- .../yape/services/common/util/Constants.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 ms-anti-fraud/src/main/java/com/yape/services/common/util/Constants.java diff --git a/ms-anti-fraud/src/main/java/com/yape/services/common/util/Constants.java b/ms-anti-fraud/src/main/java/com/yape/services/common/util/Constants.java new file mode 100644 index 0000000000..a6f759170e --- /dev/null +++ b/ms-anti-fraud/src/main/java/com/yape/services/common/util/Constants.java @@ -0,0 +1,29 @@ +package com.yape.services.common.util; + +/** + * Constants used across the anti-fraud service. + */ +public final class Constants { + + private Constants() { + // Utility class, prevent instantiation + } + + // Event sources + public static final String EVENT_SOURCE = "ms-anti-fraud"; + + // Event types + public static final String EVENT_TYPE_TRANSACTION_STATUS_UPDATED = "TRANSACTION_STATUS_UPDATED"; + + // Transaction statuses + public static final String TRANSACTION_STATUS_PENDING = "PENDING"; + public static final String TRANSACTION_STATUS_APPROVED = "APPROVED"; + public static final String TRANSACTION_STATUS_REJECTED = "REJECTED"; + + // Validation rules + public static final String RULE_MAX_AMOUNT_EXCEEDED = "MAX_AMOUNT_EXCEEDED"; + + // Default schema version + public static final String SCHEMA_VERSION = "1.0.0"; + +} From 669206d8f566dc0ba1b3ce924b09085d4efe9d49 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:57:53 -0500 Subject: [PATCH 45/54] test(ms-anti-fraud): add unit tests for validation and messaging --- .../ValidateTransactionUseCaseTest.java | 206 ++++++++++++++++++ .../domain/model/ValidationResultTest.java | 35 +++ .../AntiFraudValidationServiceTest.java | 44 ++++ .../KafkaTransactionCreatedConsumerTest.java | 80 +++++++ .../KafkaTransactionStatusPublisherTest.java | 76 +++++++ 5 files changed, 441 insertions(+) create mode 100644 ms-anti-fraud/src/test/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCaseTest.java create mode 100644 ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/model/ValidationResultTest.java create mode 100644 ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/service/AntiFraudValidationServiceTest.java create mode 100644 ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumerTest.java create mode 100644 ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisherTest.java diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCaseTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCaseTest.java new file mode 100644 index 0000000000..f7891c9126 --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/application/usecase/ValidateTransactionUseCaseTest.java @@ -0,0 +1,206 @@ +package com.yape.services.transaction.application.usecase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.yape.services.common.events.EventMetadata; +import com.yape.services.transaction.domain.model.ValidationResult; +import com.yape.services.transaction.domain.service.AntiFraudValidationService; +import com.yape.services.transaction.domain.service.TransactionStatusEventPublisher; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import com.yape.services.transaction.events.TransactionCreatedPayload; +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.enums.TransactionStatus; +import java.math.BigDecimal; +import java.sql.Timestamp; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link ValidateTransactionUseCase}. + */ +@ExtendWith(MockitoExtension.class) +class ValidateTransactionUseCaseTest { + + @Mock + AntiFraudValidationService antiFraudValidationService; + @Mock + TransactionStatusEventPublisher eventPublisher; + + @InjectMocks + ValidateTransactionUseCase useCase; + + @Test + @DisplayName("should approve transaction and publish APPROVED event when validation passes") + void shouldApproveTransactionAndPublishApprovedEventWhenValidationPasses() { + // Arrange + String transactionExternalId = "tx-123"; + String value = "100.00"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata("req-1"); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + when(antiFraudValidationService.validate(new BigDecimal(value))) + .thenReturn(ValidationResult.approved()); + + // Act + useCase.execute(event); + + // Assert + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionStatusUpdatedEvent.class); + verify(eventPublisher).publishStatusUpdated(captor.capture()); + TransactionStatusUpdatedEvent publishedEvent = captor.getValue(); + assertEquals(TransactionStatus.APPROVED, publishedEvent.getPayload().getNewStatus()); + assertTrue(publishedEvent.getPayload().getValidationResult().getIsValid()); + assertNull(publishedEvent.getPayload().getValidationResult().getRuleCode()); + assertEquals(transactionExternalId, publishedEvent.getPayload().getTransactionExternalId()); + } + + @Test + @DisplayName("should reject transaction and publish REJECTED event when validation fails") + void shouldRejectTransactionAndPublishRejectedEventWhenValidationFails() { + // Arrange + String transactionExternalId = "tx-456"; + String value = "1000.01"; + String ruleCode = "MAX_AMOUNT_EXCEEDED"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata("req-2"); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + when(antiFraudValidationService.validate(new BigDecimal(value))) + .thenReturn(ValidationResult.rejected(ruleCode)); + + // Act + useCase.execute(event); + + // Assert + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionStatusUpdatedEvent.class); + verify(eventPublisher).publishStatusUpdated(captor.capture()); + TransactionStatusUpdatedEvent publishedEvent = captor.getValue(); + assertEquals(TransactionStatus.REJECTED, publishedEvent.getPayload().getNewStatus()); + assertFalse(publishedEvent.getPayload().getValidationResult().getIsValid()); + assertEquals(ruleCode, publishedEvent.getPayload().getValidationResult().getRuleCode()); + assertEquals(transactionExternalId, publishedEvent.getPayload().getTransactionExternalId()); + } + + @Test + @DisplayName("should handle null ruleCode in rejected validation result") + void shouldHandleNullRuleCodeInRejectedValidationResult() { + // Arrange + String transactionExternalId = "tx-789"; + String value = "2000.00"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata("req-3"); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + when(antiFraudValidationService.validate(new BigDecimal(value))) + .thenReturn(ValidationResult.rejected(null)); + + // Act + useCase.execute(event); + + // Assert + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionStatusUpdatedEvent.class); + verify(eventPublisher).publishStatusUpdated(captor.capture()); + TransactionStatusUpdatedEvent publishedEvent = captor.getValue(); + assertEquals(TransactionStatus.REJECTED, publishedEvent.getPayload().getNewStatus()); + assertFalse(publishedEvent.getPayload().getValidationResult().getIsValid()); + assertNull(publishedEvent.getPayload().getValidationResult().getRuleCode()); + } + + @Test + @DisplayName("should use requestId from original event metadata in published event") + void shouldUseRequestIdFromOriginalEventMetadataInPublishedEvent() { + // Arrange + String transactionExternalId = "tx-reqid"; + String value = "50.00"; + String requestId = "req-xyz"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata(requestId); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + when(antiFraudValidationService.validate(new BigDecimal(value))) + .thenReturn(ValidationResult.approved()); + + // Act + useCase.execute(event); + + // Assert + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionStatusUpdatedEvent.class); + verify(eventPublisher).publishStatusUpdated(captor.capture()); + TransactionStatusUpdatedEvent publishedEvent = captor.getValue(); + assertEquals(requestId, publishedEvent.getMetadata().getRequestId()); + } + + @Test + @DisplayName("should throw NumberFormatException if payload value is not a valid number") + void shouldThrowNumberFormatExceptionIfPayloadValueIsNotValidNumber() { + // Arrange + String transactionExternalId = "tx-invalid"; + String value = "not-a-number"; + TransactionCreatedPayload payload = + buildTransactionCreatedPayload(transactionExternalId, value); + EventMetadata metadata = buildMetadata("req-invalid"); + TransactionCreatedEvent event = TransactionCreatedEvent.newBuilder() + .setPayload(payload) + .setMetadata(metadata) + .build(); + + // Act & Assert + assertThrows(NumberFormatException.class, () -> useCase.execute(event)); + verifyNoInteractions(eventPublisher); + } + + EventMetadata buildMetadata(String requestId) { + return EventMetadata.newBuilder() + .setEventId("event-123") + .setEventType("TransactionCreated") + .setEventTimestamp(new Timestamp(System.currentTimeMillis()).toString()) + .setSource("ms-transaction") + .setVersion("1.0") + .setRequestId(requestId) + .build(); + } + + TransactionCreatedPayload buildTransactionCreatedPayload( + String transactionExternalId, + String value + ) { + return TransactionCreatedPayload.newBuilder() + .setTransactionExternalId(transactionExternalId) + .setAccountExternalIdDebit("debit-acc-1") + .setAccountExternalIdCredit("credit-acc-1") + .setTransferTypeId(1) + .setValue(value) + .setStatus(com.yape.services.transaction.events.enums.TransactionStatus.PENDING) + .setCreatedAt(new Timestamp(System.currentTimeMillis()).toString()) + .build(); + } +} diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/model/ValidationResultTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/model/ValidationResultTest.java new file mode 100644 index 0000000000..e8709374b0 --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/model/ValidationResultTest.java @@ -0,0 +1,35 @@ +package com.yape.services.transaction.domain.model; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the ValidationResult class. + */ +class ValidationResultTest { + + @Test + void approved_shouldReturnValidTrueAndNullRuleCode() { + // Arrange + + // Act + ValidationResult result = ValidationResult.approved(); + + // Assert + Assertions.assertTrue(result.isValid()); + Assertions.assertNull(result.getRuleCode()); + } + + @Test + void rejected_shouldReturnValidFalseAndRuleCode() { + // Arrange + String ruleCode = "MAX_AMOUNT_EXCEEDED"; + + // Act + ValidationResult result = ValidationResult.rejected(ruleCode); + + // Assert + Assertions.assertFalse(result.isValid()); + Assertions.assertEquals(ruleCode, result.getRuleCode()); + } +} diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/service/AntiFraudValidationServiceTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/service/AntiFraudValidationServiceTest.java new file mode 100644 index 0000000000..eaf6a4f483 --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/domain/service/AntiFraudValidationServiceTest.java @@ -0,0 +1,44 @@ +package com.yape.services.transaction.domain.service; + +import com.yape.services.common.util.Constants; +import com.yape.services.transaction.domain.model.ValidationResult; +import java.math.BigDecimal; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for AntiFraudValidationService. + */ +class AntiFraudValidationServiceTest { + + private final AntiFraudValidationService service = new AntiFraudValidationService(); + + @Test + @DisplayName("Should approve transaction when amount is below or equal to max limit") + void shouldApproveTransactionWhenAmountIsBelowOrEqualToMax() { + // Arrange + BigDecimal amount = new BigDecimal("500"); + + // Act + ValidationResult result = service.validate(amount); + + // Assert + Assertions.assertTrue(result.isValid()); + Assertions.assertNull(result.getRuleCode()); + } + + @Test + @DisplayName("Should reject transaction when amount exceeds max limit") + void shouldRejectTransactionWhenAmountExceedsMax() { + // Arrange + BigDecimal amount = new BigDecimal("1000.01"); + + // Act + ValidationResult result = service.validate(amount); + + // Assert + Assertions.assertFalse(result.isValid()); + Assertions.assertEquals(Constants.RULE_MAX_AMOUNT_EXCEEDED, result.getRuleCode()); + } +} diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumerTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumerTest.java new file mode 100644 index 0000000000..a0744d52b5 --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionCreatedConsumerTest.java @@ -0,0 +1,80 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.application.usecase.ValidateTransactionUseCase; +import com.yape.services.transaction.events.TransactionCreatedEvent; +import io.smallrye.reactive.messaging.kafka.Record; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link KafkaTransactionCreatedConsumer}. + */ +@ExtendWith(MockitoExtension.class) +class KafkaTransactionCreatedConsumerTest { + + @Mock + ValidateTransactionUseCase validateTransactionUseCase; + + @InjectMocks + KafkaTransactionCreatedConsumer consumer; + + @Mock + Record kafkaRecord; + + @Mock + TransactionCreatedEvent event; + + @Test + @DisplayName("should delegate event to ValidateTransactionUseCase when event is received") + void shouldDelegateEventToValidateTransactionUseCaseWhenEventIsReceived() { + // Arrange + when(kafkaRecord.key()).thenReturn("key-1"); + when(kafkaRecord.value()).thenReturn(event); + + // Act + consumer.consume(kafkaRecord); + + // Assert + org.mockito.Mockito.verify(validateTransactionUseCase, org.mockito.Mockito.times(1)) + .execute(event); + } + + @Test + @DisplayName("should log and rethrow exception if ValidateTransactionUseCase throws") + void shouldLogAndRethrowExceptionIfValidateTransactionUseCaseThrows() { + // Arrange + when(kafkaRecord.key()).thenReturn("key-2"); + when(kafkaRecord.value()).thenReturn(event); + RuntimeException ex = new RuntimeException("validation failed"); + org.mockito.Mockito.doThrow(ex).when(validateTransactionUseCase).execute(event); + + // Act & Assert + assertEquals( + ex, + assertThrows(RuntimeException.class, () -> consumer.consume(kafkaRecord)) + ); + } + + @Test + @DisplayName("should handle null event value gracefully") + void shouldHandleNullEventValueGracefully() { + // Arrange + when(kafkaRecord.key()).thenReturn("key-3"); + when(kafkaRecord.value()).thenReturn(null); + + // Act + consumer.consume(kafkaRecord); + + // Assert + org.mockito.Mockito.verify(validateTransactionUseCase, org.mockito.Mockito.times(1)) + .execute(null); + } +} diff --git a/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisherTest.java b/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisherTest.java new file mode 100644 index 0000000000..7e144f964e --- /dev/null +++ b/ms-anti-fraud/src/test/java/com/yape/services/transaction/infrastructure/messaging/KafkaTransactionStatusPublisherTest.java @@ -0,0 +1,76 @@ +package com.yape.services.transaction.infrastructure.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yape.services.transaction.events.TransactionStatusUpdatedEvent; +import com.yape.services.transaction.events.TransactionStatusUpdatedPayload; +import com.yape.services.transaction.events.enums.TransactionStatus; +import io.smallrye.reactive.messaging.kafka.Record; +import java.util.concurrent.CompletableFuture; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link KafkaTransactionStatusPublisher}. + */ +@ExtendWith(MockitoExtension.class) +class KafkaTransactionStatusPublisherTest { + + @Mock + Emitter> statusUpdatedEmitter; + + @InjectMocks + KafkaTransactionStatusPublisher publisher; + + @Mock + TransactionStatusUpdatedEvent event; + @Mock + TransactionStatusUpdatedPayload payload; + + @Test + @DisplayName("should publish event and log success when emitter sends successfully") + void shouldPublishEventAndLogSuccess() { + // Arrange + String transactionId = "tx-123"; + TransactionStatus newStatus = TransactionStatus.APPROVED; + when(event.getPayload()).thenReturn(payload); + when(payload.getTransactionExternalId()).thenReturn(transactionId); + when(payload.getNewStatus()).thenReturn(newStatus); + CompletableFuture future = new CompletableFuture<>(); + when(statusUpdatedEmitter.send(any(Record.class))).thenReturn(future); + + // Act + publisher.publishStatusUpdated(event); + future.complete(null); + + // Assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(Record.class); + verify(statusUpdatedEmitter).send(captor.capture()); + Record captorValue = captor.getValue(); + assertEquals(transactionId, captorValue.key()); + assertSame(event, captorValue.value()); + } + + @Test + @DisplayName("should handle null payload gracefully") + void shouldHandleNullPayloadGracefully() { + // Arrange + when(event.getPayload()).thenReturn(null); + + // Act & Assert + assertThrows(NullPointerException.class, () -> publisher.publishStatusUpdated(event)); + } +} From 8b8bc2f56321795956e2f6c26856a770e1e27612 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:58:03 -0500 Subject: [PATCH 46/54] feat(ms-anti-fraud): add Avro schemas for transaction events --- .../src/main/avro/TransactionCreatedEvent.avsc | 12 ++++++------ .../src/main/avro/TransactionStatusUpdatedEvent.avsc | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc index e3c57516da..dd825a7498 100644 --- a/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc +++ b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc @@ -1,7 +1,7 @@ { "type": "record", "name": "TransactionCreatedEvent", - "namespace": "com.yape.transaction.events", + "namespace": "com.yape.services.transaction.events", "doc": "Emitted event when a new transaction is created. Published to the topic 'transaction.created' for the Anti-Fraud service to validate the transaction.", "fields": [ { @@ -9,7 +9,7 @@ "type": { "type": "record", "name": "EventMetadata", - "namespace": "com.yape.common.events", + "namespace": "com.yape.services.common.events", "doc": "Standard metadata for all system events", "fields": [ { @@ -66,17 +66,17 @@ { "name": "accountExternalIdDebit", "type": "string", - "doc": "Debit account external identifier (source)" + "doc": "Debit account external identifier" }, { "name": "accountExternalIdCredit", "type": "string", - "doc": "Credit account external identifier (destination)" + "doc": "Credit account external identifier" }, { "name": "transferTypeId", "type": "int", - "doc": "Transfer type identifier. (1: INTERNAL, 2: INTRABANK, 3: INTERBANK, 4: INTERNATIONAL)" + "doc": "Transfer type identifier." }, { "name": "value", @@ -88,7 +88,7 @@ "type": { "type": "enum", "name": "TransactionStatus", - "namespace": "com.yape.transaction.events.enums", + "namespace": "com.yape.services.transaction.events.enums", "doc": "Possible statuses of a financial transaction", "symbols": [ "PENDING", diff --git a/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc index 84977f6c9c..049e9b81b1 100644 --- a/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc +++ b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc @@ -1,7 +1,7 @@ { "type": "record", "name": "TransactionStatusUpdatedEvent", - "namespace": "com.yape.transaction.events", + "namespace": "com.yape.services.transaction.events", "doc": "Emitted event when Anti-Fraud service updates the status of a transaction. Published to the topic 'transaction.status' for ms-transaction to update the status in the database.", "fields": [ { @@ -9,7 +9,7 @@ "type": { "type": "record", "name": "EventMetadata", - "namespace": "com.yape.common.events", + "namespace": "com.yape.services.common.events", "doc": "Standard metadata for all system events", "fields": [ { @@ -68,7 +68,7 @@ "type": { "type": "enum", "name": "TransactionStatus", - "namespace": "com.yape.transaction.events.enums", + "namespace": "com.yape.services.transaction.events.enums", "doc": "Possible statuses of a financial transaction", "symbols": [ "PENDING", @@ -80,7 +80,7 @@ }, { "name": "newStatus", - "type": "com.yape.transaction.events.enums.TransactionStatus", + "type": "com.yape.services.transaction.events.enums.TransactionStatus", "doc": "New status of the transaction" }, { From 6580b101c564feb0c92a9008d8a54ff66982396e Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 12:58:12 -0500 Subject: [PATCH 47/54] chore(ms-anti-fraud): update configuration, pom.xml and remove GreetingResource --- ms-anti-fraud/pom.xml | 4 ++-- .../com/yape/services/GreetingResource.java | 24 ------------------- .../src/main/resources/application-local.yml | 14 +++++++---- 3 files changed, 12 insertions(+), 30 deletions(-) delete mode 100644 ms-anti-fraud/src/main/java/com/yape/services/GreetingResource.java diff --git a/ms-anti-fraud/pom.xml b/ms-anti-fraud/pom.xml index 23ee07a92a..45e86b3832 100644 --- a/ms-anti-fraud/pom.xml +++ b/ms-anti-fraud/pom.xml @@ -116,8 +116,8 @@ test - io.rest-assured - rest-assured + io.quarkus + quarkus-junit-mockito test diff --git a/ms-anti-fraud/src/main/java/com/yape/services/GreetingResource.java b/ms-anti-fraud/src/main/java/com/yape/services/GreetingResource.java deleted file mode 100644 index 5379fd9b8a..0000000000 --- a/ms-anti-fraud/src/main/java/com/yape/services/GreetingResource.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.yape.services; - -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; - -/** - * A simple REST endpoint that returns a greeting message. - */ -@Path("/hello") -public class GreetingResource { - - /** - * Handles HTTP GET requests to the /hello endpoint. - * - * @return A plain text greeting message. - */ - @GET - @Produces(MediaType.TEXT_PLAIN) - public String hello() { - return "Hello from Quarkus REST"; - } -} diff --git a/ms-anti-fraud/src/main/resources/application-local.yml b/ms-anti-fraud/src/main/resources/application-local.yml index a9bf7430ef..f7527a8ebf 100644 --- a/ms-anti-fraud/src/main/resources/application-local.yml +++ b/ms-anti-fraud/src/main/resources/application-local.yml @@ -35,18 +35,21 @@ kafka: mp: messaging: outgoing: - kafka-out: + transaction-status-producer: connector: smallrye-kafka - topic: test-event + topic: transaction.status key: serializer: org.apache.kafka.common.serialization.StringSerializer value: serializer: io.confluent.kafka.serializers.KafkaAvroSerializer + schema: + registry: + url: "http://${schema-registry-host}:8081" incoming: - kafka-in: + transaction-created-consumer: connector: smallrye-kafka - topic: test-event + topic: transaction.created group: id: ms-anti-fraud-group auto: @@ -57,6 +60,9 @@ mp: deserializer: org.apache.kafka.common.serialization.StringDeserializer value: deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer + schema: + registry: + url: "http://${schema-registry-host}:8081" specific: avro: reader: true From 43d0b75678d1c4f819ab9e1816b2fc78b836669e Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 13:41:54 -0500 Subject: [PATCH 48/54] feat(ms-anti-fraud): add SOLUTION.md for Yape Code Challenge overview and architecture --- README_V2.md | 0 SOLUTION.md | 240 +++++++++++++++++++++ assets/01-success-fetch-transfer-types.png | Bin 0 -> 33604 bytes assets/02-success-create-transaction.png | Bin 0 -> 37328 bytes assets/03-success-fetch-transaction.png | Bin 0 -> 36775 bytes 5 files changed, 240 insertions(+) delete mode 100644 README_V2.md create mode 100644 SOLUTION.md create mode 100644 assets/01-success-fetch-transfer-types.png create mode 100644 assets/02-success-create-transaction.png create mode 100644 assets/03-success-fetch-transaction.png diff --git a/README_V2.md b/README_V2.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/SOLUTION.md b/SOLUTION.md new file mode 100644 index 0000000000..6a95d6d095 --- /dev/null +++ b/SOLUTION.md @@ -0,0 +1,240 @@ +# Yape Code Challenge - Enterprise Solution :rocket: + +[![Postman Collection](https://img.shields.io/badge/Postman-Collection-orange?logo=postman)](https://www.postman.com/me-juda-carrillo/yape-code-challenge/) + +## Table of Contents + +- [Overview](#overview) +- [Solution Architecture](#solution-architecture) +- [Problem Resolution](#problem-resolution) +- [Why Quarkus?](#why-quarkus) +- [Enterprise Features](#enterprise-features) +- [Technical Stack](#technical-stack) +- [Getting Started](#getting-started) +- [API Usage](#api-usage) +- [Testing](#testing) + +--- + +## Overview + +This solution implements a **production-ready microservices architecture** for the Yape Code Challenge, demonstrating how a fintech company would build a scalable transaction processing system. + +**Key Highlights:** +- Event-Driven Architecture with Apache Kafka & Avro +- GraphQL API (instead of REST) +- Clean Architecture + CQRS Pattern +- Multi-layer Caching with Redis +- 94% Test Coverage + +--- + +## Solution Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT (GraphQL) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MS-TRANSACTION (:18080) │ +│ GraphQL → Use Cases → Domain Services → [PostgreSQL, Redis, Kafka] │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────────┴───────────────────────┐ + │ transaction.created transaction.status │ + ▼ ▲ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MS-ANTI-FRAUD (:18081) │ +│ Kafka Consumer → Validation Service → Kafka Producer │ +│ (Reject if value > 1000) │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PostgreSQL:5432 │ Redis:6379 │ Kafka:9092 │ Schema Registry:8081 │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Problem Resolution + +| Requirement | Implementation | +|-------------|---------------| +| Create transaction with pending status | ✅ GraphQL mutation saves with `PENDING` status | +| Anti-fraud validation via messaging | ✅ Kafka event to `transaction.created` topic | +| Reject transactions > 1000 | ✅ `AntiFraudValidationService` validates threshold | +| Update status via messaging | ✅ Response via `transaction.status` topic | +| High-volume support | ✅ Redis caching + Kafka async processing | + +--- + +## Why Quarkus? + +| Metric | Spring Boot | Quarkus JVM | Quarkus Native | +|--------|-------------|-------------|----------------| +| Startup Time | ~3-5s | ~0.8s | ~15ms | +| Memory (RSS) | ~300MB | ~150MB | ~35MB | +| Docker Image | ~300MB | ~200MB | ~70MB | + +**Enterprise Benefits:** +- **Cloud-Native**: Built for Kubernetes & containers +- **Native Compilation**: GraalVM support for serverless +- **Reactive**: Non-blocking Kafka processing +- **Developer Experience**: Live reload with `quarkus:dev` +- **Standards**: MicroProfile, Jakarta EE, Vert.x + +--- + +## Enterprise Features + +### Clean Architecture +``` +src/main/java/com/yape/services/ +├── expose/graphql/ # GraphQL Resolvers +├── transaction/ +│ ├── application/ # Use Cases, Commands, Queries +│ ├── domain/ # Models, Repository Interfaces +│ └── infrastructure/ # PostgreSQL, Redis, Kafka +└── shared/exception/ # Error Handling +``` + +### Event-Driven with Avro Schemas +- Schema evolution with Confluent Schema Registry +- Type-safe serialization +- Request traceability via `requestId` + +### Caching Strategy +| Status | TTL | Rationale | +|--------|-----|-----------| +| PENDING | 5 min | Status will change | +| APPROVED/REJECTED | 1 hour | Final state | + +--- + +## Technical Stack + +| Component | Technology | Version | +|-----------|------------|---------| +| Framework | Quarkus | 3.31.1 | +| Language | Java | 21 | +| API | SmallRye GraphQL | - | +| Database | PostgreSQL | 14 | +| Cache | Redis | 8 | +| Messaging | Kafka + Avro | 7.9.0 | +| ORM | Hibernate Panache | - | +| Migrations | Flyway | - | + +--- + +## Getting Started + +### 1. Start Infrastructure +```bash +docker-compose up -d +``` + +### 2. Run Microservices +```bash +# Terminal 1 - Transaction Service +cd ms-transaction && ./mvnw quarkus:dev + +# Terminal 2 - Anti-Fraud Service +cd ms-anti-fraud && ./mvnw quarkus:dev +``` + +### 3. Verify Health +```bash +curl http://localhost:18080/ms-transaction/health +curl http://localhost:18081/ms-anti-fraud/health +``` + +--- + +## API Usage + +### Step 1: Fetch Transfer Types + +```graphql +query { + transferTypes { + transferTypeId + name + } +} +``` + +![Fetch Transfer Types](assets/01-success-fetch-transfer-types.png) + +--- + +### Step 2: Create Transaction + +```graphql +mutation { + createTransaction(input: { + accountExternalIdDebit: "550e8400-e29b-41d4-a716-446655440000" + accountExternalIdCredit: "550e8400-e29b-41d4-a716-446655440001" + transferTypeId: 1 + value: "500.00" + }) { + transactionExternalId + transactionStatus { name } + value + createdAt + } +} +``` + +![Create Transaction](assets/02-success-create-transaction.png) + +--- + +### Step 3: Fetch Transaction + +```graphql +query { + transaction(transactionExternalId: "YOUR_TRANSACTION_ID") { + transactionExternalId + transactionType { name } + transactionStatus { name } + value + createdAt + } +} +``` + +![Fetch Transaction](assets/03-success-fetch-transaction.png) + +--- + +## Testing + +### Run Tests +```bash +cd ms-transaction && ./mvnw test +cd ms-anti-fraud && ./mvnw test +``` + +### Coverage Report +```bash +./mvnw verify +# Report: target/site/jacoco/index.html +``` + +**ms-transaction: 94% coverage** + +| Package | Coverage | +|---------|----------| +| application.usecase | 94% | +| application.query | 100% | +| infrastructure.cache | 100% | +| infrastructure.messaging | 100% | + +--- + +## Author + +**Juda Carrillo** - Tech Lead | Senior Backend Developer +📧 [jbcp2006@gmail.com](mailto:jbcp2006@gmail.com) diff --git a/assets/01-success-fetch-transfer-types.png b/assets/01-success-fetch-transfer-types.png new file mode 100644 index 0000000000000000000000000000000000000000..b6d5bfbb3394c65793778a9828a46d7bc7a7b619 GIT binary patch literal 33604 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%0D;S!S-{6)BO=xtUyVC4 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 Z#BXVB>CZgIq{v%%sGufaAp7{m{{guI)TaOd literal 0 HcmV?d00001 diff --git a/assets/02-success-create-transaction.png b/assets/02-success-create-transaction.png new file mode 100644 index 0000000000000000000000000000000000000000..bb8ae10eecce2ed37ae1af6134b7c477f439b850 GIT binary patch literal 37328 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 literal 0 HcmV?d00001 diff --git a/assets/03-success-fetch-transaction.png b/assets/03-success-fetch-transaction.png new file mode 100644 index 0000000000000000000000000000000000000000..f9bd04be54fba53e574bf48fc8f0c178b6536f02 GIT binary patch literal 36775 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 literal 0 HcmV?d00001 From 2db760be9fbc6ba247854637dbf7a3ea552e1f25 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 14:36:42 -0500 Subject: [PATCH 49/54] feat(devops): add multistage Dockerfiles for microservices --- .../devops/docker/Dockerfile.multistage | 40 +++++++++++++++++++ .../devops/docker/Dockerfile.multistage | 40 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 ms-anti-fraud/devops/docker/Dockerfile.multistage create mode 100644 ms-transaction/devops/docker/Dockerfile.multistage diff --git a/ms-anti-fraud/devops/docker/Dockerfile.multistage b/ms-anti-fraud/devops/docker/Dockerfile.multistage new file mode 100644 index 0000000000..50d2db6f61 --- /dev/null +++ b/ms-anti-fraud/devops/docker/Dockerfile.multistage @@ -0,0 +1,40 @@ +#### +# Multi-stage Dockerfile for ms-anti-fraud +# Compiles the application and creates a minimal runtime image +#### + +# Stage 1: Build +FROM maven:3.9.9-eclipse-temurin-21 AS builder + +WORKDIR /build + +# Copy pom.xml and checkstyle config first for better caching +COPY pom.xml checkstyle.xml checkstyle-suppressions.xml ./ + +# Download dependencies (cached layer) +RUN mvn dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Build the application +RUN mvn package -DskipTests -B + +# Stage 2: Runtime +FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 + +ENV LANGUAGE='en_US:en' + +# Copy the built application from builder stage +COPY --from=builder --chown=185 /build/target/quarkus-app/lib/ /deployments/lib/ +COPY --from=builder --chown=185 /build/target/quarkus-app/*.jar /deployments/ +COPY --from=builder --chown=185 /build/target/quarkus-app/app/ /deployments/app/ +COPY --from=builder --chown=185 /build/target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 18081 +USER 185 + +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] \ No newline at end of file diff --git a/ms-transaction/devops/docker/Dockerfile.multistage b/ms-transaction/devops/docker/Dockerfile.multistage new file mode 100644 index 0000000000..44272cd221 --- /dev/null +++ b/ms-transaction/devops/docker/Dockerfile.multistage @@ -0,0 +1,40 @@ +#### +# Multi-stage Dockerfile for ms-transaction +# Compiles the application and creates a minimal runtime image +#### + +# Stage 1: Build +FROM maven:3.9.9-eclipse-temurin-21 AS builder + +WORKDIR /build + +# Copy pom.xml and checkstyle config first for better caching +COPY pom.xml checkstyle.xml checkstyle-suppressions.xml ./ + +# Download dependencies (cached layer) +RUN mvn dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Build the application +RUN mvn package -DskipTests -B + +# Stage 2: Runtime +FROM registry.access.redhat.com/ubi9/openjdk-21:1.23 + +ENV LANGUAGE='en_US:en' + +# Copy the built application from builder stage +COPY --from=builder --chown=185 /build/target/quarkus-app/lib/ /deployments/lib/ +COPY --from=builder --chown=185 /build/target/quarkus-app/*.jar /deployments/ +COPY --from=builder --chown=185 /build/target/quarkus-app/app/ /deployments/app/ +COPY --from=builder --chown=185 /build/target/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 18080 +USER 185 + +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] From 5c86aa7247e10602c923e314b2df285051aabc1f Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 14:37:34 -0500 Subject: [PATCH 50/54] chore(devops): update .dockerignore files --- ms-anti-fraud/.dockerignore | 27 ++++++++++++++++++++++----- ms-transaction/.dockerignore | 27 ++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/ms-anti-fraud/.dockerignore b/ms-anti-fraud/.dockerignore index 94810d006e..4cdd908d17 100644 --- a/ms-anti-fraud/.dockerignore +++ b/ms-anti-fraud/.dockerignore @@ -1,5 +1,22 @@ -* -!target/*-runner -!target/*-runner.jar -!target/lib/* -!target/quarkus-app/* \ No newline at end of file +# Ignore IDE files +.idea/ +*.iml +.vscode/ +.eclipse/ + +# Ignore build output (we build inside container) +target/ + +# Ignore git +.git/ +.gitignore + +# Ignore documentation +*.md +docs/ + +# Ignore test resources that aren't needed +src/test/resources/ + +# Ignore local config +.claude/ \ No newline at end of file diff --git a/ms-transaction/.dockerignore b/ms-transaction/.dockerignore index 94810d006e..da14c708af 100644 --- a/ms-transaction/.dockerignore +++ b/ms-transaction/.dockerignore @@ -1,5 +1,22 @@ -* -!target/*-runner -!target/*-runner.jar -!target/lib/* -!target/quarkus-app/* \ No newline at end of file +# Ignore IDE files +.idea/ +*.iml +.vscode/ +.eclipse/ + +# Ignore build output (we build inside container) +target/ + +# Ignore git +.git/ +.gitignore + +# Ignore documentation +*.md +docs/ + +# Ignore test resources that aren't needed +src/test/resources/ + +# Ignore local config +.claude/ From 876ccb60986450b3c4224ed8acfc6a0ed49f95b3 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 14:37:41 -0500 Subject: [PATCH 51/54] fix(ms-transaction): update application-local.yml with environment variables --- .../src/main/resources/application-local.yml | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/ms-transaction/src/main/resources/application-local.yml b/ms-transaction/src/main/resources/application-local.yml index 1696cd2dd6..8d885de9e0 100644 --- a/ms-transaction/src/main/resources/application-local.yml +++ b/ms-transaction/src/main/resources/application-local.yml @@ -1,30 +1,16 @@ # This configuration is used for local development environment. # Profile: local (activate with -Dquarkus.profile=local) -# External Service Hosts (use environment variables with defaults for local dev) -postgresql-host: "${POSTGRES_HOST:127.0.0.1}" -postgresql-port: "${POSTGRES_PORT:5432}" -postgresql-user: "${POSTGRES_USER:postgres}" -postgresql-pass: "${POSTGRES_PASSWORD:postgres}" - -event-hubs-host: "${KAFKA_HOST:127.0.0.1}" -schema-registry-host: "${SCHEMA_REGISTRY_HOST:127.0.0.1}" - -redis-host: "${REDIS_HOST:127.0.0.1}" -redis-port: "${REDIS_PORT:6379}" -redis-pass: "${REDIS_PASSWORD:}" - # Quarkus Configuration quarkus: # Datasource Configuration (PostgreSQL) datasource: db-kind: postgresql - username: "${postgresql-user}" - password: "${postgresql-pass}" + username: "${QUARKUS_DATASOURCE_USERNAME:postgres}" + password: "${QUARKUS_DATASOURCE_PASSWORD:postgres}" jdbc: - url: "jdbc:postgresql://${postgresql-host}:${postgresql-port}/yape_transactions" - # Connection pool settings + url: "${QUARKUS_DATASOURCE_JDBC_URL:jdbc:postgresql://localhost:5432/yape_transactions}" max-size: 16 min-size: 4 initial-size: 4 @@ -36,10 +22,10 @@ quarkus: threads: 16 netty-threads: 32 single-server-config: - address: "redis://${redis-host}:${redis-port}" - password: + address: "${QUARKUS_REDISSON_SINGLE_SERVER_CONFIG_ADDRESS:redis://localhost:6379}" flyway: + migrate-at-start: true locations: classpath:db/migration baseline-on-migrate: true @@ -60,7 +46,7 @@ quarkus: "org.hibernate.SQL": level: ERROR "io.smallrye.reactive.messaging": - level: ERROR + level: DEBUG # SmallRye GraphQL Configuration smallrye-graphql: @@ -90,10 +76,10 @@ application: # Kafka / Event Streaming Configuration kafka: bootstrap: - servers: "${event-hubs-host}:9092" + servers: "${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}" schema: registry: - url: "http://${schema-registry-host}:8081" + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" # MicroProfile Reactive Messaging Configuration mp: @@ -108,7 +94,7 @@ mp: serializer: io.confluent.kafka.serializers.KafkaAvroSerializer schema: registry: - url: "http://${schema-registry-host}:8081" + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" incoming: transaction-status-consumer: @@ -126,7 +112,7 @@ mp: deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer schema: registry: - url: "http://${schema-registry-host}:8081" + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" specific: avro: reader: true From 5b6d1bb624c5db47683277626d099588c416a2e6 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 14:37:47 -0500 Subject: [PATCH 52/54] fix(ms-anti-fraud): update configuration with environment variables and local profile --- .../src/main/resources/application-local.yml | 16 ++++------------ ms-anti-fraud/src/main/resources/application.yml | 1 + 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/ms-anti-fraud/src/main/resources/application-local.yml b/ms-anti-fraud/src/main/resources/application-local.yml index f7527a8ebf..99fdd5bd32 100644 --- a/ms-anti-fraud/src/main/resources/application-local.yml +++ b/ms-anti-fraud/src/main/resources/application-local.yml @@ -1,10 +1,6 @@ # This configuration is used for local development environment. # Profile: local (activate with -Dquarkus.profile=local) -# External Service Hosts -event-hubs-host: "127.0.0.1" -schema-registry-host: "127.0.0.1" - # Quarkus Configuration quarkus: devservices: @@ -16,20 +12,16 @@ quarkus: category: "com.yape": level: DEBUG - "org.hibernate.SQL": - level: DEBUG - "org.hibernate.type.descriptor.sql": - level: TRACE "io.smallrye.reactive.messaging": level: DEBUG # Kafka / Event Streaming Configuration kafka: bootstrap: - servers: "${event-hubs-host}:9092" + servers: "${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}" schema: registry: - url: "http://${schema-registry-host}:8081" + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" # MicroProfile Reactive Messaging Configuration mp: @@ -44,7 +36,7 @@ mp: serializer: io.confluent.kafka.serializers.KafkaAvroSerializer schema: registry: - url: "http://${schema-registry-host}:8081" + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" incoming: transaction-created-consumer: @@ -62,7 +54,7 @@ mp: deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer schema: registry: - url: "http://${schema-registry-host}:8081" + url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}" specific: avro: reader: true diff --git a/ms-anti-fraud/src/main/resources/application.yml b/ms-anti-fraud/src/main/resources/application.yml index fe01969797..03c6ec9547 100644 --- a/ms-anti-fraud/src/main/resources/application.yml +++ b/ms-anti-fraud/src/main/resources/application.yml @@ -8,6 +8,7 @@ quarkus: http: root-path: /ms-anti-fraud port: 18081 + profile: local info: project: From 83741b38c305df1c792f6363404fbffbb443ade2 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 14:37:54 -0500 Subject: [PATCH 53/54] fix(devops): update docker-compose with proper service configuration --- docker-compose.yml | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 73e1ed45e3..254e3bcf72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,16 +99,23 @@ services: ms-transaction: build: context: ./ms-transaction - dockerfile: devops/docker/Dockerfile.jvm + dockerfile: devops/docker/Dockerfile.multistage container_name: yape-ms-transaction ports: - "18080:18080" environment: + QUARKUS_PROFILE: local + # PostgreSQL QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://postgres:5432/yape_transactions QUARKUS_DATASOURCE_USERNAME: postgres QUARKUS_DATASOURCE_PASSWORD: postgres + # Flyway + QUARKUS_FLYWAY_MIGRATE_AT_START: "true" + # Redis + QUARKUS_REDISSON_SINGLE_SERVER_CONFIG_ADDRESS: redis://redis:6379 + # Kafka KAFKA_BOOTSTRAP_SERVERS: kafka:29092 - MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081 depends_on: postgres: condition: service_healthy @@ -116,36 +123,40 @@ services: condition: service_healthy schema-registry: condition: service_healthy + redis: + condition: service_healthy healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:18080/ms-transaction/health" ] - interval: 10s - timeout: 5s - retries: 5 + interval: 15s + timeout: 10s + retries: 10 + start_period: 120s networks: - yape-network ms-anti-fraud: build: context: ./ms-anti-fraud - dockerfile: devops/docker/Dockerfile.jvm + dockerfile: devops/docker/Dockerfile.multistage container_name: yape-ms-anti-fraud ports: - "18081:18081" environment: + QUARKUS_PROFILE: local + # Kafka KAFKA_BOOTSTRAP_SERVERS: kafka:29092 - MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081 depends_on: kafka: condition: service_healthy schema-registry: condition: service_healthy - redis: - condition: service_healthy healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:18081/ms-anti-fraud/health" ] - interval: 10s - timeout: 5s - retries: 5 + interval: 15s + timeout: 10s + retries: 10 + start_period: 120s networks: - yape-network @@ -155,4 +166,4 @@ volumes: networks: yape-network: - driver: bridge \ No newline at end of file + driver: bridge From 9e98fa394d21682f9bcd8eaf2ae86621d7e50b47 Mon Sep 17 00:00:00 2001 From: Juda Date: Fri, 30 Jan 2026 14:39:40 -0500 Subject: [PATCH 54/54] docs: update Getting Started section for docker-compose setup --- SOLUTION.md | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/SOLUTION.md b/SOLUTION.md index 6a95d6d095..061c3b5cc5 100644 --- a/SOLUTION.md +++ b/SOLUTION.md @@ -130,24 +130,44 @@ src/main/java/com/yape/services/ ## Getting Started -### 1. Start Infrastructure +### Prerequisites +- Docker & Docker Compose + +### 1. Start All Services ```bash docker-compose up -d ``` -### 2. Run Microservices +This command starts all services: +- **PostgreSQL** (:5432) - Transaction database +- **Redis** (:6379) - Caching layer +- **Zookeeper** (:2181) - Kafka coordination +- **Kafka** (:9092) - Event streaming +- **Schema Registry** (:8081) - Avro schema management +- **ms-transaction** (:18080) - Transaction microservice +- **ms-anti-fraud** (:18081) - Anti-fraud microservice + +### 2. Verify Health ```bash -# Terminal 1 - Transaction Service -cd ms-transaction && ./mvnw quarkus:dev +# Wait ~2 minutes for services to be ready, then: +curl http://localhost:18080/ms-transaction/health +curl http://localhost:18081/ms-anti-fraud/health +``` -# Terminal 2 - Anti-Fraud Service -cd ms-anti-fraud && ./mvnw quarkus:dev +### 3. Stop Services +```bash +docker-compose down ``` -### 3. Verify Health +### Development Mode (Optional) +For local development with hot-reload: ```bash -curl http://localhost:18080/ms-transaction/health -curl http://localhost:18081/ms-anti-fraud/health +# Start only infrastructure +docker-compose up -d postgres redis zookeeper kafka schema-registry + +# Run microservices locally +cd ms-transaction && ./mvnw quarkus:dev +cd ms-anti-fraud && ./mvnw quarkus:dev ``` ---