From 212dbf7f86f6a9681f0cc8307ebc10a09d28f9a8 Mon Sep 17 00:00:00 2001 From: Jimmy Sanchez Date: Sat, 21 Feb 2026 01:34:50 -0500 Subject: [PATCH 1/2] initial commit of transactions-service and anti-fraud-service --- .gitignore | 3 + anti-fraud-service/.gitattributes | 2 + anti-fraud-service/.gitignore | 33 ++ .../.mvn/wrapper/maven-wrapper.properties | 3 + anti-fraud-service/Dockerfile | 42 +++ anti-fraud-service/mvnw | 295 ++++++++++++++++++ anti-fraud-service/mvnw.cmd | 189 +++++++++++ anti-fraud-service/pom.xml | 128 ++++++++ .../AntiFraudServiceApplication.java | 13 + .../yape/antifraud/config/KafkaConfig.java | 64 ++++ .../antifraud/constant/TransactStatus.java | 5 + .../antifraud/domain/TransactionStatus.java | 10 + .../antifraud/domain/TransactionType.java | 10 + .../antifraud/event/FraudResultEvent.java | 23 ++ .../antifraud/event/TransactionEvent.java | 23 ++ .../messaging/FraudResultPublisher.java | 38 +++ .../messaging/TransactionConsumer.java | 55 ++++ .../serializer/JacksonDeserializer.java | 30 ++ .../serializer/JacksonSerializer.java | 26 ++ .../antifraud/service/AntiFraudService.java | 43 +++ .../src/main/resources/application.yaml | 24 ++ .../AntiFraudServiceApplicationTests.java | 13 + docker-compose.yml | 90 +++++- transaction-service/.gitattributes | 2 + transaction-service/.gitignore | 33 ++ .../.mvn/wrapper/maven-wrapper.properties | 3 + transaction-service/Dockerfile | 53 ++++ transaction-service/mvnw | 295 ++++++++++++++++++ transaction-service/mvnw.cmd | 189 +++++++++++ transaction-service/pom.xml | 135 ++++++++ .../TransactionServiceApplication.java | 13 + .../yape/transaction/config/KafkaConfig.java | 99 ++++++ .../yape/transaction/config/RouterConfig.java | 24 ++ .../transaction/cosntant/TransactStatus.java | 5 + .../yape/transaction/domain/Transaction.java | 23 ++ .../transaction/domain/TransactionStatus.java | 10 + .../transaction/domain/TransactionType.java | 10 + .../yape/transaction/dto/ErrorResponse.java | 17 + .../transaction/dto/TransactionInput.java | 22 ++ .../transaction/event/FraudResultEvent.java | 23 ++ .../transaction/event/TransactionEvent.java | 23 ++ .../yape/transaction/graph/GraphService.java | 146 +++++++++ .../handler/TransactionHandler.java | 74 +++++ .../messaging/FraudResultConsumer.java | 49 +++ .../messaging/TransactionPublisher.java | 39 +++ .../yape/transaction/node/AccountNode.java | 33 ++ .../transaction/node/FraudResultNode.java | 25 ++ .../transaction/node/TransactionNode.java | 35 +++ .../repository/AccountNodeRepository.java | 23 ++ .../repository/FraudResultNodeRepository.java | 18 ++ .../repository/TransactionNodeRepository.java | 43 +++ .../serializer/JacksonDeserializer.java | 30 ++ .../serializer/JacksonSerializer.java | 26 ++ .../service/TransactionService.java | 83 +++++ .../src/main/resources/application.yaml | 42 +++ .../main/resources/graphql/schema.graphqls | 52 +++ .../TransactionServiceApplicationTests.java | 13 + 57 files changed, 2859 insertions(+), 13 deletions(-) create mode 100644 anti-fraud-service/.gitattributes create mode 100644 anti-fraud-service/.gitignore create mode 100644 anti-fraud-service/.mvn/wrapper/maven-wrapper.properties create mode 100644 anti-fraud-service/Dockerfile create mode 100755 anti-fraud-service/mvnw create mode 100644 anti-fraud-service/mvnw.cmd create mode 100644 anti-fraud-service/pom.xml create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/config/KafkaConfig.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/constant/TransactStatus.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/domain/TransactionStatus.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/domain/TransactionType.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/event/FraudResultEvent.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/event/TransactionEvent.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/messaging/FraudResultPublisher.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/messaging/TransactionConsumer.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/serializer/JacksonDeserializer.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/serializer/JacksonSerializer.java create mode 100644 anti-fraud-service/src/main/java/com/yape/antifraud/service/AntiFraudService.java create mode 100644 anti-fraud-service/src/main/resources/application.yaml create mode 100644 anti-fraud-service/src/test/java/com/yape/antifraud/AntiFraudServiceApplicationTests.java create mode 100644 transaction-service/.gitattributes create mode 100644 transaction-service/.gitignore create mode 100644 transaction-service/.mvn/wrapper/maven-wrapper.properties create mode 100644 transaction-service/Dockerfile create mode 100755 transaction-service/mvnw create mode 100644 transaction-service/mvnw.cmd create mode 100644 transaction-service/pom.xml create mode 100644 transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/config/KafkaConfig.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/config/RouterConfig.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/cosntant/TransactStatus.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/domain/Transaction.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/domain/TransactionStatus.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/domain/TransactionType.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/dto/ErrorResponse.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/dto/TransactionInput.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/event/FraudResultEvent.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/event/TransactionEvent.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/graph/GraphService.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/handler/TransactionHandler.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/messaging/FraudResultConsumer.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/messaging/TransactionPublisher.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/node/AccountNode.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/node/FraudResultNode.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/node/TransactionNode.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/repository/AccountNodeRepository.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/repository/FraudResultNodeRepository.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/repository/TransactionNodeRepository.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/serializer/JacksonDeserializer.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/serializer/JacksonSerializer.java create mode 100644 transaction-service/src/main/java/com/yape/transaction/service/TransactionService.java create mode 100644 transaction-service/src/main/resources/application.yaml create mode 100644 transaction-service/src/main/resources/graphql/schema.graphqls create mode 100644 transaction-service/src/test/java/com/yape/transaction/TransactionServiceApplicationTests.java diff --git a/.gitignore b/.gitignore index 67045665db..84c18ce1c5 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ dist # TernJS port file .tern-port + +# Neo4J initial +neo4j diff --git a/anti-fraud-service/.gitattributes b/anti-fraud-service/.gitattributes new file mode 100644 index 0000000000..3b41682ac5 --- /dev/null +++ b/anti-fraud-service/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/anti-fraud-service/.gitignore b/anti-fraud-service/.gitignore new file mode 100644 index 0000000000..667aaef0c8 --- /dev/null +++ b/anti-fraud-service/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/anti-fraud-service/.mvn/wrapper/maven-wrapper.properties b/anti-fraud-service/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..8dea6c227c --- /dev/null +++ b/anti-fraud-service/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/anti-fraud-service/Dockerfile b/anti-fraud-service/Dockerfile new file mode 100644 index 0000000000..c3059c1885 --- /dev/null +++ b/anti-fraud-service/Dockerfile @@ -0,0 +1,42 @@ +# ── Stage 1: Build ──────────────────────────────────────────────────────────── +FROM eclipse-temurin:25-jdk-alpine AS builder + +WORKDIR /app + +COPY pom.xml . +COPY .mvn/ .mvn/ +COPY mvnw . + +RUN chmod +x mvnw && \ + ./mvnw dependency:go-offline -B + +COPY src/ src/ + +RUN ./mvnw clean package -DskipTests -B && \ + mkdir -p target/extracted && \ + java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted + +# ── Stage 2: Runtime ────────────────────────────────────────────────────────── +FROM eclipse-temurin:25-jre-alpine AS runtime + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder --chown=appuser:appgroup /app/target/extracted/dependencies/ ./ +COPY --from=builder --chown=appuser:appgroup /app/target/extracted/spring-boot-loader/ ./ +COPY --from=builder --chown=appuser:appgroup /app/target/extracted/snapshot-dependencies/ ./ +COPY --from=builder --chown=appuser:appgroup /app/target/extracted/application/ ./ + +USER appuser + +# Solo puerto interno de Actuator (no expone REST hacia afuera) +EXPOSE 8081 + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:+UseZGC \ + -Djava.security.egd=file:/dev/./urandom \ + -Dspring.profiles.active=docker" + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"] \ No newline at end of file diff --git a/anti-fraud-service/mvnw b/anti-fraud-service/mvnw new file mode 100755 index 0000000000..bd8896bf22 --- /dev/null +++ b/anti-fraud-service/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/anti-fraud-service/mvnw.cmd b/anti-fraud-service/mvnw.cmd new file mode 100644 index 0000000000..92450f9327 --- /dev/null +++ b/anti-fraud-service/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/anti-fraud-service/pom.xml b/anti-fraud-service/pom.xml new file mode 100644 index 0000000000..0ebc1d6912 --- /dev/null +++ b/anti-fraud-service/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.10 + + + com.yape + anti-fraud-service + 0.0.1-SNAPSHOT + anti-fraud-service + Anti Fraud project + + + + + + + + + + + + + + + 25 + 1.3.23 + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.kafka + spring-kafka + + + + + io.projectreactor.kafka + reactor-kafka + ${reactor-kafka.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-webflux + + + + io.micrometer + micrometer-registry-prometheus + 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.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java b/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java new file mode 100644 index 0000000000..fea8719a76 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java @@ -0,0 +1,13 @@ +package com.yape.antifraud; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AntiFraudServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AntiFraudServiceApplication.class, args); + } + +} diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/config/KafkaConfig.java b/anti-fraud-service/src/main/java/com/yape/antifraud/config/KafkaConfig.java new file mode 100644 index 0000000000..2a66f83fe6 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/config/KafkaConfig.java @@ -0,0 +1,64 @@ +package com.yape.antifraud.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.antifraud.event.TransactionEvent; +import com.yape.antifraud.serializer.JacksonDeserializer; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArrayDeserializer; +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.ReactiveKafkaConsumerTemplate; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.kafka.support.serializer.JsonSerializer; +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; + + @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, JsonSerializer.class); + props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + + return new ReactiveKafkaProducerTemplate<>(SenderOptions.create(props)); + } + + @Bean + public ReactiveKafkaConsumerTemplate transactionConsumerTemplate( + ObjectMapper kafkaObjectMapper) { + + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, "anti-fraud-service-group"); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class); + + ReceiverOptions options = ReceiverOptions + .create(props) + .withValueDeserializer( + new JacksonDeserializer<>(TransactionEvent.class, kafkaObjectMapper) + ) + .subscription(Collections.singleton("transactions")); + + return new ReactiveKafkaConsumerTemplate<>(options); + } +} + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/constant/TransactStatus.java b/anti-fraud-service/src/main/java/com/yape/antifraud/constant/TransactStatus.java new file mode 100644 index 0000000000..f8a5554040 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/constant/TransactStatus.java @@ -0,0 +1,5 @@ +package com.yape.antifraud.constant; + +public enum TransactStatus { + PENDING, APPROVED, REJECTED +} diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/TransactionStatus.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/TransactionStatus.java new file mode 100644 index 0000000000..b74c75b2b4 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/TransactionStatus.java @@ -0,0 +1,10 @@ +package com.yape.antifraud.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TransactionStatus { + private String name; +} diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/TransactionType.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/TransactionType.java new file mode 100644 index 0000000000..74c63ae64a --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/TransactionType.java @@ -0,0 +1,10 @@ +package com.yape.antifraud.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TransactionType { + private String name; +} diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/event/FraudResultEvent.java b/anti-fraud-service/src/main/java/com/yape/antifraud/event/FraudResultEvent.java new file mode 100644 index 0000000000..7cf88f7c25 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/event/FraudResultEvent.java @@ -0,0 +1,23 @@ +package com.yape.antifraud.event; + +import com.yape.antifraud.domain.TransactionStatus; +import com.yape.antifraud.domain.TransactionType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FraudResultEvent { + private String transactionId; + private String transactionExternalId; + private TransactionType transactionType; + private TransactionStatus transactionStatus; + private Float value; + private Instant createdAt; +} diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/event/TransactionEvent.java b/anti-fraud-service/src/main/java/com/yape/antifraud/event/TransactionEvent.java new file mode 100644 index 0000000000..958928b4c8 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/event/TransactionEvent.java @@ -0,0 +1,23 @@ +package com.yape.antifraud.event; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionEvent { + private String transactionId; + private String accountExternalIdDebit; + private String accountExternalIdCredit; + private Integer transferTypeId; + private Float value; + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/messaging/FraudResultPublisher.java b/anti-fraud-service/src/main/java/com/yape/antifraud/messaging/FraudResultPublisher.java new file mode 100644 index 0000000000..c906a3ba49 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/messaging/FraudResultPublisher.java @@ -0,0 +1,38 @@ +package com.yape.antifraud.messaging; + +import com.yape.antifraud.event.FraudResultEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FraudResultPublisher { + + private final ReactiveKafkaProducerTemplate kafkaTemplate; + + @Value("${kafka.topics.fraud-results}") + private String fraudResultsTopic; + + public Mono publish(FraudResultEvent event) { + return kafkaTemplate + .send(fraudResultsTopic, event.getTransactionId(), event) + .doOnSuccess(result -> log.info( + "Fraud result published: {} | status: {}", + event.getTransactionId(), + event.getTransactionStatus() + )) + .doOnError(ex -> log.error( + "Failed to publish fraud result: {}", event.getTransactionId(), ex + )) + .retryWhen(Retry.backoff(3, Duration.ofMillis(500))) + .then(); + } +} diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/messaging/TransactionConsumer.java b/anti-fraud-service/src/main/java/com/yape/antifraud/messaging/TransactionConsumer.java new file mode 100644 index 0000000000..3f4db8ac1b --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/messaging/TransactionConsumer.java @@ -0,0 +1,55 @@ +package com.yape.antifraud.messaging; + +import com.yape.antifraud.event.TransactionEvent; +import com.yape.antifraud.service.AntiFraudService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.kafka.receiver.ReceiverRecord; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TransactionConsumer implements ApplicationRunner { + + private final ReactiveKafkaConsumerTemplate consumerTemplate; + private final AntiFraudService antiFraudService; + private final FraudResultPublisher fraudResultPublisher; + + @Override + public void run(ApplicationArguments args) { + consumerTemplate + .receive() + // Paralelismo por partición usando groupBy + .groupBy(record -> record.receiverOffset().topicPartition()) + .flatMap(partitionFlux -> + partitionFlux + .publishOn(Schedulers.boundedElastic()) + .concatMap(record -> processRecord(record) // concatMap respeta orden por partición + .doOnSuccess(v -> record.receiverOffset().acknowledge()) + ) + ) + .subscribe( + null, + ex -> log.error("Fatal error in consumer stream", ex) + ); + } + + private Mono processRecord(ReceiverRecord record) { + TransactionEvent event = record.value(); + log.info("Processing transaction: {}", event.getTransactionId()); + + return antiFraudService + .evaluate(event) + .flatMap(fraudResultPublisher::publish) + .onErrorResume(ex -> { + log.error("Error evaluating transaction: {}", event.getTransactionId(), ex); + return Mono.empty(); + }); + } +} \ No newline at end of file diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/serializer/JacksonDeserializer.java b/anti-fraud-service/src/main/java/com/yape/antifraud/serializer/JacksonDeserializer.java new file mode 100644 index 0000000000..4d57b5c5bb --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/serializer/JacksonDeserializer.java @@ -0,0 +1,30 @@ +package com.yape.antifraud.serializer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Deserializer; + +import java.io.IOException; + +public class JacksonDeserializer implements Deserializer { + + private final Class targetType; + private final ObjectMapper objectMapper; + + public JacksonDeserializer(Class targetType, ObjectMapper objectMapper) { + this.targetType = targetType; + this.objectMapper = objectMapper; + } + + @Override + public T deserialize(String topic, byte[] data) { + if (data == null) return null; + try { + return objectMapper.readValue(data, targetType); + } catch (IOException e) { + throw new SerializationException( + "Error deserializing JSON to " + targetType.getSimpleName() + + " for topic: " + topic, e); + } + } +} diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/serializer/JacksonSerializer.java b/anti-fraud-service/src/main/java/com/yape/antifraud/serializer/JacksonSerializer.java new file mode 100644 index 0000000000..cc09a56b3a --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/serializer/JacksonSerializer.java @@ -0,0 +1,26 @@ +package com.yape.antifraud.serializer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Serializer; + +public class JacksonSerializer implements Serializer { + + private final ObjectMapper objectMapper; + + public JacksonSerializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public byte[] serialize(String topic, T data) { + if (data == null) return null; + try { + return objectMapper.writeValueAsBytes(data); + } catch (JsonProcessingException e) { + throw new SerializationException( + "Error serializing object to JSON for topic: " + topic, e); + } + } +} \ No newline at end of file diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/service/AntiFraudService.java b/anti-fraud-service/src/main/java/com/yape/antifraud/service/AntiFraudService.java new file mode 100644 index 0000000000..75fc18531b --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/service/AntiFraudService.java @@ -0,0 +1,43 @@ +package com.yape.antifraud.service; + +import com.yape.antifraud.constant.TransactStatus; +import com.yape.antifraud.domain.TransactionStatus; +import com.yape.antifraud.domain.TransactionType; +import com.yape.antifraud.event.FraudResultEvent; +import com.yape.antifraud.event.TransactionEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.time.Instant; + +@Service +@Slf4j +public class AntiFraudService { + + public Mono evaluate(TransactionEvent event) { + return Mono.fromCallable(() -> runRules(event)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private FraudResultEvent runRules(TransactionEvent event) { + String status; + + if (event.getValue() >= 1000) { + status = TransactStatus.REJECTED.name(); + } else { + status = TransactStatus.APPROVED.name(); + } + + return FraudResultEvent.builder() + .transactionId(event.getTransactionId()) + .transactionExternalId(event.getTransactionId()) + .transactionType(new TransactionType(event.getTransferTypeId().toString())) + .transactionStatus(new TransactionStatus(status)) + .value(event.getValue()) + .createdAt(Instant.now()) + .build(); + } + +} diff --git a/anti-fraud-service/src/main/resources/application.yaml b/anti-fraud-service/src/main/resources/application.yaml new file mode 100644 index 0000000000..8dfb66ba9d --- /dev/null +++ b/anti-fraud-service/src/main/resources/application.yaml @@ -0,0 +1,24 @@ +spring: + application: + name: anti-fraud-service + + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: anti-fraud-service-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + properties: + spring.json.trusted.packages: "*" + +kafka: + topics: + transactions: transactions + fraud-results: fraud-results + +server: + port: 8081 \ No newline at end of file diff --git a/anti-fraud-service/src/test/java/com/yape/antifraud/AntiFraudServiceApplicationTests.java b/anti-fraud-service/src/test/java/com/yape/antifraud/AntiFraudServiceApplicationTests.java new file mode 100644 index 0000000000..c4447fa858 --- /dev/null +++ b/anti-fraud-service/src/test/java/com/yape/antifraud/AntiFraudServiceApplicationTests.java @@ -0,0 +1,13 @@ +package com.yape.antifraud; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AntiFraudServiceApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..cf1720db6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,89 @@ version: "3.7" services: - postgres: - image: postgres:14 - ports: - - "5432:5432" - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres + zookeeper: - image: confluentinc/cp-zookeeper:5.5.3 + image: confluentinc/cp-zookeeper:7.5.0 environment: ZOOKEEPER_CLIENT_PORT: 2181 + ports: + - "2181:2181" + networks: + - backend + kafka: - image: confluentinc/cp-enterprise-kafka:5.5.3 + image: confluentinc/cp-kafka:7.5.0 depends_on: [zookeeper] + ports: + - "9092:9092" environment: - KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" + KAFKA_BROKER_ID: 1 + 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_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_JMX_PORT: 9991 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" + networks: + - backend + + neo4j: + image: neo4j:5.18-community ports: - - 9092:9092 + - "7474:7474" # Browser UI + - "7687:7687" # Bolt protocol (driver) + environment: + NEO4J_AUTH: neo4j/secret1234 + NEO4J_PLUGINS: '["apoc"]' # procedimientos APOC útiles para merge/upsert + NEO4J_dbms_security_procedures_unrestricted: apoc.* + NEO4J_dbms_memory_heap_initial__size: 512m + NEO4J_dbms_memory_heap_max__size: 1G + volumes: + - neo4j_data:/data + - neo4j_logs:/logs + - ./neo4j/init:/docker-entrypoint-initdb.d # scripts Cypher de inicialización + healthcheck: + test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "secret1234", "RETURN 1"] + interval: 15s + timeout: 10s + retries: 5 + networks: + - backend + + transaction-service: + build: ./transaction-service + ports: + - "8080:8080" + depends_on: + kafka: + condition: service_started + neo4j: + condition: service_healthy + environment: + KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + NEO4J_URI: bolt://neo4j:7687 + NEO4J_USERNAME: neo4j + NEO4J_PASSWORD: secret1234 + restart: on-failure + networks: + - backend + + anti-fraud-service: + build: ./anti-fraud-service + ports: + - "8081:8081" + depends_on: + kafka: + condition: service_started + environment: + KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + restart: on-failure + networks: + - backend + +volumes: + neo4j_data: + neo4j_logs: + +networks: + backend: + driver: bridge \ No newline at end of file diff --git a/transaction-service/.gitattributes b/transaction-service/.gitattributes new file mode 100644 index 0000000000..3b41682ac5 --- /dev/null +++ b/transaction-service/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/transaction-service/.gitignore b/transaction-service/.gitignore new file mode 100644 index 0000000000..667aaef0c8 --- /dev/null +++ b/transaction-service/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/transaction-service/.mvn/wrapper/maven-wrapper.properties b/transaction-service/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..8dea6c227c --- /dev/null +++ b/transaction-service/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/transaction-service/Dockerfile b/transaction-service/Dockerfile new file mode 100644 index 0000000000..f0f3d6b21f --- /dev/null +++ b/transaction-service/Dockerfile @@ -0,0 +1,53 @@ +# ── Stage 1: Build ──────────────────────────────────────────────────────────── +FROM eclipse-temurin:25-jdk-alpine AS builder + +WORKDIR /app + +# Copia solo el pom primero para aprovechar cache de capas de Docker +# Si el pom no cambia, Maven no re-descarga dependencias en rebuilds +COPY pom.xml . +COPY .mvn/ .mvn/ +COPY mvnw . + +RUN chmod +x mvnw && \ + ./mvnw dependency:go-offline -B + +# Copia el código fuente y compila +COPY src/ src/ + +RUN ./mvnw clean package -DskipTests -B && \ + mkdir -p target/extracted && \ + java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted + +# ── Stage 2: Runtime ────────────────────────────────────────────────────────── +FROM eclipse-temurin:25-jre-alpine AS runtime + +# Usuario no-root por seguridad +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +# Capas del JAR en orden de menor a mayor frecuencia de cambio +# Esto optimiza el cache de Docker en rebuilds sucesivos +COPY --from=builder --chown=appuser:appgroup /app/target/extracted/dependencies/ ./ +COPY --from=builder --chown=appuser:appgroup /app/target/extracted/spring-boot-loader/ ./ +COPY --from=builder --chown=appuser:appgroup /app/target/extracted/snapshot-dependencies/ ./ +COPY --from=builder --chown=appuser:appgroup /app/target/extracted/application/ ./ + +USER appuser + +# Puerto REST + GraphQL WebSocket +EXPOSE 8080 + +# Opciones JVM optimizadas para contenedores: +# -XX:+UseContainerSupport → detecta límites de CPU/RAM del contenedor +# -XX:MaxRAMPercentage → usa hasta el 75% de la RAM asignada al contenedor +# -XX:+UseZGC → GC de baja latencia, ideal para servicios reactivos +# -Djava.security.egd → acelera el arranque en entornos sin /dev/random +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:+UseZGC \ + -Djava.security.egd=file:/dev/./urandom \ + -Dspring.profiles.active=docker" + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"] \ No newline at end of file diff --git a/transaction-service/mvnw b/transaction-service/mvnw new file mode 100755 index 0000000000..bd8896bf22 --- /dev/null +++ b/transaction-service/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/transaction-service/mvnw.cmd b/transaction-service/mvnw.cmd new file mode 100644 index 0000000000..92450f9327 --- /dev/null +++ b/transaction-service/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/transaction-service/pom.xml b/transaction-service/pom.xml new file mode 100644 index 0000000000..7966501014 --- /dev/null +++ b/transaction-service/pom.xml @@ -0,0 +1,135 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.10 + + + com.yape + transaction-service + 0.0.1-SNAPSHOT + transaction-service + Transaction project + + + + + + + + + + + + + + + 25 + 1.3.23 + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-data-neo4j + + + org.springframework.boot + spring-boot-starter-graphql + + + + + org.springframework.kafka + spring-kafka + + + + + io.projectreactor.kafka + reactor-kafka + ${reactor-kafka.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-webflux + + + + io.micrometer + micrometer-registry-prometheus + 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.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java b/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java new file mode 100644 index 0000000000..96fb793ee9 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java @@ -0,0 +1,13 @@ +package com.yape.transaction; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TransactionServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(TransactionServiceApplication.class, args); + } + +} diff --git a/transaction-service/src/main/java/com/yape/transaction/config/KafkaConfig.java b/transaction-service/src/main/java/com/yape/transaction/config/KafkaConfig.java new file mode 100644 index 0000000000..cdbe6c8593 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/config/KafkaConfig.java @@ -0,0 +1,99 @@ +package com.yape.transaction.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.transaction.event.FraudResultEvent; +import com.yape.transaction.serializer.JacksonDeserializer; +import com.yape.transaction.serializer.JacksonSerializer; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArrayDeserializer; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; + +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; +import reactor.kafka.receiver.ReceiverOptions; +import reactor.kafka.sender.SenderOptions; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${kafka.topics.transactions}") + private String transactionsTopic; + + @Value("${kafka.topics.fraud-results}") + private String fraudResultsTopic; + + @Bean + public ReactiveKafkaProducerTemplate reactiveKafkaProducerTemplate( + ObjectMapper kafkaObjectMapper) { + + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + props.put(ProducerConfig.RETRIES_CONFIG, 3); + + SenderOptions senderOptions = SenderOptions + .create(props) + .withValueSerializer(new JacksonSerializer<>(kafkaObjectMapper)) + .withKeySerializer(new StringSerializer()); + + return new ReactiveKafkaProducerTemplate<>(senderOptions); + } + + @Bean + public ReactiveKafkaConsumerTemplate fraudResultConsumerTemplate( + ObjectMapper kafkaObjectMapper) { + + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, "transaction-service-group"); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class); + + ReceiverOptions options = ReceiverOptions + .create(props) + .withValueDeserializer( + new JacksonDeserializer<>(FraudResultEvent.class, kafkaObjectMapper) + ) + .subscription(Collections.singleton("fraud-results")); + + return new ReactiveKafkaConsumerTemplate<>(options); + } + + @Bean + public NewTopic transactionsTopic() { + log.info("Output Topic: {}", transactionsTopic); + return TopicBuilder.name("transactions") + .partitions(6) + .replicas(1) + .build(); + } + + @Bean + public NewTopic fraudResultsTopic() { + log.info("Input Topic: {}", fraudResultsTopic); + return TopicBuilder.name("fraud-results") + .partitions(6) + .replicas(1) + .build(); + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/config/RouterConfig.java b/transaction-service/src/main/java/com/yape/transaction/config/RouterConfig.java new file mode 100644 index 0000000000..f0583ad0fd --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/config/RouterConfig.java @@ -0,0 +1,24 @@ +package com.yape.transaction.config; + +import com.yape.transaction.handler.TransactionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +@Configuration +public class RouterConfig { + + @Bean + public RouterFunction transactionRoutes(TransactionHandler handler) { + return RouterFunctions.route() + .nest(RequestPredicates.path("/api/v1/transactions"), builder -> builder + .POST("", handler::createTransaction) + .GET("/{id}", handler::getTransaction) + .GET("/account/{accountId}", handler::getAccountTransactions) + ) + .build(); + } +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/transaction/cosntant/TransactStatus.java b/transaction-service/src/main/java/com/yape/transaction/cosntant/TransactStatus.java new file mode 100644 index 0000000000..f382bf8604 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/cosntant/TransactStatus.java @@ -0,0 +1,5 @@ +package com.yape.transaction.cosntant; + +public enum TransactStatus { + PENDING, APPROVED, REJECTED +} diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/Transaction.java b/transaction-service/src/main/java/com/yape/transaction/domain/Transaction.java new file mode 100644 index 0000000000..858283956e --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/Transaction.java @@ -0,0 +1,23 @@ +package com.yape.transaction.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Transaction { + private String id; + private String accountExternalIdDebit; + private String accountExternalIdCredit; + private Integer transferTypeId; + private Float value; + private String status; + private Instant createdAt; + private Instant updatedAt; +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/TransactionStatus.java b/transaction-service/src/main/java/com/yape/transaction/domain/TransactionStatus.java new file mode 100644 index 0000000000..232612f8b4 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/TransactionStatus.java @@ -0,0 +1,10 @@ +package com.yape.transaction.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TransactionStatus { + private String name; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/TransactionType.java b/transaction-service/src/main/java/com/yape/transaction/domain/TransactionType.java new file mode 100644 index 0000000000..6cf72d4712 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/TransactionType.java @@ -0,0 +1,10 @@ +package com.yape.transaction.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TransactionType { + private String name; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/dto/ErrorResponse.java b/transaction-service/src/main/java/com/yape/transaction/dto/ErrorResponse.java new file mode 100644 index 0000000000..b15f332144 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/dto/ErrorResponse.java @@ -0,0 +1,17 @@ +package com.yape.transaction.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.time.Instant; + +@Data +@AllArgsConstructor(staticName = "of") +public class ErrorResponse { + private String message; + private Instant timestamp = Instant.now(); + + public static ErrorResponse of(String message) { + return new ErrorResponse(message, Instant.now()); + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/dto/TransactionInput.java b/transaction-service/src/main/java/com/yape/transaction/dto/TransactionInput.java new file mode 100644 index 0000000000..b60536232f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/dto/TransactionInput.java @@ -0,0 +1,22 @@ +package com.yape.transaction.dto; + +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TransactionInput { + @NotBlank + private String accountExternalIdDebit; + @NotBlank + private String accountExternalIdCredit; + private Integer transferTypeId; + @NotNull + @DecimalMin("0.01") + private Float value; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/event/FraudResultEvent.java b/transaction-service/src/main/java/com/yape/transaction/event/FraudResultEvent.java new file mode 100644 index 0000000000..19af3a8e76 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/event/FraudResultEvent.java @@ -0,0 +1,23 @@ +package com.yape.transaction.event; + +import com.yape.transaction.domain.TransactionStatus; +import com.yape.transaction.domain.TransactionType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FraudResultEvent { + private String transactionId; + private String transactionExternalId; + private TransactionType transactionType; + private TransactionStatus transactionStatus; + private Float value; + private Instant createdAt; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/event/TransactionEvent.java b/transaction-service/src/main/java/com/yape/transaction/event/TransactionEvent.java new file mode 100644 index 0000000000..97795c1e38 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/event/TransactionEvent.java @@ -0,0 +1,23 @@ +package com.yape.transaction.event; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionEvent { + private String transactionId; + private String accountExternalIdDebit; + private String accountExternalIdCredit; + private Integer transferTypeId; + private Float value; + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/transaction/graph/GraphService.java b/transaction-service/src/main/java/com/yape/transaction/graph/GraphService.java new file mode 100644 index 0000000000..1b8a41b306 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/graph/GraphService.java @@ -0,0 +1,146 @@ +package com.yape.transaction.graph; + +import com.yape.transaction.domain.Transaction; +import com.yape.transaction.event.FraudResultEvent; +import com.yape.transaction.node.FraudResultNode; +import com.yape.transaction.node.TransactionNode; +import com.yape.transaction.repository.TransactionNodeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.neo4j.core.ReactiveNeo4jClient; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GraphService { + + private final ReactiveNeo4jClient neo4jClient; + private final TransactionNodeRepository txRepository; + + public Mono saveTransaction(Transaction domain) { + return neo4jClient.query(""" + MERGE (a:Account {accountId: $accountId}) + ON CREATE SET a.createdAt = $now + MERGE (t:Transaction {transactionId: $transactionId}) + ON CREATE SET + t.accountExternalIdDebit = $accountExternalIdDebit, + t.accountExternalIdCredit = $accountExternalIdCredit, + t.transferTypeId = $transferTypeId, + t.value = $value, + t.status = $status, + t.createdAt = $createdAt, + t.updatedAt = $updatedAt + MERGE (a)-[:INITIATED]->(t) + RETURN t + """) + .bindAll(Map.of( + "accountId", domain.getAccountExternalIdDebit(), + "transactionId", domain.getId(), + "accountExternalIdDebit", domain.getAccountExternalIdDebit(), + "accountExternalIdCredit", domain.getAccountExternalIdCredit(), + "transferTypeId", domain.getTransferTypeId(), + "value", domain.getValue().toString(), + "status", domain.getStatus(), + "createdAt", domain.getCreatedAt().toString(), + "updatedAt", domain.getUpdatedAt().toString(), + "now", Instant.now().toString() + )) + .fetchAs(TransactionNode.class) + .mappedBy((typeSystem, record) -> mapToTransactionNode(record.get("t"))) + .one() + .doOnSuccess(t -> log.info("[Neo4j] Transaction node saved: {}", t.getTransactionId())) + .doOnError(e -> log.error("[Neo4j] Error saving transaction node", e)); + } + + public Mono applyFraudResult(FraudResultEvent event) { + return neo4jClient.query(""" + MATCH (t:Transaction {transactionId: $transactionId}) + SET t.status = $transactionStatus, + t.updatedAt = $updatedAt + MERGE (f:FraudResult {transactionId: $transactionId}) + ON CREATE SET + f.transactionExternalId = $transactionExternalId, + f.transactionType = $transactionType, + f.transactionStatus = $transactionStatus, + f.value = $value, + f.createdAt = $createdAt + ON MATCH SET + f.transactionExternalId = $transactionExternalId, + f.transactionType = $transactionType, + f.transactionStatus = $transactionStatus, + f.value = $value, + f.createdAt = $createdAt + MERGE (t)-[:HAS_RESULT]->(f) + RETURN t + """) + .bindAll(Map.of( + "transactionId", event.getTransactionId(), + "transactionExternalId", event.getTransactionExternalId(), + "transactionType", event.getTransactionType().getName(), + "transactionStatus", event.getTransactionStatus().getName(), + "value", event.getValue(), + "createdAt", Instant.now().toString(), + "updatedAt", Instant.now().toString() + )) + .fetchAs(TransactionNode.class) + .mappedBy((typeSystem, record) -> mapToTransactionNode(record.get("t"))) + .one() + .doOnSuccess(t -> log.info( + "[Neo4j] FraudResult applied: transactionId={} status={}", + event.getTransactionId(), event.getTransactionStatus().getName() + )) + .doOnError(e -> log.error("[Neo4j] Error applying fraud result", e)); + } + + public Mono findTransactionById(String transactionId) { + return neo4jClient.query(""" + MATCH (t:Transaction {transactionId: $transactionId}) + OPTIONAL MATCH (t)-[:HAS_RESULT]->(f:FraudResult) + RETURN t, f + """) + .bindAll(Map.of("transactionId", transactionId)) + .fetchAs(TransactionNode.class) + .mappedBy((typeSystem, record) -> { + TransactionNode node = mapToTransactionNode(record.get("t")); + if (!record.get("f").isNull()) { + node.setFraudResult(mapToFraudResultNode(record.get("f"))); + } + return node; + }) + .one(); + } + + private FraudResultNode mapToFraudResultNode(org.neo4j.driver.Value node) { + return FraudResultNode.builder() + .transactionId(node.get("transactionId").asString()) + .transactionExternalId(node.get("transactionExternalId").asString()) + .transactionType(node.get("transactionType").asString()) + .transactionStatus(node.get("transactionStatus").asString()) + .value(node.get("value").asFloat()) + .createdAt(node.get("createdAt").asOffsetDateTime().toInstant()) + .build(); + } + + public Flux getAccountTransactions(String accountId, int limit) { + return txRepository.findByAccountId(accountId, limit); + } + + private TransactionNode mapToTransactionNode(org.neo4j.driver.Value node) { + return TransactionNode.builder() + .transactionId(node.get("transactionId").asString()) + .accountExternalIdDebit(node.get("accountExternalIdDebit").asString()) + .accountExternalIdCredit(node.get("accountExternalIdCredit").asString()) + .transferTypeId(node.get("transferTypeId").asInt()) + .value(Float.parseFloat(node.get("value").asString())) + .status(node.get("status").asString()) + .createdAt(Instant.parse(node.get("createdAt").asString())) + .updatedAt(Instant.parse(node.get("updatedAt").asString())) + .build(); + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/handler/TransactionHandler.java b/transaction-service/src/main/java/com/yape/transaction/handler/TransactionHandler.java new file mode 100644 index 0000000000..7067521867 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/handler/TransactionHandler.java @@ -0,0 +1,74 @@ +package com.yape.transaction.handler; + +import com.yape.transaction.dto.TransactionInput; +import com.yape.transaction.dto.ErrorResponse; +import com.yape.transaction.node.TransactionNode; +import com.yape.transaction.service.TransactionService; +import jakarta.validation.ValidationException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TransactionHandler { + + private final TransactionService transactionService; + + public Mono createTransaction(ServerRequest request) { + return request.bodyToMono(TransactionInput.class) + .doOnNext(this::validate) + .flatMap(transactionService::createTransaction) + .flatMap(tx -> ServerResponse + .status(HttpStatus.CREATED) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(tx) + ) + .onErrorResume(ValidationException.class, ex -> + ServerResponse.badRequest() + .bodyValue(ErrorResponse.of(ex.getMessage())) + ) + .onErrorResume(ex -> { + log.error("Unexpected error creating transaction", ex); + return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR) + .bodyValue(ErrorResponse.of("Internal server error")); + }); + } + + public Mono getTransaction(ServerRequest request) { + String id = request.pathVariable("id"); + return transactionService.findById(id) + .flatMap(tx -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(tx) + ) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + public Mono getAccountTransactions(ServerRequest request) { + String accountId = request.pathVariable("accountId"); + int limit = request.queryParam("limit") + .map(Integer::parseInt) + .orElse(20); + + return ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(transactionService.getAccountTransactions(accountId, limit), + TransactionNode.class); + } + + private void validate(TransactionInput input) { + if (input.getAccountExternalIdDebit() == null || input.getAccountExternalIdDebit().isBlank()) + throw new ValidationException("accountExternalIdDebit is required"); + if (input.getAccountExternalIdCredit() == null || input.getAccountExternalIdCredit().isBlank()) + throw new ValidationException("accountExternalIdCredit is required"); + if (input.getValue() == null || input.getValue().compareTo(0.0F) <= 0) + throw new ValidationException("amount must be greater than 0"); + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/messaging/FraudResultConsumer.java b/transaction-service/src/main/java/com/yape/transaction/messaging/FraudResultConsumer.java new file mode 100644 index 0000000000..c4b864f7bb --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/messaging/FraudResultConsumer.java @@ -0,0 +1,49 @@ +package com.yape.transaction.messaging; + +import com.yape.transaction.event.FraudResultEvent; +import com.yape.transaction.service.TransactionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FraudResultConsumer implements ApplicationRunner { + + private final ReactiveKafkaConsumerTemplate consumerTemplate; + private final TransactionService transactionService; + + // Sink para exponer eventos via GraphQL Subscription + private final Sinks.Many fraudResultSink = + Sinks.many().multicast().onBackpressureBuffer(); + + @Override + public void run(ApplicationArguments args) { + consumerTemplate + .receiveAutoAck() + .doOnNext(record -> log.info( + "Fraud result received: transactionId={} status={}", + record.value().getTransactionId(), + record.value().getTransactionStatus().getName() + )) + .flatMap(record -> transactionService + .updateStatus(record.value()) + .doOnSuccess(tx -> fraudResultSink.tryEmitNext(record.value())) + .doOnError(e -> log.error("Error processing fraud result", e)) + .onErrorResume(e -> Mono.empty()) // evita que el stream se rompa + ) + .subscribe(); + } + + // Expuesto para GraphQL Subscription + public Flux getFraudResultStream() { + return fraudResultSink.asFlux(); + } +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/transaction/messaging/TransactionPublisher.java b/transaction-service/src/main/java/com/yape/transaction/messaging/TransactionPublisher.java new file mode 100644 index 0000000000..4bc53d572b --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/messaging/TransactionPublisher.java @@ -0,0 +1,39 @@ +package com.yape.transaction.messaging; + +import com.yape.transaction.event.TransactionEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TransactionPublisher { + + private final ReactiveKafkaProducerTemplate kafkaTemplate; + + @Value("${kafka.topics.transactions}") + private String transactionsTopic; + + public Mono publish(TransactionEvent event) { + return kafkaTemplate + .send(transactionsTopic, event.getTransactionId(), event) + .doOnSuccess(result -> log.info( + "Transaction published: {} | partition: {} | offset: {}", + event.getTransactionId(), + result.recordMetadata().partition(), + result.recordMetadata().offset() + )) + .doOnError(ex -> log.error( + "Failed to publish transaction: {}", event.getTransactionId(), ex + )) + .retryWhen(Retry.backoff(3, Duration.ofMillis(500))) + .then(); + } +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/transaction/node/AccountNode.java b/transaction-service/src/main/java/com/yape/transaction/node/AccountNode.java new file mode 100644 index 0000000000..683046f82f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/node/AccountNode.java @@ -0,0 +1,33 @@ +package com.yape.transaction.node; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import static org.springframework.data.neo4j.core.schema.Relationship.Direction.OUTGOING; + +@Node("Account") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AccountNode { + + @Id + private String accountId; + + private String ownerName; + private Instant createdAt; + + // Relaciones salientes: una cuenta puede tener muchas transacciones + @Relationship(type = "INITIATED", direction = OUTGOING) + private List transactions = new ArrayList<>(); +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/transaction/node/FraudResultNode.java b/transaction-service/src/main/java/com/yape/transaction/node/FraudResultNode.java new file mode 100644 index 0000000000..10916bdeca --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/node/FraudResultNode.java @@ -0,0 +1,25 @@ +package com.yape.transaction.node; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; + +import java.time.Instant; + +@Node("FraudResult") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FraudResultNode { + @Id + private String transactionId; + private String transactionExternalId; + private String transactionType; + private String transactionStatus; // APPROVED | REJECTED | FLAGGED + private Float value; + private Instant createdAt; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/node/TransactionNode.java b/transaction-service/src/main/java/com/yape/transaction/node/TransactionNode.java new file mode 100644 index 0000000000..26f03ce725 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/node/TransactionNode.java @@ -0,0 +1,35 @@ +package com.yape.transaction.node; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; + +import java.time.Instant; + +import static org.springframework.data.neo4j.core.schema.Relationship.Direction.OUTGOING; + +@Node("Transaction") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionNode { + + @Id + private String transactionId; + private String accountExternalIdDebit; + private String accountExternalIdCredit; + private Integer transferTypeId; + private Float value; + private String status; // PENDING | APPROVED | REJECTED + private Instant createdAt; + private Instant updatedAt; + + // Relación saliente hacia el resultado de fraude + @Relationship(type = "HAS_RESULT", direction = OUTGOING) + private FraudResultNode fraudResult; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/repository/AccountNodeRepository.java b/transaction-service/src/main/java/com/yape/transaction/repository/AccountNodeRepository.java new file mode 100644 index 0000000000..61129544fe --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/repository/AccountNodeRepository.java @@ -0,0 +1,23 @@ +package com.yape.transaction.repository; + +import com.yape.transaction.node.AccountNode; +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +import java.time.Instant; + +@Repository +public interface AccountNodeRepository + extends ReactiveNeo4jRepository { + + // MERGE: crea la cuenta si no existe, la devuelve si ya existe + @Query(""" + MERGE (a:Account {accountId: $accountId}) + ON CREATE SET a.createdAt = $createdAt + RETURN a + """) + Mono mergeAccount(String accountId, Instant createdAt); + +} diff --git a/transaction-service/src/main/java/com/yape/transaction/repository/FraudResultNodeRepository.java b/transaction-service/src/main/java/com/yape/transaction/repository/FraudResultNodeRepository.java new file mode 100644 index 0000000000..c57db9162a --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/repository/FraudResultNodeRepository.java @@ -0,0 +1,18 @@ +package com.yape.transaction.repository; + +import com.yape.transaction.node.FraudResultNode; +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +@Repository +public interface FraudResultNodeRepository + extends ReactiveNeo4jRepository { + + @Query(""" + MATCH (t:Transaction {transactionId: $transactionId})-[:HAS_RESULT]->(f:FraudResult) + RETURN f + """) + Mono findByTransactionId(String transactionId); +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/transaction/repository/TransactionNodeRepository.java b/transaction-service/src/main/java/com/yape/transaction/repository/TransactionNodeRepository.java new file mode 100644 index 0000000000..558005ad70 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/repository/TransactionNodeRepository.java @@ -0,0 +1,43 @@ +package com.yape.transaction.repository; + +import com.yape.transaction.node.TransactionNode; +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Instant; + +@Repository +public interface TransactionNodeRepository + extends ReactiveNeo4jRepository { + + // Todas las transacciones de una cuenta con su resultado de fraude + @Query(""" + MATCH (a:Account {accountId: $accountId})-[:INITIATED]->(t:Transaction) + OPTIONAL MATCH (t)-[:HAS_RESULT]->(f:FraudResult) + RETURN t, collect(f) as fraudResult + ORDER BY t.createdAt DESC + LIMIT $limit + """) + Flux findByAccountId(String accountId, int limit); + + // Transacciones por estado (ej: todas las REJECTED de las últimas 24h) + @Query(""" + MATCH (t:Transaction {status: $status}) + WHERE t.createdAt >= $since + RETURN t + ORDER BY t.createdAt DESC + """) + Flux findByStatusSince(String status, Instant since); + + // Actualiza solo el status y updatedAt sin cargar el nodo completo + @Query(""" + MATCH (t:Transaction {transactionId: $transactionId}) + SET t.status = $status, t.updatedAt = $updatedAt + RETURN t + """) + Mono updateStatus( + String transactionId, String status, Instant updatedAt); +} diff --git a/transaction-service/src/main/java/com/yape/transaction/serializer/JacksonDeserializer.java b/transaction-service/src/main/java/com/yape/transaction/serializer/JacksonDeserializer.java new file mode 100644 index 0000000000..81d1e81bab --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/serializer/JacksonDeserializer.java @@ -0,0 +1,30 @@ +package com.yape.transaction.serializer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Deserializer; + +import java.io.IOException; + +public class JacksonDeserializer implements Deserializer { + + private final Class targetType; + private final ObjectMapper objectMapper; + + public JacksonDeserializer(Class targetType, ObjectMapper objectMapper) { + this.targetType = targetType; + this.objectMapper = objectMapper; + } + + @Override + public T deserialize(String topic, byte[] data) { + if (data == null) return null; + try { + return objectMapper.readValue(data, targetType); + } catch (IOException e) { + throw new SerializationException( + "Error deserializing JSON to " + targetType.getSimpleName() + + " for topic: " + topic, e); + } + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/serializer/JacksonSerializer.java b/transaction-service/src/main/java/com/yape/transaction/serializer/JacksonSerializer.java new file mode 100644 index 0000000000..9599decc06 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/serializer/JacksonSerializer.java @@ -0,0 +1,26 @@ +package com.yape.transaction.serializer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Serializer; + +public class JacksonSerializer implements Serializer { + + private final ObjectMapper objectMapper; + + public JacksonSerializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public byte[] serialize(String topic, T data) { + if (data == null) return null; + try { + return objectMapper.writeValueAsBytes(data); + } catch (JsonProcessingException e) { + throw new SerializationException( + "Error serializing object to JSON for topic: " + topic, e); + } + } +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/transaction/service/TransactionService.java b/transaction-service/src/main/java/com/yape/transaction/service/TransactionService.java new file mode 100644 index 0000000000..b5336a23b1 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/service/TransactionService.java @@ -0,0 +1,83 @@ +package com.yape.transaction.service; + +import com.yape.transaction.cosntant.TransactStatus; +import com.yape.transaction.domain.Transaction; +import com.yape.transaction.dto.TransactionInput; +import com.yape.transaction.event.FraudResultEvent; +import com.yape.transaction.event.TransactionEvent; +import com.yape.transaction.graph.GraphService; +import com.yape.transaction.messaging.TransactionPublisher; +import com.yape.transaction.node.TransactionNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TransactionService { + + private final TransactionPublisher transactionPublisher; + private final GraphService graphService; + + public Mono createTransaction(TransactionInput input) { + Transaction tx = Transaction.builder() + .id(UUID.randomUUID().toString()) + .accountExternalIdDebit(input.getAccountExternalIdDebit()) + .accountExternalIdCredit(input.getAccountExternalIdCredit()) + .transferTypeId(input.getTransferTypeId()) + .value(input.getValue()) + .status(TransactStatus.PENDING.name()) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + + return graphService.saveTransaction(tx) + .flatMap(saved -> { + TransactionEvent event = TransactionEvent.builder() + .transactionId(tx.getId()) + .accountExternalIdDebit(tx.getAccountExternalIdDebit()) + .accountExternalIdCredit(tx.getAccountExternalIdCredit()) + .transferTypeId(tx.getTransferTypeId()) + .value(tx.getValue()) + .createdAt(LocalDateTime.now()) + .build(); + return transactionPublisher.publish(event).thenReturn(tx); + }); + } + + public Mono findById(String id) { + return graphService.findTransactionById(id) + .map(node -> Transaction.builder() + .id(node.getTransactionId()) + .accountExternalIdDebit(node.getAccountExternalIdDebit()) + .accountExternalIdCredit(node.getAccountExternalIdCredit()) + .transferTypeId(node.getTransferTypeId()) + .value(node.getValue()) + .status(node.getStatus()) + .createdAt(node.getCreatedAt()) + .updatedAt(node.getUpdatedAt()) + .build() + ); + } + + public Mono updateStatus(FraudResultEvent fraudResult) { + return graphService.applyFraudResult(fraudResult) + .map(node -> Transaction.builder() + .id(node.getTransactionId()) + .status(node.getStatus()) + .updatedAt(node.getUpdatedAt()) + .build() + ); + } + + public Flux getAccountTransactions(String accountId, int limit) { + return graphService.getAccountTransactions(accountId, limit); + } +} diff --git a/transaction-service/src/main/resources/application.yaml b/transaction-service/src/main/resources/application.yaml new file mode 100644 index 0000000000..554a0db9b4 --- /dev/null +++ b/transaction-service/src/main/resources/application.yaml @@ -0,0 +1,42 @@ +spring: + application: + name: transaction-service + + neo4j: + uri: ${NEO4J_URI:bolt://localhost:7687} + authentication: + username: ${NEO4J_USERNAME:neo4j} + password: ${NEO4J_PASSWORD:secret1234} + + data: + neo4j: + repositories: + type: reactive # activa ReactiveNeo4jRepository + + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:kafka:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: transaction-service-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + properties: + spring.json.trusted.packages: "*" + + graphql: + schema: + locations: classpath:graphql/**/ # valor por defecto, no necesitas cambiarlo + file-extensions: .graphqls, .gqls # valor por defecto + websocket: + path: /graphql-ws + +kafka: + topics: + transactions: transactions + fraud-results: fraud-results + +server: + port: 8080 \ No newline at end of file diff --git a/transaction-service/src/main/resources/graphql/schema.graphqls b/transaction-service/src/main/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..06a6492a2f --- /dev/null +++ b/transaction-service/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,52 @@ +type Transaction { + id: ID! + accountExternalIdDebit: String! + accountExternalIdCredit: String! + transferTypeId: Int! + value: Float! + status: TransactStatus! + createdAt: String! + updatedAt: String! + fraudResult: FraudResult! +} + +type FraudResult { + transactionId: ID! + transactionExternalId: String! + transactionType: TransactionType! + transactionStatus: TransactionStatus! + value: Float! + createdAt: String! +} + +type TransactionType { + name: TransactType! +} + +type TransactionStatus { + name: TransactStatus! +} + +type Account { + accountId: ID! +} + +enum TransactStatus { + PENDING + APPROVED + REJECTED +} + +enum TransactType { + DEPOSIT + TRANSFER + PAYMENT + INTEREST + REFOUND +} + +type Query { + transaction(id: ID!): Transaction + accountTransactions(accountId: ID!, limit: Int): [Transaction!]! + highRiskAccounts(minRejections: Int!): [Account!]! +} diff --git a/transaction-service/src/test/java/com/yape/transaction/TransactionServiceApplicationTests.java b/transaction-service/src/test/java/com/yape/transaction/TransactionServiceApplicationTests.java new file mode 100644 index 0000000000..b302010df9 --- /dev/null +++ b/transaction-service/src/test/java/com/yape/transaction/TransactionServiceApplicationTests.java @@ -0,0 +1,13 @@ +package com.yape.transaction; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class TransactionServiceApplicationTests { + + @Test + void contextLoads() { + } + +} From f5b5b0d81ee09922ce8731872585674a3cff0eea Mon Sep 17 00:00:00 2001 From: Jimmy Sanchez Date: Sat, 21 Feb 2026 02:20:05 -0500 Subject: [PATCH 2/2] fix date parsing in GET endpoint and adding readme documentation --- README.md | 54 +++++++++++++++++++ .../yape/transaction/graph/GraphService.java | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b067a71026..c7d329025a 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,57 @@ You can use Graphql; 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. If you have any questions, please let us know. + +# Solution +For this scenario I created two reactive microservices transaction-service and anti-fraud-service , two Apache Kafka Topics (transactions, fraud-results) to communicate each other and Neo4J graph db to store transactions. + +## Stack +- Java 25 +- Maven 3.5.10 +- Zookeeper +- Apache Kafka +- Neo4J + +## Instructions to run the solution +You just run the following docker command line, it download and create images and then run docker instances for zookeeper, apache kafka, neo4j, transaction-service, anti-fraud-service. + +```sh +docker-compose up -d +``` + +## Functionality +### 1. Call transaction endpoint creation +To test the flow we can call the following endpoint in order to create the transaction, you can call multiple times with differen values minor or major than 1000, this endpoint register the transaction in the neo4j database and sent the evento to the transaction topic. + +```curl +curl --location 'http://localhost:8080/api/v1/transactions' \ +--header 'Content-Type: application/json' \ +--data '{ + "accountExternalIdDebit":"ACCT_DEBT_999_1", + "accountExternalIdCredit":"ACCT_CRED_999_1", + "transferTypeId":1, + "value": 1100 +}' +``` + + +### 2. Fraud evaluation +- The anti-fraud-service listen the events from the transaction topic. +- Evaluate the amount +- Send the new status (ACCEPTED, REJECTED) to the fraud-results topic +- The transaction-service listen the events and update the status. + +### 3. Verify creation and updating status +You have two ways to verify the transaction status: by endoint and by neo4j console +- By endpoint: you can use the following endpoint (replace the transactionId) +```curl +curl --location 'http://localhost:8080/api/v1/transactions/fb2e5b98-6414-421f-9eff-349806889d18' +``` +- By neo4j console: you can go the following and run the query. +```url +http://localhost:7474/browser/ +``` + +```sh +MATCH (n:Account) RETURN n LIMIT 25 +``` diff --git a/transaction-service/src/main/java/com/yape/transaction/graph/GraphService.java b/transaction-service/src/main/java/com/yape/transaction/graph/GraphService.java index 1b8a41b306..06138386a1 100644 --- a/transaction-service/src/main/java/com/yape/transaction/graph/GraphService.java +++ b/transaction-service/src/main/java/com/yape/transaction/graph/GraphService.java @@ -123,7 +123,7 @@ private FraudResultNode mapToFraudResultNode(org.neo4j.driver.Value node) { .transactionType(node.get("transactionType").asString()) .transactionStatus(node.get("transactionStatus").asString()) .value(node.get("value").asFloat()) - .createdAt(node.get("createdAt").asOffsetDateTime().toInstant()) + .createdAt(Instant.parse(node.get("createdAt").asString())) .build(); }