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/
diff --git a/SOLUTION.md b/SOLUTION.md
new file mode 100644
index 0000000000..061c3b5cc5
--- /dev/null
+++ b/SOLUTION.md
@@ -0,0 +1,260 @@
+# Yape Code Challenge - Enterprise Solution :rocket:
+
+[](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
+
+### Prerequisites
+- Docker & Docker Compose
+
+### 1. Start All Services
+```bash
+docker-compose up -d
+```
+
+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
+# Wait ~2 minutes for services to be ready, then:
+curl http://localhost:18080/ms-transaction/health
+curl http://localhost:18081/ms-anti-fraud/health
+```
+
+### 3. Stop Services
+```bash
+docker-compose down
+```
+
+### Development Mode (Optional)
+For local development with hot-reload:
+```bash
+# 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
+```
+
+---
+
+## API Usage
+
+### Step 1: Fetch Transfer Types
+
+```graphql
+query {
+ transferTypes {
+ transferTypeId
+ name
+ }
+}
+```
+
+
+
+---
+
+### 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
+ }
+}
+```
+
+
+
+---
+
+### Step 3: Fetch Transaction
+
+```graphql
+query {
+ transaction(transactionExternalId: "YOUR_TRANSACTION_ID") {
+ transactionExternalId
+ transactionType { name }
+ transactionStatus { name }
+ value
+ createdAt
+ }
+}
+```
+
+
+
+---
+
+## 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 0000000000..b6d5bfbb33
Binary files /dev/null and b/assets/01-success-fetch-transfer-types.png differ
diff --git a/assets/02-success-create-transaction.png b/assets/02-success-create-transaction.png
new file mode 100644
index 0000000000..bb8ae10eec
Binary files /dev/null and b/assets/02-success-create-transaction.png differ
diff --git a/assets/03-success-fetch-transaction.png b/assets/03-success-fetch-transaction.png
new file mode 100644
index 0000000000..f9bd04be54
Binary files /dev/null and b/assets/03-success-fetch-transaction.png differ
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..254e3bcf72 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,25 +1,169 @@
-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.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
+ KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081
+ depends_on:
+ postgres:
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ schema-registry:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ healthcheck:
+ test: [ "CMD", "curl", "-f", "http://localhost:18080/ms-transaction/health" ]
+ interval: 15s
+ timeout: 10s
+ retries: 10
+ start_period: 120s
+ networks:
+ - yape-network
+
+ ms-anti-fraud:
+ build:
+ context: ./ms-anti-fraud
+ dockerfile: devops/docker/Dockerfile.multistage
+ container_name: yape-ms-anti-fraud
+ ports:
+ - "18081:18081"
+ environment:
+ QUARKUS_PROFILE: local
+ # Kafka
+ KAFKA_BOOTSTRAP_SERVERS: kafka:29092
+ KAFKA_SCHEMA_REGISTRY_URL: http://schema-registry:8081
+ depends_on:
+ kafka:
+ condition: service_healthy
+ schema-registry:
+ condition: service_healthy
+ healthcheck:
+ test: [ "CMD", "curl", "-f", "http://localhost:18081/ms-anti-fraud/health" ]
+ interval: 15s
+ timeout: 10s
+ retries: 10
+ start_period: 120s
+ networks:
+ - yape-network
+
+volumes:
+ postgres_data:
+ redis_data:
+
+networks:
+ yape-network:
+ driver: bridge
diff --git a/ms-anti-fraud/.dockerignore b/ms-anti-fraud/.dockerignore
new file mode 100644
index 0000000000..4cdd908d17
--- /dev/null
+++ b/ms-anti-fraud/.dockerignore
@@ -0,0 +1,22 @@
+# 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-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/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-anti-fraud/pom.xml b/ms-anti-fraud/pom.xml
new file mode 100644
index 0000000000..45e86b3832
--- /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-junit
+ test
+
+
+ io.quarkus
+ quarkus-junit-mockito
+ 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/TransactionCreatedEvent.avsc b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc
new file mode 100644
index 0000000000..dd825a7498
--- /dev/null
+++ b/ms-anti-fraud/src/main/avro/TransactionCreatedEvent.avsc
@@ -0,0 +1,111 @@
+{
+ "type": "record",
+ "name": "TransactionCreatedEvent",
+ "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": [
+ {
+ "name": "metadata",
+ "type": {
+ "type": "record",
+ "name": "EventMetadata",
+ "namespace": "com.yape.services.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"
+ },
+ {
+ "name": "accountExternalIdCredit",
+ "type": "string",
+ "doc": "Credit account external identifier"
+ },
+ {
+ "name": "transferTypeId",
+ "type": "int",
+ "doc": "Transfer type identifier."
+ },
+ {
+ "name": "value",
+ "type": "string",
+ "doc": "Transaction monetary value as string to preserve precision"
+ },
+ {
+ "name": "status",
+ "type": {
+ "type": "enum",
+ "name": "TransactionStatus",
+ "namespace": "com.yape.services.transaction.events.enums",
+ "doc": "Possible statuses of a financial transaction",
+ "symbols": [
+ "PENDING",
+ "APPROVED",
+ "REJECTED"
+ ]
+ },
+ "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..049e9b81b1
--- /dev/null
+++ b/ms-anti-fraud/src/main/avro/TransactionStatusUpdatedEvent.avsc
@@ -0,0 +1,126 @@
+{
+ "type": "record",
+ "name": "TransactionStatusUpdatedEvent",
+ "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": [
+ {
+ "name": "metadata",
+ "type": {
+ "type": "record",
+ "name": "EventMetadata",
+ "namespace": "com.yape.services.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.services.transaction.events.enums",
+ "doc": "Possible statuses of a financial transaction",
+ "symbols": [
+ "PENDING",
+ "APPROVED",
+ "REJECTED"
+ ]
+ },
+ "doc": "Previous status of the transaction"
+ },
+ {
+ "name": "newStatus",
+ "type": "com.yape.services.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-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";
+
+}
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);
+ }
+ });
+ }
+
+}
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..99fdd5bd32
--- /dev/null
+++ b/ms-anti-fraud/src/main/resources/application-local.yml
@@ -0,0 +1,60 @@
+# This configuration is used for local development environment.
+# Profile: local (activate with -Dquarkus.profile=local)
+
+# Quarkus Configuration
+quarkus:
+ devservices:
+ enabled: false
+
+ # Logging Configuration
+ log:
+ level: INFO
+ category:
+ "com.yape":
+ level: DEBUG
+ "io.smallrye.reactive.messaging":
+ level: DEBUG
+
+# Kafka / Event Streaming Configuration
+kafka:
+ bootstrap:
+ servers: "${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}"
+ schema:
+ registry:
+ url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}"
+
+# MicroProfile Reactive Messaging Configuration
+mp:
+ messaging:
+ outgoing:
+ transaction-status-producer:
+ connector: smallrye-kafka
+ topic: transaction.status
+ key:
+ serializer: org.apache.kafka.common.serialization.StringSerializer
+ value:
+ serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
+ schema:
+ registry:
+ url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}"
+
+ incoming:
+ transaction-created-consumer:
+ connector: smallrye-kafka
+ topic: transaction.created
+ group:
+ id: ms-anti-fraud-group
+ auto:
+ offset:
+ reset: earliest
+ failure-strategy: ignore
+ key:
+ deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value:
+ deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
+ schema:
+ registry:
+ 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
new file mode 100644
index 0000000000..03c6ec9547
--- /dev/null
+++ b/ms-anti-fraud/src/main/resources/application.yml
@@ -0,0 +1,16 @@
+quarkus:
+ application:
+ name: ms-anti-fraud
+ smallrye-health:
+ root-path: /health
+ ui:
+ enabled: false
+ http:
+ root-path: /ms-anti-fraud
+ port: 18081
+ profile: local
+
+info:
+ project:
+ artifact: ${project.artifactId}
+ version: ${project.version}
\ No newline at end of file
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));
+ }
+}
diff --git a/ms-transaction/.dockerignore b/ms-transaction/.dockerignore
new file mode 100644
index 0000000000..da14c708af
--- /dev/null
+++ b/ms-transaction/.dockerignore
@@ -0,0 +1,22 @@
+# 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/
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..68aa749985
--- /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 18080:18080 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/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" ]
diff --git a/ms-transaction/pom.xml b/ms-transaction/pom.xml
new file mode 100644
index 0000000000..97d171c74f
--- /dev/null
+++ b/ms-transaction/pom.xml
@@ -0,0 +1,389 @@
+
+
+ 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
+ 1.18.42
+ 3.43.0
+ 1.6.3
+ 0.2.0
+
+
+ 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
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
+
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
+
+
+
+ io.quarkus
+ quarkus-jdbc-postgresql
+
+
+ io.quarkus
+ quarkus-hibernate-orm-panache
+
+
+
+
+ io.quarkus
+ quarkus-flyway
+
+
+
+
+ org.redisson
+ redisson-quarkus-30
+ ${redisson.version}
+
+
+
+
+ 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-junit
+ test
+
+
+ io.quarkus
+ quarkus-junit5-mockito
+ 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
+
+
+
+
+
+ ${project.basedir}/src/main/resources/graphql-client
+ .*\.graphqls$
+ true
+
+ ${project.build.directory}/generated-sources/graphql
+ 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
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+ org.projectlombok
+ lombok-mapstruct-binding
+ ${lombok.mapstruct.binding}
+
+
+
+ -Amapstruct.defaultComponentModel=jakarta-cdi
+
+
+
+
+ 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}
+
+
+ **/transaction/graphql/**
+ **/persistence/entity/**
+ **/TransferTypeMapperImpl.*
+ **/GraphqlTransactionMapperImpl.*
+
+
+
+
+ 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/TransactionCreatedEvent.avsc b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc
new file mode 100644
index 0000000000..dd825a7498
--- /dev/null
+++ b/ms-transaction/src/main/avro/TransactionCreatedEvent.avsc
@@ -0,0 +1,111 @@
+{
+ "type": "record",
+ "name": "TransactionCreatedEvent",
+ "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": [
+ {
+ "name": "metadata",
+ "type": {
+ "type": "record",
+ "name": "EventMetadata",
+ "namespace": "com.yape.services.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"
+ },
+ {
+ "name": "accountExternalIdCredit",
+ "type": "string",
+ "doc": "Credit account external identifier"
+ },
+ {
+ "name": "transferTypeId",
+ "type": "int",
+ "doc": "Transfer type identifier."
+ },
+ {
+ "name": "value",
+ "type": "string",
+ "doc": "Transaction monetary value as string to preserve precision"
+ },
+ {
+ "name": "status",
+ "type": {
+ "type": "enum",
+ "name": "TransactionStatus",
+ "namespace": "com.yape.services.transaction.events.enums",
+ "doc": "Possible statuses of a financial transaction",
+ "symbols": [
+ "PENDING",
+ "APPROVED",
+ "REJECTED"
+ ]
+ },
+ "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..049e9b81b1
--- /dev/null
+++ b/ms-transaction/src/main/avro/TransactionStatusUpdatedEvent.avsc
@@ -0,0 +1,126 @@
+{
+ "type": "record",
+ "name": "TransactionStatusUpdatedEvent",
+ "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": [
+ {
+ "name": "metadata",
+ "type": {
+ "type": "record",
+ "name": "EventMetadata",
+ "namespace": "com.yape.services.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.services.transaction.events.enums",
+ "doc": "Possible statuses of a financial transaction",
+ "symbols": [
+ "PENDING",
+ "APPROVED",
+ "REJECTED"
+ ]
+ },
+ "doc": "Previous status of the transaction"
+ },
+ {
+ "name": "newStatus",
+ "type": "com.yape.services.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/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();
+ }
+
+}
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";
+
+}
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);
+ }
+
+}
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();
+
+}
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();
+ }
+
+}
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..8d885de9e0
--- /dev/null
+++ b/ms-transaction/src/main/resources/application-local.yml
@@ -0,0 +1,118 @@
+# This configuration is used for local development environment.
+# Profile: local (activate with -Dquarkus.profile=local)
+
+# Quarkus Configuration
+quarkus:
+
+ # Datasource Configuration (PostgreSQL)
+ datasource:
+ db-kind: postgresql
+ username: "${QUARKUS_DATASOURCE_USERNAME:postgres}"
+ password: "${QUARKUS_DATASOURCE_PASSWORD:postgres}"
+ jdbc:
+ url: "${QUARKUS_DATASOURCE_JDBC_URL:jdbc:postgresql://localhost:5432/yape_transactions}"
+ max-size: 16
+ min-size: 4
+ initial-size: 4
+ idle-removal-interval: 2M
+ max-lifetime: 30M
+
+ # Redis Configuration
+ redisson:
+ threads: 16
+ netty-threads: 32
+ single-server-config:
+ address: "${QUARKUS_REDISSON_SINGLE_SERVER_CONFIG_ADDRESS:redis://localhost:6379}"
+
+ flyway:
+ migrate-at-start: true
+ locations: classpath:db/migration
+ baseline-on-migrate: true
+
+ # Hibernate ORM Configuration
+ hibernate-orm:
+ log:
+ sql: false
+
+ devservices:
+ enabled: false
+
+ # Logging Configuration
+ log:
+ level: INFO
+ category:
+ "com.yape":
+ level: DEBUG
+ "org.hibernate.SQL":
+ level: ERROR
+ "io.smallrye.reactive.messaging":
+ level: DEBUG
+
+ # 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:
+ bootstrap:
+ servers: "${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}"
+ schema:
+ registry:
+ url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}"
+
+# MicroProfile Reactive Messaging Configuration
+mp:
+ messaging:
+ outgoing:
+ transaction-producer:
+ connector: smallrye-kafka
+ topic: transaction.created
+ key:
+ serializer: org.apache.kafka.common.serialization.StringSerializer
+ value:
+ serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
+ schema:
+ registry:
+ url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}"
+
+ incoming:
+ transaction-status-consumer:
+ connector: smallrye-kafka
+ topic: transaction.status
+ 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
+ schema:
+ registry:
+ url: "${KAFKA_SCHEMA_REGISTRY_URL:http://localhost:8081}"
+ 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..2662a4a99f
--- /dev/null
+++ b/ms-transaction/src/main/resources/application.yml
@@ -0,0 +1,25 @@
+quarkus:
+ application:
+ name: ms-transaction
+ smallrye-health:
+ root-path: /health
+ ui:
+ enabled: false
+ 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:
+ artifact: ${project.artifactId}
+ version: ${project.version}
\ No newline at end of file
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
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..75a96a9b14
--- /dev/null
+++ b/ms-transaction/src/main/resources/graphql-client/directive.graphqls
@@ -0,0 +1,6 @@
+"""
+Indicates the minimum allowed value for a field or argument
+"""
+directive @Min(
+ value : BigDecimal = -2147483648.00
+) 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..c64c0eb0f7
--- /dev/null
+++ b/ms-transaction/src/main/resources/graphql-client/scalar.graphqls
@@ -0,0 +1,23 @@
+"""
+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
+
+"""
+Date in ISO 8601 format
+Format: yyyy-MM-dd
+"""
+scalar Date
+
+"""
+Monetary value with decimal precision
+Example: 1500.50
+"""
+scalar BigDecimal
\ 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..d4d0efc455
--- /dev/null
+++ b/ms-transaction/src/main/resources/graphql-client/transaction/inputs.graphqls
@@ -0,0 +1,23 @@
+# =============================================================================
+# Transaction Domain - Input Definitions
+# =============================================================================
+
+"""
+Input for creating a new financial transaction.
+"""
+input CreateTransaction {
+ """Identifier of the debit account"""
+ accountExternalIdDebit: UUID!
+
+ """Identifier of the credit account"""
+ accountExternalIdCredit: UUID!
+
+ """Transfer type identifier (category: TRANSFER, PAYMENT, DEPOSIT)"""
+ transferTypeId: Int!
+
+ """
+ Transaction amount.
+ - Minimum: 0
+ """
+ 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
new file mode 100644
index 0000000000..cfe81b43cf
--- /dev/null
+++ b/ms-transaction/src/main/resources/graphql-client/transaction/mutations.graphqls
@@ -0,0 +1,24 @@
+# =============================================================================
+# 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.
+
+ Possible errors:
+ - BAD_REQUEST: Invalid input data
+ - VALIDATION_ERROR: Business rule violation
+ """
+ createTransaction(
+ """Transaction data"""
+ 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
new file mode 100644
index 0000000000..bfd5ba74bf
--- /dev/null
+++ b/ms-transaction/src/main/resources/graphql-client/transaction/queries.graphqls
@@ -0,0 +1,24 @@
+# =============================================================================
+# Transaction Domain - Query Definitions
+# =============================================================================
+
+"""
+Root Query type for transaction-related operations.
+"""
+type Query {
+ """
+ Retrieves a transaction by its external identifier.
+
+ Possible errors:
+ - NOT_FOUND: Transaction does not exist
+ """
+ transaction(
+ """Unique external identifier of the transaction"""
+ transactionExternalId: UUID!
+ ): Transaction
+
+ """
+ Retrieves all available transfer types.
+ """
+ transferTypes: [TransferType!]!
+}
\ 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..f687375ddd
--- /dev/null
+++ b/ms-transaction/src/main/resources/graphql-client/transaction/types.graphqls
@@ -0,0 +1,66 @@
+# =============================================================================
+# Transaction Domain - Type Definitions
+# =============================================================================
+
+"""
+Represents a financial transaction in the system.
+"""
+type Transaction {
+ """Unique external identifier of the transaction"""
+ transactionExternalId: UUID!
+
+ """Type of the transaction"""
+ transactionType: TransactionType!
+
+ """Current status of the transaction"""
+ transactionStatus: TransactionStatus!
+
+ """Amount of the transaction"""
+ value: BigDecimal!
+
+ """Date and time of the transaction creation"""
+ createdAt: Date!
+}
+
+"""
+Details about a transfer type.
+"""
+type TransferType {
+ """Identifier of the transfer type"""
+ transferTypeId: ID!
+ """Name of the transfer type"""
+ name: String!
+}
+
+"""
+Details about a transaction type.
+"""
+type TransactionType {
+ """Name of the transaction type"""
+ name: String!
+}
+
+"""
+Details about a transaction status.
+"""
+type TransactionStatus {
+ """Name of the status"""
+ name: String!
+}
+
+"""
+Event emitted when a transaction status changes (for Subscriptions).
+"""
+type TransactionStatusChangedEvent {
+ """The updated transaction"""
+ transaction: Transaction!
+
+ """Previous status"""
+ previousStatus: String!
+
+ """New status"""
+ newStatus: String!
+
+ """Date and time of the change"""
+ changedAt: DateTime!
+}
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;
+ }
+}