diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000000..bd923295ad
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml
new file mode 100644
index 0000000000..4ea72a911a
--- /dev/null
+++ b/.idea/copilot.data.migration.agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml
new file mode 100644
index 0000000000..7ef04e2ea0
--- /dev/null
+++ b/.idea/copilot.data.migration.ask.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml
new file mode 100644
index 0000000000..1f2ea11e7f
--- /dev/null
+++ b/.idea/copilot.data.migration.ask2agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml
new file mode 100644
index 0000000000..8648f9401a
--- /dev/null
+++ b/.idea/copilot.data.migration.edit.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000000..7cf716e071
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 0000000000..712ab9d985
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000000..621aeb23b9
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000000..5e7c38b78c
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000000..35eb1ddfbb
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000000..2542bcd071
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ "lastFilter": {
+ "state": "OPEN",
+ "assignee": "3nr19u3"
+ }
+}
+ {
+ "selectedUrlAndAccountId": {
+ "url": "git@github.com:3nr19u3/app-java-codechallenge.git",
+ "accountId": "ccb41516-fc70-4471-9fb5-9b6453f6b0eb"
+ }
+}
+ {
+ "associatedIndex": 1
+}
+
+
+
+
+
+ {
+ "keyToString": {
+ "ModuleVcsDetector.initialDetectionPerformed": "true",
+ "RequestMappingsPanelOrder0": "0",
+ "RequestMappingsPanelOrder1": "1",
+ "RequestMappingsPanelWidth0": "75",
+ "RequestMappingsPanelWidth1": "75",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "RunOnceActivity.typescript.service.memoryLimit.init": "true",
+ "Spring Boot.AntifraudServiceApplication.executor": "Run",
+ "Spring Boot.TransactionServiceApplication.executor": "Run",
+ "git-widget-placeholder": "feature/implements-antifraud-flux",
+ "kotlin-language-version-configured": "true",
+ "last_opened_file_path": "/Users/luis/IdeaProjects/app-java-codechallenge",
+ "nodejs_package_manager_path": "npm",
+ "vue.rearranger.settings.migration": "true"
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1769557517634
+
+
+ 1769557517634
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/antifraud-service/.gitattributes b/antifraud-service/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/antifraud-service/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/antifraud-service/.gitignore b/antifraud-service/.gitignore
new file mode 100644
index 0000000000..667aaef0c8
--- /dev/null
+++ b/antifraud-service/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/antifraud-service/.mvn/wrapper/maven-wrapper.properties b/antifraud-service/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000..8dea6c227c
--- /dev/null
+++ b/antifraud-service/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
diff --git a/antifraud-service/mvnw b/antifraud-service/mvnw
new file mode 100755
index 0000000000..bd8896bf22
--- /dev/null
+++ b/antifraud-service/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/antifraud-service/mvnw.cmd b/antifraud-service/mvnw.cmd
new file mode 100644
index 0000000000..92450f9327
--- /dev/null
+++ b/antifraud-service/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/antifraud-service/pom.xml b/antifraud-service/pom.xml
new file mode 100644
index 0000000000..4695534899
--- /dev/null
+++ b/antifraud-service/pom.xml
@@ -0,0 +1,115 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.0.2
+
+
+ com.devpull
+ antifraud-service
+ 0.0.1-SNAPSHOT
+ antifraud-service
+ antifraud-service
+
+
+
+
+
+
+ devPull
+ luis.dev.pull@gmail.com
+ Luis Gutierrez
+ devPull
+
+ developer
+
+
+
+
+
+
+
+
+
+
+ 21
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+ true
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux-test
+ test
+
+
+ com.devpull
+ transaction-service
+ 0.0.1-SNAPSHOT
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+ 4.1.0-M1
+ compile
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/AntifraudServiceApplication.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/AntifraudServiceApplication.java
new file mode 100644
index 0000000000..576652b6e3
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/AntifraudServiceApplication.java
@@ -0,0 +1,16 @@
+package com.devpull.antifraudservice;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.r2dbc.autoconfigure.R2dbcAutoConfiguration;
+
+@SpringBootApplication(exclude = {
+ R2dbcAutoConfiguration.class // Exclude R2DBC auto-configuration
+})
+public class AntifraudServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AntifraudServiceApplication.class, args);
+ }
+
+}
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/config/KafkaProducerConfig.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/config/KafkaProducerConfig.java
new file mode 100644
index 0000000000..3ef3ba4c85
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/config/KafkaProducerConfig.java
@@ -0,0 +1,35 @@
+package com.devpull.antifraudservice.config;
+
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.ByteArraySerializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.DefaultKafkaProducerFactory;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.core.ProducerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class KafkaProducerConfig {
+
+ @Bean
+ public ProducerFactory producerFactory(
+ @Value("${spring.kafka.bootstrap-servers}") String bootstrapServers
+ ) {
+ 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, ByteArraySerializer.class);
+ props.put("spring.json.add.type.headers", false);
+ return new DefaultKafkaProducerFactory<>(props);
+ }
+
+ @Bean
+ public KafkaTemplate kafkaTemplate(ProducerFactory pf) {
+ return new KafkaTemplate<>(pf);
+ }
+}
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/config/KafkaTopicConfig.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/config/KafkaTopicConfig.java
new file mode 100644
index 0000000000..2a77ae9422
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/config/KafkaTopicConfig.java
@@ -0,0 +1,20 @@
+package com.devpull.antifraudservice.config;
+
+import org.apache.kafka.clients.admin.NewTopic;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.config.TopicBuilder;
+
+@Configuration
+public class KafkaTopicConfig {
+
+ @Value("${spring.kafka.topic.name}")
+ private String topicName;
+
+ //create a bean for kafka topic
+ @Bean
+ public NewTopic getTopic() {
+ return TopicBuilder.name(topicName).build();
+ }
+}
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/controller/AntifraudController.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/controller/AntifraudController.java
new file mode 100644
index 0000000000..fd5e741154
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/controller/AntifraudController.java
@@ -0,0 +1,47 @@
+package com.devpull.antifraudservice.controller;
+
+import com.devpull.antifraudservice.dto.TransactionCreatedEvent;
+import com.devpull.antifraudservice.dto.TransactionStatusChangedEvent;
+import com.devpull.antifraudservice.kafka.EventProducer;
+import com.devpull.antifraudservice.service.AntifraudEvaluationService;
+import com.devpull.transactionservice.domain.enums.TransactionStatus;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+
+@RestController
+@RequestMapping("/api/v1/antifraud")
+@Slf4j
+public class AntifraudController {
+
+ private final EventProducer eventProducer;
+
+ private final AntifraudEvaluationService evaluationService;
+
+
+ public AntifraudController(EventProducer eventProducer,
+ AntifraudEvaluationService evaluationService) {
+ this.eventProducer = eventProducer;
+ this.evaluationService = evaluationService;
+ }
+
+ @PostMapping
+ public ResponseEntity evaluateAntifraud(@Valid @RequestBody TransactionCreatedEvent transaction) {
+ log.info("[ANTIFRAUD] Evaluating transactionId={}, amount={}",
+ transaction.transactionId(), transaction.amount());
+
+ TransactionStatusChangedEvent resultEvent = evaluationService.evaluate(transaction);
+
+ eventProducer.sendMessage(resultEvent);
+
+ log.info("[ANTIFRAUD] Evaluation done transactionId={}, status={}",
+ resultEvent.transactionId(), resultEvent.status());
+
+ return ResponseEntity.ok("Event handled successfully!");
+ }
+}
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/dto/TransactionCreatedEvent.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/dto/TransactionCreatedEvent.java
new file mode 100644
index 0000000000..ee3a46e058
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/dto/TransactionCreatedEvent.java
@@ -0,0 +1,14 @@
+package com.devpull.antifraudservice.dto;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+
+import java.util.UUID;
+
+public record TransactionCreatedEvent(
+ @NotNull UUID transactionId,
+ @NotNull UUID accountId,
+ @NotNull String type,
+ @NotNull @Positive Double amount
+) {
+}
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/dto/TransactionStatusChangedEvent.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/dto/TransactionStatusChangedEvent.java
new file mode 100644
index 0000000000..b0a07805fa
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/dto/TransactionStatusChangedEvent.java
@@ -0,0 +1,15 @@
+package com.devpull.antifraudservice.dto;
+
+import com.devpull.antifraudservice.enums.TransactionStatus;
+import jakarta.validation.constraints.NotNull;
+
+import java.time.Instant;
+import java.util.UUID;
+
+public record TransactionStatusChangedEvent(
+ @NotNull UUID transactionId,
+ @NotNull TransactionStatus status,
+ Instant evaluatedAt,
+ String reason
+) {
+}
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/enums/TransactionStatus.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/enums/TransactionStatus.java
new file mode 100644
index 0000000000..c4a87d8d75
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/enums/TransactionStatus.java
@@ -0,0 +1,6 @@
+package com.devpull.antifraudservice.enums;
+
+public enum TransactionStatus {
+ APPROVED,
+ REJECTED
+}
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/kafka/EventProducer.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/kafka/EventProducer.java
new file mode 100644
index 0000000000..a621413e14
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/kafka/EventProducer.java
@@ -0,0 +1,37 @@
+package com.devpull.antifraudservice.kafka;
+
+import com.devpull.antifraudservice.dto.TransactionStatusChangedEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Service;
+import tools.jackson.databind.ObjectMapper;
+
+@Service
+@Slf4j
+public class EventProducer {
+
+ private final KafkaTemplate kafkaTemplate;
+ private final ObjectMapper objectMapper;
+ private final String topicName;
+
+ public EventProducer(KafkaTemplate kafkaTemplate,
+ NewTopic topic,
+ ObjectMapper objectMapper) {
+ this.kafkaTemplate = kafkaTemplate;
+ this.objectMapper = objectMapper;
+ this.topicName = topic.name();
+ }
+
+ public void sendMessage(TransactionStatusChangedEvent event) {
+ try {
+ byte[] payload = objectMapper.writeValueAsBytes(event);
+ kafkaTemplate.send(topicName, event.transactionId().toString(), payload);
+ log.info("[ANTIFRAUD] Sent status event txId={}, status={}", event.transactionId(), event.status());
+ } catch (Exception e) {
+ log.error("[ANTIFRAUD] Failed to serialize/send event", e);
+ throw new RuntimeException("Failed to produce event", e);
+ }
+ }
+
+}
diff --git a/antifraud-service/src/main/java/com/devpull/antifraudservice/service/AntifraudEvaluationService.java b/antifraud-service/src/main/java/com/devpull/antifraudservice/service/AntifraudEvaluationService.java
new file mode 100644
index 0000000000..baac50c93b
--- /dev/null
+++ b/antifraud-service/src/main/java/com/devpull/antifraudservice/service/AntifraudEvaluationService.java
@@ -0,0 +1,31 @@
+package com.devpull.antifraudservice.service;
+
+import com.devpull.antifraudservice.dto.TransactionCreatedEvent;
+import com.devpull.antifraudservice.dto.TransactionStatusChangedEvent;
+import com.devpull.antifraudservice.enums.TransactionStatus;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+
+@Service
+public class AntifraudEvaluationService {
+
+ private static final double THRESHOLD = 1000.0;
+
+ public TransactionStatusChangedEvent evaluate(TransactionCreatedEvent tx) {
+ boolean rejected = tx.amount() != null && tx.amount() > THRESHOLD;
+
+ TransactionStatus status = rejected ? TransactionStatus.REJECTED : TransactionStatus.APPROVED;
+
+ String reason = rejected
+ ? "Amount exceeds threshold (" + THRESHOLD + ")"
+ : "OK";
+
+ return new TransactionStatusChangedEvent(
+ tx.transactionId(),
+ status,
+ Instant.now(),
+ reason
+ );
+ }
+}
diff --git a/antifraud-service/src/main/resources/application.yml b/antifraud-service/src/main/resources/application.yml
new file mode 100644
index 0000000000..acdb69a797
--- /dev/null
+++ b/antifraud-service/src/main/resources/application.yml
@@ -0,0 +1,30 @@
+server:
+ port: ${SERVER_PORT:8081}
+
+spring:
+ application:
+ name: antifraud-service
+
+ main:
+ web-application-type: reactive
+
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+
+ consumer:
+ group-id: ${KAFKA_CONSUMER_GROUP:antifraud-service}
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer
+
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.apache.kafka.common.serialization.ByteArraySerializer
+
+ topic:
+ name: ${KAFKA_TOPIC_TRANSACTIONS:transactions_fraud}
+
+logging:
+ level:
+ io.r2dbc.postgresql.QUERY: ${LOG_R2DBC_QUERY:DEBUG}
+ io.r2dbc.postgresql.PARAM: ${LOG_R2DBC_PARAM:DEBUG}
diff --git a/antifraud-service/src/test/java/com/devpull/antifraudservice/AntifraudServiceApplicationTests.java b/antifraud-service/src/test/java/com/devpull/antifraudservice/AntifraudServiceApplicationTests.java
new file mode 100644
index 0000000000..5ef95f2603
--- /dev/null
+++ b/antifraud-service/src/test/java/com/devpull/antifraudservice/AntifraudServiceApplicationTests.java
@@ -0,0 +1,13 @@
+package com.devpull.antifraudservice;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class AntifraudServiceApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..0e32a0418d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,25 +1,32 @@
-version: "3.7"
services:
postgres:
- image: postgres:14
- ports:
- - "5432:5432"
+ image: postgres:16
+ container_name: postgres
environment:
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=postgres
+ POSTGRES_DB: app_java_codechallenge
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - "5433:5432"
zookeeper:
- image: confluentinc/cp-zookeeper:5.5.3
+ image: confluentinc/cp-zookeeper:7.5.0
+ container_name: zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
+ ZOOKEEPER_TICK_TIME: 2000
+ ports:
+ - "2181:2181"
+
kafka:
- image: confluentinc/cp-enterprise-kafka:5.5.3
- depends_on: [zookeeper]
+ image: confluentinc/cp-kafka:7.5.0
+ container_name: kafka
+ depends_on:
+ - zookeeper
+ ports:
+ - "9092:9092"
environment:
- KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
- KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_BROKER_ID: 1
+ KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
+ KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
- KAFKA_JMX_PORT: 9991
- ports:
- - 9092:9092
diff --git a/transaction-service/.gitattributes b/transaction-service/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/transaction-service/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/transaction-service/.gitignore b/transaction-service/.gitignore
new file mode 100644
index 0000000000..667aaef0c8
--- /dev/null
+++ b/transaction-service/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/transaction-service/.mvn/wrapper/maven-wrapper.properties b/transaction-service/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000000..8dea6c227c
--- /dev/null
+++ b/transaction-service/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
diff --git a/transaction-service/mvnw b/transaction-service/mvnw
new file mode 100755
index 0000000000..bd8896bf22
--- /dev/null
+++ b/transaction-service/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/transaction-service/mvnw.cmd b/transaction-service/mvnw.cmd
new file mode 100644
index 0000000000..92450f9327
--- /dev/null
+++ b/transaction-service/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/transaction-service/pom.xml b/transaction-service/pom.xml
new file mode 100644
index 0000000000..0d49c4f5f6
--- /dev/null
+++ b/transaction-service/pom.xml
@@ -0,0 +1,135 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.0.2
+
+
+ com.devpull
+ transaction-service
+ 0.0.1-SNAPSHOT
+ transaction-service
+ transaction-service
+
+
+
+
+
+
+ devPull
+ luis.dev.pull@gmail.com
+ Luis Gutierrez
+ devPull
+
+ developer
+
+
+
+
+
+
+
+
+
+
+ 21
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.springframework.boot
+ spring-boot-starter-data-r2dbc
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+ true
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.postgresql
+ r2dbc-postgresql
+ runtime
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-data-r2dbc-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webclient
+ 4.1.0-M1
+ compile
+
+
+ jakarta.validation
+ jakarta.validation-api
+ 4.0.0-M1
+ compile
+
+
+ org.springframework.kafka
+ spring-kafka
+ 4.0.2
+ compile
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+ 4.1.0-M1
+ compile
+
+
+ io.netty
+ netty-resolver-dns-native-macos
+ osx-aarch_64
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/TransactionServiceApplication.java b/transaction-service/src/main/java/com/devpull/transactionservice/TransactionServiceApplication.java
new file mode 100644
index 0000000000..0dc996c18d
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/TransactionServiceApplication.java
@@ -0,0 +1,13 @@
+package com.devpull.transactionservice;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class TransactionServiceApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(TransactionServiceApplication.class, args);
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/AntifraudResultListener.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/AntifraudResultListener.java
new file mode 100644
index 0000000000..1cfe2baf5b
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/AntifraudResultListener.java
@@ -0,0 +1,37 @@
+package com.devpull.transactionservice.adapter.in.kafka;
+
+import com.devpull.transactionservice.adapter.in.web.dto.kafka.TransactionStatusChangedEvent;
+import com.devpull.transactionservice.application.port.in.UpdateTransactionStatusUseCase;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+import tools.jackson.databind.ObjectMapper;
+
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class AntifraudResultListener {
+
+ private final ObjectMapper objectMapper;
+ private final UpdateTransactionStatusUseCase useCase;
+
+ @KafkaListener(topics = "${spring.kafka.topic.name}", groupId = "${spring.kafka.consumer.group-id}")
+ public void consume(byte[] payload) {
+ try{
+ TransactionStatusChangedEvent event =
+ objectMapper.readValue(payload, TransactionStatusChangedEvent.class);
+
+ log.info("Received antifraud event txId={}, status={}",
+ event.transactionId(), event.status());
+
+ useCase.updateStatus(event.transactionId(), event.status())
+ .doOnError(e -> log.error("Failed updating tx {}", event.transactionId(), e))
+ .subscribe();
+
+ } catch (Exception e) {
+ log.error("Failed to deserialize antifraud payload", e);
+ }
+
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/KafkaEnableConfig.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/KafkaEnableConfig.java
new file mode 100644
index 0000000000..146891f1bc
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/KafkaEnableConfig.java
@@ -0,0 +1,9 @@
+package com.devpull.transactionservice.adapter.in.kafka;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafka;
+
+@Configuration
+@EnableKafka
+public class KafkaEnableConfig {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/config/KafkaConsumerConfig.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/config/KafkaConsumerConfig.java
new file mode 100644
index 0000000000..ffb129c02b
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/kafka/config/KafkaConsumerConfig.java
@@ -0,0 +1,48 @@
+package com.devpull.transactionservice.adapter.in.kafka.config;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.common.serialization.ByteArrayDeserializer;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+@EnableKafka
+public class KafkaConsumerConfig {
+
+ @Bean
+ public ConsumerFactory consumerFactory(
+ @Value("${spring.kafka.bootstrap-servers}") String bootstrapServers,
+ @Value("${spring.kafka.consumer.group-id}") String groupId,
+ @Value("${spring.kafka.consumer.auto-offset-reset:earliest}") String autoOffsetReset
+ ) {
+ Map props = new HashMap<>();
+ props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset);
+ props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class);
+ return new DefaultKafkaConsumerFactory<>(props);
+ }
+
+ /**
+ * "kafkaListenerContainerFactory"
+ */
+ @Bean(name = "kafkaListenerContainerFactory")
+ public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(
+ ConsumerFactory consumerFactory
+ ) {
+ ConcurrentKafkaListenerContainerFactory factory =
+ new ConcurrentKafkaListenerContainerFactory<>();
+ factory.setConsumerFactory(consumerFactory);
+ return factory;
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/AccountController.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/AccountController.java
new file mode 100644
index 0000000000..fa0bb28f52
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/AccountController.java
@@ -0,0 +1,30 @@
+package com.devpull.transactionservice.adapter.in.web;
+
+import com.devpull.transactionservice.adapter.in.web.dto.AccountRequest;
+import com.devpull.transactionservice.adapter.in.web.dto.AccountResponse;
+import com.devpull.transactionservice.adapter.in.web.mapper.AccountWebMapper;
+import com.devpull.transactionservice.application.port.in.CreateAccountUseCase;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+@AllArgsConstructor
+@RestController
+@RequestMapping("/api/account")
+public class AccountController {
+
+ private final CreateAccountUseCase createAccountUseCase;
+
+
+ @PostMapping
+ public Mono> createAccount(@RequestBody AccountRequest request) {
+ return createAccountUseCase.createAccount(AccountWebMapper.toCommand(request))
+ .map(AccountWebMapper::toResponse)
+ .map(res -> ResponseEntity.status(HttpStatus.CREATED).body(res));
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/TransactionController.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/TransactionController.java
new file mode 100644
index 0000000000..b9d6a9e9a4
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/TransactionController.java
@@ -0,0 +1,37 @@
+package com.devpull.transactionservice.adapter.in.web;
+
+import com.devpull.transactionservice.adapter.in.web.dto.TransactionRequest;
+import com.devpull.transactionservice.adapter.in.web.dto.TransactionResponse;
+import com.devpull.transactionservice.adapter.in.web.mapper.TransactionWebMapper;
+import com.devpull.transactionservice.application.port.in.CreateTransactionUseCase;
+import com.devpull.transactionservice.application.port.in.GetTransactionUseCase;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+@AllArgsConstructor
+@RestController
+@RequestMapping("/api/transactions")
+public class TransactionController {
+
+ private final GetTransactionUseCase getTransactionUseCase;
+ private final CreateTransactionUseCase createTransactionUseCase;
+
+ @GetMapping("{id}")
+ public Mono> getTransaction(@PathVariable("id") UUID transactionId) {
+ return getTransactionUseCase.getById(transactionId)
+ .map(TransactionWebMapper::toResponse)
+ .map(ResponseEntity::ok);
+ }
+
+ @PostMapping
+ public Mono> createTransaction(@RequestBody TransactionRequest request) {
+ return createTransactionUseCase.create(TransactionWebMapper.toCommand(request))
+ .map(TransactionWebMapper::toResponse)
+ .map(res -> ResponseEntity.status(HttpStatus.CREATED).body(res));
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/AccountRequest.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/AccountRequest.java
new file mode 100644
index 0000000000..a47bc000a0
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/AccountRequest.java
@@ -0,0 +1,10 @@
+package com.devpull.transactionservice.adapter.in.web.dto;
+
+import com.devpull.transactionservice.domain.enums.AccountStatus;
+import com.devpull.transactionservice.domain.enums.AccountType;
+import jakarta.validation.constraints.NotNull;
+
+public record AccountRequest(
+ @NotNull AccountStatus accountStatus,
+ @NotNull AccountType accountType
+) {}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/AccountResponse.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/AccountResponse.java
new file mode 100644
index 0000000000..9128afefe0
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/AccountResponse.java
@@ -0,0 +1,12 @@
+package com.devpull.transactionservice.adapter.in.web.dto;
+
+import com.devpull.transactionservice.domain.enums.AccountStatus;
+import com.devpull.transactionservice.domain.enums.AccountType;
+
+import java.util.UUID;
+
+public record AccountResponse(
+ UUID id,
+ AccountStatus accountStatus,
+ AccountType accountType
+) {}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/TransactionRequest.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/TransactionRequest.java
new file mode 100644
index 0000000000..c697d6a752
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/TransactionRequest.java
@@ -0,0 +1,13 @@
+package com.devpull.transactionservice.adapter.in.web.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+public record TransactionRequest(
+ @NotNull UUID accountExternalIdDebit,
+ @NotNull UUID accountExternalIdCredit,
+ @NotBlank String transferTypeId,
+ @NotNull Double value
+) {}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/TransactionResponse.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/TransactionResponse.java
new file mode 100644
index 0000000000..46eca7f011
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/TransactionResponse.java
@@ -0,0 +1,17 @@
+package com.devpull.transactionservice.adapter.in.web.dto;
+
+import com.devpull.transactionservice.domain.enums.TransactionStatus;
+import com.devpull.transactionservice.domain.enums.TransactionType;
+
+import java.time.Instant;
+import java.util.UUID;
+
+public record TransactionResponse(
+ UUID id,
+ TransactionStatus status,
+ TransactionType type,
+ double amount,
+ Instant createdAt,
+ Instant updatedAt,
+ UUID accountId
+) {}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/kafka/TransactionStatusChangedEvent.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/kafka/TransactionStatusChangedEvent.java
new file mode 100644
index 0000000000..0ed3c13dad
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/dto/kafka/TransactionStatusChangedEvent.java
@@ -0,0 +1,11 @@
+package com.devpull.transactionservice.adapter.in.web.dto.kafka;
+
+import java.time.Instant;
+import java.util.UUID;
+
+public record TransactionStatusChangedEvent(
+ UUID transactionId,
+ String status,
+ Instant evaluatedAt,
+ String reason
+) {}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/mapper/AccountWebMapper.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/mapper/AccountWebMapper.java
new file mode 100644
index 0000000000..9af210ef3f
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/mapper/AccountWebMapper.java
@@ -0,0 +1,32 @@
+package com.devpull.transactionservice.adapter.in.web.mapper;
+
+import com.devpull.transactionservice.adapter.in.web.dto.AccountRequest;
+import com.devpull.transactionservice.adapter.in.web.dto.AccountResponse;
+import com.devpull.transactionservice.application.dto.command.CreateAccountCommand;
+import com.devpull.transactionservice.application.dto.result.AccountResult;
+
+public final class AccountWebMapper {
+
+ private AccountWebMapper() {}
+
+ /**
+ * Web -> Application
+ */
+ public static CreateAccountCommand toCommand(AccountRequest request) {
+ return new CreateAccountCommand(
+ request.accountStatus(),
+ request.accountType()
+ );
+ }
+
+ /**
+ * Application -> Web
+ */
+ public static AccountResponse toResponse(AccountResult result) {
+ return new AccountResponse(
+ result.id(),
+ result.accountStatus(),
+ result.accountType()
+ );
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/mapper/TransactionWebMapper.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/mapper/TransactionWebMapper.java
new file mode 100644
index 0000000000..713903ed43
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/in/web/mapper/TransactionWebMapper.java
@@ -0,0 +1,38 @@
+package com.devpull.transactionservice.adapter.in.web.mapper;
+
+import com.devpull.transactionservice.adapter.in.web.dto.TransactionRequest;
+import com.devpull.transactionservice.adapter.in.web.dto.TransactionResponse;
+import com.devpull.transactionservice.application.dto.command.CreateTransactionCommand;
+import com.devpull.transactionservice.application.dto.result.TransactionResult;
+
+public class TransactionWebMapper {
+
+ private TransactionWebMapper() {}
+
+ /**
+ * Web → Application
+ */
+ public static CreateTransactionCommand toCommand(TransactionRequest request) {
+ return new CreateTransactionCommand(
+ request.accountExternalIdDebit(),
+ request.accountExternalIdCredit(),
+ request.transferTypeId(),
+ request.value()
+ );
+ }
+
+ /**
+ * Application → Web
+ */
+ public static TransactionResponse toResponse(TransactionResult result) {
+ return new TransactionResponse(
+ result.id(),
+ result.status(),
+ result.type(),
+ result.amount(),
+ result.createdAt(),
+ result.updatedAt(),
+ result.accountId()
+ );
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/AntiFraudWebClientAdapter.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/AntiFraudWebClientAdapter.java
new file mode 100644
index 0000000000..c2b5c16419
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/AntiFraudWebClientAdapter.java
@@ -0,0 +1,30 @@
+package com.devpull.transactionservice.adapter.out.client;
+
+import com.devpull.transactionservice.adapter.out.client.dto.TransactionCreatedEvent;
+import com.devpull.transactionservice.adapter.out.client.mapper.AntiFraudEventMapper;
+import com.devpull.transactionservice.application.port.out.AntiFraudPort;
+import com.devpull.transactionservice.domain.model.Transaction;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+@Component
+public class AntiFraudWebClientAdapter implements AntiFraudPort {
+
+ private final WebClient webClient;
+
+ public AntiFraudWebClientAdapter(WebClient webClient) {
+ this.webClient = webClient;
+ }
+
+ @Override
+ public Mono send(Transaction tx) {
+ TransactionCreatedEvent event = AntiFraudEventMapper.toCreatedEvent(tx);
+
+ return webClient.post()
+ .uri("http://localhost:8081/api/v1/antifraud")
+ .bodyValue(event)
+ .retrieve()
+ .bodyToMono(String.class);
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/FraudScoringWebClient.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/FraudScoringWebClient.java
new file mode 100644
index 0000000000..33314ce9d5
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/FraudScoringWebClient.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.adapter.out.client;
+
+public class FraudScoringWebClient {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/adapter/FraudScoringAdapter.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/adapter/FraudScoringAdapter.java
new file mode 100644
index 0000000000..cdf54707c9
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/adapter/FraudScoringAdapter.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.adapter.out.client.adapter;
+
+public class FraudScoringAdapter {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/FraudScoreRequest.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/FraudScoreRequest.java
new file mode 100644
index 0000000000..cdcfdaeaf6
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/FraudScoreRequest.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.adapter.out.client.dto;
+
+public class FraudScoreRequest {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/FraudScoreResponse.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/FraudScoreResponse.java
new file mode 100644
index 0000000000..e20ba85142
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/FraudScoreResponse.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.adapter.out.client.dto;
+
+public class FraudScoreResponse {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/TransactionCreatedEvent.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/TransactionCreatedEvent.java
new file mode 100644
index 0000000000..a1ffbf0f7c
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/dto/TransactionCreatedEvent.java
@@ -0,0 +1,12 @@
+package com.devpull.transactionservice.adapter.out.client.dto;
+
+
+import java.util.UUID;
+
+public record TransactionCreatedEvent(
+ UUID transactionId,
+ UUID accountId,
+ String type,
+ double amount
+) {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/mapper/AntiFraudEventMapper.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/mapper/AntiFraudEventMapper.java
new file mode 100644
index 0000000000..ad951468ba
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/client/mapper/AntiFraudEventMapper.java
@@ -0,0 +1,18 @@
+package com.devpull.transactionservice.adapter.out.client.mapper;
+
+import com.devpull.transactionservice.adapter.out.client.dto.TransactionCreatedEvent;
+import com.devpull.transactionservice.domain.model.Transaction;
+
+public final class AntiFraudEventMapper {
+
+ private AntiFraudEventMapper() {}
+
+ public static TransactionCreatedEvent toCreatedEvent(Transaction tx) {
+ return new TransactionCreatedEvent(
+ tx.getId(),
+ tx.getAccountId(),
+ tx.getType().name(),
+ tx.getAmount()
+ );
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/adapter/AccountRepositoryAdapter.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/adapter/AccountRepositoryAdapter.java
new file mode 100644
index 0000000000..b47a4a43f9
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/adapter/AccountRepositoryAdapter.java
@@ -0,0 +1,34 @@
+package com.devpull.transactionservice.adapter.out.persistence.adapter;
+
+import com.devpull.transactionservice.adapter.out.persistence.mapper.AccountPersistenceMapper;
+import com.devpull.transactionservice.adapter.out.persistence.repository.R2dbcAccountRepository;
+import com.devpull.transactionservice.application.port.out.AccountRepositoryPort;
+import com.devpull.transactionservice.domain.model.Account;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+@Component
+public class AccountRepositoryAdapter implements AccountRepositoryPort {
+
+ private final R2dbcAccountRepository repository;
+
+ public AccountRepositoryAdapter(R2dbcAccountRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ public Mono save(Account account) {
+ return repository
+ .save(AccountPersistenceMapper.toEntity(account))
+ .map(AccountPersistenceMapper::toDomain);
+ }
+
+ @Override
+ public Mono findById(UUID id) {
+ return repository
+ .findById(id)
+ .map(AccountPersistenceMapper::toDomain);
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/adapter/TransactionRepositoryAdapter.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/adapter/TransactionRepositoryAdapter.java
new file mode 100644
index 0000000000..315f0b2f36
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/adapter/TransactionRepositoryAdapter.java
@@ -0,0 +1,39 @@
+package com.devpull.transactionservice.adapter.out.persistence.adapter;
+
+import com.devpull.transactionservice.adapter.out.persistence.mapper.TransactionPersistenceMapper;
+import com.devpull.transactionservice.adapter.out.persistence.repository.R2dbcTransactionRepository;
+import com.devpull.transactionservice.application.port.out.TransactionRepositoryPort;
+import com.devpull.transactionservice.domain.model.Transaction;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+@Component
+public class TransactionRepositoryAdapter implements TransactionRepositoryPort {
+
+ private final R2dbcTransactionRepository repository;
+
+ public TransactionRepositoryAdapter(R2dbcTransactionRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ public Mono save(Transaction tx) {
+ return repository.save(TransactionPersistenceMapper.toNewEntity(tx))
+ .map(TransactionPersistenceMapper::toDomain);
+ }
+
+ @Override
+ public Mono update(Transaction tx) {
+ return repository.save(TransactionPersistenceMapper.toExistingEntity(tx))
+ .map(TransactionPersistenceMapper::toDomain);
+ }
+
+
+ @Override
+ public Mono findById(UUID id) {
+ return repository.findById(id)
+ .map(TransactionPersistenceMapper::toDomain);
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/entity/AccountEntity.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/entity/AccountEntity.java
new file mode 100644
index 0000000000..ab7b71b3b4
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/entity/AccountEntity.java
@@ -0,0 +1,54 @@
+package com.devpull.transactionservice.adapter.out.persistence.entity;
+
+import com.devpull.transactionservice.domain.enums.AccountStatus;
+import com.devpull.transactionservice.domain.enums.AccountType;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.annotation.Transient;
+import org.springframework.data.domain.Persistable;
+import org.springframework.data.relational.core.mapping.Column;
+import org.springframework.data.relational.core.mapping.Table;
+
+import java.time.Instant;
+import java.util.UUID;
+
+@NoArgsConstructor
+@AllArgsConstructor
+@Getter
+@Table(name = "accounts")
+public class AccountEntity implements Persistable {
+
+ @Id
+ private UUID id;
+
+ @Column("status")
+ private AccountStatus status;
+
+ @Column("type")
+ private AccountType type;
+
+ @Column("created_at")
+ private Instant createdAt;
+
+ @Column("updated_at")
+ private Instant updatedAt;
+
+ @Transient
+ private boolean isNew;
+
+ public static AccountEntity newAccount(UUID id, AccountStatus status, AccountType type, Instant now) {
+ return new AccountEntity(id, status, type, now, now, true);
+ }
+
+ public AccountEntity markNotNew() {
+ this.isNew = false;
+ return this;
+ }
+
+ @Override
+ public boolean isNew() {
+ return isNew;
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/entity/TransactionEntity.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/entity/TransactionEntity.java
new file mode 100644
index 0000000000..2a251bfbf0
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/entity/TransactionEntity.java
@@ -0,0 +1,63 @@
+package com.devpull.transactionservice.adapter.out.persistence.entity;
+
+import com.devpull.transactionservice.domain.enums.TransactionStatus;
+import com.devpull.transactionservice.domain.enums.TransactionType;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.annotation.Transient;
+import org.springframework.data.domain.Persistable;
+import org.springframework.data.relational.core.mapping.Column;
+import org.springframework.data.relational.core.mapping.Table;
+
+import java.time.Instant;
+import java.util.UUID;
+
+@NoArgsConstructor
+@AllArgsConstructor
+@Getter
+@Table(name = "transactions")
+public class TransactionEntity implements Persistable {
+
+ @Id
+ private UUID id;
+
+ @Column("status")
+ private TransactionStatus status;
+
+ @Column("type")
+ private TransactionType type;
+
+ @Column("amount")
+ private double amount;
+
+ @Column("created_at")
+ private Instant createdAt;
+
+ @Column("updated_at")
+ private Instant updatedAt;
+
+ @Column("account_id")
+ private UUID accountId;
+
+ @Transient
+ private boolean isNew;
+
+ public static TransactionEntity newTransaction(UUID id, TransactionStatus status,
+ TransactionType type, double amount,
+ UUID accountId, Instant now) {
+ return new TransactionEntity(id, status, type, amount, now, now, accountId, true);
+ }
+
+ public TransactionEntity markNotNew() {
+ this.isNew = false;
+ return this;
+ }
+
+ @Override
+ public boolean isNew() {
+ return isNew;
+ }
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/mapper/AccountPersistenceMapper.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/mapper/AccountPersistenceMapper.java
new file mode 100644
index 0000000000..e7d37507e6
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/mapper/AccountPersistenceMapper.java
@@ -0,0 +1,27 @@
+package com.devpull.transactionservice.adapter.out.persistence.mapper;
+
+import com.devpull.transactionservice.adapter.out.persistence.entity.AccountEntity;
+import com.devpull.transactionservice.domain.model.Account;
+
+import java.time.Instant;
+
+public final class AccountPersistenceMapper {
+
+ private AccountPersistenceMapper() {}
+
+ public static AccountEntity toEntity(Account domain) {
+ return AccountEntity.newAccount(domain.getId(),
+ domain.getAccountStatus(),
+ domain.getAccountType(),
+ Instant.now());
+ }
+
+ public static Account toDomain(AccountEntity entity) {
+ return Account.rehydrate(
+ entity.getId(),
+ entity.getStatus(),
+ entity.getType(),
+ entity.getCreatedAt()
+ );
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/mapper/TransactionPersistenceMapper.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/mapper/TransactionPersistenceMapper.java
new file mode 100644
index 0000000000..fa48d8cbb6
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/mapper/TransactionPersistenceMapper.java
@@ -0,0 +1,45 @@
+package com.devpull.transactionservice.adapter.out.persistence.mapper;
+
+import com.devpull.transactionservice.adapter.out.persistence.entity.TransactionEntity;
+import com.devpull.transactionservice.domain.model.Transaction;
+
+import java.time.Instant;
+
+public final class TransactionPersistenceMapper {
+
+ private TransactionPersistenceMapper() {}
+
+ public static TransactionEntity toNewEntity(Transaction domain) {
+ return TransactionEntity.newTransaction(domain.getId(),
+ domain.getStatus(),
+ domain.getType(),
+ domain.getAmount(),
+ domain.getAccountId(),
+ Instant.now());
+ }
+
+ public static TransactionEntity toExistingEntity(Transaction tx) {
+ return new TransactionEntity(
+ tx.getId(),
+ tx.getStatus(),
+ tx.getType(),
+ tx.getAmount(),
+ tx.getCreatedAt(),
+ tx.getUpdatedAt(),
+ tx.getAccountId(),
+ false
+ );
+ }
+
+ public static Transaction toDomain(TransactionEntity entity) {
+ return Transaction.rehydrate(
+ entity.getId(),
+ entity.getStatus(),
+ entity.getType(),
+ entity.getAmount(),
+ entity.getCreatedAt(),
+ entity.getUpdatedAt(),
+ entity.getAccountId()
+ );
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/repository/R2dbcAccountRepository.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/repository/R2dbcAccountRepository.java
new file mode 100644
index 0000000000..979969a940
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/repository/R2dbcAccountRepository.java
@@ -0,0 +1,9 @@
+package com.devpull.transactionservice.adapter.out.persistence.repository;
+
+import com.devpull.transactionservice.adapter.out.persistence.entity.AccountEntity;
+import org.springframework.data.repository.reactive.ReactiveCrudRepository;
+
+import java.util.UUID;
+
+public interface R2dbcAccountRepository extends ReactiveCrudRepository {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/repository/R2dbcTransactionRepository.java b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/repository/R2dbcTransactionRepository.java
new file mode 100644
index 0000000000..7678e0f2da
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/adapter/out/persistence/repository/R2dbcTransactionRepository.java
@@ -0,0 +1,9 @@
+package com.devpull.transactionservice.adapter.out.persistence.repository;
+
+import com.devpull.transactionservice.adapter.out.persistence.entity.TransactionEntity;
+import org.springframework.data.repository.reactive.ReactiveCrudRepository;
+
+import java.util.UUID;
+
+public interface R2dbcTransactionRepository extends ReactiveCrudRepository {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/command/CreateAccountCommand.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/command/CreateAccountCommand.java
new file mode 100644
index 0000000000..43c6eb7ad7
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/command/CreateAccountCommand.java
@@ -0,0 +1,9 @@
+package com.devpull.transactionservice.application.dto.command;
+
+import com.devpull.transactionservice.domain.enums.AccountStatus;
+import com.devpull.transactionservice.domain.enums.AccountType;
+
+public record CreateAccountCommand(
+ AccountStatus accountStatus,
+ AccountType accountType
+) {}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/command/CreateTransactionCommand.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/command/CreateTransactionCommand.java
new file mode 100644
index 0000000000..9a4c93322f
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/command/CreateTransactionCommand.java
@@ -0,0 +1,10 @@
+package com.devpull.transactionservice.application.dto.command;
+
+import java.util.UUID;
+
+public record CreateTransactionCommand(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ String transferTypeId,
+ Double value
+) {}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/result/AccountResult.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/result/AccountResult.java
new file mode 100644
index 0000000000..82a3bbedde
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/result/AccountResult.java
@@ -0,0 +1,12 @@
+package com.devpull.transactionservice.application.dto.result;
+
+import com.devpull.transactionservice.domain.enums.AccountStatus;
+import com.devpull.transactionservice.domain.enums.AccountType;
+
+import java.util.UUID;
+
+public record AccountResult(
+ UUID id,
+ AccountStatus accountStatus,
+ AccountType accountType
+) {}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/result/TransactionResult.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/result/TransactionResult.java
new file mode 100644
index 0000000000..a28440fce1
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/dto/result/TransactionResult.java
@@ -0,0 +1,18 @@
+package com.devpull.transactionservice.application.dto.result;
+
+import com.devpull.transactionservice.domain.enums.TransactionStatus;
+import com.devpull.transactionservice.domain.enums.TransactionType;
+
+import java.time.Instant;
+import java.util.UUID;
+
+public record TransactionResult(
+ UUID id,
+ TransactionStatus status,
+ TransactionType type,
+ double amount,
+ Instant createdAt,
+ Instant updatedAt,
+ UUID accountId
+)
+{}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/mapper/AccountApplicationMapper.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/mapper/AccountApplicationMapper.java
new file mode 100644
index 0000000000..4ef5dec062
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/mapper/AccountApplicationMapper.java
@@ -0,0 +1,18 @@
+package com.devpull.transactionservice.application.mapper;
+
+import com.devpull.transactionservice.application.dto.result.AccountResult;
+import com.devpull.transactionservice.domain.model.Account;
+
+public final class AccountApplicationMapper {
+
+ private AccountApplicationMapper() {}
+
+ public static AccountResult toResult(Account account) {
+ return new AccountResult(
+ account.getId(),
+ account.getAccountStatus(),
+ account.getAccountType()
+ );
+ }
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/CreateAccountUseCase.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/CreateAccountUseCase.java
new file mode 100644
index 0000000000..a830ea441d
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/CreateAccountUseCase.java
@@ -0,0 +1,9 @@
+package com.devpull.transactionservice.application.port.in;
+
+import com.devpull.transactionservice.application.dto.command.CreateAccountCommand;
+import com.devpull.transactionservice.application.dto.result.AccountResult;
+import reactor.core.publisher.Mono;
+
+public interface CreateAccountUseCase {
+ Mono createAccount(CreateAccountCommand command);
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/CreateTransactionUseCase.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/CreateTransactionUseCase.java
new file mode 100644
index 0000000000..f7cd302ccb
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/CreateTransactionUseCase.java
@@ -0,0 +1,11 @@
+package com.devpull.transactionservice.application.port.in;
+
+import com.devpull.transactionservice.application.dto.command.CreateTransactionCommand;
+import com.devpull.transactionservice.application.dto.result.TransactionResult;
+import reactor.core.publisher.Mono;
+
+public interface CreateTransactionUseCase {
+
+ Mono create(CreateTransactionCommand command);
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/GetAccountUseCase.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/GetAccountUseCase.java
new file mode 100644
index 0000000000..6afac2b601
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/GetAccountUseCase.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.application.port.in;
+
+public interface GetAccountUseCase {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/GetTransactionUseCase.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/GetTransactionUseCase.java
new file mode 100644
index 0000000000..82828d2dec
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/GetTransactionUseCase.java
@@ -0,0 +1,10 @@
+package com.devpull.transactionservice.application.port.in;
+
+import com.devpull.transactionservice.application.dto.result.TransactionResult;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+public interface GetTransactionUseCase {
+ Mono getById(UUID transactionId);
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/ListTransactionsUseCase.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/ListTransactionsUseCase.java
new file mode 100644
index 0000000000..5ac918c21c
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/ListTransactionsUseCase.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.application.port.in;
+
+public interface ListTransactionsUseCase {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/UpdateTransactionStatusUseCase.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/UpdateTransactionStatusUseCase.java
new file mode 100644
index 0000000000..f0607e8c2f
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/in/UpdateTransactionStatusUseCase.java
@@ -0,0 +1,9 @@
+package com.devpull.transactionservice.application.port.in;
+
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+public interface UpdateTransactionStatusUseCase {
+ Mono updateStatus(UUID transactionId, String status);
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/AccountRepositoryPort.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/AccountRepositoryPort.java
new file mode 100644
index 0000000000..f30695c4bb
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/AccountRepositoryPort.java
@@ -0,0 +1,14 @@
+package com.devpull.transactionservice.application.port.out;
+
+import com.devpull.transactionservice.domain.model.Account;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+public interface AccountRepositoryPort {
+
+ Mono save(Account account);
+
+ Mono findById(UUID id);
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/AntiFraudPort.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/AntiFraudPort.java
new file mode 100644
index 0000000000..780d599f93
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/AntiFraudPort.java
@@ -0,0 +1,10 @@
+package com.devpull.transactionservice.application.port.out;
+
+import com.devpull.transactionservice.domain.model.Transaction;
+import reactor.core.publisher.Mono;
+
+public interface AntiFraudPort {
+
+ Mono send(Transaction tx);
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/TransactionRepositoryPort.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/TransactionRepositoryPort.java
new file mode 100644
index 0000000000..37f10b4540
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/port/out/TransactionRepositoryPort.java
@@ -0,0 +1,16 @@
+package com.devpull.transactionservice.application.port.out;
+
+import com.devpull.transactionservice.domain.model.Transaction;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+public interface TransactionRepositoryPort {
+
+ Mono save(Transaction tx);
+
+ Mono update(Transaction tx);
+
+ Mono findById(UUID id);
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/CreateAccountService.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/CreateAccountService.java
new file mode 100644
index 0000000000..c5fab9c991
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/CreateAccountService.java
@@ -0,0 +1,42 @@
+package com.devpull.transactionservice.application.usecase;
+
+import com.devpull.transactionservice.application.dto.command.CreateAccountCommand;
+import com.devpull.transactionservice.application.dto.result.AccountResult;
+import com.devpull.transactionservice.application.mapper.AccountApplicationMapper;
+import com.devpull.transactionservice.application.port.in.CreateAccountUseCase;
+import com.devpull.transactionservice.application.port.out.AccountRepositoryPort;
+import com.devpull.transactionservice.domain.exception.BadRequestException;
+import com.devpull.transactionservice.domain.model.Account;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+@Service
+public class CreateAccountService implements CreateAccountUseCase {
+
+ private final AccountRepositoryPort accountRepositoryPort;
+
+ public CreateAccountService(AccountRepositoryPort accountRepositoryPort) {
+ this.accountRepositoryPort = accountRepositoryPort;
+ }
+
+ @Override
+ public Mono createAccount(CreateAccountCommand command) {
+
+ if (command == null) {
+ return Mono.error(new BadRequestException("Request body is required"));
+ }
+ if (command.accountStatus() == null) {
+ return Mono.error(new BadRequestException("accountStatus is required"));
+ }
+ if (command.accountType() == null) {
+ return Mono.error(new BadRequestException("accountType is required"));
+ }
+
+ // Domain object creation
+ Account account = Account.createNew(command.accountStatus(), command.accountType());
+
+ // Persistence and mapping to result
+ return accountRepositoryPort.save(account)
+ .map(AccountApplicationMapper::toResult);
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/CreateTransactionService.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/CreateTransactionService.java
new file mode 100644
index 0000000000..7f67c812c7
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/CreateTransactionService.java
@@ -0,0 +1,85 @@
+package com.devpull.transactionservice.application.usecase;
+
+import com.devpull.transactionservice.application.dto.command.CreateTransactionCommand;
+import com.devpull.transactionservice.application.dto.result.TransactionResult;
+import com.devpull.transactionservice.application.port.in.CreateTransactionUseCase;
+import com.devpull.transactionservice.application.port.out.AccountRepositoryPort;
+import com.devpull.transactionservice.application.port.out.AntiFraudPort;
+import com.devpull.transactionservice.application.port.out.TransactionRepositoryPort;
+import com.devpull.transactionservice.domain.enums.TransactionType;
+import com.devpull.transactionservice.domain.exception.BadRequestException;
+import com.devpull.transactionservice.domain.exception.ResourceNotFoundException;
+import com.devpull.transactionservice.domain.model.Transaction;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+@Service
+public class CreateTransactionService implements CreateTransactionUseCase {
+
+ private final TransactionRepositoryPort txPort;
+ private final AccountRepositoryPort accountPort;
+ private final AntiFraudPort antiFraudPort;
+
+ public CreateTransactionService(TransactionRepositoryPort txPort,
+ AccountRepositoryPort accountPort,
+ AntiFraudPort antiFraudPort) {
+ this.txPort = txPort;
+ this.accountPort = accountPort;
+ this.antiFraudPort = antiFraudPort;
+ }
+
+
+ @Override
+ public Mono create(CreateTransactionCommand command) {
+ if (command == null) return Mono.error(new BadRequestException("Request body is required"));
+ if (command.value() == null || command.value() <= 0) return Mono.error(new BadRequestException("value must be > 0"));
+ if (command.transferTypeId() == null || command.transferTypeId().isBlank())
+ return Mono.error(new BadRequestException("transferTypeId is required"));
+
+ UUID accountId = (command.accountExternalIdCredit() != null)
+ ? command.accountExternalIdCredit()
+ : command.accountExternalIdDebit();
+
+ if (accountId == null) {
+ return Mono.error(new BadRequestException("Either accountExternalIdCredit or accountExternalIdDebit is required"));
+ }
+
+ TransactionType type = mapTransferType(command.transferTypeId());
+
+ // 1) validate account exists
+ return accountPort.findById(accountId)
+ .switchIfEmpty(Mono.error(new ResourceNotFoundException(
+ "Account", "id", accountId
+ )))
+ // 2) create & save transaction
+ .then(Mono.defer(() -> txPort.save(Transaction.createNew(accountId, type, command.value()))))
+ // 3) send to anti-fraud
+ .flatMap(saved ->
+ antiFraudPort.send(saved)
+ .onErrorResume(e -> Mono.just("antifraud_error")) //not fail the whole flow if antifraud fails
+ .thenReturn(saved)
+ )
+ // 4) map to result object
+ .map(saved -> new TransactionResult(
+ saved.getId(),
+ saved.getStatus(),
+ saved.getType(),
+ saved.getAmount(),
+ saved.getCreatedAt(),
+ saved.getUpdatedAt(),
+ saved.getAccountId()
+ ));
+
+ }
+
+ private TransactionType mapTransferType(String transferTypeId) {
+ try {
+ return TransactionType.valueOf(transferTypeId.trim().toUpperCase());
+ } catch (Exception e) {
+ // fallback (o BadRequest)
+ return TransactionType.FAST;
+ }
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/GetAccountService.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/GetAccountService.java
new file mode 100644
index 0000000000..8c4addc679
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/GetAccountService.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.application.usecase;
+
+public class GetAccountService {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/GetTransactionService.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/GetTransactionService.java
new file mode 100644
index 0000000000..8d962f3295
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/GetTransactionService.java
@@ -0,0 +1,35 @@
+package com.devpull.transactionservice.application.usecase;
+
+import com.devpull.transactionservice.application.dto.result.TransactionResult;
+import com.devpull.transactionservice.application.port.in.GetTransactionUseCase;
+import com.devpull.transactionservice.application.port.out.TransactionRepositoryPort;
+import com.devpull.transactionservice.domain.exception.ResourceNotFoundException;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+@Service
+public class GetTransactionService implements GetTransactionUseCase {
+
+ private final TransactionRepositoryPort txPort;
+
+ public GetTransactionService(TransactionRepositoryPort txPort) {
+ this.txPort = txPort;
+ }
+
+ @Override
+ public Mono getById(UUID transactionId) {
+ return txPort.findById(transactionId)
+ .switchIfEmpty(Mono.error(new ResourceNotFoundException("Transaction", "id", transactionId)))
+ .map(tx -> new TransactionResult(
+ tx.getId(),
+ tx.getStatus(),
+ tx.getType(),
+ tx.getAmount(),
+ tx.getCreatedAt(),
+ tx.getUpdatedAt(),
+ tx.getAccountId()
+ ));
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/ListTransactionsService.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/ListTransactionsService.java
new file mode 100644
index 0000000000..ed50b01fb1
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/ListTransactionsService.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.application.usecase;
+
+public class ListTransactionsService {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/UpdateTransactionStatusService.java b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/UpdateTransactionStatusService.java
new file mode 100644
index 0000000000..9be91e2995
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/application/usecase/UpdateTransactionStatusService.java
@@ -0,0 +1,35 @@
+package com.devpull.transactionservice.application.usecase;
+
+import com.devpull.transactionservice.application.port.in.UpdateTransactionStatusUseCase;
+import com.devpull.transactionservice.application.port.out.TransactionRepositoryPort;
+import com.devpull.transactionservice.domain.enums.TransactionStatus;
+import com.devpull.transactionservice.domain.exception.ResourceNotFoundException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+
+@Service
+@Slf4j
+public class UpdateTransactionStatusService implements UpdateTransactionStatusUseCase {
+
+ private final TransactionRepositoryPort txPort;
+
+ public UpdateTransactionStatusService(TransactionRepositoryPort txPort) {
+ this.txPort = txPort;
+ }
+
+ @Override
+ public Mono updateStatus(UUID transactionId, String status) {
+ TransactionStatus newStatus = TransactionStatus.valueOf(status.trim().toUpperCase());
+ log.info(":::::::: Updating transaction {} status to {}", transactionId, newStatus);
+ return txPort.findById(transactionId)
+ .switchIfEmpty(Mono.error(new ResourceNotFoundException("Transaction", "id", transactionId)))
+ .flatMap(tx -> {
+ tx.setStatus(newStatus);
+ return txPort.update(tx);
+ })
+ .then();
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/AccountStatus.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/AccountStatus.java
new file mode 100644
index 0000000000..1d46a2803d
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/AccountStatus.java
@@ -0,0 +1,6 @@
+package com.devpull.transactionservice.domain.enums;
+
+public enum AccountStatus {
+ AVAILABLE,
+ UNAVAILABLE,
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/AccountType.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/AccountType.java
new file mode 100644
index 0000000000..3c5f94c0c3
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/AccountType.java
@@ -0,0 +1,6 @@
+package com.devpull.transactionservice.domain.enums;
+
+public enum AccountType {
+ CREDIT,
+ DEBIT
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/TransactionStatus.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/TransactionStatus.java
new file mode 100644
index 0000000000..f000182541
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/TransactionStatus.java
@@ -0,0 +1,7 @@
+package com.devpull.transactionservice.domain.enums;
+
+public enum TransactionStatus {
+ PENDING,
+ APPROVED,
+ REJECTED
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/TransactionType.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/TransactionType.java
new file mode 100644
index 0000000000..aaa1c4fb03
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/enums/TransactionType.java
@@ -0,0 +1,8 @@
+package com.devpull.transactionservice.domain.enums;
+
+public enum TransactionType {
+ EXPRESS,
+ FAST,
+ COMMON,
+ UNUSUAL
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/APIException.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/APIException.java
new file mode 100644
index 0000000000..ca1d62388c
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/APIException.java
@@ -0,0 +1,16 @@
+package com.devpull.transactionservice.domain.exception;
+
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+public class APIException extends RuntimeException{
+
+ private final HttpStatus httpStatus;
+
+ public APIException(HttpStatus httpStatus, String message) {
+ super(message);
+ this.httpStatus = httpStatus;
+ }
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/BadRequestException.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/BadRequestException.java
new file mode 100644
index 0000000000..f5cf87d8fd
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/BadRequestException.java
@@ -0,0 +1,8 @@
+package com.devpull.transactionservice.domain.exception;
+
+public class BadRequestException extends RuntimeException {
+
+ public BadRequestException(String message) {
+ super(message);
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/ResourceNotFoundException.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/ResourceNotFoundException.java
new file mode 100644
index 0000000000..6d19406e4e
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/exception/ResourceNotFoundException.java
@@ -0,0 +1,21 @@
+package com.devpull.transactionservice.domain.exception;
+
+import lombok.Getter;
+
+import java.util.UUID;
+
+@Getter
+public class ResourceNotFoundException extends RuntimeException {
+
+ private final String resourceName;
+ private final String fieldName;
+ private final UUID fieldValue;
+
+ public ResourceNotFoundException(String resourceName, String fieldName, UUID fieldValue) {
+ super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue));
+ this.resourceName = resourceName;
+ this.fieldName = fieldName;
+ this.fieldValue = fieldValue;
+ }
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/model/Account.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/model/Account.java
new file mode 100644
index 0000000000..b1384ad067
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/model/Account.java
@@ -0,0 +1,80 @@
+package com.devpull.transactionservice.domain.model;
+
+import com.devpull.transactionservice.domain.enums.AccountStatus;
+import com.devpull.transactionservice.domain.enums.AccountType;
+import lombok.Getter;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.UUID;
+
+@Getter
+public class Account {
+
+ private final UUID id;
+ private AccountStatus accountStatus;
+ private final AccountType accountType;
+ private final Instant createdAt;
+
+ private Account(UUID id, AccountStatus accountStatus, AccountType accountType, Instant createdAt) {
+ this.id = Objects.requireNonNull(id, "id must not be null");
+ this.accountStatus = Objects.requireNonNull(accountStatus, "accountStatus must not be null");
+ this.accountType = Objects.requireNonNull(accountType, "accountType must not be null");
+ this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null");
+
+ validateInvariants();
+ }
+
+ /**
+ * Factory: to create a new account obj.
+ */
+ public static Account createNew(AccountStatus accountStatus, AccountType accountType) {
+ return new Account(
+ UUID.randomUUID(),
+ accountStatus,
+ accountType,
+ Instant.now()
+ );
+ }
+
+ /**
+ * Factory: to work from persistence slice (already exists).
+ */
+ public static Account rehydrate(UUID id, AccountStatus accountStatus, AccountType accountType, Instant createdAt) {
+ return new Account(id, accountStatus, accountType, createdAt);
+ }
+
+ /**
+ * Domain behavior example.
+ */
+ public void activate() {
+ if (this.accountStatus == AccountStatus.AVAILABLE) return;
+
+ if (this.accountStatus == AccountStatus.UNAVAILABLE) {
+ throw new IllegalStateException("A closed account cannot be activated");
+ }
+ this.accountStatus = AccountStatus.AVAILABLE;
+ }
+
+ public void suspend() {
+ if (this.accountStatus == AccountStatus.UNAVAILABLE) {
+ throw new IllegalStateException("A closed account cannot be suspended");
+ }
+ this.accountStatus = AccountStatus.UNAVAILABLE;
+ }
+
+ public void close() {
+ this.accountStatus = AccountStatus.UNAVAILABLE;
+ }
+
+ public boolean isActive() {
+ return this.accountStatus == AccountStatus.AVAILABLE;
+ }
+
+ private void validateInvariants() {
+ if (accountStatus == AccountStatus.UNAVAILABLE) {
+ throw new IllegalArgumentException("Account cannot be created with status CLOSED");
+ }
+ }
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/model/Transaction.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/model/Transaction.java
new file mode 100644
index 0000000000..c084a1f270
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/model/Transaction.java
@@ -0,0 +1,48 @@
+package com.devpull.transactionservice.domain.model;
+
+import com.devpull.transactionservice.domain.enums.TransactionStatus;
+import com.devpull.transactionservice.domain.enums.TransactionType;
+import lombok.Getter;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.UUID;
+
+@Getter
+public class Transaction {
+
+ private final UUID id;
+ private TransactionStatus status;
+ private final TransactionType type;
+ private final double amount;
+ private final Instant createdAt;
+ private final Instant updatedAt;
+ private final UUID accountId;
+
+ private Transaction(UUID id, TransactionStatus status, TransactionType type, double amount,
+ Instant createdAt, Instant updatedAt, UUID accountId) {
+ this.id = Objects.requireNonNull(id);
+ this.status = Objects.requireNonNull(status);
+ this.type = Objects.requireNonNull(type);
+ if (amount <= 0) throw new IllegalArgumentException("amount must be > 0");
+ this.amount = amount;
+ this.createdAt = Objects.requireNonNull(createdAt);
+ this.updatedAt = Objects.requireNonNull(updatedAt);
+ this.accountId = Objects.requireNonNull(accountId);
+ }
+
+ public static Transaction createNew(UUID accountId, TransactionType type, double amount) {
+ Instant now = Instant.now();
+ return new Transaction(UUID.randomUUID(), TransactionStatus.PENDING, type, amount, now, now, accountId);
+ }
+
+ public static Transaction rehydrate(UUID id, TransactionStatus status, TransactionType type, double amount,
+ Instant createdAt, Instant updatedAt, UUID accountId) {
+ return new Transaction(id, status, type, amount, createdAt, updatedAt, accountId);
+ }
+
+ public void setStatus(TransactionStatus status) {
+ this.status = Objects.requireNonNull(status);
+ }
+
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/domain/service/FraudPolicy.java b/transaction-service/src/main/java/com/devpull/transactionservice/domain/service/FraudPolicy.java
new file mode 100644
index 0000000000..ebeb71d13a
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/domain/service/FraudPolicy.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.domain.service;
+
+public class FraudPolicy {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/OpenApiConfig.java b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/OpenApiConfig.java
new file mode 100644
index 0000000000..3787465672
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/OpenApiConfig.java
@@ -0,0 +1,4 @@
+package com.devpull.transactionservice.infrastructure.config;
+
+public class OpenApiConfig {
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/PersistenceConfig.java b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/PersistenceConfig.java
new file mode 100644
index 0000000000..63eda02148
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/PersistenceConfig.java
@@ -0,0 +1,114 @@
+package com.devpull.transactionservice.infrastructure.config;
+
+import com.devpull.transactionservice.domain.enums.AccountStatus;
+import com.devpull.transactionservice.domain.enums.AccountType;
+import com.devpull.transactionservice.domain.enums.TransactionStatus;
+import com.devpull.transactionservice.domain.enums.TransactionType;
+import io.r2dbc.spi.ConnectionFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.data.convert.ReadingConverter;
+import org.springframework.data.convert.WritingConverter;
+import org.springframework.data.r2dbc.convert.R2dbcCustomConversions;
+import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
+import org.springframework.r2dbc.connection.R2dbcTransactionManager;
+import org.springframework.transaction.ReactiveTransactionManager;
+import org.springframework.transaction.reactive.TransactionalOperator;
+
+import java.util.List;
+
+@Configuration
+public class PersistenceConfig {
+
+ @Bean
+ public R2dbcEntityTemplate r2dbcEntityTemplate(ConnectionFactory connectionFactory) {
+ return new R2dbcEntityTemplate(connectionFactory);
+ }
+
+ @Bean
+ public ReactiveTransactionManager reactiveTransactionManager(ConnectionFactory connectionFactory) {
+ return new R2dbcTransactionManager(connectionFactory);
+ }
+
+ @Bean
+ public TransactionalOperator transactionalOperator(ReactiveTransactionManager txManager) {
+ return TransactionalOperator.create(txManager);
+ }
+
+ @Bean
+ public R2dbcCustomConversions r2dbcCustomConversions() {
+ List> converters = List.of(
+ new TransactionStatusReadConverter(),
+ new TransactionStatusWriteConverter(),
+ new TransactionTypeReadConverter(),
+ new TransactionTypeWriteConverter(),
+ new AccountStatusReadConverter(),
+ new AccountStatusWriteConverter(),
+ new AccountTypeReadConverter(),
+ new AccountTypeWriteConverter()
+ );
+
+ return new R2dbcCustomConversions(R2dbcCustomConversions.STORE_CONVERSIONS, converters);
+ }
+
+ // ---------- TransactionStatus ----------
+ @ReadingConverter
+ static class TransactionStatusReadConverter implements Converter {
+ @Override public TransactionStatus convert(String source) {
+ return source == null ? null : TransactionStatus.valueOf(source.trim().toUpperCase());
+ }
+ }
+
+ @WritingConverter
+ static class TransactionStatusWriteConverter implements Converter {
+ @Override public String convert(TransactionStatus source) {
+ return source == null ? null : source.name();
+ }
+ }
+
+ // ---------- TransactionType ----------
+ @ReadingConverter
+ static class TransactionTypeReadConverter implements Converter {
+ @Override public TransactionType convert(String source) {
+ return source == null ? null : TransactionType.valueOf(source.trim().toUpperCase());
+ }
+ }
+
+ @WritingConverter
+ static class TransactionTypeWriteConverter implements Converter {
+ @Override public String convert(TransactionType source) {
+ return source == null ? null : source.name();
+ }
+ }
+
+ // ---------- AccountStatus ----------
+ @ReadingConverter
+ static class AccountStatusReadConverter implements Converter {
+ @Override public AccountStatus convert(String source) {
+ return source == null ? null : AccountStatus.valueOf(source.trim().toUpperCase());
+ }
+ }
+
+ @WritingConverter
+ static class AccountStatusWriteConverter implements Converter {
+ @Override public String convert(AccountStatus source) {
+ return source == null ? null : source.name();
+ }
+ }
+
+ // ---------- AccountType ----------
+ @ReadingConverter
+ static class AccountTypeReadConverter implements Converter {
+ @Override public AccountType convert(String source) {
+ return source == null ? null : AccountType.valueOf(source.trim().toUpperCase());
+ }
+ }
+
+ @WritingConverter
+ static class AccountTypeWriteConverter implements Converter {
+ @Override public String convert(AccountType source) {
+ return source == null ? null : source.name();
+ }
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/WebClientConfig.java b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/WebClientConfig.java
new file mode 100644
index 0000000000..93428ef23e
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/config/WebClientConfig.java
@@ -0,0 +1,26 @@
+package com.devpull.transactionservice.infrastructure.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.reactive.ClientHttpConnector;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.netty.http.client.HttpClient;
+
+
+@Configuration
+public class WebClientConfig {
+
+ private static final int TIMEOUT = 5000;
+
+ @Bean
+ public WebClient webClient() {
+ HttpClient httpClient = HttpClient.create()
+ .responseTimeout(java.time.Duration.ofMillis(TIMEOUT));
+
+ ClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);
+ return WebClient.builder()
+ .clientConnector(connector)
+ .build();
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/ApiExceptionMapper.java b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/ApiExceptionMapper.java
new file mode 100644
index 0000000000..e5ed787049
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/ApiExceptionMapper.java
@@ -0,0 +1,83 @@
+package com.devpull.transactionservice.infrastructure.exception;
+
+import com.devpull.transactionservice.domain.exception.APIException;
+import com.devpull.transactionservice.domain.exception.BadRequestException;
+import com.devpull.transactionservice.domain.exception.ResourceNotFoundException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.ServerWebInputException;
+
+import java.util.Map;
+
+@Component
+public class ApiExceptionMapper {
+
+ public ResponseEntity toResponse(Throwable ex, ServerWebExchange exchange) {
+ String path = exchange.getRequest().getPath().value();
+
+ // Not found
+ if (ex instanceof ResourceNotFoundException) {
+ return build(HttpStatus.NOT_FOUND, ex.getMessage(), path, null);
+ }
+
+ // Bad request
+ if (ex instanceof BadRequestException) {
+ return build(HttpStatus.BAD_REQUEST, ex.getMessage(), path, null);
+ }
+
+ // APIException
+ if (ex instanceof APIException apiEx) {
+ HttpStatus status = apiEx.getHttpStatus() != null ? apiEx.getHttpStatus() : HttpStatus.CONFLICT;
+ return build(status, apiEx.getMessage(), path, null);
+ }
+
+ // Validation / body malformed / query param not valid (WebFlux)
+ // sometimes ServerWebInputException wrap binding error, decoding, etc.
+ if (ex instanceof ServerWebInputException) {
+ return build(HttpStatus.BAD_REQUEST, safeMessage(ex), path, null);
+ }
+
+ // If in some point launch ResponseStatusException (WebFlux normal behavior)
+ if (ex instanceof ResponseStatusException rse) {
+ HttpStatus status = HttpStatus.resolve(rse.getStatusCode().value());
+ if (status == null) status = HttpStatus.INTERNAL_SERVER_ERROR;
+ return build(status, safeMessage(rse), path, null);
+ }
+
+ // Fallback
+ return build(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error", path, null);
+ }
+
+ public ResponseEntity toValidationResponse(
+ Map fieldErrors,
+ ServerWebExchange exchange
+ ) {
+ String path = exchange.getRequest().getPath().value();
+ return build(HttpStatus.BAD_REQUEST, "Validation failed", path, fieldErrors);
+ }
+
+ private ResponseEntity build(
+ HttpStatus status,
+ String message,
+ String path,
+ Map validationErrors
+ ) {
+ ErrorDetails body = ErrorDetails.builder()
+ .status(status.value())
+ .error(status.getReasonPhrase())
+ .message(message)
+ .path(path)
+ .validationErrors(validationErrors == null || validationErrors.isEmpty() ? null : validationErrors)
+ .build();
+
+ return ResponseEntity.status(status).body(body);
+ }
+
+ private String safeMessage(Throwable ex) {
+ // Avoid nulls surprises in response
+ return (ex.getMessage() == null || ex.getMessage().isBlank()) ? ex.getClass().getSimpleName() : ex.getMessage();
+ }
+}
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/ErrorDetails.java b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/ErrorDetails.java
new file mode 100644
index 0000000000..305332c58e
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/ErrorDetails.java
@@ -0,0 +1,77 @@
+package com.devpull.transactionservice.infrastructure.exception;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Getter;
+
+import java.time.Instant;
+import java.util.Map;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@Getter
+public class ErrorDetails {
+
+ private final Instant timestamp;
+ private final int status;
+ private final String error;
+ private final String message;
+ private final String path;
+ private final Map validationErrors;
+
+ private ErrorDetails(Builder builder) {
+ this.timestamp = builder.timestamp;
+ this.status = builder.status;
+ this.error = builder.error;
+ this.message = builder.message;
+ this.path = builder.path;
+ this.validationErrors = builder.validationErrors;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private Instant timestamp = Instant.now();
+ private int status;
+ private String error;
+ private String message;
+ private String path;
+ private Map validationErrors;
+
+ public Builder timestamp(Instant timestamp) {
+ this.timestamp = timestamp;
+ return this;
+ }
+
+ public Builder status(int status) {
+ this.status = status;
+ return this;
+ }
+
+ public Builder error(String error) {
+ this.error = error;
+ return this;
+ }
+
+ public Builder message(String message) {
+ this.message = message;
+ return this;
+ }
+
+ public Builder path(String path) {
+ this.path = path;
+ return this;
+ }
+
+ public Builder validationErrors(Map validationErrors) {
+ this.validationErrors = validationErrors;
+ return this;
+ }
+
+ public ErrorDetails build() {
+ return new ErrorDetails(this);
+ }
+ }
+}
+
+
diff --git a/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/GlobalExceptionHandler.java b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000000..52b06a5be0
--- /dev/null
+++ b/transaction-service/src/main/java/com/devpull/transactionservice/infrastructure/exception/GlobalExceptionHandler.java
@@ -0,0 +1,47 @@
+package com.devpull.transactionservice.infrastructure.exception;
+
+import org.springframework.core.annotation.Order;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.bind.support.WebExchangeBindException;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@RestControllerAdvice
+@Order(-2) // to catch it early
+public class GlobalExceptionHandler {
+
+ private final ApiExceptionMapper mapper;
+
+ public GlobalExceptionHandler(ApiExceptionMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ /**
+ * Validation @Valid in WebFlux normally throw WebExchangeBindException.
+ */
+ @ExceptionHandler(WebExchangeBindException.class)
+ public Mono> handleValidation(
+ WebExchangeBindException ex,
+ ServerWebExchange exchange
+ ) {
+ Map errors = new LinkedHashMap<>();
+ for (FieldError fe : ex.getFieldErrors()) {
+ errors.put(fe.getField(), fe.getDefaultMessage());
+ }
+ return Mono.just(mapper.toValidationResponse(errors, exchange));
+ }
+
+ /**
+ * Handler global: map any know exception or unknown to proper response.
+ */
+ @ExceptionHandler(Throwable.class)
+ public Mono> handleAny(Throwable ex, ServerWebExchange exchange) {
+ return Mono.just(mapper.toResponse(ex, exchange));
+ }
+}
diff --git a/transaction-service/src/main/resources/application.yml b/transaction-service/src/main/resources/application.yml
new file mode 100644
index 0000000000..fe2f5358d9
--- /dev/null
+++ b/transaction-service/src/main/resources/application.yml
@@ -0,0 +1,41 @@
+
+app:
+ boot-marker: "transaction-service-yml-loaded"
+
+server:
+ port: ${SERVER_PORT:8080}
+
+spring:
+ application:
+ name: transaction-service
+
+ main:
+ web-application-type: reactive
+
+ r2dbc:
+ url: ${R2DBC_URL:r2dbc:postgresql://localhost:5433/app_java_codechallenge}
+ username: ${DB_USERNAME:postgres}
+ password: ${DB_PASSWORD:postgres}
+
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+
+ consumer:
+ group-id: ${KAFKA_CONSUMER_GROUP:transaction-service}
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer
+
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.apache.kafka.common.serialization.ByteArraySerializer
+
+ topic:
+ name: ${KAFKA_TOPIC_TRANSACTIONS:transactions_fraud}
+
+logging:
+ level:
+ io.r2dbc.postgresql.QUERY: ${LOG_R2DBC_QUERY:DEBUG}
+ io.r2dbc.postgresql.PARAM: ${LOG_R2DBC_PARAM:DEBUG}
+ org.springframework.kafka: DEBUG
+ org.apache.kafka: INFO
diff --git a/transaction-service/src/test/java/com/devpull/transactionservice/TransactionServiceApplicationTests.java b/transaction-service/src/test/java/com/devpull/transactionservice/TransactionServiceApplicationTests.java
new file mode 100644
index 0000000000..a63d292cfd
--- /dev/null
+++ b/transaction-service/src/test/java/com/devpull/transactionservice/TransactionServiceApplicationTests.java
@@ -0,0 +1,13 @@
+package com.devpull.transactionservice;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class TransactionServiceApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}