diff --git a/.gitignore b/.gitignore
index 67045665db..c956dd9e5c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,104 +1,99 @@
-# Logs
-logs
-*.log
+# IDEs and Editors
+# IntelliJ IDEA
+.idea/
+*.iml
+*.iws
+*.ipr
+
+# Cursor
+.cursor/
+
+# VSCode
+.vscode/
+
+# Eclipse
+.project
+.classpath
+.settings/
+.metadata/
+
+# NetBeans
+nbproject/
+nbbuild/
+dist/
+nbdist/
+.nb-gradle/
+
+# Sublime Text
+*.sublime-project
+*.sublime-workspace
+
+# Vim
+*.swp
+*.swo
+*~
+
+# Node.js
+node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-lerna-debug.log*
-
-# Diagnostic reports (https://nodejs.org/api/report.html)
-report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-*.lcov
-
-# nyc test coverage
-.nyc_output
-
-# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-bower_components
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules/
-jspm_packages/
+.pnpm-debug.log*
-# TypeScript v1 declaration files
-typings/
-
-# TypeScript cache
-*.tsbuildinfo
-
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Microbundle cache
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# dotenv environment variables file
+# Environment variables
.env
-.env.test
-
-# parcel-bundler cache (https://parceljs.org/)
-.cache
-
-# Next.js build output
-.next
-
-# Nuxt.js build / generate output
-.nuxt
-dist
+.env.local
+.env.*.local
-# Gatsby files
-.cache/
-# Comment in the public line in if your project uses Gatsby and *not* Next.js
-# https://nextjs.org/blog/next-9-1#public-directory-support
-# public
+# Operating System Files
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
-# vuepress build output
-.vuepress/dist
+# Windows
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+$RECYCLE.BIN/
-# Serverless directories
-.serverless/
+# Linux
+*~
-# FuseBox cache
-.fusebox/
-
-# DynamoDB Local files
-.dynamodb/
+# Logs
+logs/
+*.log
-# TernJS port file
-.tern-port
+# Build directories
+dist/
+build/
+out/
+
+# Gradle
+.gradle/
+gradle/
+!gradle/wrapper/gradle-wrapper.jar
+gradlew
+gradlew.bat
+gradle-wrapper.jar
+gradle-wrapper.properties
+
+# Java build artifacts
+*.class
+*.jar
+*.war
+*.ear
+*.nar
+hs_err_pid*
+
+# Maven
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+.mvn/wrapper/maven-wrapper.jar
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000..d85aec8c55
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,7 @@
+FROM confluentinc/cp-kafka-connect:7.5.0
+
+RUN confluent-hub install --no-prompt debezium/debezium-connector-postgresql:2.4.2
+
+RUN ls -la /usr/share/confluent-hub-components/
+
+USER appuser
\ No newline at end of file
diff --git a/README.md b/README.md
index b067a71026..2e7e1c74b8 100644
--- a/README.md
+++ b/README.md
@@ -1,82 +1,414 @@
-# Yape Code Challenge :rocket:
+# Yape Code Challenge - Guia de Ejecucion :rocket:
-Our code challenge will let you marvel us with your Jedi coding skills :smile:.
+## Análisis y Diseño de la Solución
-Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !!
+### Diagrama de Secuencia
-- [Problem](#problem)
-- [Tech Stack](#tech_stack)
-- [Send us your challenge](#send_us_your_challenge)
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Cliente as CLIENTE
+ participant UX as MS-UX-ORCHESTRATOR
+ participant MS_T as MS-NE-TRANSACTION-SERVICE
+ participant DB as DB (PostgreSQL)
+ participant KC as Kafka Connect (Debezium)
+ participant K as EVENTBUS (Kafka)
+ participant AF as MS-SP-ANTIFRAUD-RULES
+ participant R as MEMORY STORAGE (Redis)
+
+ Note over Cliente, AF: FLUJO DE CREACIÓN (ESCRITURA)
+ Cliente->>UX: POST /transaction
+ UX->>MS_T: Petición de Creación
+ MS_T->>DB: Guardar Transacción (Status: PENDING)
+ DB-->>MS_T: Confirmación de Persistencia
+ MS_T-->>UX: 201 Created (Status: PENDING)
+ UX-->>Cliente: Respuesta (PENDING)
-# Problem
+ Note over DB, AF: PROCESAMIENTO ASÍNCRONO Y VALIDACIÓN
+ DB->>KC: Captura de cambios (CDC) desde logs (WAL)
+ KC->>K: Publicar evento 'transaction.created'
+ K->>AF: Consumir evento
+ AF->>AF: Validar (¿Monto > 1000?)
+ AF->>K: Publicar 'transaction.validated' (APPROVED/REJECTED)
-Every time a financial transaction is created it must be validated by our anti-fraud microservice and then the same service sends a message back to update the transaction status.
-For now, we have only three transaction statuses:
+ Note over K, R: CONSUMO Y CIERRE (PERSISTENCIA FINAL)
+ K->>MS_T: Consumidor recibe resultado final
+
+ par Actualización y Caché
+ MS_T->>DB: Actualizar Estado de Transacción
+ MS_T->>R: SET transaction:{id} (Status Final)
+ end
-
- - pending
- - approved
- - rejected
-
+ Note over Cliente, R: CONSULTA DE ALTO VOLUMEN (LECTURA)
+ Cliente->>UX: GET /transaction/{id}
+ UX->>MS_T: Obtener estado de transacción
+ MS_T->>R: GET transaction:{id}
+
+ alt Existe en Redis
+ R-->>MS_T: Retornar Estado (Camino Rápido)
+ else No existe en Redis
+ MS_T->>DB: Buscar por ID (Camino de Respaldo - PENDING)
+ DB-->>MS_T: Retornar Registro
+ end
+
+ MS_T-->>UX: Retornar Datos
+ UX-->>Cliente: 200 OK (Status Final)
+```
-Every transaction with a value greater than 1000 should be rejected.
+### Diagrama de Casos de Uso
```mermaid
- flowchart LR
- Transaction -- Save Transaction with pending Status --> transactionDatabase[(Database)]
- Transaction --Send transaction Created event--> Anti-Fraud
- Anti-Fraud -- Send transaction Status Approved event--> Transaction
- Anti-Fraud -- Send transaction Status Rejected event--> Transaction
- Transaction -- Update transaction Status event--> transactionDatabase[(Database)]
+graph TD
+ %% Definición de Actores
+ subgraph Actor
+ A1[Usuario]
+ end
+ subgraph Actor Sistema
+ A2[Sistema de Colas / Kafka]
+ end
+
+ %% Definición del Sistema
+ subgraph "Sistema antifraude"
+ UC1((UC1: Crear Transacción))
+ UC2((UC2: Actualizar Transacción))
+ UC3((UC3: Obtener Transacción))
+ end
+
+ %% Relaciones
+ A1 ---> UC1
+ A1 ---> UC3
+
+ UC1 -.-> A2
+ A2 ---> UC2
+```
+
+#### Descripción de Casos de Uso
+
+**UC1 - Crear Transacción**
+- **Actor Principal:** Cliente
+- **Descripción:** Permite crear una nueva transacción financiera con estado inicial PENDING
+- **Flujo Principal:**
+ 1. Cliente envía datos de transacción (cuentas débito/crédito, tipo, monto)
+ 2. Sistema valida los datos de entrada
+ 3. Sistema persiste transacción en PostgreSQL con estado PENDING
+ 4. Sistema responde con ID de transacción y estado PENDING
+ 5. CDC captura el cambio y lo envía a Kafka para validación asíncrona
+
+**UC2 - Actualizar Transacción**
+- **Actor Principal:** Antifraud Service (Sistema)
+- **Descripción:** Actualiza el estado de una transacción basado en validación de riesgos
+- **Flujo Principal:**
+ 1. Antifraud Service consume evento de transacción creada
+ 2. Sistema valida reglas de negocio (monto > 1000)
+ 3. Sistema determina estado (APPROVED/REJECTED)
+ 4. Sistema publica resultado en Kafka
+ 5. Transaction Service consume resultado
+ 6. Sistema actualiza PostgreSQL y Redis
+
+**UC3 - Obtener Transacción**
+- **Actor Principal:** Cliente
+- **Descripción:** Permite consultar el estado actual de una transacción por ID
+- **Flujo Principal:**
+ 1. Cliente solicita transacción por ID
+ 2. Sistema busca en Redis (cache)
+ 3. Si existe en cache, retorna inmediatamente
+ 4. Si no existe, busca en PostgreSQL
+ 5. Sistema actualiza cache con resultado de BD
+ 6. Sistema responde con datos de transacción
+
+## Arquitectura y Patrones
+
+### Arquitectura Hexagonal (Ports & Adapters)
+
+Cada microservicio implementa arquitectura hexagonal para lograr:
+
+- **Separación de Responsabilidades:** Dominio desacoplado de infraestructura
+- **Testabilidad:** Lógica de negocio independiente de frameworks
+- **Flexibilidad:** Fácil cambio de tecnologías sin afectar el core
+
+```
+┌────────────────────────────────────────────────────────────┐
+│ APPLICATION LAYER │
+│ ┌──────────────────────────────────────────────────────┐ │
+│ │ Controllers / REST Endpoints │ │
+│ │ (Entry Point - Adapters) │ │
+│ └────────────────┬─────────────────────────────────────┘ │
+│ │ │
+│ ┌────────────────▼─────────────────────────────────────┐ │
+│ │ SERVICE LAYER (Ports) │ │
+│ │ ┌────────────────────────────────────────────────┐ │ │
+│ │ │ Use Cases (Business Logic) │ │ │
+│ │ │ - CreateTransactionUseCase │ │ │
+│ │ │ - GetTransactionUseCase │ │ │
+│ │ │ - UpdateTransactionStatusUseCase │ │ │
+│ │ └────────────────────────────────────────────────┘ │ │
+│ └────────────────┬─────────────────────────────────────┘ │
+│ │ │
+│ ┌────────────────▼─────────────────────────────────────┐ │
+│ │ DOMAIN LAYER (Core) │ │
+│ │ ┌────────────────────────────────────────────────┐ │ │
+│ │ │ Entities, Value Objects, Domain Services │ │ │
+│ │ │ - Transaction (Aggregate Root) │ │ │
+│ │ │ - TransactionStatus (Enum) │ │ │
+│ │ │ - TransactionType (Value Object) │ │ │
+│ │ └────────────────────────────────────────────────┘ │ │
+│ └────────────────┬─────────────────────────────────────┘ │
+│ │ │
+│ ┌────────────────▼─────────────────────────────────────┐ │
+│ │ INFRASTRUCTURE LAYER (Adapters) │ │
+│ │ ┌───────────────┐ ┌──────────────┐ ┌──────────┐ │ │
+│ │ │ JPA Repos │ │ Redis Cache │ │ Kafka │ │ │
+│ │ │ (PostgreSQL) │ │ Adapter │ │ Consumer│ │ │
+│ │ └───────────────┘ └──────────────┘ └──────────┘ │ │
+│ └──────────────────────────────────────────────────────┘ │
+└────────────────────────────────────────────────────────────┘
+```
+
+### Patrones Implementados
+
+#### 1. **Event-Driven Architecture (EDA)**
+- Comunicación asíncrona entre microservicios vía Kafka
+- Desacoplamiento temporal y espacial
+- Escalabilidad independiente de componentes
+
+#### 2. **Change Data Capture (CDC)**
+- Debezium captura cambios en PostgreSQL sin tocar código
+- Transactional Outbox Pattern implícito
+- Garantía de entrega de eventos
+
+#### 3. **CQRS (Command Query Responsibility Segregation)**
+- **Command:** Creación de transacciones → PostgreSQL
+- **Query:** Consulta de transacciones → Redis (cache) + PostgreSQL (fallback)
+- Optimización de lecturas de alto volumen
+
+#### 4. **Cache-Aside Pattern**
+- Redis actúa como cache de lectura
+- Actualización lazy: solo cuando no existe en cache
+- TTL configurado para evitar datos obsoletos
+
+#### 5. **Saga Pattern (Orquestación)**
+- Orquestador coordina flujo entre servicios
+- Manejo de transacciones distribuidas
+- Rollback compensatorio en caso de fallo
+
+#### 6. **Repository Pattern**
+- Abstracción de acceso a datos
+- Interfaces (Ports) en capa de dominio
+- Implementaciones (Adapters) en infraestructura
+
+#### 7. **API Gateway Pattern**
+- MS-UX-Orchestrator actúa como punto único de entrada
+- Composición de respuestas de múltiples servicios
+- Circuit Breaker y Timeout configurados
+
+## Tecnologías Usadas
+
+### Backend Framework & Language
+
+| Tecnología | Versión | Uso |
+|------------|---------|-----|
+| **Java** | 21 (LTS) | Lenguaje principal con Pattern Matching, Records, Switch Expressions |
+| **Spring Boot** | 3.4.1 | Framework de microservicios |
+| **Spring WebFlux** | 3.4.1 | Programación reactiva (Orchestrator) |
+| **Spring Data JPA** | 3.4.1 | ORM para PostgreSQL |
+| **Spring Cloud Stream** | 4.2.0 | Integración con Kafka |
+| **Spring Kafka** | 3.3.0 | Consumer/Producer de Kafka |
+| **Gradle** | 8.5 | Gestión de dependencias y build |
+
+### Persistencia & Cache
+
+| Tecnología | Versión | Uso |
+|------------|---------|-----|
+| **PostgreSQL** | 16 | Base de datos relacional principal |
+| **Flyway** | 10.x | Migraciones de base de datos |
+| **Redis** | 7.2 | Cache in-memory para lecturas de alto volumen |
+
+### Event Streaming & CDC
+
+| Tecnología | Versión | Uso |
+|------------|---------|-----|
+| **Apache Kafka** | 3.6.x | Event Bus para comunicación asíncrona |
+| **Debezium** | 2.4.2 | Change Data Capture desde PostgreSQL |
+| **Kafka Connect** | 7.5.0 | Plataforma de conectores (Confluent) |
+| **Confluent Control Center** | 7.5.0 | Monitoreo de Kafka |
+
+### Containerización & Orquestación
+
+| Tecnología | Versión | Uso |
+|------------|---------|-----|
+| **Docker** | 24.x | Containerización de aplicaciones |
+| **Docker Compose** | 2.x | Orquestación local multi-container |
+| **Alpine Linux** | 3.x | Imágenes base ligeras |
+
+
+
+## Ejecución Completa con Un Solo Comando
+
+```bash
+docker-compose up -d --build
+```
+
+Este comando ejecutará automáticamente:
+
+1. ✅ **Compilación de proyectos Gradle**
+ - Transaction Service (con Flyway migrations)
+ - Orchestrator Service
+ - Antifraud Service
+
+2. ✅ **Levantamiento de toda la infraestructura**
+ - PostgreSQL con CDC habilitado
+ - Redis
+ - Kafka + Zookeeper
+ - Kafka Connect con Debezium
+ - Control Center
+ - 3 Microservicios
+
+## Orden de Inicio
+
+```
+1. postgres, redis, zookeeper
+2. kafka
+3. transaction-service (ejecuta migraciones Flyway)
+4. orchestrator-service
+5. antifraud-service
+6. connect (Debezium CDC - registra conector automáticamente)
+7. control-center
```
-# Tech Stack
+## Verificar que Todo Funciona
-
- - Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
- - Any database
- - Kafka
-
+### 1. Ver logs de compilación
+```bash
+docker-compose logs -f antifraud-service
+```
-We do provide a `Dockerfile` to help you get started with a dev environment.
+### 2. Verificar servicios activos
+```bash
+docker-compose ps
+```
-You must have two resources:
+### 3. Health checks
+```bash
+curl http://localhost:9992/actuator/health
+curl http://localhost:9991/actuator/health
+curl http://localhost:9993/actuator/health
+```
-1. Resource to create a transaction that must containt:
+### 4. Verificar conector Debezium
+```bash
+curl http://localhost:8083/connectors/yape-transaction-connector/status
+```
-```json
-{
- "accountExternalIdDebit": "Guid",
- "accountExternalIdCredit": "Guid",
- "tranferTypeId": 1,
- "value": 120
-}
+### 5. Verificar tópicos de Kafka
+```bash
+docker exec -it yape-kafka kafka-topics --bootstrap-server localhost:9092 --list
```
-2. Resource to retrieve a transaction
+## Probar el Flujo Completo
-```json
-{
- "transactionExternalId": "Guid",
- "transactionType": {
- "name": ""
- },
- "transactionStatus": {
- "name": ""
- },
- "value": 120,
- "createdAt": "Date"
-}
+### 1. Crear una transacción
+```bash
+curl -X POST http://localhost:9991/transaction \
+ -H "Content-Type: application/json" \
+ -d '{
+ "accountExternalIdDebit": "550e1234-e29b-41d4-a716-446655445556",
+ "accountExternalIdCredit": "661f9321-f30c-52e5-b827-557766551111",
+ "tranferTypeId": 1,
+ "value": 1050.50
+ }'
```
-## Optional
+### 2. Consultar estado (transactionExternalId es obtenido de la respuesta de crear transaccion)
+```bash
+curl curl -X GET http://localhost:9991/transaction/{transactionExternalId} \
+ -H "Accept: application/json" \
+ -H "Content-Type: application/json"
+```
+
+### 3. Ver eventos en Kafka
+```bash
+docker exec -it yape-kafka kafka-console-consumer \
+ --bootstrap-server localhost:9092 \
+ --topic yape.public.transactions \
+ --from-beginning
+
+docker exec -it yape-kafka kafka-console-consumer \
+ --bootstrap-server localhost:9092 \
+ --topic transaction.validated \
+ --from-beginning
+```
-You can use any approach to store transaction data but you should consider that we may deal with high volume scenarios where we have a huge amount of writes and reads for the same data at the same time. How would you tackle this requirement?
-You can use Graphql;
+## Puertos Expuestos
-# Send us your challenge
+| Servicio | Puerto | URL |
+|----------|--------|-----|
+| PostgreSQL | 5432 | jdbc:postgresql://localhost:5432/yape_db |
+| Redis | 6379 | localhost:6379 |
+| Kafka | 9092 | localhost:9092 |
+| Kafka Connect | 8083 | http://localhost:8083 |
+| Control Center | 9021 | http://localhost:9021 |
+| Transaction Service | 9992 | http://localhost:9992 |
+| Orchestrator | 9991 | http://localhost:9991 |
+| Antifraud | 9993 | http://localhost:9993 |
+
+## Comandos Útiles
+
+### Reconstruir todo desde cero
+```bash
+docker-compose down -v
+docker-compose up -d --build
+```
+
+### Ver logs en tiempo real
+```bash
+docker-compose logs -f
+
+docker-compose logs -f antifraud-service
+docker-compose logs -f transaction-service
+docker-compose logs -f connect
+```
+
+
+### Detener y eliminar volúmenes (datos)
+```bash
+docker-compose down -v
+```
+
+
+
+## Notas Importantes
+
+- ⚠️ La primera compilación tardará más (descarga dependencias)
+- ⚠️ Transaction Service ejecuta Flyway migrations automáticamente
+- ⚠️ Debezium registra el conector automáticamente al iniciar
+- ⚠️ Las transacciones se crean con estado PENDING y se actualizan asíncronamente
+- ⚠️ Redis actúa como cache, PostgreSQL es la fuente de verdad
+
+## Arquitectura Multi-Stage Build
+
+```
+┌─────────────────────────────────────┐
+│ Stage 1: Builder (JDK + Gradle) │
+│ - Copia código fuente │
+│ - Ejecuta gradle clean build │
+│ - Ejecuta tests │
+│ - Crea JAR ejecutable │
+└──────────────┬──────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────┐
+│ Stage 2: Runtime (JRE Alpine) │
+│ - Copia solo el JAR │
+│ - Instala curl (healthchecks) │
+│ - Imagen ligera (~150MB) │
+│ - Lista para ejecutar │
+└─────────────────────────────────────┘
+```
-When you finish your challenge, after forking a repository, you **must** open a pull request to our repository. There are no limitations to the implementation, you can follow the programming paradigm, modularization, and style that you feel is the most appropriate solution.
+**Ventajas:**
+- ✅ No necesitas tener Java ni Gradle instalados localmente
+- ✅ Build reproducible en cualquier entorno
+- ✅ Imágenes finales más pequeñas (solo JRE, no JDK)
+- ✅ Todo se compila dentro de Docker de forma aislada
-If you have any questions, please let us know.
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..602e83720d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,25 +1,261 @@
version: "3.7"
+
services:
postgres:
- image: postgres:14
+ image: postgres:16-alpine
+ container_name: yape-postgres-db
ports:
- "5432:5432"
environment:
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=postgres
+ POSTGRES_USER: yape_user
+ POSTGRES_PASSWORD: yape_password
+ POSTGRES_DB: yape_db
+ command: ["postgres", "-c", "wal_level=logical"]
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U yape_user -d yape_db"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - yape-network
+
+ redis:
+ image: redis:7-alpine
+ container_name: yape-redis
+ ports:
+ - "6379:6379"
+ networks:
+ - yape-network
+
+ transaction-service:
+ build:
+ context: ./ms-ne-transaction-service
+ dockerfile: Dockerfile
+ container_name: yape-ne-transaction-service
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_started
+ kafka:
+ condition: service_started
+ ports:
+ - "9992:9992"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/yape_db
+ SPRING_DATASOURCE_USERNAME: yape_user
+ SPRING_DATASOURCE_PASSWORD: yape_password
+ SPRING_DATA_REDIS_HOST: redis
+ SPRING_DATA_REDIS_PORT: 6379
+ SPRING_FLYWAY_ENABLED: "true"
+ SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:29092
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:9992/actuator/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - yape-network
+
+ orchestrator-service:
+ build:
+ context: ./ms-ux-orchestrator
+ dockerfile: Dockerfile
+ container_name: yape-ux-orchestrator
+ depends_on:
+ transaction-service:
+ condition: service_healthy
+ ports:
+ - "9991:9991"
+ environment:
+ SERVICES_TRANSACTION_BASE_URL: http://transaction-service:9992
+ SERVICES_TRANSACTION_TIMEOUT: 5000
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:9991/actuator/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - yape-network
+
zookeeper:
- image: confluentinc/cp-zookeeper:5.5.3
+ image: confluentinc/cp-zookeeper:7.5.0
+ container_name: yape-zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
+ ZOOKEEPER_TICK_TIME: 2000
+ ZOOKEEPER_SYNC_LIMIT: 2
+ networks:
+ - yape-network
+
kafka:
- image: confluentinc/cp-enterprise-kafka:5.5.3
- depends_on: [zookeeper]
+ image: confluentinc/cp-enterprise-kafka:7.5.0
+ container_name: yape-kafka
+ depends_on:
+ - zookeeper
+ ports:
+ - "9092:9092"
environment:
- KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
+ KAFKA_BROKER_ID: 1
+ KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
+ KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,PLAINTEXT_HOST://0.0.0.0:9092
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_JMX_PORT: 9991
+ healthcheck:
+ test: ["CMD", "kafka-topics", "--bootstrap-server", "localhost:9092", "--list"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ networks:
+ - yape-network
+
+ connect:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: yape-connect
+ depends_on:
+ kafka:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+ transaction-service:
+ condition: service_healthy
+ ports:
+ - "8083:8083"
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8083/connector-plugins"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+ environment:
+ CONNECT_BOOTSTRAP_SERVERS: 'kafka:29092'
+ CONNECT_REST_PORT: 8083
+ CONNECT_REST_ADVERTISED_HOST_NAME: "connect"
+ CONNECT_GROUP_ID: "yape-connect-group"
+ CONNECT_CONFIG_STORAGE_TOPIC: "yape-connect-configs"
+ CONNECT_OFFSET_STORAGE_TOPIC: "yape-connect-offsets"
+ CONNECT_STATUS_STORAGE_TOPIC: "yape-connect-status"
+ CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: "1"
+ CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: "1"
+ CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: "1"
+ CONNECT_KEY_CONVERTER: org.apache.kafka.connect.storage.StringConverter
+ CONNECT_VALUE_CONVERTER: org.apache.kafka.connect.json.JsonConverter
+ CONNECT_INTERNAL_KEY_CONVERTER: "org.apache.kafka.connect.json.JsonConverter"
+ CONNECT_INTERNAL_VALUE_CONVERTER: "org.apache.kafka.connect.json.JsonConverter"
+ CONNECT_PLUGIN_PATH: "/usr/share/java,/usr/share/confluent-hub-components"
+ networks:
+ - yape-network
+ command:
+ - /bin/bash
+ - -c
+ - |
+ echo "Iniciando Kafka Connect..."
+ /etc/confluent/docker/run &
+
+ echo "Esperando a que Kafka Connect esté listo..."
+ # Primero esperamos a que el servicio esté disponible
+ until curl -s -o /dev/null -w "%{http_code}" localhost:8083 | grep -q "200\|404"; do
+ echo "Kafka Connect no está listo todavía... (reintentando en 5s)"
+ sleep 5
+ done
+
+ echo "Kafka Connect está listo. Esperando a que cargue los plugins..."
+ sleep 10
+
+ # Verificar que el plugin de Debezium esté disponible
+ MAX_RETRIES=20
+ RETRY_COUNT=0
+ until curl -s localhost:8083/connector-plugins | grep -q "PostgresConnector"; do
+ RETRY_COUNT=$((RETRY_COUNT+1))
+ if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
+ echo "ERROR: No se pudo detectar el plugin de Debezium después de $MAX_RETRIES intentos"
+ echo "Plugins disponibles:"
+ curl -s localhost:8083/connector-plugins | jq '.[].class' || curl -s localhost:8083/connector-plugins
+ exit 1
+ fi
+ echo "Todavía no detecto el plugin de Debezium... (intento $RETRY_COUNT/$MAX_RETRIES, reintentando en 5s)"
+ sleep 5
+ done
+
+ echo "¡Plugin de Debezium detectado! Registrando conector..."
+ curl -i -X POST -H "Content-Type: application/json" \
+ -d '{
+ "name": "yape-transaction-connector",
+ "config": {
+ "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
+ "tasks.max": "1",
+ "database.hostname": "postgres",
+ "database.port": "5432",
+ "database.user": "yape_user",
+ "database.password": "yape_password",
+ "database.dbname": "yape_db",
+ "topic.prefix": "yape",
+ "plugin.name": "pgoutput",
+ "table.include.list": "public.transactions",
+ "publication.autocreate.mode": "all_tables",
+ "slot.name": "yape_slot",
+ "transforms": "unwrap",
+ "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
+ "transforms.unwrap.drop.tombstones": "false",
+ "transforms.unwrap.delete.handling.mode": "rewrite",
+ "transforms.unwrap.add.fields": "op,source.ts_ms",
+ "decimal.handling.mode": "double",
+ "key.converter": "org.apache.kafka.connect.json.JsonConverter",
+ "key.converter.schemas.enable": "false",
+ "value.converter": "org.apache.kafka.connect.json.JsonConverter",
+ "value.converter.schemas.enable": "false",
+ "schema.history.internal.kafka.bootstrap.servers": "kafka:29092",
+ "schema.history.internal.kafka.topic": "yape.schema-changes.transactions"
+ }
+ }' http://localhost:8083/connectors
+
+ echo "Conector registrado. Kafka Connect continuará ejecutándose..."
+ wait
+
+ antifraud-service:
+ build:
+ context: ./ms-sp-antifraud-rules
+ dockerfile: Dockerfile
+ container_name: yape-sp-antifraud
+ depends_on:
+ kafka:
+ condition: service_healthy
+ connect:
+ condition: service_healthy
ports:
- - 9092:9092
+ - "9993:9993"
+ environment:
+ SPRING_CLOUD_STREAM_KAFKA_BINDER_BROKERS: kafka:29092
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:9993/actuator/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - yape-network
+
+ control-center:
+ image: confluentinc/cp-enterprise-control-center:7.5.0
+ container_name: yape-control-center
+ depends_on:
+ kafka:
+ condition: service_healthy
+ ports:
+ - "9021:9021"
+ environment:
+ CONTROL_CENTER_BOOTSTRAP_SERVERS: kafka:29092
+ CONTROL_CENTER_REPLICATION_FACTOR: 1
+ PORT: 9021
+ networks:
+ - yape-network
+
+networks:
+ yape-network:
+ driver: bridge
diff --git a/ms-ne-transaction-service/Dockerfile b/ms-ne-transaction-service/Dockerfile
new file mode 100644
index 0000000000..d58188ec55
--- /dev/null
+++ b/ms-ne-transaction-service/Dockerfile
@@ -0,0 +1,22 @@
+# Stage 1: Build
+FROM gradle:8.5-jdk21-alpine AS builder
+
+WORKDIR /workspace/app
+
+COPY build.gradle settings.gradle ./
+COPY src src
+
+RUN gradle clean build -x test --no-daemon
+
+# Stage 2: Runtime
+FROM eclipse-temurin:21-jre-alpine
+
+RUN apk add --no-cache curl
+
+WORKDIR /app
+
+COPY --from=builder /workspace/app/build/libs/*.jar app.jar
+
+EXPOSE 9992
+
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/ms-ne-transaction-service/build.gradle b/ms-ne-transaction-service/build.gradle
new file mode 100644
index 0000000000..8b4be31c09
--- /dev/null
+++ b/ms-ne-transaction-service/build.gradle
@@ -0,0 +1,90 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.4.1'
+ id 'io.spring.dependency-management' version '1.1.7'
+ id 'org.flywaydb.flyway' version '10.21.0'
+}
+
+group = 'pe.com.yape'
+version = '1.0.0'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+ext {
+ springCloudVersion = '2024.0.0'
+}
+
+dependencies {
+ // Spring Boot
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+
+ // Spring Cloud Stream - Kafka
+ implementation 'org.springframework.cloud:spring-cloud-starter-stream-kafka'
+ implementation 'org.springframework.kafka:spring-kafka'
+
+ // Postgres
+ runtimeOnly 'org.postgresql:postgresql'
+
+ // Flyway
+ implementation 'org.flywaydb:flyway-core'
+ implementation 'org.flywaydb:flyway-database-postgresql'
+
+ // Redis
+ implementation 'redis.clients:jedis'
+
+ // Lombok
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+
+ // Dev Tools
+ developmentOnly 'org.springframework.boot:spring-boot-devtools'
+
+ // Testing
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.kafka:spring-kafka-test'
+ testImplementation 'org.mockito:mockito-core'
+ testImplementation 'org.mockito:mockito-junit-jupiter'
+ testImplementation 'com.h2database:h2'
+ testImplementation 'it.ozimov:embedded-redis:0.7.3'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+dependencyManagement {
+ imports {
+ mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
+ }
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
+
+tasks.named('bootJar') {
+ archiveFileName.set("${project.name}-${version}.jar")
+}
+
+flyway {
+ url = 'jdbc:postgresql://localhost:5432/transactiondb'
+ user = 'yape'
+ password = 'yape123'
+ locations = ['classpath:db/migration']
+}
+
diff --git a/ms-ne-transaction-service/settings.gradle b/ms-ne-transaction-service/settings.gradle
new file mode 100644
index 0000000000..305e04782d
--- /dev/null
+++ b/ms-ne-transaction-service/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = 'ms-ne-transaction-service'
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/TransactionServiceApplication.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/TransactionServiceApplication.java
new file mode 100644
index 0000000000..bcc1568544
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/TransactionServiceApplication.java
@@ -0,0 +1,21 @@
+package pe.com.yape.ms.transaction;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+/**
+ * MS-NE-TRANSACTION-SERVICE
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@SpringBootApplication
+@EnableJpaAuditing
+public class TransactionServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(TransactionServiceApplication.class, args);
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/CacheManagementEndpoint.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/CacheManagementEndpoint.java
new file mode 100644
index 0000000000..883a3411b0
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/CacheManagementEndpoint.java
@@ -0,0 +1,34 @@
+package pe.com.yape.ms.transaction.application.config;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
+import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
+import org.springframework.stereotype.Component;
+import pe.com.yape.ms.transaction.service.port.CacheRepositoryPort;
+
+/**
+ * Endpoint de Actuator para gestionar el cache
+ * @author lmarusic
+ */
+@Component
+@Endpoint(id = "cache")
+@RequiredArgsConstructor
+@Slf4j
+public class CacheManagementEndpoint {
+
+ private final CacheRepositoryPort cacheRepository;
+
+ /**
+ * Limpia todo el cache de transacciones
+ *
+ * Uso: POST /actuator/cache/clear
+ */
+ @WriteOperation
+ public String clear() {
+ log.info("Clearing all transaction cache via actuator endpoint");
+ cacheRepository.clear();
+ return "Cache cleared successfully";
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/KafkaConsumerConfig.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/KafkaConsumerConfig.java
new file mode 100644
index 0000000000..52fb786fec
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/KafkaConsumerConfig.java
@@ -0,0 +1,46 @@
+package pe.com.yape.ms.transaction.application.config;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+
+/**
+ * Configuración de Kafka Consumer
+ * @author lmarusic
+ */
+@Configuration
+@EnableKafka
+public class KafkaConsumerConfig {
+
+ @Value("${spring.kafka.bootstrap-servers:localhost:9092}")
+ private String bootstrapServers;
+
+ @Bean
+ public ConsumerFactory consumerFactory() {
+ Map props = new HashMap<>();
+ props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ConsumerConfig.GROUP_ID_CONFIG, "transaction-service-consumer-group");
+ props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+ props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
+ return new DefaultKafkaConsumerFactory<>(props);
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() {
+ ConcurrentKafkaListenerContainerFactory factory =
+ new ConcurrentKafkaListenerContainerFactory<>();
+ factory.setConsumerFactory(consumerFactory());
+ return factory;
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/RedisConfig.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/RedisConfig.java
new file mode 100644
index 0000000000..b264eb3279
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/RedisConfig.java
@@ -0,0 +1,33 @@
+package pe.com.yape.ms.transaction.application.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+/**
+ * Configuracion de Redis para cache
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Configuration
+public class RedisConfig {
+
+ @Bean
+ public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setConnectionFactory(connectionFactory);
+
+ StringRedisSerializer serializer = new StringRedisSerializer();
+ template.setKeySerializer(serializer);
+ template.setValueSerializer(serializer);
+ template.setHashKeySerializer(serializer);
+ template.setHashValueSerializer(serializer);
+
+ template.afterPropertiesSet();
+ return template;
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/consumer/TransactionValidationConsumer.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/consumer/TransactionValidationConsumer.java
new file mode 100644
index 0000000000..eb6b9dce91
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/consumer/TransactionValidationConsumer.java
@@ -0,0 +1,97 @@
+package pe.com.yape.ms.transaction.application.consumer;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+import pe.com.yape.ms.transaction.application.dto.TransactionValidatedEventDto;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.model.domain.TransactionStatus;
+import pe.com.yape.ms.transaction.service.port.CacheRepositoryPort;
+import pe.com.yape.ms.transaction.service.port.TransactionRepositoryPort;
+
+/**
+ * Consumer que escucha eventos de validación de transacciones desde Antifraud
+ * usando @KafkaListener
+ * @author lmarusic
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class TransactionValidationConsumer {
+
+ private final TransactionRepositoryPort transactionRepository;
+ private final CacheRepositoryPort cacheRepository;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ private static final long CACHE_TTL_SECONDS = 3600L;
+
+ @KafkaListener(
+ topics = "transaction.validated",
+ groupId = "transaction-service-consumer-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ public void consumeValidation(String payload) {
+ String transactionId = "unknown";
+ try {
+ log.debug("Raw payload received: {}", payload);
+
+ TransactionValidatedEventDto event = objectMapper.readValue(
+ payload,
+ TransactionValidatedEventDto.class
+ );
+
+ transactionId = event.transactionExternalId();
+ log.info("Received validation event for transaction: {} with status: {}",
+ transactionId, event.validationStatus());
+
+ UUID transactionUUID = UUID.fromString(transactionId);
+ final String txId = transactionId;
+ Transaction currentTransaction = transactionRepository
+ .findByExternalId(transactionUUID)
+ .orElseThrow(() -> {
+ log.warn("Transaction not found for validation: {}", txId);
+ return new RuntimeException("Transaction not found: " + txId);
+ });
+
+ if (!currentTransaction.isPending()) {
+ log.warn("Transaction {} is not in PENDING state, current status: {}. Skipping update.",
+ transactionId, currentTransaction.status());
+ return;
+ }
+
+ TransactionStatus newStatus = mapValidationStatus(event.validationStatus());
+
+ // Actualizar el estado de la transacción en PostgreSQL
+ Transaction updatedTransaction = currentTransaction.withStatus(newStatus);
+ Transaction savedTransaction = transactionRepository.update(updatedTransaction);
+
+ // Guardar en Redis
+ cacheRepository.save(savedTransaction, CACHE_TTL_SECONDS);
+
+ log.info("Transaction {} successfully updated to status: {} - Reason: {}",
+ transactionId, newStatus, event.validationReason());
+
+ } catch (Exception e) {
+ log.error("Error processing validation event for transaction {}: {}",
+ transactionId, e.getMessage(), e);
+ log.error("Stack trace: ", e);
+ }
+ }
+
+ private TransactionStatus mapValidationStatus(String validationStatus) {
+ if (validationStatus == null) {
+ throw new IllegalArgumentException("Validation status cannot be null");
+ }
+
+ return switch (validationStatus.toUpperCase()) {
+ case "APPROVED" -> TransactionStatus.APPROVED;
+ case "REJECTED" -> TransactionStatus.REJECTED;
+ default -> throw new IllegalArgumentException(
+ "Unknown validation status: " + validationStatus
+ );
+ };
+ }
+}
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/dto/TransactionValidatedEventDto.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/dto/TransactionValidatedEventDto.java
new file mode 100644
index 0000000000..cb73706ded
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/dto/TransactionValidatedEventDto.java
@@ -0,0 +1,26 @@
+package pe.com.yape.ms.transaction.application.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * DTO para recibir el evento de transacción validada desde Antifraud
+ * @author lmarusic
+ */
+public record TransactionValidatedEventDto(
+ @JsonProperty("transaction_external_id")
+ String transactionExternalId,
+
+ @JsonProperty("validation_status")
+ String validationStatus,
+
+ @JsonProperty("validation_reason")
+ String validationReason,
+
+ @JsonProperty("validated_at")
+ Long validatedAt,
+
+ @JsonProperty("transaction_value")
+ Double transactionValue
+) {
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/GlobalExceptionHandler.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000000..4eb01dc173
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/GlobalExceptionHandler.java
@@ -0,0 +1,68 @@
+package pe.com.yape.ms.transaction.application.exception;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import pe.com.yape.ms.transaction.model.exception.ErrorResponse;
+
+/**
+ * Manejador global de excepciones para el ms
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(TransactionNotFoundException.class)
+ public ResponseEntity handleTransactionNotFound(TransactionNotFoundException ex) {
+ ErrorResponse error = new ErrorResponse(
+ HttpStatus.NOT_FOUND.value(),
+ ex.getMessage(),
+ LocalDateTime.now()
+ );
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) {
+ ErrorResponse error = new ErrorResponse(
+ HttpStatus.BAD_REQUEST.value(),
+ ex.getMessage(),
+ LocalDateTime.now()
+ );
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationErrors(MethodArgumentNotValidException ex) {
+ Map errors = new HashMap<>();
+ ex.getBindingResult().getFieldErrors().forEach(error ->
+ errors.put(error.getField(), error.getDefaultMessage())
+ );
+
+ ErrorResponse error = new ErrorResponse(
+ HttpStatus.BAD_REQUEST.value(),
+ "Validation failed: " + errors,
+ LocalDateTime.now()
+ );
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGenericException(Exception ex) {
+ ErrorResponse error = new ErrorResponse(
+ HttpStatus.INTERNAL_SERVER_ERROR.value(),
+ "An unexpected error occurred: " + ex.getMessage(),
+ LocalDateTime.now()
+ );
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
+ }
+
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/TransactionNotFoundException.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/TransactionNotFoundException.java
new file mode 100644
index 0000000000..0edeb9510f
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/TransactionNotFoundException.java
@@ -0,0 +1,24 @@
+package pe.com.yape.ms.transaction.application.exception;
+
+import java.util.UUID;
+
+/**
+ * Excepción lanzada cuando una transacción no es encontrada
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public class TransactionNotFoundException extends RuntimeException {
+
+ private final UUID transactionExternalId;
+
+ public TransactionNotFoundException(UUID transactionExternalId) {
+ super(String.format("Transaction not found with ID: %s", transactionExternalId));
+ this.transactionExternalId = transactionExternalId;
+ }
+
+ public UUID getTransactionExternalId() {
+ return transactionExternalId;
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/TransactionController.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/TransactionController.java
new file mode 100644
index 0000000000..5407ae20f1
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/TransactionController.java
@@ -0,0 +1,81 @@
+package pe.com.yape.ms.transaction.controller;
+
+import jakarta.validation.Valid;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import pe.com.yape.ms.transaction.controller.dto.CreateTransactionRequest;
+import pe.com.yape.ms.transaction.controller.dto.TransactionResponse;
+import pe.com.yape.ms.transaction.controller.mapper.TransactionMapper;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.service.CreateTransactionUseCase;
+import pe.com.yape.ms.transaction.service.GetTransactionUseCase;
+
+/**
+ * Controlador REST
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@RestController
+@RequestMapping("/v1/transactions")
+@RequiredArgsConstructor
+@Slf4j
+public class TransactionController {
+
+ private final CreateTransactionUseCase createTransactionUseCase;
+ private final GetTransactionUseCase getTransactionUseCase;
+ private final TransactionMapper transactionMapper;
+
+ /**
+ * Crea la transaccion
+ * POST /v1/transactions
+ *
+ * @param request CreateTransactionRequest
+ * @return TransactionResponse (201 CREATED)
+ */
+ @PostMapping
+ public ResponseEntity createTransaction(
+ @Valid @RequestBody CreateTransactionRequest request ) {
+ log.info("Starting POST /v1/transactions - createTransaction method: {}", request);
+ Transaction transaction = createTransactionUseCase.execute(
+ request.getAccountExternalIdDebit(),
+ request.getAccountExternalIdCredit(),
+ request.getTranferTypeId(),
+ request.getValue()
+ );
+
+ TransactionResponse response = transactionMapper.toResponse(transaction);
+ log.info("Transaction created successfully: {}", response.getTransactionExternalId());
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ /**
+ * Obtiene una transaccion x ID
+ * GET /v1/transactions/{transactionExternalId}
+ *
+ * @param transactionExternalId ID de la transaccion
+ * @return TransactionResponse (200 OK)
+ */
+ @GetMapping("/{transactionExternalId}")
+ public ResponseEntity getTransaction(
+ @PathVariable UUID transactionExternalId ) {
+ log.info("Starting /v1/transactions/{} - getTransaction method", transactionExternalId);
+ Transaction transaction = getTransactionUseCase.execute(transactionExternalId);
+ TransactionResponse response = transactionMapper.toResponse(transaction);
+ log.info("Transaction retrieved successfully: {}", transactionExternalId);
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * Health check endpoint
+ */
+ @GetMapping("/health")
+ public ResponseEntity health() {
+ return ResponseEntity.ok("Transaction Service is UP");
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/CreateTransactionRequest.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/CreateTransactionRequest.java
new file mode 100644
index 0000000000..6a95111676
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/CreateTransactionRequest.java
@@ -0,0 +1,35 @@
+package pe.com.yape.ms.transaction.controller.dto;
+
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.util.UUID;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Objeto de request para crear una transaccion
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class CreateTransactionRequest {
+
+ @NotNull(message = "Account external ID debit is required")
+ private UUID accountExternalIdDebit;
+
+ @NotNull(message = "Account external ID credit is required")
+ private UUID accountExternalIdCredit;
+
+ @NotNull(message = "Transfer type ID is required")
+ private Integer tranferTypeId;
+
+ @NotNull(message = "Value is required")
+ @DecimalMin(value = "0.01", message = "Value must be greater than zero")
+ private BigDecimal value;
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionResponse.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionResponse.java
new file mode 100644
index 0000000000..256eab0c8a
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionResponse.java
@@ -0,0 +1,27 @@
+package pe.com.yape.ms.transaction.controller.dto;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Objeto de response para transacciones
+ * @author lmarusic
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TransactionResponse {
+
+ private UUID transactionExternalId;
+ private TransactionTypeDto transactionType;
+ private TransactionStatusDto transactionStatus;
+ private BigDecimal value;
+ private LocalDateTime createdAt;
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionStatusDto.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionStatusDto.java
new file mode 100644
index 0000000000..5a5100f07e
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionStatusDto.java
@@ -0,0 +1,19 @@
+package pe.com.yape.ms.transaction.controller.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * DTO para el estado de transacción
+ * @author lmarusic
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TransactionStatusDto {
+ private String name;
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionTypeDto.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionTypeDto.java
new file mode 100644
index 0000000000..53529fee2e
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionTypeDto.java
@@ -0,0 +1,19 @@
+package pe.com.yape.ms.transaction.controller.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * DTO para el tipo de transacción
+ * @author lmarusic
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TransactionTypeDto {
+ private String name;
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/mapper/TransactionMapper.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/mapper/TransactionMapper.java
new file mode 100644
index 0000000000..4b0cf32402
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/mapper/TransactionMapper.java
@@ -0,0 +1,33 @@
+package pe.com.yape.ms.transaction.controller.mapper;
+
+import org.springframework.stereotype.Component;
+import pe.com.yape.ms.transaction.controller.dto.TransactionResponse;
+import pe.com.yape.ms.transaction.controller.dto.TransactionStatusDto;
+import pe.com.yape.ms.transaction.controller.dto.TransactionTypeDto;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+
+/**
+ * Mapper para convertir entre objetos de dominio y DTOs
+ * @author lmarusic
+ */
+@Component
+public class TransactionMapper {
+
+ /**
+ * Convierte una transacción del dominio a DTO de respuesta
+ */
+ public TransactionResponse toResponse(Transaction transaction) {
+ return TransactionResponse.builder()
+ .transactionExternalId(transaction.transactionExternalId())
+ .transactionType(TransactionTypeDto.builder()
+ .name(transaction.transactionType().getName())
+ .build())
+ .transactionStatus(TransactionStatusDto.builder()
+ .name(transaction.status().getValue())
+ .build())
+ .value(transaction.value())
+ .createdAt(transaction.createdAt())
+ .build();
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/Transaction.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/Transaction.java
new file mode 100644
index 0000000000..ffbf702027
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/Transaction.java
@@ -0,0 +1,101 @@
+package pe.com.yape.ms.transaction.model.domain;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * Modelo de dominio que representa una transacción financiera
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public record Transaction(
+ UUID transactionExternalId,
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ TransactionType transactionType,
+ BigDecimal value,
+ TransactionStatus status,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+
+ public Transaction {
+ if (transactionExternalId == null) {
+ throw new IllegalArgumentException("Transaction external ID cannot be null");
+ }
+ if (accountExternalIdDebit == null) {
+ throw new IllegalArgumentException("Account external ID debit cannot be null");
+ }
+ if (accountExternalIdCredit == null) {
+ throw new IllegalArgumentException("Account external ID credit cannot be null");
+ }
+ if (transactionType == null) {
+ throw new IllegalArgumentException("Transaction type cannot be null");
+ }
+ if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new IllegalArgumentException("Transaction value must be greater than zero");
+ }
+ if (status == null) {
+ throw new IllegalArgumentException("Transaction status cannot be null");
+ }
+ if (createdAt == null) {
+ throw new IllegalArgumentException("Created at cannot be null");
+ }
+ }
+
+ public static Transaction createPending(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ TransactionType transactionType,
+ BigDecimal value
+ ) {
+ LocalDateTime now = LocalDateTime.now();
+ return new Transaction(
+ UUID.randomUUID(),
+ accountExternalIdDebit,
+ accountExternalIdCredit,
+ transactionType,
+ value,
+ TransactionStatus.PENDING,
+ now,
+ now
+ );
+ }
+
+ public Transaction withStatus(TransactionStatus newStatus) {
+ return new Transaction(
+ this.transactionExternalId,
+ this.accountExternalIdDebit,
+ this.accountExternalIdCredit,
+ this.transactionType,
+ this.value,
+ newStatus,
+ this.createdAt,
+ LocalDateTime.now()
+ );
+ }
+
+ /**
+ * Verifica si la transacción está en estado pendiente
+ */
+ public boolean isPending() {
+ return this.status == TransactionStatus.PENDING;
+ }
+
+ /**
+ * Verifica si la transacción fue aprobada
+ */
+ public boolean isApproved() {
+ return this.status == TransactionStatus.APPROVED;
+ }
+
+ /**
+ * Verifica si la transacción fue rechazada
+ */
+ public boolean isRejected() {
+ return this.status == TransactionStatus.REJECTED;
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionStatus.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionStatus.java
new file mode 100644
index 0000000000..54f8a5d9e2
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionStatus.java
@@ -0,0 +1,51 @@
+package pe.com.yape.ms.transaction.model.domain;
+
+/**
+ * Estado de una transacción en el sistema
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public enum TransactionStatus {
+ /**
+ * Transacción creada pero pendiente de validación anti-fraude
+ */
+ PENDING("pending"),
+
+ /**
+ * Transacción aprobada por el motor anti-fraude
+ */
+ APPROVED("approved"),
+
+ /**
+ * Transacción rechazada por el motor anti-fraude
+ */
+ REJECTED("rejected");
+
+ private final String value;
+
+ TransactionStatus(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ /**
+ * Obtiene el enum desde su valor string
+ *
+ * @param value valor del estado
+ * @return enum correspondiente
+ * @throws IllegalArgumentException si el valor no es válido
+ */
+ public static TransactionStatus fromValue(String value) {
+ for (TransactionStatus status : values()) {
+ if (status.value.equalsIgnoreCase(value)) {
+ return status;
+ }
+ }
+ throw new IllegalArgumentException("Invalid transaction status: " + value);
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionType.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionType.java
new file mode 100644
index 0000000000..d129248411
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionType.java
@@ -0,0 +1,55 @@
+package pe.com.yape.ms.transaction.model.domain;
+
+/**
+ * Tipos de transacciones financiera
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public enum TransactionType {
+ /**
+ * Transferencia entre cuentas
+ */
+ TRANSFER(1, "Transfer"),
+
+ /**
+ * Pago de servicios
+ */
+ PAYMENT(2, "Payment"),
+
+ /**
+ * Retiro de efectivo
+ */
+ WITHDRAWAL(3, "Withdrawal"),
+
+ /**
+ * Depósito
+ */
+ DEPOSIT(4, "Deposit");
+
+ private final int id;
+ private final String name;
+
+ TransactionType(int id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public static TransactionType fromId(int id) {
+ for (TransactionType type : values()) {
+ if (type.id == id) {
+ return type;
+ }
+ }
+ throw new IllegalArgumentException("Invalid transaction type id: " + id);
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/TransactionEntity.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/TransactionEntity.java
new file mode 100644
index 0000000000..0a9a20258f
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/TransactionEntity.java
@@ -0,0 +1,103 @@
+package pe.com.yape.ms.transaction.model.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.model.domain.TransactionStatus;
+import pe.com.yape.ms.transaction.model.domain.TransactionType;
+
+/**
+ * Entidad JPA para persistencia de transacciones
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Entity
+@Table(name = "transactions")
+@EntityListeners(AuditingEntityListener.class)
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class TransactionEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @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 = "value", nullable = false, precision = 19, scale = 2)
+ private BigDecimal value;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ private TransactionStatus status;
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+ /**
+ * Convierte esta entidad a un objeto de dominio
+ */
+ public Transaction toDomain() {
+ return new Transaction(
+ this.transactionExternalId,
+ this.accountExternalIdDebit,
+ this.accountExternalIdCredit,
+ TransactionType.fromId(this.transferTypeId),
+ this.value,
+ this.status,
+ this.createdAt,
+ this.updatedAt
+ );
+ }
+
+ /**
+ * Crea una entidad desde un objeto de dominio
+ */
+ public static TransactionEntity fromDomain(Transaction transaction) {
+ TransactionEntity entity = new TransactionEntity();
+ entity.setTransactionExternalId(transaction.transactionExternalId());
+ entity.setAccountExternalIdDebit(transaction.accountExternalIdDebit());
+ entity.setAccountExternalIdCredit(transaction.accountExternalIdCredit());
+ entity.setTransferTypeId(transaction.transactionType().getId());
+ entity.setValue(transaction.value());
+ entity.setStatus(transaction.status());
+ entity.setCreatedAt(transaction.createdAt());
+ entity.setUpdatedAt(transaction.updatedAt());
+ return entity;
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/exception/ErrorResponse.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/exception/ErrorResponse.java
new file mode 100644
index 0000000000..d61b6eba85
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/exception/ErrorResponse.java
@@ -0,0 +1,25 @@
+package pe.com.yape.ms.transaction.model.exception;
+
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Formato de respuesta de error para todos los errores de la API
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ErrorResponse {
+
+ private int status;
+ private String message;
+ private LocalDateTime timestamp;
+
+}
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/CreateTransactionUseCase.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/CreateTransactionUseCase.java
new file mode 100644
index 0000000000..cee9d0dbf3
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/CreateTransactionUseCase.java
@@ -0,0 +1,31 @@
+package pe.com.yape.ms.transaction.service;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+
+/**
+ * Caso de Uso: Crear una nueva transacción
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public interface CreateTransactionUseCase {
+
+ /**
+ * Crea una nueva transacción en estado PENDING
+ *
+ * @param accountExternalIdDebit cuenta de débito
+ * @param accountExternalIdCredit cuenta de crédito
+ * @param transferTypeId tipo de transferencia
+ * @param value monto de la transacción
+ * @return Transaction
+ */
+ Transaction execute(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ int transferTypeId,
+ BigDecimal value
+ );
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/GetTransactionUseCase.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/GetTransactionUseCase.java
new file mode 100644
index 0000000000..2ba1cd6f20
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/GetTransactionUseCase.java
@@ -0,0 +1,23 @@
+package pe.com.yape.ms.transaction.service;
+
+import java.util.UUID;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+
+/**
+ * Caso de Uso: Obtener una transacción por ID
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public interface GetTransactionUseCase {
+
+ /**
+ * Obtiene una transacción por su ID externo
+ *
+ * @param transactionExternalId ID único de la transacción
+ * @return Transaction
+ * @throws pe.com.yape.ms.transaction.application.exception.TransactionNotFoundException si no existe
+ */
+ Transaction execute(UUID transactionExternalId);
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/cache/RedisCacheAdapter.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/cache/RedisCacheAdapter.java
new file mode 100644
index 0000000000..cd75c5c2fa
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/cache/RedisCacheAdapter.java
@@ -0,0 +1,92 @@
+package pe.com.yape.ms.transaction.service.adapter.cache;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.service.port.CacheRepositoryPort;
+
+/**
+ * Adaptador de Cache implementada en Redis
+ * @author lmarusic
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class RedisCacheAdapter implements CacheRepositoryPort {
+
+ private final RedisTemplate redisTemplate;
+ private final ObjectMapper objectMapper = new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ private static final String CACHE_PREFIX = "transaction:";
+
+ @Override
+ public void save(Transaction transaction, long ttlSeconds) {
+ String key = generateKey(transaction.transactionExternalId());
+
+ try {
+ String json = objectMapper.writeValueAsString(transaction);
+ redisTemplate.opsForValue().set(key, json, Duration.ofSeconds(ttlSeconds));
+
+ log.debug("Transaction cached: {} with TTL: {}s", transaction.transactionExternalId(), ttlSeconds);
+ } catch (JsonProcessingException e) {
+ log.error("Error serializing transaction to cache: {}", transaction.transactionExternalId(), e);
+ }
+ }
+
+ @Override
+ public Optional findByExternalId(UUID transactionExternalId) {
+ String key = generateKey(transactionExternalId);
+ String json = redisTemplate.opsForValue().get(key);
+
+ if (json == null) {
+ log.debug("Cache miss for transaction: {}", transactionExternalId);
+ return Optional.empty();
+ }
+
+ try {
+ Transaction transaction = objectMapper.readValue(json, Transaction.class);
+ log.debug("Cache hit for transaction: {}", transactionExternalId);
+ return Optional.of(transaction);
+ } catch (JsonProcessingException e) {
+ log.warn("Error deserializing transaction from cache: {}. Evicting corrupted cache entry.",
+ transactionExternalId, e);
+ // Eliminar el cache corrupto automáticamente
+ evict(transactionExternalId);
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public void evict(UUID transactionExternalId) {
+ String key = generateKey(transactionExternalId);
+ redisTemplate.delete(key);
+
+ log.debug("Transaction evicted from cache: {}", transactionExternalId);
+ }
+
+ @Override
+ public void clear() {
+ // Obtener todas las claves con el prefijo
+ var keys = redisTemplate.keys(CACHE_PREFIX + "*");
+ if (keys != null && !keys.isEmpty()) {
+ redisTemplate.delete(keys);
+ log.info("Cache cleared: {} keys deleted", keys.size());
+ }
+ }
+
+ private String generateKey(UUID transactionExternalId) {
+ return CACHE_PREFIX + transactionExternalId.toString();
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/repository/TransactionRepositoryAdapter.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/repository/TransactionRepositoryAdapter.java
new file mode 100644
index 0000000000..033dd11507
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/repository/TransactionRepositoryAdapter.java
@@ -0,0 +1,71 @@
+package pe.com.yape.ms.transaction.service.adapter.repository;
+
+import java.util.Optional;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.model.entity.TransactionEntity;
+import pe.com.yape.ms.transaction.service.port.TransactionRepositoryPort;
+import pe.com.yape.ms.transaction.service.adapter.repository.jpa.TransactionJpaRepository;
+
+/**
+ * TransactionRepositoryAdapter
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class TransactionRepositoryAdapter implements TransactionRepositoryPort {
+
+ private final TransactionJpaRepository jpaRepository;
+
+ @Override
+ public Transaction save(Transaction transaction) {
+ log.debug("Saving transaction: {}", transaction.transactionExternalId());
+
+ TransactionEntity entity = TransactionEntity.fromDomain(transaction);
+ TransactionEntity savedEntity = jpaRepository.save(entity);
+ log.info("Transaction saved successfully: {}", savedEntity.getTransactionExternalId());
+ return savedEntity.toDomain();
+ }
+
+ @Override
+ public Optional findByExternalId(UUID transactionExternalId) {
+ log.debug("Finding transaction by external ID: {}", transactionExternalId);
+ return jpaRepository.findByTransactionExternalId(transactionExternalId)
+ .map(entity -> {
+ log.debug("Transaction found: {}", transactionExternalId);
+ return entity.toDomain();
+ });
+ }
+
+ @Override
+ public Transaction update(Transaction transaction) {
+ log.debug("Updating transaction: {}", transaction.transactionExternalId());
+
+ // Verificar que existe antes de actualizar
+ TransactionEntity existingEntity = jpaRepository
+ .findByTransactionExternalId(transaction.transactionExternalId())
+ .orElseThrow(() -> new RuntimeException("Transaction not found for update"));
+
+ // Actualizar campos
+ existingEntity.setStatus(transaction.status());
+ existingEntity.setUpdatedAt(transaction.updatedAt());
+
+ TransactionEntity updatedEntity = jpaRepository.save(existingEntity);
+
+ log.info("Transaction updated successfully: {}", updatedEntity.getTransactionExternalId());
+ return updatedEntity.toDomain();
+ }
+
+ @Override
+ public boolean existsByExternalId(UUID transactionExternalId) {
+ log.debug("Checking if transaction exists: {}", transactionExternalId);
+ return jpaRepository.existsByTransactionExternalId(transactionExternalId);
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/repository/jpa/TransactionJpaRepository.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/repository/jpa/TransactionJpaRepository.java
new file mode 100644
index 0000000000..5ab6a35a52
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/repository/jpa/TransactionJpaRepository.java
@@ -0,0 +1,28 @@
+package pe.com.yape.ms.transaction.service.adapter.repository.jpa;
+
+import java.util.Optional;
+import java.util.UUID;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+import pe.com.yape.ms.transaction.model.entity.TransactionEntity;
+
+/**
+ * Repositorio JPA de TransactionEntity
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Repository
+public interface TransactionJpaRepository extends JpaRepository {
+
+ /**
+ * Busca una transaccion por su ID
+ */
+ Optional findByTransactionExternalId(UUID transactionExternalId);
+
+ /**
+ * Verifica si existe una transaccion con el ID
+ */
+ boolean existsByTransactionExternalId(UUID transactionExternalId);
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/CreateTransactionUseCaseImpl.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/CreateTransactionUseCaseImpl.java
new file mode 100644
index 0000000000..36f0f425f6
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/CreateTransactionUseCaseImpl.java
@@ -0,0 +1,69 @@
+package pe.com.yape.ms.transaction.service.impl;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.model.domain.TransactionType;
+import pe.com.yape.ms.transaction.service.port.TransactionRepositoryPort;
+import pe.com.yape.ms.transaction.service.CreateTransactionUseCase;
+
+/**
+ * Implementación del caso de uso: CreateTransactionUseCase
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class CreateTransactionUseCaseImpl implements CreateTransactionUseCase {
+
+ private final TransactionRepositoryPort transactionRepository;
+
+ @Override
+ @Transactional
+ public Transaction execute(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ int transferTypeId,
+ BigDecimal value
+ ) {
+ log.info("Creating transaction - Debit: {}, Credit: {}, Type: {}, Value: {}",
+ accountExternalIdDebit, accountExternalIdCredit, transferTypeId, value);
+
+ validateInputs(accountExternalIdDebit, accountExternalIdCredit, value);
+
+ TransactionType transactionType = TransactionType.fromId(transferTypeId);
+
+ Transaction transaction = Transaction.createPending(
+ accountExternalIdDebit,
+ accountExternalIdCredit,
+ transactionType,
+ value
+ );
+
+ Transaction savedTransaction = transactionRepository.save(transaction);
+
+ log.info("Transaction created successfully: {} with status: {}",
+ savedTransaction.transactionExternalId(), savedTransaction.status());
+ log.info("Transaction will be validated asynchronously by antifraud service");
+
+ return savedTransaction;
+ }
+
+ private void validateInputs(UUID accountDebit, UUID accountCredit, BigDecimal value) {
+ if (accountDebit == null) {
+ throw new IllegalArgumentException("Account external ID debit cannot be null");
+ }
+ if (accountCredit == null) {
+ throw new IllegalArgumentException("Account external ID credit cannot be null");
+ }
+ if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new IllegalArgumentException("Transaction value must be greater than zero");
+ }
+ }
+}
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/GetTransactionUseCaseImpl.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/GetTransactionUseCaseImpl.java
new file mode 100644
index 0000000000..ce2f7ef8b3
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/GetTransactionUseCaseImpl.java
@@ -0,0 +1,56 @@
+package pe.com.yape.ms.transaction.service.impl;
+
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import pe.com.yape.ms.transaction.application.exception.TransactionNotFoundException;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.service.port.CacheRepositoryPort;
+import pe.com.yape.ms.transaction.service.port.TransactionRepositoryPort;
+import pe.com.yape.ms.transaction.service.GetTransactionUseCase;
+
+/**
+ * Implementación del caso de uso: GetTransactionUseCase
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class GetTransactionUseCaseImpl implements GetTransactionUseCase {
+
+ private final TransactionRepositoryPort transactionRepository;
+ private final CacheRepositoryPort cacheRepository;
+
+ private static final long CACHE_TTL_SECONDS = 3600L;
+
+ @Override
+ @Transactional(readOnly = true)
+ public Transaction execute(UUID transactionExternalId) {
+ log.debug("Getting transaction: {}", transactionExternalId);
+
+ return cacheRepository.findByExternalId(transactionExternalId)
+ .map(transaction -> {
+ log.info("Transaction found in cache: {}", transactionExternalId);
+ return transaction;
+ })
+ .orElseGet(() -> {
+ log.debug("Transaction not in cache, searching in database: {}", transactionExternalId);
+
+ Transaction transaction = transactionRepository
+ .findByExternalId(transactionExternalId)
+ .orElseThrow(() -> {
+ log.warn("Transaction not found: {}", transactionExternalId);
+ return new TransactionNotFoundException(transactionExternalId);
+ });
+
+ cacheRepository.save(transaction, CACHE_TTL_SECONDS);
+ log.info("Transaction found in database and saved in cache: {}", transactionExternalId);
+ return transaction;
+ });
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/port/CacheRepositoryPort.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/port/CacheRepositoryPort.java
new file mode 100644
index 0000000000..00f0d7dcf3
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/port/CacheRepositoryPort.java
@@ -0,0 +1,44 @@
+package pe.com.yape.ms.transaction.service.port;
+
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Puerto de salida para operaciones de cache (Redis)
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public interface CacheRepositoryPort {
+
+ /**
+ * Guarda una transacción en cache
+ *
+ * @param transaction transacción a cachear
+ * @param ttlSeconds tiempo de vida en segundos
+ */
+ void save(Transaction transaction, long ttlSeconds);
+
+ /**
+ * Busca una transacción en cache
+ *
+ * @param transactionExternalId ID de la transacción
+ * @return Optional con la transacción si existe en cache
+ */
+ Optional findByExternalId(UUID transactionExternalId);
+
+ /**
+ * Elimina una transacción del cache
+ *
+ * @param transactionExternalId ID de la transacción
+ */
+ void evict(UUID transactionExternalId);
+
+ /**
+ * Limpia todo el cache de transacciones
+ */
+ void clear();
+}
+
diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/port/TransactionRepositoryPort.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/port/TransactionRepositoryPort.java
new file mode 100644
index 0000000000..ad78649df9
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/port/TransactionRepositoryPort.java
@@ -0,0 +1,47 @@
+package pe.com.yape.ms.transaction.service.port;
+
+import java.util.Optional;
+import java.util.UUID;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+
+/**
+ * Interface para persistencia de repositorio de transacciones
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public interface TransactionRepositoryPort {
+
+ /**
+ * Guarda una nueva transaccion en el sistema
+ *
+ * @param transaction transaccion a persistir
+ * @return Transaction
+ */
+ Transaction save(Transaction transaction);
+
+ /**
+ * Busca una transacción por su ID externo
+ *
+ * @param transactionExternalId identificador único de la transacción
+ * @return Optional con Transaction si existe, vacío si no
+ */
+ Optional findByExternalId(UUID transactionExternalId);
+
+ /**
+ * Actualiza el estado de una transaccion existente
+ *
+ * @param transaction transaccion con el nuevo estado
+ * @return Transaction
+ */
+ Transaction update(Transaction transaction);
+
+ /**
+ * Verifica si existe una transaccion con el ID externo dado
+ *
+ * @param transactionExternalId identificador único de la transacción
+ * @return true si existe, false si no
+ */
+ boolean existsByExternalId(UUID transactionExternalId);
+}
+
diff --git a/ms-ne-transaction-service/src/main/resources/application.yml b/ms-ne-transaction-service/src/main/resources/application.yml
new file mode 100644
index 0000000000..adad2258b5
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/resources/application.yml
@@ -0,0 +1,50 @@
+spring:
+ application:
+ name: ms-ne-transaction-service
+
+ datasource:
+ url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/yape_db}
+ username: ${SPRING_DATASOURCE_USERNAME:yape_user}
+ password: ${SPRING_DATASOURCE_PASSWORD:yape_password}
+ driver-class-name: org.postgresql.Driver
+
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ show-sql: false
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+ format_sql: true
+
+ data:
+ redis:
+ host: ${SPRING_DATA_REDIS_HOST:localhost}
+ port: ${SPRING_DATA_REDIS_PORT:6379}
+ timeout: 60000
+
+ flyway:
+ enabled: ${SPRING_FLYWAY_ENABLED:true}
+ locations: classpath:db/migration
+ baseline-on-migrate: true
+
+ kafka:
+ bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+
+server:
+ port: 9992
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics,cache
+ endpoint:
+ health:
+ show-details: always
+
+logging:
+ level:
+ pe.com.yape.ms.transaction: DEBUG
+ org.springframework.cloud.stream: DEBUG
+ org.springframework.kafka: INFO
diff --git a/ms-ne-transaction-service/src/main/resources/db/migration/V1__create_transaction_table.sql b/ms-ne-transaction-service/src/main/resources/db/migration/V1__create_transaction_table.sql
new file mode 100644
index 0000000000..560bb1ae94
--- /dev/null
+++ b/ms-ne-transaction-service/src/main/resources/db/migration/V1__create_transaction_table.sql
@@ -0,0 +1,20 @@
+-- V1__create_transaction_table.sql
+-- Autor: lmarusic
+-- Version: 1.0.0
+
+CREATE TABLE IF NOT EXISTS transactions (
+ id BIGSERIAL 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 INTEGER NOT NULL,
+ value NUMERIC(19,2) NOT NULL CHECK (value > 0),
+ status VARCHAR(20) NOT NULL CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP
+);
+
+CREATE INDEX idx_transaction_external_id ON transactions(transaction_external_id);
+
+-- Necesario para el conector kafka - debezium
+ALTER TABLE transactions REPLICA IDENTITY FULL;
diff --git a/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/model/domain/TransactionTest.java b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/model/domain/TransactionTest.java
new file mode 100644
index 0000000000..808c68f264
--- /dev/null
+++ b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/model/domain/TransactionTest.java
@@ -0,0 +1,214 @@
+package pe.com.yape.ms.transaction.model.domain;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Pruebas unitarias para el Record Transaction (Dominio)
+ * Verifica las reglas de negocio y comportamiento del modelo inmutable
+ *
+ * @author Yape Engineering Team
+ * @version 1.0.0
+ */
+@DisplayName("Transaction Domain Model Tests")
+class TransactionTest {
+
+ @Test
+ @DisplayName("Debe crear una transacción válida con todos los campos")
+ void shouldCreateValidTransaction() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ TransactionType type = TransactionType.TRANSFER;
+ BigDecimal value = new BigDecimal("100.00");
+ TransactionStatus status = TransactionStatus.PENDING;
+ LocalDateTime now = LocalDateTime.now();
+
+ // When
+ Transaction transaction = new Transaction(
+ transactionId,
+ debitAccount,
+ creditAccount,
+ type,
+ value,
+ status,
+ now,
+ now
+ );
+
+ // Then
+ assertNotNull(transaction);
+ assertEquals(transactionId, transaction.transactionExternalId());
+ assertEquals(debitAccount, transaction.accountExternalIdDebit());
+ assertEquals(creditAccount, transaction.accountExternalIdCredit());
+ assertEquals(type, transaction.transactionType());
+ assertEquals(value, transaction.value());
+ assertEquals(status, transaction.status());
+ assertTrue(transaction.isPending());
+ }
+
+ @Test
+ @DisplayName("Debe lanzar excepción cuando el ID de transacción es null")
+ void shouldThrowExceptionWhenTransactionIdIsNull() {
+ // Given
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ BigDecimal value = new BigDecimal("100.00");
+ LocalDateTime now = LocalDateTime.now();
+
+ // When & Then
+ assertThrows(IllegalArgumentException.class, () ->
+ new Transaction(
+ null, // ID null
+ debitAccount,
+ creditAccount,
+ TransactionType.TRANSFER,
+ value,
+ TransactionStatus.PENDING,
+ now,
+ now
+ )
+ );
+ }
+
+ @Test
+ @DisplayName("Debe lanzar excepción cuando el valor es cero o negativo")
+ void shouldThrowExceptionWhenValueIsZeroOrNegative() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ LocalDateTime now = LocalDateTime.now();
+
+ // When & Then - valor cero
+ assertThrows(IllegalArgumentException.class, () ->
+ new Transaction(
+ transactionId,
+ debitAccount,
+ creditAccount,
+ TransactionType.TRANSFER,
+ BigDecimal.ZERO,
+ TransactionStatus.PENDING,
+ now,
+ now
+ )
+ );
+
+ // When & Then - valor negativo
+ assertThrows(IllegalArgumentException.class, () ->
+ new Transaction(
+ transactionId,
+ debitAccount,
+ creditAccount,
+ TransactionType.TRANSFER,
+ new BigDecimal("-10.00"),
+ TransactionStatus.PENDING,
+ now,
+ now
+ )
+ );
+ }
+
+ @Test
+ @DisplayName("Debe crear una transacción en estado PENDING usando factory method")
+ void shouldCreatePendingTransactionUsingFactoryMethod() {
+ // Given
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ BigDecimal value = new BigDecimal("250.00");
+
+ // When
+ Transaction transaction = Transaction.createPending(
+ debitAccount,
+ creditAccount,
+ TransactionType.TRANSFER,
+ value
+ );
+
+ // Then
+ assertNotNull(transaction);
+ assertNotNull(transaction.transactionExternalId());
+ assertEquals(TransactionStatus.PENDING, transaction.status());
+ assertTrue(transaction.isPending());
+ assertFalse(transaction.isApproved());
+ assertFalse(transaction.isRejected());
+ assertNotNull(transaction.createdAt());
+ }
+
+ @Test
+ @DisplayName("Debe crear una nueva instancia con estado actualizado (inmutabilidad)")
+ void shouldCreateNewInstanceWithUpdatedStatus() {
+ // Given
+ Transaction originalTransaction = Transaction.createPending(
+ UUID.randomUUID(),
+ UUID.randomUUID(),
+ TransactionType.TRANSFER,
+ new BigDecimal("100.00")
+ );
+
+ // When
+ Transaction approvedTransaction = originalTransaction.withStatus(TransactionStatus.APPROVED);
+
+ // Then
+ // La transacción original no debe cambiar
+ assertTrue(originalTransaction.isPending());
+ assertFalse(originalTransaction.isApproved());
+
+ // La nueva transacción debe tener el estado actualizado
+ assertFalse(approvedTransaction.isPending());
+ assertTrue(approvedTransaction.isApproved());
+
+ // Deben ser instancias diferentes
+ assertNotSame(originalTransaction, approvedTransaction);
+
+ // Pero con el mismo ID
+ assertEquals(originalTransaction.transactionExternalId(),
+ approvedTransaction.transactionExternalId());
+ }
+
+ @Test
+ @DisplayName("Debe verificar correctamente los métodos de estado")
+ void shouldVerifyStatusMethods() {
+ // Given
+ UUID id = UUID.randomUUID();
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ BigDecimal value = new BigDecimal("100.00");
+ LocalDateTime now = LocalDateTime.now();
+
+ // When - PENDING
+ Transaction pendingTransaction = new Transaction(
+ id, debitAccount, creditAccount, TransactionType.TRANSFER,
+ value, TransactionStatus.PENDING, now, now
+ );
+
+ // Then
+ assertTrue(pendingTransaction.isPending());
+ assertFalse(pendingTransaction.isApproved());
+ assertFalse(pendingTransaction.isRejected());
+
+ // When - APPROVED
+ Transaction approvedTransaction = pendingTransaction.withStatus(TransactionStatus.APPROVED);
+
+ // Then
+ assertFalse(approvedTransaction.isPending());
+ assertTrue(approvedTransaction.isApproved());
+ assertFalse(approvedTransaction.isRejected());
+
+ // When - REJECTED
+ Transaction rejectedTransaction = pendingTransaction.withStatus(TransactionStatus.REJECTED);
+
+ // Then
+ assertFalse(rejectedTransaction.isPending());
+ assertFalse(rejectedTransaction.isApproved());
+ assertTrue(rejectedTransaction.isRejected());
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/CreateTransactionUseCaseTest.java b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/CreateTransactionUseCaseTest.java
new file mode 100644
index 0000000000..425b003b3e
--- /dev/null
+++ b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/CreateTransactionUseCaseTest.java
@@ -0,0 +1,178 @@
+package pe.com.yape.ms.transaction.service;
+
+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 pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.model.domain.TransactionStatus;
+import pe.com.yape.ms.transaction.model.domain.TransactionType;
+import pe.com.yape.ms.transaction.service.port.TransactionRepositoryPort;
+import pe.com.yape.ms.transaction.service.impl.CreateTransactionUseCaseImpl;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * Pruebas unitarias para CreateTransactionUseCase (TDD - GREEN Phase)
+ * Con implementación completa - Las pruebas ahora PASAN
+ *
+ * Con CDC: Debezium capturará automáticamente los cambios desde PostgreSQL WAL
+ * y publicará eventos a Kafka. No necesitamos tabla Outbox.
+ *
+ * @author Yape Engineering Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Create Transaction Use Case Tests - GREEN Phase")
+class CreateTransactionUseCaseTest {
+
+ @Mock
+ private TransactionRepositoryPort transactionRepository;
+
+ private CreateTransactionUseCase createTransactionUseCase;
+
+ @BeforeEach
+ void setUp() {
+ // Ahora SÍ existe la implementación
+ createTransactionUseCase = new CreateTransactionUseCaseImpl(
+ transactionRepository
+ );
+ }
+
+ @Test
+ @DisplayName("RED: Debe crear una transacción con estado PENDING")
+ void shouldCreateTransactionWithPendingStatus() {
+ // Given
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ int transferTypeId = TransactionType.TRANSFER.getId();
+ BigDecimal value = new BigDecimal("500.00");
+
+ Transaction expectedTransaction = Transaction.createPending(
+ debitAccount,
+ creditAccount,
+ TransactionType.TRANSFER,
+ value
+ );
+
+ when(transactionRepository.save(any(Transaction.class)))
+ .thenReturn(expectedTransaction);
+
+ // When
+ Transaction result = createTransactionUseCase.execute(
+ debitAccount,
+ creditAccount,
+ transferTypeId,
+ value
+ );
+
+ // Then
+ assertNotNull(result);
+ assertEquals(TransactionStatus.PENDING, result.status());
+ assertEquals(value, result.value());
+ assertEquals(debitAccount, result.accountExternalIdDebit());
+ assertEquals(creditAccount, result.accountExternalIdCredit());
+
+ // Verificar que se guardó en la BD
+ // Debezium CDC capturará este cambio automáticamente
+ verify(transactionRepository, times(1)).save(any(Transaction.class));
+ }
+
+ @Test
+ @DisplayName("RED: Debe lanzar excepción cuando el valor es inválido")
+ void shouldThrowExceptionWhenValueIsInvalid() {
+ // Given
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ int transferTypeId = TransactionType.TRANSFER.getId();
+ BigDecimal invalidValue = BigDecimal.ZERO;
+
+ // When & Then
+ assertThrows(IllegalArgumentException.class, () ->
+ createTransactionUseCase.execute(
+ debitAccount,
+ creditAccount,
+ transferTypeId,
+ invalidValue
+ )
+ );
+
+ verify(transactionRepository, never()).save(any(Transaction.class));
+ }
+
+ @Test
+ @DisplayName("RED: Debe validar que las cuentas no sean null")
+ void shouldValidateAccountsAreNotNull() {
+ // Given
+ int transferTypeId = TransactionType.TRANSFER.getId();
+ BigDecimal value = new BigDecimal("100.00");
+
+ // When & Then - cuenta débito null
+ assertThrows(IllegalArgumentException.class, () ->
+ createTransactionUseCase.execute(
+ null,
+ UUID.randomUUID(),
+ transferTypeId,
+ value
+ )
+ );
+
+ // When & Then - cuenta crédito null
+ assertThrows(IllegalArgumentException.class, () ->
+ createTransactionUseCase.execute(
+ UUID.randomUUID(),
+ null,
+ transferTypeId,
+ value
+ )
+ );
+
+ verify(transactionRepository, never()).save(any(Transaction.class));
+ }
+
+ @Test
+ @DisplayName("RED: Debe crear transacción y Debezium CDC capturará el cambio automáticamente")
+ void shouldCreateTransactionAndDebeziumWillCaptureChange() {
+ // Given
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ int transferTypeId = TransactionType.TRANSFER.getId();
+ BigDecimal value = new BigDecimal("750.00");
+
+ Transaction transaction = Transaction.createPending(
+ debitAccount,
+ creditAccount,
+ TransactionType.TRANSFER,
+ value
+ );
+
+ when(transactionRepository.save(any(Transaction.class)))
+ .thenReturn(transaction);
+
+ // When
+ Transaction result = createTransactionUseCase.execute(
+ debitAccount,
+ creditAccount,
+ transferTypeId,
+ value
+ );
+
+ // Then
+ assertNotNull(result);
+ assertNotNull(result.transactionExternalId());
+ assertEquals(TransactionStatus.PENDING, result.status());
+
+ // Solo verificamos que se guardó en BD
+ // Debezium CDC capturará este INSERT automáticamente desde PostgreSQL WAL
+ // y publicará el evento 'transaction.created' a Kafka
+ verify(transactionRepository, times(1)).save(any(Transaction.class));
+ }
+}
+
diff --git a/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/GetTransactionUseCaseTest.java b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/GetTransactionUseCaseTest.java
new file mode 100644
index 0000000000..94b289eee1
--- /dev/null
+++ b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/GetTransactionUseCaseTest.java
@@ -0,0 +1,155 @@
+package pe.com.yape.ms.transaction.service;
+
+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 pe.com.yape.ms.transaction.application.exception.TransactionNotFoundException;
+import pe.com.yape.ms.transaction.model.domain.Transaction;
+import pe.com.yape.ms.transaction.model.domain.TransactionType;
+import pe.com.yape.ms.transaction.service.port.CacheRepositoryPort;
+import pe.com.yape.ms.transaction.service.port.TransactionRepositoryPort;
+import pe.com.yape.ms.transaction.service.impl.GetTransactionUseCaseImpl;
+
+import java.math.BigDecimal;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+
+/**
+ * Pruebas unitarias para GetTransactionUseCase (TDD - GREEN Phase)
+ * Verifica el patrón Cache-Aside para lecturas de alto volumen
+ *
+ * @author Yape Engineering Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Get Transaction Use Case Tests - GREEN Phase")
+class GetTransactionUseCaseTest {
+
+ @Mock
+ private TransactionRepositoryPort transactionRepository;
+
+ @Mock
+ private CacheRepositoryPort cacheRepository;
+
+ private GetTransactionUseCase getTransactionUseCase;
+
+ @BeforeEach
+ void setUp() {
+ // Ahora SÍ existe la implementación
+ getTransactionUseCase = new GetTransactionUseCaseImpl(
+ transactionRepository,
+ cacheRepository
+ );
+ }
+
+ @Test
+ @DisplayName("RED: Debe obtener transacción desde cache si existe")
+ void shouldGetTransactionFromCacheWhenExists() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ Transaction cachedTransaction = Transaction.createPending(
+ UUID.randomUUID(),
+ UUID.randomUUID(),
+ TransactionType.TRANSFER,
+ new BigDecimal("100.00")
+ );
+
+ when(cacheRepository.findByExternalId(transactionId))
+ .thenReturn(Optional.of(cachedTransaction));
+
+ // When
+ Transaction result = getTransactionUseCase.execute(transactionId);
+
+ // Then
+ assertNotNull(result);
+ assertEquals(cachedTransaction, result);
+ verify(cacheRepository, times(1)).findByExternalId(transactionId);
+ verify(transactionRepository, never()).findByExternalId(any());
+ }
+
+ @Test
+ @DisplayName("RED: Debe obtener transacción de DB y guardar en cache si no existe en cache")
+ void shouldGetTransactionFromDatabaseAndCacheIt() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ Transaction dbTransaction = Transaction.createPending(
+ UUID.randomUUID(),
+ UUID.randomUUID(),
+ TransactionType.TRANSFER,
+ new BigDecimal("200.00")
+ );
+
+ when(cacheRepository.findByExternalId(transactionId))
+ .thenReturn(Optional.empty());
+ when(transactionRepository.findByExternalId(transactionId))
+ .thenReturn(Optional.of(dbTransaction));
+
+ // When
+ Transaction result = getTransactionUseCase.execute(transactionId);
+
+ // Then
+ assertNotNull(result);
+ assertEquals(dbTransaction, result);
+ verify(cacheRepository, times(1)).findByExternalId(transactionId);
+ verify(transactionRepository, times(1)).findByExternalId(transactionId);
+ verify(cacheRepository, times(1)).save(eq(dbTransaction), anyLong());
+ }
+
+ @Test
+ @DisplayName("GREEN: Debe lanzar excepción cuando la transacción no existe")
+ void shouldThrowExceptionWhenTransactionNotFound() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+
+ when(cacheRepository.findByExternalId(transactionId))
+ .thenReturn(Optional.empty());
+ when(transactionRepository.findByExternalId(transactionId))
+ .thenReturn(Optional.empty());
+
+ // When & Then
+ assertThrows(TransactionNotFoundException.class, () ->
+ getTransactionUseCase.execute(transactionId)
+ );
+
+ verify(cacheRepository, times(1)).findByExternalId(transactionId);
+ verify(transactionRepository, times(1)).findByExternalId(transactionId);
+ verify(cacheRepository, never()).save(any(), anyLong());
+ }
+
+ @Test
+ @DisplayName("RED: Debe implementar correctamente el patrón Cache-Aside")
+ void shouldImplementCacheAsidePattern() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ Transaction transaction = Transaction.createPending(
+ UUID.randomUUID(),
+ UUID.randomUUID(),
+ TransactionType.TRANSFER,
+ new BigDecimal("300.00")
+ );
+
+ // Simular cache miss
+ when(cacheRepository.findByExternalId(transactionId))
+ .thenReturn(Optional.empty());
+ when(transactionRepository.findByExternalId(transactionId))
+ .thenReturn(Optional.of(transaction));
+
+ // When
+ getTransactionUseCase.execute(transactionId);
+
+ // Then - Verificar orden: cache -> DB -> cache save
+ var inOrder = inOrder(cacheRepository, transactionRepository);
+ inOrder.verify(cacheRepository).findByExternalId(transactionId);
+ inOrder.verify(transactionRepository).findByExternalId(transactionId);
+ inOrder.verify(cacheRepository).save(transaction, 3600L); // TTL 1 hora
+ }
+}
+
diff --git a/ms-sp-antifraud-rules/Dockerfile b/ms-sp-antifraud-rules/Dockerfile
new file mode 100644
index 0000000000..f2f6b9fa4e
--- /dev/null
+++ b/ms-sp-antifraud-rules/Dockerfile
@@ -0,0 +1,21 @@
+FROM gradle:8.5-jdk21-alpine AS builder
+
+WORKDIR /workspace/app
+
+COPY build.gradle settings.gradle ./
+COPY src src
+
+RUN gradle clean build -x test --no-daemon
+
+FROM eclipse-temurin:21-jre-alpine
+
+RUN apk add --no-cache curl
+
+WORKDIR /app
+
+COPY --from=builder /workspace/app/build/libs/*.jar app.jar
+
+EXPOSE 9993
+
+ENTRYPOINT ["java", "-jar", "app.jar"]
+
diff --git a/ms-sp-antifraud-rules/build.gradle b/ms-sp-antifraud-rules/build.gradle
new file mode 100644
index 0000000000..beb0788fea
--- /dev/null
+++ b/ms-sp-antifraud-rules/build.gradle
@@ -0,0 +1,72 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.4.1'
+ id 'io.spring.dependency-management' version '1.1.7'
+}
+
+group = 'pe.com.yape'
+version = '1.0.0'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+ext {
+ springCloudVersion = '2024.0.0'
+}
+
+dependencies {
+ // Spring Boot Starters
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+
+ // Spring Cloud Stream + Kafka Binder
+ implementation 'org.springframework.cloud:spring-cloud-stream'
+ implementation 'org.springframework.cloud:spring-cloud-stream-binder-kafka'
+ implementation 'org.springframework.kafka:spring-kafka'
+
+ // JSON Processing
+ implementation 'com.fasterxml.jackson.core:jackson-databind'
+
+ // Lombok
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+
+ // Dev Tools
+ developmentOnly 'org.springframework.boot:spring-boot-devtools'
+
+ // Testing
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.cloud:spring-cloud-stream-test-binder'
+ testImplementation 'org.springframework.kafka:spring-kafka-test'
+ testImplementation 'org.mockito:mockito-core'
+ testImplementation 'org.mockito:mockito-junit-jupiter'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+dependencyManagement {
+ imports {
+ mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
+ }
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
+
+tasks.named('bootJar') {
+ archiveFileName.set("${project.name}-${version}.jar")
+}
+
diff --git a/ms-sp-antifraud-rules/settings.gradle b/ms-sp-antifraud-rules/settings.gradle
new file mode 100644
index 0000000000..7605cd9b76
--- /dev/null
+++ b/ms-sp-antifraud-rules/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = 'ms-sp-antifraud-rules'
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/AntiFraudServiceApplication.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/AntiFraudServiceApplication.java
new file mode 100644
index 0000000000..e7ff4ea04a
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/AntiFraudServiceApplication.java
@@ -0,0 +1,17 @@
+package pe.com.yape.ms.antifraud;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * MS-SP-ANTIFRAUD-RULES
+ * @author lmarusic
+ */
+@SpringBootApplication
+public class AntiFraudServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AntiFraudServiceApplication.class, args);
+ }
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/dto/DebeziumTransactionDto.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/dto/DebeziumTransactionDto.java
new file mode 100644
index 0000000000..f0fccc3578
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/dto/DebeziumTransactionDto.java
@@ -0,0 +1,37 @@
+package pe.com.yape.ms.antifraud.application.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * DTO para deserializar eventos CDC de Debezium en formato JSON.
+ * @author lmarusic
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record DebeziumTransactionDto(
+ @JsonProperty("transaction_external_id")
+ String transactionExternalId,
+
+ @JsonProperty("account_external_id_debit")
+ String accountExternalIdDebit,
+
+ @JsonProperty("account_external_id_credit")
+ String accountExternalIdCredit,
+
+ @JsonProperty("transfer_type_id")
+ Integer transferTypeId,
+
+ @JsonProperty("value")
+ Double value,
+
+ @JsonProperty("status")
+ String status,
+
+ @JsonProperty("__op")
+ String op,
+
+ @JsonProperty("__source_ts_ms")
+ Long sourceTsMs
+) {
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/dto/TransactionValidatedDto.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/dto/TransactionValidatedDto.java
new file mode 100644
index 0000000000..d859cfd71b
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/dto/TransactionValidatedDto.java
@@ -0,0 +1,26 @@
+package pe.com.yape.ms.antifraud.application.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * DTO para el evento de transacción validada (output en JSON)
+ * @author lmarusic
+ */
+public record TransactionValidatedDto(
+ @JsonProperty("transaction_external_id")
+ String transactionExternalId,
+
+ @JsonProperty("validation_status")
+ String validationStatus,
+
+ @JsonProperty("validation_reason")
+ String validationReason,
+
+ @JsonProperty("validated_at")
+ Long validatedAt,
+
+ @JsonProperty("transaction_value")
+ Double transactionValue
+) {
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/mapper/TransactionEventMapper.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/mapper/TransactionEventMapper.java
new file mode 100644
index 0000000000..02dab27aa9
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/mapper/TransactionEventMapper.java
@@ -0,0 +1,59 @@
+package pe.com.yape.ms.antifraud.application.mapper;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+import pe.com.yape.ms.antifraud.application.dto.DebeziumTransactionDto;
+import pe.com.yape.ms.antifraud.application.dto.TransactionValidatedDto;
+import pe.com.yape.ms.antifraud.model.domain.Transaction;
+import pe.com.yape.ms.antifraud.model.domain.TransactionValidation;
+
+/**
+ * Mapper entre eventos JSON y objetos de dominio
+ * @author lmarusic
+ */
+public class TransactionEventMapper {
+
+ private TransactionEventMapper() {
+ }
+
+ /**
+ * Convierte un DTO JSON de Debezium a un objeto de dominio Transaction
+ *
+ * @param dto DTO desde Debezium CDC
+ * @return Transaction
+ */
+ public static Transaction toDomain(DebeziumTransactionDto dto) {
+ LocalDateTime sourceTimestamp = dto.sourceTsMs() != null
+ ? LocalDateTime.ofInstant(Instant.ofEpochMilli(dto.sourceTsMs()), ZoneOffset.UTC)
+ : null;
+
+ return new Transaction(
+ dto.transactionExternalId(),
+ dto.accountExternalIdDebit(),
+ dto.accountExternalIdCredit(),
+ dto.value() != null ? dto.value() : 0.0,
+ dto.transferTypeId() != null ? dto.transferTypeId() : 0,
+ dto.status(),
+ dto.op(),
+ sourceTimestamp
+ );
+ }
+
+ /**
+ * Convierte un objeto de dominio TransactionValidation a un DTO JSON
+ *
+ * @param validation Objeto de dominio con el resultado de validación
+ * @return DTO JSON para publicar en Kafka
+ */
+ public static TransactionValidatedDto toDto(TransactionValidation validation) {
+ return new TransactionValidatedDto(
+ validation.transactionId(),
+ validation.status().name(),
+ validation.reason(),
+ validation.validatedAt().toEpochMilli(),
+ validation.amount()
+ );
+ }
+}
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/Transaction.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/Transaction.java
new file mode 100644
index 0000000000..e708663458
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/Transaction.java
@@ -0,0 +1,45 @@
+package pe.com.yape.ms.antifraud.model.domain;
+
+import java.time.LocalDateTime;
+
+/**
+ * Representa una transacción a validar usando Java 21 Record
+ * @author lmarusic
+ */
+public record Transaction(
+ String transactionId,
+ String debitAccount,
+ String creditAccount,
+ double amount,
+ int transferType,
+ String status,
+ String operationType,
+ LocalDateTime sourceTimestamp
+) {
+ /**
+ * Constructor compacto para validaciones
+ */
+ public Transaction {
+ if (transactionId == null || transactionId.isBlank()) {
+ throw new IllegalArgumentException("Transaction ID cannot be null or empty");
+ }
+ if (amount < 0) {
+ throw new IllegalArgumentException("Amount cannot be negative");
+ }
+ }
+
+ /**
+ * Verifica si el monto supera un umbral específico
+ */
+ public boolean amountExceeds(double threshold) {
+ return amount > threshold;
+ }
+
+ /**
+ * Verifica si el monto está dentro de un rango
+ */
+ public boolean amountWithinRange(double min, double max) {
+ return amount >= min && amount <= max;
+ }
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/TransactionRisk.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/TransactionRisk.java
new file mode 100644
index 0000000000..5292cd7bfa
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/TransactionRisk.java
@@ -0,0 +1,11 @@
+package pe.com.yape.ms.antifraud.model.domain;
+
+/**
+ * Nivel de riesgo de una transacción
+ * @author lmarusic
+ */
+public enum TransactionRisk {
+ LOW,
+ HIGH
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/TransactionValidation.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/TransactionValidation.java
new file mode 100644
index 0000000000..61d80419b5
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/TransactionValidation.java
@@ -0,0 +1,60 @@
+package pe.com.yape.ms.antifraud.model.domain;
+
+import java.time.Instant;
+
+/**
+ * Representa el resultado de una validación antifraude
+ * @author lmarusic
+ */
+public record TransactionValidation(
+ String transactionId,
+ ValidationStatus status,
+ String reason,
+ Instant validatedAt,
+ double amount
+) {
+
+ public TransactionValidation {
+ if (transactionId == null || transactionId.isBlank()) {
+ throw new IllegalArgumentException("Transaction ID cannot be null or empty");
+ }
+ if (status == null) {
+ throw new IllegalArgumentException("Validation status cannot be null");
+ }
+ if (validatedAt == null) {
+ validatedAt = Instant.now();
+ }
+ if (amount < 0) {
+ throw new IllegalArgumentException("Amount cannot be negative");
+ }
+ }
+
+ public static TransactionValidation approved(String transactionId, double amount) {
+ return new TransactionValidation(
+ transactionId,
+ ValidationStatus.APPROVED,
+ "Transaction amount is within acceptable limits",
+ Instant.now(),
+ amount
+ );
+ }
+
+ public static TransactionValidation rejected(String transactionId, double amount, String reason) {
+ return new TransactionValidation(
+ transactionId,
+ ValidationStatus.REJECTED,
+ reason,
+ Instant.now(),
+ amount
+ );
+ }
+
+ public boolean isApproved() {
+ return status == ValidationStatus.APPROVED;
+ }
+
+ public boolean isRejected() {
+ return status == ValidationStatus.REJECTED;
+ }
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/ValidationStatus.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/ValidationStatus.java
new file mode 100644
index 0000000000..ff474ec53c
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/ValidationStatus.java
@@ -0,0 +1,11 @@
+package pe.com.yape.ms.antifraud.model.domain;
+
+/**
+ * Estados de validación
+ * @author lmarusic
+ */
+public enum ValidationStatus {
+ APPROVED,
+ REJECTED
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/AntiFraudValidationService.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/AntiFraudValidationService.java
new file mode 100644
index 0000000000..fedefbf892
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/AntiFraudValidationService.java
@@ -0,0 +1,20 @@
+package pe.com.yape.ms.antifraud.service;
+
+import pe.com.yape.ms.antifraud.model.domain.Transaction;
+import pe.com.yape.ms.antifraud.model.domain.TransactionValidation;
+
+/**
+ * Puerto de dominio para la validación antifraude
+ * @author lmarusic
+ */
+public interface AntiFraudValidationService {
+
+ /**
+ * Valida una transacción contra las reglas antifraude
+ *
+ * @param transaction Transacción a validar
+ * @return Resultado de la validación
+ */
+ TransactionValidation validate(Transaction transaction);
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/AntiFraudValidationServiceImpl.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/AntiFraudValidationServiceImpl.java
new file mode 100644
index 0000000000..1aa7c133c5
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/AntiFraudValidationServiceImpl.java
@@ -0,0 +1,47 @@
+package pe.com.yape.ms.antifraud.service.impl;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import pe.com.yape.ms.antifraud.model.domain.Transaction;
+import pe.com.yape.ms.antifraud.model.domain.TransactionRisk;
+import pe.com.yape.ms.antifraud.model.domain.TransactionValidation;
+import pe.com.yape.ms.antifraud.service.AntiFraudValidationService;
+
+/**
+ * Implementación del servicio de validación antifraud
+ * @author lmarusic
+ */
+@Service
+@Slf4j
+public class AntiFraudValidationServiceImpl implements AntiFraudValidationService {
+
+ private static final double HIGH_VALUE_THRESHOLD = 1000.0;
+ private static final String REJECTION_REASON_HIGH_VALUE =
+ "Transaction rejected: amount exceeds maximum allowed value of " + HIGH_VALUE_THRESHOLD;
+
+ @Override
+ public TransactionValidation validate(Transaction transaction) {
+ log.info("Validating transaction: {} with amount: {}",
+ transaction.transactionId(), transaction.amount());
+
+ return switch (evaluateTransaction(transaction)) {
+ case LOW -> {
+ log.info("Transaction {} APPROVED - Low risk", transaction.transactionId());
+ yield TransactionValidation.approved(transaction.transactionId(),transaction.amount());
+ }
+ case HIGH -> {
+ log.warn("Transaction {} REJECTED - High risk: amount > {}",
+ transaction.transactionId(), HIGH_VALUE_THRESHOLD);
+ yield TransactionValidation.rejected(transaction.transactionId(), transaction.amount(),
+ REJECTION_REASON_HIGH_VALUE
+ );
+ }
+ };
+ }
+
+ private TransactionRisk evaluateTransaction(Transaction transaction) {
+ return transaction.amount() > HIGH_VALUE_THRESHOLD
+ ? TransactionRisk.HIGH
+ : TransactionRisk.LOW;
+ }
+}
diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/stream/TransactionValidationProcessor.java b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/stream/TransactionValidationProcessor.java
new file mode 100644
index 0000000000..7ce25b4616
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/stream/TransactionValidationProcessor.java
@@ -0,0 +1,97 @@
+package pe.com.yape.ms.antifraud.service.stream;
+
+import java.util.function.Consumer;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.cloud.stream.function.StreamBridge;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import pe.com.yape.ms.antifraud.application.dto.DebeziumTransactionDto;
+import pe.com.yape.ms.antifraud.application.dto.TransactionValidatedDto;
+import pe.com.yape.ms.antifraud.application.mapper.TransactionEventMapper;
+import pe.com.yape.ms.antifraud.model.domain.Transaction;
+import pe.com.yape.ms.antifraud.model.domain.TransactionValidation;
+import pe.com.yape.ms.antifraud.service.AntiFraudValidationService;
+
+/**
+ * Procesador de eventos de Kafka usando Spring Cloud Stream
+ * @author lmarusic
+ */
+@Configuration
+@RequiredArgsConstructor
+@Slf4j
+public class TransactionValidationProcessor {
+
+ private static final String OPERATION_CREATE = "c";
+ private static final String OUTPUT_BINDING = "processTransaction-out-0";
+
+ private final AntiFraudValidationService validationService;
+ private final StreamBridge streamBridge;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Procesa eventos de transacciones desde Debezium CDC (JSON)
+ * Filtra solo operaciones CREATE y valida contra reglas antifraude
+ *
+ * @return Consumer que procesa mensajes JSON y publica TransactionValidatedEvent
+ */
+ @Bean
+ public Consumer processTransaction() {
+ return json -> {
+ try {
+ log.info("Received raw JSON: {}", json);
+
+ // Deserializar JSON a DTO
+ DebeziumTransactionDto dto = objectMapper.readValue(json, DebeziumTransactionDto.class);
+
+ log.info("Received transaction event: {} with operation: {}",
+ dto.transactionExternalId(),
+ dto.op());
+
+ if (!isCreateOperation(dto)) {
+ log.info("Skipping non-create operation for transaction: {}",
+ dto.transactionExternalId());
+ return;
+ }
+
+ log.info("Processing CREATE transaction event: {}",
+ dto.transactionExternalId());
+
+ Transaction transaction = TransactionEventMapper.toDomain(dto);
+ TransactionValidation validation = validationService.validate(transaction);
+ TransactionValidatedDto validatedDto =
+ TransactionEventMapper.toDto(validation);
+
+ log.info("Transaction {} validated with status: {}",
+ validation.transactionId(),
+ validation.status());
+
+ // Publicar evento al topic de salida
+ streamBridge.send(OUTPUT_BINDING, validatedDto);
+
+ log.info("Published validation result for transaction: {}",
+ validation.transactionId());
+
+ } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
+ log.error("Error parsing JSON message: {}", json, e);
+ } catch (Exception e) {
+ log.error("Error processing transaction", e);
+ }
+ };
+ }
+
+ /**
+ * Verifica si el evento de transacción es una operación de creación
+ *
+ * @param dto DTO de Debezium
+ * @return true si es operación CREATE
+ */
+ private boolean isCreateOperation(DebeziumTransactionDto dto) {
+ return dto.op() != null && OPERATION_CREATE.equals(dto.op());
+ }
+}
+
diff --git a/ms-sp-antifraud-rules/src/main/resources/application.yml b/ms-sp-antifraud-rules/src/main/resources/application.yml
new file mode 100644
index 0000000000..308bdf3a14
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/main/resources/application.yml
@@ -0,0 +1,46 @@
+spring:
+ application:
+ name: ms-sp-antifraud-rules
+
+ cloud:
+ stream:
+ kafka:
+ binder:
+ brokers: ${SPRING_CLOUD_STREAM_KAFKA_BINDER_BROKERS:localhost:9092}
+
+ bindings:
+ processTransaction-in-0:
+ destination: yape.public.transactions
+ content-type: application/json
+ group: antifraud-consumer-group
+ consumer:
+ max-attempts: 3
+ back-off-initial-interval: 1000
+
+ processTransaction-out-0:
+ destination: transaction.validated
+ content-type: application/json
+
+ function:
+ definition: processTransaction
+
+server:
+ port: 9993
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics,bindings
+ endpoint:
+ health:
+ show-details: always
+ health:
+ binders:
+ enabled: true
+
+logging:
+ level:
+ pe.com.yape.ms.antifraud: INFO
+ org.springframework.cloud.stream: DEBUG
+ org.apache.kafka: INFO
diff --git a/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/service/impl/AntiFraudValidationServiceTest.java b/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/service/impl/AntiFraudValidationServiceTest.java
new file mode 100644
index 0000000000..2b62093e66
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/service/impl/AntiFraudValidationServiceTest.java
@@ -0,0 +1,185 @@
+package pe.com.yape.ms.antifraud.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import pe.com.yape.ms.antifraud.model.domain.Transaction;
+import pe.com.yape.ms.antifraud.model.domain.TransactionValidation;
+import pe.com.yape.ms.antifraud.model.domain.ValidationStatus;
+
+/**
+ * Test unitario para AntiFraudValidationService validando regla: Monto > 1000 = REJECTED
+ * @author lmarusic
+ */
+@DisplayName("AntiFraud Validation Service Tests")
+class AntiFraudValidationServiceTest {
+
+ private AntiFraudValidationServiceImpl validationService;
+
+ @BeforeEach
+ void setUp() {
+ validationService = new AntiFraudValidationServiceImpl();
+ }
+
+ @Test
+ @DisplayName("Should APPROVE transaction when amount is LESS than 1000")
+ void shouldApproveTransactionWhenAmountIsLessThan1000() {
+ // Given
+ Transaction transaction = new Transaction(
+ "txn-001",
+ "account-debit-001",
+ "account-credit-001",
+ 500.0,
+ 1,
+ "PENDING",
+ "c",
+ null
+ );
+
+ // When
+ TransactionValidation result = validationService.validate(transaction);
+
+ // Then
+ assertNotNull(result);
+ assertEquals("txn-001", result.transactionId());
+ assertEquals(ValidationStatus.APPROVED, result.status());
+ assertEquals(500.0, result.amount());
+ assertTrue(result.isApproved());
+ assertFalse(result.isRejected());
+ assertEquals("Transaction amount is within acceptable limits", result.reason());
+ }
+
+ @Test
+ @DisplayName("Should APPROVE transaction when amount is EXACTLY 1000")
+ void shouldApproveTransactionWhenAmountIsExactly1000() {
+ // Given
+ Transaction transaction = new Transaction(
+ "txn-002",
+ "account-debit-002",
+ "account-credit-002",
+ 1000.0,
+ 1,
+ "PENDING",
+ "c",
+ null
+ );
+
+ // When
+ TransactionValidation result = validationService.validate(transaction);
+
+ // Then
+ assertNotNull(result);
+ assertEquals("txn-002", result.transactionId());
+ assertEquals(ValidationStatus.APPROVED, result.status());
+ assertEquals(1000.0, result.amount());
+ assertTrue(result.isApproved());
+ }
+
+ @Test
+ @DisplayName("Should REJECT transaction when amount is GREATER than 1000")
+ void shouldRejectTransactionWhenAmountIsGreaterThan1000() {
+ // Given
+ Transaction transaction = new Transaction(
+ "txn-003",
+ "account-debit-003",
+ "account-credit-003",
+ 1500.0,
+ 1,
+ "PENDING",
+ "c",
+ null
+ );
+
+ // When
+ TransactionValidation result = validationService.validate(transaction);
+
+ // Then
+ assertNotNull(result);
+ assertEquals("txn-003", result.transactionId());
+ assertEquals(ValidationStatus.REJECTED, result.status());
+ assertEquals(1500.0, result.amount());
+ assertFalse(result.isApproved());
+ assertTrue(result.isRejected());
+ assertTrue(result.reason().contains("exceeds maximum allowed value"));
+ }
+
+ @Test
+ @DisplayName("Should REJECT transaction when amount is much GREATER than 1000")
+ void shouldRejectTransactionWhenAmountIsMuchGreaterThan1000() {
+ // Given
+ Transaction transaction = new Transaction(
+ "txn-004",
+ "account-debit-004",
+ "account-credit-004",
+ 10000.0,
+ 1,
+ "PENDING",
+ "c",
+ null
+ );
+
+ // When
+ TransactionValidation result = validationService.validate(transaction);
+
+ // Then
+ assertNotNull(result);
+ assertEquals(ValidationStatus.REJECTED, result.status());
+ assertEquals(10000.0, result.amount());
+ assertTrue(result.isRejected());
+ }
+
+ @Test
+ @DisplayName("Should APPROVE transaction with minimum amount")
+ void shouldApproveTransactionWithMinimumAmount() {
+ // Given
+ Transaction transaction = new Transaction(
+ "txn-005",
+ "account-debit-005",
+ "account-credit-005",
+ 0.01,
+ 1,
+ "PENDING",
+ "c",
+ null
+ );
+
+ // When
+ TransactionValidation result = validationService.validate(transaction);
+
+ // Then
+ assertNotNull(result);
+ assertEquals(ValidationStatus.APPROVED, result.status());
+ assertEquals(0.01, result.amount());
+ assertTrue(result.isApproved());
+ }
+
+ @Test
+ @DisplayName("Should include validated timestamp in result")
+ void shouldIncludeValidatedTimestamp() {
+ // Given
+ Transaction transaction = new Transaction(
+ "txn-006",
+ "account-debit-006",
+ "account-credit-006",
+ 500.0,
+ 1,
+ "PENDING",
+ "c",
+ null
+ );
+
+ // When
+ TransactionValidation result = validationService.validate(transaction);
+
+ // Then
+ assertNotNull(result.validatedAt());
+ assertTrue(result.validatedAt().toEpochMilli() <= System.currentTimeMillis());
+ }
+}
+
diff --git a/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/service/stream/TransactionValidationProcessorIntegrationTest.java b/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/service/stream/TransactionValidationProcessorIntegrationTest.java
new file mode 100644
index 0000000000..e1c5364eaa
--- /dev/null
+++ b/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/service/stream/TransactionValidationProcessorIntegrationTest.java
@@ -0,0 +1,178 @@
+package pe.com.yape.ms.antifraud.service.stream;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import java.util.function.Consumer;
+
+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.springframework.cloud.stream.function.StreamBridge;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import pe.com.yape.ms.antifraud.service.impl.AntiFraudValidationServiceImpl;
+
+/**
+ * Test de integración para el procesador de transacciones validando flujo completo end-to-end
+ * @author lmarusic
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Transaction Validation Processor Integration Tests")
+class TransactionValidationProcessorIntegrationTest {
+
+ @Mock
+ private StreamBridge streamBridge;
+
+ private Consumer processor;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = new ObjectMapper();
+ AntiFraudValidationServiceImpl validationService = new AntiFraudValidationServiceImpl();
+ TransactionValidationProcessor processorConfig =
+ new TransactionValidationProcessor(validationService, streamBridge, objectMapper);
+ processor = processorConfig.processTransaction();
+ }
+
+ @Test
+ @DisplayName("Should process and publish APPROVED transaction with amount less than 1000")
+ void shouldProcessAndApproveTransactionWithAmountLessThan1000() throws Exception {
+ // Given: Evento JSON de Debezium con monto < 1000 y operación CREATE
+ String json = """
+ {
+ "transaction_external_id": "550e8400-e29b-41d4-a716-446655440000",
+ "account_external_id_debit": "debit-account-001",
+ "account_external_id_credit": "credit-account-001",
+ "tranfer_type_id": 1,
+ "value": 750.0,
+ "status": "PENDING",
+ "__op": "c",
+ "__source_ts_ms": %d
+ }
+ """.formatted(System.currentTimeMillis());
+
+ // When: Procesar el evento
+ processor.accept(json);
+
+ // Then: Verificar que se publicó un mensaje
+ verify(streamBridge, times(1)).send(eq("processTransaction-out-0"), any());
+ }
+
+ @Test
+ @DisplayName("Should process and publish REJECTED transaction with amount greater than 1000")
+ void shouldProcessAndRejectTransactionWithAmountGreaterThan1000() {
+ // Given: Evento JSON de Debezium con monto > 1000
+ DebeziumTransactionDto dto = new DebeziumTransactionDto(
+ "550e8400-e29b-41d4-a716-446655440001",
+ "debit-account-002",
+ "credit-account-002",
+ 1,
+ 1500.0,
+ "PENDING",
+ "c",
+ System.currentTimeMillis()
+ );
+
+ // When: Procesar el evento
+ processor.accept(dto);
+
+ // Then: Verificar que se publicó un mensaje
+ verify(streamBridge, times(1)).send(eq("processTransaction-out-0"), any());
+ }
+
+ @Test
+ @DisplayName("Should process transaction with amount exactly at threshold (1000)")
+ void shouldProcessTransactionWithAmountExactlyAtThreshold() {
+ // Given: Evento con monto exactamente en el límite
+ DebeziumTransactionDto dto = new DebeziumTransactionDto(
+ "550e8400-e29b-41d4-a716-446655440002",
+ "debit-account-003",
+ "credit-account-003",
+ 1,
+ 1000.0,
+ "PENDING",
+ "c",
+ System.currentTimeMillis()
+ );
+
+ // When: Procesar el evento
+ processor.accept(dto);
+
+ // Then: Verificar que se publicó un mensaje
+ verify(streamBridge, times(1)).send(eq("processTransaction-out-0"), any());
+ }
+
+ @Test
+ @DisplayName("Should skip update operations (op='u')")
+ void shouldSkipUpdateOperations() {
+ // Given: Evento de actualización
+ DebeziumTransactionDto dto = new DebeziumTransactionDto(
+ "550e8400-e29b-41d4-a716-446655440005",
+ "debit-account-006",
+ "credit-account-006",
+ 1,
+ 500.0,
+ "APPROVED",
+ "u",
+ System.currentTimeMillis()
+ );
+
+ // When: Procesar el evento
+ processor.accept(dto);
+
+ // Then: No se debe publicar ningún mensaje
+ verify(streamBridge, times(0)).send(any(), any());
+ }
+
+ @Test
+ @DisplayName("Should skip delete operations (op='d')")
+ void shouldSkipDeleteOperations() {
+ // Given: Evento de eliminación
+ DebeziumTransactionDto dto = new DebeziumTransactionDto(
+ "550e8400-e29b-41d4-a716-446655440006",
+ "debit-account-007",
+ "credit-account-007",
+ 1,
+ 500.0,
+ "REJECTED",
+ "d",
+ System.currentTimeMillis()
+ );
+
+ // When: Procesar el evento
+ processor.accept(dto);
+
+ // Then: No se debe publicar ningún mensaje
+ verify(streamBridge, times(0)).send(any(), any());
+ }
+
+ @Test
+ @DisplayName("Should handle transaction with very high amount")
+ void shouldHandleTransactionWithVeryHighAmount() {
+ // Given: Evento con monto muy alto
+ DebeziumTransactionDto dto = new DebeziumTransactionDto(
+ "550e8400-e29b-41d4-a716-446655440003",
+ "debit-account-004",
+ "credit-account-004",
+ 1,
+ 100000.0,
+ "PENDING",
+ "c",
+ System.currentTimeMillis()
+ );
+
+ // When: Procesar el evento
+ processor.accept(dto);
+
+ // Then: Verificar que se publicó un mensaje
+ verify(streamBridge, times(1)).send(eq("processTransaction-out-0"), any());
+ }
+}
diff --git a/ms-ux-orchestrator/Dockerfile b/ms-ux-orchestrator/Dockerfile
new file mode 100644
index 0000000000..1bbcb10420
--- /dev/null
+++ b/ms-ux-orchestrator/Dockerfile
@@ -0,0 +1,13 @@
+FROM eclipse-temurin:21-jre-alpine
+
+RUN apk add --no-cache curl
+
+WORKDIR /app
+
+ARG JAR_FILE=build/libs/*.jar
+COPY ${JAR_FILE} app.jar
+
+EXPOSE 9991
+
+ENTRYPOINT ["java", "-jar", "app.jar"]
+
diff --git a/ms-ux-orchestrator/build.gradle b/ms-ux-orchestrator/build.gradle
new file mode 100644
index 0000000000..2b2547393b
--- /dev/null
+++ b/ms-ux-orchestrator/build.gradle
@@ -0,0 +1,67 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.4.1'
+ id 'io.spring.dependency-management' version '1.1.7'
+}
+
+group = 'pe.com.yape'
+version = '1.0.0'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+ext {
+ springCloudVersion = '2024.0.0'
+}
+
+dependencies {
+ // Spring Boot Starters
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+
+ // Spring Cloud
+ implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j'
+
+ // Lombok
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+
+ // Dev Tools
+ developmentOnly 'org.springframework.boot:spring-boot-devtools'
+
+ // Testing
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'io.projectreactor:reactor-test'
+ testImplementation 'org.mockito:mockito-core'
+ testImplementation 'org.mockito:mockito-junit-jupiter'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+dependencyManagement {
+ imports {
+ mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
+ }
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
+
+tasks.named('bootJar') {
+ archiveFileName.set("${project.name}-${version}.jar")
+}
+
diff --git a/ms-ux-orchestrator/settings.gradle b/ms-ux-orchestrator/settings.gradle
new file mode 100644
index 0000000000..aa00e2950a
--- /dev/null
+++ b/ms-ux-orchestrator/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = 'ms-ux-orchestrator'
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/OrchestratorApplication.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/OrchestratorApplication.java
new file mode 100644
index 0000000000..6591cdea69
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/OrchestratorApplication.java
@@ -0,0 +1,20 @@
+package pe.com.yape.orchestrator;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * MS-UX-ORCHESTRATOR
+ * Backend for Frontend (BFF) - Punto de entrada del sistema
+ *
+ * @author Yape Engineering Team
+ * @version 1.0.0
+ */
+@SpringBootApplication
+public class OrchestratorApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(OrchestratorApplication.class, args);
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/WebClientConfig.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/WebClientConfig.java
new file mode 100644
index 0000000000..19a47d32c0
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/WebClientConfig.java
@@ -0,0 +1,47 @@
+package pe.com.yape.orchestrator.application.config;
+
+import io.netty.channel.ChannelOption;
+import io.netty.handler.timeout.ReadTimeoutHandler;
+import io.netty.handler.timeout.WriteTimeoutHandler;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.netty.http.client.HttpClient;
+
+/**
+ * Configuracion de WebClient
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Configuration
+public class WebClientConfig {
+
+ @Value("${services.transaction.base-url}")
+ private String transactionServiceBaseUrl;
+
+ @Value("${services.transaction.timeout:5000}")
+ private int timeout;
+
+ @Bean
+ public WebClient transactionServiceWebClient(WebClient.Builder webClientBuilder) {
+ // Configurar HttpClient con Netty
+ HttpClient httpClient = HttpClient.create()
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, timeout)
+ .responseTimeout(Duration.ofMillis(timeout))
+ .doOnConnected(conn ->
+ conn.addHandlerLast(new ReadTimeoutHandler(timeout, TimeUnit.MILLISECONDS))
+ .addHandlerLast(new WriteTimeoutHandler(timeout, TimeUnit.MILLISECONDS))
+ );
+
+ return webClientBuilder
+ .baseUrl(transactionServiceBaseUrl)
+ .clientConnector(new ReactorClientHttpConnector(httpClient))
+ .build();
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/GlobalExceptionHandler.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000000..cbd723e6d0
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/GlobalExceptionHandler.java
@@ -0,0 +1,123 @@
+package pe.com.yape.orchestrator.application.exception;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.bind.support.WebExchangeBindException;
+import org.springframework.web.server.ServerWebExchange;
+import pe.com.yape.orchestrator.model.response.ErrorResponse;
+import reactor.core.publisher.Mono;
+import java.time.LocalDateTime;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Manejador global de excepciones para el ms
+ * @author lmarusic
+ */
+@RestControllerAdvice
+@Slf4j
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(TransactionNotFoundException.class)
+ public Mono> handleTransactionNotFoundException(
+ TransactionNotFoundException ex,
+ ServerWebExchange exchange
+ ) {
+ log.info("Transaction not found: {}", ex.getTransactionId());
+
+ ErrorResponse error = ErrorResponse.builder()
+ .status(HttpStatus.NOT_FOUND.value())
+ .message(ex.getMessage())
+ .path(exchange.getRequest().getPath().value())
+ .timestamp(LocalDateTime.now())
+ .build();
+
+ return Mono.just(ResponseEntity
+ .status(HttpStatus.NOT_FOUND)
+ .body(error));
+ }
+
+ @ExceptionHandler(TransactionServiceException.class)
+ public Mono> handleTransactionServiceException(
+ TransactionServiceException ex,
+ ServerWebExchange exchange
+ ) {
+ log.error("Transaction service error: {}", ex.getMessage());
+
+ ErrorResponse error = ErrorResponse.builder()
+ .status(HttpStatus.BAD_GATEWAY.value())
+ .message("Error communicating with transaction service")
+ .path(exchange.getRequest().getPath().value())
+ .timestamp(LocalDateTime.now())
+ .build();
+
+ return Mono.just(ResponseEntity
+ .status(HttpStatus.BAD_GATEWAY)
+ .body(error));
+ }
+
+ @ExceptionHandler(TimeoutException.class)
+ public Mono> handleTimeoutException(
+ TimeoutException ex,
+ ServerWebExchange exchange
+ ) {
+ log.error("Timeout error: {}", ex.getMessage());
+
+ ErrorResponse error = ErrorResponse.builder()
+ .status(HttpStatus.GATEWAY_TIMEOUT.value())
+ .message("Request timeout - transaction service did not respond in time")
+ .path(exchange.getRequest().getPath().value())
+ .timestamp(LocalDateTime.now())
+ .build();
+
+ return Mono.just(ResponseEntity
+ .status(HttpStatus.GATEWAY_TIMEOUT)
+ .body(error));
+ }
+
+ @ExceptionHandler(WebExchangeBindException.class)
+ public Mono> handleValidationException(
+ WebExchangeBindException ex,
+ ServerWebExchange exchange
+ ) {
+ log.warn("Validation error: {}", ex.getMessage());
+
+ StringBuilder errors = new StringBuilder();
+ ex.getBindingResult().getFieldErrors().forEach(error ->
+ errors.append(error.getField()).append(": ").append(error.getDefaultMessage()).append("; ")
+ );
+
+ ErrorResponse error = ErrorResponse.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .message("Validation failed: " + errors.toString())
+ .path(exchange.getRequest().getPath().value())
+ .timestamp(LocalDateTime.now())
+ .build();
+
+ return Mono.just(ResponseEntity
+ .status(HttpStatus.BAD_REQUEST)
+ .body(error));
+ }
+
+ @ExceptionHandler(Exception.class)
+ public Mono> handleGenericException(
+ Exception ex,
+ ServerWebExchange exchange
+ ) {
+ log.error("Unexpected error: {}", ex.getMessage(), ex);
+
+ ErrorResponse error = ErrorResponse.builder()
+ .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
+ .message("An unexpected error occurred")
+ .path(exchange.getRequest().getPath().value())
+ .timestamp(LocalDateTime.now())
+ .build();
+
+ return Mono.just(ResponseEntity
+ .status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(error));
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/TransactionNotFoundException.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/TransactionNotFoundException.java
new file mode 100644
index 0000000000..924b44b602
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/TransactionNotFoundException.java
@@ -0,0 +1,22 @@
+package pe.com.yape.orchestrator.application.exception;
+
+import java.util.UUID;
+
+/**
+ * Excepción lanzada cuando una transacción no es encontrada
+ * @author lmarusic
+ */
+public class TransactionNotFoundException extends RuntimeException {
+
+ private final UUID transactionId;
+
+ public TransactionNotFoundException(UUID transactionId) {
+ super("Transaction not found with ID: " + transactionId);
+ this.transactionId = transactionId;
+ }
+
+ public UUID getTransactionId() {
+ return transactionId;
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/TransactionServiceException.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/TransactionServiceException.java
new file mode 100644
index 0000000000..985f4798df
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/TransactionServiceException.java
@@ -0,0 +1,19 @@
+package pe.com.yape.orchestrator.application.exception;
+
+/**
+ * Excepción personalizada cuadno hay un error con transaction-service
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public class TransactionServiceException extends RuntimeException {
+
+ public TransactionServiceException(String message) {
+ super(message);
+ }
+
+ public TransactionServiceException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/TransactionController.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/TransactionController.java
new file mode 100644
index 0000000000..4879d1dea8
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/TransactionController.java
@@ -0,0 +1,81 @@
+package pe.com.yape.orchestrator.controller;
+
+import java.util.UUID;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import pe.com.yape.orchestrator.model.request.CreateTransactionRequest;
+import pe.com.yape.orchestrator.model.response.TransactionResponse;
+import pe.com.yape.orchestrator.service.TransactionOrchestrationService;
+import reactor.core.publisher.Mono;
+
+/**
+ * Controlador REST
+ *
+ * Endpoints:
+ * - POST /transaction - Crear transaccion
+ * - GET /transaction/{id} - Obtener transaccion x ID
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@RestController
+@RequestMapping("/transaction")
+@RequiredArgsConstructor
+@Validated
+@Slf4j
+public class TransactionController {
+
+ private final TransactionOrchestrationService orchestrationService;
+
+ /**
+ * Crea una nueva transaccion
+ *
+ * POST /transaction
+ *
+ * @param request CreateTransactionRequest
+ * @return Mono (201 CREATED)
+ */
+ @PostMapping
+ public Mono> createTransaction(
+ @Valid @RequestBody CreateTransactionRequest request ) {
+
+ log.info("POST /transaction - Starting createTransaction method");
+ return orchestrationService.createTransaction(request)
+ .map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response))
+ .doOnSuccess(response -> log.info("Transaction created: {}",
+ response.getBody().getTransactionExternalId()));
+ }
+
+ /**
+ * Obtiene una transaccion por ID
+ *
+ * GET /transaction/{transactionExternalId}
+ *
+ * @param transactionExternalId ID de la transaccion
+ * @return Mono (200 OK)
+ */
+ @GetMapping("/{transactionExternalId}")
+ public Mono> getTransaction(
+ @PathVariable UUID transactionExternalId ) {
+
+ log.info("GET /transaction/{} - Retrieving transaction", transactionExternalId);
+ return orchestrationService.getTransaction(transactionExternalId)
+ .map(ResponseEntity::ok)
+ .doOnSuccess(response ->
+ log.info("Transaction retrieved: {}", transactionExternalId));
+ }
+
+ /**
+ * Health check endpoint
+ */
+ @GetMapping("/health")
+ public Mono> health() {
+ return Mono.just(ResponseEntity.ok("Orchestrator Service is UP"));
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/TransactionStatus.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/TransactionStatus.java
new file mode 100644
index 0000000000..c26049a75d
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/TransactionStatus.java
@@ -0,0 +1,33 @@
+package pe.com.yape.orchestrator.model;
+
+/**
+ * Estados posibles de la transacción
+ *
+ * @author Yape Engineering Team
+ * @version 1.0.0
+ */
+public enum TransactionStatus {
+ PENDING("pending"),
+ APPROVED("approved"),
+ REJECTED("rejected");
+
+ private final String value;
+
+ TransactionStatus(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public static TransactionStatus fromValue(String value) {
+ for (TransactionStatus status : values()) {
+ if (status.value.equalsIgnoreCase(value)) {
+ return status;
+ }
+ }
+ throw new IllegalArgumentException("Invalid transaction status: " + value);
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/request/CreateTransactionRequest.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/request/CreateTransactionRequest.java
new file mode 100644
index 0000000000..8001b4328c
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/request/CreateTransactionRequest.java
@@ -0,0 +1,37 @@
+package pe.com.yape.orchestrator.model.request;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Objeto de request para crear la transaccion
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CreateTransactionRequest {
+
+ @NotNull(message = "Account external ID debit is required")
+ private UUID accountExternalIdDebit;
+
+ @NotNull(message = "Account external ID credit is required")
+ private UUID accountExternalIdCredit;
+
+ @NotNull(message = "Transfer type ID is required")
+ private Integer tranferTypeId;
+
+ @NotNull(message = "Value is required")
+ @DecimalMin(value = "0.01", message = "Value must be greater than zero")
+ private BigDecimal value;
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/response/ErrorResponse.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/response/ErrorResponse.java
new file mode 100644
index 0000000000..c7aa89a6ff
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/response/ErrorResponse.java
@@ -0,0 +1,26 @@
+package pe.com.yape.orchestrator.model.response;
+
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Formato de respuesta de error para todos los errores de la API
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ErrorResponse {
+
+ private int status;
+ private String message;
+ private String path;
+ private LocalDateTime timestamp;
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/response/TransactionResponse.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/response/TransactionResponse.java
new file mode 100644
index 0000000000..aa5edaafab
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/response/TransactionResponse.java
@@ -0,0 +1,51 @@
+package pe.com.yape.orchestrator.model.response;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Objeto de respuesta de la transaccion
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TransactionResponse {
+
+ private UUID transactionExternalId;
+ private TransactionTypeDto transactionType;
+ private TransactionStatusDto transactionStatus;
+ private BigDecimal value;
+ private LocalDateTime createdAt;
+
+ /**
+ * DTO anidado para tipo de transacción
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class TransactionTypeDto {
+ private String name;
+ }
+
+ /**
+ * DTO anidado para estado de transacción
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class TransactionStatusDto {
+ private String name;
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/TransactionOrchestrationService.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/TransactionOrchestrationService.java
new file mode 100644
index 0000000000..0f43c6951a
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/TransactionOrchestrationService.java
@@ -0,0 +1,33 @@
+package pe.com.yape.orchestrator.service;
+
+import java.util.UUID;
+import pe.com.yape.orchestrator.model.request.CreateTransactionRequest;
+import pe.com.yape.orchestrator.model.response.TransactionResponse;
+import reactor.core.publisher.Mono;
+
+/**
+ * Servicio de orquestacion de transacciones
+ * aca se puede implementar el realizar distintas acciones en base a featureFlags
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public interface TransactionOrchestrationService {
+
+ /**
+ * Orquesta la creacion de una transaccion
+ *
+ * @param request CreateTransactionRequest
+ * @return Mono TransactionResponse
+ */
+ Mono createTransaction(CreateTransactionRequest request);
+
+ /**
+ * Orquesta la consulta de una transaccion
+ *
+ * @param transactionExternalId ID de la transacción
+ * @return Mono TransactionResponse
+ */
+ Mono getTransaction(UUID transactionExternalId);
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/TransactionOrchestrationServiceImpl.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/TransactionOrchestrationServiceImpl.java
new file mode 100644
index 0000000000..8f3025cb2c
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/TransactionOrchestrationServiceImpl.java
@@ -0,0 +1,48 @@
+package pe.com.yape.orchestrator.service;
+
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import pe.com.yape.orchestrator.model.request.CreateTransactionRequest;
+import pe.com.yape.orchestrator.model.response.TransactionResponse;
+import pe.com.yape.orchestrator.service.port.TransactionServicePort;
+import reactor.core.publisher.Mono;
+
+/**
+ * Implementacion de la clase TransactionOrchestrationService
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class TransactionOrchestrationServiceImpl implements TransactionOrchestrationService {
+
+ private final TransactionServicePort transactionServicePort;
+
+ @Override
+ public Mono createTransaction(CreateTransactionRequest request) {
+ log.info("Orchestrating transaction creation - Debit: {}, Credit: {}, Value: {}",
+ request.getAccountExternalIdDebit(),
+ request.getAccountExternalIdCredit(),
+ request.getValue());
+ return transactionServicePort.createTransaction(request)
+ .doOnSuccess(response ->
+ log.info("Transaction created successfully: {}", response.getTransactionExternalId()))
+ .doOnError(error ->
+ log.error("Error creating transaction", error));
+ }
+
+ @Override
+ public Mono getTransaction(UUID transactionExternalId) {
+ log.info("Orchestrating transaction retrieval: {}", transactionExternalId);
+ return transactionServicePort.getTransactionById(transactionExternalId)
+ .doOnSuccess(response ->
+ log.info("Transaction retrieved successfully: {}", transactionExternalId))
+ .doOnError(error ->
+ log.error("Error retrieving transaction: {}", transactionExternalId, error));
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/adapter/TransactionServiceWebClientAdapter.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/adapter/TransactionServiceWebClientAdapter.java
new file mode 100644
index 0000000000..0bfdceafd4
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/adapter/TransactionServiceWebClientAdapter.java
@@ -0,0 +1,92 @@
+package pe.com.yape.orchestrator.service.adapter;
+
+import java.time.Duration;
+import java.util.UUID;
+import java.util.concurrent.TimeoutException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import pe.com.yape.orchestrator.application.exception.TransactionServiceException;
+import pe.com.yape.orchestrator.model.request.CreateTransactionRequest;
+import pe.com.yape.orchestrator.model.response.TransactionResponse;
+import pe.com.yape.orchestrator.service.port.TransactionServicePort;
+import reactor.core.publisher.Mono;
+
+/**
+ * WebClient para comunicacion con transaction-service
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class TransactionServiceWebClientAdapter implements TransactionServicePort {
+
+ private final WebClient transactionServiceWebClient;
+
+ @Value("${services.transaction.timeout:5000}")
+ private long timeout;
+
+ @Override
+ public Mono createTransaction(CreateTransactionRequest request) {
+
+ log.debug("Calling transaction-service to create transaction");
+ return transactionServiceWebClient
+ .post()
+ .uri("/v1/transactions")
+ .bodyValue(request)
+ .retrieve()
+ .onStatus(
+ status -> status.is4xxClientError() || status.is5xxServerError(),
+ clientResponse -> clientResponse.bodyToMono(String.class)
+ .flatMap(errorBody -> {
+ log.error("Error from transaction-service: {}", errorBody);
+ return Mono.error(new TransactionServiceException(
+ "Error creating transaction: " + errorBody));
+ })
+ )
+ .bodyToMono(TransactionResponse.class)
+ .timeout(Duration.ofMillis(timeout))
+ .doOnError(error ->
+ log.error("Failed to create transaction in transaction-service", error));
+ }
+
+ @Override
+ public Mono getTransactionById(UUID transactionExternalId) {
+
+ log.debug("Calling transaction-service to get transaction: {}", transactionExternalId);
+ return transactionServiceWebClient
+ .get()
+ .uri("/v1/transactions/{id}", transactionExternalId)
+ .retrieve()
+ .onStatus(
+ status -> status.value() == 404,
+ clientResponse -> {
+ log.info("Transaction not found: {}", transactionExternalId);
+ return Mono.error(new pe.com.yape.orchestrator.application.exception.TransactionNotFoundException(
+ transactionExternalId));
+ }
+ )
+ .onStatus(
+ status -> status.is4xxClientError() || status.is5xxServerError(),
+ clientResponse -> clientResponse.bodyToMono(String.class)
+ .flatMap(errorBody -> {
+ log.error("Error from transaction-service: {}", errorBody);
+ return Mono.error(new TransactionServiceException(
+ "Error communicating with transaction service: " + errorBody));
+ })
+ )
+ .bodyToMono(TransactionResponse.class)
+ .timeout(Duration.ofMillis(timeout))
+ .doOnError(TimeoutException.class, error ->
+ log.error("Timeout getting transaction from transaction-service: {}",
+ transactionExternalId))
+ .doOnError(TransactionServiceException.class, error ->
+ log.error("Failed to get transaction from transaction-service: {}",
+ transactionExternalId));
+ }
+}
+
diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/port/TransactionServicePort.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/port/TransactionServicePort.java
new file mode 100644
index 0000000000..b73f5eddec
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/port/TransactionServicePort.java
@@ -0,0 +1,32 @@
+package pe.com.yape.orchestrator.service.port;
+
+import java.util.UUID;
+import pe.com.yape.orchestrator.model.request.CreateTransactionRequest;
+import pe.com.yape.orchestrator.model.response.TransactionResponse;
+import reactor.core.publisher.Mono;
+
+/**
+ * Puerto de salida para comunicacion con el microservicio de transacciones
+ *
+ * @author lmarusic
+ * @version 1.0.0
+ */
+public interface TransactionServicePort {
+
+ /**
+ * Crea una nueva transaccion en el ms de transacciones
+ *
+ * @param request CreateTransactionRequest
+ * @return Mono TransactionResponse
+ */
+ Mono createTransaction(CreateTransactionRequest request);
+
+ /**
+ * Obtiene una transaccion x ID
+ *
+ * @param transactionExternalId ID unico de la transaccion
+ * @return Mono TransactionResponse
+ */
+ Mono getTransactionById(UUID transactionExternalId);
+}
+
diff --git a/ms-ux-orchestrator/src/main/resources/application.yml b/ms-ux-orchestrator/src/main/resources/application.yml
new file mode 100644
index 0000000000..5f3c93bf18
--- /dev/null
+++ b/ms-ux-orchestrator/src/main/resources/application.yml
@@ -0,0 +1,26 @@
+spring:
+ application:
+ name: ms-ux-orchestrator
+
+server:
+ port: 9991
+
+services:
+ transaction:
+ base-url: ${SERVICES_TRANSACTION_BASE_URL:http://localhost:9992}
+ timeout: ${SERVICES_TRANSACTION_TIMEOUT:5000}
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics
+ endpoint:
+ health:
+ show-details: always
+
+logging:
+ level:
+ pe.com.yape.orchestrator: INFO
+ org.springframework.web.reactive: DEBUG
+
diff --git a/ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/service/TransactionOrchestrationServiceImplTest.java b/ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/service/TransactionOrchestrationServiceImplTest.java
new file mode 100644
index 0000000000..172f08d93f
--- /dev/null
+++ b/ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/service/TransactionOrchestrationServiceImplTest.java
@@ -0,0 +1,148 @@
+package pe.com.yape.orchestrator.service;
+
+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 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.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import pe.com.yape.orchestrator.application.exception.TransactionServiceException;
+import pe.com.yape.orchestrator.model.request.CreateTransactionRequest;
+import pe.com.yape.orchestrator.model.response.TransactionResponse;
+import pe.com.yape.orchestrator.service.port.TransactionServicePort;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+/**
+ * Test unitario para TransactionOrchestrationServiceImpl
+ * @author lmarusic
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Transaction Orchestration Service Tests")
+class TransactionOrchestrationServiceImplTest {
+
+ @Mock
+ private TransactionServicePort transactionServicePort;
+
+ @InjectMocks
+ private TransactionOrchestrationServiceImpl orchestrationService;
+
+ private CreateTransactionRequest createRequest;
+ private TransactionResponse transactionResponse;
+ private UUID transactionId;
+
+ @BeforeEach
+ void setUp() {
+ transactionId = UUID.randomUUID();
+
+ createRequest = CreateTransactionRequest.builder()
+ .accountExternalIdDebit(UUID.randomUUID())
+ .accountExternalIdCredit(UUID.randomUUID())
+ .tranferTypeId(1)
+ .value(new BigDecimal("100.50"))
+ .build();
+
+ transactionResponse = TransactionResponse.builder()
+ .transactionExternalId(transactionId)
+ .value(new BigDecimal("100.50"))
+ .createdAt(LocalDateTime.now())
+ .transactionType(TransactionResponse.TransactionTypeDto.builder()
+ .name("Transfer")
+ .build())
+ .transactionStatus(TransactionResponse.TransactionStatusDto.builder()
+ .name("PENDING")
+ .build())
+ .build();
+ }
+
+ @Test
+ @DisplayName("Should create transaction successfully")
+ void shouldCreateTransactionSuccessfully() {
+ when(transactionServicePort.createTransaction(any(CreateTransactionRequest.class)))
+ .thenReturn(Mono.just(transactionResponse));
+
+ Mono result = orchestrationService.createTransaction(createRequest);
+
+ StepVerifier.create(result)
+ .assertNext(response -> {
+ assertNotNull(response);
+ assertEquals(transactionId, response.getTransactionExternalId());
+ assertEquals(new BigDecimal("100.50"), response.getValue());
+ assertEquals("PENDING", response.getTransactionStatus().getName());
+ })
+ .verifyComplete();
+
+ verify(transactionServicePort).createTransaction(createRequest);
+ }
+
+ @Test
+ @DisplayName("Should handle error when creating transaction")
+ void shouldHandleErrorWhenCreatingTransaction() {
+ TransactionServiceException exception =
+ new TransactionServiceException("Service unavailable");
+
+ when(transactionServicePort.createTransaction(any(CreateTransactionRequest.class)))
+ .thenReturn(Mono.error(exception));
+
+ Mono result = orchestrationService.createTransaction(createRequest);
+
+ StepVerifier.create(result)
+ .expectErrorMatches(throwable ->
+ throwable instanceof TransactionServiceException &&
+ throwable.getMessage().equals("Service unavailable"))
+ .verify();
+
+ verify(transactionServicePort).createTransaction(createRequest);
+ }
+
+ @Test
+ @DisplayName("Should get transaction successfully")
+ void shouldGetTransactionSuccessfully() {
+ when(transactionServicePort.getTransactionById(transactionId))
+ .thenReturn(Mono.just(transactionResponse));
+
+ Mono result = orchestrationService.getTransaction(transactionId);
+
+ StepVerifier.create(result)
+ .assertNext(response -> {
+ assertNotNull(response);
+ assertEquals(transactionId, response.getTransactionExternalId());
+ assertEquals(new BigDecimal("100.50"), response.getValue());
+ })
+ .verifyComplete();
+
+ verify(transactionServicePort).getTransactionById(transactionId);
+ }
+
+ @Test
+ @DisplayName("Should handle error when getting transaction")
+ void shouldHandleErrorWhenGettingTransaction() {
+ TransactionServiceException exception =
+ new TransactionServiceException("Transaction not found");
+
+ when(transactionServicePort.getTransactionById(transactionId))
+ .thenReturn(Mono.error(exception));
+
+ Mono result = orchestrationService.getTransaction(transactionId);
+
+ StepVerifier.create(result)
+ .expectErrorMatches(throwable ->
+ throwable instanceof TransactionServiceException &&
+ throwable.getMessage().equals("Transaction not found"))
+ .verify();
+
+ verify(transactionServicePort).getTransactionById(transactionId);
+ }
+
+}
+
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000000..e74454a3ea
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,6 @@
+rootProject.name = 'yape-code-challenge'
+
+include 'ms-ux-orchestrator'
+include 'ms-ne-transaction-service'
+include 'ms-sp-antifraud-rules'
+