From 504ed6d4c6ff417c84f21d3734be9164da3e6730 Mon Sep 17 00:00:00 2001 From: Ludwig Marusic Date: Sat, 3 Jan 2026 20:41:51 -0500 Subject: [PATCH 1/5] feat: microservices creation, define structure, initial configs --- .gitignore | 183 +++++++++--------- README.md | 62 +++++- build-all.bat | 21 ++ build-all.sh | 25 +++ ms-ne-transaction-service/Dockerfile | 11 ++ ms-ne-transaction-service/build.gradle | 90 +++++++++ ms-ne-transaction-service/settings.gradle | 2 + .../TransactionServiceApplication.java | 22 +++ .../transaction/application/config/.gitkeep | 0 .../application/exception/.gitkeep | 0 .../pe/com/yape/ms/transaction/cache/.gitkeep | 0 .../yape/ms/transaction/controller/.gitkeep | 0 .../ms/transaction/kafka/consumer/.gitkeep | 0 .../yape/ms/transaction/kafka/model/.gitkeep | 0 .../yape/ms/transaction/model/domain/.gitkeep | 0 .../yape/ms/transaction/model/entity/.gitkeep | 0 .../yape/ms/transaction/model/port/.gitkeep | 0 .../transaction/repository/adapter/.gitkeep | 0 .../ms/transaction/repository/jpa/.gitkeep | 0 .../com/yape/ms/transaction/service/.gitkeep | 0 .../src/main/resources/application.yml | 46 +++++ .../src/main/resources/db/migration/.gitkeep | 0 .../java/pe/com/yape/ms/transaction/.gitkeep | 0 ms-sp-antifraud-rules/Dockerfile | 11 ++ ms-sp-antifraud-rules/build.gradle | 71 +++++++ ms-sp-antifraud-rules/settings.gradle | 2 + .../AntiFraudServiceApplication.java | 20 ++ .../ms/antifraud/application/config/.gitkeep | 0 .../com/yape/ms/antifraud/controller/.gitkeep | 0 .../yape/ms/antifraud/model/domain/.gitkeep | 0 .../yape/ms/antifraud/model/event/.gitkeep | 0 .../yape/ms/antifraud/service/impl/.gitkeep | 0 .../yape/ms/antifraud/service/rules/.gitkeep | 0 .../src/main/resources/application.yml | 26 +++ .../java/pe/com/yape/ms/antifraud/.gitkeep | 0 ms-ux-orchestrator/Dockerfile | 11 ++ ms-ux-orchestrator/build.gradle | 67 +++++++ ms-ux-orchestrator/settings.gradle | 2 + .../orchestrator/OrchestratorApplication.java | 20 ++ .../orchestrator/application/config/.gitkeep | 0 .../application/exception/.gitkeep | 0 .../com/yape/orchestrator/controller/.gitkeep | 0 .../pe/com/yape/orchestrator/model/.gitkeep | 0 .../pe/com/yape/orchestrator/service/.gitkeep | 0 .../src/main/resources/application.yml | 20 ++ .../java/pe/com/yape/orchestrator/.gitkeep | 0 settings.gradle | 6 + 47 files changed, 620 insertions(+), 98 deletions(-) create mode 100644 build-all.bat create mode 100644 build-all.sh create mode 100644 ms-ne-transaction-service/Dockerfile create mode 100644 ms-ne-transaction-service/build.gradle create mode 100644 ms-ne-transaction-service/settings.gradle create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/TransactionServiceApplication.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/cache/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/consumer/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/model/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/port/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/adapter/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/jpa/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/resources/application.yml create mode 100644 ms-ne-transaction-service/src/main/resources/db/migration/.gitkeep create mode 100644 ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/.gitkeep create mode 100644 ms-sp-antifraud-rules/Dockerfile create mode 100644 ms-sp-antifraud-rules/build.gradle create mode 100644 ms-sp-antifraud-rules/settings.gradle create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/AntiFraudServiceApplication.java create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/config/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/controller/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/event/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/rules/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/resources/application.yml create mode 100644 ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/.gitkeep create mode 100644 ms-ux-orchestrator/Dockerfile create mode 100644 ms-ux-orchestrator/build.gradle create mode 100644 ms-ux-orchestrator/settings.gradle create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/OrchestratorApplication.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/resources/application.yml create mode 100644 ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/.gitkeep create mode 100644 settings.gradle 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/README.md b/README.md index b067a71026..ad825e5432 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,67 @@ Every transaction with a value greater than 1000 should be rejected. # Tech Stack +Esta implementación tiene: +
    -
  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. +
  7. Java 21 - Aprovechando Virtual Threads y Records
  8. +
  9. Spring Boot 3.4.1 - Framework base para los microservicios
  10. +
  11. PostgreSQL - Base de datos principal con CDC habilitado
  12. +
  13. Redis - Cache para lecturas de alto volumen
  14. +
  15. Kafka + Debezium - Event Streaming y CDC
  16. +
  17. Spring Cloud Stream - Comunicación con Kafka
  18. +
  19. Flyway - Migraciones de base de datos
