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 -
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. -
+ 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 -
    -
  1. Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
  2. -
  3. Any database
  4. -
  5. Kafka
  6. -
+### 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' +