From 66cfed50c45fe3fe4b82addc308b0f2062b40500 Mon Sep 17 00:00:00 2001 From: Luis Alberto Gamarra Astocondor Date: Sat, 28 Feb 2026 20:43:39 -0500 Subject: [PATCH 1/6] feat: upload ms transaction and ms antifraud --- antifraud/.gitattributes | 2 + antifraud/.gitignore | 33 ++ .../.mvn/wrapper/maven-wrapper.properties | 3 + antifraud/mvnw | 295 ++++++++++++++++++ antifraud/mvnw.cmd | 189 +++++++++++ antifraud/pom.xml | 121 +++++++ .../yape/antifraud/AntifraudApplication.java | 13 + .../service/ValidateAntiFraudService.java | 39 +++ .../usecase/ValidateAntiFraudUseCase.java | 8 + .../domain/enums/TransactionStatus.java | 15 + .../antifraud/domain/model/EventInbound.java | 13 + .../antifraud/domain/model/EventOutbound.java | 15 + .../domain/ports/KafkaProducerPort.java | 8 + .../infraestructure/kafka/KafkaConfig.java | 53 ++++ .../infraestructure/kafka/KafkaConsumer.java | 62 ++++ .../infraestructure/kafka/KafkaProducer.java | 37 +++ .../src/main/resources/application.properties | 1 + antifraud/src/main/resources/application.yml | 8 + .../antifraud/AntifraudApplicationTests.java | 13 + docker-compose.yml | 6 +- transaction/.gitattributes | 2 + transaction/.gitignore | 33 ++ .../.mvn/wrapper/maven-wrapper.properties | 3 + transaction/mvnw | 295 ++++++++++++++++++ transaction/mvnw.cmd | 189 +++++++++++ transaction/pom.xml | 131 ++++++++ .../transaction/TransactionApplication.java | 13 + .../application/dto/BaseTransaction.java | 12 + .../dto/CreateTransactionRequest.java | 27 ++ .../dto/CreateTransactionResponse.java | 12 + .../dto/GetTransactionResponse.java | 18 ++ .../application/mapper/TransactionMapper.java | 57 ++++ .../service/CreateTransactionService.java | 55 ++++ .../service/GetTransactionService.java | 33 ++ .../service/UpdateTransactionService.java | 26 ++ .../usecase/CreateTransactionUseCase.java | 9 + .../usecase/GetTransactionUseCase.java | 8 + .../usecase/UpdateTransactionUseCase.java | 8 + .../domain/entities/Transaction.java | 48 +++ .../domain/enums/TransactionStatus.java | 14 + .../domain/enums/TransactionType.java | 16 + .../domain/model/EventInbound.java | 15 + .../domain/model/EventOutbound.java | 19 ++ .../domain/ports/KafkaProducerPort.java | 8 + .../ports/TransactionRepositoryPort.java | 10 + .../adapter/kafka/KafkaConfig.java | 52 +++ .../adapter/kafka/KafkaConsumer.java | 62 ++++ .../adapter/kafka/KafkaProducer.java | 39 +++ .../TransactionR2dbcRepository.java | 9 + .../TransactionRepositoryImpl.java | 78 +++++ .../adapter/rest/TransactionController.java | 38 +++ .../exception/CustomExceptionHandler.java | 63 ++++ .../adapter/rest/exception/ErrorResponse.java | 18 ++ .../src/main/resources/application.properties | 2 + .../src/main/resources/application.yml | 20 ++ transaction/src/main/resources/schema.sql | 19 ++ .../TransactionApplicationTests.java | 13 + 57 files changed, 2407 insertions(+), 1 deletion(-) create mode 100644 antifraud/.gitattributes create mode 100644 antifraud/.gitignore create mode 100644 antifraud/.mvn/wrapper/maven-wrapper.properties create mode 100644 antifraud/mvnw create mode 100644 antifraud/mvnw.cmd create mode 100644 antifraud/pom.xml create mode 100644 antifraud/src/main/java/com/yape/antifraud/AntifraudApplication.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/application/service/ValidateAntiFraudService.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/application/usecase/ValidateAntiFraudUseCase.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/domain/enums/TransactionStatus.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/domain/model/EventOutbound.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/domain/ports/KafkaProducerPort.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConfig.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConsumer.java create mode 100644 antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaProducer.java create mode 100644 antifraud/src/main/resources/application.properties create mode 100644 antifraud/src/main/resources/application.yml create mode 100644 antifraud/src/test/java/com/yape/antifraud/AntifraudApplicationTests.java create mode 100644 transaction/.gitattributes create mode 100644 transaction/.gitignore create mode 100644 transaction/.mvn/wrapper/maven-wrapper.properties create mode 100644 transaction/mvnw create mode 100644 transaction/mvnw.cmd create mode 100644 transaction/pom.xml create mode 100644 transaction/src/main/java/com/yape/transaction/TransactionApplication.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/dto/BaseTransaction.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionRequest.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionResponse.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/dto/GetTransactionResponse.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/mapper/TransactionMapper.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/service/GetTransactionService.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/service/UpdateTransactionService.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCase.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCase.java create mode 100644 transaction/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionUseCase.java create mode 100644 transaction/src/main/java/com/yape/transaction/domain/entities/Transaction.java create mode 100644 transaction/src/main/java/com/yape/transaction/domain/enums/TransactionStatus.java create mode 100644 transaction/src/main/java/com/yape/transaction/domain/enums/TransactionType.java create mode 100644 transaction/src/main/java/com/yape/transaction/domain/model/EventInbound.java create mode 100644 transaction/src/main/java/com/yape/transaction/domain/model/EventOutbound.java create mode 100644 transaction/src/main/java/com/yape/transaction/domain/ports/KafkaProducerPort.java create mode 100644 transaction/src/main/java/com/yape/transaction/domain/ports/TransactionRepositoryPort.java create mode 100644 transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConfig.java create mode 100644 transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConsumer.java create mode 100644 transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaProducer.java create mode 100644 transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionR2dbcRepository.java create mode 100644 transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java create mode 100644 transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/TransactionController.java create mode 100644 transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/CustomExceptionHandler.java create mode 100644 transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/ErrorResponse.java create mode 100644 transaction/src/main/resources/application.properties create mode 100644 transaction/src/main/resources/application.yml create mode 100644 transaction/src/main/resources/schema.sql create mode 100644 transaction/src/test/java/com/yape/transaction/TransactionApplicationTests.java diff --git a/antifraud/.gitattributes b/antifraud/.gitattributes new file mode 100644 index 0000000000..3b41682ac5 --- /dev/null +++ b/antifraud/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/antifraud/.gitignore b/antifraud/.gitignore new file mode 100644 index 0000000000..d74ac631fd --- /dev/null +++ b/antifraud/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/antifraud/.mvn/wrapper/maven-wrapper.properties b/antifraud/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..8dea6c227c --- /dev/null +++ b/antifraud/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/antifraud/mvnw b/antifraud/mvnw new file mode 100644 index 0000000000..bd8896bf22 --- /dev/null +++ b/antifraud/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/antifraud/mvnw.cmd b/antifraud/mvnw.cmd new file mode 100644 index 0000000000..92450f9327 --- /dev/null +++ b/antifraud/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/antifraud/pom.xml b/antifraud/pom.xml new file mode 100644 index 0000000000..f12c5efdc5 --- /dev/null +++ b/antifraud/pom.xml @@ -0,0 +1,121 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + com.yape + antifraud + 0.0.1-SNAPSHOT + antifraud + service that manages transaction antifraud + + + + + + + + + + + + + + + 17 + 2025.0.1 + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.cloud + spring-cloud-starter + + + org.springframework.kafka + spring-kafka + + + io.projectreactor.kafka + reactor-kafka + 1.3.23 + + + com.fasterxml.jackson.core + jackson-databind + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.kafka + spring-kafka-test + test + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/antifraud/src/main/java/com/yape/antifraud/AntifraudApplication.java b/antifraud/src/main/java/com/yape/antifraud/AntifraudApplication.java new file mode 100644 index 0000000000..05b16c808d --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/AntifraudApplication.java @@ -0,0 +1,13 @@ +package com.yape.antifraud; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AntifraudApplication { + + public static void main(String[] args) { + SpringApplication.run(AntifraudApplication.class, args); + } + +} diff --git a/antifraud/src/main/java/com/yape/antifraud/application/service/ValidateAntiFraudService.java b/antifraud/src/main/java/com/yape/antifraud/application/service/ValidateAntiFraudService.java new file mode 100644 index 0000000000..4bd283a018 --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/application/service/ValidateAntiFraudService.java @@ -0,0 +1,39 @@ +package com.yape.antifraud.application.service; + +import com.yape.antifraud.application.usecase.ValidateAntiFraudUseCase; +import com.yape.antifraud.domain.enums.TransactionStatus; +import com.yape.antifraud.domain.model.EventInbound; +import com.yape.antifraud.domain.model.EventOutbound; +import com.yape.antifraud.domain.ports.KafkaProducerPort; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import java.math.BigDecimal; + +@Service +@RequiredArgsConstructor +public class ValidateAntiFraudService implements ValidateAntiFraudUseCase { + + private static final BigDecimal FRAUD_THRESHOLD = new BigDecimal("1000"); + private static final Logger logger = LoggerFactory.getLogger(ValidateAntiFraudService.class); + private final KafkaProducerPort kafkaProducerPort; + + @Override + public Mono execute(EventInbound message) { + return Mono.fromSupplier(() -> isFraudulent(message) ? TransactionStatus.REJECTED : TransactionStatus.APPROVED) + .doOnNext(status -> logger.info("Transaction is {}", status == TransactionStatus.REJECTED ? "Fraudulent" : "Not Fraudulent")) + .map(status -> EventOutbound.builder() + .id(message.getId()) + .transactionExternalId(message.getTransactionExternalId()) + .value(message.getValue()) + .status(status.getValue()) + .build()) + .flatMap(kafkaProducerPort::sendTransactionProcessed); + } + + private boolean isFraudulent(EventInbound message) { + return message.getValue().compareTo(FRAUD_THRESHOLD) > 0; + } +} diff --git a/antifraud/src/main/java/com/yape/antifraud/application/usecase/ValidateAntiFraudUseCase.java b/antifraud/src/main/java/com/yape/antifraud/application/usecase/ValidateAntiFraudUseCase.java new file mode 100644 index 0000000000..2932c5882b --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/application/usecase/ValidateAntiFraudUseCase.java @@ -0,0 +1,8 @@ +package com.yape.antifraud.application.usecase; + +import com.yape.antifraud.domain.model.EventInbound; +import reactor.core.publisher.Mono; + +public interface ValidateAntiFraudUseCase { + Mono execute(EventInbound message); +} diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/enums/TransactionStatus.java b/antifraud/src/main/java/com/yape/antifraud/domain/enums/TransactionStatus.java new file mode 100644 index 0000000000..964c884973 --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/domain/enums/TransactionStatus.java @@ -0,0 +1,15 @@ +package com.yape.antifraud.domain.enums; + +import lombok.Getter; + +@Getter +public enum TransactionStatus { + REJECTED("REJECTED"), + APPROVED("APPROVED"); + + private final String value; + + TransactionStatus(String value) { + this.value = value; + } +} diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java new file mode 100644 index 0000000000..dab8c23446 --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java @@ -0,0 +1,13 @@ +package com.yape.antifraud.domain.model; + +import lombok.Getter; +import java.math.BigDecimal; + +@Getter +public class EventInbound { + private Long id; + private String transactionExternalId; + private BigDecimal value; + private String status; +} + diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/model/EventOutbound.java b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventOutbound.java new file mode 100644 index 0000000000..a29209527a --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventOutbound.java @@ -0,0 +1,15 @@ +package com.yape.antifraud.domain.model; + +import lombok.*; +import java.math.BigDecimal; + +@Getter +@Setter +@Builder +public class EventOutbound { + private Long id; + private BigDecimal value; + private String transactionExternalId; + private String status; +} + diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/ports/KafkaProducerPort.java b/antifraud/src/main/java/com/yape/antifraud/domain/ports/KafkaProducerPort.java new file mode 100644 index 0000000000..90a9c49ea9 --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/domain/ports/KafkaProducerPort.java @@ -0,0 +1,8 @@ +package com.yape.antifraud.domain.ports; + +import com.yape.antifraud.domain.model.EventOutbound; +import reactor.core.publisher.Mono; + +public interface KafkaProducerPort { + Mono sendTransactionProcessed(EventOutbound eventOutbound); +} diff --git a/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConfig.java b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConfig.java new file mode 100644 index 0000000000..547745dfe5 --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConfig.java @@ -0,0 +1,53 @@ +package com.yape.antifraud.infraestructure.kafka; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import reactor.kafka.receiver.ReceiverOptions; +import reactor.kafka.sender.SenderOptions; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + @Value("${spring.kafka.topics.anti-fraud}") + private String antiFraudTopic; + + @Bean + public ReceiverOptions receiverOptions() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + + return ReceiverOptions.create(props) + .subscription(Collections.singleton(antiFraudTopic)); + } + + @Bean + public ReactiveKafkaProducerTemplate reactiveKafkaProducerTemplate() { + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + + return new ReactiveKafkaProducerTemplate<>(SenderOptions.create(props)); + } +} + diff --git a/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConsumer.java b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConsumer.java new file mode 100644 index 0000000000..6a36d3e4cd --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaConsumer.java @@ -0,0 +1,62 @@ +package com.yape.antifraud.infraestructure.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.antifraud.application.service.ValidateAntiFraudService; +import com.yape.antifraud.domain.model.EventInbound; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.kafka.receiver.KafkaReceiver; +import reactor.kafka.receiver.ReceiverOptions; +import reactor.util.retry.Retry; +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + private final ValidateAntiFraudService validateAntiFraudService; + private final ObjectMapper objectMapper; + private final ReceiverOptions receiverOptions; + + @PostConstruct + public void listen() { + KafkaReceiver.create(receiverOptions) + .receive() + .flatMap(record -> { + logger.info("Received message - topic: {}, offset: {}, message: {}", + record.topic(), record.offset(), record.value()); + return parseEvent(record.value()) + .flatMap(validateAntiFraudService::execute) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) + .maxBackoff(Duration.ofSeconds(5)) + .doBeforeRetry(signal -> logger.warn("Retrying message - offset: {}, attempt: {}", + record.offset(), signal.totalRetries() + 1))) + .doOnSuccess(v -> { + record.receiverOffset().acknowledge(); + logger.info("Message acknowledged - offset: {}", record.offset()); + }) + .onErrorResume(e -> { + logger.error("All retries exhausted - offset: {}, error: {}", record.offset(), e.getMessage()); + record.receiverOffset().acknowledge(); + return Mono.empty(); + }); + }) + .subscribe( + null, + e -> logger.error("Fatal error in Kafka consumer: {}", e.getMessage()) + ); + + } + + private Mono parseEvent(String message) { + return Mono.fromCallable(() -> + objectMapper.readValue(message, EventInbound.class)) + .doOnError(e -> logger.error("Error parsing message: {}", e.getMessage())); + } +} diff --git a/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaProducer.java b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaProducer.java new file mode 100644 index 0000000000..feba652191 --- /dev/null +++ b/antifraud/src/main/java/com/yape/antifraud/infraestructure/kafka/KafkaProducer.java @@ -0,0 +1,37 @@ +package com.yape.antifraud.infraestructure.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.antifraud.domain.model.EventOutbound; +import com.yape.antifraud.domain.ports.KafkaProducerPort; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class KafkaProducer implements KafkaProducerPort { + + private static final Logger logger = LoggerFactory.getLogger(KafkaProducer.class); + + private final ReactiveKafkaProducerTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Value("${spring.kafka.topics.transaction}") + private String transactionTopic; + + @Override + public Mono sendTransactionProcessed(EventOutbound eventOutbound) { + return Mono.fromCallable(() -> objectMapper.writeValueAsString(eventOutbound)) + .doOnNext(message -> logger.info("Sending event to Kafka: {}", message)) + .flatMap(message -> kafkaTemplate.send(transactionTopic, + eventOutbound.getTransactionExternalId(), message)) + .doOnSuccess(result -> logger.info("Event sent successfully to topic: {}", transactionTopic)) + .doOnError(e -> logger.error("Error sending event to Kafka: {}", e.getMessage())) + .onErrorMap(e -> new RuntimeException("Error processing EventOutbound", e)) + .then(); + } +} diff --git a/antifraud/src/main/resources/application.properties b/antifraud/src/main/resources/application.properties new file mode 100644 index 0000000000..87d151f714 --- /dev/null +++ b/antifraud/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=antifraud diff --git a/antifraud/src/main/resources/application.yml b/antifraud/src/main/resources/application.yml new file mode 100644 index 0000000000..dd6d4b769f --- /dev/null +++ b/antifraud/src/main/resources/application.yml @@ -0,0 +1,8 @@ +spring: + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: anti-fraud-group-id + topics: + transaction: transaction-topic + anti-fraud: anti-fraud-topic diff --git a/antifraud/src/test/java/com/yape/antifraud/AntifraudApplicationTests.java b/antifraud/src/test/java/com/yape/antifraud/AntifraudApplicationTests.java new file mode 100644 index 0000000000..ac8d891afd --- /dev/null +++ b/antifraud/src/test/java/com/yape/antifraud/AntifraudApplicationTests.java @@ -0,0 +1,13 @@ +package com.yape.antifraud; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AntifraudApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..7c85bf9357 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ version: "3.7" services: postgres: image: postgres:14 + container_name: yape-postgres ports: - "5432:5432" environment: @@ -9,11 +10,14 @@ services: - POSTGRES_PASSWORD=postgres zookeeper: image: confluentinc/cp-zookeeper:5.5.3 + container_name: yape-zookeeper environment: ZOOKEEPER_CLIENT_PORT: 2181 kafka: image: confluentinc/cp-enterprise-kafka:5.5.3 - depends_on: [zookeeper] + container_name: yape-kafka + depends_on: + - zookeeper environment: KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 diff --git a/transaction/.gitattributes b/transaction/.gitattributes new file mode 100644 index 0000000000..3b41682ac5 --- /dev/null +++ b/transaction/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/transaction/.gitignore b/transaction/.gitignore new file mode 100644 index 0000000000..667aaef0c8 --- /dev/null +++ b/transaction/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/transaction/.mvn/wrapper/maven-wrapper.properties b/transaction/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..8dea6c227c --- /dev/null +++ b/transaction/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/transaction/mvnw b/transaction/mvnw new file mode 100644 index 0000000000..bd8896bf22 --- /dev/null +++ b/transaction/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/transaction/mvnw.cmd b/transaction/mvnw.cmd new file mode 100644 index 0000000000..92450f9327 --- /dev/null +++ b/transaction/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/transaction/pom.xml b/transaction/pom.xml new file mode 100644 index 0000000000..fb35cdb499 --- /dev/null +++ b/transaction/pom.xml @@ -0,0 +1,131 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + com.yape + transaction + 0.0.1-SNAPSHOT + transaction + service that manages transaction status + + + + + + + + + + + + + + + 17 + 2025.0.1 + + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.cloud + spring-cloud-starter + + + org.springframework.kafka + spring-kafka + + + + io.projectreactor.kafka + reactor-kafka + + + org.postgresql + postgresql + runtime + + + org.postgresql + r2dbc-postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.springframework.kafka + spring-kafka-test + test + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + \ No newline at end of file diff --git a/transaction/src/main/java/com/yape/transaction/TransactionApplication.java b/transaction/src/main/java/com/yape/transaction/TransactionApplication.java new file mode 100644 index 0000000000..812ec66ba6 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/TransactionApplication.java @@ -0,0 +1,13 @@ +package com.yape.transaction; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TransactionApplication { + + public static void main(String[] args) { + SpringApplication.run(TransactionApplication.class, args); + } + +} diff --git a/transaction/src/main/java/com/yape/transaction/application/dto/BaseTransaction.java b/transaction/src/main/java/com/yape/transaction/application/dto/BaseTransaction.java new file mode 100644 index 0000000000..ea641fcc00 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/dto/BaseTransaction.java @@ -0,0 +1,12 @@ +package com.yape.transaction.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class BaseTransaction { + private String name; +} diff --git a/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionRequest.java b/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionRequest.java new file mode 100644 index 0000000000..c41a657dfb --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionRequest.java @@ -0,0 +1,27 @@ +package com.yape.transaction.application.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +public class CreateTransactionRequest { + @NotEmpty + @Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "Debe ser un UUID válido") + private String accountExternalIdDebit; + + @NotEmpty + @Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "Debe ser un UUID válido") + private String accountExternalIdCredit; + + @NotNull + private Integer tranferTypeId; + + @NotNull + private BigDecimal value; +} diff --git a/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionResponse.java b/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionResponse.java new file mode 100644 index 0000000000..19ba0d8594 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/dto/CreateTransactionResponse.java @@ -0,0 +1,12 @@ +package com.yape.transaction.application.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +public class CreateTransactionResponse { + private String transactionExternalId; +} diff --git a/transaction/src/main/java/com/yape/transaction/application/dto/GetTransactionResponse.java b/transaction/src/main/java/com/yape/transaction/application/dto/GetTransactionResponse.java new file mode 100644 index 0000000000..61468b25c0 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/dto/GetTransactionResponse.java @@ -0,0 +1,18 @@ +package com.yape.transaction.application.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +public class GetTransactionResponse { + private String transactionExternalId; + private BaseTransaction transactionType; + private BaseTransaction transactionStatus; + private BigDecimal value; + private LocalDateTime createdAt; +} diff --git a/transaction/src/main/java/com/yape/transaction/application/mapper/TransactionMapper.java b/transaction/src/main/java/com/yape/transaction/application/mapper/TransactionMapper.java new file mode 100644 index 0000000000..8dcd6e4845 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/mapper/TransactionMapper.java @@ -0,0 +1,57 @@ +package com.yape.transaction.application.mapper; + +import com.yape.transaction.application.dto.CreateTransactionRequest; +import com.yape.transaction.application.dto.CreateTransactionResponse; +import com.yape.transaction.application.dto.GetTransactionResponse; +import com.yape.transaction.application.dto.BaseTransaction; +import com.yape.transaction.domain.entities.Transaction; +import com.yape.transaction.domain.enums.TransactionStatus; +import com.yape.transaction.domain.enums.TransactionType; +import com.yape.transaction.domain.model.EventOutbound; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +public class TransactionMapper { + + private TransactionMapper() {} + + public static Transaction toEntity(CreateTransactionRequest request, TransactionType transactionType) { + return Transaction.builder() + .transactionExternalId(UUID.randomUUID().toString()) + .accountExternalIdDebit(request.getAccountExternalIdDebit()) + .accountExternalIdCredit(request.getAccountExternalIdCredit()) + .type(transactionType.name()) + .status(TransactionStatus.PENDING.getValue()) + .value(request.getValue()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + public static EventOutbound toEventOutbound(Transaction transaction) { + return EventOutbound.builder() + .id(transaction.getId()) + .value(transaction.getValue()) + .transactionExternalId(transaction.getTransactionExternalId()) + .status(transaction.getStatus()) + .build(); + } + + public static CreateTransactionResponse toCreateResponse(Transaction transaction) { + return CreateTransactionResponse.builder() + .transactionExternalId(transaction.getTransactionExternalId()) + .build(); + } + + public static GetTransactionResponse toGetResponse(Transaction transaction) { + return GetTransactionResponse.builder() + .transactionExternalId(transaction.getTransactionExternalId()) + .value(transaction.getValue()) + .createdAt(transaction.getCreatedAt()) + .transactionStatus(new BaseTransaction(transaction.getStatus())) + .transactionType(new BaseTransaction(transaction.getType())) + .build(); + } +} + diff --git a/transaction/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java b/transaction/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java new file mode 100644 index 0000000000..26baab94d0 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/service/CreateTransactionService.java @@ -0,0 +1,55 @@ +package com.yape.transaction.application.service; + +import com.yape.transaction.application.dto.CreateTransactionRequest; +import com.yape.transaction.application.dto.CreateTransactionResponse; +import com.yape.transaction.application.mapper.TransactionMapper; +import com.yape.transaction.application.usecase.CreateTransactionUseCase; +import com.yape.transaction.domain.enums.TransactionType; +import com.yape.transaction.domain.ports.KafkaProducerPort; +import com.yape.transaction.domain.ports.TransactionRepositoryPort; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; +import java.util.Arrays; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CreateTransactionService implements CreateTransactionUseCase { + + private static final Logger logger = LoggerFactory.getLogger(CreateTransactionService.class); + + private final TransactionRepositoryPort transactionRepositoryPort; + private final KafkaProducerPort kafkaProducerPort; + + @Override + public Mono execute(CreateTransactionRequest request) { + logger.info("Creating a new transaction"); + + return Mono.just(request.getTranferTypeId()) + .map(this::validateTransactionType) + .flatMap(transactionType -> transactionRepositoryPort.save( + TransactionMapper.toEntity(request, transactionType) + )) + .doOnNext(saved -> logger.info("Transaction created: {}", saved.getTransactionExternalId())) + .flatMap(saved -> kafkaProducerPort.sendValidate(TransactionMapper.toEventOutbound(saved)) + .thenReturn(TransactionMapper.toCreateResponse(saved))); + } + + private TransactionType validateTransactionType(Integer transferTypeId) { + return Arrays.stream(TransactionType.values()) + .filter(type -> type.getValue() == transferTypeId) + .findFirst() + .orElseThrow(() -> { + String validTypes = Arrays.stream(TransactionType.values()) + .map(type -> type.getValue() + " = " + type.name()) + .collect(Collectors.joining(", ")); + return new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid transaction type: " + transferTypeId + ". Valid types are: " + validTypes); + }); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/application/service/GetTransactionService.java b/transaction/src/main/java/com/yape/transaction/application/service/GetTransactionService.java new file mode 100644 index 0000000000..f99b7fbc76 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/service/GetTransactionService.java @@ -0,0 +1,33 @@ +package com.yape.transaction.application.service; + +import com.yape.transaction.application.dto.GetTransactionResponse; +import com.yape.transaction.application.mapper.TransactionMapper; +import com.yape.transaction.application.usecase.GetTransactionUseCase; +import com.yape.transaction.domain.ports.TransactionRepositoryPort; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +public class GetTransactionService implements GetTransactionUseCase { + + private static final Logger logger = LoggerFactory.getLogger(GetTransactionService.class); + + private final TransactionRepositoryPort transactionRepositoryPort; + + @Override + public Mono execute(String transactionExternalId) { + return transactionRepositoryPort.findById(transactionExternalId) + .switchIfEmpty(Mono.defer(() -> { + logger.warn("Transaction with ID: {} not found", transactionExternalId); + return Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, + "Transaction with ID: " + transactionExternalId + " not found")); + })) + .map(TransactionMapper::toGetResponse); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/application/service/UpdateTransactionService.java b/transaction/src/main/java/com/yape/transaction/application/service/UpdateTransactionService.java new file mode 100644 index 0000000000..ef173c52e1 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/service/UpdateTransactionService.java @@ -0,0 +1,26 @@ +package com.yape.transaction.application.service; + +import com.yape.transaction.domain.model.EventInbound; +import com.yape.transaction.domain.ports.TransactionRepositoryPort; +import com.yape.transaction.application.usecase.UpdateTransactionUseCase; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +public class UpdateTransactionService implements UpdateTransactionUseCase { + + private static final Logger logger = LoggerFactory.getLogger(UpdateTransactionService.class); + + private final TransactionRepositoryPort transactionRepositoryPort; + + @Override + public Mono execute(EventInbound message) { + logger.info("Updating status with id: {}", message.getTransactionExternalId()); + return transactionRepositoryPort.updateStatus(message.getTransactionExternalId(), message.getStatus()) + .then(); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCase.java b/transaction/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCase.java new file mode 100644 index 0000000000..9fdcf7f0e1 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCase.java @@ -0,0 +1,9 @@ +package com.yape.transaction.application.usecase; + +import com.yape.transaction.application.dto.CreateTransactionRequest; +import com.yape.transaction.application.dto.CreateTransactionResponse; +import reactor.core.publisher.Mono; + +public interface CreateTransactionUseCase { + Mono execute(CreateTransactionRequest createTransactionRequest); +} diff --git a/transaction/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCase.java b/transaction/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCase.java new file mode 100644 index 0000000000..92a3fa5560 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCase.java @@ -0,0 +1,8 @@ +package com.yape.transaction.application.usecase; + +import com.yape.transaction.application.dto.GetTransactionResponse; +import reactor.core.publisher.Mono; + +public interface GetTransactionUseCase { + Mono execute(String transactionExternalId); +} diff --git a/transaction/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionUseCase.java b/transaction/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionUseCase.java new file mode 100644 index 0000000000..3f296932c5 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionUseCase.java @@ -0,0 +1,8 @@ +package com.yape.transaction.application.usecase; + +import com.yape.transaction.domain.model.EventInbound; +import reactor.core.publisher.Mono; + +public interface UpdateTransactionUseCase { + Mono execute(EventInbound message); +} diff --git a/transaction/src/main/java/com/yape/transaction/domain/entities/Transaction.java b/transaction/src/main/java/com/yape/transaction/domain/entities/Transaction.java new file mode 100644 index 0000000000..7458c19d75 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/domain/entities/Transaction.java @@ -0,0 +1,48 @@ +package com.yape.transaction.domain.entities; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Version; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.relational.core.mapping.Column; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@Table(name = "transactions") +public class Transaction { + @Id + private Long id; + + @Version + @Column("version") + private Long version; + + @Column("transaction_external_id") + private String transactionExternalId; + + @Column("account_external_id_debit") + private String accountExternalIdDebit; + + @Column("account_external_id_credit") + private String accountExternalIdCredit; + + @Column("type") + private String type; + + @Column("status") + private String status; + + @Column("created_at") + private LocalDateTime createdAt; + + @Column("updated_at") + private LocalDateTime updatedAt; + + @Column("value") + private BigDecimal value; +} diff --git a/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionStatus.java b/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionStatus.java new file mode 100644 index 0000000000..b83f5d3fce --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionStatus.java @@ -0,0 +1,14 @@ +package com.yape.transaction.domain.enums; + +import lombok.Getter; + +@Getter +public enum TransactionStatus { + PENDING("PENDING"); + + private final String value; + + TransactionStatus(String value) { + this.value = value; + } +} diff --git a/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionType.java b/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionType.java new file mode 100644 index 0000000000..4b79f21c2e --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/domain/enums/TransactionType.java @@ -0,0 +1,16 @@ +package com.yape.transaction.domain.enums; + +import lombok.Getter; + +@Getter +public enum TransactionType { + DEPOSIT(1), + WITHDRAWAL(2); + + private final int value; + + TransactionType(int value) { + this.value = value; + } + +} diff --git a/transaction/src/main/java/com/yape/transaction/domain/model/EventInbound.java b/transaction/src/main/java/com/yape/transaction/domain/model/EventInbound.java new file mode 100644 index 0000000000..07e97812c7 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/domain/model/EventInbound.java @@ -0,0 +1,15 @@ +package com.yape.transaction.domain.model; + +import lombok.Getter; +import lombok.Setter; +import java.math.BigDecimal; + +@Getter +@Setter +public class EventInbound { + private Long id; + private String transactionExternalId; + private BigDecimal value; + private String status; +} + diff --git a/transaction/src/main/java/com/yape/transaction/domain/model/EventOutbound.java b/transaction/src/main/java/com/yape/transaction/domain/model/EventOutbound.java new file mode 100644 index 0000000000..bd953664c4 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/domain/model/EventOutbound.java @@ -0,0 +1,19 @@ +package com.yape.transaction.domain.model; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import java.math.BigDecimal; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventOutbound { + private Long id; + private BigDecimal value; + private String transactionExternalId; + private String status; +} + diff --git a/transaction/src/main/java/com/yape/transaction/domain/ports/KafkaProducerPort.java b/transaction/src/main/java/com/yape/transaction/domain/ports/KafkaProducerPort.java new file mode 100644 index 0000000000..2badeee96a --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/domain/ports/KafkaProducerPort.java @@ -0,0 +1,8 @@ +package com.yape.transaction.domain.ports; + +import com.yape.transaction.domain.model.EventOutbound; +import reactor.core.publisher.Mono; + +public interface KafkaProducerPort { + Mono sendValidate(EventOutbound eventOutbound); +} diff --git a/transaction/src/main/java/com/yape/transaction/domain/ports/TransactionRepositoryPort.java b/transaction/src/main/java/com/yape/transaction/domain/ports/TransactionRepositoryPort.java new file mode 100644 index 0000000000..bfa89ce1e2 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/domain/ports/TransactionRepositoryPort.java @@ -0,0 +1,10 @@ +package com.yape.transaction.domain.ports; + +import reactor.core.publisher.Mono; +import com.yape.transaction.domain.entities.Transaction; + +public interface TransactionRepositoryPort { + Mono findById(String id); + Mono save(Transaction transaction); + Mono updateStatus(String transactionExternalId, String status); +} diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConfig.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConfig.java new file mode 100644 index 0000000000..24debb630d --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConfig.java @@ -0,0 +1,52 @@ +package com.yape.transaction.infraestructure.adapter.kafka; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import reactor.kafka.receiver.ReceiverOptions; +import reactor.kafka.sender.SenderOptions; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + @Value("${spring.kafka.topics.transaction}") + private String transactionTopic; + + @Bean + public ReceiverOptions receiverOptions() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + + return ReceiverOptions.create(props) + .subscription(Collections.singleton(transactionTopic)); + } + + @Bean + public ReactiveKafkaProducerTemplate reactiveKafkaProducerTemplate() { + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + + return new ReactiveKafkaProducerTemplate<>(SenderOptions.create(props)); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConsumer.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConsumer.java new file mode 100644 index 0000000000..dc1a7c74d1 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaConsumer.java @@ -0,0 +1,62 @@ +package com.yape.transaction.infraestructure.adapter.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.transaction.application.usecase.UpdateTransactionUseCase; +import com.yape.transaction.domain.model.EventInbound; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.kafka.receiver.KafkaReceiver; +import reactor.kafka.receiver.ReceiverOptions; +import reactor.util.retry.Retry; +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + private final UpdateTransactionUseCase updateTransactionPort; + private final ObjectMapper objectMapper; + private final ReceiverOptions receiverOptions; + + @PostConstruct + public void listen() { + KafkaReceiver.create(receiverOptions) + .receive() + .flatMap(record -> { + logger.info("Received message - topic: {}, offset: {}, message: {}", + record.topic(), record.offset(), record.value()); + return parseEvent(record.value()) + .flatMap(updateTransactionPort::execute) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) + .maxBackoff(Duration.ofSeconds(5)) + .doBeforeRetry(signal -> logger.warn("Retrying message - offset: {}, attempt: {}", + record.offset(), signal.totalRetries() + 1))) + .doOnSuccess(v -> { + record.receiverOffset().acknowledge(); + logger.info("Message acknowledged - offset: {}", record.offset()); + }) + .doOnError(e -> logger.error("Error processing message - offset: {}, error: {}", + record.offset(), e.getMessage())) + .onErrorResume(e -> { + record.receiverOffset().acknowledge(); + return Mono.empty(); + }); + }) + .subscribe( + null, + e -> logger.error("Fatal error in Kafka consumer: {}", e.getMessage()) + ); + } + + private Mono parseEvent(String message) { + return Mono.fromCallable(() -> + objectMapper.readValue(message, EventInbound.class)) + .doOnError(e -> logger.error("Error parsing message: {}", e.getMessage())); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaProducer.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaProducer.java new file mode 100644 index 0000000000..ac1373646a --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/kafka/KafkaProducer.java @@ -0,0 +1,39 @@ +package com.yape.transaction.infraestructure.adapter.kafka; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.transaction.domain.model.EventOutbound; +import com.yape.transaction.domain.ports.KafkaProducerPort; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class KafkaProducer implements KafkaProducerPort { + + private static final Logger logger = LoggerFactory.getLogger(KafkaProducer.class); + + private final ReactiveKafkaProducerTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Value("${spring.kafka.topics.anti-fraud}") + private String antiFraudTopic; + + @Override + public Mono sendValidate(EventOutbound eventOutbound) { + return Mono.fromCallable(() -> objectMapper.writeValueAsString(eventOutbound)) + .doOnNext(message -> logger.info("Sending event to Kafka: {}", message)) + .flatMap(message -> kafkaTemplate.send(antiFraudTopic, + eventOutbound.getTransactionExternalId(), message)) + .doOnSuccess(result -> logger.info("Event sent successfully to topic: {}", antiFraudTopic)) + .doOnError(e -> logger.error("Error sending event to Kafka: {}", e.getMessage())) + .onErrorMap(JsonProcessingException.class, + e -> new RuntimeException("Error serializing EventOutbound", e)) + .then(); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionR2dbcRepository.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionR2dbcRepository.java new file mode 100644 index 0000000000..5c4f13faa7 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionR2dbcRepository.java @@ -0,0 +1,9 @@ +package com.yape.transaction.infraestructure.adapter.persistence; + +import com.yape.transaction.domain.entities.Transaction; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Mono; + +public interface TransactionR2dbcRepository extends ReactiveCrudRepository { + Mono findByTransactionExternalId(String transactionExternalId); +} diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java new file mode 100644 index 0000000000..1e40607ab9 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java @@ -0,0 +1,78 @@ +package com.yape.transaction.infraestructure.adapter.persistence; + +import com.yape.transaction.domain.entities.Transaction; +import com.yape.transaction.domain.ports.TransactionRepositoryPort; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Repository; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import java.time.Duration; + +@Repository +@RequiredArgsConstructor +public class TransactionRepositoryImpl implements TransactionRepositoryPort { + + private static final Logger logger = LoggerFactory.getLogger(TransactionRepositoryImpl.class); + private static final int MAX_RETRIES = 3; + private static final Duration RETRY_DELAY = Duration.ofMillis(200); + private static final String PENDING_INDEX = "idx_debit_credit_type_pending"; + + private final TransactionR2dbcRepository transactionR2dbcRepository; + + @Override + public Mono findById(String transactionExternalId) { + return transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId); + } + + @Override + public Mono save(Transaction transaction) { + return transactionR2dbcRepository.save(transaction) + .retryWhen(Retry.backoff(MAX_RETRIES, RETRY_DELAY) + .filter(ex -> ex instanceof OptimisticLockingFailureException) + .doBeforeRetry(signal -> logger.warn( + "Retrying save for account: {}, attempt: {}", + transaction.getAccountExternalIdDebit(), signal.totalRetries() + 1))) + .doOnSuccess(t -> logger.info("Transaction saved for account: {}", + transaction.getAccountExternalIdDebit())) + .onErrorMap(this::isUniqueIndexViolation, ex -> { + logger.warn("Pending transaction already exists for debit: {}, credit: {}, type: {}", + transaction.getAccountExternalIdDebit(), + transaction.getAccountExternalIdCredit(), + transaction.getType()); + return new ResponseStatusException(HttpStatus.CONFLICT, + "Ya existe una transacción pendiente para las cuentas debit: " + + transaction.getAccountExternalIdDebit() + + ", credit: " + transaction.getAccountExternalIdCredit() + + ", type: " + transaction.getType()); + }) + .doOnError(e -> logger.error("Failed to save for account: {}, error: {}", + transaction.getAccountExternalIdDebit(), e.getMessage())); + } + + @Override + public Mono updateStatus(String transactionExternalId, String status) { + return transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId) + .flatMap(transaction -> { + transaction.setStatus(status); + return transactionR2dbcRepository.save(transaction) + .retryWhen(Retry.backoff(MAX_RETRIES, RETRY_DELAY) + .filter(ex -> ex instanceof OptimisticLockingFailureException) + .doBeforeRetry(signal -> logger.warn( + "Retrying updateStatus for account: {}, attempt: {}", + transaction.getAccountExternalIdDebit(), + signal.totalRetries() + 1))); + }) + .doOnError(e -> logger.error("Failed to update status: {}, error: {}", + transactionExternalId, e.getMessage())) + .then(); + } + + private boolean isUniqueIndexViolation(Throwable ex) { + return ex.getMessage().contains(PENDING_INDEX); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/TransactionController.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/TransactionController.java new file mode 100644 index 0000000000..ddd665de80 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/TransactionController.java @@ -0,0 +1,38 @@ +package com.yape.transaction.infraestructure.adapter.rest; + +import com.yape.transaction.application.dto.CreateTransactionRequest; +import com.yape.transaction.application.dto.CreateTransactionResponse; +import com.yape.transaction.application.dto.GetTransactionResponse; +import com.yape.transaction.application.usecase.CreateTransactionUseCase; +import com.yape.transaction.application.usecase.GetTransactionUseCase; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/transactions") +@RequiredArgsConstructor +public class TransactionController { + + private static final Logger logger = LoggerFactory.getLogger(TransactionController.class); + + private final CreateTransactionUseCase createTransactionUseCase; + private final GetTransactionUseCase getTransactionUseCase; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Mono createTransaction(@Valid @RequestBody CreateTransactionRequest createTransactionRequest) { + logger.info("Creating transaction"); + return createTransactionUseCase.execute(createTransactionRequest); + } + + @GetMapping("/{transactionExternalId}") + public Mono getTransaction(@PathVariable String transactionExternalId) { + logger.info("Getting transaction with id: {}", transactionExternalId); + return getTransactionUseCase.execute(transactionExternalId); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/CustomExceptionHandler.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/CustomExceptionHandler.java new file mode 100644 index 0000000000..dfd6c35292 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/CustomExceptionHandler.java @@ -0,0 +1,63 @@ +package com.yape.transaction.infraestructure.adapter.rest.exception; + +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@RestControllerAdvice +@Order(-1) +public class CustomExceptionHandler { + + @ExceptionHandler(WebExchangeBindException.class) + public ResponseEntity handleValidationException(WebExchangeBindException ex) { + List details = ex.getBindingResult() + .getFieldErrors() + .stream() + .map(error -> "Campo '" + error.getField() + "': " + error.getDefaultMessage()) + .collect(Collectors.toList()); + + ErrorResponse errorResponse = new ErrorResponse(); + errorResponse.setStatus(HttpStatus.BAD_REQUEST.value()); + errorResponse.setMessage("Error de validación"); + errorResponse.setTimestamp(LocalDateTime.now()); + errorResponse.setDetails(details); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException(ResponseStatusException ex) { + ErrorResponse errorResponse = new ErrorResponse(); + errorResponse.setStatus(ex.getStatusCode().value()); + errorResponse.setMessage("Ocurrio un error en la solicitud"); + errorResponse.setTimestamp(LocalDateTime.now()); + errorResponse.setDetails(List.of(ex.getReason())); + + return ResponseEntity.status(ex.getStatusCode()).body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + if (ex instanceof WebExchangeBindException bindEx) { + return handleValidationException(bindEx); + } + + ErrorResponse errorResponse = new ErrorResponse(); + errorResponse.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + errorResponse.setMessage("Error interno del servidor"); + errorResponse.setTimestamp(LocalDateTime.now()); + errorResponse.setDetails(List.of(ex.getLocalizedMessage())); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} + + diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/ErrorResponse.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/ErrorResponse.java new file mode 100644 index 0000000000..f4e4804213 --- /dev/null +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/rest/exception/ErrorResponse.java @@ -0,0 +1,18 @@ +package com.yape.transaction.infraestructure.adapter.rest.exception; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +public class ErrorResponse { + + private int status; + private String message; + private LocalDateTime timestamp; + private List details; + +} diff --git a/transaction/src/main/resources/application.properties b/transaction/src/main/resources/application.properties new file mode 100644 index 0000000000..34507697e9 --- /dev/null +++ b/transaction/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=transaction +server.port=8081 diff --git a/transaction/src/main/resources/application.yml b/transaction/src/main/resources/application.yml new file mode 100644 index 0000000000..d2ebaef969 --- /dev/null +++ b/transaction/src/main/resources/application.yml @@ -0,0 +1,20 @@ +spring: + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: transaction-group-id + topics: + anti-fraud: anti-fraud-topic + transaction: transaction-topic + + r2dbc: + url: r2dbc:postgresql://localhost:5432/postgres?schema=public + username: postgres + password: postgres + pool: + initial-size: 10 + max-size: 50 + max-idle-time: 30m + max-acquire-time: 5s + validation-query: SELECT 1 + diff --git a/transaction/src/main/resources/schema.sql b/transaction/src/main/resources/schema.sql new file mode 100644 index 0000000000..ced688765a --- /dev/null +++ b/transaction/src/main/resources/schema.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS transactions ( + id SERIAL PRIMARY KEY, + version BIGINT DEFAULT 0, + transaction_external_id VARCHAR(255) NOT NULL, + account_external_id_debit VARCHAR(255) NOT NULL, + account_external_id_credit VARCHAR(255) NOT NULL, + type VARCHAR(100) NOT NULL, + status VARCHAR(100) NOT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP, + value DECIMAL(19,2) +); + +-- Índice para búsquedas por transactionExternalId +CREATE INDEX IF NOT EXISTS idx_transaction_external_id ON transactions(transaction_external_id); + +-- Índice para búsquedas y control de volumen por cuenta debitora +CREATE INDEX IF NOT EXISTS idx_account_external_id_debit ON transactions(account_external_id_debit); + diff --git a/transaction/src/test/java/com/yape/transaction/TransactionApplicationTests.java b/transaction/src/test/java/com/yape/transaction/TransactionApplicationTests.java new file mode 100644 index 0000000000..c82fd3c114 --- /dev/null +++ b/transaction/src/test/java/com/yape/transaction/TransactionApplicationTests.java @@ -0,0 +1,13 @@ +package com.yape.transaction; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class TransactionApplicationTests { + + @Test + void contextLoads() { + } + +} From 74dda057eb3b3cd6a19f4b9a55473bd052d61e8f Mon Sep 17 00:00:00 2001 From: Luis Alberto Gamarra Astocondor Date: Sun, 1 Mar 2026 14:12:52 -0500 Subject: [PATCH 2/6] feat: add docker-compose and update readme. --- .gitignore | 2 + .idea/.gitignore | 10 + .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + README.md | 172 ++++++++++++------ antifraud/Dockerfile | 15 ++ .../src/main/resources/application.properties | 1 + antifraud/src/main/resources/application.yml | 8 +- docker-compose.local.yml | 30 +++ docker-compose.yml | 92 +++++++++- transaction/Dockerfile | 15 ++ .../src/main/resources/application.properties | 2 +- .../src/main/resources/application.yml | 18 +- transaction/src/main/resources/schema.sql | 33 ++-- 16 files changed, 337 insertions(+), 87 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 antifraud/Dockerfile create mode 100644 docker-compose.local.yml create mode 100644 transaction/Dockerfile diff --git a/.gitignore b/.gitignore index 67045665db..f15d1da44b 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +.idea \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000..ab1f4164ed --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ 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/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000..4b151abfdc --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..65867f62de --- /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/README.md b/README.md index b067a71026..56a705a7eb 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,138 @@ -# Yape Code Challenge :rocket: +# Solución Técnica - Sistema de Transacciones con Anti-Fraude -Our code challenge will let you marvel us with your Jedi coding skills :smile:. -Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !! +## 📋 Stack Tecnológico -- [Problem](#problem) -- [Tech Stack](#tech_stack) -- [Send us your challenge](#send_us_your_challenge) +- **Java 17** - Lenguaje base +- **Spring Boot 3.5.11** - Framework principal +- **Spring WebFlux** - Programación reactiva no bloqueante +- **R2DBC** - Operaciones reactivas con base de datos +- **Apache Kafka** - Mensajería asíncrona +- **PostgreSQL** - Base de datos persistente -# Problem +--- -Every time a financial transaction is created it must be validated by our anti-fraud microservice and then the same service sends a message back to update the transaction status. -For now, we have only three transaction statuses: +## 🏗️ Arquitectura -
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. -
+### Gestión de Concurrencia +- **Bloqueo Optimista**: Maneja actualizaciones concurrentes sin bloquear lecturas +- **Control de Duplicados**: Prevención de transacciones duplicadas -Every transaction with a value greater than 1000 should be rejected. +### Tipos de Transacción +- `1` = Depósito +- `2` = Retiro -```mermaid - flowchart LR - Transaction -- Save Transaction with pending Status --> transactionDatabase[(Database)] - Transaction --Send transaction Created event--> Anti-Fraud - Anti-Fraud -- Send transaction Status Approved event--> Transaction - Anti-Fraud -- Send transaction Status Rejected event--> Transaction - Transaction -- Update transaction Status event--> transactionDatabase[(Database)] +## 🔄 Flujo de Comunicación + +### Servicio de Transacciones (Transaction) +**Produce a**: `anti-fraud-topic` +- Datos de transacción recién creada (estado: `PENDING`) +- Trigger: Inmediatamente después de persistir + +**Consume de**: `transaction-topic` +- Estado final de validación (`APPROVED` / `REJECTED`) +- Acción: Actualiza estado en base de datos + +### Servicio Anti-Fraude (Antifraud) +**Consume de**: `anti-fraud-topic` +- Transacción a validar +- Rechaza transacciones con valor > $1000 + +**Produce a**: `transaction-topic` +- Resultado de validación con ID de transacción y nuevo estado + +--- + +## 🚀 Cómo Ejecutar + +### MODO LOCAL ⭐ (Recomendado) + +#### 1. Levantar infraestructura +```powershell +docker-compose -f docker-compose.local.yml up ``` -# Tech Stack +**Incluye:** +- PostgreSQL (puerto 5432) +- Zookeeper (puerto 2181) +- Kafka (puerto 9092) -
    -
  1. Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
  2. -
  3. Any database
  4. -
  5. Kafka
  6. -
+#### 2. Ejecutar microservicios desde el IDE -We do provide a `Dockerfile` to help you get started with a dev environment. +### Requisitos Previos -You must have two resources: +### Java 17 +Este proyecto requiere **Java 17**. Asegúrate de tener instalado JDK 17 y configurado en tu variable de entorno `JAVA_HOME`. -1. Resource to create a transaction that must containt: +#### Windows +```powershell +# Verificar versión de Java +java -version -```json -{ - "accountExternalIdDebit": "Guid", - "accountExternalIdCredit": "Guid", - "tranferTypeId": 1, - "value": 120 -} +# Configurar JAVA_HOME (reemplaza la ruta con tu instalación de JDK 17) +setx JAVA_HOME "C:\Program Files\Java\jdk-17" +setx PATH "%JAVA_HOME%\bin;%PATH%" + +# Reinicia tu terminal para aplicar cambios ``` -2. Resource to retrieve a transaction - -```json -{ - "transactionExternalId": "Guid", - "transactionType": { - "name": "" - }, - "transactionStatus": { - "name": "" - }, - "value": 120, - "createdAt": "Date" -} +#### Mac +```bash +# Verificar versión de Java +java -version + +# Configurar JAVA_HOME en ~/.zshrc o ~/.bash_profile +export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home +export PATH=$JAVA_HOME/bin:$PATH + +# Aplicar cambios +source ~/.zshrc # o source ~/.bash_profile ``` -## Optional +**Opción A - Terminal:** +```powershell +# Terminal 1 - Antifraud +cd antifraud +./mvnw spring-boot:run + +# Terminal 2 - Transaction +cd transaction +./mvnw spring-boot:run +``` + +**Opción B - IntelliJ IDEA:** +- Click derecho en `AntifraudApplication.java` → Run +- Click derecho en `TransactionApplication.java` → Run + +--- -You can use any approach to store transaction data but you should consider that we may deal with high volume scenarios where we have a huge amount of writes and reads for the same data at the same time. How would you tackle this requirement? +### MODO AUTOMATIZADO (Docker Completo) -You can use Graphql; +#### Levantar todo el sistema +```powershell +docker-compose up --build -d +``` + +**Incluye:** +- PostgreSQL +- Zookeeper +- Kafka +- Antifraud (Dockerizado, puerto 8081) +- Transaction (Dockerizado, puerto 8080) + +#### Detener +```powershell +docker-compose down +``` -# Send us your challenge +--- -When you finish your challenge, after forking a repository, you **must** open a pull request to our repository. There are no limitations to the implementation, you can follow the programming paradigm, modularization, and style that you feel is the most appropriate solution. +## 📍 Puertos y Servicios -If you have any questions, please let us know. +| Servicio | Puerto | URL | +|----------|--------|-----| +| Transaction | 8080 | http://localhost:8080 | +| Antifraud | 8081 | http://localhost:8081 | +| PostgreSQL | 5432 | localhost:5432 | +| Kafka | 9092 | localhost:9092 | +| Zookeeper | 2181 | localhost:2181 | diff --git a/antifraud/Dockerfile b/antifraud/Dockerfile new file mode 100644 index 0000000000..aa2f7b2b51 --- /dev/null +++ b/antifraud/Dockerfile @@ -0,0 +1,15 @@ +FROM eclipse-temurin:17-jdk-alpine AS build +WORKDIR /app +COPY mvnw . +COPY .mvn .mvn +COPY pom.xml . +COPY src src +RUN chmod +x mvnw +RUN ./mvnw clean package -DskipTests + +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY --from=build /app/target/antifraud-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8081 +ENTRYPOINT ["java", "-jar", "app.jar"] + diff --git a/antifraud/src/main/resources/application.properties b/antifraud/src/main/resources/application.properties index 87d151f714..708a0c5f6c 100644 --- a/antifraud/src/main/resources/application.properties +++ b/antifraud/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=antifraud +server.port=${SERVER_PORT:8081} diff --git a/antifraud/src/main/resources/application.yml b/antifraud/src/main/resources/application.yml index dd6d4b769f..f6df97fd8d 100644 --- a/antifraud/src/main/resources/application.yml +++ b/antifraud/src/main/resources/application.yml @@ -1,8 +1,8 @@ spring: kafka: - bootstrap-servers: localhost:9092 + bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092} consumer: - group-id: anti-fraud-group-id + group-id: ${SPRING_KAFKA_CONSUMER_GROUP_ID:anti-fraud-group-id} topics: - transaction: transaction-topic - anti-fraud: anti-fraud-topic + transaction: ${KAFKA_TOPIC_TRANSACTION:transaction-topic} + anti-fraud: ${KAFKA_TOPIC_ANTIFRAUD:anti-fraud-topic} diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000000..f450857861 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,30 @@ +version: "3.7" +services: + postgres: + image: postgres:14 + container_name: yape-postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + zookeeper: + image: confluentinc/cp-zookeeper:5.5.3 + container_name: yape-zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + kafka: + image: confluentinc/cp-enterprise-kafka:5.5.3 + container_name: yape-kafka + depends_on: + - zookeeper + environment: + KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_BROKER_ID: 1 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_JMX_PORT: 9991 + KAFKA_HEAP_OPTS: "-Xmx512M -Xms256M" + ports: + - 9092:9092 diff --git a/docker-compose.yml b/docker-compose.yml index 7c85bf9357..9a69741c1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,16 +8,42 @@ services: environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + deploy: + resources: + limits: + memory: 128M + zookeeper: image: confluentinc/cp-zookeeper:5.5.3 container_name: yape-zookeeper environment: ZOOKEEPER_CLIENT_PORT: 2181 + healthcheck: + test: ["CMD-SHELL", "echo srvr | nc localhost 2181 || exit 0"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + networks: + - yape-network + deploy: + resources: + limits: + memory: 128M + kafka: image: confluentinc/cp-enterprise-kafka:5.5.3 container_name: yape-kafka depends_on: - - zookeeper + zookeeper: + condition: service_healthy environment: KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 @@ -25,5 +51,69 @@ services: KAFKA_BROKER_ID: 1 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_JMX_PORT: 9991 + KAFKA_HEAP_OPTS: "-Xmx512M -Xms256M" ports: - 9092:9092 + healthcheck: + test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:29092 || exit 1"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s + networks: + - yape-network + deploy: + resources: + limits: + memory: 512M + + antifraud: + build: + context: ./antifraud + dockerfile: Dockerfile + container_name: yape-antifraud + depends_on: + kafka: + condition: service_healthy + environment: + - SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:29092 + ports: + - "8081:8081" + networks: + - yape-network + restart: unless-stopped + deploy: + resources: + limits: + memory: 256M + + transaction: + build: + context: ./transaction + dockerfile: Dockerfile + container_name: yape-transaction + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_healthy + antifraud: + condition: service_started + environment: + - SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:29092 + - SPRING_R2DBC_URL=r2dbc:postgresql://postgres:5432/postgres?schema=public + - SPRING_R2DBC_USERNAME=postgres + - SPRING_R2DBC_PASSWORD=postgres + ports: + - "8080:8080" + networks: + - yape-network + restart: unless-stopped + deploy: + resources: + limits: + memory: 256M + +networks: + yape-network: + driver: bridge diff --git a/transaction/Dockerfile b/transaction/Dockerfile new file mode 100644 index 0000000000..4efbcd779f --- /dev/null +++ b/transaction/Dockerfile @@ -0,0 +1,15 @@ +FROM eclipse-temurin:17-jdk-alpine AS build +WORKDIR /app +COPY mvnw . +COPY .mvn .mvn +COPY pom.xml . +COPY src src +RUN chmod +x mvnw +RUN ./mvnw clean package -DskipTests + +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY --from=build /app/target/transaction-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] + diff --git a/transaction/src/main/resources/application.properties b/transaction/src/main/resources/application.properties index 34507697e9..db681beb9d 100644 --- a/transaction/src/main/resources/application.properties +++ b/transaction/src/main/resources/application.properties @@ -1,2 +1,2 @@ spring.application.name=transaction -server.port=8081 +server.port=${SERVER_PORT:8080} diff --git a/transaction/src/main/resources/application.yml b/transaction/src/main/resources/application.yml index d2ebaef969..ac1bda6320 100644 --- a/transaction/src/main/resources/application.yml +++ b/transaction/src/main/resources/application.yml @@ -1,16 +1,20 @@ spring: kafka: - bootstrap-servers: localhost:9092 + bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092} consumer: - group-id: transaction-group-id + group-id: ${SPRING_KAFKA_CONSUMER_GROUP_ID:transaction-group-id} topics: - anti-fraud: anti-fraud-topic - transaction: transaction-topic + anti-fraud: ${KAFKA_TOPIC_ANTIFRAUD:anti-fraud-topic} + transaction: ${KAFKA_TOPIC_TRANSACTION:transaction-topic} + + sql: + init: + mode: always r2dbc: - url: r2dbc:postgresql://localhost:5432/postgres?schema=public - username: postgres - password: postgres + url: ${SPRING_R2DBC_URL:r2dbc:postgresql://localhost:5432/postgres?schema=public} + username: ${SPRING_R2DBC_USERNAME:postgres} + password: ${SPRING_R2DBC_PASSWORD:postgres} pool: initial-size: 10 max-size: 50 diff --git a/transaction/src/main/resources/schema.sql b/transaction/src/main/resources/schema.sql index ced688765a..39a6d554e4 100644 --- a/transaction/src/main/resources/schema.sql +++ b/transaction/src/main/resources/schema.sql @@ -1,19 +1,20 @@ -CREATE TABLE IF NOT EXISTS transactions ( - id SERIAL PRIMARY KEY, - version BIGINT DEFAULT 0, - transaction_external_id VARCHAR(255) NOT NULL, - account_external_id_debit VARCHAR(255) NOT NULL, - account_external_id_credit VARCHAR(255) NOT NULL, - type VARCHAR(100) NOT NULL, - status VARCHAR(100) NOT NULL, - created_at TIMESTAMP, - updated_at TIMESTAMP, - value DECIMAL(19,2) +CREATE TABLE IF NOT EXISTS public.transactions ( + id int4 NOT NULL GENERATED BY DEFAULT AS IDENTITY, + transaction_external_id varchar(36) NOT NULL, + account_external_id_debit varchar(36) NOT NULL, + account_external_id_credit varchar(36) NOT NULL, + type varchar(10) NOT NULL, + status varchar(10) NOT NULL, + updated_at timestamp NULL, + created_at timestamp NOT NULL, + value numeric(10, 2) NOT NULL, + version int4 NOT NULL DEFAULT 0, + CONSTRAINT transactions_pkey PRIMARY KEY (id) ); --- Índice para búsquedas por transactionExternalId -CREATE INDEX IF NOT EXISTS idx_transaction_external_id ON transactions(transaction_external_id); - --- Índice para búsquedas y control de volumen por cuenta debitora -CREATE INDEX IF NOT EXISTS idx_account_external_id_debit ON transactions(account_external_id_debit); +CREATE UNIQUE INDEX IF NOT EXISTS idx_transaction_external_id + ON transactions(transaction_external_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_debit_credit_type_pending + ON transactions(account_external_id_debit, account_external_id_credit, type) + WHERE status = 'PENDING'; \ No newline at end of file From dd6893fc3983a1ab4bdfb30673c50b9f7256655c Mon Sep 17 00:00:00 2001 From: Luis Alberto Gamarra Astocondor Date: Sun, 1 Mar 2026 14:18:07 -0500 Subject: [PATCH 3/6] feat: update readme. --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 56a705a7eb..352f071b0a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ ### Servicio de Transacciones (Transaction) **Produce a**: `anti-fraud-topic` - Datos de transacción recién creada (estado: `PENDING`) -- Trigger: Inmediatamente después de persistir +- Inmediatamente después de persistir envia el mensaje a Kafka para validación **Consume de**: `transaction-topic` - Estado final de validación (`APPROVED` / `REJECTED`) @@ -40,12 +40,15 @@ **Produce a**: `transaction-topic` - Resultado de validación con ID de transacción y nuevo estado +- Después de procesar cada mensaje, envía el resultado a Kafka para que Transaction actualice su estado --- ## 🚀 Cómo Ejecutar -### MODO LOCAL ⭐ (Recomendado) +Se tiene 2 modos de ejecución: Local (Infraestructura con docker y microservicios corriendo en tu máquina) o Automatizado (con todo el sistema dockerizado). + +### 💻 MODO LOCAL #### 1. Levantar infraestructura ```powershell @@ -106,7 +109,7 @@ cd transaction --- -### MODO AUTOMATIZADO (Docker Completo) +### 🐳 MODO AUTOMATIZADO (Docker Completo) #### Levantar todo el sistema ```powershell From cf36b27cf64e1641d42e57428a09cf3198a36643 Mon Sep 17 00:00:00 2001 From: Luis Alberto Gamarra Astocondor Date: Sun, 1 Mar 2026 14:23:44 -0500 Subject: [PATCH 4/6] feat: update readme, and add postma collection. --- README.md | 57 ++++++++++++++++++++++++++++++++ yape.postman_collection.json | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 yape.postman_collection.json diff --git a/README.md b/README.md index 352f071b0a..46ea6d675f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Solución Técnica - Sistema de Transacciones con Anti-Fraude +La estructura del proyecto es hexagonal, con dos microservicios principales: `Transaction` y `Antifraud`. ## 📋 Stack Tecnológico @@ -139,3 +140,59 @@ docker-compose down | PostgreSQL | 5432 | localhost:5432 | | Kafka | 9092 | localhost:9092 | | Zookeeper | 2181 | localhost:2181 | + + +--- + +## 📮 Pruebas con POSTMAN + +### 1️⃣ Crear Transacción + +**POST** `http://localhost:8080/transactions` + +**Body (JSON):** +```json +{ + "accountExternalIdDebit": "6085462b-f1da-48a0-8f11-72ee428b2a32", + "accountExternalIdCredit": "1cb43a09-01a7-4019-b683-589b6b00ea58", + "tranferTypeId": 1, + "value": 900 +} +``` + +**Response (201 CREATED):** +```json +{ + "transactionExternalId": "47f62081-95c7-483b-a000-91c0e391f696" +} +``` + +--- + +### 2️⃣ Obtener Transacción + +**GET** `http://localhost:8080/transactions/{transactionExternalId}` + +**Parámetro:** +- `transactionExternalId`: ID de la transacción a consultar + +**Response (200 OK):** +```json +{ + "transactionExternalId": "47f62081-95c7-483b-a000-91c0e391f696", + "transactionType": { + "name": "DEPOSIT" + }, + "transactionStatus": { + "name": "APPROVED" + }, + "value": 900.00, + "createdAt": "2026-03-01T19:11:52.070044" +} +``` + +--- + +### 📌 Notas Importantes + +- **tranferTypeId**: `1` = Depósito, `2` = Retiro diff --git a/yape.postman_collection.json b/yape.postman_collection.json new file mode 100644 index 0000000000..ed62acd7bb --- /dev/null +++ b/yape.postman_collection.json @@ -0,0 +1,64 @@ +{ + "info": { + "_postman_id": "4cb6abaa-c625-464a-ae46-50ecbb44d82a", + "name": "yape", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "32644997" + }, + "item": [ + { + "name": "create transaction", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"accountExternalIdDebit\": \"6085462b-f1da-48a0-8f11-72ee428b2a32\",\r\n \"accountExternalIdCredit\": \"1cb43a09-01a7-4019-b683-589b6b00ea58\",\r\n \"tranferTypeId\":1,\r\n \"value\": 900\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/transactions", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "transactions" + ] + } + }, + "response": [] + }, + { + "name": "get transaction", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/transactions/:transactionExternalId", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "transactions", + ":transactionExternalId" + ], + "variable": [ + { + "key": "transactionExternalId", + "value": "47f62081-95c7-483b-a000-91c0e391f696" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file From 8b565cccbefd106d8dd6ce9038959d06f2609c8d Mon Sep 17 00:00:00 2001 From: Luis Alberto Gamarra Astocondor Date: Sun, 1 Mar 2026 19:43:03 -0500 Subject: [PATCH 5/6] test: add test unit. --- README.md | 2 +- antifraud/pom.xml | 5 + .../antifraud/domain/model/EventInbound.java | 3 + .../service/ValidateAntiFraudServiceTest.java | 181 ++++++++++++++++ .../TransactionRepositoryImpl.java | 9 + .../TransactionApplicationTests.java | 13 -- .../service/CreateTransactionServiceTest.java | 156 ++++++++++++++ .../service/GetTransactionServiceTest.java | 104 +++++++++ .../service/UpdateTransactionServiceTest.java | 83 ++++++++ .../TransactionRepositoryImplTest.java | 201 ++++++++++++++++++ .../rest/TransactionControllerTest.java | 169 +++++++++++++++ 11 files changed, 912 insertions(+), 14 deletions(-) create mode 100644 antifraud/src/test/java/com/yape/antifraud/application/service/ValidateAntiFraudServiceTest.java delete mode 100644 transaction/src/test/java/com/yape/transaction/TransactionApplicationTests.java create mode 100644 transaction/src/test/java/com/yape/transaction/application/service/CreateTransactionServiceTest.java create mode 100644 transaction/src/test/java/com/yape/transaction/application/service/GetTransactionServiceTest.java create mode 100644 transaction/src/test/java/com/yape/transaction/application/service/UpdateTransactionServiceTest.java create mode 100644 transaction/src/test/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImplTest.java create mode 100644 transaction/src/test/java/com/yape/transaction/infraestructure/adapter/rest/TransactionControllerTest.java diff --git a/README.md b/README.md index 46ea6d675f..21bb2ec5b9 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Se tiene 2 modos de ejecución: Local (Infraestructura con docker y microservici #### 1. Levantar infraestructura ```powershell -docker-compose -f docker-compose.local.yml up +docker-compose -f docker-compose.local.yml up -d ``` **Incluye:** diff --git a/antifraud/pom.xml b/antifraud/pom.xml index f12c5efdc5..868c8bda0e 100644 --- a/antifraud/pom.xml +++ b/antifraud/pom.xml @@ -76,6 +76,11 @@ spring-kafka-test test + + io.projectreactor + reactor-test + test + diff --git a/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java index dab8c23446..3cf034b91b 100644 --- a/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java +++ b/antifraud/src/main/java/com/yape/antifraud/domain/model/EventInbound.java @@ -1,9 +1,12 @@ package com.yape.antifraud.domain.model; import lombok.Getter; +import lombok.Setter; + import java.math.BigDecimal; @Getter +@Setter public class EventInbound { private Long id; private String transactionExternalId; diff --git a/antifraud/src/test/java/com/yape/antifraud/application/service/ValidateAntiFraudServiceTest.java b/antifraud/src/test/java/com/yape/antifraud/application/service/ValidateAntiFraudServiceTest.java new file mode 100644 index 0000000000..24a23bd26f --- /dev/null +++ b/antifraud/src/test/java/com/yape/antifraud/application/service/ValidateAntiFraudServiceTest.java @@ -0,0 +1,181 @@ +package com.yape.antifraud.application.service; + +import com.yape.antifraud.domain.enums.TransactionStatus; +import com.yape.antifraud.domain.model.EventInbound; +import com.yape.antifraud.domain.model.EventOutbound; +import com.yape.antifraud.domain.ports.KafkaProducerPort; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ValidateAntiFraudServiceTest { + + @Mock + private KafkaProducerPort kafkaProducerPort; + + @InjectMocks + private ValidateAntiFraudService validateAntiFraudService; + + private EventInbound buildEventInbound(Long id, String transactionExternalId, BigDecimal value) { + EventInbound event = new EventInbound(); + event.setId(id); + event.setTransactionExternalId(transactionExternalId); + event.setValue(value); + return event; + } + + @Test + @DisplayName("Given a value equal to the threshold (1000), the transaction should be APPROVED") + void execute_whenValueEqualsThreshold_shouldApprove() { + when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty()); + EventInbound message = buildEventInbound(1L, "ext-001", new BigDecimal("1000")); + + StepVerifier.create(validateAntiFraudService.execute(message)) + .verifyComplete(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class); + verify(kafkaProducerPort, times(1)).sendTransactionProcessed(captor.capture()); + + EventOutbound outbound = captor.getValue(); + assertThat(outbound.getStatus()).isEqualTo(TransactionStatus.APPROVED.getValue()); + assertThat(outbound.getId()).isEqualTo(1L); + assertThat(outbound.getTransactionExternalId()).isEqualTo("ext-001"); + assertThat(outbound.getValue()).isEqualByComparingTo(new BigDecimal("1000")); + } + + @Test + @DisplayName("Given a value below the threshold (999.99), the transaction should be APPROVED") + void execute_whenValueBelowThreshold_shouldApprove() { + when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty()); + EventInbound message = buildEventInbound(2L, "ext-002", new BigDecimal("999.99")); + + StepVerifier.create(validateAntiFraudService.execute(message)) + .verifyComplete(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class); + verify(kafkaProducerPort).sendTransactionProcessed(captor.capture()); + + assertThat(captor.getValue().getStatus()).isEqualTo(TransactionStatus.APPROVED.getValue()); + } + + @Test + @DisplayName("Given a value of zero, the transaction should be APPROVED") + void execute_whenValueIsZero_shouldApprove() { + when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty()); + EventInbound message = buildEventInbound(3L, "ext-003", BigDecimal.ZERO); + + StepVerifier.create(validateAntiFraudService.execute(message)) + .verifyComplete(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class); + verify(kafkaProducerPort).sendTransactionProcessed(captor.capture()); + + assertThat(captor.getValue().getStatus()).isEqualTo(TransactionStatus.APPROVED.getValue()); + } + + // ------------------------------------------------------------------------- + // Tests: REJECTED transaction (value > 1000) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("Given a value above the threshold (1000.01), the transaction should be REJECTED") + void execute_whenValueAboveThreshold_shouldReject() { + when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty()); + EventInbound message = buildEventInbound(4L, "ext-004", new BigDecimal("1000.01")); + + StepVerifier.create(validateAntiFraudService.execute(message)) + .verifyComplete(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class); + verify(kafkaProducerPort).sendTransactionProcessed(captor.capture()); + + EventOutbound outbound = captor.getValue(); + assertThat(outbound.getStatus()).isEqualTo(TransactionStatus.REJECTED.getValue()); + assertThat(outbound.getId()).isEqualTo(4L); + assertThat(outbound.getTransactionExternalId()).isEqualTo("ext-004"); + } + + @Test + @DisplayName("Given a very high value (9999), the transaction should be REJECTED") + void execute_whenValueVeryHigh_shouldReject() { + when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty()); + EventInbound message = buildEventInbound(5L, "ext-005", new BigDecimal("9999")); + + StepVerifier.create(validateAntiFraudService.execute(message)) + .verifyComplete(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class); + verify(kafkaProducerPort).sendTransactionProcessed(captor.capture()); + + assertThat(captor.getValue().getStatus()).isEqualTo(TransactionStatus.REJECTED.getValue()); + } + + // ------------------------------------------------------------------------- + // Tests: producer error propagation + // ------------------------------------------------------------------------- + + @Test + @DisplayName("If the producer fails, the Mono should propagate the error") + void execute_whenProducerFails_shouldPropagateError() { + EventInbound message = buildEventInbound(6L, "ext-006", new BigDecimal("500")); + RuntimeException producerError = new RuntimeException("Kafka not available"); + + when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))) + .thenReturn(Mono.error(producerError)); + + StepVerifier.create(validateAntiFraudService.execute(message)) + .expectErrorMatches(err -> err instanceof RuntimeException + && err.getMessage().equals("Kafka not available")) + .verify(); + } + + // ------------------------------------------------------------------------- + // Tests: EventOutbound field mapping + // ------------------------------------------------------------------------- + + @Test + @DisplayName("The EventOutbound should contain the same id, transactionExternalId and value as the EventInbound") + void execute_shouldMapEventInboundFieldsToEventOutbound() { + Long expectedId = 7L; + String expectedExtId = "ext-007"; + BigDecimal expectedValue = new BigDecimal("250"); + EventInbound message = buildEventInbound(expectedId, expectedExtId, expectedValue); + when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty()); + + StepVerifier.create(validateAntiFraudService.execute(message)) + .verifyComplete(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventOutbound.class); + verify(kafkaProducerPort).sendTransactionProcessed(captor.capture()); + + EventOutbound outbound = captor.getValue(); + assertThat(outbound.getId()).isEqualTo(expectedId); + assertThat(outbound.getTransactionExternalId()).isEqualTo(expectedExtId); + assertThat(outbound.getValue()).isEqualByComparingTo(expectedValue); + } + + @Test + @DisplayName("The producer should be invoked exactly once per execution") + void execute_shouldCallProducerExactlyOnce() { + when(kafkaProducerPort.sendTransactionProcessed(any(EventOutbound.class))).thenReturn(Mono.empty()); + EventInbound message = buildEventInbound(8L, "ext-008", new BigDecimal("100")); + + StepVerifier.create(validateAntiFraudService.execute(message)) + .verifyComplete(); + + verify(kafkaProducerPort, times(1)).sendTransactionProcessed(any(EventOutbound.class)); + } +} diff --git a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java index 1e40607ab9..e755f31c60 100644 --- a/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java +++ b/transaction/src/main/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImpl.java @@ -1,6 +1,7 @@ package com.yape.transaction.infraestructure.adapter.persistence; import com.yape.transaction.domain.entities.Transaction; +import com.yape.transaction.domain.enums.TransactionStatus; import com.yape.transaction.domain.ports.TransactionRepositoryPort; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -12,6 +13,7 @@ import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import java.time.Duration; +import java.time.LocalDateTime; @Repository @RequiredArgsConstructor @@ -57,8 +59,15 @@ public Mono save(Transaction transaction) { @Override public Mono updateStatus(String transactionExternalId, String status) { return transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId) + .filter(transaction -> { + boolean isPending = TransactionStatus.PENDING.getValue().equals(transaction.getStatus()); + if (!isPending) logger.warn("Skipping update for transaction {} because its status is '{}', not PENDING", + transactionExternalId, transaction.getStatus()); + return isPending; + }) .flatMap(transaction -> { transaction.setStatus(status); + transaction.setUpdatedAt(LocalDateTime.now()); return transactionR2dbcRepository.save(transaction) .retryWhen(Retry.backoff(MAX_RETRIES, RETRY_DELAY) .filter(ex -> ex instanceof OptimisticLockingFailureException) diff --git a/transaction/src/test/java/com/yape/transaction/TransactionApplicationTests.java b/transaction/src/test/java/com/yape/transaction/TransactionApplicationTests.java deleted file mode 100644 index c82fd3c114..0000000000 --- a/transaction/src/test/java/com/yape/transaction/TransactionApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.yape.transaction; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class TransactionApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/transaction/src/test/java/com/yape/transaction/application/service/CreateTransactionServiceTest.java b/transaction/src/test/java/com/yape/transaction/application/service/CreateTransactionServiceTest.java new file mode 100644 index 0000000000..277ae8b61f --- /dev/null +++ b/transaction/src/test/java/com/yape/transaction/application/service/CreateTransactionServiceTest.java @@ -0,0 +1,156 @@ +package com.yape.transaction.application.service; + +import com.yape.transaction.application.dto.CreateTransactionRequest; +import com.yape.transaction.application.dto.CreateTransactionResponse; +import com.yape.transaction.domain.entities.Transaction; +import com.yape.transaction.domain.enums.TransactionStatus; +import com.yape.transaction.domain.model.EventOutbound; +import com.yape.transaction.domain.ports.KafkaProducerPort; +import com.yape.transaction.domain.ports.TransactionRepositoryPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CreateTransactionServiceTest { + + @Mock + private TransactionRepositoryPort transactionRepositoryPort; + + @Mock + private KafkaProducerPort kafkaProducerPort; + + @InjectMocks + private CreateTransactionService createTransactionService; + + private CreateTransactionRequest request; + private Transaction savedTransaction; + + @BeforeEach + void setUp() { + request = new CreateTransactionRequest(); + request.setAccountExternalIdDebit(UUID.randomUUID().toString()); + request.setAccountExternalIdCredit(UUID.randomUUID().toString()); + request.setValue(new BigDecimal("500.00")); + + savedTransaction = Transaction.builder() + .id(1L) + .transactionExternalId(UUID.randomUUID().toString()) + .accountExternalIdDebit(request.getAccountExternalIdDebit()) + .accountExternalIdCredit(request.getAccountExternalIdCredit()) + .type("DEPOSIT") + .status(TransactionStatus.PENDING.getValue()) + .value(request.getValue()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .version(0L) + .build(); + } + + @Test + @DisplayName("should create transaction and send kafka event when type is DEPOSIT") + void execute_shouldCreateTransactionAndSendEvent_whenTypeIsDeposit() { + request.setTranferTypeId(1); + + when(transactionRepositoryPort.save(any(Transaction.class))).thenReturn(Mono.just(savedTransaction)); + when(kafkaProducerPort.sendValidate(any(EventOutbound.class))).thenReturn(Mono.empty()); + + StepVerifier.create(createTransactionService.execute(request)) + .assertNext(response -> { + assertThat(response).isNotNull(); + assertThat(response.getTransactionExternalId()) + .isEqualTo(savedTransaction.getTransactionExternalId()); + }) + .verifyComplete(); + + verify(transactionRepositoryPort, times(1)).save(any(Transaction.class)); + verify(kafkaProducerPort, times(1)).sendValidate(any(EventOutbound.class)); + } + + @Test + @DisplayName("should create transaction and send kafka event when type is WITHDRAWAL") + void execute_shouldCreateTransactionAndSendEvent_whenTypeIsWithdrawal() { + request.setTranferTypeId(2); + savedTransaction.setType("WITHDRAWAL"); + + when(transactionRepositoryPort.save(any(Transaction.class))).thenReturn(Mono.just(savedTransaction)); + when(kafkaProducerPort.sendValidate(any(EventOutbound.class))).thenReturn(Mono.empty()); + + StepVerifier.create(createTransactionService.execute(request)) + .assertNext(response -> assertThat(response.getTransactionExternalId()) + .isEqualTo(savedTransaction.getTransactionExternalId())) + .verifyComplete(); + + verify(transactionRepositoryPort, times(1)).save(any(Transaction.class)); + verify(kafkaProducerPort, times(1)).sendValidate(any(EventOutbound.class)); + } + + @Test + @DisplayName("should throw BAD_REQUEST when tranferTypeId is invalid") + void execute_shouldThrowBadRequest_whenTransactionTypeIsInvalid() { + request.setTranferTypeId(99); + + StepVerifier.create(createTransactionService.execute(request)) + .expectErrorMatches(ex -> + ex instanceof ResponseStatusException && + ((ResponseStatusException) ex).getStatusCode().value() == 400 && + ex.getMessage().contains("99")) + .verify(); + + verifyNoInteractions(transactionRepositoryPort); + verifyNoInteractions(kafkaProducerPort); + } + + @Test + @DisplayName("should propagate error when repository save fails") + void execute_shouldPropagateError_whenRepositorySaveFails() { + request.setTranferTypeId(1); + + when(transactionRepositoryPort.save(any(Transaction.class))) + .thenReturn(Mono.error(new RuntimeException("DB connection error"))); + + StepVerifier.create(createTransactionService.execute(request)) + .expectErrorMatches(ex -> + ex instanceof RuntimeException && + ex.getMessage().equals("DB connection error")) + .verify(); + + verify(transactionRepositoryPort, times(1)).save(any(Transaction.class)); + verifyNoInteractions(kafkaProducerPort); + } + + @Test + @DisplayName("should propagate error when kafka producer fails") + void execute_shouldPropagateError_whenKafkaProducerFails() { + request.setTranferTypeId(1); + + when(transactionRepositoryPort.save(any(Transaction.class))).thenReturn(Mono.just(savedTransaction)); + when(kafkaProducerPort.sendValidate(any(EventOutbound.class))) + .thenReturn(Mono.error(new RuntimeException("Kafka unavailable"))); + + StepVerifier.create(createTransactionService.execute(request)) + .expectErrorMatches(ex -> + ex instanceof RuntimeException && + ex.getMessage().equals("Kafka unavailable")) + .verify(); + + verify(transactionRepositoryPort, times(1)).save(any(Transaction.class)); + verify(kafkaProducerPort, times(1)).sendValidate(any(EventOutbound.class)); + } +} + diff --git a/transaction/src/test/java/com/yape/transaction/application/service/GetTransactionServiceTest.java b/transaction/src/test/java/com/yape/transaction/application/service/GetTransactionServiceTest.java new file mode 100644 index 0000000000..e924fe9fe0 --- /dev/null +++ b/transaction/src/test/java/com/yape/transaction/application/service/GetTransactionServiceTest.java @@ -0,0 +1,104 @@ +package com.yape.transaction.application.service; + +import com.yape.transaction.application.dto.GetTransactionResponse; +import com.yape.transaction.domain.entities.Transaction; +import com.yape.transaction.domain.enums.TransactionStatus; +import com.yape.transaction.domain.ports.TransactionRepositoryPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GetTransactionServiceTest { + + @Mock + private TransactionRepositoryPort transactionRepositoryPort; + + @InjectMocks + private GetTransactionService getTransactionService; + + private String transactionExternalId; + private Transaction transaction; + + @BeforeEach + void setUp() { + transactionExternalId = UUID.randomUUID().toString(); + + transaction = Transaction.builder() + .id(1L) + .transactionExternalId(transactionExternalId) + .accountExternalIdDebit(UUID.randomUUID().toString()) + .accountExternalIdCredit(UUID.randomUUID().toString()) + .type("DEPOSIT") + .status(TransactionStatus.PENDING.getValue()) + .value(new BigDecimal("500.00")) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .version(0L) + .build(); + } + + @Test + @DisplayName("should return transaction when it exists") + void execute_shouldReturnTransaction_whenExists() { + when(transactionRepositoryPort.findById(transactionExternalId)).thenReturn(Mono.just(transaction)); + + StepVerifier.create(getTransactionService.execute(transactionExternalId)) + .assertNext(response -> { + assertThat(response).isNotNull(); + assertThat(response.getTransactionExternalId()).isEqualTo(transactionExternalId); + assertThat(response.getValue()).isEqualTo(transaction.getValue()); + assertThat(response.getTransactionStatus().getName()).isEqualTo(TransactionStatus.PENDING.getValue()); + assertThat(response.getTransactionType().getName()).isEqualTo("DEPOSIT"); + assertThat(response.getCreatedAt()).isEqualTo(transaction.getCreatedAt()); + }) + .verifyComplete(); + + verify(transactionRepositoryPort, times(1)).findById(transactionExternalId); + } + + @Test + @DisplayName("should throw 404 NOT_FOUND when transaction does not exist") + void execute_shouldThrowNotFound_whenTransactionDoesNotExist() { + when(transactionRepositoryPort.findById(transactionExternalId)).thenReturn(Mono.empty()); + + StepVerifier.create(getTransactionService.execute(transactionExternalId)) + .expectErrorMatches(ex -> + ex instanceof ResponseStatusException && + ((ResponseStatusException) ex).getStatusCode().value() == 404 && + ex.getMessage().contains(transactionExternalId)) + .verify(); + + verify(transactionRepositoryPort, times(1)).findById(transactionExternalId); + } + + @Test + @DisplayName("should propagate error when repository fails") + void execute_shouldPropagateError_whenRepositoryFails() { + when(transactionRepositoryPort.findById(transactionExternalId)) + .thenReturn(Mono.error(new RuntimeException("DB connection error"))); + + StepVerifier.create(getTransactionService.execute(transactionExternalId)) + .expectErrorMatches(ex -> + ex instanceof RuntimeException && + ex.getMessage().equals("DB connection error")) + .verify(); + + verify(transactionRepositoryPort, times(1)).findById(transactionExternalId); + } +} + diff --git a/transaction/src/test/java/com/yape/transaction/application/service/UpdateTransactionServiceTest.java b/transaction/src/test/java/com/yape/transaction/application/service/UpdateTransactionServiceTest.java new file mode 100644 index 0000000000..5e9b2463f8 --- /dev/null +++ b/transaction/src/test/java/com/yape/transaction/application/service/UpdateTransactionServiceTest.java @@ -0,0 +1,83 @@ +package com.yape.transaction.application.service; + +import com.yape.transaction.domain.model.EventInbound; +import com.yape.transaction.domain.ports.TransactionRepositoryPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UpdateTransactionServiceTest { + + @Mock + private TransactionRepositoryPort transactionRepositoryPort; + + @InjectMocks + private UpdateTransactionService updateTransactionService; + + private EventInbound message; + + @BeforeEach + void setUp() { + message = new EventInbound(); + message.setTransactionExternalId(UUID.randomUUID().toString()); + message.setValue(new BigDecimal("500.00")); + message.setStatus("APPROVED"); + } + + @Test + @DisplayName("should update status successfully when repository completes") + void execute_shouldUpdateStatus_whenRepositoryCompletes() { + when(transactionRepositoryPort.updateStatus(message.getTransactionExternalId(), message.getStatus())) + .thenReturn(Mono.empty()); + + StepVerifier.create(updateTransactionService.execute(message)) + .verifyComplete(); + + verify(transactionRepositoryPort, times(1)) + .updateStatus(message.getTransactionExternalId(), message.getStatus()); + } + + @Test + @DisplayName("should complete without error when transaction is not PENDING (no-op from repository)") + void execute_shouldCompleteWithoutError_whenTransactionIsNotPending() { + message.setStatus("REJECTED"); + + when(transactionRepositoryPort.updateStatus(message.getTransactionExternalId(), message.getStatus())) + .thenReturn(Mono.empty()); + + StepVerifier.create(updateTransactionService.execute(message)) + .verifyComplete(); + + verify(transactionRepositoryPort, times(1)) + .updateStatus(message.getTransactionExternalId(), message.getStatus()); + } + + @Test + @DisplayName("should propagate error when repository fails") + void execute_shouldPropagateError_whenRepositoryFails() { + when(transactionRepositoryPort.updateStatus(message.getTransactionExternalId(), message.getStatus())) + .thenReturn(Mono.error(new RuntimeException("DB connection error"))); + + StepVerifier.create(updateTransactionService.execute(message)) + .expectErrorMatches(ex -> + ex instanceof RuntimeException && + ex.getMessage().equals("DB connection error")) + .verify(); + + verify(transactionRepositoryPort, times(1)) + .updateStatus(message.getTransactionExternalId(), message.getStatus()); + } +} + diff --git a/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImplTest.java b/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImplTest.java new file mode 100644 index 0000000000..8c45861b39 --- /dev/null +++ b/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/persistence/TransactionRepositoryImplTest.java @@ -0,0 +1,201 @@ +package com.yape.transaction.infraestructure.adapter.persistence; + +import com.yape.transaction.domain.entities.Transaction; +import com.yape.transaction.domain.enums.TransactionStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TransactionRepositoryImplTest { + + @Mock + private TransactionR2dbcRepository transactionR2dbcRepository; + + @InjectMocks + private TransactionRepositoryImpl transactionRepositoryImpl; + + private String transactionExternalId; + private Transaction pendingTransaction; + private Transaction approvedTransaction; + + @BeforeEach + void setUp() { + transactionExternalId = UUID.randomUUID().toString(); + + pendingTransaction = Transaction.builder() + .id(1L) + .transactionExternalId(transactionExternalId) + .accountExternalIdDebit(UUID.randomUUID().toString()) + .accountExternalIdCredit(UUID.randomUUID().toString()) + .type("DEPOSIT") + .status(TransactionStatus.PENDING.getValue()) + .value(new BigDecimal("500.00")) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .version(0L) + .build(); + + approvedTransaction = Transaction.builder() + .id(2L) + .transactionExternalId(transactionExternalId) + .accountExternalIdDebit(UUID.randomUUID().toString()) + .accountExternalIdCredit(UUID.randomUUID().toString()) + .type("DEPOSIT") + .status("APPROVED") + .value(new BigDecimal("500.00")) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .version(1L) + .build(); + } + + @Test + @DisplayName("findById - should return transaction when it exists") + void findById_shouldReturnTransaction_whenExists() { + when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId)) + .thenReturn(Mono.just(pendingTransaction)); + + StepVerifier.create(transactionRepositoryImpl.findById(transactionExternalId)) + .assertNext(t -> assertThat(t.getTransactionExternalId()).isEqualTo(transactionExternalId)) + .verifyComplete(); + + verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId); + } + + @Test + @DisplayName("findById - should return empty when transaction does not exist") + void findById_shouldReturnEmpty_whenNotExists() { + when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId)) + .thenReturn(Mono.empty()); + + StepVerifier.create(transactionRepositoryImpl.findById(transactionExternalId)) + .verifyComplete(); + + verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId); + } + + + @Test + @DisplayName("save - should save transaction successfully") + void save_shouldSaveTransaction_successfully() { + when(transactionR2dbcRepository.save(pendingTransaction)).thenReturn(Mono.just(pendingTransaction)); + + StepVerifier.create(transactionRepositoryImpl.save(pendingTransaction)) + .assertNext(t -> assertThat(t.getTransactionExternalId()).isEqualTo(transactionExternalId)) + .verifyComplete(); + + verify(transactionR2dbcRepository, times(1)).save(pendingTransaction); + } + + @Test + @DisplayName("save - should throw 409 CONFLICT when unique index violation occurs") + void save_shouldThrowConflict_whenUniqueIndexViolation() { + when(transactionR2dbcRepository.save(pendingTransaction)) + .thenReturn(Mono.error(new RuntimeException("idx_debit_credit_type_pending"))); + + StepVerifier.create(transactionRepositoryImpl.save(pendingTransaction)) + .expectErrorMatches(ex -> + ex instanceof ResponseStatusException && + ((ResponseStatusException) ex).getStatusCode().value() == 409) + .verify(); + } + + + @Test + @DisplayName("save - should propagate error when repository fails with non-retriable error") + void save_shouldPropagateError_whenNonRetriableError() { + when(transactionR2dbcRepository.save(pendingTransaction)) + .thenReturn(Mono.error(new RuntimeException("Unexpected DB error"))); + + StepVerifier.create(transactionRepositoryImpl.save(pendingTransaction)) + .expectErrorMatches(ex -> ex instanceof RuntimeException) + .verify(); + } + + + @Test + @DisplayName("updateStatus - should update status when transaction is PENDING") + void updateStatus_shouldUpdate_whenTransactionIsPending() { + Transaction updatedTransaction = Transaction.builder() + .id(pendingTransaction.getId()) + .transactionExternalId(transactionExternalId) + .accountExternalIdDebit(pendingTransaction.getAccountExternalIdDebit()) + .accountExternalIdCredit(pendingTransaction.getAccountExternalIdCredit()) + .type(pendingTransaction.getType()) + .status("APPROVED") + .value(pendingTransaction.getValue()) + .createdAt(pendingTransaction.getCreatedAt()) + .updatedAt(LocalDateTime.now()) + .version(1L) + .build(); + + when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId)) + .thenReturn(Mono.just(pendingTransaction)); + when(transactionR2dbcRepository.save(any(Transaction.class))).thenReturn(Mono.just(updatedTransaction)); + + StepVerifier.create(transactionRepositoryImpl.updateStatus(transactionExternalId, "APPROVED")) + .verifyComplete(); + + verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId); + verify(transactionR2dbcRepository, times(1)).save(any(Transaction.class)); + } + + @Test + @DisplayName("updateStatus - should do nothing when transaction is not PENDING") + void updateStatus_shouldDoNothing_whenTransactionIsNotPending() { + when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId)) + .thenReturn(Mono.just(approvedTransaction)); + + StepVerifier.create(transactionRepositoryImpl.updateStatus(transactionExternalId, "REJECTED")) + .verifyComplete(); + + verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId); + verify(transactionR2dbcRepository, never()).save(any(Transaction.class)); + } + + @Test + @DisplayName("updateStatus - should complete when transaction does not exist") + void updateStatus_shouldComplete_whenTransactionNotFound() { + when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId)) + .thenReturn(Mono.empty()); + + StepVerifier.create(transactionRepositoryImpl.updateStatus(transactionExternalId, "APPROVED")) + .verifyComplete(); + + verify(transactionR2dbcRepository, times(1)).findByTransactionExternalId(transactionExternalId); + verify(transactionR2dbcRepository, never()).save(any(Transaction.class)); + } + + + @Test + @DisplayName("updateStatus - should propagate error when repository save fails") + void updateStatus_shouldPropagateError_whenSaveFails() { + when(transactionR2dbcRepository.findByTransactionExternalId(transactionExternalId)) + .thenReturn(Mono.just(pendingTransaction)); + when(transactionR2dbcRepository.save(any(Transaction.class))) + .thenReturn(Mono.error(new RuntimeException("DB connection error"))); + + StepVerifier.create(transactionRepositoryImpl.updateStatus(transactionExternalId, "APPROVED")) + .expectErrorMatches(ex -> + ex instanceof RuntimeException && + ex.getMessage().equals("DB connection error")) + .verify(); + } +} + diff --git a/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/rest/TransactionControllerTest.java b/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/rest/TransactionControllerTest.java new file mode 100644 index 0000000000..da853e539d --- /dev/null +++ b/transaction/src/test/java/com/yape/transaction/infraestructure/adapter/rest/TransactionControllerTest.java @@ -0,0 +1,169 @@ +package com.yape.transaction.infraestructure.adapter.rest; + +import com.yape.transaction.application.dto.BaseTransaction; +import com.yape.transaction.application.dto.CreateTransactionRequest; +import com.yape.transaction.application.dto.CreateTransactionResponse; +import com.yape.transaction.application.dto.GetTransactionResponse; +import com.yape.transaction.application.usecase.CreateTransactionUseCase; +import com.yape.transaction.application.usecase.GetTransactionUseCase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TransactionControllerTest { + + @Mock + private CreateTransactionUseCase createTransactionUseCase; + + @Mock + private GetTransactionUseCase getTransactionUseCase; + + @InjectMocks + private TransactionController transactionController; + + private CreateTransactionRequest createRequest; + private CreateTransactionResponse createResponse; + private GetTransactionResponse getResponse; + private String transactionExternalId; + + @BeforeEach + void setUp() { + transactionExternalId = UUID.randomUUID().toString(); + + createRequest = new CreateTransactionRequest(); + createRequest.setAccountExternalIdDebit(UUID.randomUUID().toString()); + createRequest.setAccountExternalIdCredit(UUID.randomUUID().toString()); + createRequest.setTranferTypeId(1); + createRequest.setValue(new BigDecimal("500.00")); + + createResponse = CreateTransactionResponse.builder() + .transactionExternalId(transactionExternalId) + .build(); + + getResponse = GetTransactionResponse.builder() + .transactionExternalId(transactionExternalId) + .transactionType(new BaseTransaction("DEPOSIT")) + .transactionStatus(new BaseTransaction("PENDING")) + .value(new BigDecimal("500.00")) + .createdAt(LocalDateTime.now()) + .build(); + } + + + @Test + @DisplayName("createTransaction - should return transactionExternalId when created successfully") + void createTransaction_shouldReturnResponse_whenCreatedSuccessfully() { + when(createTransactionUseCase.execute(any(CreateTransactionRequest.class))) + .thenReturn(Mono.just(createResponse)); + + StepVerifier.create(transactionController.createTransaction(createRequest)) + .assertNext(response -> { + assertThat(response).isNotNull(); + assertThat(response.getTransactionExternalId()).isEqualTo(transactionExternalId); + }) + .verifyComplete(); + + verify(createTransactionUseCase, times(1)).execute(createRequest); + } + + @Test + @DisplayName("createTransaction - should propagate error when use case fails") + void createTransaction_shouldPropagateError_whenUseCaseFails() { + when(createTransactionUseCase.execute(any(CreateTransactionRequest.class))) + .thenReturn(Mono.error(new RuntimeException("Unexpected error"))); + + StepVerifier.create(transactionController.createTransaction(createRequest)) + .expectErrorMatches(ex -> + ex instanceof RuntimeException && + ex.getMessage().equals("Unexpected error")) + .verify(); + + verify(createTransactionUseCase, times(1)).execute(createRequest); + } + + @Test + @DisplayName("createTransaction - should propagate 400 when transaction type is invalid") + void createTransaction_shouldPropagate400_whenTransactionTypeIsInvalid() { + when(createTransactionUseCase.execute(any(CreateTransactionRequest.class))) + .thenReturn(Mono.error(new ResponseStatusException( + org.springframework.http.HttpStatus.BAD_REQUEST, "Invalid transaction type"))); + + StepVerifier.create(transactionController.createTransaction(createRequest)) + .expectErrorMatches(ex -> + ex instanceof ResponseStatusException && + ((ResponseStatusException) ex).getStatusCode().value() == 400) + .verify(); + + verify(createTransactionUseCase, times(1)).execute(createRequest); + } + + + @Test + @DisplayName("getTransaction - should return transaction when it exists") + void getTransaction_shouldReturnTransaction_whenExists() { + when(getTransactionUseCase.execute(transactionExternalId)) + .thenReturn(Mono.just(getResponse)); + + StepVerifier.create(transactionController.getTransaction(transactionExternalId)) + .assertNext(response -> { + assertThat(response).isNotNull(); + assertThat(response.getTransactionExternalId()).isEqualTo(transactionExternalId); + assertThat(response.getTransactionType().getName()).isEqualTo("DEPOSIT"); + assertThat(response.getTransactionStatus().getName()).isEqualTo("PENDING"); + assertThat(response.getValue()).isEqualTo(new BigDecimal("500.00")); + }) + .verifyComplete(); + + verify(getTransactionUseCase, times(1)).execute(transactionExternalId); + } + + @Test + @DisplayName("getTransaction - should propagate 404 when transaction does not exist") + void getTransaction_shouldPropagate404_whenTransactionDoesNotExist() { + when(getTransactionUseCase.execute(transactionExternalId)) + .thenReturn(Mono.error(new ResponseStatusException( + org.springframework.http.HttpStatus.NOT_FOUND, + "Transaction with ID: " + transactionExternalId + " not found"))); + + StepVerifier.create(transactionController.getTransaction(transactionExternalId)) + .expectErrorMatches(ex -> + ex instanceof ResponseStatusException && + ((ResponseStatusException) ex).getStatusCode().value() == 404 && + ex.getMessage().contains(transactionExternalId)) + .verify(); + + verify(getTransactionUseCase, times(1)).execute(transactionExternalId); + } + + @Test + @DisplayName("getTransaction - should propagate error when use case fails") + void getTransaction_shouldPropagateError_whenUseCaseFails() { + when(getTransactionUseCase.execute(transactionExternalId)) + .thenReturn(Mono.error(new RuntimeException("DB connection error"))); + + StepVerifier.create(transactionController.getTransaction(transactionExternalId)) + .expectErrorMatches(ex -> + ex instanceof RuntimeException && + ex.getMessage().equals("DB connection error")) + .verify(); + + verify(getTransactionUseCase, times(1)).execute(transactionExternalId); + } +} + From d90eecc1e62656ca084b0b18cd43b5a8b2299f25 Mon Sep 17 00:00:00 2001 From: Luis Alberto Gamarra Astocondor Date: Sun, 1 Mar 2026 19:45:26 -0500 Subject: [PATCH 6/6] feat: remove folder unnecesary. --- .idea/.gitignore | 10 ---------- .idea/copilot.data.migration.ask2agent.xml | 6 ------ .idea/misc.xml | 6 ------ .idea/modules.xml | 8 -------- .idea/vcs.xml | 6 ------ 5 files changed, 36 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/copilot.data.migration.ask2agent.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index ab1f4164ed..0000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml deleted file mode 100644 index 1f2ea11e7f..0000000000 --- a/.idea/copilot.data.migration.ask2agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 4b151abfdc..0000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 65867f62de..0000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfbb..0000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file