diff --git a/antifraude/.gitattributes b/antifraude/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/antifraude/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/antifraude/.gitignore b/antifraude/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/antifraude/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/antifraude/build.gradle b/antifraude/build.gradle new file mode 100644 index 0000000..e086cd2 --- /dev/null +++ b/antifraude/build.gradle @@ -0,0 +1,48 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.0' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.pe.yape' +version = '0.0.1-SNAPSHOT' +description = 'Demo project for Spring Boot' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} +ext { + mapstructVersion = "1.5.5.Final" +} +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webflux' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.kafka:spring-kafka' + implementation 'io.projectreactor.kafka:reactor-kafka:1.3.25' + implementation 'com.fasterxml.jackson.core:jackson-databind' // Para JSON + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation "org.mapstruct:mapstruct:${mapstructVersion}" + annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-webflux-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/antifraude/gradle/wrapper/gradle-wrapper.jar b/antifraude/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/antifraude/gradle/wrapper/gradle-wrapper.jar differ diff --git a/antifraude/gradle/wrapper/gradle-wrapper.properties b/antifraude/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/antifraude/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/antifraude/gradlew b/antifraude/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/antifraude/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +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 + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/antifraude/gradlew.bat b/antifraude/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/antifraude/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/antifraude/settings.gradle b/antifraude/settings.gradle new file mode 100644 index 0000000..239465c --- /dev/null +++ b/antifraude/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'antifraude' diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/AntifraudeApplication.java b/antifraude/src/main/java/com/pe/yape/antifraude/AntifraudeApplication.java new file mode 100644 index 0000000..fc4fad4 --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/AntifraudeApplication.java @@ -0,0 +1,15 @@ +package com.pe.yape.antifraude; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.kafka.annotation.KafkaListener; + +import static reactor.netty.http.HttpConnectionLiveness.log; + +@SpringBootApplication +public class AntifraudeApplication { + + public static void main(String[] args) { + SpringApplication.run(AntifraudeApplication.class, args); + } +} diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/application/service/AntiFraudService.java b/antifraude/src/main/java/com/pe/yape/antifraude/application/service/AntiFraudService.java new file mode 100644 index 0000000..0580059 --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/application/service/AntiFraudService.java @@ -0,0 +1,33 @@ +package com.pe.yape.antifraude.application.service; + +import com.pe.yape.antifraude.domain.model.FraudValidation; +import com.pe.yape.antifraude.domain.port.input.ValidateTransactionUseCase; +import com.pe.yape.antifraude.domain.port.output.ValidationResultPublisherPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AntiFraudService implements ValidateTransactionUseCase { + + + private final ValidationResultPublisherPort publisher; + + @Override + public Mono validate(UUID transactionId, BigDecimal value) { + log.info("Iniciando análisis de fraude para Transacción: {}", transactionId); + + FraudValidation analysis = FraudValidation.analyze(transactionId, value); + + + log.info("Análisis guardado. Resultado: {}", analysis.getResult()); + return publisher.sendResult(analysis.getTransactionId(), analysis.getResult()); + + } +} \ No newline at end of file diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/domain/model/FraudValidation.java b/antifraude/src/main/java/com/pe/yape/antifraude/domain/model/FraudValidation.java new file mode 100644 index 0000000..fc0b872 --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/domain/model/FraudValidation.java @@ -0,0 +1,43 @@ +package com.pe.yape.antifraude.domain.model; + +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +public class FraudValidation { + private UUID id; + private UUID transactionId; + private ValidationStatus result; + private String reason; + private LocalDateTime checkedAt; + + public static FraudValidation analyze(UUID transactionId, BigDecimal value) { + FraudValidation check = new FraudValidation(); + check.id = UUID.randomUUID(); + check.transactionId = transactionId; + check.checkedAt = LocalDateTime.now(); + + if (value.compareTo(BigDecimal.valueOf(1000)) > 0) { + check.result = ValidationStatus.REJECTED; + check.reason = "Value exceeds limit of 1000"; + } else { + check.result = ValidationStatus.APPROVED; + check.reason = "Value within limits"; + } + + return check; + } + + private FraudValidation() {} + + public FraudValidation(UUID id, UUID txId, ValidationStatus res, String reason, LocalDateTime date) { + this.id = id; + this.transactionId = txId; + this.result = res; + this.reason = reason; + this.checkedAt = date; + } +} diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/domain/model/ValidationStatus.java b/antifraude/src/main/java/com/pe/yape/antifraude/domain/model/ValidationStatus.java new file mode 100644 index 0000000..9b7c975 --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/domain/model/ValidationStatus.java @@ -0,0 +1,6 @@ +package com.pe.yape.antifraude.domain.model; + +public enum ValidationStatus { + APPROVED, + REJECTED +} diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/domain/port/input/ValidateTransactionUseCase.java b/antifraude/src/main/java/com/pe/yape/antifraude/domain/port/input/ValidateTransactionUseCase.java new file mode 100644 index 0000000..d7d6abb --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/domain/port/input/ValidateTransactionUseCase.java @@ -0,0 +1,10 @@ +package com.pe.yape.antifraude.domain.port.input; + +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.util.UUID; + +public interface ValidateTransactionUseCase { + Mono validate(UUID transactionId, BigDecimal value); +} \ No newline at end of file diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/domain/port/output/ValidationResultPublisherPort.java b/antifraude/src/main/java/com/pe/yape/antifraude/domain/port/output/ValidationResultPublisherPort.java new file mode 100644 index 0000000..36dd21e --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/domain/port/output/ValidationResultPublisherPort.java @@ -0,0 +1,10 @@ +package com.pe.yape.antifraude.domain.port.output; + +import com.pe.yape.antifraude.domain.model.ValidationStatus; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +public interface ValidationResultPublisherPort { + Mono sendResult(UUID transactionId, ValidationStatus status); +} diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/adapter/input/event/TransactionCreatedListener.java b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/adapter/input/event/TransactionCreatedListener.java new file mode 100644 index 0000000..f8ae801 --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/adapter/input/event/TransactionCreatedListener.java @@ -0,0 +1,37 @@ +package com.pe.yape.antifraude.infrastructure.adapter.input.event; + +import com.pe.yape.antifraude.domain.port.input.ValidateTransactionUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import org.springframework.kafka.annotation.KafkaListener; +import java.math.BigDecimal; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TransactionCreatedListener { + private final ValidateTransactionUseCase useCase; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = "transaction-created", groupId = "antifraud-service-group") + public void handleTransactionCreated(String message) { + try { + JsonNode json = objectMapper.readTree(message); + String txIdStr = json.get("transactionExternalId").asText(); + double valDouble = json.get("value").asDouble(); + + UUID txId = UUID.fromString(txIdStr); + BigDecimal value = BigDecimal.valueOf(valDouble); + + useCase.validate(txId, value) + .subscribe(); + + } catch (Exception e) { + log.error("Error procesando mensaje de transaccion", e); + } + } +} diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/adapter/output/event/KafkaResultPublisher.java b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/adapter/output/event/KafkaResultPublisher.java new file mode 100644 index 0000000..e192cda --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/adapter/output/event/KafkaResultPublisher.java @@ -0,0 +1,41 @@ +package com.pe.yape.antifraude.infrastructure.adapter.output.event; + +import com.pe.yape.antifraude.domain.model.ValidationStatus; +import com.pe.yape.antifraude.domain.port.output.ValidationResultPublisherPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderRecord; +import tools.jackson.databind.ObjectMapper; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KafkaResultPublisher implements ValidationResultPublisherPort { + + private final KafkaSender kafkaSender; + private final ObjectMapper objectMapper; + + @Override + public Mono sendResult(UUID transactionId, ValidationStatus status) { + return Mono.fromCallable(() -> { + Map payload = new HashMap<>(); + payload.put("id", transactionId.toString()); + payload.put("status", status.name()); + return objectMapper.writeValueAsString(payload); + }).flatMap(json -> { + SenderRecord record = SenderRecord.create( + new ProducerRecord<>("transaction-validation-result", transactionId.toString(), json), + 1 + ); + return kafkaSender.send(Mono.just(record)).next(); + }).then(); + } +} diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/JsonConfig.java b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/JsonConfig.java new file mode 100644 index 0000000..61518bc --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/JsonConfig.java @@ -0,0 +1,22 @@ +package com.pe.yape.antifraude.infrastructure.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class JsonConfig { + + @Bean + @Primary + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return mapper; + } +} diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/KafkaConfig.java b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/KafkaConfig.java new file mode 100644 index 0000000..6ae7a76 --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/KafkaConfig.java @@ -0,0 +1,50 @@ +package com.pe.yape.antifraude.infrastructure.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderOptions; + +import java.util.HashMap; +import java.util.Map; + +@EnableKafka +@Configuration +public class KafkaConfig { + + @Bean + public SenderOptions senderOptions(KafkaProperties kafkaProperties) { + Map props = kafkaProperties.buildProducerProperties(); + return SenderOptions.create(props); + } + + @Bean + public KafkaSender kafkaSender(SenderOptions senderOptions) { + return KafkaSender.create(senderOptions); + } + + @Bean + public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaProperties.getBootstrapServers()); + props.put(ConsumerConfig.GROUP_ID_CONFIG, "default-group"); + + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + return new DefaultKafkaConsumerFactory<>(props); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(ConsumerFactory consumerFactory) { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory); + return factory; + } +} \ No newline at end of file diff --git a/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/KafkaProperties.java b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/KafkaProperties.java new file mode 100644 index 0000000..6288b74 --- /dev/null +++ b/antifraude/src/main/java/com/pe/yape/antifraude/infrastructure/config/KafkaProperties.java @@ -0,0 +1,37 @@ +package com.pe.yape.antifraude.infrastructure.config; + +import lombok.Data; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +@Data +@Configuration +@ConfigurationProperties(prefix = "yape.kafka") +public class KafkaProperties { + + private String bootstrapServers; + private Producer producer; + + @Data + public static class Producer { + private String clientId; + private String acks; + private Integer retries; + } + + public Map buildProducerProperties() { + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.CLIENT_ID_CONFIG, producer.getClientId()); + props.put(ProducerConfig.ACKS_CONFIG, producer.getAcks()); + props.put(ProducerConfig.RETRIES_CONFIG, producer.getRetries()); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + return props; + } +} \ No newline at end of file diff --git a/antifraude/src/main/resources/application.yml b/antifraude/src/main/resources/application.yml new file mode 100644 index 0000000..525b02c --- /dev/null +++ b/antifraude/src/main/resources/application.yml @@ -0,0 +1,21 @@ +server: + port: 8081 # Puerto distinto al Transaction Service (8080) + +spring: + application: + name: antifraud + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: debug-group-fallback + auto-offset-reset: earliest + # ¡ESTO ES LO QUE FALTABA! Sin esto, el consumidor no sabe leer texto + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer +yape: + kafka: + bootstrap-servers: localhost:9092 + producer: + client-id: antifraud + acks: all + retries: 3 \ No newline at end of file diff --git a/antifraude/src/test/java/com/pe/yape/antifraude/AntifraudeApplicationTests.java b/antifraude/src/test/java/com/pe/yape/antifraude/AntifraudeApplicationTests.java new file mode 100644 index 0000000..dfb45b7 --- /dev/null +++ b/antifraude/src/test/java/com/pe/yape/antifraude/AntifraudeApplicationTests.java @@ -0,0 +1,13 @@ +package com.pe.yape.antifraude; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AntifraudeApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/antifraude/src/test/java/com/pe/yape/antifraude/application/service/AntiFraudServiceTest.java b/antifraude/src/test/java/com/pe/yape/antifraude/application/service/AntiFraudServiceTest.java new file mode 100644 index 0000000..2295640 --- /dev/null +++ b/antifraude/src/test/java/com/pe/yape/antifraude/application/service/AntiFraudServiceTest.java @@ -0,0 +1,71 @@ +package com.pe.yape.antifraude.application.service; + +import com.pe.yape.antifraude.domain.model.ValidationStatus; +import com.pe.yape.antifraude.domain.port.output.ValidationResultPublisherPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AntiFraudServiceTest { + + @Mock + private ValidationResultPublisherPort publisher; + + @InjectMocks + private AntiFraudService antiFraudService; + + private UUID transactionId; + + @BeforeEach + void setUp() { + transactionId = UUID.randomUUID(); + } + + @Test + void validate_WhenAmountWithinLimit_ShouldApprove() { + BigDecimal amount = new BigDecimal("999.99"); + when(publisher.sendResult(any(UUID.class), eq(ValidationStatus.APPROVED))) + .thenReturn(Mono.empty()); + + StepVerifier.create(antiFraudService.validate(transactionId, amount)) + .verifyComplete(); + + verify(publisher).sendResult(transactionId, ValidationStatus.APPROVED); + } + + @Test + void validate_WhenAmountExceedsLimit_ShouldReject() { + BigDecimal amount = new BigDecimal("1001.00"); + when(publisher.sendResult(any(UUID.class), eq(ValidationStatus.REJECTED))) + .thenReturn(Mono.empty()); + + StepVerifier.create(antiFraudService.validate(transactionId, amount)) + .verifyComplete(); + + verify(publisher).sendResult(transactionId, ValidationStatus.REJECTED); + } + + @Test + void validate_WhenPublisherFails_ShouldPropagateError() { + BigDecimal amount = new BigDecimal("500.00"); + when(publisher.sendResult(any(UUID.class), any())) + .thenReturn(Mono.error(new RuntimeException("Publishing failed"))); + + StepVerifier.create(antiFraudService.validate(transactionId, amount)) + .expectError(RuntimeException.class) + .verify(); + } +} diff --git a/antifraude/src/test/java/com/pe/yape/antifraude/domain/model/FraudValidationTest.java b/antifraude/src/test/java/com/pe/yape/antifraude/domain/model/FraudValidationTest.java new file mode 100644 index 0000000..8ccc1b0 --- /dev/null +++ b/antifraude/src/test/java/com/pe/yape/antifraude/domain/model/FraudValidationTest.java @@ -0,0 +1,70 @@ +package com.pe.yape.antifraude.domain.model; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class FraudValidationTest { + + private static Stream provideAmountsForValidation() { + return Stream.of( + Arguments.of(new BigDecimal("999.99"), ValidationStatus.APPROVED, "Value within limits"), + Arguments.of(new BigDecimal("1000.00"), ValidationStatus.APPROVED, "Value within limits"), + Arguments.of(new BigDecimal("1000.01"), ValidationStatus.REJECTED, "Value exceeds limit of 1000"), + Arguments.of(new BigDecimal("5000.00"), ValidationStatus.REJECTED, "Value exceeds limit of 1000") + ); + } + + @ParameterizedTest + @MethodSource("provideAmountsForValidation") + void analyze_ShouldReturnCorrectValidationResult(BigDecimal amount, ValidationStatus expectedStatus, String expectedReason) { + UUID transactionId = UUID.randomUUID(); + + FraudValidation result = FraudValidation.analyze(transactionId, amount); + + assertNotNull(result.getId()); + assertEquals(transactionId, result.getTransactionId()); + assertEquals(expectedStatus, result.getResult()); + assertEquals(expectedReason, result.getReason()); + assertNotNull(result.getCheckedAt()); + assertTrue(result.getCheckedAt().isBefore(LocalDateTime.now().plusSeconds(1))); + } + + @Test + void constructor_ShouldSetAllFields() { + UUID id = UUID.randomUUID(); + UUID transactionId = UUID.randomUUID(); + ValidationStatus status = ValidationStatus.APPROVED; + String reason = "Test reason"; + LocalDateTime now = LocalDateTime.now(); + + FraudValidation validation = new FraudValidation(id, transactionId, status, reason, now); + + assertEquals(id, validation.getId()); + assertEquals(transactionId, validation.getTransactionId()); + assertEquals(status, validation.getResult()); + assertEquals(reason, validation.getReason()); + assertEquals(now, validation.getCheckedAt()); + } + + @Test + void analyze_ShouldGenerateDifferentIdsForMultipleCalls() { + UUID transactionId = UUID.randomUUID(); + BigDecimal amount = new BigDecimal("100"); + + FraudValidation first = FraudValidation.analyze(transactionId, amount); + FraudValidation second = FraudValidation.analyze(transactionId, amount); + + assertNotEquals(first.getId(), second.getId()); + assertEquals(transactionId, first.getTransactionId()); + assertEquals(transactionId, second.getTransactionId()); + } +} diff --git a/antifraude/src/test/java/com/pe/yape/antifraude/infrastructure/adapter/output/event/KafkaResultPublisherTest.java b/antifraude/src/test/java/com/pe/yape/antifraude/infrastructure/adapter/output/event/KafkaResultPublisherTest.java new file mode 100644 index 0000000..0bbdf75 --- /dev/null +++ b/antifraude/src/test/java/com/pe/yape/antifraude/infrastructure/adapter/output/event/KafkaResultPublisherTest.java @@ -0,0 +1,103 @@ +package com.pe.yape.antifraude.infrastructure.adapter.output.event; + +import com.pe.yape.antifraude.domain.model.ValidationStatus; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderRecord; +import reactor.kafka.sender.SenderResult; +import org.reactivestreams.Publisher; +import reactor.test.StepVerifier; +import tools.jackson.databind.ObjectMapper; + +import java.util.Map; +import java.util.UUID; + + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +@ExtendWith(MockitoExtension.class) +class KafkaResultPublisherTest { + + @Mock + private KafkaSender kafkaSender; + + @Mock + private SenderResult senderResult; + + @InjectMocks + private KafkaResultPublisher kafkaResultPublisher; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private UUID transactionId; + private final String topic = "transaction-validation-result"; + + @BeforeEach + void setUp() { + transactionId = UUID.randomUUID(); + kafkaResultPublisher = new KafkaResultPublisher(kafkaSender, objectMapper); + + + } + @Test + void sendResult_WithApprovedStatus_ShouldSendCorrectMessage() { + when(kafkaSender.send(any(Publisher.class))).thenReturn(Flux.just(senderResult)); + + StepVerifier.create(kafkaResultPublisher.sendResult(transactionId, ValidationStatus.APPROVED)) + .verifyComplete(); + + verify(kafkaSender).send(any(Publisher.class)); + } + + @Test + void sendResult_WhenKafkaFails_ShouldPropagateError() { + when(kafkaSender.send(any(Publisher.class))).thenReturn(Flux.error(new RuntimeException("Kafka error"))); + + StepVerifier.create(kafkaResultPublisher.sendResult(transactionId, ValidationStatus.APPROVED)) + .expectError(RuntimeException.class) + .verify(); + } + + + @Test + void sendResult_WithRejectedStatus_ShouldSendCorrectMessage() { + when(kafkaSender.send(any(Publisher.class))).thenReturn(Flux.just(senderResult)); + + StepVerifier.create(kafkaResultPublisher.sendResult(transactionId, ValidationStatus.REJECTED)) + .verifyComplete(); + + verify(kafkaSender).send(any(Publisher.class)); + } + + + + @Test + void sendResult_WithNullTransactionId_ShouldFail() { + StepVerifier.create(kafkaResultPublisher.sendResult(null, ValidationStatus.APPROVED)) + .expectError(NullPointerException.class) + .verify(); + } + + @Test + void sendResult_WithNullStatus_ShouldFail() { + StepVerifier.create(kafkaResultPublisher.sendResult(transactionId, null)) + .expectError(NullPointerException.class) + .verify(); + } + + + +} diff --git a/codechallenge/.gitattributes b/codechallenge/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/codechallenge/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/codechallenge/.gitignore b/codechallenge/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/codechallenge/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/codechallenge/build.gradle b/codechallenge/build.gradle new file mode 100644 index 0000000..93ea6ac --- /dev/null +++ b/codechallenge/build.gradle @@ -0,0 +1,56 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.0' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.pe.yape' +version = '0.0.1-SNAPSHOT' +description = 'Demo project for Spring Boot' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +ext { + mapstructVersion = "1.5.5.Final" + lombokMapstructBindingVersion = "0.2.0" + testcontainersVersion = "1.19.3" +} +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webflux' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc' + runtimeOnly 'org.postgresql:r2dbc-postgresql' + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.springframework.kafka:spring-kafka' + implementation 'io.projectreactor.kafka:reactor-kafka:1.3.25' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation "org.mapstruct:mapstruct:${mapstructVersion}" + annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}" + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'org.springframework.kafka:spring-kafka-test' + + testImplementation "org.testcontainers:junit-jupiter:${testcontainersVersion}" + testImplementation "org.testcontainers:kafka:${testcontainersVersion}" + testImplementation "org.testcontainers:postgresql:${testcontainersVersion}" + testImplementation "org.testcontainers:r2dbc:${testcontainersVersion}" + annotationProcessor "org.projectlombok:lombok-mapstruct-binding:${lombokMapstructBindingVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-webflux-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/codechallenge/docker-compose.yml b/codechallenge/docker-compose.yml new file mode 100644 index 0000000..59f1276 --- /dev/null +++ b/codechallenge/docker-compose.yml @@ -0,0 +1,29 @@ +version: "3.7" +services: + postgres: + image: postgres:14 + ports: + - "5432:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=transactions_db # Agregué esto para que cree la DB automáticamente + + zookeeper: + image: confluentinc/cp-zookeeper:5.5.3 + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + + kafka: + image: confluentinc/cp-enterprise-kafka:5.5.3 + depends_on: [zookeeper] + environment: + KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" + # Esta configuración es CRUCIAL para que puedas conectarte desde fuera de Docker (tu Java local) + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_BROKER_ID: 1 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_JMX_PORT: 9991 + ports: + - "9092:9092" \ No newline at end of file diff --git a/codechallenge/gradle/wrapper/gradle-wrapper.jar b/codechallenge/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/codechallenge/gradle/wrapper/gradle-wrapper.jar differ diff --git a/codechallenge/gradle/wrapper/gradle-wrapper.properties b/codechallenge/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/codechallenge/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/codechallenge/gradlew b/codechallenge/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/codechallenge/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +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 + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/codechallenge/gradlew.bat b/codechallenge/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/codechallenge/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/codechallenge/settings.gradle b/codechallenge/settings.gradle new file mode 100644 index 0000000..56f6db8 --- /dev/null +++ b/codechallenge/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'codechallenge' diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/CodechallengeApplication.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/CodechallengeApplication.java new file mode 100644 index 0000000..baf5526 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/CodechallengeApplication.java @@ -0,0 +1,13 @@ +package com.pe.yape.codechallenge; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CodechallengeApplication { + + public static void main(String[] args) { + SpringApplication.run(CodechallengeApplication.class, args); + } + +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/application/dto/CreateTransactionCommand.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/application/dto/CreateTransactionCommand.java new file mode 100644 index 0000000..cf8e55e --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/application/dto/CreateTransactionCommand.java @@ -0,0 +1,27 @@ +package com.pe.yape.codechallenge.application.dto; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.math.BigDecimal; +import java.util.UUID; + +public record CreateTransactionCommand( + + @NotNull(message = "El ID de la cuenta de débito es obligatorio") + UUID accountExternalIdDebit, + + @NotNull(message = "El ID de la cuenta de crédito es obligatorio") + UUID accountExternalIdCredit, + + @NotNull(message = "El tipo de transferencia es obligatorio") + Integer transferTypeId, + + @NotNull(message = "El valor de la transacción es obligatorio") + @Positive(message = "El valor debe ser mayor a cero") + BigDecimal value +) { + public CreateTransactionCommand { + if (accountExternalIdDebit != null && accountExternalIdDebit.equals(accountExternalIdCredit)) { + throw new IllegalArgumentException("No se puede transferir a la misma cuenta"); + } + } +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/application/mapper/TransactionDtoMapper.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/application/mapper/TransactionDtoMapper.java new file mode 100644 index 0000000..297ad81 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/application/mapper/TransactionDtoMapper.java @@ -0,0 +1,18 @@ +package com.pe.yape.codechallenge.application.mapper; + +import com.pe.yape.codechallenge.application.dto.CreateTransactionCommand; +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto.CreateTransactionRequest; +import com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto.TransactionResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface TransactionDtoMapper { + @Mapping(target = "transferTypeId", source = "tranferTypeId") + CreateTransactionCommand toCommand(CreateTransactionRequest request); + + @Mapping(target = "transactionType.name", source = "type") + @Mapping(target = "transactionStatus.name", source = "status") + TransactionResponse toResponse(Transaction transaction); +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/application/service/TransactionService.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/application/service/TransactionService.java new file mode 100644 index 0000000..abb0d6d --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/application/service/TransactionService.java @@ -0,0 +1,63 @@ +package com.pe.yape.codechallenge.application.service; + +import com.pe.yape.codechallenge.application.dto.CreateTransactionCommand; +import com.pe.yape.codechallenge.domain.exception.TransactionNotFoundException; +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.domain.model.TransactionStatus; +import com.pe.yape.codechallenge.domain.port.input.CreateTransactionUseCase; +import com.pe.yape.codechallenge.domain.port.input.RetrieveTransactionUseCase; +import com.pe.yape.codechallenge.domain.port.input.UpdateTransactionStatusUseCase; +import com.pe.yape.codechallenge.domain.port.output.EventPublisherPort; +import com.pe.yape.codechallenge.domain.port.output.TransactionRepositoryPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TransactionService implements CreateTransactionUseCase, RetrieveTransactionUseCase, UpdateTransactionStatusUseCase { + private final TransactionRepositoryPort repositoryPort; + private final EventPublisherPort eventPublisherPort; + + @Override + public Mono create(CreateTransactionCommand command) { + if (command.accountExternalIdDebit().equals(command.accountExternalIdCredit())) { + throw new IllegalArgumentException("No se puede transferir a la misma cuenta"); + } + Transaction transaction = Transaction.create(command.value(), + command.transferTypeId(), command.accountExternalIdDebit().toString(), command.accountExternalIdCredit().toString()); + + return repositoryPort.save(transaction) + .flatMap(saved -> eventPublisherPort.publishTransactionCreated(saved) + .thenReturn(saved)); + } + + @Override + public Mono getTransaction(UUID transactionExternalId) { + return repositoryPort.findById(transactionExternalId) + + .switchIfEmpty(Mono.error(new TransactionNotFoundException(transactionExternalId))) + .doOnSuccess(t -> log.info("Transacción recuperada: {}", t.getTransactionExternalId())); + } + + @Override + public Mono updateStatus(String transactionId, TransactionStatus newStatus) { + return repositoryPort.findById(UUID.fromString(transactionId)) + .flatMap(transaction -> { + + transaction.updateStatus(newStatus); + log.info("Actualizando transacción {} a estado {}", transactionId, newStatus); + + return repositoryPort.save(transaction); + }) + .doOnSuccess(t -> log.info("Transacción {} actualizada exitosamente", t.getTransactionExternalId())) + .switchIfEmpty(Mono.fromRunnable(() -> + log.warn("Se intentó actualizar una transacción inexistente: {}", transactionId) + )) + .then(); + } +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/exception/TransactionNotFoundException.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/exception/TransactionNotFoundException.java new file mode 100644 index 0000000..b4ea843 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/exception/TransactionNotFoundException.java @@ -0,0 +1,9 @@ +package com.pe.yape.codechallenge.domain.exception; + +import java.util.UUID; + +public class TransactionNotFoundException extends RuntimeException { + public TransactionNotFoundException(UUID id) { + super("No se encontró la transacción con ID: " + id); + } +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/Transaction.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/Transaction.java new file mode 100644 index 0000000..171fc5a --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/Transaction.java @@ -0,0 +1,41 @@ +package com.pe.yape.codechallenge.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Transaction { + private String transactionExternalId; + private TransactionType type; + + private LocalDateTime createdAt; + private BigDecimal value; + private TransactionStatus status; + private String accountExternalDebit; + private String accountExternalCredit; + + public static Transaction create(BigDecimal value, Integer typeId, String accountExternalDebit, String accountExternalCredit) { + Transaction t = new Transaction(); + t.value = value; + t.accountExternalDebit = accountExternalDebit; + t.accountExternalCredit = accountExternalCredit; + t.type = TransactionType.fromId(typeId); + t.status = TransactionStatus.PENDING; + t.createdAt = LocalDateTime.now(); + + return t; + } + + public void updateStatus(TransactionStatus newStatus) { + this.status = newStatus; + } +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/TransactionStatus.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/TransactionStatus.java new file mode 100644 index 0000000..cdbaae3 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/TransactionStatus.java @@ -0,0 +1,8 @@ +package com.pe.yape.codechallenge.domain.model; + +public enum TransactionStatus { + PENDING, + APPROVED, + REJECTED; + +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/TransactionType.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/TransactionType.java new file mode 100644 index 0000000..b0fdbbb --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/model/TransactionType.java @@ -0,0 +1,22 @@ +package com.pe.yape.codechallenge.domain.model; + +import lombok.Getter; + +@Getter +public enum TransactionType { + DEBIT(0), + CREDIT(1); + + private final int id; + + TransactionType(int id) { + this.id = id; + } + + public static TransactionType fromId(Integer id) { + if (id == null || id < 0 || id >= values().length) { + throw new IllegalArgumentException("Invalid TransactionType id: " + id); + } + return values()[id]; + } +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/CreateTransactionUseCase.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/CreateTransactionUseCase.java new file mode 100644 index 0000000..c30e3da --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/CreateTransactionUseCase.java @@ -0,0 +1,9 @@ +package com.pe.yape.codechallenge.domain.port.input; + +import com.pe.yape.codechallenge.application.dto.CreateTransactionCommand; +import com.pe.yape.codechallenge.domain.model.Transaction; +import reactor.core.publisher.Mono; + +public interface CreateTransactionUseCase { + Mono create(CreateTransactionCommand command); +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/RetrieveTransactionUseCase.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/RetrieveTransactionUseCase.java new file mode 100644 index 0000000..94faed8 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/RetrieveTransactionUseCase.java @@ -0,0 +1,11 @@ +package com.pe.yape.codechallenge.domain.port.input; + +import com.pe.yape.codechallenge.domain.model.Transaction; +import reactor.core.publisher.Mono; + +import java.util.UUID; + + +public interface RetrieveTransactionUseCase { + Mono getTransaction(UUID transactionExternalId); +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/UpdateTransactionStatusUseCase.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/UpdateTransactionStatusUseCase.java new file mode 100644 index 0000000..6243382 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/input/UpdateTransactionStatusUseCase.java @@ -0,0 +1,8 @@ +package com.pe.yape.codechallenge.domain.port.input; + +import com.pe.yape.codechallenge.domain.model.TransactionStatus; +import reactor.core.publisher.Mono; + +public interface UpdateTransactionStatusUseCase { + Mono updateStatus(String transactionId, TransactionStatus newStatus); +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/output/EventPublisherPort.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/output/EventPublisherPort.java new file mode 100644 index 0000000..e3f7cca --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/output/EventPublisherPort.java @@ -0,0 +1,8 @@ +package com.pe.yape.codechallenge.domain.port.output; + +import com.pe.yape.codechallenge.domain.model.Transaction; +import reactor.core.publisher.Mono; + +public interface EventPublisherPort { + Mono publishTransactionCreated(Transaction transaction); +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/output/TransactionRepositoryPort.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/output/TransactionRepositoryPort.java new file mode 100644 index 0000000..b4b1680 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/domain/port/output/TransactionRepositoryPort.java @@ -0,0 +1,14 @@ +package com.pe.yape.codechallenge.domain.port.output; + +import com.pe.yape.codechallenge.domain.model.Transaction; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +import java.util.UUID; + + +@Repository +public interface TransactionRepositoryPort{ + Mono save(Transaction transaction); + Mono findById(UUID id); +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/KafkaTransactionListener.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/KafkaTransactionListener.java new file mode 100644 index 0000000..dd0b1af --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/KafkaTransactionListener.java @@ -0,0 +1,45 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pe.yape.codechallenge.domain.model.TransactionStatus; +import com.pe.yape.codechallenge.domain.port.input.UpdateTransactionStatusUseCase; +import com.pe.yape.codechallenge.infrastructure.adapter.input.event.dto.ValidationResultDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KafkaTransactionListener { + + private final UpdateTransactionStatusUseCase updateUseCase; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = "transaction-validation-result", groupId = "${yape.kafka.consumer.group-id:transaction-group-v2}") + public void handleValidationResult(String message) { + log.debug("Mensaje recibido de Kafka: {}", message); + + try { + ValidationResultDto result = objectMapper.readValue(message, ValidationResultDto.class); + + String uuidString = result.getId(); + TransactionStatus status = TransactionStatus.valueOf(result.getStatus()); + + + updateUseCase.updateStatus(uuidString, status) + .subscribe( + success -> log.debug("Estado actualizado correctamente para {}", uuidString), + error -> log.error("Error actualizando estado para {}", uuidString, error) + ); + + } catch (IllegalArgumentException e) { + log.error("Error de formato en ID o Status: {}", message, e); + } catch (Exception e) { + log.error("Error procesando mensaje de validación", e); + } + } +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/dto/ValidationResultDto.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/dto/ValidationResultDto.java new file mode 100644 index 0000000..e6d3525 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/dto/ValidationResultDto.java @@ -0,0 +1,9 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.event.dto; + +import lombok.Data; + +@Data +public class ValidationResultDto { + private String id; + private String status; +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/TransactionController.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/TransactionController.java new file mode 100644 index 0000000..0f76c8a --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/TransactionController.java @@ -0,0 +1,39 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.rest; + +import com.pe.yape.codechallenge.application.mapper.TransactionDtoMapper; +import com.pe.yape.codechallenge.domain.port.input.CreateTransactionUseCase; +import com.pe.yape.codechallenge.domain.port.input.RetrieveTransactionUseCase; +import com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto.CreateTransactionRequest; +import com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto.TransactionResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.UUID; + + +@RestController +@RequestMapping("/transactions") +@RequiredArgsConstructor +public class TransactionController { + + private final CreateTransactionUseCase createUseCase; + private final RetrieveTransactionUseCase retrieveUseCase; + private final TransactionDtoMapper mapper; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Mono create(@Valid @RequestBody CreateTransactionRequest request) { + return createUseCase.create(mapper.toCommand(request)) + .map(mapper::toResponse); + } + + + @GetMapping("/{id}") + public Mono getById(@PathVariable UUID id) { + return retrieveUseCase.getTransaction(id) + .map(mapper::toResponse); + } +} \ No newline at end of file diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/CreateTransactionRequest.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/CreateTransactionRequest.java new file mode 100644 index 0000000..6be8586 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/CreateTransactionRequest.java @@ -0,0 +1,18 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +import java.math.BigDecimal; + + +@Data +public class CreateTransactionRequest { + @NotNull + private String accountExternalIdDebit; + @NotNull private String accountExternalIdCredit; + @NotNull private Integer tranferTypeId; + @NotNull @Positive + private BigDecimal value; +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/StatusDto.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/StatusDto.java new file mode 100644 index 0000000..9137d82 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/StatusDto.java @@ -0,0 +1,10 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class StatusDto { + private String name; +} \ No newline at end of file diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/TransactionResponse.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/TransactionResponse.java new file mode 100644 index 0000000..d9aeb78 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/TransactionResponse.java @@ -0,0 +1,18 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto; + +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Builder +public class TransactionResponse { + private UUID transactionExternalId; + private TypeDto transactionType; + private StatusDto transactionStatus; + private BigDecimal value; + private LocalDateTime createdAt; +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/TypeDto.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/TypeDto.java new file mode 100644 index 0000000..7121945 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/dto/TypeDto.java @@ -0,0 +1,10 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class TypeDto { + private String name; +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/event/KafkaEventPublisher.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/event/KafkaEventPublisher.java new file mode 100644 index 0000000..453dc31 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/event/KafkaEventPublisher.java @@ -0,0 +1,40 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.output.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.domain.port.output.EventPublisherPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderRecord; + +@Component +@RequiredArgsConstructor +@Slf4j +public class KafkaEventPublisher implements EventPublisherPort { + + private final KafkaSender kafkaSender; + private final ObjectMapper objectMapper; + + @Override + public Mono publishTransactionCreated(Transaction transaction) { + String topic = "transaction-created"; + String key = transaction.getTransactionExternalId(); + + return Mono.fromCallable(() -> objectMapper.writeValueAsString(transaction)) + .flatMap(jsonPayload -> { + SenderRecord record = SenderRecord.create( + new ProducerRecord<>(topic, key, jsonPayload), + 1 + ); + + return kafkaSender.send(Mono.just(record)) + .next() + .doOnNext(result -> log.info("Evento enviado a Kafka: {}", key)) + .then(); + }); + } +} \ No newline at end of file diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/TransactionPersistenceAdapter.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/TransactionPersistenceAdapter.java new file mode 100644 index 0000000..7c6e958 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/TransactionPersistenceAdapter.java @@ -0,0 +1,33 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.output.persistence; + +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.domain.port.output.TransactionRepositoryPort; +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.entity.TransactionEntity; +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.mapper.TransactionPersistenceMapper; +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.repository.TransactionReactiveRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class TransactionPersistenceAdapter implements TransactionRepositoryPort { + + private final TransactionReactiveRepository repository; + private final TransactionPersistenceMapper mapper; + + @Override + public Mono save(Transaction transaction) { + TransactionEntity entity = mapper.toEntity(transaction); + return repository.save(entity) + .map(mapper::toDomain); + } + + @Override + public Mono findById(UUID id) { + return repository.findById(id) + .map(mapper::toDomain); + } +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/entity/TransactionEntity.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/entity/TransactionEntity.java new file mode 100644 index 0000000..b897a77 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/entity/TransactionEntity.java @@ -0,0 +1,28 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Table; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table("transactions") +public class TransactionEntity { + @Id + private UUID transactionExternalId; + private String accountExternalIdDebit; + private String accountExternalIdCredit; + private Integer transferTypeId; + private BigDecimal value; + private String status; + private LocalDateTime createdAt; +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/mapper/TransactionPersistenceMapper.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/mapper/TransactionPersistenceMapper.java new file mode 100644 index 0000000..afac1d8 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/mapper/TransactionPersistenceMapper.java @@ -0,0 +1,24 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.mapper; + +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.entity.TransactionEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface TransactionPersistenceMapper { + + @Mapping(target = "status", expression = "java(transaction.getStatus().name())") + @Mapping(target = "transferTypeId", expression = "java(transaction.getType().getId())") + @Mapping(target = "createdAt", expression = "java(transaction.getCreatedAt())") + @Mapping(target = "value", expression = "java(transaction.getValue())") + @Mapping(target = "accountExternalIdDebit", expression = "java(transaction.getAccountExternalDebit())") + @Mapping(target = "accountExternalIdCredit", expression = "java(transaction.getAccountExternalCredit())") + TransactionEntity toEntity(Transaction transaction); + + @Mapping(target = "status", expression = "java(com.pe.yape.codechallenge.domain.model.TransactionStatus.valueOf(entity.getStatus()))") + @Mapping(target = "type", expression = "java(com.pe.yape.codechallenge.domain.model.TransactionType.fromId(entity.getTransferTypeId()))") + @Mapping(target = "accountExternalDebit", expression = "java(entity.getAccountExternalIdDebit())") + @Mapping(target = "accountExternalCredit", expression = "java(entity.getAccountExternalIdCredit())") + Transaction toDomain(TransactionEntity entity); +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/repository/TransactionReactiveRepository.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/repository/TransactionReactiveRepository.java new file mode 100644 index 0000000..2118675 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/repository/TransactionReactiveRepository.java @@ -0,0 +1,12 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.repository; + +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.entity.TransactionEntity; +import org.springframework.stereotype.Repository; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +import java.util.UUID; + +@Repository +public interface TransactionReactiveRepository extends ReactiveCrudRepository { + +} diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/JsonConfig.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/JsonConfig.java new file mode 100644 index 0000000..87fe796 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/JsonConfig.java @@ -0,0 +1,21 @@ +package com.pe.yape.codechallenge.infrastructure.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class JsonConfig { + + @Bean + @Primary + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} \ No newline at end of file diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/KafkaConfig.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/KafkaConfig.java new file mode 100644 index 0000000..3aab6b5 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/KafkaConfig.java @@ -0,0 +1,50 @@ +package com.pe.yape.codechallenge.infrastructure.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderOptions; + +import java.util.HashMap; +import java.util.Map; + +@EnableKafka +@Configuration +public class KafkaConfig { + + @Bean + public SenderOptions senderOptions(KafkaProperties kafkaProperties) { + Map props = kafkaProperties.buildProducerProperties(); + return SenderOptions.create(props); + } + + @Bean + public KafkaSender kafkaSender(SenderOptions senderOptions) { + return KafkaSender.create(senderOptions); + } + + @Bean + public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaProperties.getBootstrapServers()); + props.put(ConsumerConfig.GROUP_ID_CONFIG, "default-group"); + + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + return new DefaultKafkaConsumerFactory<>(props); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(ConsumerFactory consumerFactory) { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory); + return factory; + } +} \ No newline at end of file diff --git a/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/KafkaProperties.java b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/KafkaProperties.java new file mode 100644 index 0000000..7612b30 --- /dev/null +++ b/codechallenge/src/main/java/com/pe/yape/codechallenge/infrastructure/config/KafkaProperties.java @@ -0,0 +1,43 @@ +package com.pe.yape.codechallenge.infrastructure.config; + +import lombok.Data; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +@Data +@Configuration +@ConfigurationProperties(prefix = "yape.kafka") +public class KafkaProperties { + + private String bootstrapServers; + private Producer producer; + + @Data + public static class Producer { + private String clientId; + private String acks; + private Integer retries; + } + + + public Map buildProducerProperties() { + Map props = new HashMap<>(); + + + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.CLIENT_ID_CONFIG, producer.getClientId()); + + props.put(ProducerConfig.ACKS_CONFIG, producer.getAcks()); + props.put(ProducerConfig.RETRIES_CONFIG, producer.getRetries()); + + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + + return props; + } +} diff --git a/codechallenge/src/main/resources/application.yml b/codechallenge/src/main/resources/application.yml new file mode 100644 index 0000000..44d2321 --- /dev/null +++ b/codechallenge/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + application: + name: transaction-service + r2dbc: + url: r2dbc:postgresql://localhost:5432/transactions_db + username: postgres + password: postgres + pool: + initial-size: 5 + max-size: 20 + sql: + init: + mode: always + schema-locations: classpath:schema.sql +yape: + kafka: + bootstrap-servers: localhost:9092 + producer: + client-id: transaction-service + acks: all + retries: 3 diff --git a/codechallenge/src/main/resources/schema.sql b/codechallenge/src/main/resources/schema.sql new file mode 100644 index 0000000..7b225ba --- /dev/null +++ b/codechallenge/src/main/resources/schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS transactions ( + transaction_external_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_external_id_debit VARCHAR(255), + account_external_id_credit VARCHAR(255), + transfer_type_id INTEGER, + value NUMERIC(19, 2), + status VARCHAR(50), + created_at TIMESTAMP +); \ No newline at end of file diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/CodechallengeApplicationTests.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/CodechallengeApplicationTests.java new file mode 100644 index 0000000..6beb9be --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/CodechallengeApplicationTests.java @@ -0,0 +1,13 @@ +package com.pe.yape.codechallenge; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CodechallengeApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/application/mapper/TransactionDtoMapperTest.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/application/mapper/TransactionDtoMapperTest.java new file mode 100644 index 0000000..51b6670 --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/application/mapper/TransactionDtoMapperTest.java @@ -0,0 +1,59 @@ +package com.pe.yape.codechallenge.application.mapper; + +import com.pe.yape.codechallenge.application.dto.CreateTransactionCommand; +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.domain.model.TransactionStatus; +import com.pe.yape.codechallenge.domain.model.TransactionType; +import com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto.CreateTransactionRequest; +import com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto.TransactionResponse; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class TransactionDtoMapperTest { + + private final TransactionDtoMapper mapper = Mappers.getMapper(TransactionDtoMapper.class); + + @Test + void toCommand_shouldMapRequestToCommand() { + CreateTransactionRequest request = new CreateTransactionRequest(); + request.setAccountExternalIdDebit(UUID.randomUUID().toString()); + request.setAccountExternalIdCredit(UUID.randomUUID().toString()); + request.setTranferTypeId(1); + request.setValue(BigDecimal.valueOf(1000)); + + CreateTransactionCommand command = mapper.toCommand(request); + + assertNotNull(command); + assertEquals(request.getAccountExternalIdDebit(), command.accountExternalIdDebit().toString()); + assertEquals(request.getAccountExternalIdCredit(), command.accountExternalIdCredit().toString()); + assertEquals(request.getTranferTypeId(), command.transferTypeId()); + assertEquals(request.getValue(), command.value()); + } + + @Test + void toResponse_shouldMapTransactionToResponse() { + Transaction transaction = Transaction.builder() + .transactionExternalId(UUID.randomUUID().toString()) + .type(TransactionType.CREDIT) + .status(TransactionStatus.APPROVED) + .value(BigDecimal.valueOf(1500)) + .createdAt(LocalDateTime.now()) + .build(); + + TransactionResponse response = mapper.toResponse(transaction); + + assertNotNull(response); + assertEquals(transaction.getTransactionExternalId(), response.getTransactionExternalId().toString()); + assertEquals(transaction.getType().name(), response.getTransactionType().getName()); + assertEquals(transaction.getStatus().name(), response.getTransactionStatus().getName()); + assertEquals(transaction.getValue(), response.getValue()); + assertEquals(transaction.getCreatedAt(), response.getCreatedAt()); + } +} diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/application/service/TransactionServiceTest.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/application/service/TransactionServiceTest.java new file mode 100644 index 0000000..8bf6a02 --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/application/service/TransactionServiceTest.java @@ -0,0 +1,75 @@ +package com.pe.yape.codechallenge.application.service; + +import com.pe.yape.codechallenge.domain.exception.TransactionNotFoundException; +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.domain.model.TransactionStatus; +import com.pe.yape.codechallenge.domain.port.output.EventPublisherPort; +import com.pe.yape.codechallenge.domain.port.output.TransactionRepositoryPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionServiceTest { + + @Mock + private TransactionRepositoryPort repositoryPort; + + @Mock + private EventPublisherPort eventPublisherPort; + + @InjectMocks + private TransactionService transactionService; + + private Transaction transaction; + + @BeforeEach + void setUp() { + transaction = Transaction.builder() + .transactionExternalId(UUID.randomUUID().toString()) + .value(BigDecimal.TEN) + .status(TransactionStatus.PENDING) + .build(); + } + + @Test + void getTransaction_shouldReturnTransaction_whenFound() { + when(repositoryPort.findById(any(UUID.class))).thenReturn(Mono.just(transaction)); + + StepVerifier.create(transactionService.getTransaction(UUID.fromString(transaction.getTransactionExternalId()))) + .expectNext(transaction) + .verifyComplete(); + } + + @Test + void getTransaction_shouldThrowException_whenNotFound() { + when(repositoryPort.findById(any(UUID.class))).thenReturn(Mono.empty()); + UUID transactionId = UUID.randomUUID(); + + StepVerifier.create(transactionService.getTransaction(transactionId)) + .expectError(TransactionNotFoundException.class) + .verify(); + } + + @Test + void updateStatus_shouldComplete_whenTransactionExists() { + when(repositoryPort.findById(any(UUID.class))).thenReturn(Mono.just(transaction)); + when(repositoryPort.save(any(Transaction.class))).thenReturn(Mono.just(transaction)); + + StepVerifier.create(transactionService.updateStatus(transaction.getTransactionExternalId(), TransactionStatus.APPROVED)) + .verifyComplete(); + } + + +} diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/domain/model/TransactionTest.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/domain/model/TransactionTest.java new file mode 100644 index 0000000..9723b5e --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/domain/model/TransactionTest.java @@ -0,0 +1,38 @@ +package com.pe.yape.codechallenge.domain.model; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.*; + +class TransactionTest { + + @Test + void create_shouldReturnTransactionWithPendingStatus() { + BigDecimal value = BigDecimal.valueOf(100); + Integer typeId = 1; + String accountExternalDebit = "debit-account"; + String accountExternalCredit = "credit-account"; + + Transaction transaction = Transaction.create(value, typeId, accountExternalDebit, accountExternalCredit); + + assertNotNull(transaction); + assertEquals(value, transaction.getValue()); + assertEquals(TransactionType.fromId(typeId), transaction.getType()); + assertEquals(TransactionStatus.PENDING, transaction.getStatus()); + assertEquals(accountExternalDebit, transaction.getAccountExternalDebit()); + assertEquals(accountExternalCredit, transaction.getAccountExternalCredit()); + assertNotNull(transaction.getCreatedAt()); + } + + @Test + void updateStatus_shouldChangeTransactionStatus() { + Transaction transaction = new Transaction(); + transaction.setStatus(TransactionStatus.PENDING); + + transaction.updateStatus(TransactionStatus.APPROVED); + + assertEquals(TransactionStatus.APPROVED, transaction.getStatus()); + } +} diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/KafkaTransactionListenerTest.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/KafkaTransactionListenerTest.java new file mode 100644 index 0000000..92f5a4c --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/input/event/KafkaTransactionListenerTest.java @@ -0,0 +1,72 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pe.yape.codechallenge.domain.model.TransactionStatus; +import com.pe.yape.codechallenge.domain.port.input.UpdateTransactionStatusUseCase; +import com.pe.yape.codechallenge.infrastructure.adapter.input.event.dto.ValidationResultDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class KafkaTransactionListenerTest { + + @Mock + private UpdateTransactionStatusUseCase updateUseCase; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private KafkaTransactionListener kafkaTransactionListener; + + @Test + void handleValidationResult_shouldUpdateStatus() throws Exception { + ValidationResultDto resultDto = new ValidationResultDto(); + resultDto.setId(UUID.randomUUID().toString()); + resultDto.setStatus("APPROVED"); + + String message = "{\"id\":\"" + resultDto.getId() + "\",\"status\":\"APPROVED\"}"; + + when(objectMapper.readValue(message, ValidationResultDto.class)).thenReturn(resultDto); + when(updateUseCase.updateStatus(anyString(), any(TransactionStatus.class))).thenReturn(Mono.empty()); + + kafkaTransactionListener.handleValidationResult(message); + + verify(updateUseCase, times(1)).updateStatus(resultDto.getId(), TransactionStatus.APPROVED); + } + + @Test + void handleValidationResult_shouldLogError_whenMessageIsInvalid() throws Exception { + String invalidMessage = "invalid json"; + + when(objectMapper.readValue(invalidMessage, ValidationResultDto.class)).thenThrow(new com.fasterxml.jackson.core.JsonParseException(null, "test")); + + kafkaTransactionListener.handleValidationResult(invalidMessage); + + verify(updateUseCase, never()).updateStatus(anyString(), any(TransactionStatus.class)); + } + + @Test + void handleValidationResult_shouldLogError_whenStatusIsInvalid() throws Exception { + ValidationResultDto resultDto = new ValidationResultDto(); + resultDto.setId(UUID.randomUUID().toString()); + resultDto.setStatus("INVALID_STATUS"); + + String message = "{\"id\":\"" + resultDto.getId() + "\",\"status\":\"INVALID_STATUS\"}"; + + when(objectMapper.readValue(message, ValidationResultDto.class)).thenReturn(resultDto); + + kafkaTransactionListener.handleValidationResult(message); + + verify(updateUseCase, never()).updateStatus(anyString(), any(TransactionStatus.class)); + } +} diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/TransactionControllerTest.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/TransactionControllerTest.java new file mode 100644 index 0000000..47fb153 --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/input/rest/TransactionControllerTest.java @@ -0,0 +1,68 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.input.rest; + +import com.pe.yape.codechallenge.application.dto.CreateTransactionCommand; +import com.pe.yape.codechallenge.application.mapper.TransactionDtoMapper; +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.domain.port.input.CreateTransactionUseCase; +import com.pe.yape.codechallenge.domain.port.input.RetrieveTransactionUseCase; +import com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto.CreateTransactionRequest; +import com.pe.yape.codechallenge.infrastructure.adapter.input.rest.dto.TransactionResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionControllerTest { + + @Mock + private CreateTransactionUseCase createUseCase; + + @Mock + private RetrieveTransactionUseCase retrieveUseCase; + + @Mock + private TransactionDtoMapper mapper; + + @InjectMocks + private TransactionController transactionController; + + @Test + void create_shouldReturnTransactionResponse() { + CreateTransactionRequest request = new CreateTransactionRequest(); + CreateTransactionCommand command = new CreateTransactionCommand(UUID.randomUUID(), UUID.randomUUID(), 1, BigDecimal.TEN); + Transaction transaction = new Transaction(); + TransactionResponse response = TransactionResponse.builder().build(); + + when(mapper.toCommand(request)).thenReturn(command); + when(createUseCase.create(command)).thenReturn(Mono.just(transaction)); + when(mapper.toResponse(transaction)).thenReturn(response); + + StepVerifier.create(transactionController.create(request)) + .expectNext(response) + .verifyComplete(); + } + + @Test + void getById_shouldReturnTransactionResponse() { + UUID transactionId = UUID.randomUUID(); + Transaction transaction = new Transaction(); + TransactionResponse response = TransactionResponse.builder().build(); + + when(retrieveUseCase.getTransaction(transactionId)).thenReturn(Mono.just(transaction)); + when(mapper.toResponse(transaction)).thenReturn(response); + + StepVerifier.create(transactionController.getById(transactionId)) + .expectNext(response) + .verifyComplete(); + } +} diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/event/KafkaEventPublisherTest.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/event/KafkaEventPublisherTest.java new file mode 100644 index 0000000..693cd92 --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/event/KafkaEventPublisherTest.java @@ -0,0 +1,64 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.output.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pe.yape.codechallenge.domain.model.Transaction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderResult; +import reactor.test.StepVerifier; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class KafkaEventPublisherTest { + + @Mock + private KafkaSender kafkaSender; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private KafkaEventPublisher kafkaEventPublisher; + + @Test + void publishTransactionCreated_shouldSendEvent() throws Exception { + Transaction transaction = new Transaction(); + transaction.setTransactionExternalId(UUID.randomUUID().toString()); + String jsonPayload = "{\"transactionExternalId\":\"" + transaction.getTransactionExternalId() + "\"}"; + + when(objectMapper.writeValueAsString(transaction)).thenReturn(jsonPayload); + + SenderResult senderResult = new MockSenderResult(); + when(kafkaSender.send(any(Mono.class))).thenReturn(Flux.just(senderResult)); + + StepVerifier.create(kafkaEventPublisher.publishTransactionCreated(transaction)) + .verifyComplete(); + } + + private static class MockSenderResult implements SenderResult { + @Override + public org.apache.kafka.clients.producer.RecordMetadata recordMetadata() { + return null; + } + + @Override + public Exception exception() { + return null; + } + + @Override + public Integer correlationMetadata() { + return 1; + } + } +} diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/TransactionPersistenceAdapterTest.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/TransactionPersistenceAdapterTest.java new file mode 100644 index 0000000..36770c0 --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/TransactionPersistenceAdapterTest.java @@ -0,0 +1,69 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.output.persistence; + +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.entity.TransactionEntity; +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.mapper.TransactionPersistenceMapper; +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.repository.TransactionReactiveRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionPersistenceAdapterTest { + + @Mock + private TransactionReactiveRepository repository; + + @Mock + private TransactionPersistenceMapper mapper; + + @InjectMocks + private TransactionPersistenceAdapter transactionPersistenceAdapter; + + @Test + void save_shouldReturnTransaction() { + Transaction transaction = new Transaction(); + TransactionEntity entity = new TransactionEntity(); + + when(mapper.toEntity(transaction)).thenReturn(entity); + when(repository.save(entity)).thenReturn(Mono.just(entity)); + when(mapper.toDomain(entity)).thenReturn(transaction); + + StepVerifier.create(transactionPersistenceAdapter.save(transaction)) + .expectNext(transaction) + .verifyComplete(); + } + + @Test + void findById_shouldReturnTransaction_whenFound() { + UUID transactionId = UUID.randomUUID(); + TransactionEntity entity = new TransactionEntity(); + Transaction transaction = new Transaction(); + + when(repository.findById(transactionId)).thenReturn(Mono.just(entity)); + when(mapper.toDomain(entity)).thenReturn(transaction); + + StepVerifier.create(transactionPersistenceAdapter.findById(transactionId)) + .expectNext(transaction) + .verifyComplete(); + } + + @Test + void findById_shouldReturnEmpty_whenNotFound() { + UUID transactionId = UUID.randomUUID(); + + when(repository.findById(transactionId)).thenReturn(Mono.empty()); + + StepVerifier.create(transactionPersistenceAdapter.findById(transactionId)) + .verifyComplete(); + } +} diff --git a/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/mapper/TransactionPersistenceMapperTest.java b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/mapper/TransactionPersistenceMapperTest.java new file mode 100644 index 0000000..1659983 --- /dev/null +++ b/codechallenge/src/test/java/com/pe/yape/codechallenge/infrastructure/adapter/output/persistence/mapper/TransactionPersistenceMapperTest.java @@ -0,0 +1,68 @@ +package com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.mapper; + +import com.pe.yape.codechallenge.domain.model.Transaction; +import com.pe.yape.codechallenge.domain.model.TransactionStatus; +import com.pe.yape.codechallenge.domain.model.TransactionType; +import com.pe.yape.codechallenge.infrastructure.adapter.output.persistence.entity.TransactionEntity; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class TransactionPersistenceMapperTest { + + private final TransactionPersistenceMapper mapper = Mappers.getMapper(TransactionPersistenceMapper.class); + + @Test + void toEntity_shouldMapTransactionToEntity() { + Transaction transaction = Transaction.builder() + .transactionExternalId(UUID.randomUUID().toString()) + .type(TransactionType.DEBIT) + .status(TransactionStatus.PENDING) + .value(BigDecimal.valueOf(200)) + .createdAt(LocalDateTime.now()) + .accountExternalDebit("debit-id") + .accountExternalCredit("credit-id") + .build(); + + TransactionEntity entity = mapper.toEntity(transaction); + + assertNotNull(entity); + assertEquals(transaction.getTransactionExternalId(), entity.getTransactionExternalId().toString()); + assertEquals(transaction.getType().getId(), entity.getTransferTypeId()); + assertEquals(transaction.getStatus().name(), entity.getStatus()); + assertEquals(transaction.getValue(), entity.getValue()); + assertEquals(transaction.getCreatedAt(), entity.getCreatedAt()); + assertEquals(transaction.getAccountExternalDebit(), entity.getAccountExternalIdDebit()); + assertEquals(transaction.getAccountExternalCredit(), entity.getAccountExternalIdCredit()); + } + + @Test + void toDomain_shouldMapEntityToTransaction() { + TransactionEntity entity = TransactionEntity.builder() + .transactionExternalId(UUID.randomUUID()) + .transferTypeId(0) + .status(TransactionStatus.REJECTED.name()) + .value(BigDecimal.valueOf(500)) + .createdAt(LocalDateTime.now()) + .accountExternalIdDebit("debit-id") + .accountExternalIdCredit("credit-id") + .build(); + + Transaction transaction = mapper.toDomain(entity); + + assertNotNull(transaction); + assertEquals(entity.getTransactionExternalId().toString(), transaction.getTransactionExternalId()); + assertEquals(TransactionType.fromId(entity.getTransferTypeId()), transaction.getType()); + assertEquals(entity.getStatus(), transaction.getStatus().name()); + assertEquals(entity.getValue(), transaction.getValue()); + assertEquals(entity.getCreatedAt(), transaction.getCreatedAt()); + assertEquals(entity.getAccountExternalIdDebit(), transaction.getAccountExternalDebit()); + assertEquals(entity.getAccountExternalIdCredit(), transaction.getAccountExternalCredit()); + } +}