diff --git a/.gitignore b/.gitignore
index 67045665db..f15d1da44b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -102,3 +102,5 @@ dist
# TernJS port file
.tern-port
+
+.idea
\ No newline at end of file
diff --git a/README.md b/README.md
index b067a71026..21bb2ec5b9 100644
--- a/README.md
+++ b/README.md
@@ -1,82 +1,198 @@
-# Yape Code Challenge :rocket:
+# Solución Técnica - Sistema de Transacciones con Anti-Fraude
-Our code challenge will let you marvel us with your Jedi coding skills :smile:.
+La estructura del proyecto es hexagonal, con dos microservicios principales: `Transaction` y `Antifraud`.
-Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !!
+## 📋 Stack Tecnológico
-- [Problem](#problem)
-- [Tech Stack](#tech_stack)
-- [Send us your challenge](#send_us_your_challenge)
+- **Java 17** - Lenguaje base
+- **Spring Boot 3.5.11** - Framework principal
+- **Spring WebFlux** - Programación reactiva no bloqueante
+- **R2DBC** - Operaciones reactivas con base de datos
+- **Apache Kafka** - Mensajería asíncrona
+- **PostgreSQL** - Base de datos persistente
-# Problem
+---
-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:
+## 🏗️ Arquitectura
-
- - pending
- - approved
- - rejected
-
+### Gestión de Concurrencia
+- **Bloqueo Optimista**: Maneja actualizaciones concurrentes sin bloquear lecturas
+- **Control de Duplicados**: Prevención de transacciones duplicadas
-Every transaction with a value greater than 1000 should be rejected.
+### Tipos de Transacción
+- `1` = Depósito
+- `2` = Retiro
-```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)]
+## 🔄 Flujo de Comunicación
+
+### Servicio de Transacciones (Transaction)
+**Produce a**: `anti-fraud-topic`
+- Datos de transacción recién creada (estado: `PENDING`)
+- Inmediatamente después de persistir envia el mensaje a Kafka para validación
+
+**Consume de**: `transaction-topic`
+- Estado final de validación (`APPROVED` / `REJECTED`)
+- Acción: Actualiza estado en base de datos
+
+### Servicio Anti-Fraude (Antifraud)
+**Consume de**: `anti-fraud-topic`
+- Transacción a validar
+- Rechaza transacciones con valor > $1000
+
+**Produce a**: `transaction-topic`
+- Resultado de validación con ID de transacción y nuevo estado
+- Después de procesar cada mensaje, envía el resultado a Kafka para que Transaction actualice su estado
+
+---
+
+## 🚀 Cómo Ejecutar
+
+Se tiene 2 modos de ejecución: Local (Infraestructura con docker y microservicios corriendo en tu máquina) o Automatizado (con todo el sistema dockerizado).
+
+### 💻 MODO LOCAL
+
+#### 1. Levantar infraestructura
+```powershell
+docker-compose -f docker-compose.local.yml up -d
+```
+
+**Incluye:**
+- PostgreSQL (puerto 5432)
+- Zookeeper (puerto 2181)
+- Kafka (puerto 9092)
+
+#### 2. Ejecutar microservicios desde el IDE
+
+### Requisitos Previos
+
+### Java 17
+Este proyecto requiere **Java 17**. Asegúrate de tener instalado JDK 17 y configurado en tu variable de entorno `JAVA_HOME`.
+
+#### Windows
+```powershell
+# Verificar versión de Java
+java -version
+
+# Configurar JAVA_HOME (reemplaza la ruta con tu instalación de JDK 17)
+setx JAVA_HOME "C:\Program Files\Java\jdk-17"
+setx PATH "%JAVA_HOME%\bin;%PATH%"
+
+# Reinicia tu terminal para aplicar cambios
+```
+
+#### Mac
+```bash
+# Verificar versión de Java
+java -version
+
+# Configurar JAVA_HOME en ~/.zshrc o ~/.bash_profile
+export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
+export PATH=$JAVA_HOME/bin:$PATH
+
+# Aplicar cambios
+source ~/.zshrc # o source ~/.bash_profile
+```
+
+**Opción A - Terminal:**
+```powershell
+# Terminal 1 - Antifraud
+cd antifraud
+./mvnw spring-boot:run
+
+# Terminal 2 - Transaction
+cd transaction
+./mvnw spring-boot:run
+```
+
+**Opción B - IntelliJ IDEA:**
+- Click derecho en `AntifraudApplication.java` → Run
+- Click derecho en `TransactionApplication.java` → Run
+
+---
+
+### 🐳 MODO AUTOMATIZADO (Docker Completo)
+
+#### Levantar todo el sistema
+```powershell
+docker-compose up --build -d
```
-# Tech Stack
+**Incluye:**
+- PostgreSQL
+- Zookeeper
+- Kafka
+- Antifraud (Dockerizado, puerto 8081)
+- Transaction (Dockerizado, puerto 8080)
+
+#### Detener
+```powershell
+docker-compose down
+```
+
+---
+
+## 📍 Puertos y Servicios
+
+| Servicio | Puerto | URL |
+|----------|--------|-----|
+| Transaction | 8080 | http://localhost:8080 |
+| Antifraud | 8081 | http://localhost:8081 |
+| PostgreSQL | 5432 | localhost:5432 |
+| Kafka | 9092 | localhost:9092 |
+| Zookeeper | 2181 | localhost:2181 |
+
-
- - Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
- - Any database
- - Kafka
-
+---
-We do provide a `Dockerfile` to help you get started with a dev environment.
+## 📮 Pruebas con POSTMAN
-You must have two resources:
+### 1️⃣ Crear Transacción
-1. Resource to create a transaction that must containt:
+**POST** `http://localhost:8080/transactions`
+**Body (JSON):**
```json
{
- "accountExternalIdDebit": "Guid",
- "accountExternalIdCredit": "Guid",
+ "accountExternalIdDebit": "6085462b-f1da-48a0-8f11-72ee428b2a32",
+ "accountExternalIdCredit": "1cb43a09-01a7-4019-b683-589b6b00ea58",
"tranferTypeId": 1,
- "value": 120
+ "value": 900
}
```
-2. Resource to retrieve a transaction
+**Response (201 CREATED):**
+```json
+{
+ "transactionExternalId": "47f62081-95c7-483b-a000-91c0e391f696"
+}
+```
+
+---
+
+### 2️⃣ Obtener Transacción
+**GET** `http://localhost:8080/transactions/{transactionExternalId}`
+
+**Parámetro:**
+- `transactionExternalId`: ID de la transacción a consultar
+
+**Response (200 OK):**
```json
{
- "transactionExternalId": "Guid",
+ "transactionExternalId": "47f62081-95c7-483b-a000-91c0e391f696",
"transactionType": {
- "name": ""
+ "name": "DEPOSIT"
},
"transactionStatus": {
- "name": ""
+ "name": "APPROVED"
},
- "value": 120,
- "createdAt": "Date"
+ "value": 900.00,
+ "createdAt": "2026-03-01T19:11:52.070044"
}
```
-## Optional
-
-You can use any approach to store transaction data but you should consider that we may deal with high volume scenarios where we have a huge amount of writes and reads for the same data at the same time. How would you tackle this requirement?
-
-You can use Graphql;
-
-# Send us your challenge
+---
-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.
+### 📌 Notas Importantes
-If you have any questions, please let us know.
+- **tranferTypeId**: `1` = Depósito, `2` = Retiro
diff --git a/antifraud/.gitattributes b/antifraud/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/antifraud/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/antifraud/.gitignore b/antifraud/.gitignore
new file mode 100644
index 0000000000..d74ac631fd
--- /dev/null
+++ b/antifraud/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
\ No newline at end of file
diff --git a/antifraud/.mvn/wrapper/maven-wrapper.properties b/antifraud/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000..8dea6c227c
--- /dev/null
+++ b/antifraud/.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.12/apache-maven-3.9.12-bin.zip
diff --git a/antifraud/Dockerfile b/antifraud/Dockerfile
new file mode 100644
index 0000000000..aa2f7b2b51
--- /dev/null
+++ b/antifraud/Dockerfile
@@ -0,0 +1,15 @@
+FROM eclipse-temurin:17-jdk-alpine AS build
+WORKDIR /app
+COPY mvnw .
+COPY .mvn .mvn
+COPY pom.xml .
+COPY src src
+RUN chmod +x mvnw
+RUN ./mvnw clean package -DskipTests
+
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY --from=build /app/target/antifraud-0.0.1-SNAPSHOT.jar app.jar
+EXPOSE 8081
+ENTRYPOINT ["java", "-jar", "app.jar"]
+
diff --git a/antifraud/mvnw b/antifraud/mvnw
new file mode 100644
index 0000000000..bd8896bf22
--- /dev/null
+++ b/antifraud/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/antifraud/mvnw.cmd b/antifraud/mvnw.cmd
new file mode 100644
index 0000000000..92450f9327
--- /dev/null
+++ b/antifraud/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/antifraud/pom.xml b/antifraud/pom.xml
new file mode 100644
index 0000000000..868c8bda0e
--- /dev/null
+++ b/antifraud/pom.xml
@@ -0,0 +1,126 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.11
+
+
+ com.yape
+ antifraud
+ 0.0.1-SNAPSHOT
+ antifraud
+ service that manages transaction antifraud
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 17
+ 2025.0.1
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.springframework.cloud
+ spring-cloud-starter
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+ io.projectreactor.kafka
+ reactor-kafka
+ 1.3.23
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/antifraud/src/main/java/com/yape/antifraud/AntifraudApplication.java b/antifraud/src/main/java/com/yape/antifraud/AntifraudApplication.java
new file mode 100644
index 0000000000..05b16c808d
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/AntifraudApplication.java
@@ -0,0 +1,13 @@
+package com.yape.antifraud;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class AntifraudApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AntifraudApplication.class, args);
+ }
+
+}
diff --git a/antifraud/src/main/java/com/yape/antifraud/application/service/ValidateAntiFraudService.java b/antifraud/src/main/java/com/yape/antifraud/application/service/ValidateAntiFraudService.java
new file mode 100644
index 0000000000..4bd283a018
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/application/service/ValidateAntiFraudService.java
@@ -0,0 +1,39 @@
+package com.yape.antifraud.application.service;
+
+import com.yape.antifraud.application.usecase.ValidateAntiFraudUseCase;
+import com.yape.antifraud.domain.enums.TransactionStatus;
+import com.yape.antifraud.domain.model.EventInbound;
+import com.yape.antifraud.domain.model.EventOutbound;
+import com.yape.antifraud.domain.ports.KafkaProducerPort;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+import java.math.BigDecimal;
+
+@Service
+@RequiredArgsConstructor
+public class ValidateAntiFraudService implements ValidateAntiFraudUseCase {
+
+ private static final BigDecimal FRAUD_THRESHOLD = new BigDecimal("1000");
+ private static final Logger logger = LoggerFactory.getLogger(ValidateAntiFraudService.class);
+ private final KafkaProducerPort kafkaProducerPort;
+
+ @Override
+ public Mono execute(EventInbound message) {
+ return Mono.fromSupplier(() -> isFraudulent(message) ? TransactionStatus.REJECTED : TransactionStatus.APPROVED)
+ .doOnNext(status -> logger.info("Transaction is {}", status == TransactionStatus.REJECTED ? "Fraudulent" : "Not Fraudulent"))
+ .map(status -> EventOutbound.builder()
+ .id(message.getId())
+ .transactionExternalId(message.getTransactionExternalId())
+ .value(message.getValue())
+ .status(status.getValue())
+ .build())
+ .flatMap(kafkaProducerPort::sendTransactionProcessed);
+ }
+
+ private boolean isFraudulent(EventInbound message) {
+ return message.getValue().compareTo(FRAUD_THRESHOLD) > 0;
+ }
+}
diff --git a/antifraud/src/main/java/com/yape/antifraud/application/usecase/ValidateAntiFraudUseCase.java b/antifraud/src/main/java/com/yape/antifraud/application/usecase/ValidateAntiFraudUseCase.java
new file mode 100644
index 0000000000..2932c5882b
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/application/usecase/ValidateAntiFraudUseCase.java
@@ -0,0 +1,8 @@
+package com.yape.antifraud.application.usecase;
+
+import com.yape.antifraud.domain.model.EventInbound;
+import reactor.core.publisher.Mono;
+
+public interface ValidateAntiFraudUseCase {
+ Mono execute(EventInbound message);
+}
diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/enums/TransactionStatus.java b/antifraud/src/main/java/com/yape/antifraud/domain/enums/TransactionStatus.java
new file mode 100644
index 0000000000..964c884973
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/domain/enums/TransactionStatus.java
@@ -0,0 +1,15 @@
+package com.yape.antifraud.domain.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum TransactionStatus {
+ REJECTED("REJECTED"),
+ APPROVED("APPROVED");
+
+ private final String value;
+
+ TransactionStatus(String value) {
+ this.value = value;
+ }
+}
diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java
new file mode 100644
index 0000000000..3cf034b91b
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java
@@ -0,0 +1,16 @@
+package com.yape.antifraud.domain.model;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+
+@Getter
+@Setter
+public class EventInbound {
+ private Long id;
+ private String transactionExternalId;
+ private BigDecimal value;
+ private String status;
+}
+
diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/model/EventOutbound.java b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventOutbound.java
new file mode 100644
index 0000000000..a29209527a
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventOutbound.java
@@ -0,0 +1,15 @@
+package com.yape.antifraud.domain.model;
+
+import lombok.*;
+import java.math.BigDecimal;
+
+@Getter
+@Setter
+@Builder
+public class EventOutbound {
+ private Long id;
+ private BigDecimal value;
+ private String transactionExternalId;
+ private String status;
+}
+
diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/ports/KafkaProducerPort.java b/antifraud/src/main/java/com/yape/antifraud/domain/ports/KafkaProducerPort.java
new file mode 100644
index 0000000000..90a9c49ea9
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/domain/ports/KafkaProducerPort.java
@@ -0,0 +1,8 @@
+package com.yape.antifraud.domain.ports;
+
+import com.yape.antifraud.domain.model.EventOutbound;
+import reactor.core.publisher.Mono;
+
+public interface KafkaProducerPort {
+ Mono sendTransactionProcessed(EventOutbound eventOutbound);
+}
diff --git a/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConfig.java b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConfig.java
new file mode 100644
index 0000000000..547745dfe5
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConfig.java
@@ -0,0 +1,53 @@
+package com.yape.antifraud.infraestructure.kafka;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate;
+import reactor.kafka.receiver.ReceiverOptions;
+import reactor.kafka.sender.SenderOptions;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class KafkaConfig {
+
+ @Value("${spring.kafka.bootstrap-servers}")
+ private String bootstrapServers;
+
+ @Value("${spring.kafka.consumer.group-id}")
+ private String groupId;
+
+ @Value("${spring.kafka.topics.anti-fraud}")
+ private String antiFraudTopic;
+
+ @Bean
+ public ReceiverOptions receiverOptions() {
+ Map props = new HashMap<>();
+ props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+ props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+ props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
+
+ return ReceiverOptions.create(props)
+ .subscription(Collections.singleton(antiFraudTopic));
+ }
+
+ @Bean
+ public ReactiveKafkaProducerTemplate reactiveKafkaProducerTemplate() {
+ Map props = new HashMap<>();
+ props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+
+ return new ReactiveKafkaProducerTemplate<>(SenderOptions.create(props));
+ }
+}
+
diff --git a/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConsumer.java b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConsumer.java
new file mode 100644
index 0000000000..6a36d3e4cd
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConsumer.java
@@ -0,0 +1,62 @@
+package com.yape.antifraud.infraestructure.kafka;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yape.antifraud.application.service.ValidateAntiFraudService;
+import com.yape.antifraud.domain.model.EventInbound;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+import reactor.kafka.receiver.KafkaReceiver;
+import reactor.kafka.receiver.ReceiverOptions;
+import reactor.util.retry.Retry;
+import java.time.Duration;
+
+@Component
+@RequiredArgsConstructor
+public class KafkaConsumer {
+
+ private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class);
+
+ private final ValidateAntiFraudService validateAntiFraudService;
+ private final ObjectMapper objectMapper;
+ private final ReceiverOptions receiverOptions;
+
+ @PostConstruct
+ public void listen() {
+ KafkaReceiver.create(receiverOptions)
+ .receive()
+ .flatMap(record -> {
+ logger.info("Received message - topic: {}, offset: {}, message: {}",
+ record.topic(), record.offset(), record.value());
+ return parseEvent(record.value())
+ .flatMap(validateAntiFraudService::execute)
+ .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
+ .maxBackoff(Duration.ofSeconds(5))
+ .doBeforeRetry(signal -> logger.warn("Retrying message - offset: {}, attempt: {}",
+ record.offset(), signal.totalRetries() + 1)))
+ .doOnSuccess(v -> {
+ record.receiverOffset().acknowledge();
+ logger.info("Message acknowledged - offset: {}", record.offset());
+ })
+ .onErrorResume(e -> {
+ logger.error("All retries exhausted - offset: {}, error: {}", record.offset(), e.getMessage());
+ record.receiverOffset().acknowledge();
+ return Mono.empty();
+ });
+ })
+ .subscribe(
+ null,
+ e -> logger.error("Fatal error in Kafka consumer: {}", e.getMessage())
+ );
+
+ }
+
+ private Mono parseEvent(String message) {
+ return Mono.fromCallable(() ->
+ objectMapper.readValue(message, EventInbound.class))
+ .doOnError(e -> logger.error("Error parsing message: {}", e.getMessage()));
+ }
+}
diff --git a/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaProducer.java b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaProducer.java
new file mode 100644
index 0000000000..feba652191
--- /dev/null
+++ b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaProducer.java
@@ -0,0 +1,37 @@
+package com.yape.antifraud.infraestructure.kafka;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yape.antifraud.domain.model.EventOutbound;
+import com.yape.antifraud.domain.ports.KafkaProducerPort;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+@Component
+@RequiredArgsConstructor
+public class KafkaProducer implements KafkaProducerPort {
+
+ private static final Logger logger = LoggerFactory.getLogger(KafkaProducer.class);
+
+ private final ReactiveKafkaProducerTemplate kafkaTemplate;
+ private final ObjectMapper objectMapper;
+
+ @Value("${spring.kafka.topics.transaction}")
+ private String transactionTopic;
+
+ @Override
+ public Mono sendTransactionProcessed(EventOutbound eventOutbound) {
+ return Mono.fromCallable(() -> objectMapper.writeValueAsString(eventOutbound))
+ .doOnNext(message -> logger.info("Sending event to Kafka: {}", message))
+ .flatMap(message -> kafkaTemplate.send(transactionTopic,
+ eventOutbound.getTransactionExternalId(), message))
+ .doOnSuccess(result -> logger.info("Event sent successfully to topic: {}", transactionTopic))
+ .doOnError(e -> logger.error("Error sending event to Kafka: {}", e.getMessage()))
+ .onErrorMap(e -> new RuntimeException("Error processing EventOutbound", e))
+ .then();
+ }
+}
diff --git a/antifraud/src/main/resources/application.properties b/antifraud/src/main/resources/application.properties
new file mode 100644
index 0000000000..708a0c5f6c
--- /dev/null
+++ b/antifraud/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+spring.application.name=antifraud
+server.port=${SERVER_PORT:8081}
diff --git a/antifraud/src/main/resources/application.yml b/antifraud/src/main/resources/application.yml
new file mode 100644
index 0000000000..f6df97fd8d
--- /dev/null
+++ b/antifraud/src/main/resources/application.yml
@@ -0,0 +1,8 @@
+spring:
+ kafka:
+ bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: ${SPRING_KAFKA_CONSUMER_GROUP_ID:anti-fraud-group-id}
+ topics:
+ transaction: ${KAFKA_TOPIC_TRANSACTION:transaction-topic}
+ anti-fraud: ${KAFKA_TOPIC_ANTIFRAUD:anti-fraud-topic}
diff --git a/antifraud/src/test/java/com/yape/antifraud/AntifraudApplicationTests.java b/antifraud/src/test/java/com/yape/antifraud/AntifraudApplicationTests.java
new file mode 100644
index 0000000000..ac8d891afd
--- /dev/null
+++ b/antifraud/src/test/java/com/yape/antifraud/AntifraudApplicationTests.java
@@ -0,0 +1,13 @@
+package com.yape.antifraud;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class AntifraudApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}
diff --git a/antifraud/src/test/java/com/yape/antifraud/application/service/ValidateAntiFraudServiceTest.java b/antifraud/src/test/java/com/yape/antifraud/application/service/ValidateAntiFraudServiceTest.java
new file mode 100644
index 0000000000..24a23bd26f
--- /dev/null
+++ b/antifraud/src/test/java/com/yape/antifraud/application/service/ValidateAntiFraudServiceTest.java
@@ -0,0 +1,181 @@
+package com.yape.antifraud.application.service;
+
+import com.yape.antifraud.domain.enums.TransactionStatus;
+import com.yape.antifraud.domain.model.EventInbound;
+import com.yape.antifraud.domain.model.EventOutbound;
+import com.yape.antifraud.domain.ports.KafkaProducerPort;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ValidateAntiFraudServiceTest {
+
+ @Mock
+ private KafkaProducerPort kafkaProducerPort;
+
+ @InjectMocks
+ private ValidateAntiFraudService validateAntiFraudService;
+
+ private EventInbound buildEventInbound(Long id, String transactionExternalId, BigDecimal value) {
+ EventInbound event = new EventInbound();
+ event.setId(id);
+ event.setTransactionExternalId(transactionExternalId);
+ event.setValue(value);
+ return event;
+ }
+
+ @Test
+ @DisplayName("Given a value equal to the threshold (1000), the transaction should be APPROVED")
+ void execute_whenValueEqualsThreshold_shouldApprove() {
+ when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty());
+ EventInbound message = buildEventInbound(1L, "ext-001", new BigDecimal("1000"));
+
+ StepVerifier.create(validateAntiFraudService.execute(message))
+ .verifyComplete();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class);
+ verify(kafkaProducerPort, times(1)).sendTransactionProcessed(captor.capture());
+
+ EventOutbound outbound = captor.getValue();
+ assertThat(outbound.getStatus()).isEqualTo(TransactionStatus.APPROVED.getValue());
+ assertThat(outbound.getId()).isEqualTo(1L);
+ assertThat(outbound.getTransactionExternalId()).isEqualTo("ext-001");
+ assertThat(outbound.getValue()).isEqualByComparingTo(new BigDecimal("1000"));
+ }
+
+ @Test
+ @DisplayName("Given a value below the threshold (999.99), the transaction should be APPROVED")
+ void execute_whenValueBelowThreshold_shouldApprove() {
+ when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty());
+ EventInbound message = buildEventInbound(2L, "ext-002", new BigDecimal("999.99"));
+
+ StepVerifier.create(validateAntiFraudService.execute(message))
+ .verifyComplete();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class);
+ verify(kafkaProducerPort).sendTransactionProcessed(captor.capture());
+
+ assertThat(captor.getValue().getStatus()).isEqualTo(TransactionStatus.APPROVED.getValue());
+ }
+
+ @Test
+ @DisplayName("Given a value of zero, the transaction should be APPROVED")
+ void execute_whenValueIsZero_shouldApprove() {
+ when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty());
+ EventInbound message = buildEventInbound(3L, "ext-003", BigDecimal.ZERO);
+
+ StepVerifier.create(validateAntiFraudService.execute(message))
+ .verifyComplete();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class);
+ verify(kafkaProducerPort).sendTransactionProcessed(captor.capture());
+
+ assertThat(captor.getValue().getStatus()).isEqualTo(TransactionStatus.APPROVED.getValue());
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: REJECTED transaction (value > 1000)
+ // -------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("Given a value above the threshold (1000.01), the transaction should be REJECTED")
+ void execute_whenValueAboveThreshold_shouldReject() {
+ when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty());
+ EventInbound message = buildEventInbound(4L, "ext-004", new BigDecimal("1000.01"));
+
+ StepVerifier.create(validateAntiFraudService.execute(message))
+ .verifyComplete();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class);
+ verify(kafkaProducerPort).sendTransactionProcessed(captor.capture());
+
+ EventOutbound outbound = captor.getValue();
+ assertThat(outbound.getStatus()).isEqualTo(TransactionStatus.REJECTED.getValue());
+ assertThat(outbound.getId()).isEqualTo(4L);
+ assertThat(outbound.getTransactionExternalId()).isEqualTo("ext-004");
+ }
+
+ @Test
+ @DisplayName("Given a very high value (9999), the transaction should be REJECTED")
+ void execute_whenValueVeryHigh_shouldReject() {
+ when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty());
+ EventInbound message = buildEventInbound(5L, "ext-005", new BigDecimal("9999"));
+
+ StepVerifier.create(validateAntiFraudService.execute(message))
+ .verifyComplete();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class);
+ verify(kafkaProducerPort).sendTransactionProcessed(captor.capture());
+
+ assertThat(captor.getValue().getStatus()).isEqualTo(TransactionStatus.REJECTED.getValue());
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: producer error propagation
+ // -------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("If the producer fails, the Mono should propagate the error")
+ void execute_whenProducerFails_shouldPropagateError() {
+ EventInbound message = buildEventInbound(6L, "ext-006", new BigDecimal("500"));
+ RuntimeException producerError = new RuntimeException("Kafka not available");
+
+ when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class)))
+ .thenReturn(Mono.error(producerError));
+
+ StepVerifier.create(validateAntiFraudService.execute(message))
+ .expectErrorMatches(err -> err instanceof RuntimeException
+ && err.getMessage().equals("Kafka not available"))
+ .verify();
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: EventOutbound field mapping
+ // -------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("The EventOutbound should contain the same id, transactionExternalId and value as the EventInbound")
+ void execute_shouldMapEventInboundFieldsToEventOutbound() {
+ Long expectedId = 7L;
+ String expectedExtId = "ext-007";
+ BigDecimal expectedValue = new BigDecimal("250");
+ EventInbound message = buildEventInbound(expectedId, expectedExtId, expectedValue);
+ when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty());
+
+ StepVerifier.create(validateAntiFraudService.execute(message))
+ .verifyComplete();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class);
+ verify(kafkaProducerPort).sendTransactionProcessed(captor.capture());
+
+ EventOutbound outbound = captor.getValue();
+ assertThat(outbound.getId()).isEqualTo(expectedId);
+ assertThat(outbound.getTransactionExternalId()).isEqualTo(expectedExtId);
+ assertThat(outbound.getValue()).isEqualByComparingTo(expectedValue);
+ }
+
+ @Test
+ @DisplayName("The producer should be invoked exactly once per execution")
+ void execute_shouldCallProducerExactlyOnce() {
+ when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty());
+ EventInbound message = buildEventInbound(8L, "ext-008", new BigDecimal("100"));
+
+ StepVerifier.create(validateAntiFraudService.execute(message))
+ .verifyComplete();
+
+ verify(kafkaProducerPort, times(1)).sendTransactionProcessed(any(EventOutbound.class));
+ }
+}
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
new file mode 100644
index 0000000000..f450857861
--- /dev/null
+++ b/docker-compose.local.yml
@@ -0,0 +1,30 @@
+version: "3.7"
+services:
+ postgres:
+ image: postgres:14
+ container_name: yape-postgres
+ ports:
+ - "5432:5432"
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=postgres
+ zookeeper:
+ image: confluentinc/cp-zookeeper:5.5.3
+ container_name: yape-zookeeper
+ environment:
+ ZOOKEEPER_CLIENT_PORT: 2181
+ kafka:
+ image: confluentinc/cp-enterprise-kafka:5.5.3
+ container_name: yape-kafka
+ depends_on:
+ - zookeeper
+ 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_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ KAFKA_JMX_PORT: 9991
+ KAFKA_HEAP_OPTS: "-Xmx512M -Xms256M"
+ ports:
+ - 9092:9092
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..9a69741c1c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,18 +2,48 @@ version: "3.7"
services:
postgres:
image: postgres:14
+ container_name: yape-postgres
ports:
- "5432:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - yape-network
+ deploy:
+ resources:
+ limits:
+ memory: 128M
+
zookeeper:
image: confluentinc/cp-zookeeper:5.5.3
+ container_name: yape-zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
+ healthcheck:
+ test: ["CMD-SHELL", "echo srvr | nc localhost 2181 || exit 0"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+ networks:
+ - yape-network
+ deploy:
+ resources:
+ limits:
+ memory: 128M
+
kafka:
image: confluentinc/cp-enterprise-kafka:5.5.3
- depends_on: [zookeeper]
+ container_name: yape-kafka
+ depends_on:
+ zookeeper:
+ condition: service_healthy
environment:
KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
@@ -21,5 +51,69 @@ services:
KAFKA_BROKER_ID: 1
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_JMX_PORT: 9991
+ KAFKA_HEAP_OPTS: "-Xmx512M -Xms256M"
ports:
- 9092:9092
+ healthcheck:
+ test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:29092 || exit 1"]
+ interval: 10s
+ timeout: 10s
+ retries: 10
+ start_period: 30s
+ networks:
+ - yape-network
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+
+ antifraud:
+ build:
+ context: ./antifraud
+ dockerfile: Dockerfile
+ container_name: yape-antifraud
+ depends_on:
+ kafka:
+ condition: service_healthy
+ environment:
+ - SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:29092
+ ports:
+ - "8081:8081"
+ networks:
+ - yape-network
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 256M
+
+ transaction:
+ build:
+ context: ./transaction
+ dockerfile: Dockerfile
+ container_name: yape-transaction
+ depends_on:
+ postgres:
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ antifraud:
+ condition: service_started
+ environment:
+ - SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:29092
+ - SPRING_R2DBC_URL=r2dbc:postgresql://postgres:5432/postgres?schema=public
+ - SPRING_R2DBC_USERNAME=postgres
+ - SPRING_R2DBC_PASSWORD=postgres
+ ports:
+ - "8080:8080"
+ networks:
+ - yape-network
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 256M
+
+networks:
+ yape-network:
+ driver: bridge
diff --git a/transaction/.gitattributes b/transaction/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/transaction/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/transaction/.gitignore b/transaction/.gitignore
new file mode 100644
index 0000000000..667aaef0c8
--- /dev/null
+++ b/transaction/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/transaction/.mvn/wrapper/maven-wrapper.properties b/transaction/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000..8dea6c227c
--- /dev/null
+++ b/transaction/.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.12/apache-maven-3.9.12-bin.zip
diff --git a/transaction/Dockerfile b/transaction/Dockerfile
new file mode 100644
index 0000000000..4efbcd779f
--- /dev/null
+++ b/transaction/Dockerfile
@@ -0,0 +1,15 @@
+FROM eclipse-temurin:17-jdk-alpine AS build
+WORKDIR /app
+COPY mvnw .
+COPY .mvn .mvn
+COPY pom.xml .
+COPY src src
+RUN chmod +x mvnw
+RUN ./mvnw clean package -DskipTests
+
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY --from=build /app/target/transaction-0.0.1-SNAPSHOT.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "app.jar"]
+
diff --git a/transaction/mvnw b/transaction/mvnw
new file mode 100644
index 0000000000..bd8896bf22
--- /dev/null
+++ b/transaction/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/mvnw.cmd b/transaction/mvnw.cmd
new file mode 100644
index 0000000000..92450f9327
--- /dev/null
+++ b/transaction/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/pom.xml b/transaction/pom.xml
new file mode 100644
index 0000000000..fb35cdb499
--- /dev/null
+++ b/transaction/pom.xml
@@ -0,0 +1,131 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.11
+
+
+ com.yape
+ transaction
+ 0.0.1-SNAPSHOT
+ transaction
+ service that manages transaction status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 17
+ 2025.0.1
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-r2dbc
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.springframework.cloud
+ spring-cloud-starter
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+
+ io.projectreactor.kafka
+ reactor-kafka
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.postgresql
+ r2dbc-postgresql
+ runtime
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/transaction/src/main/java/com/yape/transaction/TransactionApplication.java b/transaction/src/main/java/com/yape/transaction/TransactionApplication.java
new file mode 100644
index 0000000000..812ec66ba6
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/TransactionApplication.java
@@ -0,0 +1,13 @@
+package com.yape.transaction;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class TransactionApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(TransactionApplication.class, args);
+ }
+
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/dto/BaseTransaction.java b/transaction/src/main/java/com/yape/transaction/application/dto/BaseTransaction.java
new file mode 100644
index 0000000000..ea641fcc00
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/dto/BaseTransaction.java
@@ -0,0 +1,12 @@
+package com.yape.transaction.application.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+public class BaseTransaction {
+ private String name;
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionRequest.java b/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionRequest.java
new file mode 100644
index 0000000000..c41a657dfb
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionRequest.java
@@ -0,0 +1,27 @@
+package com.yape.transaction.application.dto;
+
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+
+@Getter
+@Setter
+public class CreateTransactionRequest {
+ @NotEmpty
+ @Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "Debe ser un UUID válido")
+ private String accountExternalIdDebit;
+
+ @NotEmpty
+ @Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "Debe ser un UUID válido")
+ private String accountExternalIdCredit;
+
+ @NotNull
+ private Integer tranferTypeId;
+
+ @NotNull
+ private BigDecimal value;
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionResponse.java b/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionResponse.java
new file mode 100644
index 0000000000..19ba0d8594
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionResponse.java
@@ -0,0 +1,12 @@
+package com.yape.transaction.application.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Builder
+public class CreateTransactionResponse {
+ private String transactionExternalId;
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/dto/GetTransactionResponse.java b/transaction/src/main/java/com/yape/transaction/application/dto/GetTransactionResponse.java
new file mode 100644
index 0000000000..61468b25c0
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/dto/GetTransactionResponse.java
@@ -0,0 +1,18 @@
+package com.yape.transaction.application.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@Builder
+public class GetTransactionResponse {
+ private String transactionExternalId;
+ private BaseTransaction transactionType;
+ private BaseTransaction transactionStatus;
+ private BigDecimal value;
+ private LocalDateTime createdAt;
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/mapper/TransactionMapper.java b/transaction/src/main/java/com/yape/transaction/application/mapper/TransactionMapper.java
new file mode 100644
index 0000000000..8dcd6e4845
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/mapper/TransactionMapper.java
@@ -0,0 +1,57 @@
+package com.yape.transaction.application.mapper;
+
+import com.yape.transaction.application.dto.CreateTransactionRequest;
+import com.yape.transaction.application.dto.CreateTransactionResponse;
+import com.yape.transaction.application.dto.GetTransactionResponse;
+import com.yape.transaction.application.dto.BaseTransaction;
+import com.yape.transaction.domain.entities.Transaction;
+import com.yape.transaction.domain.enums.TransactionStatus;
+import com.yape.transaction.domain.enums.TransactionType;
+import com.yape.transaction.domain.model.EventOutbound;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public class TransactionMapper {
+
+ private TransactionMapper() {}
+
+ public static Transaction toEntity(CreateTransactionRequest request, TransactionType transactionType) {
+ return Transaction.builder()
+ .transactionExternalId(UUID.randomUUID().toString())
+ .accountExternalIdDebit(request.getAccountExternalIdDebit())
+ .accountExternalIdCredit(request.getAccountExternalIdCredit())
+ .type(transactionType.name())
+ .status(TransactionStatus.PENDING.getValue())
+ .value(request.getValue())
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .build();
+ }
+
+ public static EventOutbound toEventOutbound(Transaction transaction) {
+ return EventOutbound.builder()
+ .id(transaction.getId())
+ .value(transaction.getValue())
+ .transactionExternalId(transaction.getTransactionExternalId())
+ .status(transaction.getStatus())
+ .build();
+ }
+
+ public static CreateTransactionResponse toCreateResponse(Transaction transaction) {
+ return CreateTransactionResponse.builder()
+ .transactionExternalId(transaction.getTransactionExternalId())
+ .build();
+ }
+
+ public static GetTransactionResponse toGetResponse(Transaction transaction) {
+ return GetTransactionResponse.builder()
+ .transactionExternalId(transaction.getTransactionExternalId())
+ .value(transaction.getValue())
+ .createdAt(transaction.getCreatedAt())
+ .transactionStatus(new BaseTransaction(transaction.getStatus()))
+ .transactionType(new BaseTransaction(transaction.getType()))
+ .build();
+ }
+}
+
diff --git a/transaction/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java b/transaction/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java
new file mode 100644
index 0000000000..26baab94d0
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java
@@ -0,0 +1,55 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.application.dto.CreateTransactionRequest;
+import com.yape.transaction.application.dto.CreateTransactionResponse;
+import com.yape.transaction.application.mapper.TransactionMapper;
+import com.yape.transaction.application.usecase.CreateTransactionUseCase;
+import com.yape.transaction.domain.enums.TransactionType;
+import com.yape.transaction.domain.ports.KafkaProducerPort;
+import com.yape.transaction.domain.ports.TransactionRepositoryPort;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+import reactor.core.publisher.Mono;
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class CreateTransactionService implements CreateTransactionUseCase {
+
+ private static final Logger logger = LoggerFactory.getLogger(CreateTransactionService.class);
+
+ private final TransactionRepositoryPort transactionRepositoryPort;
+ private final KafkaProducerPort kafkaProducerPort;
+
+ @Override
+ public Mono execute(CreateTransactionRequest request) {
+ logger.info("Creating a new transaction");
+
+ return Mono.just(request.getTranferTypeId())
+ .map(this::validateTransactionType)
+ .flatMap(transactionType -> transactionRepositoryPort.save(
+ TransactionMapper.toEntity(request, transactionType)
+ ))
+ .doOnNext(saved -> logger.info("Transaction created: {}", saved.getTransactionExternalId()))
+ .flatMap(saved -> kafkaProducerPort.sendValidate(TransactionMapper.toEventOutbound(saved))
+ .thenReturn(TransactionMapper.toCreateResponse(saved)));
+ }
+
+ private TransactionType validateTransactionType(Integer transferTypeId) {
+ return Arrays.stream(TransactionType.values())
+ .filter(type -> type.getValue() == transferTypeId)
+ .findFirst()
+ .orElseThrow(() -> {
+ String validTypes = Arrays.stream(TransactionType.values())
+ .map(type -> type.getValue() + " = " + type.name())
+ .collect(Collectors.joining(", "));
+ return new ResponseStatusException(HttpStatus.BAD_REQUEST,
+ "Invalid transaction type: " + transferTypeId + ". Valid types are: " + validTypes);
+ });
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/service/GetTransactionService.java b/transaction/src/main/java/com/yape/transaction/application/service/GetTransactionService.java
new file mode 100644
index 0000000000..f99b7fbc76
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/service/GetTransactionService.java
@@ -0,0 +1,33 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.application.dto.GetTransactionResponse;
+import com.yape.transaction.application.mapper.TransactionMapper;
+import com.yape.transaction.application.usecase.GetTransactionUseCase;
+import com.yape.transaction.domain.ports.TransactionRepositoryPort;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+import reactor.core.publisher.Mono;
+
+@Service
+@RequiredArgsConstructor
+public class GetTransactionService implements GetTransactionUseCase {
+
+ private static final Logger logger = LoggerFactory.getLogger(GetTransactionService.class);
+
+ private final TransactionRepositoryPort transactionRepositoryPort;
+
+ @Override
+ public Mono execute(String transactionExternalId) {
+ return transactionRepositoryPort.findById(transactionExternalId)
+ .switchIfEmpty(Mono.defer(() -> {
+ logger.warn("Transaction with ID: {} not found", transactionExternalId);
+ return Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND,
+ "Transaction with ID: " + transactionExternalId + " not found"));
+ }))
+ .map(TransactionMapper::toGetResponse);
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/service/UpdateTransactionService.java b/transaction/src/main/java/com/yape/transaction/application/service/UpdateTransactionService.java
new file mode 100644
index 0000000000..ef173c52e1
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/service/UpdateTransactionService.java
@@ -0,0 +1,26 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.domain.model.EventInbound;
+import com.yape.transaction.domain.ports.TransactionRepositoryPort;
+import com.yape.transaction.application.usecase.UpdateTransactionUseCase;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+@Service
+@RequiredArgsConstructor
+public class UpdateTransactionService implements UpdateTransactionUseCase {
+
+ private static final Logger logger = LoggerFactory.getLogger(UpdateTransactionService.class);
+
+ private final TransactionRepositoryPort transactionRepositoryPort;
+
+ @Override
+ public Mono execute(EventInbound message) {
+ logger.info("Updating status with id: {}", message.getTransactionExternalId());
+ return transactionRepositoryPort.updateStatus(message.getTransactionExternalId(), message.getStatus())
+ .then();
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCase.java b/transaction/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCase.java
new file mode 100644
index 0000000000..9fdcf7f0e1
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCase.java
@@ -0,0 +1,9 @@
+package com.yape.transaction.application.usecase;
+
+import com.yape.transaction.application.dto.CreateTransactionRequest;
+import com.yape.transaction.application.dto.CreateTransactionResponse;
+import reactor.core.publisher.Mono;
+
+public interface CreateTransactionUseCase {
+ Mono execute(CreateTransactionRequest createTransactionRequest);
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCase.java b/transaction/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCase.java
new file mode 100644
index 0000000000..92a3fa5560
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCase.java
@@ -0,0 +1,8 @@
+package com.yape.transaction.application.usecase;
+
+import com.yape.transaction.application.dto.GetTransactionResponse;
+import reactor.core.publisher.Mono;
+
+public interface GetTransactionUseCase {
+ Mono execute(String transactionExternalId);
+}
diff --git a/transaction/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionUseCase.java b/transaction/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionUseCase.java
new file mode 100644
index 0000000000..3f296932c5
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionUseCase.java
@@ -0,0 +1,8 @@
+package com.yape.transaction.application.usecase;
+
+import com.yape.transaction.domain.model.EventInbound;
+import reactor.core.publisher.Mono;
+
+public interface UpdateTransactionUseCase {
+ Mono execute(EventInbound message);
+}
diff --git a/transaction/src/main/java/com/yape/transaction/domain/entities/Transaction.java b/transaction/src/main/java/com/yape/transaction/domain/entities/Transaction.java
new file mode 100644
index 0000000000..7458c19d75
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/domain/entities/Transaction.java
@@ -0,0 +1,48 @@
+package com.yape.transaction.domain.entities;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.annotation.Version;
+import org.springframework.data.relational.core.mapping.Table;
+import org.springframework.data.relational.core.mapping.Column;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@Builder
+@Table(name = "transactions")
+public class Transaction {
+ @Id
+ private Long id;
+
+ @Version
+ @Column("version")
+ private Long version;
+
+ @Column("transaction_external_id")
+ private String transactionExternalId;
+
+ @Column("account_external_id_debit")
+ private String accountExternalIdDebit;
+
+ @Column("account_external_id_credit")
+ private String accountExternalIdCredit;
+
+ @Column("type")
+ private String type;
+
+ @Column("status")
+ private String status;
+
+ @Column("created_at")
+ private LocalDateTime createdAt;
+
+ @Column("updated_at")
+ private LocalDateTime updatedAt;
+
+ @Column("value")
+ private BigDecimal value;
+}
diff --git a/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionStatus.java b/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionStatus.java
new file mode 100644
index 0000000000..b83f5d3fce
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionStatus.java
@@ -0,0 +1,14 @@
+package com.yape.transaction.domain.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum TransactionStatus {
+ PENDING("PENDING");
+
+ private final String value;
+
+ TransactionStatus(String value) {
+ this.value = value;
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionType.java b/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionType.java
new file mode 100644
index 0000000000..4b79f21c2e
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionType.java
@@ -0,0 +1,16 @@
+package com.yape.transaction.domain.enums;
+
+import lombok.Getter;
+
+@Getter
+public enum TransactionType {
+ DEPOSIT(1),
+ WITHDRAWAL(2);
+
+ private final int value;
+
+ TransactionType(int value) {
+ this.value = value;
+ }
+
+}
diff --git a/transaction/src/main/java/com/yape/transaction/domain/model/EventInbound.java b/transaction/src/main/java/com/yape/transaction/domain/model/EventInbound.java
new file mode 100644
index 0000000000..07e97812c7
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/domain/model/EventInbound.java
@@ -0,0 +1,15 @@
+package com.yape.transaction.domain.model;
+
+import lombok.Getter;
+import lombok.Setter;
+import java.math.BigDecimal;
+
+@Getter
+@Setter
+public class EventInbound {
+ private Long id;
+ private String transactionExternalId;
+ private BigDecimal value;
+ private String status;
+}
+
diff --git a/transaction/src/main/java/com/yape/transaction/domain/model/EventOutbound.java b/transaction/src/main/java/com/yape/transaction/domain/model/EventOutbound.java
new file mode 100644
index 0000000000..bd953664c4
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/domain/model/EventOutbound.java
@@ -0,0 +1,19 @@
+package com.yape.transaction.domain.model;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import java.math.BigDecimal;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class EventOutbound {
+ private Long id;
+ private BigDecimal value;
+ private String transactionExternalId;
+ private String status;
+}
+
diff --git a/transaction/src/main/java/com/yape/transaction/domain/ports/KafkaProducerPort.java b/transaction/src/main/java/com/yape/transaction/domain/ports/KafkaProducerPort.java
new file mode 100644
index 0000000000..2badeee96a
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/domain/ports/KafkaProducerPort.java
@@ -0,0 +1,8 @@
+package com.yape.transaction.domain.ports;
+
+import com.yape.transaction.domain.model.EventOutbound;
+import reactor.core.publisher.Mono;
+
+public interface KafkaProducerPort {
+ Mono sendValidate(EventOutbound eventOutbound);
+}
diff --git a/transaction/src/main/java/com/yape/transaction/domain/ports/TransactionRepositoryPort.java b/transaction/src/main/java/com/yape/transaction/domain/ports/TransactionRepositoryPort.java
new file mode 100644
index 0000000000..bfa89ce1e2
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/domain/ports/TransactionRepositoryPort.java
@@ -0,0 +1,10 @@
+package com.yape.transaction.domain.ports;
+
+import reactor.core.publisher.Mono;
+import com.yape.transaction.domain.entities.Transaction;
+
+public interface TransactionRepositoryPort {
+ Mono findById(String id);
+ Mono save(Transaction transaction);
+ Mono updateStatus(String transactionExternalId, String status);
+}
diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConfig.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConfig.java
new file mode 100644
index 0000000000..24debb630d
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConfig.java
@@ -0,0 +1,52 @@
+package com.yape.transaction.infraestructure.adapter.kafka;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate;
+import reactor.kafka.receiver.ReceiverOptions;
+import reactor.kafka.sender.SenderOptions;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class KafkaConfig {
+
+ @Value("${spring.kafka.bootstrap-servers}")
+ private String bootstrapServers;
+
+ @Value("${spring.kafka.consumer.group-id}")
+ private String groupId;
+
+ @Value("${spring.kafka.topics.transaction}")
+ private String transactionTopic;
+
+ @Bean
+ public ReceiverOptions receiverOptions() {
+ Map props = new HashMap<>();
+ props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+ props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+ props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
+
+ return ReceiverOptions.create(props)
+ .subscription(Collections.singleton(transactionTopic));
+ }
+
+ @Bean
+ public ReactiveKafkaProducerTemplate reactiveKafkaProducerTemplate() {
+ Map props = new HashMap<>();
+ props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+
+ return new ReactiveKafkaProducerTemplate<>(SenderOptions.create(props));
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConsumer.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConsumer.java
new file mode 100644
index 0000000000..dc1a7c74d1
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConsumer.java
@@ -0,0 +1,62 @@
+package com.yape.transaction.infraestructure.adapter.kafka;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yape.transaction.application.usecase.UpdateTransactionUseCase;
+import com.yape.transaction.domain.model.EventInbound;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+import reactor.kafka.receiver.KafkaReceiver;
+import reactor.kafka.receiver.ReceiverOptions;
+import reactor.util.retry.Retry;
+import java.time.Duration;
+
+@Component
+@RequiredArgsConstructor
+public class KafkaConsumer {
+
+ private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class);
+
+ private final UpdateTransactionUseCase updateTransactionPort;
+ private final ObjectMapper objectMapper;
+ private final ReceiverOptions receiverOptions;
+
+ @PostConstruct
+ public void listen() {
+ KafkaReceiver.create(receiverOptions)
+ .receive()
+ .flatMap(record -> {
+ logger.info("Received message - topic: {}, offset: {}, message: {}",
+ record.topic(), record.offset(), record.value());
+ return parseEvent(record.value())
+ .flatMap(updateTransactionPort::execute)
+ .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
+ .maxBackoff(Duration.ofSeconds(5))
+ .doBeforeRetry(signal -> logger.warn("Retrying message - offset: {}, attempt: {}",
+ record.offset(), signal.totalRetries() + 1)))
+ .doOnSuccess(v -> {
+ record.receiverOffset().acknowledge();
+ logger.info("Message acknowledged - offset: {}", record.offset());
+ })
+ .doOnError(e -> logger.error("Error processing message - offset: {}, error: {}",
+ record.offset(), e.getMessage()))
+ .onErrorResume(e -> {
+ record.receiverOffset().acknowledge();
+ return Mono.empty();
+ });
+ })
+ .subscribe(
+ null,
+ e -> logger.error("Fatal error in Kafka consumer: {}", e.getMessage())
+ );
+ }
+
+ private Mono parseEvent(String message) {
+ return Mono.fromCallable(() ->
+ objectMapper.readValue(message, EventInbound.class))
+ .doOnError(e -> logger.error("Error parsing message: {}", e.getMessage()));
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaProducer.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaProducer.java
new file mode 100644
index 0000000000..ac1373646a
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaProducer.java
@@ -0,0 +1,39 @@
+package com.yape.transaction.infraestructure.adapter.kafka;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yape.transaction.domain.model.EventOutbound;
+import com.yape.transaction.domain.ports.KafkaProducerPort;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+@Component
+@RequiredArgsConstructor
+public class KafkaProducer implements KafkaProducerPort {
+
+ private static final Logger logger = LoggerFactory.getLogger(KafkaProducer.class);
+
+ private final ReactiveKafkaProducerTemplate kafkaTemplate;
+ private final ObjectMapper objectMapper;
+
+ @Value("${spring.kafka.topics.anti-fraud}")
+ private String antiFraudTopic;
+
+ @Override
+ public Mono sendValidate(EventOutbound eventOutbound) {
+ return Mono.fromCallable(() -> objectMapper.writeValueAsString(eventOutbound))
+ .doOnNext(message -> logger.info("Sending event to Kafka: {}", message))
+ .flatMap(message -> kafkaTemplate.send(antiFraudTopic,
+ eventOutbound.getTransactionExternalId(), message))
+ .doOnSuccess(result -> logger.info("Event sent successfully to topic: {}", antiFraudTopic))
+ .doOnError(e -> logger.error("Error sending event to Kafka: {}", e.getMessage()))
+ .onErrorMap(JsonProcessingException.class,
+ e -> new RuntimeException("Error serializing EventOutbound", e))
+ .then();
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionR2dbcRepository.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionR2dbcRepository.java
new file mode 100644
index 0000000000..5c4f13faa7
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionR2dbcRepository.java
@@ -0,0 +1,9 @@
+package com.yape.transaction.infraestructure.adapter.persistence;
+
+import com.yape.transaction.domain.entities.Transaction;
+import org.springframework.data.repository.reactive.ReactiveCrudRepository;
+import reactor.core.publisher.Mono;
+
+public interface TransactionR2dbcRepository extends ReactiveCrudRepository {
+ Mono findByTransactionExternalId(String transactionExternalId);
+}
diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java
new file mode 100644
index 0000000000..e755f31c60
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java
@@ -0,0 +1,87 @@
+package com.yape.transaction.infraestructure.adapter.persistence;
+
+import com.yape.transaction.domain.entities.Transaction;
+import com.yape.transaction.domain.enums.TransactionStatus;
+import com.yape.transaction.domain.ports.TransactionRepositoryPort;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.OptimisticLockingFailureException;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Repository;
+import org.springframework.web.server.ResponseStatusException;
+import reactor.core.publisher.Mono;
+import reactor.util.retry.Retry;
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+@Repository
+@RequiredArgsConstructor
+public class TransactionRepositoryImpl implements TransactionRepositoryPort {
+
+ private static final Logger logger = LoggerFactory.getLogger(TransactionRepositoryImpl.class);
+ private static final int MAX_RETRIES = 3;
+ private static final Duration RETRY_DELAY = Duration.ofMillis(200);
+ private static final String PENDING_INDEX = "idx_debit_credit_type_pending";
+
+ private final TransactionR2dbcRepository transactionR2dbcRepository;
+
+ @Override
+ public Mono findById(String transactionExternalId) {
+ return transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId);
+ }
+
+ @Override
+ public Mono save(Transaction transaction) {
+ return transactionR2dbcRepository.save(transaction)
+ .retryWhen(Retry.backoff(MAX_RETRIES, RETRY_DELAY)
+ .filter(ex -> ex instanceof OptimisticLockingFailureException)
+ .doBeforeRetry(signal -> logger.warn(
+ "Retrying save for account: {}, attempt: {}",
+ transaction.getAccountExternalIdDebit(), signal.totalRetries() + 1)))
+ .doOnSuccess(t -> logger.info("Transaction saved for account: {}",
+ transaction.getAccountExternalIdDebit()))
+ .onErrorMap(this::isUniqueIndexViolation, ex -> {
+ logger.warn("Pending transaction already exists for debit: {}, credit: {}, type: {}",
+ transaction.getAccountExternalIdDebit(),
+ transaction.getAccountExternalIdCredit(),
+ transaction.getType());
+ return new ResponseStatusException(HttpStatus.CONFLICT,
+ "Ya existe una transacción pendiente para las cuentas debit: "
+ + transaction.getAccountExternalIdDebit()
+ + ", credit: " + transaction.getAccountExternalIdCredit()
+ + ", type: " + transaction.getType());
+ })
+ .doOnError(e -> logger.error("Failed to save for account: {}, error: {}",
+ transaction.getAccountExternalIdDebit(), e.getMessage()));
+ }
+
+ @Override
+ public Mono updateStatus(String transactionExternalId, String status) {
+ return transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId)
+ .filter(transaction -> {
+ boolean isPending = TransactionStatus.PENDING.getValue().equals(transaction.getStatus());
+ if (!isPending) logger.warn("Skipping update for transaction {} because its status is '{}', not PENDING",
+ transactionExternalId, transaction.getStatus());
+ return isPending;
+ })
+ .flatMap(transaction -> {
+ transaction.setStatus(status);
+ transaction.setUpdatedAt(LocalDateTime.now());
+ return transactionR2dbcRepository.save(transaction)
+ .retryWhen(Retry.backoff(MAX_RETRIES, RETRY_DELAY)
+ .filter(ex -> ex instanceof OptimisticLockingFailureException)
+ .doBeforeRetry(signal -> logger.warn(
+ "Retrying updateStatus for account: {}, attempt: {}",
+ transaction.getAccountExternalIdDebit(),
+ signal.totalRetries() + 1)));
+ })
+ .doOnError(e -> logger.error("Failed to update status: {}, error: {}",
+ transactionExternalId, e.getMessage()))
+ .then();
+ }
+
+ private boolean isUniqueIndexViolation(Throwable ex) {
+ return ex.getMessage().contains(PENDING_INDEX);
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/TransactionController.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/TransactionController.java
new file mode 100644
index 0000000000..ddd665de80
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/TransactionController.java
@@ -0,0 +1,38 @@
+package com.yape.transaction.infraestructure.adapter.rest;
+
+import com.yape.transaction.application.dto.CreateTransactionRequest;
+import com.yape.transaction.application.dto.CreateTransactionResponse;
+import com.yape.transaction.application.dto.GetTransactionResponse;
+import com.yape.transaction.application.usecase.CreateTransactionUseCase;
+import com.yape.transaction.application.usecase.GetTransactionUseCase;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Mono;
+
+@RestController
+@RequestMapping("/transactions")
+@RequiredArgsConstructor
+public class TransactionController {
+
+ private static final Logger logger = LoggerFactory.getLogger(TransactionController.class);
+
+ private final CreateTransactionUseCase createTransactionUseCase;
+ private final GetTransactionUseCase getTransactionUseCase;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public Mono createTransaction(@Valid @RequestBody CreateTransactionRequest createTransactionRequest) {
+ logger.info("Creating transaction");
+ return createTransactionUseCase.execute(createTransactionRequest);
+ }
+
+ @GetMapping("/{transactionExternalId}")
+ public Mono getTransaction(@PathVariable String transactionExternalId) {
+ logger.info("Getting transaction with id: {}", transactionExternalId);
+ return getTransactionUseCase.execute(transactionExternalId);
+ }
+}
diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/CustomExceptionHandler.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/CustomExceptionHandler.java
new file mode 100644
index 0000000000..dfd6c35292
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/CustomExceptionHandler.java
@@ -0,0 +1,63 @@
+package com.yape.transaction.infraestructure.adapter.rest.exception;
+
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.bind.support.WebExchangeBindException;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestControllerAdvice
+@Order(-1)
+public class CustomExceptionHandler {
+
+ @ExceptionHandler(WebExchangeBindException.class)
+ public ResponseEntity handleValidationException(WebExchangeBindException ex) {
+ List details = ex.getBindingResult()
+ .getFieldErrors()
+ .stream()
+ .map(error -> "Campo '" + error.getField() + "': " + error.getDefaultMessage())
+ .collect(Collectors.toList());
+
+ ErrorResponse errorResponse = new ErrorResponse();
+ errorResponse.setStatus(HttpStatus.BAD_REQUEST.value());
+ errorResponse.setMessage("Error de validación");
+ errorResponse.setTimestamp(LocalDateTime.now());
+ errorResponse.setDetails(details);
+
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
+ }
+
+ @ExceptionHandler(ResponseStatusException.class)
+ public ResponseEntity handleResponseStatusException(ResponseStatusException ex) {
+ ErrorResponse errorResponse = new ErrorResponse();
+ errorResponse.setStatus(ex.getStatusCode().value());
+ errorResponse.setMessage("Ocurrio un error en la solicitud");
+ errorResponse.setTimestamp(LocalDateTime.now());
+ errorResponse.setDetails(List.of(ex.getReason()));
+
+ return ResponseEntity.status(ex.getStatusCode()).body(errorResponse);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGenericException(Exception ex) {
+ if (ex instanceof WebExchangeBindException bindEx) {
+ return handleValidationException(bindEx);
+ }
+
+ ErrorResponse errorResponse = new ErrorResponse();
+ errorResponse.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
+ errorResponse.setMessage("Error interno del servidor");
+ errorResponse.setTimestamp(LocalDateTime.now());
+ errorResponse.setDetails(List.of(ex.getLocalizedMessage()));
+
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
+ }
+}
+
+
diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/ErrorResponse.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/ErrorResponse.java
new file mode 100644
index 0000000000..f4e4804213
--- /dev/null
+++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/ErrorResponse.java
@@ -0,0 +1,18 @@
+package com.yape.transaction.infraestructure.adapter.rest.exception;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@Setter
+public class ErrorResponse {
+
+ private int status;
+ private String message;
+ private LocalDateTime timestamp;
+ private List details;
+
+}
diff --git a/transaction/src/main/resources/application.properties b/transaction/src/main/resources/application.properties
new file mode 100644
index 0000000000..db681beb9d
--- /dev/null
+++ b/transaction/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+spring.application.name=transaction
+server.port=${SERVER_PORT:8080}
diff --git a/transaction/src/main/resources/application.yml b/transaction/src/main/resources/application.yml
new file mode 100644
index 0000000000..ac1bda6320
--- /dev/null
+++ b/transaction/src/main/resources/application.yml
@@ -0,0 +1,24 @@
+spring:
+ kafka:
+ bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: ${SPRING_KAFKA_CONSUMER_GROUP_ID:transaction-group-id}
+ topics:
+ anti-fraud: ${KAFKA_TOPIC_ANTIFRAUD:anti-fraud-topic}
+ transaction: ${KAFKA_TOPIC_TRANSACTION:transaction-topic}
+
+ sql:
+ init:
+ mode: always
+
+ r2dbc:
+ url: ${SPRING_R2DBC_URL:r2dbc:postgresql://localhost:5432/postgres?schema=public}
+ username: ${SPRING_R2DBC_USERNAME:postgres}
+ password: ${SPRING_R2DBC_PASSWORD:postgres}
+ pool:
+ initial-size: 10
+ max-size: 50
+ max-idle-time: 30m
+ max-acquire-time: 5s
+ validation-query: SELECT 1
+
diff --git a/transaction/src/main/resources/schema.sql b/transaction/src/main/resources/schema.sql
new file mode 100644
index 0000000000..39a6d554e4
--- /dev/null
+++ b/transaction/src/main/resources/schema.sql
@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS public.transactions (
+ id int4 NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+ transaction_external_id varchar(36) NOT NULL,
+ account_external_id_debit varchar(36) NOT NULL,
+ account_external_id_credit varchar(36) NOT NULL,
+ type varchar(10) NOT NULL,
+ status varchar(10) NOT NULL,
+ updated_at timestamp NULL,
+ created_at timestamp NOT NULL,
+ value numeric(10, 2) NOT NULL,
+ version int4 NOT NULL DEFAULT 0,
+ CONSTRAINT transactions_pkey PRIMARY KEY (id)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_transaction_external_id
+ ON transactions(transaction_external_id);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_debit_credit_type_pending
+ ON transactions(account_external_id_debit, account_external_id_credit, type)
+ WHERE status = 'PENDING';
\ No newline at end of file
diff --git a/transaction/src/test/java/com/yape/transaction/application/service/CreateTransactionServiceTest.java b/transaction/src/test/java/com/yape/transaction/application/service/CreateTransactionServiceTest.java
new file mode 100644
index 0000000000..277ae8b61f
--- /dev/null
+++ b/transaction/src/test/java/com/yape/transaction/application/service/CreateTransactionServiceTest.java
@@ -0,0 +1,156 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.application.dto.CreateTransactionRequest;
+import com.yape.transaction.application.dto.CreateTransactionResponse;
+import com.yape.transaction.domain.entities.Transaction;
+import com.yape.transaction.domain.enums.TransactionStatus;
+import com.yape.transaction.domain.model.EventOutbound;
+import com.yape.transaction.domain.ports.KafkaProducerPort;
+import com.yape.transaction.domain.ports.TransactionRepositoryPort;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.server.ResponseStatusException;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class CreateTransactionServiceTest {
+
+ @Mock
+ private TransactionRepositoryPort transactionRepositoryPort;
+
+ @Mock
+ private KafkaProducerPort kafkaProducerPort;
+
+ @InjectMocks
+ private CreateTransactionService createTransactionService;
+
+ private CreateTransactionRequest request;
+ private Transaction savedTransaction;
+
+ @BeforeEach
+ void setUp() {
+ request = new CreateTransactionRequest();
+ request.setAccountExternalIdDebit(UUID.randomUUID().toString());
+ request.setAccountExternalIdCredit(UUID.randomUUID().toString());
+ request.setValue(new BigDecimal("500.00"));
+
+ savedTransaction = Transaction.builder()
+ .id(1L)
+ .transactionExternalId(UUID.randomUUID().toString())
+ .accountExternalIdDebit(request.getAccountExternalIdDebit())
+ .accountExternalIdCredit(request.getAccountExternalIdCredit())
+ .type("DEPOSIT")
+ .status(TransactionStatus.PENDING.getValue())
+ .value(request.getValue())
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .version(0L)
+ .build();
+ }
+
+ @Test
+ @DisplayName("should create transaction and send kafka event when type is DEPOSIT")
+ void execute_shouldCreateTransactionAndSendEvent_whenTypeIsDeposit() {
+ request.setTranferTypeId(1);
+
+ when(transactionRepositoryPort.save(any(Transaction.class))).thenReturn(Mono.just(savedTransaction));
+ when(kafkaProducerPort.sendValidate(any(EventOutbound.class))).thenReturn(Mono.empty());
+
+ StepVerifier.create(createTransactionService.execute(request))
+ .assertNext(response -> {
+ assertThat(response).isNotNull();
+ assertThat(response.getTransactionExternalId())
+ .isEqualTo(savedTransaction.getTransactionExternalId());
+ })
+ .verifyComplete();
+
+ verify(transactionRepositoryPort, times(1)).save(any(Transaction.class));
+ verify(kafkaProducerPort, times(1)).sendValidate(any(EventOutbound.class));
+ }
+
+ @Test
+ @DisplayName("should create transaction and send kafka event when type is WITHDRAWAL")
+ void execute_shouldCreateTransactionAndSendEvent_whenTypeIsWithdrawal() {
+ request.setTranferTypeId(2);
+ savedTransaction.setType("WITHDRAWAL");
+
+ when(transactionRepositoryPort.save(any(Transaction.class))).thenReturn(Mono.just(savedTransaction));
+ when(kafkaProducerPort.sendValidate(any(EventOutbound.class))).thenReturn(Mono.empty());
+
+ StepVerifier.create(createTransactionService.execute(request))
+ .assertNext(response -> assertThat(response.getTransactionExternalId())
+ .isEqualTo(savedTransaction.getTransactionExternalId()))
+ .verifyComplete();
+
+ verify(transactionRepositoryPort, times(1)).save(any(Transaction.class));
+ verify(kafkaProducerPort, times(1)).sendValidate(any(EventOutbound.class));
+ }
+
+ @Test
+ @DisplayName("should throw BAD_REQUEST when tranferTypeId is invalid")
+ void execute_shouldThrowBadRequest_whenTransactionTypeIsInvalid() {
+ request.setTranferTypeId(99);
+
+ StepVerifier.create(createTransactionService.execute(request))
+ .expectErrorMatches(ex ->
+ ex instanceof ResponseStatusException &&
+ ((ResponseStatusException) ex).getStatusCode().value() == 400 &&
+ ex.getMessage().contains("99"))
+ .verify();
+
+ verifyNoInteractions(transactionRepositoryPort);
+ verifyNoInteractions(kafkaProducerPort);
+ }
+
+ @Test
+ @DisplayName("should propagate error when repository save fails")
+ void execute_shouldPropagateError_whenRepositorySaveFails() {
+ request.setTranferTypeId(1);
+
+ when(transactionRepositoryPort.save(any(Transaction.class)))
+ .thenReturn(Mono.error(new RuntimeException("DB connection error")));
+
+ StepVerifier.create(createTransactionService.execute(request))
+ .expectErrorMatches(ex ->
+ ex instanceof RuntimeException &&
+ ex.getMessage().equals("DB connection error"))
+ .verify();
+
+ verify(transactionRepositoryPort, times(1)).save(any(Transaction.class));
+ verifyNoInteractions(kafkaProducerPort);
+ }
+
+ @Test
+ @DisplayName("should propagate error when kafka producer fails")
+ void execute_shouldPropagateError_whenKafkaProducerFails() {
+ request.setTranferTypeId(1);
+
+ when(transactionRepositoryPort.save(any(Transaction.class))).thenReturn(Mono.just(savedTransaction));
+ when(kafkaProducerPort.sendValidate(any(EventOutbound.class)))
+ .thenReturn(Mono.error(new RuntimeException("Kafka unavailable")));
+
+ StepVerifier.create(createTransactionService.execute(request))
+ .expectErrorMatches(ex ->
+ ex instanceof RuntimeException &&
+ ex.getMessage().equals("Kafka unavailable"))
+ .verify();
+
+ verify(transactionRepositoryPort, times(1)).save(any(Transaction.class));
+ verify(kafkaProducerPort, times(1)).sendValidate(any(EventOutbound.class));
+ }
+}
+
diff --git a/transaction/src/test/java/com/yape/transaction/application/service/GetTransactionServiceTest.java b/transaction/src/test/java/com/yape/transaction/application/service/GetTransactionServiceTest.java
new file mode 100644
index 0000000000..e924fe9fe0
--- /dev/null
+++ b/transaction/src/test/java/com/yape/transaction/application/service/GetTransactionServiceTest.java
@@ -0,0 +1,104 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.application.dto.GetTransactionResponse;
+import com.yape.transaction.domain.entities.Transaction;
+import com.yape.transaction.domain.enums.TransactionStatus;
+import com.yape.transaction.domain.ports.TransactionRepositoryPort;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.server.ResponseStatusException;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class GetTransactionServiceTest {
+
+ @Mock
+ private TransactionRepositoryPort transactionRepositoryPort;
+
+ @InjectMocks
+ private GetTransactionService getTransactionService;
+
+ private String transactionExternalId;
+ private Transaction transaction;
+
+ @BeforeEach
+ void setUp() {
+ transactionExternalId = UUID.randomUUID().toString();
+
+ transaction = Transaction.builder()
+ .id(1L)
+ .transactionExternalId(transactionExternalId)
+ .accountExternalIdDebit(UUID.randomUUID().toString())
+ .accountExternalIdCredit(UUID.randomUUID().toString())
+ .type("DEPOSIT")
+ .status(TransactionStatus.PENDING.getValue())
+ .value(new BigDecimal("500.00"))
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .version(0L)
+ .build();
+ }
+
+ @Test
+ @DisplayName("should return transaction when it exists")
+ void execute_shouldReturnTransaction_whenExists() {
+ when(transactionRepositoryPort.findById(transactionExternalId)).thenReturn(Mono.just(transaction));
+
+ StepVerifier.create(getTransactionService.execute(transactionExternalId))
+ .assertNext(response -> {
+ assertThat(response).isNotNull();
+ assertThat(response.getTransactionExternalId()).isEqualTo(transactionExternalId);
+ assertThat(response.getValue()).isEqualTo(transaction.getValue());
+ assertThat(response.getTransactionStatus().getName()).isEqualTo(TransactionStatus.PENDING.getValue());
+ assertThat(response.getTransactionType().getName()).isEqualTo("DEPOSIT");
+ assertThat(response.getCreatedAt()).isEqualTo(transaction.getCreatedAt());
+ })
+ .verifyComplete();
+
+ verify(transactionRepositoryPort, times(1)).findById(transactionExternalId);
+ }
+
+ @Test
+ @DisplayName("should throw 404 NOT_FOUND when transaction does not exist")
+ void execute_shouldThrowNotFound_whenTransactionDoesNotExist() {
+ when(transactionRepositoryPort.findById(transactionExternalId)).thenReturn(Mono.empty());
+
+ StepVerifier.create(getTransactionService.execute(transactionExternalId))
+ .expectErrorMatches(ex ->
+ ex instanceof ResponseStatusException &&
+ ((ResponseStatusException) ex).getStatusCode().value() == 404 &&
+ ex.getMessage().contains(transactionExternalId))
+ .verify();
+
+ verify(transactionRepositoryPort, times(1)).findById(transactionExternalId);
+ }
+
+ @Test
+ @DisplayName("should propagate error when repository fails")
+ void execute_shouldPropagateError_whenRepositoryFails() {
+ when(transactionRepositoryPort.findById(transactionExternalId))
+ .thenReturn(Mono.error(new RuntimeException("DB connection error")));
+
+ StepVerifier.create(getTransactionService.execute(transactionExternalId))
+ .expectErrorMatches(ex ->
+ ex instanceof RuntimeException &&
+ ex.getMessage().equals("DB connection error"))
+ .verify();
+
+ verify(transactionRepositoryPort, times(1)).findById(transactionExternalId);
+ }
+}
+
diff --git a/transaction/src/test/java/com/yape/transaction/application/service/UpdateTransactionServiceTest.java b/transaction/src/test/java/com/yape/transaction/application/service/UpdateTransactionServiceTest.java
new file mode 100644
index 0000000000..5e9b2463f8
--- /dev/null
+++ b/transaction/src/test/java/com/yape/transaction/application/service/UpdateTransactionServiceTest.java
@@ -0,0 +1,83 @@
+package com.yape.transaction.application.service;
+
+import com.yape.transaction.domain.model.EventInbound;
+import com.yape.transaction.domain.ports.TransactionRepositoryPort;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class UpdateTransactionServiceTest {
+
+ @Mock
+ private TransactionRepositoryPort transactionRepositoryPort;
+
+ @InjectMocks
+ private UpdateTransactionService updateTransactionService;
+
+ private EventInbound message;
+
+ @BeforeEach
+ void setUp() {
+ message = new EventInbound();
+ message.setTransactionExternalId(UUID.randomUUID().toString());
+ message.setValue(new BigDecimal("500.00"));
+ message.setStatus("APPROVED");
+ }
+
+ @Test
+ @DisplayName("should update status successfully when repository completes")
+ void execute_shouldUpdateStatus_whenRepositoryCompletes() {
+ when(transactionRepositoryPort.updateStatus(message.getTransactionExternalId(), message.getStatus()))
+ .thenReturn(Mono.empty());
+
+ StepVerifier.create(updateTransactionService.execute(message))
+ .verifyComplete();
+
+ verify(transactionRepositoryPort, times(1))
+ .updateStatus(message.getTransactionExternalId(), message.getStatus());
+ }
+
+ @Test
+ @DisplayName("should complete without error when transaction is not PENDING (no-op from repository)")
+ void execute_shouldCompleteWithoutError_whenTransactionIsNotPending() {
+ message.setStatus("REJECTED");
+
+ when(transactionRepositoryPort.updateStatus(message.getTransactionExternalId(), message.getStatus()))
+ .thenReturn(Mono.empty());
+
+ StepVerifier.create(updateTransactionService.execute(message))
+ .verifyComplete();
+
+ verify(transactionRepositoryPort, times(1))
+ .updateStatus(message.getTransactionExternalId(), message.getStatus());
+ }
+
+ @Test
+ @DisplayName("should propagate error when repository fails")
+ void execute_shouldPropagateError_whenRepositoryFails() {
+ when(transactionRepositoryPort.updateStatus(message.getTransactionExternalId(), message.getStatus()))
+ .thenReturn(Mono.error(new RuntimeException("DB connection error")));
+
+ StepVerifier.create(updateTransactionService.execute(message))
+ .expectErrorMatches(ex ->
+ ex instanceof RuntimeException &&
+ ex.getMessage().equals("DB connection error"))
+ .verify();
+
+ verify(transactionRepositoryPort, times(1))
+ .updateStatus(message.getTransactionExternalId(), message.getStatus());
+ }
+}
+
diff --git a/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImplTest.java b/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImplTest.java
new file mode 100644
index 0000000000..8c45861b39
--- /dev/null
+++ b/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImplTest.java
@@ -0,0 +1,201 @@
+package com.yape.transaction.infraestructure.adapter.persistence;
+
+import com.yape.transaction.domain.entities.Transaction;
+import com.yape.transaction.domain.enums.TransactionStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.server.ResponseStatusException;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class TransactionRepositoryImplTest {
+
+ @Mock
+ private TransactionR2dbcRepository transactionR2dbcRepository;
+
+ @InjectMocks
+ private TransactionRepositoryImpl transactionRepositoryImpl;
+
+ private String transactionExternalId;
+ private Transaction pendingTransaction;
+ private Transaction approvedTransaction;
+
+ @BeforeEach
+ void setUp() {
+ transactionExternalId = UUID.randomUUID().toString();
+
+ pendingTransaction = Transaction.builder()
+ .id(1L)
+ .transactionExternalId(transactionExternalId)
+ .accountExternalIdDebit(UUID.randomUUID().toString())
+ .accountExternalIdCredit(UUID.randomUUID().toString())
+ .type("DEPOSIT")
+ .status(TransactionStatus.PENDING.getValue())
+ .value(new BigDecimal("500.00"))
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .version(0L)
+ .build();
+
+ approvedTransaction = Transaction.builder()
+ .id(2L)
+ .transactionExternalId(transactionExternalId)
+ .accountExternalIdDebit(UUID.randomUUID().toString())
+ .accountExternalIdCredit(UUID.randomUUID().toString())
+ .type("DEPOSIT")
+ .status("APPROVED")
+ .value(new BigDecimal("500.00"))
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .version(1L)
+ .build();
+ }
+
+ @Test
+ @DisplayName("findById - should return transaction when it exists")
+ void findById_shouldReturnTransaction_whenExists() {
+ when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId))
+ .thenReturn(Mono.just(pendingTransaction));
+
+ StepVerifier.create(transactionRepositoryImpl.findById(transactionExternalId))
+ .assertNext(t -> assertThat(t.getTransactionExternalId()).isEqualTo(transactionExternalId))
+ .verifyComplete();
+
+ verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId);
+ }
+
+ @Test
+ @DisplayName("findById - should return empty when transaction does not exist")
+ void findById_shouldReturnEmpty_whenNotExists() {
+ when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId))
+ .thenReturn(Mono.empty());
+
+ StepVerifier.create(transactionRepositoryImpl.findById(transactionExternalId))
+ .verifyComplete();
+
+ verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId);
+ }
+
+
+ @Test
+ @DisplayName("save - should save transaction successfully")
+ void save_shouldSaveTransaction_successfully() {
+ when(transactionR2dbcRepository.save(pendingTransaction)).thenReturn(Mono.just(pendingTransaction));
+
+ StepVerifier.create(transactionRepositoryImpl.save(pendingTransaction))
+ .assertNext(t -> assertThat(t.getTransactionExternalId()).isEqualTo(transactionExternalId))
+ .verifyComplete();
+
+ verify(transactionR2dbcRepository, times(1)).save(pendingTransaction);
+ }
+
+ @Test
+ @DisplayName("save - should throw 409 CONFLICT when unique index violation occurs")
+ void save_shouldThrowConflict_whenUniqueIndexViolation() {
+ when(transactionR2dbcRepository.save(pendingTransaction))
+ .thenReturn(Mono.error(new RuntimeException("idx_debit_credit_type_pending")));
+
+ StepVerifier.create(transactionRepositoryImpl.save(pendingTransaction))
+ .expectErrorMatches(ex ->
+ ex instanceof ResponseStatusException &&
+ ((ResponseStatusException) ex).getStatusCode().value() == 409)
+ .verify();
+ }
+
+
+ @Test
+ @DisplayName("save - should propagate error when repository fails with non-retriable error")
+ void save_shouldPropagateError_whenNonRetriableError() {
+ when(transactionR2dbcRepository.save(pendingTransaction))
+ .thenReturn(Mono.error(new RuntimeException("Unexpected DB error")));
+
+ StepVerifier.create(transactionRepositoryImpl.save(pendingTransaction))
+ .expectErrorMatches(ex -> ex instanceof RuntimeException)
+ .verify();
+ }
+
+
+ @Test
+ @DisplayName("updateStatus - should update status when transaction is PENDING")
+ void updateStatus_shouldUpdate_whenTransactionIsPending() {
+ Transaction updatedTransaction = Transaction.builder()
+ .id(pendingTransaction.getId())
+ .transactionExternalId(transactionExternalId)
+ .accountExternalIdDebit(pendingTransaction.getAccountExternalIdDebit())
+ .accountExternalIdCredit(pendingTransaction.getAccountExternalIdCredit())
+ .type(pendingTransaction.getType())
+ .status("APPROVED")
+ .value(pendingTransaction.getValue())
+ .createdAt(pendingTransaction.getCreatedAt())
+ .updatedAt(LocalDateTime.now())
+ .version(1L)
+ .build();
+
+ when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId))
+ .thenReturn(Mono.just(pendingTransaction));
+ when(transactionR2dbcRepository.save(any(Transaction.class))).thenReturn(Mono.just(updatedTransaction));
+
+ StepVerifier.create(transactionRepositoryImpl.updateStatus(transactionExternalId, "APPROVED"))
+ .verifyComplete();
+
+ verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId);
+ verify(transactionR2dbcRepository, times(1)).save(any(Transaction.class));
+ }
+
+ @Test
+ @DisplayName("updateStatus - should do nothing when transaction is not PENDING")
+ void updateStatus_shouldDoNothing_whenTransactionIsNotPending() {
+ when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId))
+ .thenReturn(Mono.just(approvedTransaction));
+
+ StepVerifier.create(transactionRepositoryImpl.updateStatus(transactionExternalId, "REJECTED"))
+ .verifyComplete();
+
+ verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId);
+ verify(transactionR2dbcRepository, never()).save(any(Transaction.class));
+ }
+
+ @Test
+ @DisplayName("updateStatus - should complete when transaction does not exist")
+ void updateStatus_shouldComplete_whenTransactionNotFound() {
+ when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId))
+ .thenReturn(Mono.empty());
+
+ StepVerifier.create(transactionRepositoryImpl.updateStatus(transactionExternalId, "APPROVED"))
+ .verifyComplete();
+
+ verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId);
+ verify(transactionR2dbcRepository, never()).save(any(Transaction.class));
+ }
+
+
+ @Test
+ @DisplayName("updateStatus - should propagate error when repository save fails")
+ void updateStatus_shouldPropagateError_whenSaveFails() {
+ when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId))
+ .thenReturn(Mono.just(pendingTransaction));
+ when(transactionR2dbcRepository.save(any(Transaction.class)))
+ .thenReturn(Mono.error(new RuntimeException("DB connection error")));
+
+ StepVerifier.create(transactionRepositoryImpl.updateStatus(transactionExternalId, "APPROVED"))
+ .expectErrorMatches(ex ->
+ ex instanceof RuntimeException &&
+ ex.getMessage().equals("DB connection error"))
+ .verify();
+ }
+}
+
diff --git a/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/rest/TransactionControllerTest.java b/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/rest/TransactionControllerTest.java
new file mode 100644
index 0000000000..da853e539d
--- /dev/null
+++ b/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/rest/TransactionControllerTest.java
@@ -0,0 +1,169 @@
+package com.yape.transaction.infraestructure.adapter.rest;
+
+import com.yape.transaction.application.dto.BaseTransaction;
+import com.yape.transaction.application.dto.CreateTransactionRequest;
+import com.yape.transaction.application.dto.CreateTransactionResponse;
+import com.yape.transaction.application.dto.GetTransactionResponse;
+import com.yape.transaction.application.usecase.CreateTransactionUseCase;
+import com.yape.transaction.application.usecase.GetTransactionUseCase;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.server.ResponseStatusException;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class TransactionControllerTest {
+
+ @Mock
+ private CreateTransactionUseCase createTransactionUseCase;
+
+ @Mock
+ private GetTransactionUseCase getTransactionUseCase;
+
+ @InjectMocks
+ private TransactionController transactionController;
+
+ private CreateTransactionRequest createRequest;
+ private CreateTransactionResponse createResponse;
+ private GetTransactionResponse getResponse;
+ private String transactionExternalId;
+
+ @BeforeEach
+ void setUp() {
+ transactionExternalId = UUID.randomUUID().toString();
+
+ createRequest = new CreateTransactionRequest();
+ createRequest.setAccountExternalIdDebit(UUID.randomUUID().toString());
+ createRequest.setAccountExternalIdCredit(UUID.randomUUID().toString());
+ createRequest.setTranferTypeId(1);
+ createRequest.setValue(new BigDecimal("500.00"));
+
+ createResponse = CreateTransactionResponse.builder()
+ .transactionExternalId(transactionExternalId)
+ .build();
+
+ getResponse = GetTransactionResponse.builder()
+ .transactionExternalId(transactionExternalId)
+ .transactionType(new BaseTransaction("DEPOSIT"))
+ .transactionStatus(new BaseTransaction("PENDING"))
+ .value(new BigDecimal("500.00"))
+ .createdAt(LocalDateTime.now())
+ .build();
+ }
+
+
+ @Test
+ @DisplayName("createTransaction - should return transactionExternalId when created successfully")
+ void createTransaction_shouldReturnResponse_whenCreatedSuccessfully() {
+ when(createTransactionUseCase.execute(any(CreateTransactionRequest.class)))
+ .thenReturn(Mono.just(createResponse));
+
+ StepVerifier.create(transactionController.createTransaction(createRequest))
+ .assertNext(response -> {
+ assertThat(response).isNotNull();
+ assertThat(response.getTransactionExternalId()).isEqualTo(transactionExternalId);
+ })
+ .verifyComplete();
+
+ verify(createTransactionUseCase, times(1)).execute(createRequest);
+ }
+
+ @Test
+ @DisplayName("createTransaction - should propagate error when use case fails")
+ void createTransaction_shouldPropagateError_whenUseCaseFails() {
+ when(createTransactionUseCase.execute(any(CreateTransactionRequest.class)))
+ .thenReturn(Mono.error(new RuntimeException("Unexpected error")));
+
+ StepVerifier.create(transactionController.createTransaction(createRequest))
+ .expectErrorMatches(ex ->
+ ex instanceof RuntimeException &&
+ ex.getMessage().equals("Unexpected error"))
+ .verify();
+
+ verify(createTransactionUseCase, times(1)).execute(createRequest);
+ }
+
+ @Test
+ @DisplayName("createTransaction - should propagate 400 when transaction type is invalid")
+ void createTransaction_shouldPropagate400_whenTransactionTypeIsInvalid() {
+ when(createTransactionUseCase.execute(any(CreateTransactionRequest.class)))
+ .thenReturn(Mono.error(new ResponseStatusException(
+ org.springframework.http.HttpStatus.BAD_REQUEST, "Invalid transaction type")));
+
+ StepVerifier.create(transactionController.createTransaction(createRequest))
+ .expectErrorMatches(ex ->
+ ex instanceof ResponseStatusException &&
+ ((ResponseStatusException) ex).getStatusCode().value() == 400)
+ .verify();
+
+ verify(createTransactionUseCase, times(1)).execute(createRequest);
+ }
+
+
+ @Test
+ @DisplayName("getTransaction - should return transaction when it exists")
+ void getTransaction_shouldReturnTransaction_whenExists() {
+ when(getTransactionUseCase.execute(transactionExternalId))
+ .thenReturn(Mono.just(getResponse));
+
+ StepVerifier.create(transactionController.getTransaction(transactionExternalId))
+ .assertNext(response -> {
+ assertThat(response).isNotNull();
+ assertThat(response.getTransactionExternalId()).isEqualTo(transactionExternalId);
+ assertThat(response.getTransactionType().getName()).isEqualTo("DEPOSIT");
+ assertThat(response.getTransactionStatus().getName()).isEqualTo("PENDING");
+ assertThat(response.getValue()).isEqualTo(new BigDecimal("500.00"));
+ })
+ .verifyComplete();
+
+ verify(getTransactionUseCase, times(1)).execute(transactionExternalId);
+ }
+
+ @Test
+ @DisplayName("getTransaction - should propagate 404 when transaction does not exist")
+ void getTransaction_shouldPropagate404_whenTransactionDoesNotExist() {
+ when(getTransactionUseCase.execute(transactionExternalId))
+ .thenReturn(Mono.error(new ResponseStatusException(
+ org.springframework.http.HttpStatus.NOT_FOUND,
+ "Transaction with ID: " + transactionExternalId + " not found")));
+
+ StepVerifier.create(transactionController.getTransaction(transactionExternalId))
+ .expectErrorMatches(ex ->
+ ex instanceof ResponseStatusException &&
+ ((ResponseStatusException) ex).getStatusCode().value() == 404 &&
+ ex.getMessage().contains(transactionExternalId))
+ .verify();
+
+ verify(getTransactionUseCase, times(1)).execute(transactionExternalId);
+ }
+
+ @Test
+ @DisplayName("getTransaction - should propagate error when use case fails")
+ void getTransaction_shouldPropagateError_whenUseCaseFails() {
+ when(getTransactionUseCase.execute(transactionExternalId))
+ .thenReturn(Mono.error(new RuntimeException("DB connection error")));
+
+ StepVerifier.create(transactionController.getTransaction(transactionExternalId))
+ .expectErrorMatches(ex ->
+ ex instanceof RuntimeException &&
+ ex.getMessage().equals("DB connection error"))
+ .verify();
+
+ verify(getTransactionUseCase, times(1)).execute(transactionExternalId);
+ }
+}
+
diff --git a/yape.postman_collection.json b/yape.postman_collection.json
new file mode 100644
index 0000000000..ed62acd7bb
--- /dev/null
+++ b/yape.postman_collection.json
@@ -0,0 +1,64 @@
+{
+ "info": {
+ "_postman_id": "4cb6abaa-c625-464a-ae46-50ecbb44d82a",
+ "name": "yape",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
+ "_exporter_id": "32644997"
+ },
+ "item": [
+ {
+ "name": "create transaction",
+ "request": {
+ "method": "POST",
+ "header": [],
+ "body": {
+ "mode": "raw",
+ "raw": "{\r\n \"accountExternalIdDebit\": \"6085462b-f1da-48a0-8f11-72ee428b2a32\",\r\n \"accountExternalIdCredit\": \"1cb43a09-01a7-4019-b683-589b6b00ea58\",\r\n \"tranferTypeId\":1,\r\n \"value\": 900\r\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "http://localhost:8080/transactions",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "transactions"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "get transaction",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "http://localhost:8080/transactions/:transactionExternalId",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "transactions",
+ ":transactionExternalId"
+ ],
+ "variable": [
+ {
+ "key": "transactionExternalId",
+ "value": "47f62081-95c7-483b-a000-91c0e391f696"
+ }
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+}
\ No newline at end of file