diff --git a/README.md b/README.md
index b067a71026..d21a04c127 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,7 @@ You must have two resources:
}
```
-## Optional
+## Optional[app-transaction-report-service](app-transaction-report-service)
You can use any approach to store transaction data but you should consider that we may deal with high volume scenarios where we have a huge amount of writes and reads for the same data at the same time. How would you tackle this requirement?
@@ -80,3 +80,47 @@ You can use Graphql;
When you finish your challenge, after forking a repository, you **must** open a pull request to our repository. There are no limitations to the implementation, you can follow the programming paradigm, modularization, and style that you feel is the most appropriate solution.
If you have any questions, please let us know.
+
+
+
+
+# Resumen de Implementación
+
+## Endpoints Implementados
+
+### 1. **Gateway de Orquestación**
+
+* `POST /api/transactions` → Crea transacción (proxy hacia Transaction Service)
+* `GET /api/transactions/{id}` → Consulta transacción
+
+
+### 2. **Anti-Fraud Listener (Kafka)**
+
+* Consumidor del tópico: `transactions.validated`
+* Procesa validación antifraude y actualiza estado de transacción
+
+### 3. **Producer de Eventos**
+
+* Tópico: `transactions.created`
+* Publicación cuando se crea una transacción
+
+---
+
+## Tecnologías Implementadas
+
+| Tecnología | Uso |
+| -------------------------------- | ----------------------------------------------------- |
+| **Spring Boot 3.3.5** | Core del proyecto |
+| **Spring WebFlux** | API reactiva y backpressure |
+| **Spring Cloud Gateway 2023.x** | API Gateway reactivo |
+| **Resilience4J Circuit Breaker** | Protección ante fallos en downstream |
+| **Redis Rate Limiting** | Límite de requests por IP |
+| **Apache Kafka** | Broker de eventos |
+| **Reactor Kafka** | Publisher/Subscriber reactivo |
+| **Schema Registry (Confluent)** | Gestión y versionamiento de Avro schemas |
+| **Avro** | Serialización eficiente binaria para eventos |
+| **Docker Compose** | Orquestación de Kafka + Registry + Redis |
+| **RecordNameStrategy** | Estrategia recomendada para versionamiento de schemas |
+
+---
+
diff --git a/api-transaction-gateway/.gitattributes b/api-transaction-gateway/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/api-transaction-gateway/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/api-transaction-gateway/.gitignore b/api-transaction-gateway/.gitignore
new file mode 100644
index 0000000000..667aaef0c8
--- /dev/null
+++ b/api-transaction-gateway/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/api-transaction-gateway/pom.xml b/api-transaction-gateway/pom.xml
new file mode 100644
index 0000000000..c1fd3311b8
--- /dev/null
+++ b/api-transaction-gateway/pom.xml
@@ -0,0 +1,82 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ com.pe.yape.service.challenge
+ api-transaction-gateway
+ 0.0.1-SNAPSHOT
+ API Transaction Gateway Service Application
+
+
+
+ 17
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ 2023.0.3
+ pom
+ import
+
+
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-gateway
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-circuitbreaker-reactor-resilience4j
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis-reactive
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+ io.github.resilience4j
+ resilience4j-spring-boot3
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/ApiTransactionGateway.java b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/ApiTransactionGateway.java
new file mode 100644
index 0000000000..51724ec2f9
--- /dev/null
+++ b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/ApiTransactionGateway.java
@@ -0,0 +1,13 @@
+package com.pe.yape.service.challenge.transactions;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class ApiTransactionGateway {
+
+ public static void main(String[] args) {
+ SpringApplication.run(ApiTransactionGateway.class, args);
+ }
+
+}
diff --git a/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/config/WebClientConfig.java b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/config/WebClientConfig.java
new file mode 100644
index 0000000000..12fe30b640
--- /dev/null
+++ b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/config/WebClientConfig.java
@@ -0,0 +1,23 @@
+package com.pe.yape.service.challenge.transactions.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.netty.http.client.HttpClient;
+
+import java.time.Duration;
+
+@Configuration
+public class WebClientConfig {
+
+ @Bean
+ public WebClient.Builder webClientBuilder() {
+ return WebClient.builder()
+ .clientConnector(
+ new ReactorClientHttpConnector(
+ HttpClient.create()
+ .responseTimeout(Duration.ofSeconds(5))
+ ));
+ }
+}
diff --git a/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/controller/TransactionGatewayController.java b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/controller/TransactionGatewayController.java
new file mode 100644
index 0000000000..243cfb5448
--- /dev/null
+++ b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/controller/TransactionGatewayController.java
@@ -0,0 +1,63 @@
+package com.pe.yape.service.challenge.transactions.controller;
+
+import com.pe.yape.service.challenge.transactions.httpclient.dto.*;
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import io.github.resilience4j.retry.annotation.Retry;
+import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@RestController
+@RequestMapping("/api/transactions")
+public class TransactionGatewayController {
+
+ private final WebClient client;
+
+ public TransactionGatewayController(WebClient.Builder builder) {
+ this.client = builder.build();
+ }
+
+ @GetMapping("/{transactionExternalId}")
+ @CircuitBreaker(name = "transactionServices", fallbackMethod = "fallbackTransaction")
+ @Retry(name = "transactionRetry")
+ @TimeLimiter(name = "transactionTimeout")
+ public Mono getById(@PathVariable String transactionExternalId) {
+ return client.get()
+ .uri("http://localhost:8085/api/transactions-report/transactions/{id}", transactionExternalId)
+ .retrieve()
+ .bodyToMono(SearchTransactionResponse.class);
+ }
+
+ @PostMapping("/")
+ @CircuitBreaker(name = "transactionServices", fallbackMethod = "fallbackCreateTransaction")
+ @Retry(name = "transactionRetry")
+ @TimeLimiter(name = "transactionTimeout")
+ public Mono create(@RequestBody CreateTransactionRequest request) {
+
+ return client.post()
+ .uri("http://localhost:8080/api/transactions/")
+ .bodyValue(request)
+ .retrieve()
+ .bodyToMono(CreateTransactionResponse.class);
+ }
+
+
+ public Mono fallbackTransaction(String id, Throwable ex) {
+ return Mono.just(new SearchTransactionResponse(
+ id,
+ new TransactionType("UNKNOWN"),
+ new TransactionStatus("FAILED"),
+ new BigDecimal("0"),
+ LocalDateTime.now()
+ ));
+ }
+
+ public Mono fallbackCreateTransaction(CreateTransactionRequest request,
+ Throwable ex) {
+ return Mono.just(new CreateTransactionResponse("none"));
+ }
+}
diff --git a/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/CreateTransactionRequest.java b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/CreateTransactionRequest.java
new file mode 100644
index 0000000000..ef66534703
--- /dev/null
+++ b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/CreateTransactionRequest.java
@@ -0,0 +1,7 @@
+package com.pe.yape.service.challenge.transactions.httpclient.dto;
+
+public record CreateTransactionRequest(String accountExternalIdDebit,
+ String accountExternalIdCredit,
+ Integer transferTypeId,
+ Double value) {
+}
diff --git a/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/CreateTransactionResponse.java b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/CreateTransactionResponse.java
new file mode 100644
index 0000000000..131c171a63
--- /dev/null
+++ b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/CreateTransactionResponse.java
@@ -0,0 +1,4 @@
+package com.pe.yape.service.challenge.transactions.httpclient.dto;
+
+public record CreateTransactionResponse(String transactionExternalId) {
+}
diff --git a/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/SearchTransactionResponse.java b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/SearchTransactionResponse.java
new file mode 100644
index 0000000000..5f175204b4
--- /dev/null
+++ b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/SearchTransactionResponse.java
@@ -0,0 +1,9 @@
+package com.pe.yape.service.challenge.transactions.httpclient.dto;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+public record SearchTransactionResponse(String transactionExternalId, TransactionType transactionType,
+ TransactionStatus transactionStatus, BigDecimal value,
+ LocalDateTime createdAt) {
+}
diff --git a/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/TransactionStatus.java b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/TransactionStatus.java
new file mode 100644
index 0000000000..804ed8dfcf
--- /dev/null
+++ b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/TransactionStatus.java
@@ -0,0 +1,4 @@
+package com.pe.yape.service.challenge.transactions.httpclient.dto;
+
+public record TransactionStatus(String name) {
+}
\ No newline at end of file
diff --git a/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/TransactionType.java b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/TransactionType.java
new file mode 100644
index 0000000000..598fb5135f
--- /dev/null
+++ b/api-transaction-gateway/src/main/java/com/pe/yape/service/challenge/transactions/httpclient/dto/TransactionType.java
@@ -0,0 +1,5 @@
+package com.pe.yape.service.challenge.transactions.httpclient.dto;
+
+public record TransactionType(String name) {
+}
+
diff --git a/api-transaction-gateway/src/main/resources/application.yml b/api-transaction-gateway/src/main/resources/application.yml
new file mode 100644
index 0000000000..30e7aa463a
--- /dev/null
+++ b/api-transaction-gateway/src/main/resources/application.yml
@@ -0,0 +1,32 @@
+server:
+ port: 9000
+spring:
+ redis:
+ host: localhost
+ port: 6379
+
+resilience4j:
+ circuitbreaker:
+ instances:
+ transactionServices:
+ sliding-window-size: 6
+ permitted-number-of-calls-in-half-open-state: 2
+ failure-rate-threshold: 50
+ wait-duration-in-open-state: 10s
+
+ retry:
+ instances:
+ transactionRetry:
+ max-attempts: 3
+ wait-duration: 2s
+
+ timelimiter:
+ instances:
+ transactionTimeout:
+ timeout-duration: 4s
+
+
+logging:
+ level:
+ root: INFO
+ org.springframework.web: DEBUG
diff --git a/app-anti-fraud-service/.gitattributes b/app-anti-fraud-service/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/app-anti-fraud-service/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/app-anti-fraud-service/.gitignore b/app-anti-fraud-service/.gitignore
new file mode 100644
index 0000000000..667aaef0c8
--- /dev/null
+++ b/app-anti-fraud-service/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/app-anti-fraud-service/pom.xml b/app-anti-fraud-service/pom.xml
new file mode 100644
index 0000000000..ad681a48e2
--- /dev/null
+++ b/app-anti-fraud-service/pom.xml
@@ -0,0 +1,134 @@
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ com.pe.yape.service.challenge
+ app-anti-fraud-service
+ 0.0.1-SNAPSHOT
+ jar
+
+
+ 17
+ 3.3.5
+ 1.11.4
+ 1.3.22
+
+
+
+
+ confluent
+ https://packages.confluent.io/maven/
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+ org.springframework.boot
+ spring-boot-starter-logging
+
+
+
+
+
+
+ io.projectreactor.kafka
+ reactor-kafka
+ 1.3.22
+
+
+
+
+ org.apache.avro
+ avro
+ 1.11.4
+
+
+
+ io.confluent
+ kafka-avro-serializer
+ 7.5.0
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-log4j2
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.junit.vintage
+ junit-vintage-engine
+
+
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.38
+ provided
+
+
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+ org.apache.avro
+ avro-maven-plugin
+ ${avro.version}
+
+
+ generate-sources
+
+ schema
+
+
+ ${project.basedir}/src/main/avro
+ ${project.build.directory}/generated-sources/avro
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 9
+ 9
+
+
+
+
+
diff --git a/app-anti-fraud-service/src/main/avro/TransactionCreatedEvent.avsc b/app-anti-fraud-service/src/main/avro/TransactionCreatedEvent.avsc
new file mode 100644
index 0000000000..76615a0f7c
--- /dev/null
+++ b/app-anti-fraud-service/src/main/avro/TransactionCreatedEvent.avsc
@@ -0,0 +1,12 @@
+{
+ "type": "record",
+ "name": "TransactionCreatedEvent",
+ "namespace": "com.pe.yape.service.challenge.transactions.avro",
+ "fields": [
+ {"name": "transactionExternalId", "type": "string"},
+ {"name": "accountExternalIdDebit", "type": "string"},
+ {"name": "accountExternalIdCredit", "type": "string"},
+ {"name": "transferTypeId", "type": "int"},
+ {"name": "value", "type": "double"}
+ ]
+}
diff --git a/app-anti-fraud-service/src/main/avro/TransactionValidatedEvent.avsc b/app-anti-fraud-service/src/main/avro/TransactionValidatedEvent.avsc
new file mode 100644
index 0000000000..36cde2e1e6
--- /dev/null
+++ b/app-anti-fraud-service/src/main/avro/TransactionValidatedEvent.avsc
@@ -0,0 +1,11 @@
+{
+ "type": "record",
+ "name": "TransactionValidatedEvent",
+ "namespace": "com.pe.yape.service.challenge.transactions.avro",
+ "fields": [
+ {"name": "transactionExternalId", "type": "string"},
+ {"name": "status", "type": "string"},
+ {"name": "reasonCode", "type": ["null","string"], "default": null},
+ {"name": "validatedAt", "type": "string"}
+ ]
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/AntiFraudServiceApplication.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/AntiFraudServiceApplication.java
new file mode 100644
index 0000000000..6b9f39184f
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/AntiFraudServiceApplication.java
@@ -0,0 +1,13 @@
+package com.pe.yape.service.challenge.antifraud;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class AntiFraudServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AntiFraudServiceApplication.class, args);
+ }
+
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/config/KafkaConfig.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/config/KafkaConfig.java
new file mode 100644
index 0000000000..c3f6fecbe2
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/config/KafkaConfig.java
@@ -0,0 +1,64 @@
+package com.pe.yape.service.challenge.antifraud.config;
+
+import com.pe.yape.service.challenge.transactions.avro.TransactionCreatedEvent;
+import com.pe.yape.service.challenge.transactions.avro.TransactionValidatedEvent;
+import io.confluent.kafka.serializers.KafkaAvroDeserializer;
+import io.confluent.kafka.serializers.KafkaAvroSerializer;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import reactor.kafka.receiver.ReceiverOptions;
+import reactor.kafka.sender.SenderOptions;
+
+import java.util.Map;
+
+@Configuration
+public class KafkaConfig {
+
+ public static final String TOPIC_TRANSACTIONS_CREATED = "transactions.created";
+ public static final String TOPIC_TRANSACTIONS_VALIDATED = "transactions.validated";
+
+ @Bean
+ public NewTopic transactionsCreatedTopic() {
+ return new NewTopic(TOPIC_TRANSACTIONS_CREATED, 3, (short) 1);
+ }
+
+ @Bean
+ public NewTopic transactionsValidatedTopic() {
+ return new NewTopic(TOPIC_TRANSACTIONS_VALIDATED, 3, (short) 1);
+ }
+
+ @Bean
+ public ReceiverOptions receiverOptions() {
+ Map props = Map.of(
+ ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092",
+ ConsumerConfig.GROUP_ID_CONFIG, "anti-fraud-service-group",
+ ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest",
+ "schema.registry.url", "http://localhost:8081",
+ ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class,
+ ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, KafkaAvroDeserializer.class,
+ "specific.avro.reader",true
+ );
+
+ return ReceiverOptions.create(props)
+ .subscription(java.util.List.of(TOPIC_TRANSACTIONS_CREATED));
+ }
+
+ @Bean
+ public SenderOptions senderOptions(){
+ Map props = Map.of(
+ ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092",
+ ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class,
+ ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class,
+ "schema.registry.url", "http://localhost:8081"
+ );
+
+ return SenderOptions.create(props);
+ }
+
+
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/config/properties/AntifraudProperties.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/config/properties/AntifraudProperties.java
new file mode 100644
index 0000000000..64ba6e17e5
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/config/properties/AntifraudProperties.java
@@ -0,0 +1,18 @@
+package com.pe.yape.service.challenge.antifraud.config.properties;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "application.rules")
+
+@Getter
+@Setter
+public class AntifraudProperties {
+
+ private String amountLimit;
+
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/domain/TransactionStatus.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/domain/TransactionStatus.java
new file mode 100644
index 0000000000..d196f62ac9
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/domain/TransactionStatus.java
@@ -0,0 +1,8 @@
+package com.pe.yape.service.challenge.antifraud.domain;
+
+public enum TransactionStatus {
+
+ APPROVED,
+
+ REJECTED
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/domain/ValidationResult.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/domain/ValidationResult.java
new file mode 100644
index 0000000000..f4123ee958
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/domain/ValidationResult.java
@@ -0,0 +1,16 @@
+package com.pe.yape.service.challenge.antifraud.domain;
+
+import lombok.*;
+
+@Getter
+@Setter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class ValidationResult {
+
+ private TransactionStatus transactionStatus;
+
+ private String reasonCode;
+
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/messaging/TransactionCreatedConsumer.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/messaging/TransactionCreatedConsumer.java
new file mode 100644
index 0000000000..baeef2f6d9
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/messaging/TransactionCreatedConsumer.java
@@ -0,0 +1,68 @@
+package com.pe.yape.service.challenge.antifraud.messaging;
+
+
+import com.pe.yape.service.challenge.transactions.avro.TransactionCreatedEvent;
+import com.pe.yape.service.challenge.transactions.avro.TransactionValidatedEvent;
+import com.pe.yape.service.challenge.antifraud.service.AntifraudService;
+
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import reactor.kafka.receiver.KafkaReceiver;
+import reactor.kafka.receiver.ReceiverOptions;
+import reactor.core.publisher.Flux;
+import org.springframework.stereotype.Component;
+import reactor.kafka.receiver.ReceiverRecord;
+
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+
+@Component
+@Slf4j
+public class TransactionCreatedConsumer {
+
+ private final TransactionValidatedProducer producer;
+ private final AntifraudService antifraudService;
+ private final ReceiverOptions receiverOptions;
+
+ public TransactionCreatedConsumer(TransactionValidatedProducer producer,
+ AntifraudService antifraudService,
+ ReceiverOptions receiverOptions) {
+ this.producer = producer;
+ this.antifraudService = antifraudService;
+ this.receiverOptions = receiverOptions;
+ }
+
+ @PostConstruct
+ public void startConsumer() {
+ log.info("Starting Kafka TransactionCreatedEvent consumer...");
+
+ Flux> kafkaFlux =
+ KafkaReceiver.create(receiverOptions).receive();
+
+ kafkaFlux.flatMap(record -> {
+ TransactionCreatedEvent transactionCreatedEvent = record.value();
+ log.debug("Received message for transaction: {}", transactionCreatedEvent.getTransactionExternalId());
+
+ return antifraudService.validate(transactionCreatedEvent)
+ .map(validationResult ->
+ TransactionValidatedEvent.newBuilder()
+ .setTransactionExternalId(transactionCreatedEvent.getTransactionExternalId())
+ .setStatus(validationResult.getTransactionStatus().toString())
+ .setReasonCode(validationResult.getReasonCode())
+ .setValidatedAt(OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) // Use ISO_OFFSET_DATE_TIME
+ .build()
+ ).flatMap(transactionValidatedEvent ->
+ producer.publish(transactionValidatedEvent, transactionCreatedEvent.getTransactionExternalId().toString())
+ )
+ .doOnNext(s -> {
+ record.receiverOffset().acknowledge();
+ log.info("Acknowledged transaction ID: {}", transactionCreatedEvent.getTransactionExternalId());
+ });
+ })
+ .subscribe(
+ result -> log.debug("Processing loop successful for a record."),
+ error -> log.error("Error during Kafka consumption process: {}", error.getMessage(), error),
+ () -> log.warn("Kafka consumer flux completed unexpectedly.")
+ );
+ }
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/messaging/TransactionValidatedProducer.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/messaging/TransactionValidatedProducer.java
new file mode 100644
index 0000000000..1f8056a230
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/messaging/TransactionValidatedProducer.java
@@ -0,0 +1,34 @@
+package com.pe.yape.service.challenge.antifraud.messaging;
+
+
+import com.pe.yape.service.challenge.transactions.avro.TransactionValidatedEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+import reactor.kafka.sender.KafkaSender;
+import reactor.kafka.sender.SenderOptions;
+import reactor.kafka.sender.SenderRecord;
+import reactor.kafka.sender.SenderResult;
+
+import static com.pe.yape.service.challenge.antifraud.config.KafkaConfig.TOPIC_TRANSACTIONS_VALIDATED;
+
+@Component
+@Slf4j
+public class TransactionValidatedProducer {
+
+ private final KafkaSender sender;
+
+ public TransactionValidatedProducer(SenderOptions senderOptions) {
+ this.sender = KafkaSender.create(senderOptions);
+ }
+
+ public Mono publish(TransactionValidatedEvent event, String key) {
+ SenderRecord record =
+ SenderRecord.create(TOPIC_TRANSACTIONS_VALIDATED, null, null, key, event, key);
+
+ return sender.send(Mono.just(record))
+ .doOnNext(result -> log.info("Send message {} to {}" , key , event) )
+ .map(SenderResult::correlationMetadata)
+ .ignoreElements();
+ }
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/service/AntifraudService.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/service/AntifraudService.java
new file mode 100644
index 0000000000..335ce70edc
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/service/AntifraudService.java
@@ -0,0 +1,40 @@
+package com.pe.yape.service.challenge.antifraud.service;
+
+import com.pe.yape.service.challenge.transactions.avro.TransactionCreatedEvent;
+import com.pe.yape.service.challenge.antifraud.config.properties.AntifraudProperties;
+import com.pe.yape.service.challenge.antifraud.domain.TransactionStatus;
+import com.pe.yape.service.challenge.antifraud.domain.ValidationResult;
+import com.pe.yape.service.challenge.antifraud.util.ReasonCodes;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+@Service
+@RequiredArgsConstructor
+public class AntifraudService {
+
+ private final AntifraudProperties antifraudProperties;
+
+ public Mono validate(TransactionCreatedEvent event) {
+ return Mono.fromCallable(() -> {
+
+ ValidationResult.ValidationResultBuilder validationResultBuilder = ValidationResult.builder();
+
+ if (Double.compare(event.getValue(), 0.0) == 0) {
+ return validationResultBuilder.transactionStatus(TransactionStatus.REJECTED)
+ .reasonCode(ReasonCodes.MISSING_VALUE)
+ .build();
+ }
+ if (event.getValue() > Integer.parseInt(antifraudProperties.getAmountLimit())) {
+ return validationResultBuilder.transactionStatus(TransactionStatus.REJECTED)
+ .reasonCode(ReasonCodes.VALUE_EXCEEDS_LIMIT)
+ .build();
+ }
+ return validationResultBuilder.transactionStatus(TransactionStatus.APPROVED)
+ .build();
+ });
+
+ }
+
+
+}
diff --git a/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/util/ReasonCodes.java b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/util/ReasonCodes.java
new file mode 100644
index 0000000000..7bd5062207
--- /dev/null
+++ b/app-anti-fraud-service/src/main/java/com/pe/yape/service/challenge/antifraud/util/ReasonCodes.java
@@ -0,0 +1,8 @@
+package com.pe.yape.service.challenge.antifraud.util;
+
+public final class ReasonCodes {
+ public static final String VALUE_EXCEEDS_LIMIT = "VALUE_EXCEEDS_LIMIT";
+ public static final String MISSING_VALUE = "MISSING_VALUE";
+
+ private ReasonCodes() {}
+}
diff --git a/app-anti-fraud-service/src/main/resources/application.yml b/app-anti-fraud-service/src/main/resources/application.yml
new file mode 100644
index 0000000000..92a47e2095
--- /dev/null
+++ b/app-anti-fraud-service/src/main/resources/application.yml
@@ -0,0 +1,29 @@
+---
+server:
+ port: 8082
+spring:
+ kafka:
+ bootstrap-servers: localhost:9092
+ topics:
+ transactions-created: transactions.created
+ transactions-validated: transactions.validated
+ schema-registry-url: http://localhost:8081
+application:
+ rules:
+ amount-limit: "1000"
+logging:
+ level:
+ root: INFO
+ org.springframework.web: DEBUG
+ org.springframework.r2dbc: DEBUG
+ org.springframework.kafka: INFO
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics
+ health:
+ db:
+ enabled: true
+ kafka:
+ enabled: true
diff --git a/app-transaction-report-service/.gitattributes b/app-transaction-report-service/.gitattributes
new file mode 100644
index 0000000000..3b41682ac5
--- /dev/null
+++ b/app-transaction-report-service/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/app-transaction-report-service/.gitignore b/app-transaction-report-service/.gitignore
new file mode 100644
index 0000000000..667aaef0c8
--- /dev/null
+++ b/app-transaction-report-service/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/app-transaction-report-service/pom.xml b/app-transaction-report-service/pom.xml
new file mode 100644
index 0000000000..d1dfe2dcf8
--- /dev/null
+++ b/app-transaction-report-service/pom.xml
@@ -0,0 +1,122 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ com.pe.yape.service.challenge
+ app-transaction-report-service
+ 0.0.1-SNAPSHOT
+ Transaction Report service application
+
+
+
+ 17
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-r2dbc
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+ org.postgresql
+ postgresql
+ runtime
+ 42.7.8
+
+
+
+ org.postgresql
+ r2dbc-postgresql
+ runtime
+ 1.0.9.RELEASE
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+
+ jakarta.validation
+ jakarta.validation-api
+ 3.0.2
+
+
+
+ org.hibernate.validator
+ hibernate-validator
+ 8.0.1.Final
+
+
+
+ org.mapstruct
+ mapstruct
+ 1.5.5.Final
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+
+
+ org.mapstruct
+ mapstruct-processor
+ 1.5.5.Final
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/AppTransactionReportServiceApplication.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/AppTransactionReportServiceApplication.java
new file mode 100644
index 0000000000..717feceaa3
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/AppTransactionReportServiceApplication.java
@@ -0,0 +1,13 @@
+package com.pe.yape.service.challenge.transactions;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class AppTransactionReportServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AppTransactionReportServiceApplication.class, args);
+ }
+
+}
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/config/AppConfig.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/config/AppConfig.java
new file mode 100644
index 0000000000..b5d118d912
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/config/AppConfig.java
@@ -0,0 +1,20 @@
+package com.pe.yape.service.challenge.transactions.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+
+import java.nio.charset.StandardCharsets;
+
+@Configuration
+public class AppConfig {
+
+ @Bean
+ public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
+ MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter();
+ jsonConverter.setDefaultCharset(StandardCharsets.UTF_8);
+ return jsonConverter;
+ }
+
+
+}
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/controller/TransactionReportController.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/controller/TransactionReportController.java
new file mode 100644
index 0000000000..d910e7b53c
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/controller/TransactionReportController.java
@@ -0,0 +1,28 @@
+package com.pe.yape.service.challenge.transactions.controller;
+
+import com.pe.yape.service.challenge.transactions.dto.TransactionResponse;
+import com.pe.yape.service.challenge.transactions.service.SearchTransactionService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Mono;
+
+@CrossOrigin(allowedHeaders = "*")
+@RestController
+@RequestMapping("/transactions")
+@Slf4j
+public class TransactionReportController {
+
+ private final SearchTransactionService searchTransactionService;
+
+ public TransactionReportController(SearchTransactionService searchTransactionService) {
+ this.searchTransactionService = searchTransactionService;
+ }
+
+ @GetMapping(value = "/{transactionId}", produces = MediaType.APPLICATION_JSON_VALUE)
+ public Mono> retrieve(@PathVariable String transactionId) {
+ return searchTransactionService.search(transactionId)
+ .map(ResponseEntity::ok);
+ }
+}
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/domain/model/Transaction.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/domain/model/Transaction.java
new file mode 100644
index 0000000000..466a186b08
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/domain/model/Transaction.java
@@ -0,0 +1,29 @@
+package com.pe.yape.service.challenge.transactions.domain.model;
+
+import lombok.*;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.relational.core.mapping.Table;
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+
+@Table(value = "transaction" , schema = "anti_fraud")
+@Getter
+@Setter
+@Builder
+public class Transaction {
+
+ @Id
+ private UUID transactionExternalId;
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private Integer transferTypeId;
+ private Double value;
+ private String status;
+ private OffsetDateTime createdAt;
+ private OffsetDateTime updatedAt;
+
+
+
+
+}
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionResponse.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionResponse.java
new file mode 100644
index 0000000000..e6b2587b00
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionResponse.java
@@ -0,0 +1,15 @@
+package com.pe.yape.service.challenge.transactions.dto;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+public record TransactionResponse(
+ String transactionExternalId,
+ TransactionType transactionType,
+ TransactionStatus transactionStatus,
+ BigDecimal value,
+ LocalDateTime createdAt
+ ) {}
+
+
+
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionStatus.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionStatus.java
new file mode 100644
index 0000000000..d163ba8adf
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionStatus.java
@@ -0,0 +1,4 @@
+package com.pe.yape.service.challenge.transactions.dto;
+
+public record TransactionStatus(String name) {
+}
\ No newline at end of file
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionType.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionType.java
new file mode 100644
index 0000000000..56bab80c36
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/dto/TransactionType.java
@@ -0,0 +1,5 @@
+package com.pe.yape.service.challenge.transactions.dto;
+
+public record TransactionType(String name) {
+}
+
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/exception/handler/ApiException.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/exception/handler/ApiException.java
new file mode 100644
index 0000000000..cdb61f8200
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/exception/handler/ApiException.java
@@ -0,0 +1,14 @@
+package com.pe.yape.service.challenge.transactions.exception.handler;
+
+import lombok.Getter;
+
+@Getter
+public class ApiException extends RuntimeException {
+
+ private final Integer code;
+
+ public ApiException(Integer code, String message){
+ super(message);
+ this.code = code;
+ }
+}
diff --git a/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/exception/handler/CustomErrorAttributes.java b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/exception/handler/CustomErrorAttributes.java
new file mode 100644
index 0000000000..0df866a63b
--- /dev/null
+++ b/app-transaction-report-service/src/main/java/com/pe/yape/service/challenge/transactions/exception/handler/CustomErrorAttributes.java
@@ -0,0 +1,60 @@
+package com.pe.yape.service.challenge.transactions.exception.handler;
+
+import org.springframework.boot.web.error.ErrorAttributeOptions;
+import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebExchangeBindException;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.server.ServerWebInputException;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+public class CustomErrorAttributes extends DefaultErrorAttributes {
+
+ @Override
+ public Map getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
+ Throwable error = getError(request);
+
+ Map body = new LinkedHashMap<>();
+ body.put("path", request.path());
+ body.put("status", getStatus(error));
+ body.put("message", buildMessage(error));
+ body.put("errors", extractValidationErrors(error));
+
+ return body;
+ }
+
+ private int getStatus(Throwable error) {
+ if (error instanceof ServerWebInputException ex) {
+ return ex.getStatusCode().value();
+ }
+ else if (error instanceof ApiException) {
+ return ((ApiException) error).getCode();
+ }
+ return 500;
+ }
+
+ private String buildMessage(Throwable error) {
+ if (error instanceof ServerWebInputException) {
+ return "Existen errores de validación";
+ }
+ else if (error instanceof ApiException) {
+ return error.getMessage();
+ }
+ return error.getMessage();
+ }
+
+ private List