diff --git a/.gitignore b/.gitignore
index 67045665db..a13028cf39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,104 +1,34 @@
+# Java
+*.class
+*.jar
+*.war
+
+# Maven
+target/
+!.mvn/wrapper/maven-wrapper.jar
+
+# IDE
+.idea/
+*.iml
+*.ipr
+*.iws
+.vscode/
+.settings/
+.classpath
+.project
+.factorypath
+*.swp
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
# Logs
-logs
*.log
-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/
-
-# 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
.env
-.env.test
-
-# parcel-bundler cache (https://parceljs.org/)
-.cache
-
-# Next.js build output
-.next
-# Nuxt.js build / generate output
-.nuxt
-dist
-
-# 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
-
-# vuepress build output
-.vuepress/dist
-
-# Serverless directories
-.serverless/
-
-# FuseBox cache
-.fusebox/
-
-# DynamoDB Local files
-.dynamodb/
-
-# TernJS port file
-.tern-port
+# Node (legacy from original repo)
+node_modules/
diff --git a/README.md b/README.md
index b067a71026..6698e32e05 100644
--- a/README.md
+++ b/README.md
@@ -1,82 +1,286 @@
-# Yape Code Challenge :rocket:
+# Yape Code Challenge | Sistema Anti-Fraude
-Our code challenge will let you marvel us with your Jedi coding skills :smile:.
+Sistema de validación anti-fraude para transacciones financieras. Dos microservicios en **Java 21** y **Spring Boot 3.4** que se comunican por **Kafka** de forma asíncrona, con **PostgreSQL** como base de datos.
-Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !!
+Cada servicio sigue Arquitectura Hexagonal: el dominio no depende de ningún framework, toda la lógica de negocio está aislada y es testeable sin infraestructura.
-- [Problem](#problem)
-- [Tech Stack](#tech_stack)
-- [Send us your challenge](#send_us_your_challenge)
+---
-# Problem
+## Inicio rápido
-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:
+Para ejecutar el proyecto se necesita Docker. Asegurar que los puertos `8080`, `8081`, `5432` y `9094` estén libres:
-
- - pending
- - approved
- - rejected
-
+```bash
+docker compose up --build
+```
+
+Una vez que los contenedores estén ejecutándose, se pueden probar los endpoints:
+
+Crear una transacción (monto <= 1000, se aprueba):
+
+```bash
+curl -s -X POST http://localhost:8080/api/v1/transactions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000",
+ "accountExternalIdCredit": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
+ "transferTypeId": 1,
+ "value": 500
+ }'
+```
+
+Crear una transacción (monto > 1000, se rechaza):
+
+```bash
+curl -s -X POST http://localhost:8080/api/v1/transactions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440001",
+ "accountExternalIdCredit": "6ba7b810-9dad-11d1-80b4-00c04fd430c9",
+ "transferTypeId": 1,
+ "value": 1500
+ }'
+```
-Every transaction with a value greater than 1000 should be rejected.
+Consultar una transacción (reemplazar `{id}` con el ID de la respuesta anterior):
+
+```bash
+curl -s http://localhost:8080/api/v1/transactions/{id}
+```
+
+### Errores
+
+| Código | Cuándo |
+|--------|--------|
+| 400 | Campos faltantes, monto negativo, cuentas débito y crédito iguales |
+| 404 | Transacción no encontrada |
+| 500 | Error interno |
+
+---
+
+## Arquitectura
```mermaid
- flowchart LR
- Transaction -- Save Transaction with pending Status --> transactionDatabase[(Database)]
- Transaction --Send transaction Created event--> Anti-Fraud
- Anti-Fraud -- Send transaction Status Approved event--> Transaction
- Anti-Fraud -- Send transaction Status Rejected event--> Transaction
- Transaction -- Update transaction Status event--> transactionDatabase[(Database)]
+graph TB
+ Client([Cliente])
+
+ subgraph TX ["Transaction Service :8080"]
+ Controller[REST Controller]
+ CreateUC[CreateTransactionUseCase]
+ UpdateUC[UpdateTransactionStatusUseCase]
+ Repo[(PostgreSQL)]
+ TxProducer[Kafka Producer]
+ TxConsumer[Kafka Consumer]
+ end
+
+ subgraph AF ["Anti-Fraud Service :8081"]
+ FraudConsumer[Kafka Consumer]
+ FraudUC[EvaluateFraudUseCase]
+ FraudDomain["FraudEvaluationService
monto > 1000 → rechazado"]
+ FraudProducer[Kafka Producer]
+ end
+
+ K1[/Kafka: created.v1/]
+ K2[/Kafka: status-updated.v1/]
+
+ Client -- "POST /transactions" --> Controller
+ Client -- "GET /transactions/{id}" --> Controller
+ Controller --> CreateUC
+ CreateUC --> Repo
+ CreateUC --> TxProducer
+ TxProducer -. produce .-> K1
+ K1 -. consume .-> FraudConsumer
+ FraudConsumer --> FraudUC
+ FraudUC --> FraudDomain
+ FraudUC --> FraudProducer
+ FraudProducer -. produce .-> K2
+ K2 -. consume .-> TxConsumer
+ TxConsumer --> UpdateUC
+ UpdateUC --> Repo
```
-# Tech Stack
+| Servicio | Puerto | Qué hace |
+|----------|--------|----------|
+| **Transaction Service** | 8080 | API REST, persiste transacciones en PostgreSQL, publica y consume eventos Kafka |
+| **Anti-Fraud Service** | 8081 | Evalúa fraude por monto, sin base de datos propia (stateless) |
+
+---
+
+## Flujo de una transacción
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor Cliente
+ participant TX as Transaction Service
+ participant DB as PostgreSQL
+ participant K1 as Kafka: created.v1
+ participant AF as Anti-Fraud Service
+ participant K2 as Kafka: status-updated.v1
+
+ Cliente->>+TX: POST /api/v1/transactions (value=500)
+ TX->>+DB: guardar con status PENDING
+ DB-->>-TX: OK
+ TX--)K1: {transactionId, value=500}
+ TX-->>-Cliente: 201 Created
+
+ K1->>+AF: consume mensaje
+ AF->>AF: 500 <= 1000 → aprobada
+ AF--)K2: {transactionId, status=approved}
+ deactivate AF
+
+ K2->>+TX: consume mensaje
+ TX->>+DB: actualizar status → APPROVED
+ DB-->>-TX: OK
+ deactivate TX
+```
+
+---
+
+## Tecnologías
+
+| Tecnología | Versión | Por qué |
+|------------|---------|---------|
+| Java | 21 LTS | Records, pattern matching, sealed classes |
+| Spring Boot | 3.4.3 | Integración nativa con Kafka, auto-configuración, actuator |
+| PostgreSQL | 16 | NUMERIC(15,2) para montos financieros, ACID |
+| Apache Kafka | 3.8.1 (KRaft) | Mensajería asíncrona sin Zookeeper, productor idempotente |
+| Flyway | via Spring Boot | Migraciones de base de datos versionadas |
+| JUnit 5 + AssertJ | via Spring Boot | Tests sin necesidad de infraestructura |
+
+---
+
+## Decisiones de diseño
-
- - Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
- - Any database
- - Kafka
-
+### Arquitectura Hexagonal
-We do provide a `Dockerfile` to help you get started with a dev environment.
+El dominio no conoce ni depende de Spring, Kafka, JPA ni ningún framework. Toda comunicación con el exterior pasa por interfaces (puertos) que se implementan con adaptadores concretos.
-You must have two resources:
+Esto permite testear toda la lógica de negocio sin levantar base de datos, sin Kafka, sin servidor HTTP. Si desea cambiar de PostgreSQL por MongoDB, solo se reemplaza el adaptador de persistencia y el dominio no se toca.
-1. Resource to create a transaction that must containt:
+### Modelo de dominio
-```json
-{
- "accountExternalIdDebit": "Guid",
- "accountExternalIdCredit": "Guid",
- "tranferTypeId": 1,
- "value": 120
-}
+Cada microservicio tiene su propio contexto acotado. `Transaction` es el agregado raíz que encapsula las reglas de creación, aprobación y rechazo. `FraudEvaluationService` es un servicio de dominio puro que aplica la regla de fraude (monto > 1000 se rechaza) sin conocer Kafka ni ninguna otra dependencia externa.
+
+Las transiciones de estado están protegidas: solo una transacción en estado `PENDING` puede pasar a `APPROVED` o `REJECTED`.
+
+### Kafka
+
+| Decisión | Razón |
+|----------|-------|
+| KRaft (sin Zookeeper) | Simplifica la operación, es el modo recomendado desde Kafka 3.3 |
+| Nombrado de topics: `yape.transaction.event.created.v1` | Sigue la convención `org.dominio.tipo.nombre.version` |
+| Productor idempotente con `acks=all` | Previene duplicados y garantiza entrega |
+| TransactionId como clave del mensaje | Garantiza orden de procesamiento por transacción |
+| Grupos de consumidores dedicados | Cada servicio consume de forma independiente |
+
+### Virtual Threads (Java 21)
+
+Ambos servicios hacen mucho I/O (queries a base de datos, envíos a Kafka). Con virtual threads habilitados (`spring.threads.virtual.enabled: true`), Spring Boot usa threads virtuales para Tomcat, listeners de Kafka y tareas asíncronas. Esto elimina el cuello de botella del pool de threads fijo sin cambiar código de aplicación.
+
+---
+
+## Estructura de cada servicio
+
+Ambos microservicios siguen la misma organización de paquetes:
+
+```
+service/
+├── domain/ # Sin dependencias de framework
+│ ├── model/ # Agregados, value objects, enums
+│ ├── event/ # Eventos de dominio
+│ ├── exception/ # Excepciones de dominio
+│ ├── service/ # Servicios de dominio (lógica pura)
+│ └── port/
+│ ├── in/ # Casos de uso (interfaces) + comandos
+│ └── out/ # Repositorio, publicador de eventos (interfaces)
+│
+├── application/service/ # Implementación de los casos de uso
+│
+└── infrastructure/
+ ├── adapter/in/rest/ # Controladores, DTOs, manejo de errores
+ ├── adapter/in/kafka/ # Consumidores Kafka
+ ├── adapter/out/kafka/ # Productores Kafka
+ ├── adapter/out/persistence/ # Entidades JPA, repositorios, mappers
+ └── config/ # Configuración de Kafka, Jackson, filtros
```
-2. Resource to retrieve a transaction
-
-```json
-{
- "transactionExternalId": "Guid",
- "transactionType": {
- "name": ""
- },
- "transactionStatus": {
- "name": ""
- },
- "value": 120,
- "createdAt": "Date"
-}
+---
+
+## Tests
+
+```bash
+cd transaction-service && ./mvnw test # 16 tests
+cd anti-fraud-service && ./mvnw test # 15 tests
```
-## Optional
+| Tipo | Qué valida | Necesita infraestructura |
+|------|------------|--------------------------|
+| Unitario (dominio) | Reglas de negocio, invariantes del agregado | No |
+| Unitario (aplicación) | Casos de uso con repositorios en memoria | No |
+| Unitario (consumidores) | Consumidores Kafka con publicadores falsos | No |
+| Controladores | Endpoints, validación, manejo de errores | Solo Spring MockMvc |
+
+---
+
+## Observabilidad
+
+### Logs estructurados
+
+Todos los logs siguen un formato clave=valor consistente:
+
+```
+event=transaction.created, transactionId=uuid, value=500, status=PENDING, outcome=success
+```
+
+En desarrollo se usa texto plano legible. En Docker/producción se usa formato JSON para ingesta en herramientas de monitoreo.
+
+### Trazabilidad entre servicios
+
+Un `correlationId` viaja por todo el flujo: llega como header HTTP `X-Correlation-ID`, se propaga en los headers de Kafka, y aparece en todos los logs de ambos servicios. Si el cliente no lo envía, el sistema genera uno automáticamente.
+
+---
+
+## Seguridad
+
+- Headers de seguridad en todas las respuestas HTTP (Content-Type-Options, Frame-Options, CSP, Referrer-Policy)
+- Header del servidor oculto para no exponer tecnología
+- Contenedores ejecutan con usuario no-root
+- Dockerfiles multi-stage: JDK para compilar, solo JRE en la imagen final
+- Validación de entrada con Bean Validation (`@NotNull`, `@Positive`, `@DecimalMax`)
+- Errores genéricos al cliente, sin stack traces ni detalles internos
+- IDs con UUID para no exponer secuencia ni volumen
+- Paquetes confiables de Kafka restringidos (no se usa wildcard `*`)
+
+---
+
+## Estrategia de alto volumen
+
+> *¿Cómo manejar escenarios de alto volumen con muchas escrituras y lecturas simultáneas sobre los mismos datos?*
+
+### Lo que ya está implementado
+
+| Estrategia | Cómo | Beneficio |
+|------------|------|-----------|
+| Virtual Threads | `spring.threads.virtual.enabled: true` | Millones de hilos concurrentes para operaciones de I/O |
+| Particionamiento Kafka | TransactionId como clave, 3 particiones | Paralelismo entre particiones, orden por transacción |
+| Productor Kafka idempotente | Ver sección Kafka | Sin duplicados, entrega garantizada |
+| Pool de conexiones | HikariCP con tamaño configurable | Uso eficiente de conexiones a la base de datos |
+| Lecturas optimizadas | `@Transactional(readOnly=true)` en GET | Evita flush innecesario de Hibernate |
+| Inserciones en lote | Hibernate `batch_size=20` + `order_inserts` | Menos ida y vuelta a la base de datos |
+| Recolector de basura ZGC | `-XX:+UseZGC` en Docker | Pausas de GC menores a 1ms |
+| Anti-fraude sin estado | Sin base de datos, escala con consumer groups | Escala horizontal sin coordinación |
+| Actualización idempotente | Ignora si la transacción ya tiene estado final | Tolerante a mensajes duplicados |
+| Índices en base de datos | Sobre `status`, `created_at`, `account_debit` | Consultas rápidas sin recorrer toda la tabla |
+
+### Por qué no se usa Redis
-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?
+PostgreSQL resuelve las consultas por UUID en pocos milisegundos. Redis lo haría más rápido, pero el riesgo supera la ganancia: las transacciones cambian de estado de forma asíncrona vía Kafka, y un cache mal invalidado mostraría estados incorrectos al usuario. En un sistema financiero eso no es un bug menor, es una pérdida de confianza. Además las transacciones se crean una vez y se consultan pocas veces, que no es el tipo de carga donde cache aporta valor.
-You can use Graphql;
+### Por qué PgBouncer y no más conexiones directas
-# Send us your challenge
+A medida que se levantan más instancias del servicio, la cantidad de conexiones a la base de datos crece rápido y PostgreSQL empieza a sufrir. PgBouncer actúa como intermediario: recibe todas las conexiones de las aplicaciones y las comparte con un número mucho menor de conexiones reales a la base. El resultado es que se pueden escalar los servicios sin saturar PostgreSQL. Es el mismo enfoque que usa OpenAI para manejar cientos de millones de usuarios.
-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.
+### Escalamiento progresivo
-If you have any questions, please let us know.
+E sistema escala con virtual threads, Kafka, pool de conexiones e índices. Si el volumen crece, el camino es: primero PgBouncer para multiplexar conexiones y réplicas de lectura en PostgreSQL, después evaluar Redis con una estrategia de invalidación bien diseñada (un Redis puesto a medias hace más daño que no tener cache), y para volúmenes mucho mayores patrones como Transactional Outbox y CQRS. Cada paso se da cuando las métricas lo justifiquen.
diff --git a/anti-fraud-service/.dockerignore b/anti-fraud-service/.dockerignore
new file mode 100644
index 0000000000..b477b995fb
--- /dev/null
+++ b/anti-fraud-service/.dockerignore
@@ -0,0 +1,6 @@
+target/
+.idea/
+*.iml
+.git
+.DS_Store
+*.md
diff --git a/anti-fraud-service/.mvn/wrapper/maven-wrapper.properties b/anti-fraud-service/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000..ffcab66aa2
--- /dev/null
+++ b/anti-fraud-service/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/anti-fraud-service/Dockerfile b/anti-fraud-service/Dockerfile
new file mode 100644
index 0000000000..7552a7d555
--- /dev/null
+++ b/anti-fraud-service/Dockerfile
@@ -0,0 +1,34 @@
+FROM eclipse-temurin:21-jdk-alpine AS builder
+
+WORKDIR /app
+
+COPY pom.xml ./
+COPY .mvn .mvn
+COPY mvnw ./
+RUN chmod +x mvnw
+
+RUN ./mvnw dependency:go-offline -B
+
+COPY src ./src
+RUN ./mvnw clean package -DskipTests -B
+
+RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted
+
+FROM eclipse-temurin:21-jre-alpine
+
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
+WORKDIR /app
+
+COPY --from=builder /app/extracted/dependencies/ ./
+COPY --from=builder /app/extracted/spring-boot-loader/ ./
+COPY --from=builder /app/extracted/snapshot-dependencies/ ./
+COPY --from=builder /app/extracted/application/ ./
+
+USER appuser
+
+EXPOSE 8081
+
+ENV JAVA_OPTS=""
+
+ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher"]
diff --git a/anti-fraud-service/mvnw b/anti-fraud-service/mvnw
new file mode 100755
index 0000000000..bd8896bf22
--- /dev/null
+++ b/anti-fraud-service/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/anti-fraud-service/mvnw.cmd b/anti-fraud-service/mvnw.cmd
new file mode 100644
index 0000000000..5761d94892
--- /dev/null
+++ b/anti-fraud-service/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/anti-fraud-service/pom.xml b/anti-fraud-service/pom.xml
new file mode 100644
index 0000000000..09d91e9e2c
--- /dev/null
+++ b/anti-fraud-service/pom.xml
@@ -0,0 +1,65 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.4.3
+
+
+
+ com.yape
+ anti-fraud-service
+ 1.0.0
+ anti-fraud-service
+ Yape Anti-Fraud Service
+
+
+ 21
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ true
+
+
+
+
+
+
\ No newline at end of file
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java b/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java
new file mode 100644
index 0000000000..5874ee363c
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java
@@ -0,0 +1,12 @@
+package com.yape.antifraud;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class AntiFraudServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AntiFraudServiceApplication.class, args);
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/application/service/EvaluateFraudApplicationService.java b/anti-fraud-service/src/main/java/com/yape/antifraud/application/service/EvaluateFraudApplicationService.java
new file mode 100644
index 0000000000..41083c42c6
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/application/service/EvaluateFraudApplicationService.java
@@ -0,0 +1,38 @@
+package com.yape.antifraud.application.service;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+import com.yape.antifraud.domain.port.in.EvaluateFraudCommand;
+import com.yape.antifraud.domain.port.in.EvaluateFraudUseCase;
+import com.yape.antifraud.domain.port.out.FraudResultPublisher;
+import com.yape.antifraud.domain.service.FraudEvaluationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+@Service
+public class EvaluateFraudApplicationService implements EvaluateFraudUseCase {
+
+ private static final Logger log = LoggerFactory.getLogger(EvaluateFraudApplicationService.class);
+
+ private final FraudEvaluationService fraudEvaluationService;
+ private final FraudResultPublisher resultPublisher;
+
+ public EvaluateFraudApplicationService(FraudEvaluationService fraudEvaluationService,
+ FraudResultPublisher resultPublisher) {
+ this.fraudEvaluationService = fraudEvaluationService;
+ this.resultPublisher = resultPublisher;
+ }
+
+ @Override
+ public void execute(EvaluateFraudCommand command) {
+ FraudEvaluation evaluation = fraudEvaluationService.evaluate(
+ command.transactionId(),
+ command.value()
+ );
+
+ log.info("event=fraud.evaluated, transactionId={}, value={}, result={}, outcome=success",
+ command.transactionId(), command.value(), evaluation.status());
+
+ resultPublisher.publishResult(evaluation);
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudEvaluation.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudEvaluation.java
new file mode 100644
index 0000000000..2c25249ddc
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudEvaluation.java
@@ -0,0 +1,17 @@
+package com.yape.antifraud.domain.model;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public record FraudEvaluation(
+ UUID transactionId,
+ BigDecimal value,
+ String status
+) {
+ public static final String APPROVED = "approved";
+ public static final String REJECTED = "rejected";
+
+ public boolean isApproved() {
+ return APPROVED.equals(status);
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateFraudCommand.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateFraudCommand.java
new file mode 100644
index 0000000000..6a2e7e4a54
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateFraudCommand.java
@@ -0,0 +1,9 @@
+package com.yape.antifraud.domain.port.in;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public record EvaluateFraudCommand(
+ UUID transactionId,
+ BigDecimal value
+) {}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateFraudUseCase.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateFraudUseCase.java
new file mode 100644
index 0000000000..c39d8a85da
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateFraudUseCase.java
@@ -0,0 +1,6 @@
+package com.yape.antifraud.domain.port.in;
+
+public interface EvaluateFraudUseCase {
+
+ void execute(EvaluateFraudCommand command);
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/out/FraudResultPublisher.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/out/FraudResultPublisher.java
new file mode 100644
index 0000000000..019a2bded1
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/out/FraudResultPublisher.java
@@ -0,0 +1,8 @@
+package com.yape.antifraud.domain.port.out;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+
+public interface FraudResultPublisher {
+
+ void publishResult(FraudEvaluation evaluation);
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/service/FraudEvaluationService.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/service/FraudEvaluationService.java
new file mode 100644
index 0000000000..4bb73d51e9
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/service/FraudEvaluationService.java
@@ -0,0 +1,17 @@
+package com.yape.antifraud.domain.service;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public class FraudEvaluationService {
+
+ private static final BigDecimal MAX_ALLOWED_VALUE = new BigDecimal("1000");
+
+ public FraudEvaluation evaluate(UUID transactionId, BigDecimal value) {
+ boolean isFraudulent = value.compareTo(MAX_ALLOWED_VALUE) > 0;
+ String status = isFraudulent ? FraudEvaluation.REJECTED : FraudEvaluation.APPROVED;
+ return new FraudEvaluation(transactionId, value, status);
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/kafka/FraudKafkaConsumer.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/kafka/FraudKafkaConsumer.java
new file mode 100644
index 0000000000..48e32b29dd
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/kafka/FraudKafkaConsumer.java
@@ -0,0 +1,66 @@
+package com.yape.antifraud.infrastructure.adapter.in.kafka;
+
+import com.yape.antifraud.domain.port.in.EvaluateFraudCommand;
+import com.yape.antifraud.domain.port.in.EvaluateFraudUseCase;
+import com.yape.antifraud.infrastructure.config.KafkaGroups;
+import com.yape.antifraud.infrastructure.config.KafkaTopics;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.Header;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+@Component
+public class FraudKafkaConsumer {
+
+ private static final Logger log = LoggerFactory.getLogger(FraudKafkaConsumer.class);
+ private static final String CORRELATION_HEADER = "X-Correlation-ID";
+
+ private final EvaluateFraudUseCase evaluateFraudUseCase;
+
+ public FraudKafkaConsumer(EvaluateFraudUseCase evaluateFraudUseCase) {
+ this.evaluateFraudUseCase = evaluateFraudUseCase;
+ }
+
+ @KafkaListener(
+ topics = KafkaTopics.TRANSACTION_CREATED,
+ groupId = KafkaGroups.TRANSACTION_CREATED
+ )
+ public void consume(ConsumerRecord record) {
+ try {
+ String correlationId = extractCorrelationId(record);
+ MDC.put("correlationId", correlationId);
+ MDC.put("service", "anti-fraud-service");
+
+ TransactionCreatedMessage message = record.value();
+ log.info("event=kafka.message_received, topic={}, transactionId={}, value={}",
+ record.topic(), message.transactionId(), message.value());
+ try {
+ var command = new EvaluateFraudCommand(
+ message.transactionId(),
+ message.value()
+ );
+ evaluateFraudUseCase.execute(command);
+ } catch (Exception ex) {
+ log.error("event=kafka.message_processing_failed, topic={}, transactionId={}, outcome=failure, error={}",
+ record.topic(), message.transactionId(), ex.getMessage(), ex);
+ throw ex;
+ }
+ } finally {
+ MDC.clear();
+ }
+ }
+
+ private String extractCorrelationId(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(CORRELATION_HEADER);
+ if (header != null && header.value() != null) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/kafka/TransactionCreatedMessage.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/kafka/TransactionCreatedMessage.java
new file mode 100644
index 0000000000..385b93ab09
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/kafka/TransactionCreatedMessage.java
@@ -0,0 +1,6 @@
+package com.yape.antifraud.infrastructure.adapter.in.kafka;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public record TransactionCreatedMessage(UUID transactionId, BigDecimal value) {}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/kafka/KafkaFraudResultPublisher.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/kafka/KafkaFraudResultPublisher.java
new file mode 100644
index 0000000000..8657ffa5b8
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/kafka/KafkaFraudResultPublisher.java
@@ -0,0 +1,60 @@
+package com.yape.antifraud.infrastructure.adapter.out.kafka;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+import com.yape.antifraud.domain.port.out.FraudResultPublisher;
+import com.yape.antifraud.infrastructure.config.KafkaTopics;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+@Component
+public class KafkaFraudResultPublisher implements FraudResultPublisher {
+
+ private static final Logger log = LoggerFactory.getLogger(KafkaFraudResultPublisher.class);
+ private static final String TOPIC = KafkaTopics.TRANSACTION_STATUS_UPDATED;
+ private static final String CORRELATION_HEADER = "X-Correlation-ID";
+ private static final int SEND_TIMEOUT_SECONDS = 10;
+
+ private final KafkaTemplate kafkaTemplate;
+
+ public KafkaFraudResultPublisher(KafkaTemplate kafkaTemplate) {
+ this.kafkaTemplate = kafkaTemplate;
+ }
+
+ @Override
+ public void publishResult(FraudEvaluation evaluation) {
+ var message = new TransactionStatusMessage(
+ evaluation.transactionId(),
+ evaluation.status()
+ );
+ var record = new ProducerRecord<>(TOPIC, null, evaluation.transactionId().toString(), message);
+
+ String correlationId = MDC.get("correlationId");
+ if (correlationId != null) {
+ record.headers().add(CORRELATION_HEADER, correlationId.getBytes(StandardCharsets.UTF_8));
+ }
+
+ try {
+ var result = kafkaTemplate.send(record).get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ log.info("event=kafka.message_sent, topic={}, transactionId={}, status={}, partition={}, outcome=success",
+ TOPIC, evaluation.transactionId(),
+ evaluation.status(),
+ result.getRecordMetadata().partition());
+ } catch (ExecutionException | TimeoutException | InterruptedException ex) {
+ log.error("event=kafka.send_failed, topic={}, transactionId={}, outcome=failure, error={}",
+ TOPIC, evaluation.transactionId(), ex.getMessage(), ex);
+ if (ex instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ throw new RuntimeException("Failed to publish fraud result to Kafka", ex);
+ }
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/kafka/TransactionStatusMessage.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/kafka/TransactionStatusMessage.java
new file mode 100644
index 0000000000..5b7b961419
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/kafka/TransactionStatusMessage.java
@@ -0,0 +1,8 @@
+package com.yape.antifraud.infrastructure.adapter.out.kafka;
+
+import java.util.UUID;
+
+public record TransactionStatusMessage(
+ UUID transactionId,
+ String status
+) {}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/DomainConfig.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/DomainConfig.java
new file mode 100644
index 0000000000..095495c373
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/DomainConfig.java
@@ -0,0 +1,14 @@
+package com.yape.antifraud.infrastructure.config;
+
+import com.yape.antifraud.domain.service.FraudEvaluationService;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class DomainConfig {
+
+ @Bean
+ public FraudEvaluationService fraudEvaluationService() {
+ return new FraudEvaluationService();
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaConfig.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaConfig.java
new file mode 100644
index 0000000000..bc424edb84
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaConfig.java
@@ -0,0 +1,36 @@
+package com.yape.antifraud.infrastructure.config;
+
+import org.apache.kafka.clients.admin.NewTopic;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.config.TopicBuilder;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.listener.DefaultErrorHandler;
+import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
+import org.springframework.util.backoff.FixedBackOff;
+
+@Configuration
+public class KafkaConfig {
+
+ @Bean
+ public NewTopic transactionCreatedTopic() {
+ return TopicBuilder.name(KafkaTopics.TRANSACTION_CREATED)
+ .partitions(3)
+ .replicas(1)
+ .build();
+ }
+
+ @Bean
+ public NewTopic transactionStatusTopic() {
+ return TopicBuilder.name(KafkaTopics.TRANSACTION_STATUS_UPDATED)
+ .partitions(3)
+ .replicas(1)
+ .build();
+ }
+
+ @Bean
+ public DefaultErrorHandler errorHandler(KafkaTemplate kafkaTemplate) {
+ var recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate);
+ return new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3));
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaGroups.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaGroups.java
new file mode 100644
index 0000000000..d40ce92f6f
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaGroups.java
@@ -0,0 +1,9 @@
+package com.yape.antifraud.infrastructure.config;
+
+public final class KafkaGroups {
+
+ public static final String TRANSACTION_CREATED = "yape.anti-fraud-service.transaction-created.v1";
+
+ private KafkaGroups() {
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaTopics.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaTopics.java
new file mode 100644
index 0000000000..bca35c26d9
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaTopics.java
@@ -0,0 +1,10 @@
+package com.yape.antifraud.infrastructure.config;
+
+public final class KafkaTopics {
+
+ public static final String TRANSACTION_CREATED = "yape.transaction.event.created.v1";
+ public static final String TRANSACTION_STATUS_UPDATED = "yape.transaction.event.status-updated.v1";
+
+ private KafkaTopics() {
+ }
+}
diff --git a/anti-fraud-service/src/main/resources/application-docker.yml b/anti-fraud-service/src/main/resources/application-docker.yml
new file mode 100644
index 0000000000..20b137573e
--- /dev/null
+++ b/anti-fraud-service/src/main/resources/application-docker.yml
@@ -0,0 +1,3 @@
+spring:
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:kafka:9092}
diff --git a/anti-fraud-service/src/main/resources/application.yml b/anti-fraud-service/src/main/resources/application.yml
new file mode 100644
index 0000000000..be4cfd62a9
--- /dev/null
+++ b/anti-fraud-service/src/main/resources/application.yml
@@ -0,0 +1,50 @@
+server:
+ port: 8081
+ server-header: ""
+ shutdown: graceful
+ error:
+ whitelabel:
+ enabled: false
+
+spring:
+ lifecycle:
+ timeout-per-shutdown-phase: 30s
+ threads:
+ virtual:
+ enabled: true
+
+ application:
+ name: anti-fraud-service
+
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9094}
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+ acks: all
+ properties:
+ enable.idempotence: true
+ spring.json.type.mapping: >
+ transaction-status-updated:com.yape.antifraud.infrastructure.adapter.out.kafka.TransactionStatusMessage
+ consumer:
+ group-id: yape.anti-fraud-service.transaction-created.v1
+ key-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
+ value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
+ auto-offset-reset: earliest
+ properties:
+ spring.deserializer.key.delegate.class: org.apache.kafka.common.serialization.StringDeserializer
+ spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
+ spring.json.trusted.packages: "com.yape.antifraud.infrastructure.adapter.in.kafka"
+ spring.json.type.mapping: >
+ transaction-created:com.yape.antifraud.infrastructure.adapter.in.kafka.TransactionCreatedMessage
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info
+ endpoint:
+ health:
+ show-details: never
+ probes:
+ enabled: true
diff --git a/anti-fraud-service/src/main/resources/logback-spring.xml b/anti-fraud-service/src/main/resources/logback-spring.xml
new file mode 100644
index 0000000000..cd0b61292b
--- /dev/null
+++ b/anti-fraud-service/src/main/resources/logback-spring.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ %d{ISO8601} [%thread] %-5level %logger{36} [cid=%X{correlationId:-}] - %msg%n
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/anti-fraud-service/src/test/java/com/yape/antifraud/domain/service/FraudEvaluationServiceTest.java b/anti-fraud-service/src/test/java/com/yape/antifraud/domain/service/FraudEvaluationServiceTest.java
new file mode 100644
index 0000000000..031e51f6e6
--- /dev/null
+++ b/anti-fraud-service/src/test/java/com/yape/antifraud/domain/service/FraudEvaluationServiceTest.java
@@ -0,0 +1,55 @@
+package com.yape.antifraud.domain.service;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class FraudEvaluationServiceTest {
+
+ private final FraudEvaluationService service = new FraudEvaluationService();
+
+ @Test
+ void shouldApproveTransactionWithValueLessThan1000() {
+ FraudEvaluation result = service.evaluate(UUID.randomUUID(), new BigDecimal("500.00"));
+ assertThat(result.status()).isEqualTo("approved");
+ assertThat(result.isApproved()).isTrue();
+ }
+
+ @Test
+ void shouldApproveTransactionWithValueExactly1000() {
+ FraudEvaluation result = service.evaluate(UUID.randomUUID(), new BigDecimal("1000.00"));
+ assertThat(result.status()).isEqualTo("approved");
+ }
+
+ @Test
+ void shouldRejectTransactionWithValueGreaterThan1000() {
+ FraudEvaluation result = service.evaluate(UUID.randomUUID(), new BigDecimal("1000.01"));
+ assertThat(result.status()).isEqualTo("rejected");
+ assertThat(result.isApproved()).isFalse();
+ }
+
+ @Test
+ void shouldRejectTransactionWithLargeValue() {
+ FraudEvaluation result = service.evaluate(UUID.randomUUID(), new BigDecimal("999999.99"));
+ assertThat(result.status()).isEqualTo("rejected");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"0.01", "1.00", "100.00", "999.99", "1000.00"})
+ void shouldApproveTransactionsUpTo1000(String value) {
+ FraudEvaluation result = service.evaluate(UUID.randomUUID(), new BigDecimal(value));
+ assertThat(result.status()).isEqualTo("approved");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"1000.01", "1001.00", "5000.00", "10000.00"})
+ void shouldRejectTransactionsOver1000(String value) {
+ FraudEvaluation result = service.evaluate(UUID.randomUUID(), new BigDecimal(value));
+ assertThat(result.status()).isEqualTo("rejected");
+ }
+}
diff --git a/anti-fraud-service/src/test/java/com/yape/antifraud/infrastructure/adapter/in/kafka/FraudConsumerTest.java b/anti-fraud-service/src/test/java/com/yape/antifraud/infrastructure/adapter/in/kafka/FraudConsumerTest.java
new file mode 100644
index 0000000000..348b59d3f2
--- /dev/null
+++ b/anti-fraud-service/src/test/java/com/yape/antifraud/infrastructure/adapter/in/kafka/FraudConsumerTest.java
@@ -0,0 +1,65 @@
+package com.yape.antifraud.infrastructure.adapter.in.kafka;
+
+import com.yape.antifraud.application.service.EvaluateFraudApplicationService;
+import com.yape.antifraud.domain.model.FraudEvaluation;
+import com.yape.antifraud.domain.port.out.FraudResultPublisher;
+import com.yape.antifraud.domain.service.FraudEvaluationService;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class FraudConsumerTest {
+
+ @Test
+ void shouldEvaluateAndPublishApprovedResult() {
+ CapturingFraudResultPublisher publisher = new CapturingFraudResultPublisher();
+ FraudEvaluationService domainService = new FraudEvaluationService();
+ EvaluateFraudApplicationService useCase = new EvaluateFraudApplicationService(domainService, publisher);
+ FraudKafkaConsumer consumer = new FraudKafkaConsumer(useCase);
+
+ UUID transactionId = UUID.randomUUID();
+ TransactionCreatedMessage message = new TransactionCreatedMessage(transactionId, new BigDecimal("500"));
+ ConsumerRecord record =
+ new ConsumerRecord<>("yape.transaction.event.created.v1", 0, 0L, transactionId.toString(), message);
+
+ consumer.consume(record);
+
+ assertThat(publisher.publishedResults).hasSize(1);
+ assertThat(publisher.publishedResults.getFirst().transactionId()).isEqualTo(transactionId);
+ assertThat(publisher.publishedResults.getFirst().status()).isEqualTo("approved");
+ }
+
+ @Test
+ void shouldEvaluateAndPublishRejectedResult() {
+ CapturingFraudResultPublisher publisher = new CapturingFraudResultPublisher();
+ FraudEvaluationService domainService = new FraudEvaluationService();
+ EvaluateFraudApplicationService useCase = new EvaluateFraudApplicationService(domainService, publisher);
+ FraudKafkaConsumer consumer = new FraudKafkaConsumer(useCase);
+
+ UUID transactionId = UUID.randomUUID();
+ TransactionCreatedMessage message = new TransactionCreatedMessage(transactionId, new BigDecimal("5000"));
+ ConsumerRecord record =
+ new ConsumerRecord<>("yape.transaction.event.created.v1", 0, 0L, transactionId.toString(), message);
+
+ consumer.consume(record);
+
+ assertThat(publisher.publishedResults).hasSize(1);
+ assertThat(publisher.publishedResults.getFirst().transactionId()).isEqualTo(transactionId);
+ assertThat(publisher.publishedResults.getFirst().status()).isEqualTo("rejected");
+ }
+
+ static class CapturingFraudResultPublisher implements FraudResultPublisher {
+ final List publishedResults = new ArrayList<>();
+
+ @Override
+ public void publishResult(FraudEvaluation evaluation) {
+ publishedResults.add(evaluation);
+ }
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..9586e39963 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,25 +1,101 @@
-version: "3.7"
services:
postgres:
- image: postgres:14
+ image: postgres:16-alpine
+ container_name: yape-postgres
ports:
- "5432:5432"
environment:
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=postgres
- zookeeper:
- image: confluentinc/cp-zookeeper:5.5.3
- environment:
- ZOOKEEPER_CLIENT_PORT: 2181
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: transaction_db
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - yape-network
+
kafka:
- image: confluentinc/cp-enterprise-kafka:5.5.3
- depends_on: [zookeeper]
+ image: apache/kafka:3.8.1
+ container_name: yape-kafka
+ ports:
+ - "9094:9094"
environment:
- KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
- KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
- KAFKA_BROKER_ID: 1
+ KAFKA_NODE_ID: 1
+ KAFKA_PROCESS_ROLES: broker,controller
+ KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
- KAFKA_JMX_PORT: 9991
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
+ KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
+ healthcheck:
+ test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1"]
+ interval: 10s
+ timeout: 10s
+ retries: 10
+ start_period: 30s
+ networks:
+ - yape-network
+
+ transaction-service:
+ build: ./transaction-service
+ container_name: yape-transaction-service
ports:
- - 9092:9092
+ - "8080:8080"
+ environment:
+ SPRING_PROFILES_ACTIVE: docker
+ DB_HOST: postgres
+ DB_PORT: 5432
+ DB_NAME: transaction_db
+ DB_USER: postgres
+ DB_PASSWORD: postgres
+ KAFKA_BOOTSTRAP_SERVERS: kafka:9092
+ JAVA_OPTS: "-XX:+UseZGC -XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/actuator/health || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - yape-network
+
+ anti-fraud-service:
+ build: ./anti-fraud-service
+ container_name: yape-anti-fraud-service
+ ports:
+ - "8081:8081"
+ environment:
+ SPRING_PROFILES_ACTIVE: docker
+ KAFKA_BOOTSTRAP_SERVERS: kafka:9092
+ JAVA_OPTS: "-XX:+UseZGC -XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError"
+ depends_on:
+ kafka:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - yape-network
+
+volumes:
+ postgres-data:
+
+networks:
+ yape-network:
+ driver: bridge
diff --git a/transaction-service/.dockerignore b/transaction-service/.dockerignore
new file mode 100644
index 0000000000..b477b995fb
--- /dev/null
+++ b/transaction-service/.dockerignore
@@ -0,0 +1,6 @@
+target/
+.idea/
+*.iml
+.git
+.DS_Store
+*.md
diff --git a/transaction-service/.mvn/wrapper/maven-wrapper.properties b/transaction-service/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000..ffcab66aa2
--- /dev/null
+++ b/transaction-service/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/transaction-service/Dockerfile b/transaction-service/Dockerfile
new file mode 100644
index 0000000000..7da89000d6
--- /dev/null
+++ b/transaction-service/Dockerfile
@@ -0,0 +1,34 @@
+FROM eclipse-temurin:21-jdk-alpine AS builder
+
+WORKDIR /app
+
+COPY pom.xml ./
+COPY .mvn .mvn
+COPY mvnw ./
+RUN chmod +x mvnw
+
+RUN ./mvnw dependency:go-offline -B
+
+COPY src ./src
+RUN ./mvnw clean package -DskipTests -B
+
+RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted
+
+FROM eclipse-temurin:21-jre-alpine
+
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
+WORKDIR /app
+
+COPY --from=builder /app/extracted/dependencies/ ./
+COPY --from=builder /app/extracted/spring-boot-loader/ ./
+COPY --from=builder /app/extracted/snapshot-dependencies/ ./
+COPY --from=builder /app/extracted/application/ ./
+
+USER appuser
+
+EXPOSE 8080
+
+ENV JAVA_OPTS=""
+
+ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher"]
diff --git a/transaction-service/mvnw b/transaction-service/mvnw
new file mode 100755
index 0000000000..bd8896bf22
--- /dev/null
+++ b/transaction-service/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/transaction-service/mvnw.cmd b/transaction-service/mvnw.cmd
new file mode 100644
index 0000000000..5761d94892
--- /dev/null
+++ b/transaction-service/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/transaction-service/pom.xml b/transaction-service/pom.xml
new file mode 100644
index 0000000000..4b7d23cad0
--- /dev/null
+++ b/transaction-service/pom.xml
@@ -0,0 +1,121 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.4.3
+
+
+
+ com.yape
+ transaction-service
+ 1.0.0
+ transaction-service
+ Yape Transaction Service
+
+
+ 21
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+ org.testcontainers
+ postgresql
+ test
+
+
+ org.testcontainers
+ kafka
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+
+
+
+
+ org.testcontainers
+ testcontainers-bom
+ 1.20.4
+ pom
+ import
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ true
+
+
+
+
+
+
\ No newline at end of file
diff --git a/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java b/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java
new file mode 100644
index 0000000000..ae998b71b2
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java
@@ -0,0 +1,12 @@
+package com.yape.transaction;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class TransactionServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(TransactionServiceApplication.class, args);
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java b/transaction-service/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java
new file mode 100644
index 0000000000..c16bb03a18
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java
@@ -0,0 +1,49 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.domain.event.TransactionCreatedEvent;
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.in.CreateTransactionCommand;
+import com.yape.transaction.domain.port.in.CreateTransactionUseCase;
+import com.yape.transaction.domain.port.out.TransactionEventPublisher;
+import com.yape.transaction.domain.port.out.TransactionRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class CreateTransactionService implements CreateTransactionUseCase {
+
+ private static final Logger log = LoggerFactory.getLogger(CreateTransactionService.class);
+
+ private final TransactionRepository repository;
+ private final TransactionEventPublisher eventPublisher;
+
+ public CreateTransactionService(TransactionRepository repository,
+ TransactionEventPublisher eventPublisher) {
+ this.repository = repository;
+ this.eventPublisher = eventPublisher;
+ }
+
+ @Override
+ @Transactional
+ public Transaction execute(CreateTransactionCommand command) {
+ Transaction transaction = Transaction.create(
+ command.accountExternalIdDebit(),
+ command.accountExternalIdCredit(),
+ command.transferTypeId(),
+ command.value()
+ );
+
+ transaction = repository.save(transaction);
+ log.info("event=transaction.created, transactionId={}, value={}, status={}, outcome=success",
+ transaction.getId(), transaction.getValue(), transaction.getStatus());
+
+ eventPublisher.publishTransactionCreated(new TransactionCreatedEvent(
+ transaction.getId(),
+ transaction.getValue()
+ ));
+
+ return transaction;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/application/service/GetTransactionService.java b/transaction-service/src/main/java/com/yape/transaction/application/service/GetTransactionService.java
new file mode 100644
index 0000000000..ada81f84fd
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/application/service/GetTransactionService.java
@@ -0,0 +1,27 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.domain.exception.TransactionNotFoundException;
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.in.GetTransactionUseCase;
+import com.yape.transaction.domain.port.out.TransactionRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+@Service
+public class GetTransactionService implements GetTransactionUseCase {
+
+ private final TransactionRepository repository;
+
+ public GetTransactionService(TransactionRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Transaction execute(UUID transactionId) {
+ return repository.findById(transactionId)
+ .orElseThrow(() -> new TransactionNotFoundException(transactionId));
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/application/service/UpdateTransactionStatusService.java b/transaction-service/src/main/java/com/yape/transaction/application/service/UpdateTransactionStatusService.java
new file mode 100644
index 0000000000..d96c3a2759
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/application/service/UpdateTransactionStatusService.java
@@ -0,0 +1,47 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.domain.exception.TransactionNotFoundException;
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.model.TransactionStatus;
+import com.yape.transaction.domain.port.in.UpdateTransactionStatusCommand;
+import com.yape.transaction.domain.port.in.UpdateTransactionStatusUseCase;
+import com.yape.transaction.domain.port.out.TransactionRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class UpdateTransactionStatusService implements UpdateTransactionStatusUseCase {
+
+ private static final Logger log = LoggerFactory.getLogger(UpdateTransactionStatusService.class);
+
+ private final TransactionRepository repository;
+
+ public UpdateTransactionStatusService(TransactionRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ @Transactional
+ public void execute(UpdateTransactionStatusCommand command) {
+ Transaction transaction = repository.findById(command.transactionId())
+ .orElseThrow(() -> new TransactionNotFoundException(command.transactionId()));
+
+ if (!transaction.isPending()) {
+ log.warn("event=transaction.status_update_skipped, transactionId={}, currentStatus={}, requestedStatus={}, outcome=skipped",
+ command.transactionId(), transaction.getStatus(), command.newStatus());
+ return;
+ }
+
+ switch (command.newStatus()) {
+ case APPROVED -> transaction.approve();
+ case REJECTED -> transaction.reject();
+ default -> throw new IllegalArgumentException("Invalid status update: " + command.newStatus());
+ }
+
+ repository.save(transaction);
+ log.info("event=transaction.status_updated, transactionId={}, status={}, outcome=success",
+ command.transactionId(), transaction.getStatus());
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/event/TransactionCreatedEvent.java b/transaction-service/src/main/java/com/yape/transaction/domain/event/TransactionCreatedEvent.java
new file mode 100644
index 0000000000..c0249ced4b
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/event/TransactionCreatedEvent.java
@@ -0,0 +1,9 @@
+package com.yape.transaction.domain.event;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public record TransactionCreatedEvent(
+ UUID transactionId,
+ BigDecimal value
+) {}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/exception/TransactionNotFoundException.java b/transaction-service/src/main/java/com/yape/transaction/domain/exception/TransactionNotFoundException.java
new file mode 100644
index 0000000000..3248943a20
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/exception/TransactionNotFoundException.java
@@ -0,0 +1,17 @@
+package com.yape.transaction.domain.exception;
+
+import java.util.UUID;
+
+public class TransactionNotFoundException extends RuntimeException {
+
+ private final UUID transactionId;
+
+ public TransactionNotFoundException(UUID transactionId) {
+ super("Transaction not found: " + transactionId);
+ this.transactionId = transactionId;
+ }
+
+ public UUID getTransactionId() {
+ return transactionId;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java
new file mode 100644
index 0000000000..8815a184a4
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java
@@ -0,0 +1,104 @@
+package com.yape.transaction.domain.model;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public class Transaction {
+
+ private UUID id;
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private Integer transferTypeId;
+ private BigDecimal value;
+ private TransactionStatus status;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ private Transaction() {}
+
+ public static Transaction create(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ Integer transferTypeId,
+ BigDecimal value
+ ) {
+ if (accountExternalIdDebit == null || accountExternalIdCredit == null) {
+ throw new IllegalArgumentException("Debit and credit accounts must not be null");
+ }
+ if (transferTypeId == null || value == null) {
+ throw new IllegalArgumentException("Transfer type and value must not be null");
+ }
+ if (accountExternalIdDebit.equals(accountExternalIdCredit)) {
+ throw new IllegalArgumentException("Debit and credit accounts must be different");
+ }
+ var transaction = new Transaction();
+ transaction.id = UUID.randomUUID();
+ transaction.accountExternalIdDebit = accountExternalIdDebit;
+ transaction.accountExternalIdCredit = accountExternalIdCredit;
+ transaction.transferTypeId = transferTypeId;
+ transaction.value = value;
+ transaction.status = TransactionStatus.PENDING;
+ transaction.createdAt = LocalDateTime.now();
+ return transaction;
+ }
+
+ public static Transaction reconstitute(
+ UUID id,
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ Integer transferTypeId,
+ BigDecimal value,
+ TransactionStatus status,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+ ) {
+ var transaction = new Transaction();
+ transaction.id = id;
+ transaction.accountExternalIdDebit = accountExternalIdDebit;
+ transaction.accountExternalIdCredit = accountExternalIdCredit;
+ transaction.transferTypeId = transferTypeId;
+ transaction.value = value;
+ transaction.status = status;
+ transaction.createdAt = createdAt;
+ transaction.updatedAt = updatedAt;
+ return transaction;
+ }
+
+ public void approve() {
+ if (this.status != TransactionStatus.PENDING) {
+ throw new IllegalStateException(
+ "Cannot approve transaction with status: " + this.status
+ );
+ }
+ this.status = TransactionStatus.APPROVED;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public void reject() {
+ if (this.status != TransactionStatus.PENDING) {
+ throw new IllegalStateException(
+ "Cannot reject transaction with status: " + this.status
+ );
+ }
+ this.status = TransactionStatus.REJECTED;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public boolean isPending() {
+ return this.status == TransactionStatus.PENDING;
+ }
+
+ public UUID getId() { return id; }
+ public UUID getAccountExternalIdDebit() { return accountExternalIdDebit; }
+ public UUID getAccountExternalIdCredit() { return accountExternalIdCredit; }
+ public Integer getTransferTypeId() { return transferTypeId; }
+ public BigDecimal getValue() { return value; }
+ public TransactionStatus getStatus() { return status; }
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public LocalDateTime getUpdatedAt() { return updatedAt; }
+
+ public String getTransferTypeName() {
+ return TransferType.fromId(this.transferTypeId).getName();
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionStatus.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionStatus.java
new file mode 100644
index 0000000000..7e82f83a1a
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionStatus.java
@@ -0,0 +1,33 @@
+package com.yape.transaction.domain.model;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public enum TransactionStatus {
+ PENDING("pending"),
+ APPROVED("approved"),
+ REJECTED("rejected");
+
+ private static final Map BY_VALUE = Arrays.stream(values())
+ .collect(Collectors.toMap(status -> status.value.toLowerCase(), Function.identity()));
+
+ private final String value;
+
+ TransactionStatus(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public static TransactionStatus fromString(String status) {
+ var result = BY_VALUE.get(status.toLowerCase());
+ if (result == null) {
+ throw new IllegalArgumentException("Unknown transaction status: " + status);
+ }
+ return result;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/model/TransferType.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransferType.java
new file mode 100644
index 0000000000..a5d0ebb8a7
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransferType.java
@@ -0,0 +1,31 @@
+package com.yape.transaction.domain.model;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public enum TransferType {
+ TRANSFER(1, "transfer");
+
+ private static final Map BY_ID = Arrays.stream(values())
+ .collect(Collectors.toMap(TransferType::getId, type -> type));
+
+ private final int id;
+ private final String name;
+
+ TransferType(int id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public int getId() { return id; }
+ public String getName() { return name; }
+
+ public static TransferType fromId(int id) {
+ TransferType type = BY_ID.get(id);
+ if (type == null) {
+ throw new IllegalArgumentException("Unknown transfer type ID: " + id);
+ }
+ return type;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionCommand.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionCommand.java
new file mode 100644
index 0000000000..ae9e6046c4
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionCommand.java
@@ -0,0 +1,11 @@
+package com.yape.transaction.domain.port.in;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public record CreateTransactionCommand(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ Integer transferTypeId,
+ BigDecimal value
+) {}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionUseCase.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionUseCase.java
new file mode 100644
index 0000000000..b7e52044b1
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionUseCase.java
@@ -0,0 +1,8 @@
+package com.yape.transaction.domain.port.in;
+
+import com.yape.transaction.domain.model.Transaction;
+
+public interface CreateTransactionUseCase {
+
+ Transaction execute(CreateTransactionCommand command);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/in/GetTransactionUseCase.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/GetTransactionUseCase.java
new file mode 100644
index 0000000000..8fcecf3e7b
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/GetTransactionUseCase.java
@@ -0,0 +1,10 @@
+package com.yape.transaction.domain.port.in;
+
+import com.yape.transaction.domain.model.Transaction;
+
+import java.util.UUID;
+
+public interface GetTransactionUseCase {
+
+ Transaction execute(UUID transactionId);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusCommand.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusCommand.java
new file mode 100644
index 0000000000..2f529cea2a
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusCommand.java
@@ -0,0 +1,10 @@
+package com.yape.transaction.domain.port.in;
+
+import com.yape.transaction.domain.model.TransactionStatus;
+
+import java.util.UUID;
+
+public record UpdateTransactionStatusCommand(
+ UUID transactionId,
+ TransactionStatus newStatus
+) {}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusUseCase.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusUseCase.java
new file mode 100644
index 0000000000..d85898acf4
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusUseCase.java
@@ -0,0 +1,6 @@
+package com.yape.transaction.domain.port.in;
+
+public interface UpdateTransactionStatusUseCase {
+
+ void execute(UpdateTransactionStatusCommand command);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionEventPublisher.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionEventPublisher.java
new file mode 100644
index 0000000000..b232cb8ed4
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionEventPublisher.java
@@ -0,0 +1,8 @@
+package com.yape.transaction.domain.port.out;
+
+import com.yape.transaction.domain.event.TransactionCreatedEvent;
+
+public interface TransactionEventPublisher {
+
+ void publishTransactionCreated(TransactionCreatedEvent event);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepository.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepository.java
new file mode 100644
index 0000000000..d32f5f8487
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepository.java
@@ -0,0 +1,13 @@
+package com.yape.transaction.domain.port.out;
+
+import com.yape.transaction.domain.model.Transaction;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface TransactionRepository {
+
+ Transaction save(Transaction transaction);
+
+ Optional findById(UUID transactionId);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/kafka/TransactionStatusKafkaConsumer.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/kafka/TransactionStatusKafkaConsumer.java
new file mode 100644
index 0000000000..0e2e44491c
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/kafka/TransactionStatusKafkaConsumer.java
@@ -0,0 +1,67 @@
+package com.yape.transaction.infrastructure.adapter.in.kafka;
+
+import com.yape.transaction.domain.model.TransactionStatus;
+import com.yape.transaction.domain.port.in.UpdateTransactionStatusCommand;
+import com.yape.transaction.domain.port.in.UpdateTransactionStatusUseCase;
+import com.yape.transaction.infrastructure.config.KafkaGroups;
+import com.yape.transaction.infrastructure.config.KafkaTopics;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.Header;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+@Component
+public class TransactionStatusKafkaConsumer {
+
+ private static final Logger log = LoggerFactory.getLogger(TransactionStatusKafkaConsumer.class);
+ private static final String CORRELATION_HEADER = "X-Correlation-ID";
+
+ private final UpdateTransactionStatusUseCase updateStatusUseCase;
+
+ public TransactionStatusKafkaConsumer(UpdateTransactionStatusUseCase updateStatusUseCase) {
+ this.updateStatusUseCase = updateStatusUseCase;
+ }
+
+ @KafkaListener(
+ topics = KafkaTopics.TRANSACTION_STATUS_UPDATED,
+ groupId = KafkaGroups.TRANSACTION_STATUS_UPDATED
+ )
+ public void consume(ConsumerRecord record) {
+ try {
+ String correlationId = extractCorrelationId(record);
+ MDC.put("correlationId", correlationId);
+ MDC.put("service", "transaction-service");
+
+ TransactionStatusMessage message = record.value();
+ log.info("event=kafka.message_received, topic={}, transactionId={}, status={}",
+ record.topic(), message.transactionId(), message.status());
+ try {
+ var command = new UpdateTransactionStatusCommand(
+ message.transactionId(),
+ TransactionStatus.fromString(message.status())
+ );
+ updateStatusUseCase.execute(command);
+ } catch (Exception ex) {
+ log.error("event=kafka.message_processing_failed, topic={}, transactionId={}, outcome=failure, error={}",
+ record.topic(), message.transactionId(), ex.getMessage(), ex);
+ throw ex;
+ }
+ } finally {
+ MDC.clear();
+ }
+ }
+
+ private String extractCorrelationId(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(CORRELATION_HEADER);
+ if (header != null && header.value() != null) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/kafka/TransactionStatusMessage.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/kafka/TransactionStatusMessage.java
new file mode 100644
index 0000000000..dc8a6e372a
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/kafka/TransactionStatusMessage.java
@@ -0,0 +1,5 @@
+package com.yape.transaction.infrastructure.adapter.in.kafka;
+
+import java.util.UUID;
+
+public record TransactionStatusMessage(UUID transactionId, String status) {}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/CreateTransactionRequest.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/CreateTransactionRequest.java
new file mode 100644
index 0000000000..aa52322c6e
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/CreateTransactionRequest.java
@@ -0,0 +1,25 @@
+package com.yape.transaction.infrastructure.adapter.in.rest;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public record CreateTransactionRequest(
+ @NotNull(message = "accountExternalIdDebit is required")
+ UUID accountExternalIdDebit,
+
+ @NotNull(message = "accountExternalIdCredit is required")
+ UUID accountExternalIdCredit,
+
+ @NotNull(message = "transferTypeId is required")
+ @JsonAlias("tranferTypeId")
+ Integer transferTypeId,
+
+ @NotNull(message = "value is required")
+ @Positive(message = "value must be positive")
+ @DecimalMax(value = "999999999.99", message = "value exceeds maximum allowed amount")
+ BigDecimal value
+) {}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/GlobalExceptionHandler.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/GlobalExceptionHandler.java
new file mode 100644
index 0000000000..6b3bdc8f58
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/GlobalExceptionHandler.java
@@ -0,0 +1,81 @@
+package com.yape.transaction.infrastructure.adapter.in.rest;
+
+import com.yape.transaction.domain.exception.TransactionNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.validation.FieldError;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ @ExceptionHandler(TransactionNotFoundException.class)
+ public ResponseEntity