-We do provide a `Dockerfile` to help you get started with a dev environment. +## 🏗️ Arquitectura de Microservicios + +El proyecto está organizado en 3 microservicios siguiendo : + +### 1. **ms-ux-orchestrator** (Puerto: 8080) +- **Rol**: Backend for Frontend (BFF) +- **Tecnologías**: Spring WebFlux, WebClient +- **Responsabilidad**: API Gateway, validaciones y orquestación + +### 2. **ms-ne-transaction-service** (Puerto: 8081) +- **Rol**: Core de Negocio +- **Tecnologías**: Spring Data JPA, PostgreSQL, Redis, Kafka Consumer +- **Responsabilidad**: Gestión de transacciones y persistencia +- **Patrones**: Transactional Outbox implementado con CDC + +### 3. **ms-sp-antifraud-rules** (Puerto: 8082) +- **Rol**: Motor de Reglas Especializadas +- **Tecnologías**: Kafka Streams, Spring Cloud Stream +- **Responsabilidad**: Validación anti-fraude en tiempo real +- **Patrones**: Event Streaming, Chain of Responsibility + +## 📦 Estructura del Proyecto + +``` +app-nodejs-codechallenge/ +├── ms-ux-orchestrator/ # BFF - API Gateway +├── ms-ne-transaction-service/ # Servicio Core de Transacciones +├── ms-sp-antifraud-rules/ # Motor de Reglas Anti-Fraude +├── settings.gradle # Configuración multi-proyecto +├── build-all.sh # Script de compilación (Linux/Mac) +├── build-all.bat # Script de compilación (Windows) +└── docker-compose.yml # Orquestación de servicios +``` + +Cada microservicio sigue la estructura de **Arquitectura Hexagonal**: +- `controller/` - Adaptadores de entrada (REST) +- `service/` - Casos de uso (Application Layer) +- `model/` - Dominio (domain, entity, port) +- `repository/` - Adaptadores de salida (Persistencia) +- `application/config/` - Configuración +- `application/exception/` - Manejo de errores + +## 🚀 Cómo Ejecutar + +### Pre-requisitos +- Java 21 JDK zulu +- Gradle 8.x (aca falta incluir la varsionc on el wrapper para que funcione siempre) +- Docker & Docker Compose + You must have two resources: diff --git a/build-all.bat b/build-all.bat new file mode 100644 index 0000000000..9631cadc21 --- /dev/null +++ b/build-all.bat @@ -0,0 +1,21 @@ +@echo off +echo 🏗️ Compilando todos los microservicios... + +echo 📦 Compilando ms-ne-transaction-service... +cd ms-ne-transaction-service +call gradle clean build -x test +cd .. + +echo 🛡️ Compilando ms-sp-antifraud-rules... +cd ms-sp-antifraud-rules +call gradle clean build -x test +cd .. + +echo 🎯 Compilando ms-ux-orchestrator... +cd ms-ux-orchestrator +call gradle clean build -x test +cd .. + +echo ✅ Todos los servicios compilados exitosamente! +echo 🐳 Ejecuta 'docker-compose up --build' para levantar los servicios + diff --git a/build-all.sh b/build-all.sh new file mode 100644 index 0000000000..089294f4fa --- /dev/null +++ b/build-all.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +echo "🏗️ Compilando todos los microservicios..." + +# Transaction Service +echo "📦 Compilando ms-ne-transaction-service..." +cd ms-ne-transaction-service +gradle clean build -x test +cd .. + +# Anti-Fraud Service +echo "🛡️ Compilando ms-sp-antifraud-rules..." +cd ms-sp-antifraud-rules +gradle clean build -x test +cd .. + +# Orchestrator +echo "🎯 Compilando ms-ux-orchestrator..." +cd ms-ux-orchestrator +gradle clean build -x test +cd .. + +echo "✅ Todos los servicios compilados exitosamente!" +echo "🐳 Ejecuta 'docker-compose up --build' para levantar los servicios" + diff --git a/ms-ne-transaction-service/Dockerfile b/ms-ne-transaction-service/Dockerfile new file mode 100644 index 0000000000..894893fcd9 --- /dev/null +++ b/ms-ne-transaction-service/Dockerfile @@ -0,0 +1,11 @@ +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar + +EXPOSE 8081 + +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..468035e038 --- /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 Starters + 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' + + // PostgreSQL + runtimeOnly 'org.postgresql:postgresql' + + // Flyway Migration + 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..7a4e980f39 --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/TransactionServiceApplication.java @@ -0,0 +1,22 @@ +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 + * Servicio de negocio core para gestión de transacciones + * + * @author Yape Engineering Team + * @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/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/cache/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/cache/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/consumer/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/consumer/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/model/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/model/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/port/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/port/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/adapter/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/adapter/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/jpa/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/jpa/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 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..2dbcdfee72 --- /dev/null +++ b/ms-ne-transaction-service/src/main/resources/application.yml @@ -0,0 +1,46 @@ +spring: + application: + name: ms-ne-transaction-service + + datasource: + url: jdbc:postgresql://localhost:5432/transactiondb + username: yape + password: yape123 + 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: localhost + port: 6379 + timeout: 60000 + + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + +server: + port: 8081 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +logging: + level: + pe.com.yape.ms.transaction: INFO + diff --git a/ms-ne-transaction-service/src/main/resources/db/migration/.gitkeep b/ms-ne-transaction-service/src/main/resources/db/migration/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/.gitkeep b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-sp-antifraud-rules/Dockerfile b/ms-sp-antifraud-rules/Dockerfile new file mode 100644 index 0000000000..b0d88e5901 --- /dev/null +++ b/ms-sp-antifraud-rules/Dockerfile @@ -0,0 +1,11 @@ +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar + +EXPOSE 8082 + +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..ad8570af0d --- /dev/null +++ b/ms-sp-antifraud-rules/build.gradle @@ -0,0 +1,71 @@ +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 Streams + implementation 'org.springframework.cloud:spring-cloud-starter-stream-kafka' + implementation 'org.apache.kafka:kafka-streams' + implementation 'org.springframework.kafka:spring-kafka' + + // Kafka Streams Test Utils + testImplementation 'org.apache.kafka:kafka-streams-test-utils' + + // 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' + 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..8e940ea35a --- /dev/null +++ b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/AntiFraudServiceApplication.java @@ -0,0 +1,20 @@ +package pe.com.yape.ms.antifraud; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * MS-SP-ANTIFRAUD-RULES + * Motor de reglas especializadas para detección de fraude + * + * @author Yape Engineering Team + * @version 1.0.0 + */ +@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/config/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/config/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/controller/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/controller/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/event/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/event/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/rules/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/rules/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 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..c901d6ef15 --- /dev/null +++ b/ms-sp-antifraud-rules/src/main/resources/application.yml @@ -0,0 +1,26 @@ +spring: + application: + name: ms-sp-antifraud-rules + + cloud: + stream: + kafka: + binder: + brokers: localhost:9092 + +server: + port: 8082 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +logging: + level: + pe.com.yape.ms.antifraud: INFO + diff --git a/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/.gitkeep b/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ux-orchestrator/Dockerfile b/ms-ux-orchestrator/Dockerfile new file mode 100644 index 0000000000..c4779c8f5b --- /dev/null +++ b/ms-ux-orchestrator/Dockerfile @@ -0,0 +1,11 @@ +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar + +EXPOSE 8080 + +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/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 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..a1a2515669 --- /dev/null +++ b/ms-ux-orchestrator/src/main/resources/application.yml @@ -0,0 +1,20 @@ +spring: + application: + name: ms-ux-orchestrator + +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +logging: + level: + pe.com.yape.orchestrator: INFO + diff --git a/ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/.gitkeep b/ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 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' + From 41767f514d56409da4c3f3aa0cbcc46bdf0a3eee Mon Sep 17 00:00:00 2001 From: Ludwig Marusic Date: Tue, 6 Jan 2026 04:22:58 -0500 Subject: [PATCH 2/5] feat: docker components configuration, modify transaction and orchestrator component --- Dockerfile | 7 + build-all.bat | 21 -- build-all.sh | 25 -- docker-compose.yml | 251 +++++++++++++++++- ms-ne-transaction-service/Dockerfile | 2 +- ms-ne-transaction-service/build.gradle | 8 +- .../TransactionServiceApplication.java | 5 +- .../transaction/application/config/.gitkeep | 0 .../application/config/RedisConfig.java | 34 +++ .../application/exception/.gitkeep | 0 .../exception/GlobalExceptionHandler.java | 78 ++++++ .../InvalidTransactionStateException.java | 44 +++ .../TransactionNotFoundException.java | 24 ++ .../pe/com/yape/ms/transaction/cache/.gitkeep | 0 .../yape/ms/transaction/controller/.gitkeep | 0 .../controller/TransactionController.java | 81 ++++++ .../dto/CreateTransactionRequest.java | 35 +++ .../controller/dto/TransactionResponse.java | 49 ++++ .../controller/mapper/TransactionMapper.java | 33 +++ .../ms/transaction/kafka/consumer/.gitkeep | 0 .../yape/ms/transaction/kafka/model/.gitkeep | 0 .../yape/ms/transaction/model/domain/.gitkeep | 0 .../transaction/model/domain/Transaction.java | 112 ++++++++ .../model/domain/TransactionStatus.java | 51 ++++ .../model/domain/TransactionType.java | 62 +++++ .../yape/ms/transaction/model/entity/.gitkeep | 0 .../model/entity/TransactionEntity.java | 100 +++++++ .../model/exception/ErrorResponse.java | 25 ++ .../yape/ms/transaction/model/port/.gitkeep | 0 .../transaction/repository/adapter/.gitkeep | 0 .../ms/transaction/repository/jpa/.gitkeep | 0 .../com/yape/ms/transaction/service/.gitkeep | 0 .../service/CreateTransactionUseCase.java | 31 +++ .../service/GetTransactionUseCase.java | 23 ++ .../UpdateTransactionStatusUseCase.java | 24 ++ .../adapter/cache/RedisCacheAdapter.java | 88 ++++++ .../TransactionRepositoryAdapter.java | 71 +++++ .../jpa/TransactionJpaRepository.java | 28 ++ .../impl/CreateTransactionUseCaseImpl.java | 75 ++++++ .../impl/GetTransactionUseCaseImpl.java | 60 +++++ .../UpdateTransactionStatusUseCaseImpl.java | 73 +++++ .../service/port/CacheRepositoryPort.java | 44 +++ .../port/TransactionRepositoryPort.java | 47 ++++ .../src/main/resources/application.yml | 8 +- .../src/main/resources/db/migration/.gitkeep | 0 .../V1__create_transaction_table.sql | 20 ++ .../java/pe/com/yape/ms/transaction/.gitkeep | 0 .../model/domain/TransactionTest.java | 214 +++++++++++++++ .../service/CreateTransactionUseCaseTest.java | 178 +++++++++++++ .../service/GetTransactionUseCaseTest.java | 155 +++++++++++ .../UpdateTransactionStatusUseCaseTest.java | 195 ++++++++++++++ ms-ux-orchestrator/Dockerfile | 2 +- .../orchestrator/application/config/.gitkeep | 0 .../application/config/WebClientConfig.java | 47 ++++ .../application/exception/.gitkeep | 0 .../exception/GlobalExceptionHandler.java | 102 +++++++ .../TransactionServiceException.java | 19 ++ .../com/yape/orchestrator/controller/.gitkeep | 0 .../controller/TransactionController.java | 81 ++++++ .../pe/com/yape/orchestrator/model/.gitkeep | 0 .../orchestrator/model/TransactionStatus.java | 33 +++ .../request/CreateTransactionRequest.java | 37 +++ .../model/response/ErrorResponse.java | 26 ++ .../model/response/TransactionResponse.java | 51 ++++ .../pe/com/yape/orchestrator/service/.gitkeep | 0 .../TransactionOrchestrationService.java | 33 +++ .../TransactionOrchestrationServiceImpl.java | 48 ++++ .../TransactionServiceWebClientAdapter.java | 80 ++++++ .../service/port/TransactionServicePort.java | 32 +++ .../src/main/resources/application.yml | 8 +- 70 files changed, 2910 insertions(+), 70 deletions(-) create mode 100644 Dockerfile delete mode 100644 build-all.bat delete mode 100644 build-all.sh delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/RedisConfig.java delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/GlobalExceptionHandler.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/InvalidTransactionStateException.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/TransactionNotFoundException.java delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/cache/.gitkeep delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/TransactionController.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/CreateTransactionRequest.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionResponse.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/mapper/TransactionMapper.java delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/consumer/.gitkeep delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/model/.gitkeep delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/Transaction.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionStatus.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionType.java delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/TransactionEntity.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/exception/ErrorResponse.java delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/port/.gitkeep delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/adapter/.gitkeep delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/jpa/.gitkeep delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/CreateTransactionUseCase.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/GetTransactionUseCase.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCase.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/cache/RedisCacheAdapter.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/repository/TransactionRepositoryAdapter.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/repository/jpa/TransactionJpaRepository.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/CreateTransactionUseCaseImpl.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/GetTransactionUseCaseImpl.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/UpdateTransactionStatusUseCaseImpl.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/port/CacheRepositoryPort.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/port/TransactionRepositoryPort.java delete mode 100644 ms-ne-transaction-service/src/main/resources/db/migration/.gitkeep create mode 100644 ms-ne-transaction-service/src/main/resources/db/migration/V1__create_transaction_table.sql delete mode 100644 ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/.gitkeep create mode 100644 ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/model/domain/TransactionTest.java create mode 100644 ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/CreateTransactionUseCaseTest.java create mode 100644 ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/GetTransactionUseCaseTest.java create mode 100644 ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCaseTest.java delete mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/WebClientConfig.java delete mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/GlobalExceptionHandler.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/TransactionServiceException.java delete mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/TransactionController.java delete mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/TransactionStatus.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/request/CreateTransactionRequest.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/response/ErrorResponse.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/response/TransactionResponse.java delete mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/.gitkeep create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/TransactionOrchestrationService.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/TransactionOrchestrationServiceImpl.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/adapter/TransactionServiceWebClientAdapter.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/port/TransactionServicePort.java 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/build-all.bat b/build-all.bat deleted file mode 100644 index 9631cadc21..0000000000 --- a/build-all.bat +++ /dev/null @@ -1,21 +0,0 @@ -@echo off -echo 🏗️ Compilando todos los microservicios... - -echo 📦 Compilando ms-ne-transaction-service... -cd ms-ne-transaction-service -call gradle clean build -x test -cd .. - -echo 🛡️ Compilando ms-sp-antifraud-rules... -cd ms-sp-antifraud-rules -call gradle clean build -x test -cd .. - -echo 🎯 Compilando ms-ux-orchestrator... -cd ms-ux-orchestrator -call gradle clean build -x test -cd .. - -echo ✅ Todos los servicios compilados exitosamente! -echo 🐳 Ejecuta 'docker-compose up --build' para levantar los servicios - diff --git a/build-all.sh b/build-all.sh deleted file mode 100644 index 089294f4fa..0000000000 --- a/build-all.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -echo "🏗️ Compilando todos los microservicios..." - -# Transaction Service -echo "📦 Compilando ms-ne-transaction-service..." -cd ms-ne-transaction-service -gradle clean build -x test -cd .. - -# Anti-Fraud Service -echo "🛡️ Compilando ms-sp-antifraud-rules..." -cd ms-sp-antifraud-rules -gradle clean build -x test -cd .. - -# Orchestrator -echo "🎯 Compilando ms-ux-orchestrator..." -cd ms-ux-orchestrator -gradle clean build -x test -cd .. - -echo "✅ Todos los servicios compilados exitosamente!" -echo "🐳 Ejecuta 'docker-compose up --build' para levantar los servicios" - diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..885c0dbd3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,256 @@ 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 + start_period: 10s + 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 + 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" + 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 + + schema-registry: + image: confluentinc/cp-schema-registry:7.5.0 + container_name: yape-schema-registry + depends_on: + kafka: + condition: service_healthy + ports: + - "8081:8081" + environment: + SCHEMA_REGISTRY_HOST_NAME: schema-registry + SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081 + SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: kafka:29092 + SCHEMA_REGISTRY_KAFKASTORE_SECURITY_PROTOCOL: PLAINTEXT + networks: + - yape-network + + connect: + build: + context: . + dockerfile: Dockerfile + container_name: yape-connect + depends_on: + kafka: + condition: service_healthy + postgres: + condition: service_healthy + schema-registry: + condition: service_started + transaction-service: + condition: service_healthy ports: - - 9092:9092 + - "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: io.confluent.connect.avro.AvroConverter + CONNECT_KEY_CONVERTER_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + CONNECT_VALUE_CONVERTER: io.confluent.connect.avro.AvroConverter + CONNECT_VALUE_CONVERTER_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + 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", + "key.converter": "io.confluent.connect.avro.AvroConverter", + "key.converter.schema.registry.url": "http://schema-registry:8081", + "value.converter": "io.confluent.connect.avro.AvroConverter", + "value.converter.schema.registry.url": "http://schema-registry:8081", + "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 + + 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_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + CONTROL_CENTER_REPLICATION_FACTOR: 1 + PORT: 9021 + networks: + - yape-network + +networks: + yape-network: + driver: bridge \ No newline at end of file diff --git a/ms-ne-transaction-service/Dockerfile b/ms-ne-transaction-service/Dockerfile index 894893fcd9..4b0f9776b0 100644 --- a/ms-ne-transaction-service/Dockerfile +++ b/ms-ne-transaction-service/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app ARG JAR_FILE=build/libs/*.jar COPY ${JAR_FILE} app.jar -EXPOSE 8081 +EXPOSE 9992 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/ms-ne-transaction-service/build.gradle b/ms-ne-transaction-service/build.gradle index 468035e038..8b4be31c09 100644 --- a/ms-ne-transaction-service/build.gradle +++ b/ms-ne-transaction-service/build.gradle @@ -29,21 +29,21 @@ ext { } dependencies { - // Spring Boot Starters + // 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 + // Spring Cloud Stream - Kafka implementation 'org.springframework.cloud:spring-cloud-starter-stream-kafka' implementation 'org.springframework.kafka:spring-kafka' - // PostgreSQL + // Postgres runtimeOnly 'org.postgresql:postgresql' - // Flyway Migration + // Flyway implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' 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 index 7a4e980f39..bcc1568544 100644 --- 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 @@ -6,9 +6,8 @@ /** * MS-NE-TRANSACTION-SERVICE - * Servicio de negocio core para gestión de transacciones - * - * @author Yape Engineering Team + * + * @author lmarusic * @version 1.0.0 */ @SpringBootApplication diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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..2afa4cbac1 --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/RedisConfig.java @@ -0,0 +1,34 @@ +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); + + // Usar StringRedisSerializer para claves y valores + 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/exception/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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..a41cb57a33 --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/GlobalExceptionHandler.java @@ -0,0 +1,78 @@ +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(InvalidTransactionStateException.class) + public ResponseEntity handleInvalidTransactionState(InvalidTransactionStateException ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.CONFLICT.value(), + ex.getMessage(), + LocalDateTime.now() + ); + return ResponseEntity.status(HttpStatus.CONFLICT).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/InvalidTransactionStateException.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/InvalidTransactionStateException.java new file mode 100644 index 0000000000..7376948f84 --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/InvalidTransactionStateException.java @@ -0,0 +1,44 @@ +package pe.com.yape.ms.transaction.application.exception; + +import java.util.UUID; +import pe.com.yape.ms.transaction.model.domain.TransactionStatus; + +/** + * Excepcion lanzada cuando se intenta actualizar una transaccion que no deberia ser actualizada + * + * @author lmarusic + * @version 1.0.0 + */ +public class InvalidTransactionStateException extends RuntimeException { + + private final UUID transactionExternalId; + private final TransactionStatus currentStatus; + private final TransactionStatus attemptedStatus; + + public InvalidTransactionStateException( + UUID transactionExternalId, + TransactionStatus currentStatus, + TransactionStatus attemptedStatus + ) { + super(String.format( + "Cannot update transaction %s from status %s to %s. Only PENDING transactions can be updated.", + transactionExternalId, currentStatus, attemptedStatus + )); + this.transactionExternalId = transactionExternalId; + this.currentStatus = currentStatus; + this.attemptedStatus = attemptedStatus; + } + + public UUID getTransactionExternalId() { + return transactionExternalId; + } + + public TransactionStatus getCurrentStatus() { + return currentStatus; + } + + public TransactionStatus getAttemptedStatus() { + return attemptedStatus; + } +} + 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/cache/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/cache/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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..9fe6681471 --- /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; // Mantener typo del README original + + @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..940e6c562b --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionResponse.java @@ -0,0 +1,49 @@ +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 Yape Engineering Team + * @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; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TransactionTypeDto { + private String name; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TransactionStatusDto { + 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..8845cf62c0 --- /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.model.domain.Transaction; + +/** + * Mapper para convertir entre objetos de dominio y DTOs + * + * @author lmarusic + * @version 1.0.0 + */ +@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(TransactionResponse.TransactionTypeDto.builder() + .name(transaction.transactionType().getName()) + .build()) + .transactionStatus(TransactionResponse.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/kafka/consumer/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/consumer/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/model/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/kafka/model/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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..2b36879e98 --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/Transaction.java @@ -0,0 +1,112 @@ +package pe.com.yape.ms.transaction.model.domain; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Modelo de dominio inmutable que representa una transacción financiera + * Usa Java 21 Record para inmutabilidad y expresividad + * + * @author Yape Engineering Team + * @version 1.0.0 + */ +public record Transaction( + UUID transactionExternalId, + UUID accountExternalIdDebit, + UUID accountExternalIdCredit, + TransactionType transactionType, + BigDecimal value, + TransactionStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + /** + * Constructor compacto con validaciones de negocio + */ + 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"); + } + } + + /** + * Factory method para crear una nueva transacción en estado PENDING + */ + 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 + ); + } + + /** + * Crea una nueva transacción con el estado actualizado + * Patrón inmutable: retorna nueva instancia en lugar de modificar + */ + 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..6e12c420ab --- /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 Yape Engineering Team + * @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..6d4760f044 --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/domain/TransactionType.java @@ -0,0 +1,62 @@ +package pe.com.yape.ms.transaction.model.domain; + +/** + * Tipo de transacción financiera + * + * @author Yape Engineering Team + * @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; + } + + /** + * Obtiene el tipo de transacción por ID + * + * @param id identificador del tipo + * @return enum correspondiente + * @throws IllegalArgumentException si el ID no es válido + */ + 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/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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..b04b7f243b --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/entity/TransactionEntity.java @@ -0,0 +1,100 @@ +package pe.com.yape.ms.transaction.model.entity; + +import jakarta.persistence.*; +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; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entidad JPA para persistencia de transacciones + * Adaptador entre el dominio (Transaction Record) y la base de datos + * + * @author Yape Engineering Team + * @version 1.0.0 + */ +@Entity +@Table(name = "transactions", indexes = { + @Index(name = "idx_transaction_external_id", columnList = "transaction_external_id"), + @Index(name = "idx_transaction_status", columnList = "status"), + @Index(name = "idx_transaction_created_at", columnList = "created_at") +}) +@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/model/port/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/model/port/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/adapter/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/adapter/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/jpa/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/repository/jpa/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/.gitkeep b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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/UpdateTransactionStatusUseCase.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCase.java new file mode 100644 index 0000000000..a30c0cd2ea --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCase.java @@ -0,0 +1,24 @@ +package pe.com.yape.ms.transaction.service; + +import java.util.UUID; +import pe.com.yape.ms.transaction.model.domain.Transaction; +import pe.com.yape.ms.transaction.model.domain.TransactionStatus; + +/** + * Caso de Uso: Actualizar el estado de una transacción + * + * @author lamrusic + * @version 1.0.0 + */ +public interface UpdateTransactionStatusUseCase { + + /** + * Actualiza el estado de una transacción existente + * + * @param transactionExternalId ID de la transacción + * @param newStatus nuevo estado (APPROVED o REJECTED) + * @return Transaction + */ + Transaction execute(UUID transactionExternalId, TransactionStatus newStatus); +} + 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..044182dd2f --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/adapter/cache/RedisCacheAdapter.java @@ -0,0 +1,88 @@ +package pe.com.yape.ms.transaction.service.adapter.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +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 + * @version 1.0.0 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisCacheAdapter implements CacheRepositoryPort { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + 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.error("Error deserializing transaction from cache: {}", transactionExternalId, e); + 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..2ab43df36c --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/CreateTransactionUseCaseImpl.java @@ -0,0 +1,75 @@ +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); + + // Validar datos de entrada + validateInputs(accountExternalIdDebit, accountExternalIdCredit, value); + + // Obtener el tipo de transacción + TransactionType transactionType = TransactionType.fromId(transferTypeId); + + // Crear transacción en estado PENDING usando factory method + Transaction transaction = Transaction.createPending( + accountExternalIdDebit, + accountExternalIdCredit, + transactionType, + value + ); + + // Guardar en PostgreSQL + // Debezium CDC capturará este INSERT automáticamente desde WAL + // y publicará el evento 'transaction.created' a Kafka + Transaction savedTransaction = transactionRepository.save(transaction); + + log.info("Transaction created successfully: {} with status: {}", + savedTransaction.transactionExternalId(), savedTransaction.status()); + + 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..16273d6be6 --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/GetTransactionUseCaseImpl.java @@ -0,0 +1,60 @@ +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; // 1 hora + + @Override + @Transactional(readOnly = true) + public Transaction execute(UUID transactionExternalId) { + log.debug("Getting transaction: {}", transactionExternalId); + + // 1. Buscar en cache (Redis) - Fast path + return cacheRepository.findByExternalId(transactionExternalId) + .map(transaction -> { + log.info("Transaction found in cache: {}", transactionExternalId); + return transaction; + }) + // 2. Si no está en cache, buscar en BD - Slow path + .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); + }); + + // 3. Guardar en cache para futuras consultas + cacheRepository.save(transaction, CACHE_TTL_SECONDS); + log.info("Transaction found in database and cached: {}", transactionExternalId); + + return transaction; + }); + } +} + diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/UpdateTransactionStatusUseCaseImpl.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/UpdateTransactionStatusUseCaseImpl.java new file mode 100644 index 0000000000..7bc36265ca --- /dev/null +++ b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/UpdateTransactionStatusUseCaseImpl.java @@ -0,0 +1,73 @@ +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.InvalidTransactionStateException; +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.TransactionStatus; +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.UpdateTransactionStatusUseCase; + +/** + * Implementación del caso de uso: UpdateTransactionStatusUseCase + * + * @author lmarusic + * @version 1.0.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class UpdateTransactionStatusUseCaseImpl implements UpdateTransactionStatusUseCase { + + private final TransactionRepositoryPort transactionRepository; + private final CacheRepositoryPort cacheRepository; + + private static final long CACHE_TTL_SECONDS = 3600L; // 1 hora + + @Override + @Transactional + public Transaction execute(UUID transactionExternalId, TransactionStatus newStatus) { + log.info("Updating transaction {} to status: {}", transactionExternalId, newStatus); + + // 1. Buscar transacción + Transaction currentTransaction = transactionRepository + .findByExternalId(transactionExternalId) + .orElseThrow(() -> { + log.warn("Transaction not found for update: {}", transactionExternalId); + return new TransactionNotFoundException(transactionExternalId); + }); + + // 2. Validar que esté en estado PENDING + if (!currentTransaction.isPending()) { + log.warn("Attempted to update non-pending transaction: {} from {} to {}", + transactionExternalId, currentTransaction.status(), newStatus); + throw new InvalidTransactionStateException( + transactionExternalId, + currentTransaction.status(), + newStatus + ); + } + + // 3. Crear nueva instancia con estado actualizado (inmutabilidad) + Transaction updatedTransaction = currentTransaction.withStatus(newStatus); + + // 4. Guardar en BD + // Debezium CDC capturará este UPDATE automáticamente desde WAL + // y publicará el evento 'transaction.updated' a Kafka + Transaction savedTransaction = transactionRepository.update(updatedTransaction); + + // 5. Actualizar cache + cacheRepository.save(savedTransaction, CACHE_TTL_SECONDS); + + log.info("Transaction updated successfully: {} - Status: {}", + savedTransaction.transactionExternalId(), savedTransaction.status()); + + return savedTransaction; + } +} + 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 index 2dbcdfee72..8b5f99c45a 100644 --- a/ms-ne-transaction-service/src/main/resources/application.yml +++ b/ms-ne-transaction-service/src/main/resources/application.yml @@ -3,9 +3,9 @@ spring: name: ms-ne-transaction-service datasource: - url: jdbc:postgresql://localhost:5432/transactiondb - username: yape - password: yape123 + url: jdbc:postgresql://localhost:5432/yape_db + username: yape_user + password: yape_password driver-class-name: org.postgresql.Driver jpa: @@ -29,7 +29,7 @@ spring: baseline-on-migrate: true server: - port: 8081 + port: 9992 management: endpoints: diff --git a/ms-ne-transaction-service/src/main/resources/db/migration/.gitkeep b/ms-ne-transaction-service/src/main/resources/db/migration/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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/.gitkeep b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCaseTest.java b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCaseTest.java new file mode 100644 index 0000000000..4f299ef55e --- /dev/null +++ b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCaseTest.java @@ -0,0 +1,195 @@ +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.InvalidTransactionStateException; +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.TransactionStatus; +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.UpdateTransactionStatusUseCaseImpl; + +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 UpdateTransactionStatusUseCase (TDD - GREEN Phase) + * Verifica las reglas de negocio de actualización de estado + * + * @author Yape Engineering Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Update Transaction Status Use Case Tests - GREEN Phase") +class UpdateTransactionStatusUseCaseTest { + + @Mock + private TransactionRepositoryPort transactionRepository; + + @Mock + private CacheRepositoryPort cacheRepository; + + private UpdateTransactionStatusUseCase updateTransactionStatusUseCase; + + @BeforeEach + void setUp() { + // Ahora SÍ existe la implementación + updateTransactionStatusUseCase = new UpdateTransactionStatusUseCaseImpl( + transactionRepository, + cacheRepository + ); + } + + @Test + @DisplayName("RED: Debe actualizar transacción de PENDING a APPROVED") + void shouldUpdateTransactionFromPendingToApproved() { + // Given + UUID transactionId = UUID.randomUUID(); + Transaction pendingTransaction = Transaction.createPending( + UUID.randomUUID(), + UUID.randomUUID(), + TransactionType.TRANSFER, + new BigDecimal("500.00") + ); + + Transaction approvedTransaction = pendingTransaction.withStatus(TransactionStatus.APPROVED); + + when(transactionRepository.findByExternalId(transactionId)) + .thenReturn(Optional.of(pendingTransaction)); + when(transactionRepository.update(any(Transaction.class))) + .thenReturn(approvedTransaction); + + // When + Transaction result = updateTransactionStatusUseCase.execute( + transactionId, + TransactionStatus.APPROVED + ); + + // Then + assertNotNull(result); + assertEquals(TransactionStatus.APPROVED, result.status()); + assertTrue(result.isApproved()); + verify(transactionRepository, times(1)).update(any(Transaction.class)); + verify(cacheRepository, times(1)).save(eq(result), anyLong()); + } + + @Test + @DisplayName("RED: Debe actualizar transacción de PENDING a REJECTED") + void shouldUpdateTransactionFromPendingToRejected() { + // Given + UUID transactionId = UUID.randomUUID(); + Transaction pendingTransaction = Transaction.createPending( + UUID.randomUUID(), + UUID.randomUUID(), + TransactionType.TRANSFER, + new BigDecimal("1500.00") + ); + + Transaction rejectedTransaction = pendingTransaction.withStatus(TransactionStatus.REJECTED); + + when(transactionRepository.findByExternalId(transactionId)) + .thenReturn(Optional.of(pendingTransaction)); + when(transactionRepository.update(any(Transaction.class))) + .thenReturn(rejectedTransaction); + + // When + Transaction result = updateTransactionStatusUseCase.execute( + transactionId, + TransactionStatus.REJECTED + ); + + // Then + assertNotNull(result); + assertEquals(TransactionStatus.REJECTED, result.status()); + assertTrue(result.isRejected()); + verify(transactionRepository, times(1)).update(any(Transaction.class)); + } + + @Test + @DisplayName("GREEN: Debe lanzar excepción si la transacción no existe") + void shouldThrowExceptionWhenTransactionNotFound() { + // Given + UUID transactionId = UUID.randomUUID(); + + when(transactionRepository.findByExternalId(transactionId)) + .thenReturn(Optional.empty()); + + // When & Then + assertThrows(TransactionNotFoundException.class, () -> + updateTransactionStatusUseCase.execute( + transactionId, + TransactionStatus.APPROVED + ) + ); + + verify(transactionRepository, never()).update(any()); + verify(cacheRepository, never()).save(any(), anyLong()); + } + + @Test + @DisplayName("GREEN: Debe lanzar excepción si la transacción no está en estado PENDING") + void shouldThrowExceptionWhenTransactionIsNotPending() { + // Given + UUID transactionId = UUID.randomUUID(); + Transaction approvedTransaction = Transaction.createPending( + UUID.randomUUID(), + UUID.randomUUID(), + TransactionType.TRANSFER, + new BigDecimal("100.00") + ).withStatus(TransactionStatus.APPROVED); + + when(transactionRepository.findByExternalId(transactionId)) + .thenReturn(Optional.of(approvedTransaction)); + + // When & Then + assertThrows(InvalidTransactionStateException.class, () -> + updateTransactionStatusUseCase.execute( + transactionId, + TransactionStatus.REJECTED + ) + ); + + verify(transactionRepository, never()).update(any()); + } + + @Test + @DisplayName("RED: Debe actualizar el cache después de actualizar en DB") + void shouldUpdateCacheAfterDatabaseUpdate() { + // Given + UUID transactionId = UUID.randomUUID(); + Transaction pendingTransaction = Transaction.createPending( + UUID.randomUUID(), + UUID.randomUUID(), + TransactionType.TRANSFER, + new BigDecimal("300.00") + ); + + Transaction approvedTransaction = pendingTransaction.withStatus(TransactionStatus.APPROVED); + + when(transactionRepository.findByExternalId(transactionId)) + .thenReturn(Optional.of(pendingTransaction)); + when(transactionRepository.update(any(Transaction.class))) + .thenReturn(approvedTransaction); + + // When + updateTransactionStatusUseCase.execute(transactionId, TransactionStatus.APPROVED); + + // Then - Verificar orden: DB update -> cache save + var inOrder = inOrder(transactionRepository, cacheRepository); + inOrder.verify(transactionRepository).update(any(Transaction.class)); + inOrder.verify(cacheRepository).save(eq(approvedTransaction), anyLong()); + } +} + diff --git a/ms-ux-orchestrator/Dockerfile b/ms-ux-orchestrator/Dockerfile index c4779c8f5b..76f9306af9 100644 --- a/ms-ux-orchestrator/Dockerfile +++ b/ms-ux-orchestrator/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app ARG JAR_FILE=build/libs/*.jar COPY ${JAR_FILE} app.jar -EXPOSE 8080 +EXPOSE 9991 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/config/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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..f9bee2ce69 --- /dev/null +++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/GlobalExceptionHandler.java @@ -0,0 +1,102 @@ +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 + * @version 1.0.0 + */ +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(TransactionServiceException.class) + public Mono> handleTransactionServiceException( + TransactionServiceException ex, + ServerWebExchange exchange + ) { + log.error("Transaction service error", ex); + + ErrorResponse error = ErrorResponse.builder() + .status(HttpStatus.BAD_GATEWAY.value()) + .message("Error communicating with transaction service: " + ex.getMessage()) + .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); + + ErrorResponse error = ErrorResponse.builder() + .status(HttpStatus.GATEWAY_TIMEOUT.value()) + .message("Request timeout: " + ex.getMessage()) + .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.error("Validation error", ex); + + 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); + + ErrorResponse error = ErrorResponse.builder() + .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) + .message("An unexpected error occurred: " + ex.getMessage()) + .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/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/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/controller/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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..3b3b65e189 --- /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; // Mantener typo del README + + @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/.gitkeep b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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..f84ee2416b --- /dev/null +++ b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/service/adapter/TransactionServiceWebClientAdapter.java @@ -0,0 +1,80 @@ +package pe.com.yape.orchestrator.service.adapter; + +import java.time.Duration; +import java.util.UUID; +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.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + log.error("Error from transaction-service: {}", errorBody); + return Mono.error(new TransactionServiceException( + "Error retrieving transaction: " + errorBody)); + }) + ) + .bodyToMono(TransactionResponse.class) + .timeout(Duration.ofMillis(timeout)) + .doOnError(error -> + log.error("Failed to get transaction from transaction-service: {}", + transactionExternalId, error)); + } +} + 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 index a1a2515669..a924e7030f 100644 --- a/ms-ux-orchestrator/src/main/resources/application.yml +++ b/ms-ux-orchestrator/src/main/resources/application.yml @@ -3,7 +3,12 @@ spring: name: ms-ux-orchestrator server: - port: 8080 + port: 9991 + +services: + transaction: + base-url: http://localhost:9992 + timeout: 5000 management: endpoints: @@ -17,4 +22,5 @@ management: logging: level: pe.com.yape.orchestrator: INFO + org.springframework.web.reactive: DEBUG From cbdf26d91d0b1e18222d44fec61ad2639b53d99f Mon Sep 17 00:00:00 2001 From: Ludwig Marusic Date: Tue, 6 Jan 2026 13:13:40 -0500 Subject: [PATCH 3/5] feat: add correct logic in service classes, add test, modfications of docker-compose file --- DOCKER_SETUP.md | 235 ++++++++++++++++++ docker-compose.yml | 65 ++--- ms-ne-transaction-service/Dockerfile | 17 +- .../config/CacheManagementEndpoint.java | 34 +++ .../config/KafkaConsumerConfig.java | 46 ++++ .../application/config/RedisConfig.java | 1 - .../TransactionValidationConsumer.java | 97 ++++++++ .../dto/TransactionValidatedEventDto.java | 26 ++ .../exception/GlobalExceptionHandler.java | 10 - .../InvalidTransactionStateException.java | 44 ---- .../dto/CreateTransactionRequest.java | 2 +- .../controller/dto/TransactionResponse.java | 24 +- .../controller/dto/TransactionStatusDto.java | 19 ++ .../controller/dto/TransactionTypeDto.java | 19 ++ .../controller/mapper/TransactionMapper.java | 8 +- .../transaction/model/domain/Transaction.java | 15 +- .../model/domain/TransactionStatus.java | 2 +- .../model/domain/TransactionType.java | 11 +- .../model/entity/TransactionEntity.java | 29 ++- .../UpdateTransactionStatusUseCase.java | 24 -- .../adapter/cache/RedisCacheAdapter.java | 12 +- .../impl/CreateTransactionUseCaseImpl.java | 8 +- .../impl/GetTransactionUseCaseImpl.java | 8 +- .../UpdateTransactionStatusUseCaseImpl.java | 73 ------ .../src/main/resources/application.yml | 22 +- .../UpdateTransactionStatusUseCaseTest.java | 195 --------------- ms-sp-antifraud-rules/Dockerfile | 16 +- ms-sp-antifraud-rules/build.gradle | 11 +- .../AntiFraudServiceApplication.java | 5 +- .../ms/antifraud/application/config/.gitkeep | 0 .../dto/DebeziumTransactionDto.java | 37 +++ .../dto/TransactionValidatedDto.java | 26 ++ .../mapper/TransactionEventMapper.java | 59 +++++ .../com/yape/ms/antifraud/controller/.gitkeep | 0 .../yape/ms/antifraud/model/domain/.gitkeep | 0 .../antifraud/model/domain/Transaction.java | 45 ++++ .../model/domain/TransactionRisk.java | 11 + .../model/domain/TransactionValidation.java | 60 +++++ .../model/domain/ValidationStatus.java | 11 + .../yape/ms/antifraud/model/event/.gitkeep | 0 .../service/AntiFraudValidationService.java | 20 ++ .../yape/ms/antifraud/service/impl/.gitkeep | 0 .../impl/AntiFraudValidationServiceImpl.java | 47 ++++ .../yape/ms/antifraud/service/rules/.gitkeep | 0 .../TransactionValidationProcessor.java | 97 ++++++++ .../src/main/resources/application.yml | 28 ++- .../java/pe/com/yape/ms/antifraud/.gitkeep | 0 .../impl/AntiFraudValidationServiceTest.java | 185 ++++++++++++++ ...ionValidationProcessorIntegrationTest.java | 178 +++++++++++++ ms-ux-orchestrator/Dockerfile | 2 + .../exception/GlobalExceptionHandler.java | 47 +++- .../TransactionNotFoundException.java | 22 ++ .../request/CreateTransactionRequest.java | 2 +- .../TransactionServiceWebClientAdapter.java | 18 +- .../src/main/resources/application.yml | 4 +- .../java/pe/com/yape/orchestrator/.gitkeep | 0 ...ansactionOrchestrationServiceImplTest.java | 148 +++++++++++ 57 files changed, 1620 insertions(+), 505 deletions(-) create mode 100644 DOCKER_SETUP.md create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/CacheManagementEndpoint.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/config/KafkaConsumerConfig.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/consumer/TransactionValidationConsumer.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/dto/TransactionValidatedEventDto.java delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/InvalidTransactionStateException.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionStatusDto.java create mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/controller/dto/TransactionTypeDto.java delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCase.java delete mode 100644 ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/UpdateTransactionStatusUseCaseImpl.java delete mode 100644 ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCaseTest.java delete mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/config/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/dto/DebeziumTransactionDto.java create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/dto/TransactionValidatedDto.java create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/mapper/TransactionEventMapper.java delete mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/controller/.gitkeep delete mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/Transaction.java create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/TransactionRisk.java create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/TransactionValidation.java create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/ValidationStatus.java delete mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/event/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/AntiFraudValidationService.java delete mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/AntiFraudValidationServiceImpl.java delete mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/rules/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/stream/TransactionValidationProcessor.java delete mode 100644 ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/.gitkeep create mode 100644 ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/service/impl/AntiFraudValidationServiceTest.java create mode 100644 ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/service/stream/TransactionValidationProcessorIntegrationTest.java create mode 100644 ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/application/exception/TransactionNotFoundException.java delete mode 100644 ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/.gitkeep create mode 100644 ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/service/TransactionOrchestrationServiceImplTest.java diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md new file mode 100644 index 0000000000..d515125455 --- /dev/null +++ b/DOCKER_SETUP.md @@ -0,0 +1,235 @@ +# Guía de Ejecución con Docker Compose + +## 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 (con generación de clases Avro) + +2. ✅ **Generación de clases Avro** + - `TransactionCreatedEvent.java` + - `TransactionValidatedEvent.java` + - `ValidationStatus.java` + +3. ✅ **Registro de esquemas en Schema Registry** + - `yape.public.transactions-value` + - `transaction.validated-value` + +4. ✅ **Levantamiento de toda la infraestructura** + - PostgreSQL con CDC habilitado + - Redis + - Kafka + Zookeeper + - Schema Registry + - Kafka Connect con Debezium + - Control Center + - 3 Microservicios + +## Orden de Inicio + +``` +1. postgres, redis, zookeeper +2. kafka +3. schema-registry +4. schema-registry-init (registra esquemas) +5. transaction-service (ejecuta migraciones) +6. orchestrator-service +7. connect (Debezium CDC) +8. antifraud-service +9. control-center +``` + +## Verificar que Todo Funciona + +### 1. Ver logs de compilación +```bash +docker-compose logs -f antifraud-service +``` + +### 2. Verificar esquemas registrados +```bash +curl http://localhost:8081/subjects +``` + +Deberías ver: +```json +[ + "yape.public.transactions-value", + "transaction.validated-value" +] +``` + +### 3. Ver esquema específico +```bash +curl http://localhost:8081/subjects/transaction.validated-value/versions/latest +``` + +### 4. Verificar servicios activos +```bash +docker-compose ps +``` + +### 5. Health checks +```bash +curl http://localhost:9992/actuator/health +curl http://localhost:9991/actuator/health +curl http://localhost:9993/actuator/health +``` + +## Puertos Expuestos + +| Servicio | Puerto | URL | +|----------|--------|-----| +| PostgreSQL | 5432 | jdbc:postgresql://localhost:5432/yape_db | +| Redis | 6379 | localhost:6379 | +| Kafka | 9092 | localhost:9092 | +| Schema Registry | 8081 | http://localhost:8081 | +| 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 +``` + +### Reconstruir solo un servicio +```bash +docker-compose up -d --build antifraud-service +``` + +### Detener todo +```bash +docker-compose down +``` + +### Detener y eliminar volúmenes (datos) +```bash +docker-compose down -v +``` + +## Probar el Flujo Completo + +### 1. Crear una transacción +```bash +curl -X POST http://localhost:9991/api/transactions \ + -H "Content-Type: application/json" \ + -d '{ + "accountExternalIdDebit": "acc-001", + "accountExternalIdCredit": "acc-002", + "tranferTypeId": 1, + "value": 500.0 + }' +``` + +### 2. Consultar estado +```bash +curl http://localhost:9991/api/transactions/{transactionId} +``` + +### 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 +``` + +## Troubleshooting + +### Problema: Antifraud no compila +```bash +docker-compose logs antifraud-service +``` + +### Problema: Esquemas no se registran +```bash +docker-compose logs schema-registry-init +docker-compose restart schema-registry-init +``` + +### Problema: Debezium no captura eventos +```bash +curl http://localhost:8083/connectors/yape-transaction-connector/status +``` + +### Problema: Servicios no se comunican +```bash +docker network inspect yape-network +``` + +## Desarrollo Local vs Docker + +### Desarrollo Local +```bash +cd ms-sp-antifraud-rules +./gradlew generateAvroJava +./gradlew bootRun +``` + +### Con Docker +```bash +docker-compose up -d --build antifraud-service +``` + +## Limpieza Total + +```bash +docker-compose down -v +docker system prune -a --volumes +``` + +## Notas Importantes + +- ⚠️ La primera compilación tardará más (descarga dependencias) +- ⚠️ El servicio antifraud espera a que los esquemas estén registrados +- ⚠️ Transaction Service ejecuta Flyway migrations automáticamente +- ⚠️ Debezium registra el conector automáticamente al iniciar + +## Arquitectura Multi-Stage Build + +``` +┌─────────────────────────────────────┐ +│ Stage 1: Builder (JDK) │ +│ - Copia código fuente │ +│ - Ejecuta ./gradlew build │ +│ - Genera clases Avro │ +│ - Crea JAR ejecutable │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Stage 2: Runtime (JRE) │ +│ - Copia solo el JAR │ +│ - Imagen ligera │ +│ - Lista para ejecutar │ +└─────────────────────────────────────┘ +``` + +**Ventaja:** No necesitas tener Java, Gradle ni Avro instalados localmente. Todo se compila dentro de Docker. + diff --git a/docker-compose.yml b/docker-compose.yml index 885c0dbd3b..602e83720d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,6 @@ services: interval: 5s timeout: 5s retries: 5 - start_period: 10s networks: - yape-network @@ -38,6 +37,8 @@ services: condition: service_healthy redis: condition: service_started + kafka: + condition: service_started ports: - "9992:9992" environment: @@ -47,6 +48,7 @@ services: 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 @@ -67,7 +69,7 @@ services: ports: - "9991:9991" environment: - SERVICES_TRANSACTION_BASE-URL: http://transaction-service:9992 + SERVICES_TRANSACTION_BASE_URL: http://transaction-service:9992 SERVICES_TRANSACTION_TIMEOUT: 5000 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9991/actuator/health"] @@ -111,22 +113,6 @@ services: networks: - yape-network - schema-registry: - image: confluentinc/cp-schema-registry:7.5.0 - container_name: yape-schema-registry - depends_on: - kafka: - condition: service_healthy - ports: - - "8081:8081" - environment: - SCHEMA_REGISTRY_HOST_NAME: schema-registry - SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081 - SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: kafka:29092 - SCHEMA_REGISTRY_KAFKASTORE_SECURITY_PROTOCOL: PLAINTEXT - networks: - - yape-network - connect: build: context: . @@ -137,8 +123,6 @@ services: condition: service_healthy postgres: condition: service_healthy - schema-registry: - condition: service_started transaction-service: condition: service_healthy ports: @@ -160,10 +144,8 @@ services: CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: "1" CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: "1" CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: "1" - CONNECT_KEY_CONVERTER: io.confluent.connect.avro.AvroConverter - CONNECT_KEY_CONVERTER_SCHEMA_REGISTRY_URL: http://schema-registry:8081 - CONNECT_VALUE_CONVERTER: io.confluent.connect.avro.AvroConverter - CONNECT_VALUE_CONVERTER_SCHEMA_REGISTRY_URL: http://schema-registry:8081 + 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" @@ -223,10 +205,11 @@ services: "transforms.unwrap.drop.tombstones": "false", "transforms.unwrap.delete.handling.mode": "rewrite", "transforms.unwrap.add.fields": "op,source.ts_ms", - "key.converter": "io.confluent.connect.avro.AvroConverter", - "key.converter.schema.registry.url": "http://schema-registry:8081", - "value.converter": "io.confluent.connect.avro.AvroConverter", - "value.converter.schema.registry.url": "http://schema-registry:8081", + "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" } @@ -235,6 +218,29 @@ services: 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: + - "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 @@ -245,7 +251,6 @@ services: - "9021:9021" environment: CONTROL_CENTER_BOOTSTRAP_SERVERS: kafka:29092 - CONTROL_CENTER_SCHEMA_REGISTRY_URL: http://schema-registry:8081 CONTROL_CENTER_REPLICATION_FACTOR: 1 PORT: 9021 networks: @@ -253,4 +258,4 @@ services: networks: yape-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/ms-ne-transaction-service/Dockerfile b/ms-ne-transaction-service/Dockerfile index 4b0f9776b0..d58188ec55 100644 --- a/ms-ne-transaction-service/Dockerfile +++ b/ms-ne-transaction-service/Dockerfile @@ -1,11 +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 -ARG JAR_FILE=build/libs/*.jar -COPY ${JAR_FILE} app.jar +COPY --from=builder /workspace/app/build/libs/*.jar app.jar EXPOSE 9992 ENTRYPOINT ["java", "-jar", "app.jar"] - 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 index 2afa4cbac1..b264eb3279 100644 --- 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 @@ -20,7 +20,6 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); - // Usar StringRedisSerializer para claves y valores StringRedisSerializer serializer = new StringRedisSerializer(); template.setKeySerializer(serializer); template.setValueSerializer(serializer); 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 index a41cb57a33..4eb01dc173 100644 --- 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 @@ -29,16 +29,6 @@ public ResponseEntity handleTransactionNotFound(TransactionNotFou return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } - @ExceptionHandler(InvalidTransactionStateException.class) - public ResponseEntity handleInvalidTransactionState(InvalidTransactionStateException ex) { - ErrorResponse error = new ErrorResponse( - HttpStatus.CONFLICT.value(), - ex.getMessage(), - LocalDateTime.now() - ); - return ResponseEntity.status(HttpStatus.CONFLICT).body(error); - } - @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { ErrorResponse error = new ErrorResponse( diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/InvalidTransactionStateException.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/InvalidTransactionStateException.java deleted file mode 100644 index 7376948f84..0000000000 --- a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/application/exception/InvalidTransactionStateException.java +++ /dev/null @@ -1,44 +0,0 @@ -package pe.com.yape.ms.transaction.application.exception; - -import java.util.UUID; -import pe.com.yape.ms.transaction.model.domain.TransactionStatus; - -/** - * Excepcion lanzada cuando se intenta actualizar una transaccion que no deberia ser actualizada - * - * @author lmarusic - * @version 1.0.0 - */ -public class InvalidTransactionStateException extends RuntimeException { - - private final UUID transactionExternalId; - private final TransactionStatus currentStatus; - private final TransactionStatus attemptedStatus; - - public InvalidTransactionStateException( - UUID transactionExternalId, - TransactionStatus currentStatus, - TransactionStatus attemptedStatus - ) { - super(String.format( - "Cannot update transaction %s from status %s to %s. Only PENDING transactions can be updated.", - transactionExternalId, currentStatus, attemptedStatus - )); - this.transactionExternalId = transactionExternalId; - this.currentStatus = currentStatus; - this.attemptedStatus = attemptedStatus; - } - - public UUID getTransactionExternalId() { - return transactionExternalId; - } - - public TransactionStatus getCurrentStatus() { - return currentStatus; - } - - public TransactionStatus getAttemptedStatus() { - return attemptedStatus; - } -} - 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 index 9fe6681471..6a95111676 100644 --- 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 @@ -26,7 +26,7 @@ public class CreateTransactionRequest { private UUID accountExternalIdCredit; @NotNull(message = "Transfer type ID is required") - private Integer tranferTypeId; // Mantener typo del README original + private Integer tranferTypeId; @NotNull(message = "Value is required") @DecimalMin(value = "0.01", message = "Value must be greater than zero") 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 index 940e6c562b..256eab0c8a 100644 --- 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 @@ -10,9 +10,7 @@ /** * Objeto de response para transacciones - * - * @author Yape Engineering Team - * @version 1.0.0 + * @author lmarusic */ @Data @Builder @@ -21,29 +19,9 @@ public class TransactionResponse { private UUID transactionExternalId; - private TransactionTypeDto transactionType; - private TransactionStatusDto transactionStatus; - private BigDecimal value; - private LocalDateTime createdAt; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class TransactionTypeDto { - private String name; - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class TransactionStatusDto { - private String name; - } } 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 index 8845cf62c0..4b0cf32402 100644 --- 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 @@ -2,13 +2,13 @@ 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 - * @version 1.0.0 */ @Component public class TransactionMapper { @@ -19,10 +19,10 @@ public class TransactionMapper { public TransactionResponse toResponse(Transaction transaction) { return TransactionResponse.builder() .transactionExternalId(transaction.transactionExternalId()) - .transactionType(TransactionResponse.TransactionTypeDto.builder() + .transactionType(TransactionTypeDto.builder() .name(transaction.transactionType().getName()) .build()) - .transactionStatus(TransactionResponse.TransactionStatusDto.builder() + .transactionStatus(TransactionStatusDto.builder() .name(transaction.status().getValue()) .build()) .value(transaction.value()) 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 index 2b36879e98..ffbf702027 100644 --- 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 @@ -5,10 +5,9 @@ import java.util.UUID; /** - * Modelo de dominio inmutable que representa una transacción financiera - * Usa Java 21 Record para inmutabilidad y expresividad + * Modelo de dominio que representa una transacción financiera * - * @author Yape Engineering Team + * @author lmarusic * @version 1.0.0 */ public record Transaction( @@ -22,9 +21,6 @@ public record Transaction( LocalDateTime updatedAt ) { - /** - * Constructor compacto con validaciones de negocio - */ public Transaction { if (transactionExternalId == null) { throw new IllegalArgumentException("Transaction external ID cannot be null"); @@ -49,9 +45,6 @@ public record Transaction( } } - /** - * Factory method para crear una nueva transacción en estado PENDING - */ public static Transaction createPending( UUID accountExternalIdDebit, UUID accountExternalIdCredit, @@ -71,10 +64,6 @@ public static Transaction createPending( ); } - /** - * Crea una nueva transacción con el estado actualizado - * Patrón inmutable: retorna nueva instancia en lugar de modificar - */ public Transaction withStatus(TransactionStatus newStatus) { return new Transaction( this.transactionExternalId, 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 index 6e12c420ab..54f8a5d9e2 100644 --- 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 @@ -3,7 +3,7 @@ /** * Estado de una transacción en el sistema * - * @author Yape Engineering Team + * @author lmarusic * @version 1.0.0 */ public enum TransactionStatus { 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 index 6d4760f044..d129248411 100644 --- 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 @@ -1,9 +1,9 @@ package pe.com.yape.ms.transaction.model.domain; /** - * Tipo de transacción financiera + * Tipos de transacciones financiera * - * @author Yape Engineering Team + * @author lmarusic * @version 1.0.0 */ public enum TransactionType { @@ -43,13 +43,6 @@ public String getName() { return name; } - /** - * Obtiene el tipo de transacción por ID - * - * @param id identificador del tipo - * @return enum correspondiente - * @throws IllegalArgumentException si el ID no es válido - */ public static TransactionType fromId(int id) { for (TransactionType type : values()) { if (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 index b04b7f243b..0a9a20258f 100644 --- 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 @@ -1,6 +1,18 @@ package pe.com.yape.ms.transaction.model.entity; -import jakarta.persistence.*; +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; @@ -11,23 +23,14 @@ import pe.com.yape.ms.transaction.model.domain.TransactionStatus; import pe.com.yape.ms.transaction.model.domain.TransactionType; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.UUID; - /** * Entidad JPA para persistencia de transacciones - * Adaptador entre el dominio (Transaction Record) y la base de datos - * - * @author Yape Engineering Team + * + * @author lmarusic * @version 1.0.0 */ @Entity -@Table(name = "transactions", indexes = { - @Index(name = "idx_transaction_external_id", columnList = "transaction_external_id"), - @Index(name = "idx_transaction_status", columnList = "status"), - @Index(name = "idx_transaction_created_at", columnList = "created_at") -}) +@Table(name = "transactions") @EntityListeners(AuditingEntityListener.class) @Data @NoArgsConstructor diff --git a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCase.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCase.java deleted file mode 100644 index a30c0cd2ea..0000000000 --- a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCase.java +++ /dev/null @@ -1,24 +0,0 @@ -package pe.com.yape.ms.transaction.service; - -import java.util.UUID; -import pe.com.yape.ms.transaction.model.domain.Transaction; -import pe.com.yape.ms.transaction.model.domain.TransactionStatus; - -/** - * Caso de Uso: Actualizar el estado de una transacción - * - * @author lamrusic - * @version 1.0.0 - */ -public interface UpdateTransactionStatusUseCase { - - /** - * Actualiza el estado de una transacción existente - * - * @param transactionExternalId ID de la transacción - * @param newStatus nuevo estado (APPROVED o REJECTED) - * @return Transaction - */ - Transaction execute(UUID transactionExternalId, TransactionStatus newStatus); -} - 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 index 044182dd2f..cd75c5c2fa 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -15,9 +16,7 @@ /** * Adaptador de Cache implementada en Redis - * * @author lmarusic - * @version 1.0.0 */ @Component @RequiredArgsConstructor @@ -25,7 +24,9 @@ public class RedisCacheAdapter implements CacheRepositoryPort { private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); private static final String CACHE_PREFIX = "transaction:"; @@ -58,7 +59,10 @@ public Optional findByExternalId(UUID transactionExternalId) { log.debug("Cache hit for transaction: {}", transactionExternalId); return Optional.of(transaction); } catch (JsonProcessingException e) { - log.error("Error deserializing transaction from cache: {}", transactionExternalId, 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(); } } 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 index 2ab43df36c..36f0f425f6 100644 --- 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 @@ -35,13 +35,10 @@ public Transaction execute( log.info("Creating transaction - Debit: {}, Credit: {}, Type: {}, Value: {}", accountExternalIdDebit, accountExternalIdCredit, transferTypeId, value); - // Validar datos de entrada validateInputs(accountExternalIdDebit, accountExternalIdCredit, value); - // Obtener el tipo de transacción TransactionType transactionType = TransactionType.fromId(transferTypeId); - // Crear transacción en estado PENDING usando factory method Transaction transaction = Transaction.createPending( accountExternalIdDebit, accountExternalIdCredit, @@ -49,13 +46,11 @@ public Transaction execute( value ); - // Guardar en PostgreSQL - // Debezium CDC capturará este INSERT automáticamente desde WAL - // y publicará el evento 'transaction.created' a Kafka 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; } @@ -72,4 +67,3 @@ private void validateInputs(UUID accountDebit, UUID accountCredit, BigDecimal va } } } - 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 index 16273d6be6..ce2f7ef8b3 100644 --- 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 @@ -25,20 +25,18 @@ public class GetTransactionUseCaseImpl implements GetTransactionUseCase { private final TransactionRepositoryPort transactionRepository; private final CacheRepositoryPort cacheRepository; - private static final long CACHE_TTL_SECONDS = 3600L; // 1 hora + private static final long CACHE_TTL_SECONDS = 3600L; @Override @Transactional(readOnly = true) public Transaction execute(UUID transactionExternalId) { log.debug("Getting transaction: {}", transactionExternalId); - // 1. Buscar en cache (Redis) - Fast path return cacheRepository.findByExternalId(transactionExternalId) .map(transaction -> { log.info("Transaction found in cache: {}", transactionExternalId); return transaction; }) - // 2. Si no está en cache, buscar en BD - Slow path .orElseGet(() -> { log.debug("Transaction not in cache, searching in database: {}", transactionExternalId); @@ -49,10 +47,8 @@ public Transaction execute(UUID transactionExternalId) { return new TransactionNotFoundException(transactionExternalId); }); - // 3. Guardar en cache para futuras consultas cacheRepository.save(transaction, CACHE_TTL_SECONDS); - log.info("Transaction found in database and cached: {}", transactionExternalId); - + 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/impl/UpdateTransactionStatusUseCaseImpl.java b/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/UpdateTransactionStatusUseCaseImpl.java deleted file mode 100644 index 7bc36265ca..0000000000 --- a/ms-ne-transaction-service/src/main/java/pe/com/yape/ms/transaction/service/impl/UpdateTransactionStatusUseCaseImpl.java +++ /dev/null @@ -1,73 +0,0 @@ -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.InvalidTransactionStateException; -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.TransactionStatus; -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.UpdateTransactionStatusUseCase; - -/** - * Implementación del caso de uso: UpdateTransactionStatusUseCase - * - * @author lmarusic - * @version 1.0.0 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class UpdateTransactionStatusUseCaseImpl implements UpdateTransactionStatusUseCase { - - private final TransactionRepositoryPort transactionRepository; - private final CacheRepositoryPort cacheRepository; - - private static final long CACHE_TTL_SECONDS = 3600L; // 1 hora - - @Override - @Transactional - public Transaction execute(UUID transactionExternalId, TransactionStatus newStatus) { - log.info("Updating transaction {} to status: {}", transactionExternalId, newStatus); - - // 1. Buscar transacción - Transaction currentTransaction = transactionRepository - .findByExternalId(transactionExternalId) - .orElseThrow(() -> { - log.warn("Transaction not found for update: {}", transactionExternalId); - return new TransactionNotFoundException(transactionExternalId); - }); - - // 2. Validar que esté en estado PENDING - if (!currentTransaction.isPending()) { - log.warn("Attempted to update non-pending transaction: {} from {} to {}", - transactionExternalId, currentTransaction.status(), newStatus); - throw new InvalidTransactionStateException( - transactionExternalId, - currentTransaction.status(), - newStatus - ); - } - - // 3. Crear nueva instancia con estado actualizado (inmutabilidad) - Transaction updatedTransaction = currentTransaction.withStatus(newStatus); - - // 4. Guardar en BD - // Debezium CDC capturará este UPDATE automáticamente desde WAL - // y publicará el evento 'transaction.updated' a Kafka - Transaction savedTransaction = transactionRepository.update(updatedTransaction); - - // 5. Actualizar cache - cacheRepository.save(savedTransaction, CACHE_TTL_SECONDS); - - log.info("Transaction updated successfully: {} - Status: {}", - savedTransaction.transactionExternalId(), savedTransaction.status()); - - return savedTransaction; - } -} - diff --git a/ms-ne-transaction-service/src/main/resources/application.yml b/ms-ne-transaction-service/src/main/resources/application.yml index 8b5f99c45a..adad2258b5 100644 --- a/ms-ne-transaction-service/src/main/resources/application.yml +++ b/ms-ne-transaction-service/src/main/resources/application.yml @@ -3,9 +3,9 @@ spring: name: ms-ne-transaction-service datasource: - url: jdbc:postgresql://localhost:5432/yape_db - username: yape_user - password: yape_password + 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: @@ -19,15 +19,18 @@ spring: data: redis: - host: localhost - port: 6379 + host: ${SPRING_DATA_REDIS_HOST:localhost} + port: ${SPRING_DATA_REDIS_PORT:6379} timeout: 60000 flyway: - enabled: true + 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 @@ -35,12 +38,13 @@ management: endpoints: web: exposure: - include: health,info,metrics + include: health,info,metrics,cache endpoint: health: show-details: always logging: level: - pe.com.yape.ms.transaction: INFO - + pe.com.yape.ms.transaction: DEBUG + org.springframework.cloud.stream: DEBUG + org.springframework.kafka: INFO diff --git a/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCaseTest.java b/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCaseTest.java deleted file mode 100644 index 4f299ef55e..0000000000 --- a/ms-ne-transaction-service/src/test/java/pe/com/yape/ms/transaction/service/UpdateTransactionStatusUseCaseTest.java +++ /dev/null @@ -1,195 +0,0 @@ -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.InvalidTransactionStateException; -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.TransactionStatus; -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.UpdateTransactionStatusUseCaseImpl; - -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 UpdateTransactionStatusUseCase (TDD - GREEN Phase) - * Verifica las reglas de negocio de actualización de estado - * - * @author Yape Engineering Team - * @version 1.0.0 - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("Update Transaction Status Use Case Tests - GREEN Phase") -class UpdateTransactionStatusUseCaseTest { - - @Mock - private TransactionRepositoryPort transactionRepository; - - @Mock - private CacheRepositoryPort cacheRepository; - - private UpdateTransactionStatusUseCase updateTransactionStatusUseCase; - - @BeforeEach - void setUp() { - // Ahora SÍ existe la implementación - updateTransactionStatusUseCase = new UpdateTransactionStatusUseCaseImpl( - transactionRepository, - cacheRepository - ); - } - - @Test - @DisplayName("RED: Debe actualizar transacción de PENDING a APPROVED") - void shouldUpdateTransactionFromPendingToApproved() { - // Given - UUID transactionId = UUID.randomUUID(); - Transaction pendingTransaction = Transaction.createPending( - UUID.randomUUID(), - UUID.randomUUID(), - TransactionType.TRANSFER, - new BigDecimal("500.00") - ); - - Transaction approvedTransaction = pendingTransaction.withStatus(TransactionStatus.APPROVED); - - when(transactionRepository.findByExternalId(transactionId)) - .thenReturn(Optional.of(pendingTransaction)); - when(transactionRepository.update(any(Transaction.class))) - .thenReturn(approvedTransaction); - - // When - Transaction result = updateTransactionStatusUseCase.execute( - transactionId, - TransactionStatus.APPROVED - ); - - // Then - assertNotNull(result); - assertEquals(TransactionStatus.APPROVED, result.status()); - assertTrue(result.isApproved()); - verify(transactionRepository, times(1)).update(any(Transaction.class)); - verify(cacheRepository, times(1)).save(eq(result), anyLong()); - } - - @Test - @DisplayName("RED: Debe actualizar transacción de PENDING a REJECTED") - void shouldUpdateTransactionFromPendingToRejected() { - // Given - UUID transactionId = UUID.randomUUID(); - Transaction pendingTransaction = Transaction.createPending( - UUID.randomUUID(), - UUID.randomUUID(), - TransactionType.TRANSFER, - new BigDecimal("1500.00") - ); - - Transaction rejectedTransaction = pendingTransaction.withStatus(TransactionStatus.REJECTED); - - when(transactionRepository.findByExternalId(transactionId)) - .thenReturn(Optional.of(pendingTransaction)); - when(transactionRepository.update(any(Transaction.class))) - .thenReturn(rejectedTransaction); - - // When - Transaction result = updateTransactionStatusUseCase.execute( - transactionId, - TransactionStatus.REJECTED - ); - - // Then - assertNotNull(result); - assertEquals(TransactionStatus.REJECTED, result.status()); - assertTrue(result.isRejected()); - verify(transactionRepository, times(1)).update(any(Transaction.class)); - } - - @Test - @DisplayName("GREEN: Debe lanzar excepción si la transacción no existe") - void shouldThrowExceptionWhenTransactionNotFound() { - // Given - UUID transactionId = UUID.randomUUID(); - - when(transactionRepository.findByExternalId(transactionId)) - .thenReturn(Optional.empty()); - - // When & Then - assertThrows(TransactionNotFoundException.class, () -> - updateTransactionStatusUseCase.execute( - transactionId, - TransactionStatus.APPROVED - ) - ); - - verify(transactionRepository, never()).update(any()); - verify(cacheRepository, never()).save(any(), anyLong()); - } - - @Test - @DisplayName("GREEN: Debe lanzar excepción si la transacción no está en estado PENDING") - void shouldThrowExceptionWhenTransactionIsNotPending() { - // Given - UUID transactionId = UUID.randomUUID(); - Transaction approvedTransaction = Transaction.createPending( - UUID.randomUUID(), - UUID.randomUUID(), - TransactionType.TRANSFER, - new BigDecimal("100.00") - ).withStatus(TransactionStatus.APPROVED); - - when(transactionRepository.findByExternalId(transactionId)) - .thenReturn(Optional.of(approvedTransaction)); - - // When & Then - assertThrows(InvalidTransactionStateException.class, () -> - updateTransactionStatusUseCase.execute( - transactionId, - TransactionStatus.REJECTED - ) - ); - - verify(transactionRepository, never()).update(any()); - } - - @Test - @DisplayName("RED: Debe actualizar el cache después de actualizar en DB") - void shouldUpdateCacheAfterDatabaseUpdate() { - // Given - UUID transactionId = UUID.randomUUID(); - Transaction pendingTransaction = Transaction.createPending( - UUID.randomUUID(), - UUID.randomUUID(), - TransactionType.TRANSFER, - new BigDecimal("300.00") - ); - - Transaction approvedTransaction = pendingTransaction.withStatus(TransactionStatus.APPROVED); - - when(transactionRepository.findByExternalId(transactionId)) - .thenReturn(Optional.of(pendingTransaction)); - when(transactionRepository.update(any(Transaction.class))) - .thenReturn(approvedTransaction); - - // When - updateTransactionStatusUseCase.execute(transactionId, TransactionStatus.APPROVED); - - // Then - Verificar orden: DB update -> cache save - var inOrder = inOrder(transactionRepository, cacheRepository); - inOrder.verify(transactionRepository).update(any(Transaction.class)); - inOrder.verify(cacheRepository).save(eq(approvedTransaction), anyLong()); - } -} - diff --git a/ms-sp-antifraud-rules/Dockerfile b/ms-sp-antifraud-rules/Dockerfile index b0d88e5901..f2f6b9fa4e 100644 --- a/ms-sp-antifraud-rules/Dockerfile +++ b/ms-sp-antifraud-rules/Dockerfile @@ -1,11 +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 -ARG JAR_FILE=build/libs/*.jar -COPY ${JAR_FILE} app.jar +COPY --from=builder /workspace/app/build/libs/*.jar app.jar -EXPOSE 8082 +EXPOSE 9993 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/ms-sp-antifraud-rules/build.gradle b/ms-sp-antifraud-rules/build.gradle index ad8570af0d..beb0788fea 100644 --- a/ms-sp-antifraud-rules/build.gradle +++ b/ms-sp-antifraud-rules/build.gradle @@ -32,13 +32,13 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-actuator' - // Spring Cloud Stream + Kafka Streams - implementation 'org.springframework.cloud:spring-cloud-starter-stream-kafka' - implementation 'org.apache.kafka:kafka-streams' + // 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' - // Kafka Streams Test Utils - testImplementation 'org.apache.kafka:kafka-streams-test-utils' + // JSON Processing + implementation 'com.fasterxml.jackson.core:jackson-databind' // Lombok compileOnly 'org.projectlombok:lombok' @@ -49,6 +49,7 @@ dependencies { // 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' 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 index 8e940ea35a..e7ff4ea04a 100644 --- 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 @@ -5,10 +5,7 @@ /** * MS-SP-ANTIFRAUD-RULES - * Motor de reglas especializadas para detección de fraude - * - * @author Yape Engineering Team - * @version 1.0.0 + * @author lmarusic */ @SpringBootApplication public class AntiFraudServiceApplication { diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/config/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/application/config/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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/controller/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/controller/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/domain/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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/model/event/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/model/event/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/impl/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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/rules/.gitkeep b/ms-sp-antifraud-rules/src/main/java/pe/com/yape/ms/antifraud/service/rules/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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 index c901d6ef15..308bdf3a14 100644 --- a/ms-sp-antifraud-rules/src/main/resources/application.yml +++ b/ms-sp-antifraud-rules/src/main/resources/application.yml @@ -6,21 +6,41 @@ spring: stream: kafka: binder: - brokers: localhost:9092 + 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: 8082 + port: 9993 management: endpoints: web: exposure: - include: health,info,metrics + 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/.gitkeep b/ms-sp-antifraud-rules/src/test/java/pe/com/yape/ms/antifraud/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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 index 76f9306af9..1bbcb10420 100644 --- a/ms-ux-orchestrator/Dockerfile +++ b/ms-ux-orchestrator/Dockerfile @@ -1,5 +1,7 @@ FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl + WORKDIR /app ARG JAR_FILE=build/libs/*.jar 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 index f9bee2ce69..cbd723e6d0 100644 --- 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 @@ -14,24 +14,41 @@ /** * Manejador global de excepciones para el ms - * * @author lmarusic - * @version 1.0.0 */ @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); + log.error("Transaction service error: {}", ex.getMessage()); ErrorResponse error = ErrorResponse.builder() .status(HttpStatus.BAD_GATEWAY.value()) - .message("Error communicating with transaction service: " + ex.getMessage()) + .message("Error communicating with transaction service") .path(exchange.getRequest().getPath().value()) .timestamp(LocalDateTime.now()) .build(); @@ -46,11 +63,11 @@ public Mono> handleTimeoutException( TimeoutException ex, ServerWebExchange exchange ) { - log.error("Timeout error", ex); + log.error("Timeout error: {}", ex.getMessage()); ErrorResponse error = ErrorResponse.builder() .status(HttpStatus.GATEWAY_TIMEOUT.value()) - .message("Request timeout: " + ex.getMessage()) + .message("Request timeout - transaction service did not respond in time") .path(exchange.getRequest().getPath().value()) .timestamp(LocalDateTime.now()) .build(); @@ -61,9 +78,11 @@ public Mono> handleTimeoutException( } @ExceptionHandler(WebExchangeBindException.class) - public Mono> handleValidationException(WebExchangeBindException ex, - ServerWebExchange exchange) { - log.error("Validation error", ex); + public Mono> handleValidationException( + WebExchangeBindException ex, + ServerWebExchange exchange + ) { + log.warn("Validation error: {}", ex.getMessage()); StringBuilder errors = new StringBuilder(); ex.getBindingResult().getFieldErrors().forEach(error -> @@ -83,13 +102,15 @@ public Mono> handleValidationException(WebExchange } @ExceptionHandler(Exception.class) - public Mono> handleGenericException(Exception ex, - ServerWebExchange exchange) { - log.error("Unexpected error", ex); + 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: " + ex.getMessage()) + .message("An unexpected error occurred") .path(exchange.getRequest().getPath().value()) .timestamp(LocalDateTime.now()) .build(); 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/model/request/CreateTransactionRequest.java b/ms-ux-orchestrator/src/main/java/pe/com/yape/orchestrator/model/request/CreateTransactionRequest.java index 3b3b65e189..8001b4328c 100644 --- 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 @@ -28,7 +28,7 @@ public class CreateTransactionRequest { private UUID accountExternalIdCredit; @NotNull(message = "Transfer type ID is required") - private Integer tranferTypeId; // Mantener typo del README + private Integer tranferTypeId; @NotNull(message = "Value is required") @DecimalMin(value = "0.01", message = "Value must be greater than zero") 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 index f84ee2416b..0bfdceafd4 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -61,20 +62,31 @@ public Mono getTransactionById(UUID transactionExternalId) .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 retrieving transaction: " + errorBody)); + "Error communicating with transaction service: " + errorBody)); }) ) .bodyToMono(TransactionResponse.class) .timeout(Duration.ofMillis(timeout)) - .doOnError(error -> + .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, error)); + transactionExternalId)); } } diff --git a/ms-ux-orchestrator/src/main/resources/application.yml b/ms-ux-orchestrator/src/main/resources/application.yml index a924e7030f..5f3c93bf18 100644 --- a/ms-ux-orchestrator/src/main/resources/application.yml +++ b/ms-ux-orchestrator/src/main/resources/application.yml @@ -7,8 +7,8 @@ server: services: transaction: - base-url: http://localhost:9992 - timeout: 5000 + base-url: ${SERVICES_TRANSACTION_BASE_URL:http://localhost:9992} + timeout: ${SERVICES_TRANSACTION_TIMEOUT:5000} management: endpoints: diff --git a/ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/.gitkeep b/ms-ux-orchestrator/src/test/java/pe/com/yape/orchestrator/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 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); + } + +} + From 36322b877e687f867e3b9073c63105de8c323ccb Mon Sep 17 00:00:00 2001 From: Ludwig Marusic Date: Tue, 6 Jan 2026 13:40:27 -0500 Subject: [PATCH 4/5] feat: add documentation, diagrams and execution guide --- DOCKER_SETUP.md | 235 ------------------------ README.md | 471 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 372 insertions(+), 334 deletions(-) delete mode 100644 DOCKER_SETUP.md diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md deleted file mode 100644 index d515125455..0000000000 --- a/DOCKER_SETUP.md +++ /dev/null @@ -1,235 +0,0 @@ -# Guía de Ejecución con Docker Compose - -## 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 (con generación de clases Avro) - -2. ✅ **Generación de clases Avro** - - `TransactionCreatedEvent.java` - - `TransactionValidatedEvent.java` - - `ValidationStatus.java` - -3. ✅ **Registro de esquemas en Schema Registry** - - `yape.public.transactions-value` - - `transaction.validated-value` - -4. ✅ **Levantamiento de toda la infraestructura** - - PostgreSQL con CDC habilitado - - Redis - - Kafka + Zookeeper - - Schema Registry - - Kafka Connect con Debezium - - Control Center - - 3 Microservicios - -## Orden de Inicio - -``` -1. postgres, redis, zookeeper -2. kafka -3. schema-registry -4. schema-registry-init (registra esquemas) -5. transaction-service (ejecuta migraciones) -6. orchestrator-service -7. connect (Debezium CDC) -8. antifraud-service -9. control-center -``` - -## Verificar que Todo Funciona - -### 1. Ver logs de compilación -```bash -docker-compose logs -f antifraud-service -``` - -### 2. Verificar esquemas registrados -```bash -curl http://localhost:8081/subjects -``` - -Deberías ver: -```json -[ - "yape.public.transactions-value", - "transaction.validated-value" -] -``` - -### 3. Ver esquema específico -```bash -curl http://localhost:8081/subjects/transaction.validated-value/versions/latest -``` - -### 4. Verificar servicios activos -```bash -docker-compose ps -``` - -### 5. Health checks -```bash -curl http://localhost:9992/actuator/health -curl http://localhost:9991/actuator/health -curl http://localhost:9993/actuator/health -``` - -## Puertos Expuestos - -| Servicio | Puerto | URL | -|----------|--------|-----| -| PostgreSQL | 5432 | jdbc:postgresql://localhost:5432/yape_db | -| Redis | 6379 | localhost:6379 | -| Kafka | 9092 | localhost:9092 | -| Schema Registry | 8081 | http://localhost:8081 | -| 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 -``` - -### Reconstruir solo un servicio -```bash -docker-compose up -d --build antifraud-service -``` - -### Detener todo -```bash -docker-compose down -``` - -### Detener y eliminar volúmenes (datos) -```bash -docker-compose down -v -``` - -## Probar el Flujo Completo - -### 1. Crear una transacción -```bash -curl -X POST http://localhost:9991/api/transactions \ - -H "Content-Type: application/json" \ - -d '{ - "accountExternalIdDebit": "acc-001", - "accountExternalIdCredit": "acc-002", - "tranferTypeId": 1, - "value": 500.0 - }' -``` - -### 2. Consultar estado -```bash -curl http://localhost:9991/api/transactions/{transactionId} -``` - -### 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 -``` - -## Troubleshooting - -### Problema: Antifraud no compila -```bash -docker-compose logs antifraud-service -``` - -### Problema: Esquemas no se registran -```bash -docker-compose logs schema-registry-init -docker-compose restart schema-registry-init -``` - -### Problema: Debezium no captura eventos -```bash -curl http://localhost:8083/connectors/yape-transaction-connector/status -``` - -### Problema: Servicios no se comunican -```bash -docker network inspect yape-network -``` - -## Desarrollo Local vs Docker - -### Desarrollo Local -```bash -cd ms-sp-antifraud-rules -./gradlew generateAvroJava -./gradlew bootRun -``` - -### Con Docker -```bash -docker-compose up -d --build antifraud-service -``` - -## Limpieza Total - -```bash -docker-compose down -v -docker system prune -a --volumes -``` - -## Notas Importantes - -- ⚠️ La primera compilación tardará más (descarga dependencias) -- ⚠️ El servicio antifraud espera a que los esquemas estén registrados -- ⚠️ Transaction Service ejecuta Flyway migrations automáticamente -- ⚠️ Debezium registra el conector automáticamente al iniciar - -## Arquitectura Multi-Stage Build - -``` -┌─────────────────────────────────────┐ -│ Stage 1: Builder (JDK) │ -│ - Copia código fuente │ -│ - Ejecuta ./gradlew build │ -│ - Genera clases Avro │ -│ - Crea JAR ejecutable │ -└──────────────┬──────────────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ Stage 2: Runtime (JRE) │ -│ - Copia solo el JAR │ -│ - Imagen ligera │ -│ - Lista para ejecutar │ -└─────────────────────────────────────┘ -``` - -**Ventaja:** No necesitas tener Java, Gradle ni Avro instalados localmente. Todo se compila dentro de Docker. - diff --git a/README.md b/README.md index ad825e5432..5e08fa0c10 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,409 @@ -# 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) - -# Problem +```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) + + 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) + + 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 + + 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 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: +### Diagrama de Casos de Uso -
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. -
+```mermaid +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 +``` -Every transaction with a value greater than 1000 should be rejected. +#### 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 -```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)] ``` +┌────────────────────────────────────────────────────────────┐ +│ 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 | -# Tech Stack +### Event Streaming & CDC -Esta implementación tiene: +| 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 | -
    -
  1. Java 21 - Aprovechando Virtual Threads y Records
  2. -
  3. Spring Boot 3.4.1 - Framework base para los microservicios
  4. -
  5. PostgreSQL - Base de datos principal con CDC habilitado
  6. -
  7. Redis - Cache para lecturas de alto volumen
  8. -
  9. Kafka + Debezium - Event Streaming y CDC
  10. -
  11. Spring Cloud Stream - Comunicación con Kafka
  12. -
  13. Flyway - Migraciones de base de datos
  14. -
