diff --git a/README.md b/README.md
index b067a71026..5494e3df6b 100644
--- a/README.md
+++ b/README.md
@@ -1,82 +1,145 @@
-# Yape Code Challenge :rocket:
+# Reto Yape
-Our code challenge will let you marvel us with your Jedi coding skills :smile:.
+## Arquitectura
-Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !!
-
-- [Problem](#problem)
-- [Tech Stack](#tech_stack)
-- [Send us your challenge](#send_us_your_challenge)
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ transaction-service │
+│ │
+│ REST API ──► CreateTransactionUseCase ──► DB (PENDING) │
+│ │ │
+│ └──► Kafka: transaction.created │
+│ │
+│ Kafka Consumer ◄── transaction.status.updated │
+│ │ │
+│ └──► UpdateTransactionStatusUseCase ──► DB (update) │
+└─────────────────────────────────────────────────────────────────┘
+ ▲ │
+ │ ▼ Kafka
+┌─────────────────────────────────────────────────────────────────┐
+│ anti-fraud-service │
+│ │
+│ Kafka Consumer ◄── transaction.created │
+│ │ │
+│ └──► EvaluateTransactionUseCase │
+│ value > 1000 → REJECTED │
+│ value ≤ 1000 → APPROVED │
+│ │ │
+│ └──► Kafka: transaction.status.updated │
+└─────────────────────────────────────────────────────────────────┘
+```
-# Problem
+## Estructura de paquetes - Arquitectura Hexagonal
-Every time a financial transaction is created it must be validated by our anti-fraud microservice and then the same service sends a message back to update the transaction status.
-For now, we have only three transaction statuses:
+```
+src/main/java/com/yape/transaction/
+├── domain/
+│ ├── model/ # Entidades y Value Objects (núcleo del dominio)
+│ │ ├── Transaction.java
+│ │ ├── TransactionStatus.java
+│ │ └── TransactionType.java
+│ └── port/
+│ ├── in/ # Casos de uso (interfaces de entrada)
+│ │ ├── CreateTransactionUseCase.java
+│ │ ├── GetTransactionUseCase.java
+│ │ └── UpdateTransactionStatusUseCase.java
+│ └── out/ # Puertos de salida (contratos de infraestructura)
+│ ├── TransactionRepository.java
+│ └── TransactionEventPublisher.java
+├── application/
+│ └── usecase/ # Implementaciones de casos de uso
+│ ├── CreateTransactionService.java
+│ ├── GetTransactionService.java
+│ └── UpdateTransactionStatusService.java
+└── infrastructure/
+ ├── adapter/
+ │ ├── in/
+ │ │ ├── rest/ # Controllers REST (adaptador de entrada)
+ │ │ └── messaging/ # Kafka consumers (adaptador de entrada)
+ │ └── out/
+ │ ├── persistence/ # JPA adapters (adaptador de salida)
+ │ └── messaging/ # Kafka producers (adaptador de salida)
+ └── config/ # Configuración de Spring / Kafka
+```
-
- - pending
- - approved
- - rejected
-
+## Cómo levantar el proyecto
-Every transaction with a value greater than 1000 should be rejected.
+### Prerrequisitos
+- Docker & Docker Compose
+- Java 17+
+- Maven 3.9+
-```mermaid
- flowchart LR
- Transaction -- Save Transaction with pending Status --> transactionDatabase[(Database)]
- Transaction --Send transaction Created event--> Anti-Fraud
- Anti-Fraud -- Send transaction Status Approved event--> Transaction
- Anti-Fraud -- Send transaction Status Rejected event--> Transaction
- Transaction -- Update transaction Status event--> transactionDatabase[(Database)]
+### Levantar infraestructura + servicios
+```bash
+docker-compose up -d
```
-# Tech Stack
+### Solo infraestructura (para desarrollo local)
+```bash
+docker-compose up -d postgres kafka zookeeper kafka-ui
+```
-
- - Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
- - Any database
- - Kafka
-
+### Ejecutar servicios localmente
+```bash
+# Terminal 1 - Transaction Service
+cd transaction-service
+mvn spring-boot:run
-We do provide a `Dockerfile` to help you get started with a dev environment.
+# Terminal 2 - Anti-Fraud Service
+cd anti-fraud-service
+mvn spring-boot:run
+```
-You must have two resources:
+## API Endpoints
-1. Resource to create a transaction that must containt:
+### Crear transacción
+```http
+POST http://localhost:8080/api/v1/transactions
+Content-Type: application/json
-```json
{
- "accountExternalIdDebit": "Guid",
- "accountExternalIdCredit": "Guid",
+ "accountExternalIdDebit": "3a0c1b2d-4e5f-6789-abcd-ef0123456789",
+ "accountExternalIdCredit": "7f8e9d0c-1b2a-3456-789a-bcdef0123456",
"tranferTypeId": 1,
"value": 120
}
```
-2. Resource to retrieve a transaction
+### Recuperar transacción
+```http
+GET http://localhost:8080/api/v1/transactions/{transactionExternalId}
+```
+### Respuesta esperada
```json
{
- "transactionExternalId": "Guid",
- "transactionType": {
- "name": ""
- },
- "transactionStatus": {
- "name": ""
- },
+ "transactionExternalId": "uuid",
+ "transactionType": { "name": "Transfer" },
+ "transactionStatus": { "name": "APPROVED" },
"value": 120,
- "createdAt": "Date"
+ "createdAt": "2024-01-15T10:30:00"
}
```
-## Optional
+## Tópicos Kafka
-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?
+| Tópico | Publicado por | Consumido por |
+|-------------------------------|-----------------------|-----------------------|
+| `transaction.created` | transaction-service | anti-fraud-service |
+| `transaction.status.updated` | anti-fraud-service | transaction-service |
-You can use Graphql;
+## Reglas de negocio
-# Send us your challenge
+- Toda transacción inicia con estado **PENDING**
+- Anti-fraud aprueba si `value <= 1000`
+- Anti-fraud rechaza si `value > 1000`
-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.
+## Tests
-If you have any questions, please let us know.
+```bash
+# Transaction service
+cd transaction-service && mvn test
+
+# Anti-fraud service
+cd anti-fraud-service && mvn test
+```
diff --git a/anti-fraud-service/Dockerfile b/anti-fraud-service/Dockerfile
new file mode 100644
index 0000000000..f5241f27a2
--- /dev/null
+++ b/anti-fraud-service/Dockerfile
@@ -0,0 +1,12 @@
+FROM maven:3.9-eclipse-temurin-17-alpine AS build
+WORKDIR /app
+COPY pom.xml .
+RUN mvn dependency:go-offline -B
+COPY src ./src
+RUN mvn clean package -DskipTests
+
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY --from=build /app/target/*.jar app.jar
+EXPOSE 8081
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/anti-fraud-service/pom.xml b/anti-fraud-service/pom.xml
new file mode 100644
index 0000000000..ef4bb2f84c
--- /dev/null
+++ b/anti-fraud-service/pom.xml
@@ -0,0 +1,63 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.0
+
+
+
+ com.yape
+ anti-fraud-service
+ 1.0.0
+ anti-fraud-service
+
+
+ 17
+
+
+
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudApplication.java b/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudApplication.java
new file mode 100644
index 0000000000..00aa3bed46
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudApplication.java
@@ -0,0 +1,12 @@
+package com.yape.antifraud;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class AntiFraudApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AntiFraudApplication.class, args);
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/application/usecase/EvaluateTransactionService.java b/anti-fraud-service/src/main/java/com/yape/antifraud/application/usecase/EvaluateTransactionService.java
new file mode 100644
index 0000000000..344e276fe0
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/application/usecase/EvaluateTransactionService.java
@@ -0,0 +1,35 @@
+package com.yape.antifraud.application.usecase;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+import com.yape.antifraud.domain.port.in.EvaluateTransactionUseCase;
+import com.yape.antifraud.domain.port.out.FraudResultPublisher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+@Service
+public class EvaluateTransactionService implements EvaluateTransactionUseCase {
+
+ private static final Logger log = LoggerFactory.getLogger(EvaluateTransactionService.class);
+
+ private final FraudResultPublisher fraudResultPublisher;
+
+ public EvaluateTransactionService(FraudResultPublisher fraudResultPublisher) {
+ this.fraudResultPublisher = fraudResultPublisher;
+ }
+
+ @Override
+ public FraudEvaluation execute(Command command) {
+ FraudEvaluation evaluation = new FraudEvaluation(
+ command.transactionExternalId(),
+ command.value()
+ ).evaluate();
+
+ log.info("Transaction {} evaluated as {} (value: {})",
+ command.transactionExternalId(), evaluation.getResult(), command.value());
+
+ fraudResultPublisher.publishResult(evaluation);
+
+ return evaluation;
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudEvaluation.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudEvaluation.java
new file mode 100644
index 0000000000..e313d42803
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudEvaluation.java
@@ -0,0 +1,35 @@
+package com.yape.antifraud.domain.model;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public class FraudEvaluation {
+
+ private static final BigDecimal MAX_ALLOWED_VALUE = BigDecimal.valueOf(1000);
+
+ private final UUID transactionExternalId;
+ private final BigDecimal value;
+ private FraudResult result;
+
+ public FraudEvaluation(UUID transactionExternalId, BigDecimal value) {
+ this.transactionExternalId = transactionExternalId;
+ this.value = value;
+ }
+
+ /**
+ * Core business rule: transactions over 1000 are rejected
+ * This is a pure domain method — no infrastructure concerns
+ */
+ public FraudEvaluation evaluate() {
+ this.result = value.compareTo(MAX_ALLOWED_VALUE) > 0
+ ? FraudResult.REJECTED
+ : FraudResult.APPROVED;
+ return this;
+ }
+
+ public UUID getTransactionExternalId() { return transactionExternalId; }
+ public BigDecimal getValue() { return value; }
+ public FraudResult getResult() { return result; }
+ public boolean isApproved() { return FraudResult.APPROVED.equals(result); }
+ public boolean isRejected() { return FraudResult.REJECTED.equals(result); }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudResult.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudResult.java
new file mode 100644
index 0000000000..4a3629fae2
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/model/FraudResult.java
@@ -0,0 +1,6 @@
+package com.yape.antifraud.domain.model;
+
+public enum FraudResult {
+ APPROVED,
+ REJECTED
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateTransactionUseCase.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateTransactionUseCase.java
new file mode 100644
index 0000000000..f3bfe5ef83
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateTransactionUseCase.java
@@ -0,0 +1,13 @@
+package com.yape.antifraud.domain.port.in;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public interface EvaluateTransactionUseCase {
+
+ FraudEvaluation execute(Command command);
+
+ record Command(UUID transactionExternalId, BigDecimal value) {}
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/out/FraudResultPublisher.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/out/FraudResultPublisher.java
new file mode 100644
index 0000000000..019a2bded1
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/port/out/FraudResultPublisher.java
@@ -0,0 +1,8 @@
+package com.yape.antifraud.domain.port.out;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+
+public interface FraudResultPublisher {
+
+ void publishResult(FraudEvaluation evaluation);
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumer.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumer.java
new file mode 100644
index 0000000000..f059339771
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumer.java
@@ -0,0 +1,38 @@
+package com.yape.antifraud.infrastructure.adapter.in.messaging;
+
+import com.yape.antifraud.domain.port.in.EvaluateTransactionUseCase;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+@Component
+public class TransactionCreatedConsumer {
+
+ private static final Logger log = LoggerFactory.getLogger(TransactionCreatedConsumer.class);
+
+ private final EvaluateTransactionUseCase evaluateTransactionUseCase;
+
+ public TransactionCreatedConsumer(EvaluateTransactionUseCase evaluateTransactionUseCase) {
+ this.evaluateTransactionUseCase = evaluateTransactionUseCase;
+ }
+
+ @KafkaListener(
+ topics = "${kafka.topics.transaction-created}",
+ groupId = "${spring.kafka.consumer.group-id}",
+ containerFactory = "transactionCreatedKafkaListenerContainerFactory"
+ )
+ public void onTransactionCreated(TransactionCreatedMessage message) {
+ log.info("Anti-fraud received transaction: {} with value: {}",
+ message.transactionExternalId(), message.value());
+
+ evaluateTransactionUseCase.execute(
+ new EvaluateTransactionUseCase.Command(
+ UUID.fromString(message.transactionExternalId()),
+ message.value()
+ )
+ );
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedMessage.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedMessage.java
new file mode 100644
index 0000000000..d60f12247c
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedMessage.java
@@ -0,0 +1,11 @@
+package com.yape.antifraud.infrastructure.adapter.in.messaging;
+
+import java.math.BigDecimal;
+
+public record TransactionCreatedMessage(
+ String transactionExternalId,
+ String accountExternalIdDebit,
+ String accountExternalIdCredit,
+ String transactionType,
+ BigDecimal value
+) {}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/KafkaFraudResultPublisher.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/KafkaFraudResultPublisher.java
new file mode 100644
index 0000000000..3ff6372dae
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/KafkaFraudResultPublisher.java
@@ -0,0 +1,46 @@
+package com.yape.antifraud.infrastructure.adapter.out.messaging;
+
+import com.yape.antifraud.domain.model.FraudEvaluation;
+import com.yape.antifraud.domain.port.out.FraudResultPublisher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+@Component
+public class KafkaFraudResultPublisher implements FraudResultPublisher {
+
+ private static final Logger log = LoggerFactory.getLogger(KafkaFraudResultPublisher.class);
+
+ private final KafkaTemplate kafkaTemplate;
+
+ @Value("${kafka.topics.transaction-status-updated}")
+ private String transactionStatusUpdatedTopic;
+
+ public KafkaFraudResultPublisher(KafkaTemplate kafkaTemplate) {
+ this.kafkaTemplate = kafkaTemplate;
+ }
+
+ @Override
+ public void publishResult(FraudEvaluation evaluation) {
+ TransactionStatusUpdatedEvent event = new TransactionStatusUpdatedEvent(
+ evaluation.getTransactionExternalId().toString(),
+ evaluation.getResult().name()
+ );
+
+ kafkaTemplate.send(
+ transactionStatusUpdatedTopic,
+ evaluation.getTransactionExternalId().toString(),
+ event
+ ).whenComplete((result, ex) -> {
+ if (ex != null) {
+ log.error("Failed to publish fraud result for transaction: {}",
+ evaluation.getTransactionExternalId(), ex);
+ } else {
+ log.info("Fraud result {} published for transaction: {}",
+ evaluation.getResult(), evaluation.getTransactionExternalId());
+ }
+ });
+ }
+}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/TransactionStatusUpdatedEvent.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/TransactionStatusUpdatedEvent.java
new file mode 100644
index 0000000000..b68ccae32b
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/TransactionStatusUpdatedEvent.java
@@ -0,0 +1,6 @@
+package com.yape.antifraud.infrastructure.adapter.out.messaging;
+
+public record TransactionStatusUpdatedEvent(
+ String transactionExternalId,
+ String status
+) {}
diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaConfig.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaConfig.java
new file mode 100644
index 0000000000..77cdb056d3
--- /dev/null
+++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/config/KafkaConfig.java
@@ -0,0 +1,69 @@
+package com.yape.antifraud.infrastructure.config;
+
+import com.yape.antifraud.infrastructure.adapter.in.messaging.TransactionCreatedMessage;
+import com.yape.antifraud.infrastructure.adapter.out.messaging.TransactionStatusUpdatedEvent;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.*;
+import org.springframework.kafka.support.serializer.JsonDeserializer;
+import org.springframework.kafka.support.serializer.JsonSerializer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class KafkaConfig {
+
+ @Value("${spring.kafka.bootstrap-servers}")
+ private String bootstrapServers;
+
+ @Value("${spring.kafka.consumer.group-id}")
+ private String groupId;
+
+ // ── Producer ──────────────────────────────────────────
+ @Bean
+ public ProducerFactory statusUpdatedProducerFactory() {
+ Map props = new HashMap<>();
+ props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
+ props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
+ props.put(ProducerConfig.ACKS_CONFIG, "all");
+ return new DefaultKafkaProducerFactory<>(props);
+ }
+
+ @Bean
+ public KafkaTemplate kafkaTemplate() {
+ return new KafkaTemplate<>(statusUpdatedProducerFactory());
+ }
+
+ // ── Consumer ──────────────────────────────────────────
+ @Bean
+ public ConsumerFactory transactionCreatedConsumerFactory() {
+ Map props = new HashMap<>();
+ props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+
+ JsonDeserializer deserializer =
+ new JsonDeserializer<>(TransactionCreatedMessage.class, false);
+
+ return new DefaultKafkaConsumerFactory<>(props, new StringDeserializer(), deserializer);
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory
+ transactionCreatedKafkaListenerContainerFactory() {
+ ConcurrentKafkaListenerContainerFactory factory =
+ new ConcurrentKafkaListenerContainerFactory<>();
+ factory.setConsumerFactory(transactionCreatedConsumerFactory());
+ factory.setConcurrency(3);
+ return factory;
+ }
+}
diff --git a/anti-fraud-service/src/main/resources/application.yml b/anti-fraud-service/src/main/resources/application.yml
new file mode 100644
index 0000000000..216b475021
--- /dev/null
+++ b/anti-fraud-service/src/main/resources/application.yml
@@ -0,0 +1,21 @@
+spring:
+ application:
+ name: anti-fraud-service
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: anti-fraud-service-group
+ auto-offset-reset: earliest
+
+server:
+ port: 8081
+
+kafka:
+ topics:
+ transaction-created: transaction.created
+ transaction-status-updated: transaction.status.updated
+
+logging:
+ level:
+ com.yape.antifraud: DEBUG
+ org.springframework.kafka: INFO
diff --git a/anti-fraud-service/src/test/java/com/yape/antifraud/domain/model/FraudEvaluationTest.java b/anti-fraud-service/src/test/java/com/yape/antifraud/domain/model/FraudEvaluationTest.java
new file mode 100644
index 0000000000..97cf29ad9b
--- /dev/null
+++ b/anti-fraud-service/src/test/java/com/yape/antifraud/domain/model/FraudEvaluationTest.java
@@ -0,0 +1,45 @@
+package com.yape.antifraud.domain.model;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("FraudEvaluation - Domain Business Rules")
+class FraudEvaluationTest {
+
+ @ParameterizedTest(name = "value={0} should be APPROVED")
+ @ValueSource(strings = {"1", "500", "999.99", "1000"})
+ void shouldApproveTransactionsUpTo1000(String value) {
+ FraudEvaluation evaluation = new FraudEvaluation(UUID.randomUUID(), new BigDecimal(value))
+ .evaluate();
+
+ assertThat(evaluation.isApproved()).isTrue();
+ assertThat(evaluation.getResult()).isEqualTo(FraudResult.APPROVED);
+ }
+
+ @ParameterizedTest(name = "value={0} should be REJECTED")
+ @ValueSource(strings = {"1000.01", "1001", "5000", "999999"})
+ void shouldRejectTransactionsOver1000(String value) {
+ FraudEvaluation evaluation = new FraudEvaluation(UUID.randomUUID(), new BigDecimal(value))
+ .evaluate();
+
+ assertThat(evaluation.isRejected()).isTrue();
+ assertThat(evaluation.getResult()).isEqualTo(FraudResult.REJECTED);
+ }
+
+ @Test
+ @DisplayName("Should preserve transaction ID after evaluation")
+ void shouldPreserveTransactionId() {
+ UUID transactionId = UUID.randomUUID();
+ FraudEvaluation evaluation = new FraudEvaluation(transactionId, BigDecimal.valueOf(500))
+ .evaluate();
+
+ assertThat(evaluation.getTransactionExternalId()).isEqualTo(transactionId);
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..827c2f654a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,25 +1,99 @@
-version: "3.7"
+version: '3.8'
+
services:
+
+ # ── PostgreSQL ────────────────────────────────────────
postgres:
- image: postgres:14
+ image: postgres:15-alpine
+ container_name: yape-postgres
+ environment:
+ POSTGRES_DB: transactions_db
+ POSTGRES_USER: yape
+ POSTGRES_PASSWORD: yape
ports:
- "5432:5432"
- environment:
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=postgres
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U yape -d transactions_db"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ # ── Zookeeper ─────────────────────────────────────────
zookeeper:
- image: confluentinc/cp-zookeeper:5.5.3
+ image: confluentinc/cp-zookeeper:7.5.0
+ container_name: yape-zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
+ ZOOKEEPER_TICK_TIME: 2000
+
+ # ── Kafka ─────────────────────────────────────────────
kafka:
- image: confluentinc/cp-enterprise-kafka:5.5.3
- depends_on: [zookeeper]
+ image: confluentinc/cp-kafka:7.5.0
+ container_name: yape-kafka
+ depends_on:
+ - zookeeper
+ ports:
+ - "9092:9092"
environment:
- KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
- KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_BROKER_ID: 1
+ KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_INTERNAL://kafka:29092
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_INTERNAL:PLAINTEXT
+ KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
- KAFKA_JMX_PORT: 9991
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
+ healthcheck:
+ test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ # ── Kafka UI (optional - useful for debugging) ────────
+ kafka-ui:
+ image: provectuslabs/kafka-ui:latest
+ container_name: yape-kafka-ui
+ depends_on:
+ - kafka
+ ports:
+ - "8090:8080"
+ environment:
+ KAFKA_CLUSTERS_0_NAME: local
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
+
+ # ── Transaction Service ───────────────────────────────
+ transaction-service:
+ build:
+ context: ./transaction-service
+ dockerfile: Dockerfile
+ container_name: yape-transaction-service
+ depends_on:
+ postgres:
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
+ ports:
+ - "8080:8080"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/transactions_db
+ DB_USERNAME: yape
+ DB_PASSWORD: yape
+ KAFKA_BOOTSTRAP_SERVERS: kafka:29092
+
+ # ── Anti-Fraud Service ────────────────────────────────
+ anti-fraud-service:
+ build:
+ context: ./anti-fraud-service
+ dockerfile: Dockerfile
+ container_name: yape-anti-fraud-service
+ depends_on:
+ kafka:
+ condition: service_healthy
ports:
- - 9092:9092
+ - "8081:8081"
+ environment:
+ KAFKA_BOOTSTRAP_SERVERS: kafka:29092
+
+volumes:
+ postgres_data:
diff --git a/transaction-service/Dockerfile b/transaction-service/Dockerfile
new file mode 100644
index 0000000000..e19086aed9
--- /dev/null
+++ b/transaction-service/Dockerfile
@@ -0,0 +1,12 @@
+FROM maven:3.9-eclipse-temurin-17-alpine AS build
+WORKDIR /app
+COPY pom.xml .
+RUN mvn dependency:go-offline -B
+COPY src ./src
+RUN mvn clean package -DskipTests
+
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY --from=build /app/target/*.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/transaction-service/pom.xml b/transaction-service/pom.xml
new file mode 100644
index 0000000000..f1d8de9d28
--- /dev/null
+++ b/transaction-service/pom.xml
@@ -0,0 +1,86 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.0
+
+
+
+ com.yape
+ transaction-service
+ 1.0.0
+ transaction-service
+
+
+ 17
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+
+
+ org.flywaydb
+ flyway-core
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/transaction-service/src/main/java/com/yape/transaction/TransactionApplication.java b/transaction-service/src/main/java/com/yape/transaction/TransactionApplication.java
new file mode 100644
index 0000000000..e2a6ac59d5
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/TransactionApplication.java
@@ -0,0 +1,12 @@
+package com.yape.transaction;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class TransactionApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(TransactionApplication.class, args);
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/application/usecase/CreateTransactionService.java b/transaction-service/src/main/java/com/yape/transaction/application/usecase/CreateTransactionService.java
new file mode 100644
index 0000000000..4e8444d584
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/application/usecase/CreateTransactionService.java
@@ -0,0 +1,39 @@
+package com.yape.transaction.application.usecase;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.in.CreateTransactionUseCase;
+import com.yape.transaction.domain.port.out.TransactionEventPublisher;
+import com.yape.transaction.domain.port.out.TransactionRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class CreateTransactionService implements CreateTransactionUseCase {
+
+ private final TransactionRepository transactionRepository;
+ private final TransactionEventPublisher eventPublisher;
+
+ public CreateTransactionService(TransactionRepository transactionRepository,
+ TransactionEventPublisher eventPublisher) {
+ this.transactionRepository = transactionRepository;
+ this.eventPublisher = eventPublisher;
+ }
+
+ @Override
+ @Transactional
+ public Transaction execute(Command command) {
+ Transaction transaction = Transaction.create(
+ command.accountExternalIdDebit(),
+ command.accountExternalIdCredit(),
+ command.transferTypeId(),
+ command.value()
+ );
+
+ Transaction saved = transactionRepository.save(transaction);
+
+ // Publish event to Kafka → Anti-Fraud will consume it
+ eventPublisher.publishTransactionCreated(saved);
+
+ return saved;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/application/usecase/GetTransactionService.java b/transaction-service/src/main/java/com/yape/transaction/application/usecase/GetTransactionService.java
new file mode 100644
index 0000000000..dcd2d6a506
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/application/usecase/GetTransactionService.java
@@ -0,0 +1,26 @@
+package com.yape.transaction.application.usecase;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.in.GetTransactionUseCase;
+import com.yape.transaction.domain.port.out.TransactionRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+@Service
+public class GetTransactionService implements GetTransactionUseCase {
+
+ private final TransactionRepository transactionRepository;
+
+ public GetTransactionService(TransactionRepository transactionRepository) {
+ this.transactionRepository = transactionRepository;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Transaction execute(UUID transactionExternalId) {
+ return transactionRepository.findByTransactionExternalId(transactionExternalId)
+ .orElseThrow(() -> new TransactionNotFoundException(transactionExternalId));
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/application/usecase/TransactionNotFoundException.java b/transaction-service/src/main/java/com/yape/transaction/application/usecase/TransactionNotFoundException.java
new file mode 100644
index 0000000000..a80e99c1ef
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/application/usecase/TransactionNotFoundException.java
@@ -0,0 +1,10 @@
+package com.yape.transaction.application.usecase;
+
+import java.util.UUID;
+
+public class TransactionNotFoundException extends RuntimeException {
+
+ public TransactionNotFoundException(UUID transactionExternalId) {
+ super("Transaction not found with id: " + transactionExternalId);
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionStatusService.java b/transaction-service/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionStatusService.java
new file mode 100644
index 0000000000..0cb413c15a
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/application/usecase/UpdateTransactionStatusService.java
@@ -0,0 +1,34 @@
+package com.yape.transaction.application.usecase;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.model.TransactionStatus;
+import com.yape.transaction.domain.port.in.UpdateTransactionStatusUseCase;
+import com.yape.transaction.domain.port.out.TransactionRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class UpdateTransactionStatusService implements UpdateTransactionStatusUseCase {
+
+ private final TransactionRepository transactionRepository;
+
+ public UpdateTransactionStatusService(TransactionRepository transactionRepository) {
+ this.transactionRepository = transactionRepository;
+ }
+
+ @Override
+ @Transactional
+ public void execute(Command command) {
+ Transaction transaction = transactionRepository
+ .findByTransactionExternalId(command.transactionExternalId())
+ .orElseThrow(() -> new TransactionNotFoundException(command.transactionExternalId()));
+
+ if (TransactionStatus.APPROVED.equals(command.newStatus())) {
+ transaction.approve();
+ } else {
+ transaction.reject();
+ }
+
+ transactionRepository.update(transaction);
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java
new file mode 100644
index 0000000000..0eb55ee037
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java
@@ -0,0 +1,79 @@
+package com.yape.transaction.domain.model;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public class Transaction {
+
+ private final UUID transactionExternalId;
+ private final UUID accountExternalIdDebit;
+ private final UUID accountExternalIdCredit;
+ private final TransactionType transactionType;
+ private TransactionStatus transactionStatus;
+ private final BigDecimal value;
+ private final LocalDateTime createdAt;
+
+ private Transaction(Builder builder) {
+ this.transactionExternalId = builder.transactionExternalId;
+ this.accountExternalIdDebit = builder.accountExternalIdDebit;
+ this.accountExternalIdCredit = builder.accountExternalIdCredit;
+ this.transactionType = builder.transactionType;
+ this.transactionStatus = TransactionStatus.PENDING;
+ this.value = builder.value;
+ this.createdAt = LocalDateTime.now();
+ }
+
+ // Factory method - DDD style
+ public static Transaction create(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ Integer transferTypeId,
+ BigDecimal value) {
+
+ return new Builder()
+ .transactionExternalId(UUID.randomUUID())
+ .accountExternalIdDebit(accountExternalIdDebit)
+ .accountExternalIdCredit(accountExternalIdCredit)
+ .transactionType(TransactionType.fromId(transferTypeId))
+ .value(value)
+ .build();
+ }
+
+ public void approve() {
+ this.transactionStatus = TransactionStatus.APPROVED;
+ }
+
+ public void reject() {
+ this.transactionStatus = TransactionStatus.REJECTED;
+ }
+
+ public boolean isPending() {
+ return TransactionStatus.PENDING.equals(this.transactionStatus);
+ }
+
+ // Getters
+ public UUID getTransactionExternalId() { return transactionExternalId; }
+ public UUID getAccountExternalIdDebit() { return accountExternalIdDebit; }
+ public UUID getAccountExternalIdCredit() { return accountExternalIdCredit; }
+ public TransactionType getTransactionType() { return transactionType; }
+ public TransactionStatus getTransactionStatus() { return transactionStatus; }
+ public BigDecimal getValue() { return value; }
+ public LocalDateTime getCreatedAt() { return createdAt; }
+
+ public static class Builder {
+ private UUID transactionExternalId;
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private TransactionType transactionType;
+ private BigDecimal value;
+
+ public Builder transactionExternalId(UUID id) { this.transactionExternalId = id; return this; }
+ public Builder accountExternalIdDebit(UUID id) { this.accountExternalIdDebit = id; return this; }
+ public Builder accountExternalIdCredit(UUID id) { this.accountExternalIdCredit = id; return this; }
+ public Builder transactionType(TransactionType type) { this.transactionType = type; return this; }
+ public Builder value(BigDecimal value) { this.value = value; return this; }
+
+ public Transaction build() { return new Transaction(this); }
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionStatus.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionStatus.java
new file mode 100644
index 0000000000..90e94027ef
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionStatus.java
@@ -0,0 +1,7 @@
+package com.yape.transaction.domain.model;
+
+public enum TransactionStatus {
+ PENDING,
+ APPROVED,
+ REJECTED;
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionType.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionType.java
new file mode 100644
index 0000000000..f3705f2806
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionType.java
@@ -0,0 +1,27 @@
+package com.yape.transaction.domain.model;
+
+import java.util.Arrays;
+
+public enum TransactionType {
+ TRANSFER(1, "Transfer"),
+ PAYMENT(2, "Payment"),
+ WITHDRAWAL(3, "Withdrawal");
+
+ private final Integer id;
+ private final String name;
+
+ TransactionType(Integer id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public static TransactionType fromId(Integer id) {
+ return Arrays.stream(values())
+ .filter(t -> t.id.equals(id))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unknown transfer type id: " + id));
+ }
+
+ public Integer getId() { return id; }
+ public String getName() { return name; }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionUseCase.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionUseCase.java
new file mode 100644
index 0000000000..c3c38bacc5
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionUseCase.java
@@ -0,0 +1,25 @@
+package com.yape.transaction.domain.port.in;
+
+import com.yape.transaction.domain.model.Transaction;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public interface CreateTransactionUseCase {
+
+ Transaction execute(Command command);
+
+ record Command(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ Integer transferTypeId,
+ BigDecimal value
+ ) {
+ public Command {
+ if (value == null || value.compareTo(BigDecimal.ZERO) <= 0)
+ throw new IllegalArgumentException("Value must be greater than zero");
+ if (accountExternalIdDebit == null || accountExternalIdCredit == null)
+ throw new IllegalArgumentException("Account IDs cannot be null");
+ }
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/in/GetTransactionUseCase.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/GetTransactionUseCase.java
new file mode 100644
index 0000000000..5c3c71ace7
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/GetTransactionUseCase.java
@@ -0,0 +1,10 @@
+package com.yape.transaction.domain.port.in;
+
+import com.yape.transaction.domain.model.Transaction;
+
+import java.util.UUID;
+
+public interface GetTransactionUseCase {
+
+ Transaction execute(UUID transactionExternalId);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusUseCase.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusUseCase.java
new file mode 100644
index 0000000000..9b60321abf
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusUseCase.java
@@ -0,0 +1,12 @@
+package com.yape.transaction.domain.port.in;
+
+import com.yape.transaction.domain.model.TransactionStatus;
+
+import java.util.UUID;
+
+public interface UpdateTransactionStatusUseCase {
+
+ void execute(Command command);
+
+ record Command(UUID transactionExternalId, TransactionStatus newStatus) {}
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionEventPublisher.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionEventPublisher.java
new file mode 100644
index 0000000000..804b3d62fd
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionEventPublisher.java
@@ -0,0 +1,8 @@
+package com.yape.transaction.domain.port.out;
+
+import com.yape.transaction.domain.model.Transaction;
+
+public interface TransactionEventPublisher {
+
+ void publishTransactionCreated(Transaction transaction);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepository.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepository.java
new file mode 100644
index 0000000000..f170c0aa3c
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepository.java
@@ -0,0 +1,15 @@
+package com.yape.transaction.domain.port.out;
+
+import com.yape.transaction.domain.model.Transaction;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface TransactionRepository {
+
+ Transaction save(Transaction transaction);
+
+ Optional findByTransactionExternalId(UUID transactionExternalId);
+
+ Transaction update(Transaction transaction);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusConsumer.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusConsumer.java
new file mode 100644
index 0000000000..209d1b0577
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusConsumer.java
@@ -0,0 +1,36 @@
+package com.yape.transaction.infrastructure.adapter.in.messaging;
+
+import com.yape.transaction.domain.model.TransactionStatus;
+import com.yape.transaction.domain.port.in.UpdateTransactionStatusUseCase;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+@Component
+public class TransactionStatusConsumer {
+
+ private static final Logger log = LoggerFactory.getLogger(TransactionStatusConsumer.class);
+
+ private final UpdateTransactionStatusUseCase updateTransactionStatusUseCase;
+
+ public TransactionStatusConsumer(UpdateTransactionStatusUseCase updateTransactionStatusUseCase) {
+ this.updateTransactionStatusUseCase = updateTransactionStatusUseCase;
+ }
+
+ @KafkaListener(
+ topics = "${kafka.topics.transaction-status-updated}",
+ groupId = "${spring.kafka.consumer.group-id}",
+ containerFactory = "statusUpdatedKafkaListenerContainerFactory"
+ )
+ public void consumeTransactionStatusUpdated(TransactionStatusUpdatedEvent event) {
+ log.info("Received status update for transaction: {} → {}", event.transactionExternalId(), event.status());
+
+ updateTransactionStatusUseCase.execute(new UpdateTransactionStatusUseCase.Command(
+ UUID.fromString(event.transactionExternalId()),
+ TransactionStatus.valueOf(event.status())
+ ));
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusUpdatedEvent.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusUpdatedEvent.java
new file mode 100644
index 0000000000..c0a530cb7f
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusUpdatedEvent.java
@@ -0,0 +1,6 @@
+package com.yape.transaction.infrastructure.adapter.in.messaging;
+
+public record TransactionStatusUpdatedEvent(
+ String transactionExternalId,
+ String status
+) {}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionController.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionController.java
new file mode 100644
index 0000000000..dbadc0a522
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionController.java
@@ -0,0 +1,98 @@
+package com.yape.transaction.infrastructure.adapter.in.rest;
+
+import com.yape.transaction.application.usecase.TransactionNotFoundException;
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.in.CreateTransactionUseCase;
+import com.yape.transaction.domain.port.in.GetTransactionUseCase;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/v1/transactions")
+public class TransactionController {
+
+ private final CreateTransactionUseCase createTransactionUseCase;
+ private final GetTransactionUseCase getTransactionUseCase;
+
+ public TransactionController(CreateTransactionUseCase createTransactionUseCase,
+ GetTransactionUseCase getTransactionUseCase) {
+ this.createTransactionUseCase = createTransactionUseCase;
+ this.getTransactionUseCase = getTransactionUseCase;
+ }
+
+ @PostMapping
+ public ResponseEntity createTransaction(
+ @RequestBody CreateTransactionRequest request) {
+
+ Transaction transaction = createTransactionUseCase.execute(
+ new CreateTransactionUseCase.Command(
+ request.accountExternalIdDebit(),
+ request.accountExternalIdCredit(),
+ request.tranferTypeId(),
+ request.value()
+ )
+ );
+
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(new CreateTransactionResponse(transaction.getTransactionExternalId()));
+ }
+
+ @GetMapping("/{transactionExternalId}")
+ public ResponseEntity getTransaction(
+ @PathVariable UUID transactionExternalId) {
+
+ Transaction transaction = getTransactionUseCase.execute(transactionExternalId);
+
+ return ResponseEntity.ok(GetTransactionResponse.from(transaction));
+ }
+
+ @ExceptionHandler(TransactionNotFoundException.class)
+ public ResponseEntity handleNotFound(TransactionNotFoundException ex) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(new ErrorResponse(ex.getMessage()));
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity handleBadRequest(IllegalArgumentException ex) {
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+ .body(new ErrorResponse(ex.getMessage()));
+ }
+
+ // ── Request / Response DTOs (inner records - keep them close to the controller) ──
+
+ public record CreateTransactionRequest(
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ Integer tranferTypeId,
+ BigDecimal value
+ ) {}
+
+ public record CreateTransactionResponse(UUID transactionExternalId) {}
+
+ public record GetTransactionResponse(
+ UUID transactionExternalId,
+ TransactionTypeDto transactionType,
+ TransactionStatusDto transactionStatus,
+ BigDecimal value,
+ LocalDateTime createdAt
+ ) {
+ static GetTransactionResponse from(Transaction t) {
+ return new GetTransactionResponse(
+ t.getTransactionExternalId(),
+ new TransactionTypeDto(t.getTransactionType().getName()),
+ new TransactionStatusDto(t.getTransactionStatus().name()),
+ t.getValue(),
+ t.getCreatedAt()
+ );
+ }
+ }
+
+ public record TransactionTypeDto(String name) {}
+ public record TransactionStatusDto(String name) {}
+ public record ErrorResponse(String message) {}
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/messaging/KafkaTransactionEventPublisher.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/messaging/KafkaTransactionEventPublisher.java
new file mode 100644
index 0000000000..e805bb484d
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/messaging/KafkaTransactionEventPublisher.java
@@ -0,0 +1,46 @@
+package com.yape.transaction.infrastructure.adapter.out.messaging;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.out.TransactionEventPublisher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+@Component
+public class KafkaTransactionEventPublisher implements TransactionEventPublisher {
+
+ private static final Logger log = LoggerFactory.getLogger(KafkaTransactionEventPublisher.class);
+
+ private final KafkaTemplate kafkaTemplate;
+
+ @Value("${kafka.topics.transaction-created}")
+ private String transactionCreatedTopic;
+
+ public KafkaTransactionEventPublisher(KafkaTemplate kafkaTemplate) {
+ this.kafkaTemplate = kafkaTemplate;
+ }
+
+ @Override
+ public void publishTransactionCreated(Transaction transaction) {
+ TransactionCreatedEvent event = new TransactionCreatedEvent(
+ transaction.getTransactionExternalId(),
+ transaction.getAccountExternalIdDebit(),
+ transaction.getAccountExternalIdCredit(),
+ transaction.getTransactionType().getName(),
+ transaction.getValue()
+ );
+
+ kafkaTemplate.send(transactionCreatedTopic, transaction.getTransactionExternalId().toString(), event)
+ .whenComplete((result, ex) -> {
+ if (ex != null) {
+ log.error("Failed to publish TransactionCreatedEvent for id: {}",
+ transaction.getTransactionExternalId(), ex);
+ } else {
+ log.info("TransactionCreatedEvent published for id: {}",
+ transaction.getTransactionExternalId());
+ }
+ });
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/messaging/TransactionCreatedEvent.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/messaging/TransactionCreatedEvent.java
new file mode 100644
index 0000000000..72fd717fb0
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/messaging/TransactionCreatedEvent.java
@@ -0,0 +1,12 @@
+package com.yape.transaction.infrastructure.adapter.out.messaging;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public record TransactionCreatedEvent(
+ UUID transactionExternalId,
+ UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit,
+ String transactionType,
+ BigDecimal value
+) {}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionPersistenceAdapter.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionPersistenceAdapter.java
new file mode 100644
index 0000000000..25b9c61d9b
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionPersistenceAdapter.java
@@ -0,0 +1,74 @@
+package com.yape.transaction.infrastructure.adapter.out.persistence;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.model.TransactionStatus;
+import com.yape.transaction.domain.model.TransactionType;
+import com.yape.transaction.domain.port.out.TransactionRepository;
+import com.yape.transaction.infrastructure.adapter.out.persistence.entity.TransactionEntity;
+import com.yape.transaction.infrastructure.adapter.out.persistence.repository.TransactionJpaRepository;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+import java.util.UUID;
+
+@Component
+public class TransactionPersistenceAdapter implements TransactionRepository {
+
+ private final TransactionJpaRepository jpaRepository;
+
+ public TransactionPersistenceAdapter(TransactionJpaRepository jpaRepository) {
+ this.jpaRepository = jpaRepository;
+ }
+
+ @Override
+ public Transaction save(Transaction transaction) {
+ TransactionEntity entity = toEntity(transaction);
+ TransactionEntity saved = jpaRepository.save(entity);
+ return toDomain(saved);
+ }
+
+ @Override
+ public Optional findByTransactionExternalId(UUID transactionExternalId) {
+ return jpaRepository.findByTransactionExternalId(transactionExternalId)
+ .map(this::toDomain);
+ }
+
+ @Override
+ public Transaction update(Transaction transaction) {
+ TransactionEntity entity = jpaRepository.findByTransactionExternalId(transaction.getTransactionExternalId())
+ .orElseThrow();
+ entity.setTransactionStatus(transaction.getTransactionStatus().name());
+ return toDomain(jpaRepository.save(entity));
+ }
+
+ private TransactionEntity toEntity(Transaction domain) {
+ TransactionEntity entity = new TransactionEntity();
+ entity.setTransactionExternalId(domain.getTransactionExternalId());
+ entity.setAccountExternalIdDebit(domain.getAccountExternalIdDebit());
+ entity.setAccountExternalIdCredit(domain.getAccountExternalIdCredit());
+ entity.setTransactionType(domain.getTransactionType().getName());
+ entity.setTransactionTypeId(domain.getTransactionType().getId());
+ entity.setTransactionStatus(domain.getTransactionStatus().name());
+ entity.setValue(domain.getValue());
+ entity.setCreatedAt(domain.getCreatedAt());
+ return entity;
+ }
+
+ private Transaction toDomain(TransactionEntity entity) {
+ Transaction transaction = Transaction.create(
+ entity.getAccountExternalIdDebit(),
+ entity.getAccountExternalIdCredit(),
+ entity.getTransactionTypeId(),
+ entity.getValue()
+ );
+ // Reconstruct status from DB
+ applyStatus(transaction, entity.getTransactionStatus());
+ return transaction;
+ }
+
+ private void applyStatus(Transaction transaction, String status) {
+ if (TransactionStatus.APPROVED.name().equals(status)) transaction.approve();
+ else if (TransactionStatus.REJECTED.name().equals(status)) transaction.reject();
+ // PENDING is default, no action needed
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/entity/TransactionEntity.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/entity/TransactionEntity.java
new file mode 100644
index 0000000000..95e7c7fdda
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/entity/TransactionEntity.java
@@ -0,0 +1,61 @@
+package com.yape.transaction.infrastructure.adapter.out.persistence.entity;
+
+import jakarta.persistence.*;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(name = "transactions", indexes = {
+ @Index(name = "idx_transaction_external_id", columnList = "transaction_external_id")
+})
+public class TransactionEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "transaction_external_id", nullable = false, unique = true)
+ private UUID transactionExternalId;
+
+ @Column(name = "account_external_id_debit", nullable = false)
+ private UUID accountExternalIdDebit;
+
+ @Column(name = "account_external_id_credit", nullable = false)
+ private UUID accountExternalIdCredit;
+
+ @Column(name = "transaction_type", nullable = false)
+ private String transactionType;
+
+ @Column(name = "transaction_type_id", nullable = false)
+ private Integer transactionTypeId;
+
+ @Column(name = "transaction_status", nullable = false)
+ private String transactionStatus;
+
+ @Column(name = "value", nullable = false, precision = 19, scale = 4)
+ private BigDecimal value;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ // Getters & Setters
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+ public UUID getTransactionExternalId() { return transactionExternalId; }
+ public void setTransactionExternalId(UUID transactionExternalId) { this.transactionExternalId = transactionExternalId; }
+ public UUID getAccountExternalIdDebit() { return accountExternalIdDebit; }
+ public void setAccountExternalIdDebit(UUID accountExternalIdDebit) { this.accountExternalIdDebit = accountExternalIdDebit; }
+ public UUID getAccountExternalIdCredit() { return accountExternalIdCredit; }
+ public void setAccountExternalIdCredit(UUID accountExternalIdCredit) { this.accountExternalIdCredit = accountExternalIdCredit; }
+ public String getTransactionType() { return transactionType; }
+ public void setTransactionType(String transactionType) { this.transactionType = transactionType; }
+ public Integer getTransactionTypeId() { return transactionTypeId; }
+ public void setTransactionTypeId(Integer transactionTypeId) { this.transactionTypeId = transactionTypeId; }
+ public String getTransactionStatus() { return transactionStatus; }
+ public void setTransactionStatus(String transactionStatus) { this.transactionStatus = transactionStatus; }
+ public BigDecimal getValue() { return value; }
+ public void setValue(BigDecimal value) { this.value = value; }
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/repository/TransactionJpaRepository.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/repository/TransactionJpaRepository.java
new file mode 100644
index 0000000000..7bd5537302
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/repository/TransactionJpaRepository.java
@@ -0,0 +1,17 @@
+package com.yape.transaction.infrastructure.adapter.out.persistence.repository;
+
+import com.yape.transaction.infrastructure.adapter.out.persistence.entity.TransactionEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+@Repository
+public interface TransactionJpaRepository extends JpaRepository {
+
+ // Use index for performance on high-volume reads
+ @Query("SELECT t FROM TransactionEntity t WHERE t.transactionExternalId = :transactionExternalId")
+ Optional findByTransactionExternalId(UUID transactionExternalId);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/config/KafkaConfig.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/config/KafkaConfig.java
new file mode 100644
index 0000000000..defcc206b0
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/config/KafkaConfig.java
@@ -0,0 +1,72 @@
+package com.yape.transaction.infrastructure.config;
+
+import com.yape.transaction.infrastructure.adapter.in.messaging.TransactionStatusUpdatedEvent;
+import com.yape.transaction.infrastructure.adapter.out.messaging.TransactionCreatedEvent;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.*;
+import org.springframework.kafka.support.serializer.JsonDeserializer;
+import org.springframework.kafka.support.serializer.JsonSerializer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class KafkaConfig {
+
+ @Value("${spring.kafka.bootstrap-servers}")
+ private String bootstrapServers;
+
+ @Value("${spring.kafka.consumer.group-id}")
+ private String groupId;
+
+ // ── Producer ──────────────────────────────────────────
+ @Bean
+ public ProducerFactory transactionCreatedProducerFactory() {
+ Map props = new HashMap<>();
+ props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
+ // Idempotent producer for exactly-once semantics
+ props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
+ props.put(ProducerConfig.ACKS_CONFIG, "all");
+ props.put(ProducerConfig.RETRIES_CONFIG, 3);
+ return new DefaultKafkaProducerFactory<>(props);
+ }
+
+ @Bean
+ public KafkaTemplate kafkaTemplate() {
+ return new KafkaTemplate<>(transactionCreatedProducerFactory());
+ }
+
+ // ── Consumer ──────────────────────────────────────────
+ @Bean
+ public ConsumerFactory statusUpdatedConsumerFactory() {
+ Map props = new HashMap<>();
+ props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+
+ JsonDeserializer deserializer =
+ new JsonDeserializer<>(TransactionStatusUpdatedEvent.class, false);
+
+ return new DefaultKafkaConsumerFactory<>(props, new StringDeserializer(), deserializer);
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory
+ statusUpdatedKafkaListenerContainerFactory() {
+ ConcurrentKafkaListenerContainerFactory factory =
+ new ConcurrentKafkaListenerContainerFactory<>();
+ factory.setConsumerFactory(statusUpdatedConsumerFactory());
+ // Concurrent consumers for high-volume scenarios
+ factory.setConcurrency(3);
+ return factory;
+ }
+}
diff --git a/transaction-service/src/main/resources/application.yml b/transaction-service/src/main/resources/application.yml
new file mode 100644
index 0000000000..a2065995a3
--- /dev/null
+++ b/transaction-service/src/main/resources/application.yml
@@ -0,0 +1,38 @@
+spring:
+ application:
+ name: transaction-service
+ datasource:
+ url: jdbc:postgresql://localhost:5432/transactions_db
+ username: ${DB_USERNAME:yape}
+ password: ${DB_PASSWORD:yape}
+ driver-class-name: org.postgresql.Driver
+ hikari:
+ maximum-pool-size: 20
+ minimum-idle: 5
+ connection-timeout: 30000
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ show-sql: false
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+ format_sql: true
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: transaction-service-group
+ auto-offset-reset: earliest
+
+server:
+ port: 8080
+
+kafka:
+ topics:
+ transaction-created: transaction.created
+ transaction-status-updated: transaction.status.updated
+
+logging:
+ level:
+ com.yape.transaction: DEBUG
+ org.springframework.kafka: INFO
diff --git a/transaction-service/src/main/resources/db/migration/V1__create_transactions_table.sql b/transaction-service/src/main/resources/db/migration/V1__create_transactions_table.sql
new file mode 100644
index 0000000000..443f68d3fc
--- /dev/null
+++ b/transaction-service/src/main/resources/db/migration/V1__create_transactions_table.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS transactions (
+ id BIGSERIAL PRIMARY KEY,
+ transaction_external_id UUID NOT NULL UNIQUE,
+ account_external_id_debit UUID NOT NULL,
+ account_external_id_credit UUID NOT NULL,
+ transaction_type VARCHAR(50) NOT NULL,
+ transaction_type_id INTEGER NOT NULL,
+ transaction_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+ value NUMERIC(19,4) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_transactions_external_id
+ ON transactions (transaction_external_id);
+
+CREATE INDEX IF NOT EXISTS idx_transactions_status
+ ON transactions (transaction_status);
diff --git a/transaction-service/src/test/java/com/yape/transaction/application/usecase/CreateTransactionServiceTest.java b/transaction-service/src/test/java/com/yape/transaction/application/usecase/CreateTransactionServiceTest.java
new file mode 100644
index 0000000000..8512a0ece0
--- /dev/null
+++ b/transaction-service/src/test/java/com/yape/transaction/application/usecase/CreateTransactionServiceTest.java
@@ -0,0 +1,82 @@
+package com.yape.transaction.application.usecase;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.model.TransactionStatus;
+import com.yape.transaction.domain.port.in.CreateTransactionUseCase;
+import com.yape.transaction.domain.port.out.TransactionEventPublisher;
+import com.yape.transaction.domain.port.out.TransactionRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CreateTransactionService - Application Use Case")
+class CreateTransactionServiceTest {
+
+ @Mock
+ private TransactionRepository transactionRepository;
+
+ @Mock
+ private TransactionEventPublisher eventPublisher;
+
+ @InjectMocks
+ private CreateTransactionService createTransactionService;
+
+ private CreateTransactionUseCase.Command validCommand;
+
+ @BeforeEach
+ void setUp() {
+ validCommand = new CreateTransactionUseCase.Command(
+ UUID.randomUUID(),
+ UUID.randomUUID(),
+ 1,
+ BigDecimal.valueOf(120)
+ );
+ }
+
+ @Test
+ @DisplayName("Should create transaction with PENDING status and publish event")
+ void shouldCreateTransactionWithPendingStatusAndPublishEvent() {
+ when(transactionRepository.save(any(Transaction.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ Transaction result = createTransactionService.execute(validCommand);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getTransactionStatus()).isEqualTo(TransactionStatus.PENDING);
+ assertThat(result.getValue()).isEqualByComparingTo(BigDecimal.valueOf(120));
+
+ verify(transactionRepository, times(1)).save(any(Transaction.class));
+ verify(eventPublisher, times(1)).publishTransactionCreated(any(Transaction.class));
+ }
+
+ @Test
+ @DisplayName("Should throw exception when value is zero")
+ void shouldThrowExceptionWhenValueIsZero() {
+ assertThatThrownBy(() ->
+ new CreateTransactionUseCase.Command(UUID.randomUUID(), UUID.randomUUID(), 1, BigDecimal.ZERO)
+ ).isInstanceOf(IllegalArgumentException.class);
+
+ verifyNoInteractions(transactionRepository, eventPublisher);
+ }
+
+ @Test
+ @DisplayName("Should throw exception when accountIds are null")
+ void shouldThrowExceptionWhenAccountIdsAreNull() {
+ assertThatThrownBy(() ->
+ new CreateTransactionUseCase.Command(null, null, 1, BigDecimal.valueOf(100))
+ ).isInstanceOf(IllegalArgumentException.class);
+ }
+}