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..6e58a12b3c 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ Every transaction with a value greater than 1000 should be rejected.
# Tech Stack
- - Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
+ - Java. You can use any framework you want (i.e. spring web flux with an ORM like spring jpa o r2db2)
- Any database
- Kafka
diff --git a/anti-fraud-services/.gitattributes b/anti-fraud-services/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/anti-fraud-services/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/anti-fraud-services/.gitignore b/anti-fraud-services/.gitignore
new file mode 100644
index 0000000000..316caa599f
--- /dev/null
+++ b/anti-fraud-services/.gitignore
@@ -0,0 +1,36 @@
+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/
+
+# Any autogenerated Avro code
+**/kafka/avro/
diff --git a/anti-fraud-services/.mvn/wrapper/maven-wrapper.properties b/anti-fraud-services/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000..c0bcafe984
--- /dev/null
+++ b/anti-fraud-services/.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.11/apache-maven-3.9.11-bin.zip
diff --git a/anti-fraud-services/Dockerfile b/anti-fraud-services/Dockerfile
new file mode 100644
index 0000000000..17a20ea92d
--- /dev/null
+++ b/anti-fraud-services/Dockerfile
@@ -0,0 +1,15 @@
+FROM ubuntu:22.04
+
+RUN apt-get update && \
+ apt-get install -y openjdk-17-jdk openjdk-17-jre maven && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /transaction-services
+
+COPY pom.xml .
+COPY src ./src
+
+RUN mvn clean package -DskipTests
+
+CMD ["java", "-jar", "target/anti-fraud-services-0.0.1-SNAPSHOT.jar"]
\ No newline at end of file
diff --git a/anti-fraud-services/mvnw b/anti-fraud-services/mvnw
new file mode 100644
index 0000000000..bd8896bf22
--- /dev/null
+++ b/anti-fraud-services/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/anti-fraud-services/mvnw.cmd b/anti-fraud-services/mvnw.cmd
new file mode 100644
index 0000000000..92450f9327
--- /dev/null
+++ b/anti-fraud-services/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/anti-fraud-services/pom.xml b/anti-fraud-services/pom.xml
new file mode 100644
index 0000000000..d5e224e666
--- /dev/null
+++ b/anti-fraud-services/pom.xml
@@ -0,0 +1,166 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.0.0
+
+
+ com.yape.challenge
+ anti-fraud-services
+ 0.0.1-SNAPSHOT
+ anti-fraud-services
+ Demo project for Spring Boot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 17
+ 2025.1.0
+ 1.11.4
+ 7.5.0
+ 1.5.5.Final
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-kafka
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.projectlombok
+ lombok
+ 1.18.28
+ provided
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-kafka-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux-test
+ test
+
+
+ org.apache.avro
+ avro
+ ${avro.version}
+
+
+ io.confluent
+ kafka-avro-serializer
+ ${confluent.version}
+
+
+ org.mapstruct
+ mapstruct
+ ${map-struct.version}
+
+
+
+
+ central
+ https://repo.maven.apache.org/maven2
+
+
+ confluent
+ https://packages.confluent.io/maven/
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.28
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${map-struct.version}
+
+
+ true
+
+ -Amapstruct.suppressGeneratorTimestamp=true
+ -Amapstruct.defaultComponentModel=spring
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.apache.avro
+ avro-maven-plugin
+ ${avro.version}
+
+
+ generate-sources
+
+ schema
+
+
+ ${project.basedir}/src/main/resources/avro
+ ${project.basedir}/src/main/java
+
+
+
+
+
+
+
+
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/AntiFraudServicesApplication.java b/anti-fraud-services/src/main/java/com/yape/challenge/AntiFraudServicesApplication.java
new file mode 100644
index 0000000000..dc24a6046d
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/AntiFraudServicesApplication.java
@@ -0,0 +1,13 @@
+package com.yape.challenge;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class AntiFraudServicesApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AntiFraudServicesApplication.class, args);
+ }
+
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/adapter/in/kafka/AntiFraudConsumer.java b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/in/kafka/AntiFraudConsumer.java
new file mode 100644
index 0000000000..eb6a4b0430
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/in/kafka/AntiFraudConsumer.java
@@ -0,0 +1,32 @@
+package com.yape.challenge.adapter.in.kafka;
+
+import com.pe.yape.service.challenge.kafka.avro.CreatedTransactionMessageEvent;
+import com.yape.challenge.domain.port.in.AntiFraudUseCase;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class AntiFraudConsumer {
+
+ private final AntiFraudUseCase antiFraudUseCase;
+
+ @KafkaListener(
+ topics = "${spring.kafka.consumer.topic}",
+ groupId = "${spring.kafka.consumer.group-id}",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ public void consume(CreatedTransactionMessageEvent event) {
+ log.info("Mensaje Avro recibido para validación antifraude");
+ antiFraudUseCase.validate(event)
+ .doOnError(e -> log.error("Error validando transacción", e))
+ .subscribe();
+ }
+
+}
+
+
+
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/adapter/in/kafka/config/KafkaConsumerConfiguration.java b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/in/kafka/config/KafkaConsumerConfiguration.java
new file mode 100644
index 0000000000..f579558e36
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/in/kafka/config/KafkaConsumerConfiguration.java
@@ -0,0 +1,50 @@
+package com.yape.challenge.adapter.in.kafka.config;
+
+import com.pe.yape.service.challenge.kafka.avro.CreatedTransactionMessageEvent;
+import io.confluent.kafka.serializers.KafkaAvroDeserializer;
+import io.confluent.kafka.serializers.KafkaAvroDeserializerConfig;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.boot.kafka.autoconfigure.KafkaProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.listener.ContainerProperties;
+import java.util.Map;
+
+@Configuration
+@EnableKafka
+public class KafkaConsumerConfiguration {
+
+ @Bean
+ public ConsumerFactory consumerFactory(
+ KafkaProperties kafkaProperties
+ ) {
+ Map props = kafkaProperties.buildConsumerProperties();
+
+ props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, KafkaAvroDeserializer.class);
+ props.put(KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG, "http://localhost:8081");
+ props.put(KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG, true);
+ props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+
+ return new DefaultKafkaConsumerFactory<>(props);
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory
+ kafkaListenerContainerFactory(ConsumerFactory consumerFactory) {
+
+ ConcurrentKafkaListenerContainerFactory factory =
+ new ConcurrentKafkaListenerContainerFactory<>();
+
+ factory.setConsumerFactory(consumerFactory);
+ factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
+
+ return factory;
+ }
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/AntiFraudMessagingAdapter.java b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/AntiFraudMessagingAdapter.java
new file mode 100644
index 0000000000..b8b314c34d
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/AntiFraudMessagingAdapter.java
@@ -0,0 +1,22 @@
+package com.yape.challenge.adapter.out;
+
+import com.yape.challenge.adapter.out.kafka.AntiFraudProducer;
+import com.yape.challenge.adapter.out.mapper.AntiFraudMessageMapper;
+import com.yape.challenge.domain.model.ValidateTransactionEvent;
+import com.yape.challenge.domain.port.out.AntiFraudProducerPort;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+@Component
+@RequiredArgsConstructor
+public class AntiFraudMessagingAdapter implements AntiFraudProducerPort {
+ private final AntiFraudProducer kafkaProducerMessage;
+ private final AntiFraudMessageMapper antiFraudMessageMapper;
+
+ @Override
+ public Mono sendValidationResult(ValidateTransactionEvent status) {
+ return Mono.fromRunnable(() -> kafkaProducerMessage
+ .sendTransactionEvent(antiFraudMessageMapper.toValidate(status)));
+ }
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/kafka/AntiFraudProducer.java b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/kafka/AntiFraudProducer.java
new file mode 100644
index 0000000000..aaef69d535
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/kafka/AntiFraudProducer.java
@@ -0,0 +1,42 @@
+package com.yape.challenge.adapter.out.kafka;
+
+import com.pe.yape.service.challenge.kafka.avro.ValidateTransactionMessageEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+
+@Configuration
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class AntiFraudProducer {
+
+ private final KafkaTemplate kafkaTemplate;
+
+ @Value("${spring.kafka.producer.topic}")
+ private String topic;
+
+ public void sendTransactionEvent(ValidateTransactionMessageEvent event) {
+
+ log.info("Enviando evento Avro a Kafka - TransactionId: {}", event.getTransactionId());
+ kafkaTemplate
+ .send(topic, event.getTransactionId().toString(), event)
+ .whenComplete((result, ex) -> {
+ if (ex != null) {
+ log.error("Error enviando evento Avro a Kafka", ex);
+ } else {
+ log.info(
+ "Evento Avro enviado - Topic: {}, Partition: {}, Offset: {}, Key: {}",
+ result.getRecordMetadata().topic(),
+ result.getRecordMetadata().partition(),
+ result.getRecordMetadata().offset(),
+ event.getTransactionId()
+ );
+ }
+ });
+ }
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/kafka/config/KafkaProducerConfiguration.java b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/kafka/config/KafkaProducerConfiguration.java
new file mode 100644
index 0000000000..e088732432
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/kafka/config/KafkaProducerConfiguration.java
@@ -0,0 +1,52 @@
+package com.yape.challenge.adapter.out.kafka.config;
+
+import com.pe.yape.service.challenge.kafka.avro.ValidateTransactionMessageEvent;
+import io.confluent.kafka.serializers.KafkaAvroSerializer;
+import io.confluent.kafka.serializers.KafkaAvroSerializerConfig;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.DefaultKafkaProducerFactory;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.core.ProducerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Getter
+@Setter
+@Configuration
+@ConfigurationProperties(prefix = "spring.kafka.producer")
+public class KafkaProducerConfiguration {
+
+ private String bootstrapServers;
+
+ @Bean
+ public ProducerFactory producerFactory() {
+
+ 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, KafkaAvroSerializer.class);
+
+ props.put(KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG, "http://localhost:8081");
+
+ props.put(ProducerConfig.ACKS_CONFIG, "all");
+ props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
+ props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
+ props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
+
+ return new DefaultKafkaProducerFactory<>(props);
+ }
+
+ @Bean
+ public KafkaTemplate kafkaTemplate(
+ ProducerFactory producerFactory) {
+ return new KafkaTemplate<>(producerFactory);
+ }
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/mapper/AntiFraudMessageMapper.java b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/mapper/AntiFraudMessageMapper.java
new file mode 100644
index 0000000000..d0dda02e47
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/adapter/out/mapper/AntiFraudMessageMapper.java
@@ -0,0 +1,16 @@
+package com.yape.challenge.adapter.out.mapper;
+
+import com.pe.yape.service.challenge.kafka.avro.ValidateTransactionMessageEvent;
+import com.yape.challenge.domain.model.ValidateTransactionEvent;
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.NullValueCheckStrategy;
+import org.mapstruct.NullValuePropertyMappingStrategy;
+
+@Mapper(componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
+ nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
+public interface AntiFraudMessageMapper {
+ ValidateTransactionMessageEvent toValidate(ValidateTransactionEvent validateTransactionEvent);
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/CreateTransactionEvent.java b/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/CreateTransactionEvent.java
new file mode 100644
index 0000000000..963d8a0ec3
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/CreateTransactionEvent.java
@@ -0,0 +1,16 @@
+package com.yape.challenge.domain.model;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Builder
+public class CreateTransactionEvent {
+ private String transactionId;
+ private String accountExternalIdDebit;
+ private String accountExternalIdCredit;
+ private int transferTypeId;
+ private Double value;
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/Status.java b/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/Status.java
new file mode 100644
index 0000000000..dc946da0f5
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/Status.java
@@ -0,0 +1,13 @@
+package com.yape.challenge.domain.model;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum Status {
+ APPROVED(1, "APPROVED"),
+ REJECTED(2, "REJECTED");
+ private final int code;
+ private final String description;
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/ValidateTransactionEvent.java b/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/ValidateTransactionEvent.java
new file mode 100644
index 0000000000..33bf2d1c3e
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/domain/model/ValidateTransactionEvent.java
@@ -0,0 +1,17 @@
+package com.yape.challenge.domain.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Builder
+@RequiredArgsConstructor
+@AllArgsConstructor
+public class ValidateTransactionEvent {
+ private String transactionId;
+ private String status;
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/domain/port/in/AntiFraudUseCase.java b/anti-fraud-services/src/main/java/com/yape/challenge/domain/port/in/AntiFraudUseCase.java
new file mode 100644
index 0000000000..22a1641648
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/domain/port/in/AntiFraudUseCase.java
@@ -0,0 +1,9 @@
+package com.yape.challenge.domain.port.in;
+
+
+import com.pe.yape.service.challenge.kafka.avro.CreatedTransactionMessageEvent;
+import reactor.core.publisher.Mono;
+
+public interface AntiFraudUseCase {
+ Mono validate(CreatedTransactionMessageEvent transaction);
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/domain/port/out/AntiFraudProducerPort.java b/anti-fraud-services/src/main/java/com/yape/challenge/domain/port/out/AntiFraudProducerPort.java
new file mode 100644
index 0000000000..59a55f79f7
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/domain/port/out/AntiFraudProducerPort.java
@@ -0,0 +1,8 @@
+package com.yape.challenge.domain.port.out;
+
+import com.yape.challenge.domain.model.ValidateTransactionEvent;
+import reactor.core.publisher.Mono;
+
+public interface AntiFraudProducerPort {
+ Mono sendValidationResult(ValidateTransactionEvent status);
+}
diff --git a/anti-fraud-services/src/main/java/com/yape/challenge/domain/service/AntiFraudService.java b/anti-fraud-services/src/main/java/com/yape/challenge/domain/service/AntiFraudService.java
new file mode 100644
index 0000000000..503b4fca12
--- /dev/null
+++ b/anti-fraud-services/src/main/java/com/yape/challenge/domain/service/AntiFraudService.java
@@ -0,0 +1,33 @@
+package com.yape.challenge.domain.service;
+
+import com.pe.yape.service.challenge.kafka.avro.CreatedTransactionMessageEvent;
+import com.yape.challenge.domain.model.Status;
+import com.yape.challenge.domain.model.ValidateTransactionEvent;
+import com.yape.challenge.domain.port.in.AntiFraudUseCase;
+import com.yape.challenge.domain.port.out.AntiFraudProducerPort;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AntiFraudService implements AntiFraudUseCase {
+
+ private final AntiFraudProducerPort producerPort;
+
+ @Override
+ public Mono validate(CreatedTransactionMessageEvent transaction) {
+ log.info("Validating transaction with id: {}", transaction.getTransactionId());
+ ValidateTransactionEvent validateTransactionEvent = new ValidateTransactionEvent();
+ validateTransactionEvent.setTransactionId(transaction.getTransactionId().toString());
+ if (transaction.getValue() <= 1000) {
+ validateTransactionEvent.setStatus(Status.APPROVED.getDescription());
+ } else {
+ validateTransactionEvent.setStatus(Status.REJECTED.getDescription());
+ }
+
+ return this.producerPort.sendValidationResult(validateTransactionEvent);
+ }
+}
diff --git a/anti-fraud-services/src/main/resources/application.yaml b/anti-fraud-services/src/main/resources/application.yaml
new file mode 100644
index 0000000000..c2d8791aa4
--- /dev/null
+++ b/anti-fraud-services/src/main/resources/application.yaml
@@ -0,0 +1,27 @@
+spring:
+ application:
+ name: anti-fraud-services
+ profiles:
+ active: dev
+ kafka:
+ consumer:
+ topic: createTransaction
+ bootstrap-servers: localhost:9092
+ group-id: create-transaction-group
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
+ properties:
+ specific.avro.reader: true
+ schema.registry.url: http://localhost:8081
+ producer:
+ topic: validateTransaction
+ bootstrap-servers: localhost:9092
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
+ properties:
+ schema.registry.url: http://localhost:8081
+
+server:
+ port: 8080
+
diff --git a/anti-fraud-services/src/main/resources/avro/CreatedTransactionEvent.avsc b/anti-fraud-services/src/main/resources/avro/CreatedTransactionEvent.avsc
new file mode 100644
index 0000000000..3ee907e481
--- /dev/null
+++ b/anti-fraud-services/src/main/resources/avro/CreatedTransactionEvent.avsc
@@ -0,0 +1,32 @@
+{
+ "type": "record",
+ "name": "CreatedTransactionMessageEvent",
+ "namespace": "com.pe.yape.service.challenge.kafka.avro",
+ "fields": [
+ {
+ "name": "transactionId",
+ "type": "string",
+ "doc": "Identificador único de la transacción"
+ },
+ {
+ "name": "accountExternalIdDebit",
+ "type": "string",
+ "doc": "Identificador externo de la cuenta de débito"
+ },
+ {
+ "name": "accountExternalIdCredit",
+ "type": "string",
+ "doc": "Identificador externo de la cuenta en crédito"
+ },
+ {
+ "name": "transferTypeId",
+ "type": "int",
+ "doc": "Tipo de transferencia"
+ },
+ {
+ "name": "value",
+ "type": "double",
+ "doc": "Valor de la transacción"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/anti-fraud-services/src/main/resources/avro/ValidateTransactionEvent.avsc b/anti-fraud-services/src/main/resources/avro/ValidateTransactionEvent.avsc
new file mode 100644
index 0000000000..23617c4c03
--- /dev/null
+++ b/anti-fraud-services/src/main/resources/avro/ValidateTransactionEvent.avsc
@@ -0,0 +1,17 @@
+{
+ "type": "record",
+ "name": "ValidateTransactionMessageEvent",
+ "namespace": "com.pe.yape.service.challenge.kafka.avro",
+ "fields": [
+ {
+ "name": "transactionId",
+ "type": "string",
+ "doc": "Identificador único de la transacción"
+ },
+ {
+ "name": "status",
+ "type": "string",
+ "doc": "estatus de la validación de la transacción"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..cb56602309 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -23,3 +23,43 @@ services:
KAFKA_JMX_PORT: 9991
ports:
- 9092:9092
+ schema-registry:
+ image: confluentinc/cp-schema-registry:7.5.0
+ depends_on: [ kafka ]
+ ports:
+ - "8081:8081"
+ environment:
+ SCHEMA_REGISTRY_HOST_NAME: schema-registry
+ SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: kafka:29092
+ SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081
+ kafka-setup:
+ image: confluentinc/cp-kafka:5.5.3
+ depends_on:
+ - kafka
+ command: >
+ bash -c "
+ echo 'Esperando a que Kafka esté listo...'
+ cub kafka-ready -b kafka:29092 1 30
+ echo 'Creando tópicos...'
+ kafka-topics --create --if-not-exists --bootstrap-server kafka:29092 --partitions 1 --replication-factor 1 --topic createTransaction
+ kafka-topics --create --if-not-exists --bootstrap-server kafka:29092 --partitions 1 --replication-factor 1 --topic validateTransaction
+ echo 'Tópicos creados exitosamente'
+ kafka-topics --list --bootstrap-server kafka:29092
+ "
+ restart: "no"
+ anti-fraud-services:
+ build:
+ context: ./anti-fraud-services
+ dockerfile: Dockerfile
+ ports:
+ - "8080:8080"
+ environment:
+ - SPRING_PROFILES_ACTIVE=dev
+ transaction-services:
+ build:
+ context: ./transaction-services
+ dockerfile: Dockerfile
+ ports:
+ - "8085:8085"
+ environment:
+ - SPRING_PROFILES_ACTIVE=dev
diff --git a/transaction-services/.gitattributes b/transaction-services/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/transaction-services/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/transaction-services/.gitignore b/transaction-services/.gitignore
new file mode 100644
index 0000000000..fb2d60c75b
--- /dev/null
+++ b/transaction-services/.gitignore
@@ -0,0 +1,37 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/main/**/avro/
+!**/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/
+
+# Any autogenerated Avro code
+**/kafka/avro/
diff --git a/transaction-services/.mvn/wrapper/maven-wrapper.properties b/transaction-services/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000..c0bcafe984
--- /dev/null
+++ b/transaction-services/.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.11/apache-maven-3.9.11-bin.zip
diff --git a/transaction-services/Dockerfile b/transaction-services/Dockerfile
new file mode 100644
index 0000000000..cc58363d99
--- /dev/null
+++ b/transaction-services/Dockerfile
@@ -0,0 +1,15 @@
+FROM ubuntu:22.04
+
+RUN apt-get update && \
+ apt-get install -y openjdk-17-jdk openjdk-17-jre maven && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /transaction-services
+
+COPY pom.xml .
+COPY src ./src
+
+RUN mvn clean package -DskipTests
+
+CMD ["java", "-jar", "target/transaction-services-0.0.1-SNAPSHOT.jar"]
\ No newline at end of file
diff --git a/transaction-services/mvnw b/transaction-services/mvnw
new file mode 100644
index 0000000000..bd8896bf22
--- /dev/null
+++ b/transaction-services/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-services/mvnw.cmd b/transaction-services/mvnw.cmd
new file mode 100644
index 0000000000..92450f9327
--- /dev/null
+++ b/transaction-services/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-services/pom.xml b/transaction-services/pom.xml
new file mode 100644
index 0000000000..290c0ecc6f
--- /dev/null
+++ b/transaction-services/pom.xml
@@ -0,0 +1,232 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.0.0
+
+
+ com.yape.challenge
+ transaction-services
+ 0.0.1-SNAPSHOT
+ transaction-services
+ Demo project for Spring Boot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 17
+ 1.5.5.Final
+ 7.0.0
+ 0.2.6
+ 2.2.0
+ 1.18.28
+ 1.1.0
+ 0.6
+ 1.11.4
+ 7.5.0
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-kafka
+
+
+ org.springframework.boot
+ spring-boot-starter-data-r2dbc
+
+
+ io.r2dbc
+ r2dbc-postgresql
+ 0.8.13.RELEASE
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.projectlombok
+ lombok
+ 1.18.28
+ provided
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator-test
+ test
+
+
+ org.mapstruct
+ mapstruct
+ ${map-struct.version}
+
+
+ org.springdoc
+ springdoc-openapi-starter-webflux-ui
+ ${springdoc-openapi.version}
+
+
+ org.openapitools
+ jackson-databind-nullable
+ ${jackson-nullable.version}
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.11.0-M2
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.postgresql
+ postgresql
+
+
+ org.apache.avro
+ avro
+ ${avro.version}
+
+
+ io.confluent
+ kafka-avro-serializer
+ ${confluent.version}
+
+
+
+
+
+
+ central
+ https://repo.maven.apache.org/maven2
+
+
+ confluent
+ https://packages.confluent.io/maven/
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.28
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${map-struct.version}
+
+
+ true
+
+ -Amapstruct.suppressGeneratorTimestamp=true
+ -Amapstruct.defaultComponentModel=spring
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.openapitools
+ openapi-generator-maven-plugin
+ ${openapi-generator.version}
+
+
+
+ generate
+
+
+ true
+ ${project.basedir}/src/main/resources/openapi.yml
+ spring
+ false
+ true
+ false
+ false
+ false
+ false
+ false
+
+ true
+ true
+ true
+ spring-boot
+ ${project.groupId}.model
+ true
+
+
+
+
+
+
+ org.apache.avro
+ avro-maven-plugin
+ ${avro.version}
+
+
+ generate-sources
+
+ schema
+
+
+ ${project.basedir}/src/main/resources/avro
+ ${project.basedir}/src/main/java
+
+
+
+
+
+
+
+
diff --git a/transaction-services/src/main/java/com/yape/challenge/TransactionServicesApplication.java b/transaction-services/src/main/java/com/yape/challenge/TransactionServicesApplication.java
new file mode 100644
index 0000000000..f8c1c080da
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/TransactionServicesApplication.java
@@ -0,0 +1,13 @@
+package com.yape.challenge;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class TransactionServicesApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(TransactionServicesApplication.class, args);
+ }
+
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/in/web/TransactionController.java b/transaction-services/src/main/java/com/yape/challenge/adapter/in/web/TransactionController.java
new file mode 100644
index 0000000000..859d9fac0f
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/in/web/TransactionController.java
@@ -0,0 +1,43 @@
+package com.yape.challenge.adapter.in.web;
+
+import com.yape.challenge.domain.port.in.TransactionUseCase;
+import com.yape.challenge.model.TransactionDetailResponse;
+import com.yape.challenge.model.TransactionRequest;
+import com.yape.challenge.model.TransactionResponse;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@Validated
+@RequestMapping("/transactions")
+public class TransactionController {
+
+ private final TransactionUseCase transactionUseCase;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public Mono createTransaction(@RequestBody TransactionRequest transactionRequest) {
+ return transactionUseCase.createTransaction(transactionRequest);
+ }
+
+ @GetMapping
+ @ResponseStatus(HttpStatus.OK)
+ public Flux getTransaction(
+ @RequestParam("transactionId") @NotNull UUID transactionId) {
+
+ log.info("Getting transaction detail, transactionId={}", transactionId);
+ return transactionUseCase.getTransaction(transactionId);
+ }
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/TransactionMessagingAdapter.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/TransactionMessagingAdapter.java
new file mode 100644
index 0000000000..8168b16487
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/TransactionMessagingAdapter.java
@@ -0,0 +1,23 @@
+package com.yape.challenge.adapter.out.messaging;
+
+import com.yape.challenge.adapter.out.messaging.kafka.KafkaProducerMessage;
+import com.yape.challenge.adapter.out.messaging.mapper.TransactionMessageMapper;
+import com.yape.challenge.domain.model.CreateTransactionEvent;
+import com.yape.challenge.domain.port.out.TransactionMessagingPort;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+@Component
+@RequiredArgsConstructor
+public class TransactionMessagingAdapter implements TransactionMessagingPort {
+ private final KafkaProducerMessage kafkaProducerMessage;
+ private final TransactionMessageMapper transactionMessageMapper;
+
+ @Override
+ public Mono sendTransactionEvent(CreateTransactionEvent event) {
+ return Mono.fromRunnable(() -> kafkaProducerMessage.sendTransactionEvent(
+ transactionMessageMapper.toMapCreatedTransactionEvent(event) ));
+ }
+
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/KafkaConsumerMessage.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/KafkaConsumerMessage.java
new file mode 100644
index 0000000000..34af28b3bc
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/KafkaConsumerMessage.java
@@ -0,0 +1,35 @@
+package com.yape.challenge.adapter.out.messaging.kafka;
+
+import com.pe.yape.service.challenge.kafka.avro.ValidateTransactionMessageEvent;
+import com.yape.challenge.domain.port.in.TransactionUseCase;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.stereotype.Component;
+
+
+@Component
+@Slf4j
+@RequiredArgsConstructor
+public class KafkaConsumerMessage {
+
+ private final TransactionUseCase transactionUseCase;
+
+ @KafkaListener(
+ topics = "${spring.kafka.consumer.topic}",
+ groupId = "${spring.kafka.consumer.group-id}",
+ containerFactory = "kafkaListenerContainerFactory")
+ public void consume(ValidateTransactionMessageEvent event, Acknowledgment ack) {
+
+ log.info("Mensaje Avro recibido para validación de transacción");
+
+ transactionUseCase.validateTransaction(event)
+ .doOnSuccess(response -> {
+ ack.acknowledge();
+ log.info("Mensaje procesado y offset confirmado");
+ }).doOnError(error -> log.error("Error procesando mensaje Avro de Kafka", error))
+ .subscribe();
+ }
+
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/KafkaProducerMessage.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/KafkaProducerMessage.java
new file mode 100644
index 0000000000..9c4453db64
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/KafkaProducerMessage.java
@@ -0,0 +1,42 @@
+package com.yape.challenge.adapter.out.messaging.kafka;
+
+import com.pe.yape.service.challenge.kafka.avro.CreatedTransactionMessageEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+@Configuration
+@RequiredArgsConstructor
+@Slf4j
+@Component
+public class KafkaProducerMessage {
+
+ private final KafkaTemplate kafkaTemplate;
+
+ @Value("${spring.kafka.producer.topic}")
+ private String topic;
+
+ public void sendTransactionEvent(CreatedTransactionMessageEvent event) {
+
+ log.info("Enviando evento Avro a Kafka - TransactionId: {}", event.getTransactionId());
+
+ kafkaTemplate
+ .send(topic, event.getTransactionId().toString(), event)
+ .whenComplete((result, ex) -> {
+ if (ex != null) {
+ log.error("Error enviando evento Avro a Kafka", ex);
+ } else {
+ log.info(
+ "Evento Avro enviado - Topic: {}, Partition: {}, Offset: {}, Key: {}",
+ result.getRecordMetadata().topic(),
+ result.getRecordMetadata().partition(),
+ result.getRecordMetadata().offset(),
+ event.getTransactionId()
+ );
+ }
+ });
+ }
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/config/KafkaConsumerConfiguration.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/config/KafkaConsumerConfiguration.java
new file mode 100644
index 0000000000..0c76cedf7a
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/config/KafkaConsumerConfiguration.java
@@ -0,0 +1,51 @@
+package com.yape.challenge.adapter.out.messaging.kafka.config;
+
+
+import com.pe.yape.service.challenge.kafka.avro.ValidateTransactionMessageEvent;
+import io.confluent.kafka.serializers.KafkaAvroDeserializer;
+import io.confluent.kafka.serializers.KafkaAvroDeserializerConfig;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.boot.kafka.autoconfigure.KafkaProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.listener.ContainerProperties;
+import java.util.Map;
+
+@EnableKafka
+@Configuration
+public class KafkaConsumerConfiguration {
+ @Bean
+ public ConsumerFactory consumerFactory(
+ KafkaProperties kafkaProperties
+ ) {
+ Map props = kafkaProperties.buildConsumerProperties();
+
+ props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, KafkaAvroDeserializer.class);
+ props.put(KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG, "http://localhost:8081");
+ props.put(KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG, true);
+ props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+
+ return new DefaultKafkaConsumerFactory<>(props);
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory
+ kafkaListenerContainerFactory(ConsumerFactory consumerFactory) {
+
+ ConcurrentKafkaListenerContainerFactory factory =
+ new ConcurrentKafkaListenerContainerFactory<>();
+
+ factory.setConsumerFactory(consumerFactory);
+ factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
+
+ return factory;
+ }
+
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/config/KafkaProducerConfiguration.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/config/KafkaProducerConfiguration.java
new file mode 100644
index 0000000000..af36459d40
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/kafka/config/KafkaProducerConfiguration.java
@@ -0,0 +1,52 @@
+package com.yape.challenge.adapter.out.messaging.kafka.config;
+
+import com.pe.yape.service.challenge.kafka.avro.CreatedTransactionMessageEvent;
+import io.confluent.kafka.serializers.KafkaAvroSerializer;
+import io.confluent.kafka.serializers.KafkaAvroSerializerConfig;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.DefaultKafkaProducerFactory;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.core.ProducerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Getter
+@Setter
+@Configuration
+@ConfigurationProperties(prefix = "spring.kafka.producer")
+public class KafkaProducerConfiguration {
+ private String bootstrapServers;
+
+ @Bean
+ public ProducerFactory producerFactory() {
+
+ 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, KafkaAvroSerializer.class);
+
+ props.put(KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG, "http://localhost:8081");
+
+ props.put(ProducerConfig.ACKS_CONFIG, "all");
+ props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
+ props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
+ props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
+
+ return new DefaultKafkaProducerFactory<>(props);
+ }
+
+ @Bean
+ public KafkaTemplate kafkaTemplate(
+ ProducerFactory producerFactory) {
+ return new KafkaTemplate<>(producerFactory);
+ }
+
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/mapper/TransactionMessageMapper.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/mapper/TransactionMessageMapper.java
new file mode 100644
index 0000000000..3dbf479f55
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/messaging/mapper/TransactionMessageMapper.java
@@ -0,0 +1,16 @@
+package com.yape.challenge.adapter.out.messaging.mapper;
+
+import com.pe.yape.service.challenge.kafka.avro.CreatedTransactionMessageEvent;
+import com.yape.challenge.domain.model.CreateTransactionEvent;
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.NullValueCheckStrategy;
+import org.mapstruct.NullValuePropertyMappingStrategy;
+
+@Mapper(componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
+ nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
+public interface TransactionMessageMapper {
+ CreatedTransactionMessageEvent toMapCreatedTransactionEvent(CreateTransactionEvent event);
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/TransactionRepositoryAdapter.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/TransactionRepositoryAdapter.java
new file mode 100644
index 0000000000..8aea492272
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/TransactionRepositoryAdapter.java
@@ -0,0 +1,52 @@
+package com.yape.challenge.adapter.out.persistence;
+
+import com.yape.challenge.adapter.out.persistence.db.R2dbcTransactionRepository;
+import com.yape.challenge.adapter.out.persistence.mapper.TransactionRepositoryMapper;
+import com.yape.challenge.adapter.out.persistence.model.TransactionModelRepositoryRequest;
+import com.yape.challenge.adapter.out.persistence.model.TransactionModelRepositoryResponse;
+import com.yape.challenge.adapter.out.persistence.model.TransactionRepositoryResponse;
+import com.yape.challenge.adapter.out.persistence.model.TransactionValidationRepositoryRequest;
+import com.yape.challenge.domain.port.out.TransactionRepositoryPort;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+@Component
+@RequiredArgsConstructor
+public class TransactionRepositoryAdapter implements TransactionRepositoryPort {
+ private static final Logger log = LoggerFactory.getLogger(TransactionRepositoryAdapter.class);
+ private final R2dbcTransactionRepository repository;
+ private final TransactionRepositoryMapper mapper;
+
+
+ @Override
+ public Mono save(TransactionModelRepositoryRequest transaction) {
+ return repository.save(mapper.toMapRepository(transaction))
+ .flatMap(el -> Mono.just((TransactionModelRepositoryResponse.builder()
+ .transactionId(el.getTransactionId()).build())));
+ }
+
+ @Override
+ public Mono update(TransactionValidationRepositoryRequest transaction) {
+ return repository.findById(transaction.getTransactionId())
+ .flatMap(existing -> {
+ existing.setStatus(transaction.getStatus());
+ existing.setUpdateAt(OffsetDateTime.now());
+ return repository.save(existing);
+ })
+ .switchIfEmpty(Mono.error(new IllegalStateException("Transaction not found for update"))).then();
+ }
+
+ @Override
+ public Flux getTransaction(UUID transactionId) {
+ return repository.findById(transactionId)
+ .map(mapper::toMapGetTransactionRepository)
+ .switchIfEmpty(Mono.error(new IllegalStateException("Transaction not found for update"))).flux();
+ }
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/db/R2dbcTransactionRepository.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/db/R2dbcTransactionRepository.java
new file mode 100644
index 0000000000..450b79a317
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/db/R2dbcTransactionRepository.java
@@ -0,0 +1,9 @@
+package com.yape.challenge.adapter.out.persistence.db;
+
+import com.yape.challenge.adapter.out.persistence.model.entity.Transaction;
+import org.springframework.data.repository.reactive.ReactiveCrudRepository;
+
+import java.util.UUID;
+
+public interface R2dbcTransactionRepository extends ReactiveCrudRepository {
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/mapper/TransactionRepositoryMapper.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/mapper/TransactionRepositoryMapper.java
new file mode 100644
index 0000000000..0260cf2a1d
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/mapper/TransactionRepositoryMapper.java
@@ -0,0 +1,21 @@
+package com.yape.challenge.adapter.out.persistence.mapper;
+
+import com.yape.challenge.adapter.out.persistence.model.TransactionModelRepositoryRequest;
+import com.yape.challenge.adapter.out.persistence.model.TransactionRepositoryResponse;
+import com.yape.challenge.adapter.out.persistence.model.entity.Transaction;
+import org.mapstruct.InjectionStrategy;
+import org.mapstruct.Mapper;
+import org.mapstruct.NullValueCheckStrategy;
+import org.mapstruct.NullValuePropertyMappingStrategy;
+
+
+@Mapper(componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
+ nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
+public interface TransactionRepositoryMapper {
+
+ Transaction toMapRepository(TransactionModelRepositoryRequest transaction);
+
+ TransactionRepositoryResponse toMapGetTransactionRepository(Transaction transaction);
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionModelRepositoryRequest.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionModelRepositoryRequest.java
new file mode 100644
index 0000000000..c643a6ebb2
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionModelRepositoryRequest.java
@@ -0,0 +1,19 @@
+package com.yape.challenge.adapter.out.persistence.model;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+@Builder
+@Getter
+@Setter
+public class TransactionModelRepositoryRequest {
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private String transferTypeId;
+ private BigDecimal value;
+ private String status;
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionModelRepositoryResponse.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionModelRepositoryResponse.java
new file mode 100644
index 0000000000..c9c5de8dd1
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionModelRepositoryResponse.java
@@ -0,0 +1,14 @@
+package com.yape.challenge.adapter.out.persistence.model;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.UUID;
+
+@Builder
+@Getter
+@Setter
+public class TransactionModelRepositoryResponse {
+ private UUID transactionId;
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionRepositoryResponse.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionRepositoryResponse.java
new file mode 100644
index 0000000000..9b106abac3
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionRepositoryResponse.java
@@ -0,0 +1,21 @@
+package com.yape.challenge.adapter.out.persistence.model;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+@Builder
+@Getter
+@Setter
+public class TransactionRepositoryResponse {
+ private UUID transactionId;
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private Integer transferTypeId;
+ private String status;
+ private BigDecimal value;
+ private OffsetDateTime createAt;
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionValidationRepositoryRequest.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionValidationRepositoryRequest.java
new file mode 100644
index 0000000000..9c130a7ea1
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/TransactionValidationRepositoryRequest.java
@@ -0,0 +1,16 @@
+package com.yape.challenge.adapter.out.persistence.model;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+@Builder
+@Getter
+@Setter
+public class TransactionValidationRepositoryRequest {
+ private UUID transactionId;
+ private String status;
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/entity/Transaction.java b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/entity/Transaction.java
new file mode 100644
index 0000000000..8c2f311c8b
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/adapter/out/persistence/model/entity/Transaction.java
@@ -0,0 +1,32 @@
+package com.yape.challenge.adapter.out.persistence.model.entity;
+
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.relational.core.mapping.Column;
+import org.springframework.data.relational.core.mapping.Table;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+@Table("transactions")
+@Getter
+@Setter
+@Builder
+public class Transaction {
+ @Id
+ private UUID transactionId;
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private Integer transferTypeId;
+ private String status;
+ private BigDecimal value;
+ @Column("created_at")
+ private OffsetDateTime createAt;
+ @Column("updated_at")
+ private OffsetDateTime updateAt;
+
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/application/service/TransactionService.java b/transaction-services/src/main/java/com/yape/challenge/application/service/TransactionService.java
new file mode 100644
index 0000000000..c9c4e4117a
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/application/service/TransactionService.java
@@ -0,0 +1,50 @@
+package com.yape.challenge.application.service;
+
+import com.pe.yape.service.challenge.kafka.avro.ValidateTransactionMessageEvent;
+import com.yape.challenge.adapter.out.persistence.model.TransactionValidationRepositoryRequest;
+import com.yape.challenge.application.service.mapper.TransactionServiceMapper;
+import com.yape.challenge.domain.port.in.TransactionUseCase;
+import com.yape.challenge.domain.port.out.TransactionMessagingPort;
+import com.yape.challenge.domain.port.out.TransactionRepositoryPort;
+import com.yape.challenge.model.TransactionDetailResponse;
+import com.yape.challenge.model.TransactionRequest;
+import com.yape.challenge.model.TransactionResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class TransactionService implements TransactionUseCase {
+ private final TransactionRepositoryPort transactionRepositoryPort;
+ private final TransactionServiceMapper transactionServiceMapper;
+ private final TransactionMessagingPort transactionMessagingPort;
+
+ @Override
+ public Mono createTransaction(TransactionRequest transactionRequest) {
+ return transactionRepositoryPort.save(transactionServiceMapper
+ .toMapRepositoryRequest(transactionRequest))
+ .flatMap(response ->
+ transactionMessagingPort.sendTransactionEvent(
+ transactionServiceMapper.toMapCreateEvent(transactionRequest, response))
+ .thenReturn(transactionServiceMapper.toMapResponse(response))
+ );
+ }
+
+ @Override
+ public Mono validateTransaction(ValidateTransactionMessageEvent messageEvent) {
+ return transactionRepositoryPort.update(TransactionValidationRepositoryRequest.builder()
+ .transactionId(UUID.fromString(messageEvent.getTransactionId().toString()))
+ .status(messageEvent.getStatus().toString())
+ .build());
+ }
+
+ @Override
+ public Flux getTransaction(UUID transactionId) {
+ return transactionRepositoryPort.getTransaction(transactionId)
+ .map(transactionServiceMapper::toGetTransactions);
+ }
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/application/service/mapper/TransactionServiceMapper.java b/transaction-services/src/main/java/com/yape/challenge/application/service/mapper/TransactionServiceMapper.java
new file mode 100644
index 0000000000..6b24f0c1e0
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/application/service/mapper/TransactionServiceMapper.java
@@ -0,0 +1,48 @@
+package com.yape.challenge.application.service.mapper;
+
+import com.yape.challenge.adapter.out.persistence.model.TransactionModelRepositoryRequest;
+import com.yape.challenge.adapter.out.persistence.model.TransactionModelRepositoryResponse;
+import com.yape.challenge.adapter.out.persistence.model.TransactionRepositoryResponse;
+import com.yape.challenge.domain.model.CreateTransactionEvent;
+import com.yape.challenge.domain.model.Status;
+import com.yape.challenge.model.TransactionDetailResponse;
+import com.yape.challenge.model.TransactionRequest;
+import com.yape.challenge.model.TransactionResponse;
+import org.mapstruct.*;
+
+@Mapper(componentModel = "spring",
+ injectionStrategy = InjectionStrategy.CONSTRUCTOR,
+ nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
+ nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
+ imports = {Status.class})
+public interface TransactionServiceMapper {
+ @Mapping(target = "status",
+ expression = "java(Status.PENDING.getDescription())")
+ TransactionModelRepositoryRequest toMapRepositoryRequest(TransactionRequest request);
+
+ TransactionResponse toMapResponse(TransactionModelRepositoryResponse response);
+
+ @Mapping(target = "transactionId", source = "response.transactionId")
+ CreateTransactionEvent toMapCreateEvent(TransactionRequest request, TransactionModelRepositoryResponse response);
+
+ @Mapping(target = "transactionExternalId", source = "transactionId")
+ @Mapping(target = "transactionType.name",
+ source = "transferTypeId",
+ qualifiedByName = "mapTransferType")
+ @Mapping(target = "transactionStatus.name",
+ source = "status")
+ @Mapping(target = "createdAt", source = "createAt")
+ TransactionDetailResponse toGetTransactions(TransactionRepositoryResponse response);
+
+ @Named("mapTransferType")
+ default String mapTransferType(Integer transferTypeId) {
+ if (transferTypeId == null) {
+ return "UNKNOWN";
+ }
+ return switch (transferTypeId) {
+ case 1 -> "TRANSFER";
+ case 2 -> "PAYMENT";
+ default -> "UNKNOWN";
+ };
+ }
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/domain/model/CreateTransactionEvent.java b/transaction-services/src/main/java/com/yape/challenge/domain/model/CreateTransactionEvent.java
new file mode 100644
index 0000000000..143b7691bb
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/domain/model/CreateTransactionEvent.java
@@ -0,0 +1,8 @@
+package com.yape.challenge.domain.model;
+
+import lombok.Builder;
+
+@Builder
+public record CreateTransactionEvent(String transactionId, String accountExternalIdDebit,
+ String accountExternalIdCredit,int transferTypeId, Double value){
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/domain/model/Status.java b/transaction-services/src/main/java/com/yape/challenge/domain/model/Status.java
new file mode 100644
index 0000000000..faca0a770c
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/domain/model/Status.java
@@ -0,0 +1,14 @@
+package com.yape.challenge.domain.model;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum Status {
+ PENDING(1, "PENDING"),
+ APPROVED(2, "APPROVED"),
+ REJECTED(3, "REJECTED");
+ private final int code;
+ private final String description;
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/domain/model/ValidateTransactionEvent.java b/transaction-services/src/main/java/com/yape/challenge/domain/model/ValidateTransactionEvent.java
new file mode 100644
index 0000000000..d16442eca6
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/domain/model/ValidateTransactionEvent.java
@@ -0,0 +1,7 @@
+package com.yape.challenge.domain.model;
+
+import lombok.Builder;
+
+@Builder
+public record ValidateTransactionEvent(String transactionId, String status){
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/domain/port/in/TransactionUseCase.java b/transaction-services/src/main/java/com/yape/challenge/domain/port/in/TransactionUseCase.java
new file mode 100644
index 0000000000..a7deb1ac9a
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/domain/port/in/TransactionUseCase.java
@@ -0,0 +1,16 @@
+package com.yape.challenge.domain.port.in;
+
+import com.pe.yape.service.challenge.kafka.avro.ValidateTransactionMessageEvent;
+import com.yape.challenge.model.TransactionDetailResponse;
+import com.yape.challenge.model.TransactionRequest;
+import com.yape.challenge.model.TransactionResponse;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+public interface TransactionUseCase {
+ Mono createTransaction(TransactionRequest transactionRequest);
+ Mono validateTransaction(ValidateTransactionMessageEvent messageEvent);
+ Flux getTransaction(UUID transactionId);
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/domain/port/out/TransactionMessagingPort.java b/transaction-services/src/main/java/com/yape/challenge/domain/port/out/TransactionMessagingPort.java
new file mode 100644
index 0000000000..d32063dd46
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/domain/port/out/TransactionMessagingPort.java
@@ -0,0 +1,10 @@
+package com.yape.challenge.domain.port.out;
+
+import com.yape.challenge.domain.model.CreateTransactionEvent;
+import reactor.core.publisher.Mono;
+
+public interface TransactionMessagingPort {
+ Mono sendTransactionEvent(CreateTransactionEvent event);
+
+
+}
diff --git a/transaction-services/src/main/java/com/yape/challenge/domain/port/out/TransactionRepositoryPort.java b/transaction-services/src/main/java/com/yape/challenge/domain/port/out/TransactionRepositoryPort.java
new file mode 100644
index 0000000000..cbeb24f076
--- /dev/null
+++ b/transaction-services/src/main/java/com/yape/challenge/domain/port/out/TransactionRepositoryPort.java
@@ -0,0 +1,17 @@
+package com.yape.challenge.domain.port.out;
+
+import com.yape.challenge.adapter.out.persistence.model.TransactionModelRepositoryRequest;
+import com.yape.challenge.adapter.out.persistence.model.TransactionModelRepositoryResponse;
+import com.yape.challenge.adapter.out.persistence.model.TransactionRepositoryResponse;
+import com.yape.challenge.adapter.out.persistence.model.TransactionValidationRepositoryRequest;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+public interface TransactionRepositoryPort {
+ Mono save(TransactionModelRepositoryRequest transaction);
+ Mono update(TransactionValidationRepositoryRequest transaction);
+ Flux getTransaction(UUID transactionId);
+
+}
diff --git a/transaction-services/src/main/resources/application.yaml b/transaction-services/src/main/resources/application.yaml
new file mode 100644
index 0000000000..04e0a90d78
--- /dev/null
+++ b/transaction-services/src/main/resources/application.yaml
@@ -0,0 +1,59 @@
+info:
+ version: "@project.version@"
+ title: transaction-services
+
+spring:
+ profiles.active: dev
+ webflux:
+ base-path: /api/v1
+ r2dbc:
+ url: r2dbc:postgresql://localhost:5432/postgres
+ username: postgres
+ password: postgres
+ flyway:
+ enabled: true
+ url: jdbc:postgresql://localhost:5432/postgres
+ user: postgres
+ password: postgres
+ baseline-on-migrate: true
+ locations: classpath:db/migration
+ kafka:
+ consumer:
+ topic: validateTransaction
+ bootstrap-servers: localhost:9092
+ group-id: validate-transaction-group
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
+ properties:
+ specific.avro.reader: true
+ schema.registry.url: http://localhost:8081
+ producer:
+ topic: createTransaction
+ bootstrap-servers: localhost:9092
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
+ properties:
+ schema.registry.url: http://localhost:8081
+
+ schema-registry:
+ url: http://localhost:8081
+
+
+springdoc:
+ api-docs:
+ enabled: false
+ swagger-ui:
+ url: /swagger.yaml
+
+server:
+ port: 8085
+
+logging:
+ pattern:
+ console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
+ level:
+ root: INFO
+ org.springframework: DEBUG
+ com.financial: DEBUG
+ date-format: yyyy-MM-dd HH:mm:ss
diff --git a/transaction-services/src/main/resources/avro/CreatedTransactionEvent.avsc b/transaction-services/src/main/resources/avro/CreatedTransactionEvent.avsc
new file mode 100644
index 0000000000..3ee907e481
--- /dev/null
+++ b/transaction-services/src/main/resources/avro/CreatedTransactionEvent.avsc
@@ -0,0 +1,32 @@
+{
+ "type": "record",
+ "name": "CreatedTransactionMessageEvent",
+ "namespace": "com.pe.yape.service.challenge.kafka.avro",
+ "fields": [
+ {
+ "name": "transactionId",
+ "type": "string",
+ "doc": "Identificador único de la transacción"
+ },
+ {
+ "name": "accountExternalIdDebit",
+ "type": "string",
+ "doc": "Identificador externo de la cuenta de débito"
+ },
+ {
+ "name": "accountExternalIdCredit",
+ "type": "string",
+ "doc": "Identificador externo de la cuenta en crédito"
+ },
+ {
+ "name": "transferTypeId",
+ "type": "int",
+ "doc": "Tipo de transferencia"
+ },
+ {
+ "name": "value",
+ "type": "double",
+ "doc": "Valor de la transacción"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/transaction-services/src/main/resources/avro/ValidateTransactionEvent.avsc b/transaction-services/src/main/resources/avro/ValidateTransactionEvent.avsc
new file mode 100644
index 0000000000..23617c4c03
--- /dev/null
+++ b/transaction-services/src/main/resources/avro/ValidateTransactionEvent.avsc
@@ -0,0 +1,17 @@
+{
+ "type": "record",
+ "name": "ValidateTransactionMessageEvent",
+ "namespace": "com.pe.yape.service.challenge.kafka.avro",
+ "fields": [
+ {
+ "name": "transactionId",
+ "type": "string",
+ "doc": "Identificador único de la transacción"
+ },
+ {
+ "name": "status",
+ "type": "string",
+ "doc": "estatus de la validación de la transacción"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/transaction-services/src/main/resources/banner.txt b/transaction-services/src/main/resources/banner.txt
new file mode 100644
index 0000000000..652d9edf68
--- /dev/null
+++ b/transaction-services/src/main/resources/banner.txt
@@ -0,0 +1,7 @@
+,--. ,--. ,--.
+| | | |,---. | |,---. ,---. ,--,--,--.,---.
+| |.'.| | .-. :| | .--'| .-. || | .-. :
+| ,'. \ --.| \ `--.' '-' '| | | \ --.
+'--' '--'`----'`--'`---' `---' `--`--`--'`----'
+
+Powered by Spring Boot ${spring-boot.version}
\ No newline at end of file
diff --git a/transaction-services/src/main/resources/db/migration/V1_Transaction.sql b/transaction-services/src/main/resources/db/migration/V1_Transaction.sql
new file mode 100644
index 0000000000..5f8840851f
--- /dev/null
+++ b/transaction-services/src/main/resources/db/migration/V1_Transaction.sql
@@ -0,0 +1,17 @@
+CREATE EXTENSION IF NOT EXISTS "pgcrypto";
+
+CREATE TABLE IF NOT EXISTS transactions (
+ transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ account_external_id_debit UUID NOT NULL,
+ account_external_id_credit UUID NOT NULL,
+
+ transfer_type_id INTEGER NOT NULL,
+
+ status VARCHAR(30) NOT NULL,
+
+ value NUMERIC(19, 2) NOT NULL CHECK (value > 0),
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ
+);
\ No newline at end of file
diff --git a/transaction-services/src/main/resources/db/migration/V2_Trigger_update.sql b/transaction-services/src/main/resources/db/migration/V2_Trigger_update.sql
new file mode 100644
index 0000000000..612cb51cf3
--- /dev/null
+++ b/transaction-services/src/main/resources/db/migration/V2_Trigger_update.sql
@@ -0,0 +1,18 @@
+-- 1 funcion que actualiza cuando hay un update del row
+CREATE OR REPLACE FUNCTION set_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+
+-- 2. Trigger que ejecuta el evento
+
+DROP TRIGGER IF EXISTS trg_set_updated_at ON transactions;
+
+CREATE TRIGGER trg_set_updated_at
+BEFORE UPDATE ON transactions
+FOR EACH ROW
+EXECUTE FUNCTION set_updated_at();
\ No newline at end of file
diff --git a/transaction-services/src/main/resources/logback-spring.xml b/transaction-services/src/main/resources/logback-spring.xml
new file mode 100644
index 0000000000..d322a24bc6
--- /dev/null
+++ b/transaction-services/src/main/resources/logback-spring.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+ ${CONSOLE_LOG_PATTERN}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/transaction-services/src/main/resources/openapi.yml b/transaction-services/src/main/resources/openapi.yml
new file mode 100644
index 0000000000..34059cc419
--- /dev/null
+++ b/transaction-services/src/main/resources/openapi.yml
@@ -0,0 +1,115 @@
+openapi: '3.0.3'
+info:
+ title: Transaction Service
+ version: 1.0.0
+ description: API for creating and retrieving transactions
+
+servers:
+ - url: http://localhost:8081/api/v1
+paths:
+ /transaction:
+ post:
+ summary: Create Transaction
+ description: Create a transaction of a client
+ operationId: createTransaction
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TransactionRequest'
+ responses:
+ "201":
+ description: Created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TransactionResponse'
+ "400":
+ description: Bad Request
+ "500":
+ description: Internal Server Error
+
+ get:
+ summary: Get Transaction Detail
+ description: Get transaction information by transactionId
+ operationId: getTransaction
+ parameters:
+ - name: transactionId
+ in: query
+ required: true
+ description: Unique transaction identifier
+ schema:
+ type: string
+ format: uuid
+ example: a9d7c6b2-1234-4c99-8e91-9a1234567890
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TransactionDetailResponse'
+ "404":
+ description: Transaction Not Found
+ "500":
+ description: Internal Server Error
+
+components:
+ schemas:
+ TransactionRequest:
+ type: object
+ required:
+ - accountExternalIdDebit
+ - accountExternalIdCredit
+ - transferTypeId
+ - value
+ properties:
+ accountExternalIdDebit:
+ type: string
+ format: uuid
+ pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'
+ example: "a9d7c6b2-1234-4c99-8e91-9a1234567890"
+ accountExternalIdCredit:
+ type: string
+ format: uuid
+ pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'
+ example: "a9d7c6b2-1234-4c99-8e91-9a1234567890"
+ transferTypeId:
+ type: string
+ example: "1"
+ value:
+ type: number
+ TransactionResponse:
+ type: object
+ properties:
+ transactionId:
+ type: string
+ format: uuid
+ example: "a9d7c6b2-1234-4c99-8e91-9a1234567890"
+ TransactionDetailResponse:
+ type: object
+ properties:
+ transactionExternalId:
+ type: string
+ format: uuid
+ example: c2f7a1b4-1111-4aaa-8bbb-999999999999
+ transactionType:
+ type: object
+ properties:
+ name:
+ type: string
+ example: TRANSFER
+ transactionStatus:
+ type: object
+ properties:
+ name:
+ type: string
+ example: APPROVED
+ value:
+ type: number
+ example: 120
+ createdAt:
+ type: string
+ format: date-time
+ example: 2025-12-15T20:30:00Z
\ No newline at end of file