+### Containerización & Orquestación -## 🏗️ Arquitectura de Microservicios +| 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 | -El proyecto está organizado en 3 microservicios siguiendo : -### 1. **ms-ux-orchestrator** (Puerto: 8080) -- **Rol**: Backend for Frontend (BFF) -- **Tecnologías**: Spring WebFlux, WebClient -- **Responsabilidad**: API Gateway, validaciones y orquestación -### 2. **ms-ne-transaction-service** (Puerto: 8081) -- **Rol**: Core de Negocio -- **Tecnologías**: Spring Data JPA, PostgreSQL, Redis, Kafka Consumer -- **Responsabilidad**: Gestión de transacciones y persistencia -- **Patrones**: Transactional Outbox implementado con CDC +## Ejecución Completa con Un Solo Comando -### 3. **ms-sp-antifraud-rules** (Puerto: 8082) -- **Rol**: Motor de Reglas Especializadas -- **Tecnologías**: Kafka Streams, Spring Cloud Stream -- **Responsabilidad**: Validación anti-fraude en tiempo real -- **Patrones**: Event Streaming, Chain of Responsibility +```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 +``` + +## Verificar que Todo Funciona + +### 1. Ver logs de compilación +```bash +docker-compose logs -f antifraud-service +``` -## 📦 Estructura del Proyecto +### 2. Verificar servicios activos +```bash +docker-compose ps +``` +### 3. Health checks +```bash +curl http://localhost:9992/actuator/health +curl http://localhost:9991/actuator/health +curl http://localhost:9993/actuator/health ``` -app-nodejs-codechallenge/ -├── ms-ux-orchestrator/ # BFF - API Gateway -├── ms-ne-transaction-service/ # Servicio Core de Transacciones -├── ms-sp-antifraud-rules/ # Motor de Reglas Anti-Fraude -├── settings.gradle # Configuración multi-proyecto -├── build-all.sh # Script de compilación (Linux/Mac) -├── build-all.bat # Script de compilación (Windows) -└── docker-compose.yml # Orquestación de servicios + +### 4. Verificar conector Debezium +```bash +curl http://localhost:8083/connectors/yape-transaction-connector/status ``` -Cada microservicio sigue la estructura de **Arquitectura Hexagonal**: -- `controller/` - Adaptadores de entrada (REST) -- `service/` - Casos de uso (Application Layer) -- `model/` - Dominio (domain, entity, port) -- `repository/` - Adaptadores de salida (Persistencia) -- `application/config/` - Configuración -- `application/exception/` - Manejo de errores +### 5. Verificar tópicos de Kafka +```bash +docker exec -it yape-kafka kafka-topics --bootstrap-server localhost:9092 --list +``` -## 🚀 Cómo Ejecutar +## Puertos Expuestos + +| 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 +``` -### Pre-requisitos -- Java 21 JDK zulu -- Gradle 8.x (aca falta incluir la varsionc on el wrapper para que funcione siempre) -- Docker & Docker Compose +### 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 +``` -You must have two resources: -1. Resource to create a transaction that must containt: +### Detener y eliminar volúmenes (datos) +```bash +docker-compose down -v +``` -```json -{ - "accountExternalIdDebit": "Guid", - "accountExternalIdCredit": "Guid", - "tranferTypeId": 1, - "value": 120 -} +## Probar el Flujo Completo + +### 1. Crear una transacción +```bash +curl -X POST http://localhost:9991/api/transactions \ + -H "Content-Type: application/json" \ + -d '{ + "accountExternalIdDebit": "acc-001", + "accountExternalIdCredit": "acc-002", + "tranferTypeId": 1, + "value": 500.0 + }' ``` -2. Resource to retrieve a transaction +### 2. Consultar estado +```bash +curl http://localhost:9991/api/transactions/{transactionId} +``` -```json -{ - "transactionExternalId": "Guid", - "transactionType": { - "name": "" - }, - "transactionStatus": { - "name": "" - }, - "value": 120, - "createdAt": "Date" -} +### 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 ``` -## Optional +## Notas Importantes -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? +- ⚠️ 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 -You can use Graphql; +## Arquitectura Multi-Stage Build -# Send us your challenge +``` +┌─────────────────────────────────────┐ +│ 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. From 31485e1d172a2283dcf50215f91b187fe8b60d56 Mon Sep 17 00:00:00 2001 From: Ludwig Marusic Date: Tue, 6 Jan 2026 13:51:00 -0500 Subject: [PATCH 5/5] feat: change documentation. add excamples --- README.md | 65 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5e08fa0c10..2e7e1c74b8 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,41 @@ curl http://localhost:8083/connectors/yape-transaction-connector/status docker exec -it yape-kafka kafka-topics --bootstrap-server localhost:9092 --list ``` +## Probar el Flujo Completo + +### 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 + }' +``` + +### 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 +``` + + ## Puertos Expuestos | Servicio | Puerto | URL | @@ -340,37 +375,7 @@ docker-compose logs -f connect docker-compose down -v ``` -## Probar el Flujo Completo - -### 1. Crear una transacción -```bash -curl -X POST http://localhost:9991/api/transactions \ - -H "Content-Type: application/json" \ - -d '{ - "accountExternalIdDebit": "acc-001", - "accountExternalIdCredit": "acc-002", - "tranferTypeId": 1, - "value": 500.0 - }' -``` - -### 2. Consultar estado -```bash -curl http://localhost:9991/api/transactions/{transactionId} -``` -### 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 -``` ## Notas Importantes