diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000000..ab1f4164ed
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml
new file mode 100644
index 0000000000..742470c8a4
--- /dev/null
+++ b/.idea/checkstyle-idea.xml
@@ -0,0 +1,16 @@
+
+
+
+ 13.3.0
+ JavaOnly
+ true
+
+
+
+
+ (bundled)
+ (bundled)
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000000..b00ba77cd4
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/dbnavigator.xml b/.idea/dbnavigator.xml
new file mode 100644
index 0000000000..efd6329afc
--- /dev/null
+++ b/.idea/dbnavigator.xml
@@ -0,0 +1,479 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000000..90d81ba213
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 0000000000..712ab9d985
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000000..4c7d54ea1e
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index b067a71026..ef923eed28 100644
--- a/README.md
+++ b/README.md
@@ -1,82 +1,63 @@
-# Yape Code Challenge :rocket:
+# Yape Code Challenge - Quarkus
-Our code challenge will let you marvel us with your Jedi coding skills :smile:.
-
-Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !!
-
-- [Problem](#problem)
-- [Tech Stack](#tech_stack)
-- [Send us your challenge](#send_us_your_challenge)
-
-# Problem
-
-Every time a financial transaction is created it must be validated by our anti-fraud microservice and then the same service sends a message back to update the transaction status.
-For now, we have only three transaction statuses:
-
-
- pending
- approved
- rejected
-
-
-Every transaction with a value greater than 1000 should be rejected.
-
-```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)]
-```
+Sistema de transacciones financieras con validación anti-fraude usando Kafka y arquitectura hexagonal.
# Tech Stack
-
- Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
- Any database
- Kafka
-
+- **Quarkus 3.8** - Framework Java
+- **PostgreSQL 15** - Base de datos
+- **Apache Kafka** - Mensajería de eventos
+- **Redis** - Cache e idempotencia
+- **Arquitectura Hexagonal** - Clean Architecture
-We do provide a `Dockerfile` to help you get started with a dev environment.
+## API Endpoints
-You must have two resources:
+### Crear Transacción
-1. Resource to create a transaction that must containt:
+```bash
+POST http://localhost:8080/transactions
+Content-Type: application/json
-```json
{
- "accountExternalIdDebit": "Guid",
- "accountExternalIdCredit": "Guid",
+ "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000",
+ "accountExternalIdCredit": "550e8400-e29b-41d4-a716-446655440001",
"tranferTypeId": 1,
- "value": 120
+ "value": 500
}
```
-2. Resource to retrieve a transaction
-
+**Respuesta (201 Created):**
```json
{
- "transactionExternalId": "Guid",
+ "transactionExternalId": "generated-uuid",
"transactionType": {
- "name": ""
+ "name": "Transfer"
},
"transactionStatus": {
- "name": ""
+ "name": "pending"
},
- "value": 120,
- "createdAt": "Date"
+ "value": 500,
+ "createdAt": "2024-01-15T10:30:00"
}
```
-## Optional
-
-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?
+### Consultar Transacción
-You can use Graphql;
-
-# Send us your challenge
-
-When you finish your challenge, after forking a repository, you **must** open a pull request to our repository. There are no limitations to the implementation, you can follow the programming paradigm, modularization, and style that you feel is the most appropriate solution.
+```bash
+GET http://localhost:8080/transactions/{transactionExternalId}
+```
-If you have any questions, please let us know.
+**Respuesta (200 OK):**
+```json
+{
+ "transactionExternalId": "uuid",
+ "transactionType": {
+ "name": "Transfer"
+ },
+ "transactionStatus": {
+ "name": "approved"
+ },
+ "value": 500,
+ "createdAt": "2024-01-15T10:30:00"
+}
+```
diff --git a/antifraud-service/antifraud-service.iml b/antifraud-service/antifraud-service.iml
new file mode 100644
index 0000000000..9e3449c9d8
--- /dev/null
+++ b/antifraud-service/antifraud-service.iml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/antifraud-service/pom.xml b/antifraud-service/pom.xml
new file mode 100644
index 0000000000..b1ee00352e
--- /dev/null
+++ b/antifraud-service/pom.xml
@@ -0,0 +1,91 @@
+
+
+ 4.0.0
+
+
+ com.yape
+ yape-challenge
+ 1.0.0-SNAPSHOT
+
+
+ antifraud-service
+ Anti-Fraud Service
+ Microservice for validating transactions against fraud rules
+
+
+
+ io.quarkus
+ quarkus-arc
+
+
+
+ io.quarkus
+ quarkus-smallrye-reactive-messaging-kafka
+
+
+
+ io.quarkus
+ quarkus-redis-client
+
+
+
+ io.quarkus
+ quarkus-smallrye-fault-tolerance
+
+
+
+ io.quarkus
+ quarkus-smallrye-health
+
+
+
+ io.quarkus
+ quarkus-junit5
+ test
+
+
+ io.quarkus
+ quarkus-junit5-mockito
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
+
+ io.smallrye.reactive
+ smallrye-reactive-messaging-in-memory
+ test
+
+
+
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+ true
+
+
+
+ build
+ generate-code
+ generate-code-tests
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+
+
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/model/TransactionToValidate.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/model/TransactionToValidate.java
new file mode 100644
index 0000000000..a587ddb030
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/model/TransactionToValidate.java
@@ -0,0 +1,34 @@
+package com.yape.antifraud.domain.model;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public class TransactionToValidate {
+
+ private UUID transactionExternalId;
+ private BigDecimal value;
+
+ public TransactionToValidate() {
+ }
+
+ public TransactionToValidate(UUID transactionExternalId, BigDecimal value) {
+ this.transactionExternalId = transactionExternalId;
+ this.value = value;
+ }
+
+ public UUID getTransactionExternalId() {
+ return transactionExternalId;
+ }
+
+ public void setTransactionExternalId(UUID transactionExternalId) {
+ this.transactionExternalId = transactionExternalId;
+ }
+
+ public BigDecimal getValue() {
+ return value;
+ }
+
+ public void setValue(BigDecimal value) {
+ this.value = value;
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/model/ValidationResult.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/model/ValidationResult.java
new file mode 100644
index 0000000000..aab40b6726
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/model/ValidationResult.java
@@ -0,0 +1,58 @@
+package com.yape.antifraud.domain.model;
+
+import java.util.UUID;
+
+/**
+ * Domain model representing the validation result.
+ */
+public class ValidationResult {
+
+ private UUID transactionExternalId;
+ private ValidationStatus status;
+ private String reason;
+
+ public ValidationResult() {
+ }
+
+ public ValidationResult(UUID transactionExternalId, ValidationStatus status, String reason) {
+ this.transactionExternalId = transactionExternalId;
+ this.status = status;
+ this.reason = reason;
+ }
+
+ public static ValidationResult approved(UUID transactionExternalId) {
+ return new ValidationResult(transactionExternalId, ValidationStatus.APPROVED, "Transaction approved");
+ }
+
+ public static ValidationResult rejected(UUID transactionExternalId, String reason) {
+ return new ValidationResult(transactionExternalId, ValidationStatus.REJECTED, reason);
+ }
+
+ public UUID getTransactionExternalId() {
+ return transactionExternalId;
+ }
+
+ public void setTransactionExternalId(UUID transactionExternalId) {
+ this.transactionExternalId = transactionExternalId;
+ }
+
+ public ValidationStatus getStatus() {
+ return status;
+ }
+
+ public void setStatus(ValidationStatus status) {
+ this.status = status;
+ }
+
+ public String getReason() {
+ return reason;
+ }
+
+ public void setReason(String reason) {
+ this.reason = reason;
+ }
+
+ public boolean isApproved() {
+ return status == ValidationStatus.APPROVED;
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/model/ValidationStatus.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/model/ValidationStatus.java
new file mode 100644
index 0000000000..ac63fc48e5
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/model/ValidationStatus.java
@@ -0,0 +1,16 @@
+package com.yape.antifraud.domain.model;
+
+public enum ValidationStatus {
+ APPROVED("approved"),
+ REJECTED("rejected");
+
+ private final String name;
+
+ ValidationStatus(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/port/in/ValidateTransactionUseCase.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/in/ValidateTransactionUseCase.java
new file mode 100644
index 0000000000..aaa7089384
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/in/ValidateTransactionUseCase.java
@@ -0,0 +1,15 @@
+package com.yape.antifraud.domain.port.in;
+
+import com.yape.antifraud.domain.model.TransactionToValidate;
+import com.yape.antifraud.domain.model.ValidationResult;
+
+public interface ValidateTransactionUseCase {
+
+ /**
+ * Validates a transaction against fraud rules.
+ *
+ * @param transaction the transaction to validate
+ * @return the validation result
+ */
+ ValidationResult validate(TransactionToValidate transaction);
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/ValidationResultPublisher.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/ValidationResultPublisher.java
new file mode 100644
index 0000000000..8da1be184c
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/ValidationResultPublisher.java
@@ -0,0 +1,13 @@
+package com.yape.antifraud.domain.port.out;
+
+import com.yape.antifraud.domain.model.ValidationResult;
+
+public interface ValidationResultPublisher {
+
+ /**
+ * Publishes the validation result.
+ *
+ * @param result the validation result to publish
+ */
+ void publishValidationResult(ValidationResult result);
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/service/FraudValidationService.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/service/FraudValidationService.java
new file mode 100644
index 0000000000..dd62528115
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/service/FraudValidationService.java
@@ -0,0 +1,44 @@
+package com.yape.antifraud.domain.service;
+
+import com.yape.antifraud.domain.model.TransactionToValidate;
+import com.yape.antifraud.domain.model.ValidationResult;
+import com.yape.antifraud.domain.port.in.ValidateTransactionUseCase;
+import jakarta.enterprise.context.ApplicationScoped;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.jboss.logging.Logger;
+
+import java.math.BigDecimal;
+
+@ApplicationScoped
+public class FraudValidationService implements ValidateTransactionUseCase {
+
+ private static final Logger LOG = Logger.getLogger(FraudValidationService.class);
+
+ @ConfigProperty(name = "antifraud.max-transaction-value", defaultValue = "1000")
+ BigDecimal maxTransactionValue;
+
+ @Override
+ public ValidationResult validate(TransactionToValidate transaction) {
+ LOG.infof("Validating transaction: %s with value: %s",
+ transaction.getTransactionExternalId(), transaction.getValue());
+
+ // Regla 1: if value > 1000
+ if (transaction.getValue().compareTo(maxTransactionValue) > 0) {
+ LOG.infof("Transaction %s REJECTED: value %s exceeds maximum %s",
+ transaction.getTransactionExternalId(),
+ transaction.getValue(),
+ maxTransactionValue);
+
+ return ValidationResult.rejected(
+ transaction.getTransactionExternalId(),
+ "Transaction value exceeds maximum allowed: " + maxTransactionValue
+ );
+ }
+
+ LOG.infof("Transaction %s APPROVED: value %s is within limits",
+ transaction.getTransactionExternalId(),
+ transaction.getValue());
+
+ return ValidationResult.approved(transaction.getTransactionExternalId());
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumer.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumer.java
new file mode 100644
index 0000000000..780373f555
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumer.java
@@ -0,0 +1,75 @@
+package com.yape.antifraud.infrastructure.adapter.in.messaging;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.yape.antifraud.domain.model.TransactionToValidate;
+import com.yape.antifraud.domain.model.ValidationResult;
+import com.yape.antifraud.domain.port.in.ValidateTransactionUseCase;
+import com.yape.antifraud.domain.port.out.ValidationResultPublisher;
+import com.yape.antifraud.infrastructure.adapter.out.cache.IdempotencyService;
+import io.smallrye.common.annotation.Blocking;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.reactive.messaging.Incoming;
+import org.jboss.logging.Logger;
+
+@ApplicationScoped
+public class TransactionCreatedConsumer {
+
+ private static final Logger LOG = Logger.getLogger(TransactionCreatedConsumer.class);
+
+ private final ValidateTransactionUseCase validateUseCase;
+ private final ValidationResultPublisher resultPublisher;
+ private final IdempotencyService idempotencyService;
+ private ObjectMapper objectMapper;
+
+ @Inject
+ public TransactionCreatedConsumer(ValidateTransactionUseCase validateUseCase,
+ ValidationResultPublisher resultPublisher,
+ IdempotencyService idempotencyService) {
+ this.validateUseCase = validateUseCase;
+ this.resultPublisher = resultPublisher;
+ this.idempotencyService = idempotencyService;
+ }
+
+ @PostConstruct
+ void init() {
+ this.objectMapper = new ObjectMapper();
+ this.objectMapper.registerModule(new JavaTimeModule());
+ }
+
+ @Incoming("transaction-created-in")
+ @Blocking
+ public void consume(String message) {
+ LOG.infof("Received raw message: %s", message);
+
+ try {
+ TransactionCreatedEvent event = objectMapper.readValue(message, TransactionCreatedEvent.class);
+
+ LOG.infof("Parsed transaction created event: %s", event.getTransactionExternalId());
+
+ String idempotencyKey = "validate:" + event.getTransactionExternalId();
+ if (!idempotencyService.tryAcquireLock(idempotencyKey)) {
+ LOG.warnf("Duplicate event detected, skipping: %s", event.getTransactionExternalId());
+ return;
+ }
+
+ TransactionToValidate transaction = new TransactionToValidate(
+ event.getTransactionExternalId(),
+ event.getValue()
+ );
+
+ ValidationResult result = validateUseCase.validate(transaction);
+
+ resultPublisher.publishValidationResult(result);
+
+ LOG.infof("Transaction %s validated: %s",
+ event.getTransactionExternalId(),
+ result.getStatus());
+
+ } catch (Exception e) {
+ LOG.errorf(e, "Error processing message: %s", message);
+ }
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedEvent.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedEvent.java
new file mode 100644
index 0000000000..d7a502284b
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedEvent.java
@@ -0,0 +1,75 @@
+package com.yape.antifraud.infrastructure.adapter.in.messaging;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public class TransactionCreatedEvent {
+
+ private UUID transactionExternalId;
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private Integer transferTypeId;
+ private BigDecimal value;
+ private String status;
+ private LocalDateTime createdAt;
+
+ public TransactionCreatedEvent() {
+ }
+
+ 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 Integer getTransferTypeId() {
+ return transferTypeId;
+ }
+
+ public void setTransferTypeId(Integer transferTypeId) {
+ this.transferTypeId = transferTypeId;
+ }
+
+ public BigDecimal getValue() {
+ return value;
+ }
+
+ public void setValue(BigDecimal value) {
+ this.value = value;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/cache/IdempotencyService.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/cache/IdempotencyService.java
new file mode 100644
index 0000000000..b323469a06
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/cache/IdempotencyService.java
@@ -0,0 +1,53 @@
+package com.yape.antifraud.infrastructure.adapter.out.cache;
+
+import io.quarkus.redis.datasource.RedisDataSource;
+import io.quarkus.redis.datasource.value.ValueCommands;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.jboss.logging.Logger;
+
+import java.time.Duration;
+
+@ApplicationScoped
+public class IdempotencyService {
+
+ private static final Logger LOG = Logger.getLogger(IdempotencyService.class);
+ private static final String IDEMPOTENCY_PREFIX = "antifraud:idempotent:";
+ private static final Duration IDEMPOTENCY_TTL = Duration.ofHours(24);
+
+ private final RedisDataSource redisDataSource;
+ private ValueCommands valueCommands;
+
+ @Inject
+ public IdempotencyService(RedisDataSource redisDataSource) {
+ this.redisDataSource = redisDataSource;
+ }
+
+ @PostConstruct
+ void init() {
+ this.valueCommands = redisDataSource.value(String.class, String.class);
+ }
+
+ public boolean isProcessed(String eventKey) {
+ String key = IDEMPOTENCY_PREFIX + eventKey;
+ String value = valueCommands.get(key);
+ return value != null;
+ }
+
+ public void markAsProcessed(String eventKey) {
+ String key = IDEMPOTENCY_PREFIX + eventKey;
+ valueCommands.setex(key, IDEMPOTENCY_TTL.toSeconds(), "1");
+ LOG.debugf("Event marked as processed: %s", key);
+ }
+
+ public boolean tryAcquireLock(String eventKey) {
+ String key = IDEMPOTENCY_PREFIX + eventKey;
+ boolean acquired = valueCommands.setnx(key, "processing");
+ if(acquired){
+ redisDataSource.key().expire(key, IDEMPOTENCY_TTL.toSeconds());
+ }
+ LOG.debugf("Lock acquisition for %s: %s", key, acquired);
+ return acquired;
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/KafkaValidationResultPublisher.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/KafkaValidationResultPublisher.java
new file mode 100644
index 0000000000..4254bf3d46
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/KafkaValidationResultPublisher.java
@@ -0,0 +1,38 @@
+package com.yape.antifraud.infrastructure.adapter.out.messaging;
+
+import com.yape.antifraud.domain.model.ValidationResult;
+import com.yape.antifraud.domain.port.out.ValidationResultPublisher;
+import io.smallrye.reactive.messaging.kafka.Record;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.reactive.messaging.Channel;
+import org.eclipse.microprofile.reactive.messaging.Emitter;
+import org.jboss.logging.Logger;
+
+@ApplicationScoped
+public class KafkaValidationResultPublisher implements ValidationResultPublisher {
+
+ private static final Logger LOG = Logger.getLogger(KafkaValidationResultPublisher.class);
+
+ @Inject
+ @Channel("transaction-status-out")
+ Emitter> emitter;
+
+ @Override
+ public void publishValidationResult(ValidationResult result) {
+ LOG.infof("Publishing validation result: %s -> %s",
+ result.getTransactionExternalId(),
+ result.getStatus());
+
+ TransactionStatusEvent event = new TransactionStatusEvent(
+ result.getTransactionExternalId(),
+ result.getStatus().getName()
+ );
+
+ String key = result.getTransactionExternalId().toString();
+
+ emitter.send(Record.of(key, event));
+
+ LOG.infof("Validation result published successfully: %s", result.getTransactionExternalId());
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/TransactionStatusEvent.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/TransactionStatusEvent.java
new file mode 100644
index 0000000000..86b3e08ee0
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/adapter/out/messaging/TransactionStatusEvent.java
@@ -0,0 +1,33 @@
+package com.yape.antifraud.infrastructure.adapter.out.messaging;
+
+import java.util.UUID;
+
+public class TransactionStatusEvent {
+
+ private UUID transactionExternalId;
+ private String status;
+
+ public TransactionStatusEvent() {
+ }
+
+ public TransactionStatusEvent(UUID transactionExternalId, String status) {
+ this.transactionExternalId = transactionExternalId;
+ this.status = status;
+ }
+
+ public UUID getTransactionExternalId() {
+ return transactionExternalId;
+ }
+
+ public void setTransactionExternalId(UUID transactionExternalId) {
+ this.transactionExternalId = transactionExternalId;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+}
diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/config/TransactionCreatedEventDeserializer.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/config/TransactionCreatedEventDeserializer.java
new file mode 100644
index 0000000000..035ff8a89c
--- /dev/null
+++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/config/TransactionCreatedEventDeserializer.java
@@ -0,0 +1,11 @@
+package com.yape.antifraud.infrastructure.config;
+
+import com.yape.antifraud.infrastructure.adapter.in.messaging.TransactionCreatedEvent;
+import io.quarkus.kafka.client.serialization.ObjectMapperDeserializer;
+
+public class TransactionCreatedEventDeserializer extends ObjectMapperDeserializer {
+
+ public TransactionCreatedEventDeserializer() {
+ super(TransactionCreatedEvent.class);
+ }
+}
diff --git a/antifraud-service/src/main/resources/application.properties b/antifraud-service/src/main/resources/application.properties
new file mode 100644
index 0000000000..7f636e5e9f
--- /dev/null
+++ b/antifraud-service/src/main/resources/application.properties
@@ -0,0 +1,40 @@
+quarkus.application.name=antifraud-service
+
+quarkus.http.port=8081
+
+# Redis Configuration
+quarkus.redis.hosts=redis://localhost:6379
+
+# Kafka Configuration
+kafka.bootstrap.servers=localhost:9092
+
+# Incoming channel - Transaction Created
+mp.messaging.incoming.transaction-created-in.connector=smallrye-kafka
+mp.messaging.incoming.transaction-created-in.topic=transaction-created
+mp.messaging.incoming.transaction-created-in.group.id=antifraud-service
+mp.messaging.incoming.transaction-created-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
+#mp.messaging.incoming.transaction-created-in.value.deserializer=com.yape.antifraud.infrastructure.config.TransactionCreatedEventDeserializer
+mp.messaging.incoming.transaction-created-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
+mp.messaging.incoming.transaction-created-in.auto.offset.reset=earliest
+mp.messaging.incoming.transaction-created-in.failure-strategy=ignore
+
+# Outgoing channel - Transaction Status
+mp.messaging.outgoing.transaction-status-out.connector=smallrye-kafka
+mp.messaging.outgoing.transaction-status-out.topic=transaction-status
+mp.messaging.outgoing.transaction-status-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
+mp.messaging.outgoing.transaction-status-out.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer
+
+# Anti-Fraud Configuration
+antifraud.max-transaction-value=1000
+
+# Health
+quarkus.smallrye-health.root-path=/health
+
+# Logging
+quarkus.log.level=INFO
+quarkus.log.category."com.yape".level=DEBUG
+quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n
+
+# Dev Services
+%dev.quarkus.redis.devservices.enabled=false
+%dev.quarkus.kafka.devservices.enabled=false
diff --git a/antifraud-service/src/test/java/com/yape/antifraud/domain/model/TransactionToValidateTest.java b/antifraud-service/src/test/java/com/yape/antifraud/domain/model/TransactionToValidateTest.java
new file mode 100644
index 0000000000..7a14a379ff
--- /dev/null
+++ b/antifraud-service/src/test/java/com/yape/antifraud/domain/model/TransactionToValidateTest.java
@@ -0,0 +1,39 @@
+package com.yape.antifraud.domain.model;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("TransactionToValidate Domain Model Tests")
+class TransactionToValidateTest {
+
+ @Test
+ @DisplayName("Should create transaction to validate with constructor")
+ void shouldCreateTransactionToValidateWithConstructor() {
+ UUID transactionId = UUID.randomUUID();
+ BigDecimal value = new BigDecimal("500.00");
+
+ TransactionToValidate transaction = new TransactionToValidate(transactionId, value);
+
+ assertEquals(transactionId, transaction.getTransactionExternalId());
+ assertEquals(value, transaction.getValue());
+ }
+
+ @Test
+ @DisplayName("Should create empty transaction and set values")
+ void shouldCreateEmptyTransactionAndSetValues() {
+ TransactionToValidate transaction = new TransactionToValidate();
+ UUID transactionId = UUID.randomUUID();
+ BigDecimal value = new BigDecimal("1500.00");
+
+ transaction.setTransactionExternalId(transactionId);
+ transaction.setValue(value);
+
+ assertEquals(transactionId, transaction.getTransactionExternalId());
+ assertEquals(value, transaction.getValue());
+ }
+}
diff --git a/antifraud-service/src/test/java/com/yape/antifraud/domain/model/ValidationResultTest.java b/antifraud-service/src/test/java/com/yape/antifraud/domain/model/ValidationResultTest.java
new file mode 100644
index 0000000000..fef34338a6
--- /dev/null
+++ b/antifraud-service/src/test/java/com/yape/antifraud/domain/model/ValidationResultTest.java
@@ -0,0 +1,53 @@
+package com.yape.antifraud.domain.model;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("ValidationResult Domain Model Tests")
+class ValidationResultTest {
+
+ @Test
+ @DisplayName("Should create approved result")
+ void shouldCreateApprovedResult() {
+ UUID transactionId = UUID.randomUUID();
+
+ ValidationResult result = ValidationResult.approved(transactionId);
+
+ assertEquals(transactionId, result.getTransactionExternalId());
+ assertEquals(ValidationStatus.APPROVED, result.getStatus());
+ assertTrue(result.isApproved());
+ assertEquals("Transaction approved", result.getReason());
+ }
+
+ @Test
+ @DisplayName("Should create rejected result")
+ void shouldCreateRejectedResult() {
+ UUID transactionId = UUID.randomUUID();
+ String reason = "Value exceeds maximum";
+
+ ValidationResult result = ValidationResult.rejected(transactionId, reason);
+
+ assertEquals(transactionId, result.getTransactionExternalId());
+ assertEquals(ValidationStatus.REJECTED, result.getStatus());
+ assertFalse(result.isApproved());
+ assertEquals(reason, result.getReason());
+ }
+
+ @Test
+ @DisplayName("Should create result with constructor")
+ void shouldCreateResultWithConstructor() {
+ UUID transactionId = UUID.randomUUID();
+ ValidationStatus status = ValidationStatus.APPROVED;
+ String reason = "All checks passed";
+
+ ValidationResult result = new ValidationResult(transactionId, status, reason);
+
+ assertEquals(transactionId, result.getTransactionExternalId());
+ assertEquals(status, result.getStatus());
+ assertEquals(reason, result.getReason());
+ }
+}
diff --git a/antifraud-service/src/test/java/com/yape/antifraud/domain/service/FraudValidationServiceTest.java b/antifraud-service/src/test/java/com/yape/antifraud/domain/service/FraudValidationServiceTest.java
new file mode 100644
index 0000000000..df6a66be56
--- /dev/null
+++ b/antifraud-service/src/test/java/com/yape/antifraud/domain/service/FraudValidationServiceTest.java
@@ -0,0 +1,140 @@
+package com.yape.antifraud.domain.service;
+
+import com.yape.antifraud.domain.model.TransactionToValidate;
+import com.yape.antifraud.domain.model.ValidationResult;
+import com.yape.antifraud.domain.model.ValidationStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Fraud Validation Service Unit Tests")
+class FraudValidationServiceTest {
+
+ private FraudValidationService fraudValidationService;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ fraudValidationService = new FraudValidationService();
+ // Set the maxTransactionValue using reflection (since it's injected via @ConfigProperty)
+ Field maxValueField = FraudValidationService.class.getDeclaredField("maxTransactionValue");
+ maxValueField.setAccessible(true);
+ maxValueField.set(fraudValidationService, new BigDecimal("1000"));
+ }
+
+ @Test
+ @DisplayName("Should approve transaction with value less than 1000")
+ void shouldApproveTransactionWithValueLessThan1000() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ TransactionToValidate transaction = new TransactionToValidate(transactionId, new BigDecimal("500.00"));
+
+ // When
+ ValidationResult result = fraudValidationService.validate(transaction);
+
+ // Then
+ assertNotNull(result);
+ assertEquals(transactionId, result.getTransactionExternalId());
+ assertEquals(ValidationStatus.APPROVED, result.getStatus());
+ assertTrue(result.isApproved());
+ }
+
+ @Test
+ @DisplayName("Should approve transaction with value exactly 1000")
+ void shouldApproveTransactionWithValueExactly1000() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ TransactionToValidate transaction = new TransactionToValidate(transactionId, new BigDecimal("1000.00"));
+
+ // When
+ ValidationResult result = fraudValidationService.validate(transaction);
+
+ // Then
+ assertEquals(ValidationStatus.APPROVED, result.getStatus());
+ assertTrue(result.isApproved());
+ }
+
+ @Test
+ @DisplayName("Should reject transaction with value greater than 1000")
+ void shouldRejectTransactionWithValueGreaterThan1000() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ TransactionToValidate transaction = new TransactionToValidate(transactionId, new BigDecimal("1001.00"));
+
+ // When
+ ValidationResult result = fraudValidationService.validate(transaction);
+
+ // Then
+ assertNotNull(result);
+ assertEquals(transactionId, result.getTransactionExternalId());
+ assertEquals(ValidationStatus.REJECTED, result.getStatus());
+ assertFalse(result.isApproved());
+ assertTrue(result.getReason().contains("exceeds maximum"));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "0.01, APPROVED",
+ "100, APPROVED",
+ "500, APPROVED",
+ "999.99, APPROVED",
+ "1000, APPROVED",
+ "1000.01, REJECTED",
+ "1500, REJECTED",
+ "5000, REJECTED",
+ "10000, REJECTED"
+ })
+ @DisplayName("Should validate transactions based on value threshold")
+ void shouldValidateTransactionsBasedOnValueThreshold(String value, ValidationStatus expectedStatus) {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ TransactionToValidate transaction = new TransactionToValidate(transactionId, new BigDecimal(value));
+
+ // When
+ ValidationResult result = fraudValidationService.validate(transaction);
+
+ // Then
+ assertEquals(expectedStatus, result.getStatus());
+ }
+
+ @Test
+ @DisplayName("Should handle very large transaction values")
+ void shouldHandleVeryLargeTransactionValues() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ TransactionToValidate transaction = new TransactionToValidate(
+ transactionId,
+ new BigDecimal("999999999.99")
+ );
+
+ // When
+ ValidationResult result = fraudValidationService.validate(transaction);
+
+ // Then
+ assertEquals(ValidationStatus.REJECTED, result.getStatus());
+ }
+
+ @Test
+ @DisplayName("Should handle very small transaction values")
+ void shouldHandleVerySmallTransactionValues() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ TransactionToValidate transaction = new TransactionToValidate(
+ transactionId,
+ new BigDecimal("0.01")
+ );
+
+ // When
+ ValidationResult result = fraudValidationService.validate(transaction);
+
+ // Then
+ assertEquals(ValidationStatus.APPROVED, result.getStatus());
+ }
+}
diff --git a/antifraud-service/src/test/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumerTest.java b/antifraud-service/src/test/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumerTest.java
new file mode 100644
index 0000000000..06fa0bcf80
--- /dev/null
+++ b/antifraud-service/src/test/java/com/yape/antifraud/infrastructure/adapter/in/messaging/TransactionCreatedConsumerTest.java
@@ -0,0 +1,115 @@
+package com.yape.antifraud.infrastructure.adapter.in.messaging;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.yape.antifraud.domain.model.ValidationResult;
+import com.yape.antifraud.domain.model.ValidationStatus;
+import com.yape.antifraud.domain.port.in.ValidateTransactionUseCase;
+import com.yape.antifraud.domain.port.out.ValidationResultPublisher;
+import com.yape.antifraud.infrastructure.adapter.out.cache.IdempotencyService;
+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.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Transaction Created Consumer Tests")
+class TransactionCreatedConsumerTest {
+
+ @Mock
+ private ValidateTransactionUseCase validateUseCase;
+
+ @Mock
+ private ValidationResultPublisher resultPublisher;
+
+ @Mock
+ private IdempotencyService idempotencyService;
+
+ private TransactionCreatedConsumer consumer;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ consumer = new TransactionCreatedConsumer(validateUseCase, resultPublisher, idempotencyService);
+ consumer.init();
+ objectMapper = new ObjectMapper();
+ objectMapper.registerModule(new JavaTimeModule());
+ }
+
+ @Test
+ @DisplayName("Should process transaction and publish approved result")
+ void shouldProcessTransactionAndPublishApprovedResult() throws Exception {
+ UUID transactionId = UUID.randomUUID();
+ TransactionCreatedEvent event = createEvent(transactionId, new BigDecimal("500.00"));
+ String message = objectMapper.writeValueAsString(event);
+
+ when(idempotencyService.tryAcquireLock(any())).thenReturn(true);
+ when(validateUseCase.validate(any())).thenReturn(ValidationResult.approved(transactionId));
+
+ consumer.consume(message);
+
+ verify(idempotencyService).tryAcquireLock("validate:" + transactionId);
+ verify(validateUseCase).validate(any());
+ verify(resultPublisher).publishValidationResult(any());
+ }
+
+ @Test
+ @DisplayName("Should skip duplicate event")
+ void shouldSkipDuplicateEvent() throws Exception {
+ UUID transactionId = UUID.randomUUID();
+ TransactionCreatedEvent event = createEvent(transactionId, new BigDecimal("500.00"));
+ String message = objectMapper.writeValueAsString(event);
+
+ when(idempotencyService.tryAcquireLock(any())).thenReturn(false); // Lock not acquired = duplicate
+
+ consumer.consume(message);
+
+ verify(idempotencyService).tryAcquireLock(any());
+ verify(validateUseCase, never()).validate(any());
+ verify(resultPublisher, never()).publishValidationResult(any());
+ }
+
+ @Test
+ @DisplayName("Should process transaction and publish rejected result")
+ void shouldProcessTransactionAndPublishRejectedResult() throws Exception {
+ UUID transactionId = UUID.randomUUID();
+ TransactionCreatedEvent event = createEvent(transactionId, new BigDecimal("1500.00"));
+ String message = objectMapper.writeValueAsString(event);
+
+ when(idempotencyService.tryAcquireLock(any())).thenReturn(true);
+ when(validateUseCase.validate(any())).thenReturn(
+ ValidationResult.rejected(transactionId, "Value exceeds maximum"));
+
+ consumer.consume(message);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ValidationResult.class);
+ verify(resultPublisher).publishValidationResult(captor.capture());
+
+ ValidationResult result = captor.getValue();
+ assertEquals(ValidationStatus.REJECTED, result.getStatus());
+ assertEquals(transactionId, result.getTransactionExternalId());
+ }
+
+ private TransactionCreatedEvent createEvent(UUID transactionId, BigDecimal value) {
+ TransactionCreatedEvent event = new TransactionCreatedEvent();
+ event.setTransactionExternalId(transactionId);
+ event.setAccountExternalIdDebit(UUID.randomUUID());
+ event.setAccountExternalIdCredit(UUID.randomUUID());
+ event.setTransferTypeId(1);
+ event.setValue(value);
+ event.setStatus("pending");
+ event.setCreatedAt(LocalDateTime.now());
+ return event;
+ }
+}
\ No newline at end of file
diff --git a/antifraud-service/src/test/resources/application.properties b/antifraud-service/src/test/resources/application.properties
new file mode 100644
index 0000000000..faf013516e
--- /dev/null
+++ b/antifraud-service/src/test/resources/application.properties
@@ -0,0 +1,13 @@
+quarkus.redis.hosts=redis://localhost:6379
+quarkus.redis.devservices.enabled=true
+
+# Kafka
+mp.messaging.incoming.transaction-created-in.connector=smallrye-in-memory
+mp.messaging.outgoing.transaction-status-out.connector=smallrye-in-memory
+
+# Anti-Fraud Configuration
+antifraud.max-transaction-value=1000
+
+# Logging
+quarkus.log.level=WARN
+quarkus.log.category."com.yape".level=DEBUG
diff --git a/docker-compose-kraft.yml b/docker-compose-kraft.yml
new file mode 100644
index 0000000000..6938dc164e
--- /dev/null
+++ b/docker-compose-kraft.yml
@@ -0,0 +1,91 @@
+version: "3.8"
+
+services:
+ postgres:
+ image: postgres:15
+ container_name: yape-postgres
+ ports:
+ - "5432:5432"
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: transactions
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - yape-network
+
+ redis:
+ image: redis:7-alpine
+ container_name: yape-redis
+ ports:
+ - "6379:6379"
+ command: redis-server --appendonly yes
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+ networks:
+ - yape-network
+
+ kafka:
+ image: bitnami/kafka:3.6
+ container_name: yape-kafka-kraft
+ ports:
+ - "9092:9092"
+ environment:
+ # KRaft settings
+ KAFKA_CFG_NODE_ID: 1
+ KAFKA_CFG_PROCESS_ROLES: controller,broker
+ KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
+ KAFKA_CFG_LISTENERS: PLAINTEXT://:29092,CONTROLLER://:9093,EXTERNAL://:9092
+ KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,EXTERNAL://localhost:9092
+ KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT,PLAINTEXT:PLAINTEXT
+ KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
+ KAFKA_CFG_INTER_BROKER_LISTENER_NAME: PLAINTEXT
+ # Performance settings
+ KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR: 1
+ KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: "true"
+ # KRaft mode
+ KAFKA_KRAFT_CLUSTER_ID: "MkU3OEVBNTcwNTJENDM2Qk"
+ volumes:
+ - kafka_data:/bitnami/kafka
+ healthcheck:
+ test: kafka-broker-api-versions.sh --bootstrap-server localhost:9092 || exit 1
+ interval: 10s
+ timeout: 10s
+ retries: 5
+ networks:
+ - yape-network
+
+ kafka-ui:
+ image: provectuslabs/kafka-ui:latest
+ container_name: yape-kafka-ui
+ ports:
+ - "8090:8080"
+ environment:
+ KAFKA_CLUSTERS_0_NAME: local-kraft
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
+ depends_on:
+ - kafka
+ networks:
+ - yape-network
+
+volumes:
+ postgres_data:
+ redis_data:
+ kafka_data:
+
+networks:
+ yape-network:
+ driver: bridge
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..dbbcb7946c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,25 +1,96 @@
-version: "3.7"
+version: "3.8"
services:
postgres:
- image: postgres:14
+ image: postgres:15
+ container_name: yape-postgres
ports:
- "5432:5432"
environment:
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=postgres
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: transactions
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - yape-network
+
+ redis:
+ image: redis:7-alpine
+ container_name: yape-redis
+ ports:
+ - "6379:6379"
+ command: redis-server --appendonly yes
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+ networks:
+ - yape-network
+
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
+ healthcheck:
+ test: echo srvr | nc localhost 2181 || exit 1
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - yape-network
+
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:
+ condition: service_healthy
+ ports:
+ - "9092:9092"
environment:
- KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
+ KAFKA_BROKER_ID: 1
+ KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
- KAFKA_BROKER_ID: 1
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
- KAFKA_JMX_PORT: 9991
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
+ healthcheck:
+ test: kafka-broker-api-versions --bootstrap-server localhost:9092
+ interval: 10s
+ timeout: 10s
+ retries: 5
+ networks:
+ - yape-network
+
+ kafka-ui:
+ image: provectuslabs/kafka-ui:latest
+ container_name: yape-kafka-ui
ports:
- - 9092:9092
+ - "8090:8080"
+ environment:
+ KAFKA_CLUSTERS_0_NAME: local
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
+ depends_on:
+ - kafka
+ networks:
+ - yape-network
+
+volumes:
+ postgres_data:
+ redis_data:
+
+networks:
+ yape-network:
+ driver: bridge
diff --git a/init.sql b/init.sql
new file mode 100644
index 0000000000..67cbadc177
--- /dev/null
+++ b/init.sql
@@ -0,0 +1,17 @@
+-- Transactions table
+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,
+ transfer_type_id INTEGER NOT NULL,
+ value NUMERIC(19, 4) NOT NULL,
+ status VARCHAR(50) NOT NULL DEFAULT 'pending',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ version BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_transactions_external_id ON transactions(transaction_external_id);
+CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(status);
+CREATE INDEX IF NOT EXISTS idx_transactions_created_at ON transactions(created_at);
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000000..c8d4164576
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,69 @@
+
+
+ 4.0.0
+
+ com.yape
+ yape-challenge
+ 1.0.0-SNAPSHOT
+ pom
+
+ Yape Code Challenge
+ Anti-Fraud Transaction System with Kafka
+
+
+ transaction-service
+ antifraud-service
+
+
+
+ 21
+ UTF-8
+ 3.8.1
+ 3.12.1
+ 3.2.5
+
+
+
+
+
+ io.quarkus.platform
+ quarkus-bom
+ ${quarkus.platform.version}
+ pom
+ import
+
+
+
+
+
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+ ${quarkus.platform.version}
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${compiler-plugin.version}
+
+ ${maven.compiler.release}
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ ${surefire-plugin.version}
+
+
+ org.jboss.logmanager.LogManager
+
+
+
+
+
+
+
diff --git a/transaction-service/pom.xml b/transaction-service/pom.xml
new file mode 100644
index 0000000000..c6fca2b6c5
--- /dev/null
+++ b/transaction-service/pom.xml
@@ -0,0 +1,130 @@
+
+
+ 4.0.0
+
+
+ com.yape
+ yape-challenge
+ 1.0.0-SNAPSHOT
+
+
+ transaction-service
+ Transaction Service
+ Microservice for handling financial transactions
+
+
+
+ io.quarkus
+ quarkus-arc
+
+
+ io.quarkus
+ quarkus-resteasy-reactive-jackson
+
+
+
+ io.quarkus
+ quarkus-hibernate-orm-panache
+
+
+ io.quarkus
+ quarkus-jdbc-postgresql
+
+
+
+ io.quarkus
+ quarkus-smallrye-reactive-messaging-kafka
+
+
+
+ io.quarkus
+ quarkus-redis-client
+
+
+
+ io.quarkus
+ quarkus-hibernate-validator
+
+
+
+ io.quarkus
+ quarkus-smallrye-health
+
+
+
+ io.quarkus
+ quarkus-smallrye-openapi
+
+
+
+ io.quarkus
+ quarkus-smallrye-fault-tolerance
+
+
+
+ io.quarkus
+ quarkus-flyway
+
+
+
+
+ io.quarkus
+ quarkus-junit5
+ test
+
+
+ io.rest-assured
+ rest-assured
+ test
+
+
+ io.quarkus
+ quarkus-junit5-mockito
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
+
+ io.quarkus
+ quarkus-jdbc-h2
+ test
+
+
+ io.smallrye.reactive
+ smallrye-reactive-messaging-in-memory
+ test
+
+
+
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+ true
+
+
+
+ build
+ generate-code
+ generate-code-tests
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+
+
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..40416ee644
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java
@@ -0,0 +1,113 @@
+package com.yape.transaction.domain.model;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public class Transaction {
+
+ private UUID transactionExternalId;
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private Integer transferTypeId;
+ private BigDecimal value;
+ private TransactionStatus status;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ public Transaction() {
+ this.transactionExternalId = UUID.randomUUID();
+ this.status = TransactionStatus.PENDING;
+ this.createdAt = LocalDateTime.now();
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public Transaction(UUID accountExternalIdDebit, UUID accountExternalIdCredit,
+ Integer transferTypeId, BigDecimal value) {
+ this();
+ this.accountExternalIdDebit = accountExternalIdDebit;
+ this.accountExternalIdCredit = accountExternalIdCredit;
+ this.transferTypeId = transferTypeId;
+ this.value = value;
+ }
+
+ // Getters and Setters
+ 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 Integer getTransferTypeId() {
+ return transferTypeId;
+ }
+
+ public void setTransferTypeId(Integer transferTypeId) {
+ this.transferTypeId = transferTypeId;
+ }
+
+ public BigDecimal getValue() {
+ return value;
+ }
+
+ public void setValue(BigDecimal value) {
+ this.value = value;
+ }
+
+ public TransactionStatus getStatus() {
+ return status;
+ }
+
+ public void setStatus(TransactionStatus status) {
+ this.status = status;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(LocalDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
+ public void approve() {
+ this.status = TransactionStatus.APPROVED;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public void reject() {
+ this.status = TransactionStatus.REJECTED;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public boolean isPending() {
+ return this.status == TransactionStatus.PENDING;
+ }
+}
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..37ce3b382f
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionStatus.java
@@ -0,0 +1,29 @@
+package com.yape.transaction.domain.model;
+
+/**
+ * Enum representing the possible states of a transaction.
+ */
+public enum TransactionStatus {
+ PENDING("pending"),
+ APPROVED("approved"),
+ REJECTED("rejected");
+
+ private final String name;
+
+ TransactionStatus(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public static TransactionStatus fromString(String status) {
+ for (TransactionStatus s : TransactionStatus.values()) {
+ if (s.name.equalsIgnoreCase(status) || s.name().equalsIgnoreCase(status)) {
+ return s;
+ }
+ }
+ throw new IllegalArgumentException("Unknown status: " + status);
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/model/TransferType.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransferType.java
new file mode 100644
index 0000000000..7893584bc8
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransferType.java
@@ -0,0 +1,37 @@
+package com.yape.transaction.domain.model;
+
+public enum TransferType {
+ TRANSFER(1, "Transfer"),
+ PAYMENT(2, "Payment"),
+ DEPOSIT(3, "Deposit"),
+ WITHDRAWAL(4, "Withdrawal");
+
+ private final int id;
+ private final String name;
+
+ TransferType(int id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public static TransferType fromId(int id) {
+ for (TransferType type : TransferType.values()) {
+ if (type.id == id) {
+ return type;
+ }
+ }
+ throw new IllegalArgumentException("Unknown transfer type id: " + id);
+ }
+
+ public static String getNameById(int id) {
+ return fromId(id).getName();
+ }
+}
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..82f1e3782c
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionUseCase.java
@@ -0,0 +1,21 @@
+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 {
+
+ /**
+ * Creates a new transaction with pending status.
+ *
+ * @param accountExternalIdDebit the debit account ID
+ * @param accountExternalIdCredit the credit account ID
+ * @param transferTypeId the type of transfer
+ * @param value the transaction value
+ * @return the created transaction
+ */
+ Transaction createTransaction(UUID accountExternalIdDebit, UUID accountExternalIdCredit,
+ Integer transferTypeId, BigDecimal value);
+}
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..9b820cf01b
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/GetTransactionUseCase.java
@@ -0,0 +1,16 @@
+package com.yape.transaction.domain.port.in;
+
+import com.yape.transaction.domain.model.Transaction;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface GetTransactionUseCase {
+
+ /**
+ * Retrieves a transaction by its external ID.
+ *
+ * @param transactionExternalId the external ID of the transaction
+ * @return an Optional containing the transaction if found
+ */
+ Optional getTransaction(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..1c091eb8f9
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/UpdateTransactionStatusUseCase.java
@@ -0,0 +1,15 @@
+package com.yape.transaction.domain.port.in;
+
+import com.yape.transaction.domain.model.TransactionStatus;
+import java.util.UUID;
+
+public interface UpdateTransactionStatusUseCase {
+
+ /**
+ * Updates the status of a transaction.
+ *
+ * @param transactionExternalId the external ID of the transaction
+ * @param status the new status
+ */
+ void updateStatus(UUID transactionExternalId, TransactionStatus status);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionCachePort.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionCachePort.java
new file mode 100644
index 0000000000..c580d59358
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionCachePort.java
@@ -0,0 +1,30 @@
+package com.yape.transaction.domain.port.out;
+
+import com.yape.transaction.domain.model.Transaction;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface TransactionCachePort {
+
+ /**
+ * Caches a transaction.
+ *
+ * @param transaction the transaction to cache
+ */
+ void put(Transaction transaction);
+
+ /**
+ * Retrieves a cached transaction.
+ *
+ * @param transactionExternalId the external ID
+ * @return an Optional containing the cached transaction if found
+ */
+ Optional get(UUID transactionExternalId);
+
+ /**
+ * Invalidates a cached transaction.
+ *
+ * @param transactionExternalId the external ID to invalidate
+ */
+ void invalidate(UUID transactionExternalId);
+}
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..6fea6087fa
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionEventPublisher.java
@@ -0,0 +1,13 @@
+package com.yape.transaction.domain.port.out;
+
+import com.yape.transaction.domain.model.Transaction;
+
+public interface TransactionEventPublisher {
+
+ /**
+ * Publishes a transaction created event.
+ *
+ * @param transaction the created transaction
+ */
+ void publishTransactionCreated(Transaction transaction);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepositoryPort.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepositoryPort.java
new file mode 100644
index 0000000000..a5869f1eb8
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepositoryPort.java
@@ -0,0 +1,32 @@
+package com.yape.transaction.domain.port.out;
+
+import com.yape.transaction.domain.model.Transaction;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface TransactionRepositoryPort {
+
+ /**
+ * Saves a transaction to the repository.
+ *
+ * @param transaction the transaction to save
+ * @return the saved transaction
+ */
+ Transaction save(Transaction transaction);
+
+ /**
+ * Finds a transaction by its external ID.
+ *
+ * @param transactionExternalId the external ID
+ * @return an Optional containing the transaction if found
+ */
+ Optional findByExternalId(UUID transactionExternalId);
+
+ /**
+ * Updates an existing transaction.
+ *
+ * @param transaction the transaction to update
+ * @return the updated transaction
+ */
+ Transaction update(Transaction transaction);
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/service/TransactionService.java b/transaction-service/src/main/java/com/yape/transaction/domain/service/TransactionService.java
new file mode 100644
index 0000000000..12c1833333
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/domain/service/TransactionService.java
@@ -0,0 +1,110 @@
+package com.yape.transaction.domain.service;
+
+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.in.GetTransactionUseCase;
+import com.yape.transaction.domain.port.in.UpdateTransactionStatusUseCase;
+import com.yape.transaction.domain.port.out.TransactionCachePort;
+import com.yape.transaction.domain.port.out.TransactionEventPublisher;
+import com.yape.transaction.domain.port.out.TransactionRepositoryPort;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.transaction.Transactional;
+import org.jboss.logging.Logger;
+
+import java.math.BigDecimal;
+import java.util.Optional;
+import java.util.UUID;
+
+@ApplicationScoped
+public class TransactionService implements CreateTransactionUseCase,
+ GetTransactionUseCase,
+ UpdateTransactionStatusUseCase {
+
+ private static final Logger LOG = Logger.getLogger(TransactionService.class);
+
+ private final TransactionRepositoryPort repository;
+ private final TransactionEventPublisher eventPublisher;
+ private final TransactionCachePort cache;
+
+ @Inject
+ public TransactionService(TransactionRepositoryPort repository,
+ TransactionEventPublisher eventPublisher,
+ TransactionCachePort cache) {
+ this.repository = repository;
+ this.eventPublisher = eventPublisher;
+ this.cache = cache;
+ }
+
+ @Override
+ @Transactional
+ public Transaction createTransaction(UUID accountExternalIdDebit, UUID accountExternalIdCredit,
+ Integer transferTypeId, BigDecimal value) {
+ LOG.infof("Creating transaction: debit=%s, credit=%s, type=%d, value=%s",
+ accountExternalIdDebit, accountExternalIdCredit, transferTypeId, value);
+
+ Transaction transaction = new Transaction(
+ accountExternalIdDebit,
+ accountExternalIdCredit,
+ transferTypeId,
+ value
+ );
+
+ Transaction savedTransaction = repository.save(transaction);
+ LOG.infof("Transaction saved with ID: %s", savedTransaction.getTransactionExternalId());
+
+ cache.put(savedTransaction);
+
+ eventPublisher.publishTransactionCreated(savedTransaction);
+ LOG.infof("Transaction created event published for ID: %s", savedTransaction.getTransactionExternalId());
+
+ return savedTransaction;
+ }
+
+ @Override
+ public Optional getTransaction(UUID transactionExternalId) {
+ LOG.infof("Retrieving transaction: %s", transactionExternalId);
+
+ Optional cachedTransaction = cache.get(transactionExternalId);
+ if (cachedTransaction.isPresent()) {
+ LOG.infof("Transaction found in cache: %s", transactionExternalId);
+ return cachedTransaction;
+ }
+
+ Optional transaction = repository.findByExternalId(transactionExternalId);
+
+ transaction.ifPresent(cache::put);
+
+ return transaction;
+ }
+
+ @Override
+ @Transactional
+ public void updateStatus(UUID transactionExternalId, TransactionStatus status) {
+ LOG.infof("Updating transaction status: %s -> %s", transactionExternalId, status);
+
+ Optional transactionOpt = repository.findByExternalId(transactionExternalId);
+
+ if (transactionOpt.isEmpty()) {
+ LOG.warnf("Transaction not found: %s", transactionExternalId);
+ return;
+ }
+
+ Transaction transaction = transactionOpt.get();
+
+ if (!transaction.isPending()) {
+ LOG.warnf("Transaction %s is not pending, current status: %s",
+ transactionExternalId, transaction.getStatus());
+ return;
+ }
+
+ transaction.setStatus(status);
+ repository.update(transaction);
+
+ cache.invalidate(transactionExternalId);
+ cache.put(transaction);
+
+ LOG.infof("Transaction %s status updated to: %s", transactionExternalId, status);
+ }
+}
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..28db96dce0
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusConsumer.java
@@ -0,0 +1,64 @@
+package com.yape.transaction.infrastructure.adapter.in.messaging;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yape.transaction.domain.model.TransactionStatus;
+import com.yape.transaction.domain.port.in.UpdateTransactionStatusUseCase;
+import com.yape.transaction.infrastructure.adapter.out.cache.IdempotencyService;
+import io.smallrye.common.annotation.Blocking;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.reactive.messaging.Incoming;
+import org.jboss.logging.Logger;
+
+@ApplicationScoped
+public class TransactionStatusConsumer {
+
+ private static final Logger LOG = Logger.getLogger(TransactionStatusConsumer.class);
+
+ private final UpdateTransactionStatusUseCase updateStatusUseCase;
+ private final IdempotencyService idempotencyService;
+ private ObjectMapper objectMapper;
+
+ @Inject
+ public TransactionStatusConsumer(UpdateTransactionStatusUseCase updateStatusUseCase,
+ IdempotencyService idempotencyService) {
+ this.updateStatusUseCase = updateStatusUseCase;
+ this.idempotencyService = idempotencyService;
+ }
+
+ @PostConstruct
+ void init() {
+ this.objectMapper = new ObjectMapper();
+ }
+
+ @Incoming("transaction-status-in")
+ @Blocking
+ public void consume(String message) {
+ LOG.infof("Received raw status message: %s", message);
+
+ try {
+ TransactionStatusEvent event = objectMapper.readValue(message, TransactionStatusEvent.class);
+
+ LOG.infof("Parsed transaction status event: %s -> %s",
+ event.getTransactionExternalId(), event.getStatus());
+
+ String idempotencyKey = "status:" + event.getTransactionExternalId() + ":" + event.getStatus();
+ if (idempotencyService.isProcessed(idempotencyKey)) {
+ LOG.warnf("Duplicate event detected, skipping: %s", idempotencyKey);
+ return;
+ }
+
+ TransactionStatus status = TransactionStatus.fromString(event.getStatus());
+ updateStatusUseCase.updateStatus(event.getTransactionExternalId(), status);
+
+ idempotencyService.markAsProcessed(idempotencyKey);
+
+ LOG.infof("Transaction status updated successfully: %s -> %s",
+ event.getTransactionExternalId(), status);
+
+ } catch (Exception e) {
+ LOG.errorf(e, "Error processing status message: %s", message);
+ }
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusEvent.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusEvent.java
new file mode 100644
index 0000000000..0d54b9f5db
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/messaging/TransactionStatusEvent.java
@@ -0,0 +1,33 @@
+package com.yape.transaction.infrastructure.adapter.in.messaging;
+
+import java.util.UUID;
+
+public class TransactionStatusEvent {
+
+ private UUID transactionExternalId;
+ private String status;
+
+ public TransactionStatusEvent() {
+ }
+
+ public TransactionStatusEvent(UUID transactionExternalId, String status) {
+ this.transactionExternalId = transactionExternalId;
+ this.status = status;
+ }
+
+ public UUID getTransactionExternalId() {
+ return transactionExternalId;
+ }
+
+ public void setTransactionExternalId(UUID transactionExternalId) {
+ this.transactionExternalId = transactionExternalId;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResource.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResource.java
new file mode 100644
index 0000000000..5ba0bf8dfa
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResource.java
@@ -0,0 +1,117 @@
+package com.yape.transaction.infrastructure.adapter.in.rest;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.in.CreateTransactionUseCase;
+import com.yape.transaction.domain.port.in.GetTransactionUseCase;
+import com.yape.transaction.infrastructure.adapter.in.rest.dto.CreateTransactionRequest;
+import com.yape.transaction.infrastructure.adapter.in.rest.dto.TransactionResponse;
+import jakarta.inject.Inject;
+import jakarta.validation.Valid;
+import jakarta.ws.rs.*;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.eclipse.microprofile.openapi.annotations.Operation;
+import org.eclipse.microprofile.openapi.annotations.media.Content;
+import org.eclipse.microprofile.openapi.annotations.media.Schema;
+import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
+import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
+import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
+import org.eclipse.microprofile.openapi.annotations.tags.Tag;
+import org.jboss.logging.Logger;
+
+import java.util.Optional;
+import java.util.UUID;
+
+@Path("/transactions")
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+@Tag(name = "Transaction", description = "Transaction management operations")
+public class TransactionResource {
+
+ private static final Logger LOG = Logger.getLogger(TransactionResource.class);
+
+ private final CreateTransactionUseCase createTransactionUseCase;
+ private final GetTransactionUseCase getTransactionUseCase;
+ private final TransactionResponseMapper mapper;
+
+ @Inject
+ public TransactionResource(CreateTransactionUseCase createTransactionUseCase,
+ GetTransactionUseCase getTransactionUseCase,
+ TransactionResponseMapper mapper) {
+ this.createTransactionUseCase = createTransactionUseCase;
+ this.getTransactionUseCase = getTransactionUseCase;
+ this.mapper = mapper;
+ }
+
+ @POST
+ @Operation(summary = "Create a new transaction",
+ description = "Creates a new financial transaction with pending status")
+ @APIResponses({
+ @APIResponse(responseCode = "201", description = "Transaction created successfully",
+ content = @Content(schema = @Schema(implementation = TransactionResponse.class))),
+ @APIResponse(responseCode = "400", description = "Invalid request data")
+ })
+ public Response createTransaction(@Valid CreateTransactionRequest request) {
+ LOG.infof("Creating transaction: debit=%s, credit=%s, value=%s",
+ request.getAccountExternalIdDebit(),
+ request.getAccountExternalIdCredit(),
+ request.getValue());
+
+ Transaction transaction = createTransactionUseCase.createTransaction(
+ request.getAccountExternalIdDebit(),
+ request.getAccountExternalIdCredit(),
+ request.getTranferTypeId(),
+ request.getValue()
+ );
+
+ TransactionResponse response = mapper.toResponse(transaction);
+
+ LOG.infof("Transaction created: %s", transaction.getTransactionExternalId());
+
+ return Response.status(Response.Status.CREATED).entity(response).build();
+ }
+
+ @GET
+ @Path("/{transactionExternalId}")
+ @Operation(summary = "Get transaction by ID",
+ description = "Retrieves a transaction by its external ID")
+ @APIResponses({
+ @APIResponse(responseCode = "200", description = "Transaction found",
+ content = @Content(schema = @Schema(implementation = TransactionResponse.class))),
+ @APIResponse(responseCode = "404", description = "Transaction not found")
+ })
+ public Response getTransaction(
+ @Parameter(description = "Transaction external ID", required = true)
+ @PathParam("transactionExternalId") UUID transactionExternalId) {
+
+ LOG.infof("Retrieving transaction: %s", transactionExternalId);
+
+ Optional transactionOpt = getTransactionUseCase.getTransaction(transactionExternalId);
+
+ if (transactionOpt.isEmpty()) {
+ LOG.warnf("Transaction not found: %s", transactionExternalId);
+ return Response.status(Response.Status.NOT_FOUND)
+ .entity(new ErrorResponse("Transaction not found"))
+ .build();
+ }
+
+ TransactionResponse response = mapper.toResponse(transactionOpt.get());
+ return Response.ok(response).build();
+ }
+
+ public static class ErrorResponse {
+ private String message;
+
+ public ErrorResponse(String message) {
+ this.message = message;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResponseMapper.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResponseMapper.java
new file mode 100644
index 0000000000..4f69689a17
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResponseMapper.java
@@ -0,0 +1,32 @@
+package com.yape.transaction.infrastructure.adapter.in.rest;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.model.TransferType;
+import com.yape.transaction.infrastructure.adapter.in.rest.dto.TransactionResponse;
+import com.yape.transaction.infrastructure.adapter.in.rest.dto.TransactionResponse.TransactionStatusDto;
+import com.yape.transaction.infrastructure.adapter.in.rest.dto.TransactionResponse.TransactionTypeDto;
+import jakarta.enterprise.context.ApplicationScoped;
+
+@ApplicationScoped
+public class TransactionResponseMapper {
+
+ public TransactionResponse toResponse(Transaction transaction) {
+ String typeName = getTransferTypeName(transaction.getTransferTypeId());
+
+ return new TransactionResponse(
+ transaction.getTransactionExternalId(),
+ new TransactionTypeDto(typeName),
+ new TransactionStatusDto(transaction.getStatus().getName()),
+ transaction.getValue(),
+ transaction.getCreatedAt()
+ );
+ }
+
+ private String getTransferTypeName(Integer transferTypeId) {
+ try {
+ return TransferType.fromId(transferTypeId).getName();
+ } catch (IllegalArgumentException e) {
+ return "Unknown";
+ }
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/dto/CreateTransactionRequest.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/dto/CreateTransactionRequest.java
new file mode 100644
index 0000000000..69a9563bd2
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/dto/CreateTransactionRequest.java
@@ -0,0 +1,57 @@
+package com.yape.transaction.infrastructure.adapter.in.rest.dto;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public class CreateTransactionRequest {
+
+ @NotNull(message = "accountExternalIdDebit is required")
+ private UUID accountExternalIdDebit;
+
+ @NotNull(message = "accountExternalIdCredit is required")
+ private UUID accountExternalIdCredit;
+
+ @NotNull(message = "tranferTypeId is required")
+ private Integer tranferTypeId;
+
+ @NotNull(message = "value is required")
+ @Positive(message = "value must be positive")
+ private BigDecimal value;
+
+ public CreateTransactionRequest() {
+ }
+
+ 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 Integer getTranferTypeId() {
+ return tranferTypeId;
+ }
+
+ public void setTranferTypeId(Integer tranferTypeId) {
+ this.tranferTypeId = tranferTypeId;
+ }
+
+ public BigDecimal getValue() {
+ return value;
+ }
+
+ public void setValue(BigDecimal value) {
+ this.value = value;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/dto/TransactionResponse.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/dto/TransactionResponse.java
new file mode 100644
index 0000000000..538253a2aa
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/in/rest/dto/TransactionResponse.java
@@ -0,0 +1,105 @@
+package com.yape.transaction.infrastructure.adapter.in.rest.dto;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public class TransactionResponse {
+
+ private UUID transactionExternalId;
+ private TransactionTypeDto transactionType;
+ private TransactionStatusDto transactionStatus;
+ private BigDecimal value;
+ private LocalDateTime createdAt;
+
+ public TransactionResponse() {
+ }
+
+ public TransactionResponse(UUID transactionExternalId, TransactionTypeDto transactionType,
+ TransactionStatusDto transactionStatus, BigDecimal value,
+ LocalDateTime createdAt) {
+ this.transactionExternalId = transactionExternalId;
+ this.transactionType = transactionType;
+ this.transactionStatus = transactionStatus;
+ this.value = value;
+ this.createdAt = createdAt;
+ }
+
+ public UUID getTransactionExternalId() {
+ return transactionExternalId;
+ }
+
+ public void setTransactionExternalId(UUID transactionExternalId) {
+ this.transactionExternalId = transactionExternalId;
+ }
+
+ public TransactionTypeDto getTransactionType() {
+ return transactionType;
+ }
+
+ public void setTransactionType(TransactionTypeDto transactionType) {
+ this.transactionType = transactionType;
+ }
+
+ public TransactionStatusDto getTransactionStatus() {
+ return transactionStatus;
+ }
+
+ public void setTransactionStatus(TransactionStatusDto 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;
+ }
+
+ public static class TransactionTypeDto {
+ private String name;
+
+ public TransactionTypeDto() {
+ }
+
+ public TransactionTypeDto(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+
+ public static class TransactionStatusDto {
+ private String name;
+
+ public TransactionStatusDto() {
+ }
+
+ public TransactionStatusDto(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/cache/IdempotencyService.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/cache/IdempotencyService.java
new file mode 100644
index 0000000000..1efc06db47
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/cache/IdempotencyService.java
@@ -0,0 +1,54 @@
+package com.yape.transaction.infrastructure.adapter.out.cache;
+
+import io.quarkus.redis.datasource.RedisDataSource;
+import io.quarkus.redis.datasource.value.ValueCommands;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.jboss.logging.Logger;
+
+import java.time.Duration;
+
+@ApplicationScoped
+public class IdempotencyService {
+
+ private static final Logger LOG = Logger.getLogger(IdempotencyService.class);
+ private static final String IDEMPOTENCY_PREFIX = "idempotent:";
+ private static final Duration IDEMPOTENCY_TTL = Duration.ofHours(24);
+
+ private final RedisDataSource redisDataSource;
+ private ValueCommands valueCommands;
+
+ @Inject
+ public IdempotencyService(RedisDataSource redisDataSource) {
+ this.redisDataSource = redisDataSource;
+ }
+
+ @PostConstruct
+ void init() {
+ this.valueCommands = redisDataSource.value(String.class, String.class);
+ }
+
+ public boolean isProcessed(String eventKey) {
+ String key = IDEMPOTENCY_PREFIX + eventKey;
+ String value = valueCommands.get(key);
+ return value != null;
+ }
+
+ public void markAsProcessed(String eventKey) {
+ String key = IDEMPOTENCY_PREFIX + eventKey;
+ valueCommands.setex(key, IDEMPOTENCY_TTL.toSeconds(), "1");
+ LOG.debugf("Event marked as processed: %s", key);
+ }
+
+ public boolean tryAcquireLock(String eventKey) {
+ String key = IDEMPOTENCY_PREFIX + eventKey;
+ boolean acquired = valueCommands.setnx(key, "processing");
+ if(acquired){
+ redisDataSource.key().expire(key, IDEMPOTENCY_TTL.toSeconds());
+ }
+
+ LOG.debugf("Lock acquisition for %s: %s", key, acquired);
+ return acquired;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/cache/RedisCacheAdapter.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/cache/RedisCacheAdapter.java
new file mode 100644
index 0000000000..ccc794a0c7
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/cache/RedisCacheAdapter.java
@@ -0,0 +1,81 @@
+package com.yape.transaction.infrastructure.adapter.out.cache;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.out.TransactionCachePort;
+import io.quarkus.redis.datasource.RedisDataSource;
+import io.quarkus.redis.datasource.value.ValueCommands;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.jboss.logging.Logger;
+
+import java.time.Duration;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Redis adapter for caching transactions.
+ */
+@ApplicationScoped
+public class RedisCacheAdapter implements TransactionCachePort {
+
+ private static final Logger LOG = Logger.getLogger(RedisCacheAdapter.class);
+ private static final String CACHE_PREFIX = "transaction:";
+ private static final Duration CACHE_TTL = Duration.ofMinutes(30);
+
+ private final RedisDataSource redisDataSource;
+ private ValueCommands valueCommands;
+ private ObjectMapper objectMapper;
+
+ @Inject
+ public RedisCacheAdapter(RedisDataSource redisDataSource) {
+ this.redisDataSource = redisDataSource;
+ }
+
+ @PostConstruct
+ void init() {
+ this.valueCommands = redisDataSource.value(String.class, String.class);
+ this.objectMapper = new ObjectMapper();
+ this.objectMapper.registerModule(new JavaTimeModule());
+ }
+
+ @Override
+ public void put(Transaction transaction) {
+ String key = CACHE_PREFIX + transaction.getTransactionExternalId();
+ try {
+ String json = objectMapper.writeValueAsString(transaction);
+ valueCommands.setex(key, CACHE_TTL.toSeconds(), json);
+ LOG.debugf("Transaction cached: %s", key);
+ } catch (JsonProcessingException e) {
+ LOG.errorf(e, "Error serializing transaction for cache: %s", key);
+ }
+ }
+
+ @Override
+ public Optional get(UUID transactionExternalId) {
+ String key = CACHE_PREFIX + transactionExternalId;
+ try {
+ String json = valueCommands.get(key);
+ if (json == null) {
+ LOG.debugf("Cache miss: %s", key);
+ return Optional.empty();
+ }
+ Transaction transaction = objectMapper.readValue(json, Transaction.class);
+ LOG.debugf("Cache hit: %s", key);
+ return Optional.of(transaction);
+ } catch (JsonProcessingException e) {
+ LOG.errorf(e, "Error deserializing transaction from cache: %s", key);
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public void invalidate(UUID transactionExternalId) {
+ String key = CACHE_PREFIX + transactionExternalId;
+ redisDataSource.key().del(key);
+ LOG.debugf("Cache invalidated: %s", key);
+ }
+}
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..bc4d595e31
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/messaging/KafkaTransactionEventPublisher.java
@@ -0,0 +1,41 @@
+package com.yape.transaction.infrastructure.adapter.out.messaging;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.out.TransactionEventPublisher;
+import io.smallrye.reactive.messaging.kafka.Record;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.reactive.messaging.Channel;
+import org.eclipse.microprofile.reactive.messaging.Emitter;
+import org.jboss.logging.Logger;
+
+@ApplicationScoped
+public class KafkaTransactionEventPublisher implements TransactionEventPublisher {
+
+ private static final Logger LOG = Logger.getLogger(KafkaTransactionEventPublisher.class);
+
+ @Inject
+ @Channel("transaction-created-out")
+ Emitter> emitter;
+
+ @Override
+ public void publishTransactionCreated(Transaction transaction) {
+ LOG.infof("Publishing transaction created event: %s", transaction.getTransactionExternalId());
+
+ TransactionCreatedEvent event = new TransactionCreatedEvent(
+ transaction.getTransactionExternalId(),
+ transaction.getAccountExternalIdDebit(),
+ transaction.getAccountExternalIdCredit(),
+ transaction.getTransferTypeId(),
+ transaction.getValue(),
+ transaction.getStatus().getName(),
+ transaction.getCreatedAt()
+ );
+
+ String key = transaction.getTransactionExternalId().toString();
+
+ emitter.send(Record.of(key, event));
+
+ LOG.infof("Transaction created event published successfully: %s", 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..8478cde5c5
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/messaging/TransactionCreatedEvent.java
@@ -0,0 +1,91 @@
+package com.yape.transaction.infrastructure.adapter.out.messaging;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * Event DTO for transaction created events sent to Kafka.
+ */
+public class TransactionCreatedEvent {
+
+ private UUID transactionExternalId;
+ private UUID accountExternalIdDebit;
+ private UUID accountExternalIdCredit;
+ private Integer transferTypeId;
+ private BigDecimal value;
+ private String status;
+ private LocalDateTime createdAt;
+
+ public TransactionCreatedEvent() {
+ }
+
+ public TransactionCreatedEvent(UUID transactionExternalId, UUID accountExternalIdDebit,
+ UUID accountExternalIdCredit, Integer transferTypeId,
+ BigDecimal value, String status, LocalDateTime createdAt) {
+ this.transactionExternalId = transactionExternalId;
+ this.accountExternalIdDebit = accountExternalIdDebit;
+ this.accountExternalIdCredit = accountExternalIdCredit;
+ this.transferTypeId = transferTypeId;
+ this.value = value;
+ this.status = status;
+ this.createdAt = createdAt;
+ }
+
+ // Getters and Setters
+ 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 Integer getTransferTypeId() {
+ return transferTypeId;
+ }
+
+ public void setTransferTypeId(Integer transferTypeId) {
+ this.transferTypeId = transferTypeId;
+ }
+
+ public BigDecimal getValue() {
+ return value;
+ }
+
+ public void setValue(BigDecimal value) {
+ this.value = value;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ 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/TransactionEntity.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionEntity.java
new file mode 100644
index 0000000000..014c3e0328
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionEntity.java
@@ -0,0 +1,132 @@
+package com.yape.transaction.infrastructure.adapter.out.persistence;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import jakarta.persistence.*;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(name = "transactions")
+public class TransactionEntity extends PanacheEntityBase {
+
+ @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 = "transfer_type_id", nullable = false)
+ private Integer transferTypeId;
+
+ @Column(name = "value", nullable = false, precision = 19, scale = 4)
+ private BigDecimal value;
+
+ @Column(name = "status", nullable = false)
+ private String status;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @Version
+ private Long version;
+
+ public TransactionEntity() {
+ }
+
+ // Panache query methods
+ public static TransactionEntity findByExternalId(UUID externalId) {
+ return find("transactionExternalId", externalId).firstResult();
+ }
+
+ // Getters and 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 Integer getTransferTypeId() {
+ return transferTypeId;
+ }
+
+ public void setTransferTypeId(Integer transferTypeId) {
+ this.transferTypeId = transferTypeId;
+ }
+
+ public BigDecimal getValue() {
+ return value;
+ }
+
+ public void setValue(BigDecimal value) {
+ this.value = value;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(LocalDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
+ public Long getVersion() {
+ return version;
+ }
+
+ public void setVersion(Long version) {
+ this.version = version;
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionMapper.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionMapper.java
new file mode 100644
index 0000000000..9ee5a03114
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionMapper.java
@@ -0,0 +1,41 @@
+package com.yape.transaction.infrastructure.adapter.out.persistence;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.model.TransactionStatus;
+import jakarta.enterprise.context.ApplicationScoped;
+
+
+@ApplicationScoped
+public class TransactionMapper {
+
+ public TransactionEntity toEntity(Transaction domain) {
+ TransactionEntity entity = new TransactionEntity();
+ entity.setTransactionExternalId(domain.getTransactionExternalId());
+ entity.setAccountExternalIdDebit(domain.getAccountExternalIdDebit());
+ entity.setAccountExternalIdCredit(domain.getAccountExternalIdCredit());
+ entity.setTransferTypeId(domain.getTransferTypeId());
+ entity.setValue(domain.getValue());
+ entity.setStatus(domain.getStatus().getName());
+ entity.setCreatedAt(domain.getCreatedAt());
+ entity.setUpdatedAt(domain.getUpdatedAt());
+ return entity;
+ }
+
+ public Transaction toDomain(TransactionEntity entity) {
+ Transaction domain = new Transaction();
+ domain.setTransactionExternalId(entity.getTransactionExternalId());
+ domain.setAccountExternalIdDebit(entity.getAccountExternalIdDebit());
+ domain.setAccountExternalIdCredit(entity.getAccountExternalIdCredit());
+ domain.setTransferTypeId(entity.getTransferTypeId());
+ domain.setValue(entity.getValue());
+ domain.setStatus(TransactionStatus.fromString(entity.getStatus()));
+ domain.setCreatedAt(entity.getCreatedAt());
+ domain.setUpdatedAt(entity.getUpdatedAt());
+ return domain;
+ }
+
+ public void updateEntity(TransactionEntity entity, Transaction domain) {
+ entity.setStatus(domain.getStatus().getName());
+ entity.setUpdatedAt(domain.getUpdatedAt());
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionRepositoryAdapter.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionRepositoryAdapter.java
new file mode 100644
index 0000000000..6a23a1e4c7
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionRepositoryAdapter.java
@@ -0,0 +1,66 @@
+package com.yape.transaction.infrastructure.adapter.out.persistence;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.port.out.TransactionRepositoryPort;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.jboss.logging.Logger;
+
+import java.util.Optional;
+import java.util.UUID;
+
+
+@ApplicationScoped
+public class TransactionRepositoryAdapter implements TransactionRepositoryPort {
+
+ private static final Logger LOG = Logger.getLogger(TransactionRepositoryAdapter.class);
+
+ private final TransactionMapper mapper;
+
+ @Inject
+ public TransactionRepositoryAdapter(TransactionMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ public Transaction save(Transaction transaction) {
+ LOG.debugf("Saving transaction: %s", transaction.getTransactionExternalId());
+
+ TransactionEntity entity = mapper.toEntity(transaction);
+ entity.persist();
+
+ LOG.debugf("Transaction persisted with ID: %d", entity.getId());
+ return mapper.toDomain(entity);
+ }
+
+ @Override
+ public Optional findByExternalId(UUID transactionExternalId) {
+ LOG.debugf("Finding transaction by external ID: %s", transactionExternalId);
+
+ TransactionEntity entity = TransactionEntity.findByExternalId(transactionExternalId);
+
+ if (entity == null) {
+ LOG.debugf("Transaction not found: %s", transactionExternalId);
+ return Optional.empty();
+ }
+
+ return Optional.of(mapper.toDomain(entity));
+ }
+
+ @Override
+ public Transaction update(Transaction transaction) {
+ LOG.debugf("Updating transaction: %s", transaction.getTransactionExternalId());
+
+ TransactionEntity entity = TransactionEntity.findByExternalId(transaction.getTransactionExternalId());
+
+ if (entity == null) {
+ throw new IllegalArgumentException("Transaction not found: " + transaction.getTransactionExternalId());
+ }
+
+ mapper.updateEntity(entity, transaction);
+ entity.persist();
+
+ LOG.debugf("Transaction updated: %s", transaction.getTransactionExternalId());
+ return mapper.toDomain(entity);
+ }
+}
diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/config/TransactionStatusEventDeserializer.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/config/TransactionStatusEventDeserializer.java
new file mode 100644
index 0000000000..6c7ddf22d3
--- /dev/null
+++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/config/TransactionStatusEventDeserializer.java
@@ -0,0 +1,14 @@
+package com.yape.transaction.infrastructure.config;
+
+import com.yape.transaction.infrastructure.adapter.in.messaging.TransactionStatusEvent;
+import io.quarkus.kafka.client.serialization.ObjectMapperDeserializer;
+
+/**
+ * Custom deserializer for TransactionStatusEvent.
+ */
+public class TransactionStatusEventDeserializer extends ObjectMapperDeserializer {
+
+ public TransactionStatusEventDeserializer() {
+ super(TransactionStatusEvent.class);
+ }
+}
diff --git a/transaction-service/src/main/resources/application.properties b/transaction-service/src/main/resources/application.properties
new file mode 100644
index 0000000000..e9328d6839
--- /dev/null
+++ b/transaction-service/src/main/resources/application.properties
@@ -0,0 +1,51 @@
+quarkus.application.name=transaction-service
+
+quarkus.http.port=8080
+
+# Database Configuration
+quarkus.datasource.db-kind=postgresql
+quarkus.datasource.username=postgres
+quarkus.datasource.password=postgres
+quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/transactions
+
+quarkus.hibernate-orm.database.generation=none
+quarkus.hibernate-orm.log.sql=true
+
+quarkus.flyway.migrate-at-start=true
+quarkus.flyway.baseline-on-migrate=true
+
+quarkus.redis.hosts=redis://localhost:6379
+
+# Kafka Configuration
+kafka.bootstrap.servers=localhost:9092
+
+# Outgoing channel - Transaction Created
+mp.messaging.outgoing.transaction-created-out.connector=smallrye-kafka
+mp.messaging.outgoing.transaction-created-out.topic=transaction-created
+mp.messaging.outgoing.transaction-created-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
+mp.messaging.outgoing.transaction-created-out.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer
+
+# Incoming channel - Transaction Status
+mp.messaging.incoming.transaction-status-in.connector=smallrye-kafka
+mp.messaging.incoming.transaction-status-in.topic=transaction-status
+mp.messaging.incoming.transaction-status-in.group.id=transaction-service
+mp.messaging.incoming.transaction-status-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
+#mp.messaging.incoming.transaction-status-in.value.deserializer=com.yape.transaction.infrastructure.config.TransactionStatusEventDeserializer
+mp.messaging.incoming.transaction-status-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
+mp.messaging.incoming.transaction-status-in.auto.offset.reset=earliest
+mp.messaging.incoming.transaction-status-in.failure-strategy=ignore
+
+quarkus.smallrye-openapi.path=/openapi
+quarkus.swagger-ui.always-include=true
+quarkus.swagger-ui.path=/swagger-ui
+
+quarkus.smallrye-health.root-path=/health
+
+# Logging
+quarkus.log.level=INFO
+quarkus.log.category."com.yape".level=DEBUG
+quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n
+
+%dev.quarkus.datasource.devservices.enabled=false
+%dev.quarkus.redis.devservices.enabled=false
+%dev.quarkus.kafka.devservices.enabled=false
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..dc1b79fdfc
--- /dev/null
+++ b/transaction-service/src/main/resources/db/migration/V1__Create_transactions_table.sql
@@ -0,0 +1,21 @@
+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,
+ transfer_type_id INTEGER NOT NULL,
+ value NUMERIC(19, 4) NOT NULL,
+ status VARCHAR(50) NOT NULL DEFAULT 'pending',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ version BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX idx_transactions_external_id ON transactions(transaction_external_id);
+CREATE INDEX idx_transactions_status ON transactions(status);
+CREATE INDEX idx_transactions_created_at ON transactions(created_at);
+
+COMMENT ON TABLE transactions IS 'Financial transactions table for Yape challenge';
+COMMENT ON COLUMN transactions.transaction_external_id IS 'Public UUID for external reference';
+COMMENT ON COLUMN transactions.status IS 'Transaction status: pending, approved, rejected';
+COMMENT ON COLUMN transactions.version IS 'Optimistic locking version';
diff --git a/transaction-service/src/main/resources/db/migration/V2__Create_transfer_types_table.sql b/transaction-service/src/main/resources/db/migration/V2__Create_transfer_types_table.sql
new file mode 100644
index 0000000000..4c4d282e64
--- /dev/null
+++ b/transaction-service/src/main/resources/db/migration/V2__Create_transfer_types_table.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS transfer_types (
+ id INTEGER PRIMARY KEY,
+ name VARCHAR(50) NOT NULL UNIQUE,
+ description VARCHAR(255),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+INSERT INTO transfer_types (id, name, description) VALUES
+ (1, 'Transfer', 'Standard bank transfer'),
+ (2, 'Payment', 'Payment for services or goods'),
+ (3, 'Deposit', 'Cash or check deposit'),
+ (4, 'Withdrawal', 'Cash withdrawal')
+ON CONFLICT (id) DO NOTHING;
+
diff --git a/transaction-service/src/test/java/com/yape/transaction/domain/model/TransactionStatusTest.java b/transaction-service/src/test/java/com/yape/transaction/domain/model/TransactionStatusTest.java
new file mode 100644
index 0000000000..27a24cbb36
--- /dev/null
+++ b/transaction-service/src/test/java/com/yape/transaction/domain/model/TransactionStatusTest.java
@@ -0,0 +1,40 @@
+package com.yape.transaction.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.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("TransactionStatus Enum Tests")
+class TransactionStatusTest {
+
+ @Test
+ @DisplayName("Should have correct names for statuses")
+ void shouldHaveCorrectNamesForStatuses() {
+ assertEquals("pending", TransactionStatus.PENDING.getName());
+ assertEquals("approved", TransactionStatus.APPROVED.getName());
+ assertEquals("rejected", TransactionStatus.REJECTED.getName());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "pending, PENDING",
+ "PENDING, PENDING",
+ "approved, APPROVED",
+ "APPROVED, APPROVED",
+ "rejected, REJECTED",
+ "REJECTED, REJECTED"
+ })
+ @DisplayName("Should parse status from string")
+ void shouldParseStatusFromString(String input, TransactionStatus expected) {
+ assertEquals(expected, TransactionStatus.fromString(input));
+ }
+
+ @Test
+ @DisplayName("Should throw exception for unknown status")
+ void shouldThrowExceptionForUnknownStatus() {
+ assertThrows(IllegalArgumentException.class, () -> TransactionStatus.fromString("unknown"));
+ }
+}
diff --git a/transaction-service/src/test/java/com/yape/transaction/domain/model/TransactionTest.java b/transaction-service/src/test/java/com/yape/transaction/domain/model/TransactionTest.java
new file mode 100644
index 0000000000..08c62e6655
--- /dev/null
+++ b/transaction-service/src/test/java/com/yape/transaction/domain/model/TransactionTest.java
@@ -0,0 +1,71 @@
+package com.yape.transaction.domain.model;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Transaction Domain Model Tests")
+class TransactionTest {
+
+ @Test
+ @DisplayName("Should create transaction with default values")
+ void shouldCreateTransactionWithDefaultValues() {
+ Transaction transaction = new Transaction();
+
+ assertNotNull(transaction.getTransactionExternalId());
+ assertEquals(TransactionStatus.PENDING, transaction.getStatus());
+ assertNotNull(transaction.getCreatedAt());
+ assertNotNull(transaction.getUpdatedAt());
+ }
+
+ @Test
+ @DisplayName("Should create transaction with constructor parameters")
+ void shouldCreateTransactionWithConstructorParameters() {
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ Integer transferTypeId = 1;
+ BigDecimal value = new BigDecimal("100.00");
+
+ Transaction transaction = new Transaction(debitAccount, creditAccount, transferTypeId, value);
+
+ assertEquals(debitAccount, transaction.getAccountExternalIdDebit());
+ assertEquals(creditAccount, transaction.getAccountExternalIdCredit());
+ assertEquals(transferTypeId, transaction.getTransferTypeId());
+ assertEquals(value, transaction.getValue());
+ assertEquals(TransactionStatus.PENDING, transaction.getStatus());
+ }
+
+ @Test
+ @DisplayName("Should approve transaction")
+ void shouldApproveTransaction() {
+ Transaction transaction = new Transaction();
+
+ transaction.approve();
+
+ assertEquals(TransactionStatus.APPROVED, transaction.getStatus());
+ assertFalse(transaction.isPending());
+ }
+
+ @Test
+ @DisplayName("Should reject transaction")
+ void shouldRejectTransaction() {
+ Transaction transaction = new Transaction();
+
+ transaction.reject();
+
+ assertEquals(TransactionStatus.REJECTED, transaction.getStatus());
+ assertFalse(transaction.isPending());
+ }
+
+ @Test
+ @DisplayName("Should identify pending transaction")
+ void shouldIdentifyPendingTransaction() {
+ Transaction transaction = new Transaction();
+
+ assertTrue(transaction.isPending());
+ }
+}
diff --git a/transaction-service/src/test/java/com/yape/transaction/domain/model/TransferTypeTest.java b/transaction-service/src/test/java/com/yape/transaction/domain/model/TransferTypeTest.java
new file mode 100644
index 0000000000..7b99c7c8fb
--- /dev/null
+++ b/transaction-service/src/test/java/com/yape/transaction/domain/model/TransferTypeTest.java
@@ -0,0 +1,50 @@
+package com.yape.transaction.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.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("TransferType Enum Tests")
+class TransferTypeTest {
+
+ @ParameterizedTest
+ @CsvSource({
+ "1, TRANSFER, Transfer",
+ "2, PAYMENT, Payment",
+ "3, DEPOSIT, Deposit",
+ "4, WITHDRAWAL, Withdrawal"
+ })
+ @DisplayName("Should have correct id and name for each type")
+ void shouldHaveCorrectIdAndNameForEachType(int id, TransferType type, String name) {
+ assertEquals(id, type.getId());
+ assertEquals(name, type.getName());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "1, TRANSFER",
+ "2, PAYMENT",
+ "3, DEPOSIT",
+ "4, WITHDRAWAL"
+ })
+ @DisplayName("Should get type from id")
+ void shouldGetTypeFromId(int id, TransferType expected) {
+ assertEquals(expected, TransferType.fromId(id));
+ }
+
+ @Test
+ @DisplayName("Should throw exception for unknown id")
+ void shouldThrowExceptionForUnknownId() {
+ assertThrows(IllegalArgumentException.class, () -> TransferType.fromId(999));
+ }
+
+ @Test
+ @DisplayName("Should get name by id")
+ void shouldGetNameById() {
+ assertEquals("Transfer", TransferType.getNameById(1));
+ assertEquals("Payment", TransferType.getNameById(2));
+ }
+}
diff --git a/transaction-service/src/test/java/com/yape/transaction/domain/service/TransactionServiceTest.java b/transaction-service/src/test/java/com/yape/transaction/domain/service/TransactionServiceTest.java
new file mode 100644
index 0000000000..c7a7e56d11
--- /dev/null
+++ b/transaction-service/src/test/java/com/yape/transaction/domain/service/TransactionServiceTest.java
@@ -0,0 +1,169 @@
+package com.yape.transaction.domain.service;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.model.TransactionStatus;
+import com.yape.transaction.domain.port.out.TransactionCachePort;
+import com.yape.transaction.domain.port.out.TransactionEventPublisher;
+import com.yape.transaction.domain.port.out.TransactionRepositoryPort;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Transaction Service Unit Tests")
+class TransactionServiceTest {
+
+ @Mock
+ private TransactionRepositoryPort repository;
+
+ @Mock
+ private TransactionEventPublisher eventPublisher;
+
+ @Mock
+ private TransactionCachePort cache;
+
+ private TransactionService transactionService;
+
+ @BeforeEach
+ void setUp() {
+ transactionService = new TransactionService(repository, eventPublisher, cache);
+ }
+
+ @Test
+ @DisplayName("Should create transaction with pending status")
+ void shouldCreateTransactionWithPendingStatus() {
+ UUID debitAccount = UUID.randomUUID();
+ UUID creditAccount = UUID.randomUUID();
+ Integer transferTypeId = 1;
+ BigDecimal value = new BigDecimal("500.00");
+
+ when(repository.save(any(Transaction.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ Transaction result = transactionService.createTransaction(debitAccount, creditAccount, transferTypeId, value);
+
+ assertNotNull(result);
+ assertNotNull(result.getTransactionExternalId());
+ assertEquals(debitAccount, result.getAccountExternalIdDebit());
+ assertEquals(creditAccount, result.getAccountExternalIdCredit());
+ assertEquals(transferTypeId, result.getTransferTypeId());
+ assertEquals(value, result.getValue());
+ assertEquals(TransactionStatus.PENDING, result.getStatus());
+
+ verify(repository).save(any(Transaction.class));
+ verify(cache).put(any(Transaction.class));
+ verify(eventPublisher).publishTransactionCreated(any(Transaction.class));
+ }
+
+ @Test
+ @DisplayName("Should get transaction from cache when available")
+ void shouldGetTransactionFromCacheWhenAvailable() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ Transaction cachedTransaction = new Transaction();
+ cachedTransaction.setTransactionExternalId(transactionId);
+ cachedTransaction.setStatus(TransactionStatus.APPROVED);
+
+ when(cache.get(transactionId)).thenReturn(Optional.of(cachedTransaction));
+
+ Optional result = transactionService.getTransaction(transactionId);
+
+ assertTrue(result.isPresent());
+ assertEquals(transactionId, result.get().getTransactionExternalId());
+ assertEquals(TransactionStatus.APPROVED, result.get().getStatus());
+
+ verify(cache).get(transactionId);
+ verify(repository, never()).findByExternalId(any());
+ }
+
+ @Test
+ @DisplayName("Should get transaction from database when not in cache")
+ void shouldGetTransactionFromDatabaseWhenNotInCache() {
+ UUID transactionId = UUID.randomUUID();
+ Transaction dbTransaction = new Transaction();
+ dbTransaction.setTransactionExternalId(transactionId);
+
+ when(cache.get(transactionId)).thenReturn(Optional.empty());
+ when(repository.findByExternalId(transactionId)).thenReturn(Optional.of(dbTransaction));
+
+ Optional result = transactionService.getTransaction(transactionId);
+
+ assertTrue(result.isPresent());
+ verify(cache).get(transactionId);
+ verify(repository).findByExternalId(transactionId);
+ verify(cache).put(dbTransaction); // Should cache the result
+ }
+
+ @Test
+ @DisplayName("Should return empty when transaction not found")
+ void shouldReturnEmptyWhenTransactionNotFound() {
+ UUID transactionId = UUID.randomUUID();
+
+ when(cache.get(transactionId)).thenReturn(Optional.empty());
+ when(repository.findByExternalId(transactionId)).thenReturn(Optional.empty());
+
+ Optional result = transactionService.getTransaction(transactionId);
+
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should update transaction status when pending")
+ void shouldUpdateTransactionStatusWhenPending() {
+ UUID transactionId = UUID.randomUUID();
+ Transaction pendingTransaction = new Transaction();
+ pendingTransaction.setTransactionExternalId(transactionId);
+ pendingTransaction.setStatus(TransactionStatus.PENDING);
+
+ when(repository.findByExternalId(transactionId)).thenReturn(Optional.of(pendingTransaction));
+ when(repository.update(any(Transaction.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ transactionService.updateStatus(transactionId, TransactionStatus.APPROVED);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Transaction.class);
+ verify(repository).update(captor.capture());
+ assertEquals(TransactionStatus.APPROVED, captor.getValue().getStatus());
+
+ verify(cache).invalidate(transactionId);
+ verify(cache).put(any(Transaction.class));
+ }
+
+ @Test
+ @DisplayName("Should not update transaction status when not pending")
+ void shouldNotUpdateTransactionStatusWhenNotPending() {
+ UUID transactionId = UUID.randomUUID();
+ Transaction approvedTransaction = new Transaction();
+ approvedTransaction.setTransactionExternalId(transactionId);
+ approvedTransaction.setStatus(TransactionStatus.APPROVED);
+
+ when(repository.findByExternalId(transactionId)).thenReturn(Optional.of(approvedTransaction));
+
+ transactionService.updateStatus(transactionId, TransactionStatus.REJECTED);
+
+ verify(repository, never()).update(any());
+ verify(cache, never()).invalidate(any());
+ }
+
+ @Test
+ @DisplayName("Should not update when transaction not found")
+ void shouldNotUpdateWhenTransactionNotFound() {
+ UUID transactionId = UUID.randomUUID();
+
+ when(repository.findByExternalId(transactionId)).thenReturn(Optional.empty());
+
+ transactionService.updateStatus(transactionId, TransactionStatus.APPROVED);
+
+ verify(repository, never()).update(any());
+ }
+}
diff --git a/transaction-service/src/test/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResourceTest.java b/transaction-service/src/test/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResourceTest.java
new file mode 100644
index 0000000000..106704c531
--- /dev/null
+++ b/transaction-service/src/test/java/com/yape/transaction/infrastructure/adapter/in/rest/TransactionResourceTest.java
@@ -0,0 +1,139 @@
+package com.yape.transaction.infrastructure.adapter.in.rest;
+
+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.in.GetTransactionUseCase;
+import io.quarkus.test.InjectMock;
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.http.ContentType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Optional;
+import java.util.UUID;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@QuarkusTest
+@DisplayName("Transaction Resource Integration Tests")
+class TransactionResourceTest {
+
+ @InjectMock
+ CreateTransactionUseCase createTransactionUseCase;
+
+ @InjectMock
+ GetTransactionUseCase getTransactionUseCase;
+
+ @Test
+ @DisplayName("Should create transaction successfully")
+ void shouldCreateTransactionSuccessfully() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ Transaction transaction = createMockTransaction(transactionId, new BigDecimal("500.00"));
+
+ when(createTransactionUseCase.createTransaction(any(), any(), any(), any()))
+ .thenReturn(transaction);
+
+ String requestBody = """
+ {
+ "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000",
+ "accountExternalIdCredit": "550e8400-e29b-41d4-a716-446655440001",
+ "tranferTypeId": 1,
+ "value": 500.00
+ }
+ """;
+
+ // When & Then
+ given()
+ .contentType(ContentType.JSON)
+ .body(requestBody)
+ .when()
+ .post("/transactions")
+ .then()
+ .statusCode(201)
+ .body("transactionExternalId", notNullValue())
+ .body("transactionStatus.name", equalTo("pending"))
+ .body("transactionType.name", equalTo("Transfer"))
+ .body("value", equalTo(500.00f));
+ }
+
+ @Test
+ @DisplayName("Should return 400 for invalid request")
+ void shouldReturn400ForInvalidRequest() {
+ String invalidRequestBody = """
+ {
+ "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000",
+ "value": -100
+ }
+ """;
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(invalidRequestBody)
+ .when()
+ .post("/transactions")
+ .then()
+ .statusCode(400);
+ }
+
+ @Test
+ @DisplayName("Should get transaction by ID")
+ void shouldGetTransactionById() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+ Transaction transaction = createMockTransaction(transactionId, new BigDecimal("750.00"));
+ transaction.setStatus(TransactionStatus.APPROVED);
+
+ when(getTransactionUseCase.getTransaction(transactionId))
+ .thenReturn(Optional.of(transaction));
+
+ // When & Then
+ given()
+ .pathParam("transactionExternalId", transactionId.toString())
+ .when()
+ .get("/transactions/{transactionExternalId}")
+ .then()
+ .statusCode(200)
+ .body("transactionExternalId", equalTo(transactionId.toString()))
+ .body("transactionStatus.name", equalTo("approved"))
+ .body("value", equalTo(750.00f));
+ }
+
+ @Test
+ @DisplayName("Should return 404 when transaction not found")
+ void shouldReturn404WhenTransactionNotFound() {
+ // Given
+ UUID transactionId = UUID.randomUUID();
+
+ when(getTransactionUseCase.getTransaction(transactionId))
+ .thenReturn(Optional.empty());
+
+ // When & Then
+ given()
+ .pathParam("transactionExternalId", transactionId.toString())
+ .when()
+ .get("/transactions/{transactionExternalId}")
+ .then()
+ .statusCode(404)
+ .body("message", equalTo("Transaction not found"));
+ }
+
+ private Transaction createMockTransaction(UUID id, BigDecimal value) {
+ Transaction transaction = new Transaction();
+ transaction.setTransactionExternalId(id);
+ transaction.setAccountExternalIdDebit(UUID.randomUUID());
+ transaction.setAccountExternalIdCredit(UUID.randomUUID());
+ transaction.setTransferTypeId(1);
+ transaction.setValue(value);
+ transaction.setStatus(TransactionStatus.PENDING);
+ transaction.setCreatedAt(LocalDateTime.now());
+ transaction.setUpdatedAt(LocalDateTime.now());
+ return transaction;
+ }
+}
diff --git a/transaction-service/src/test/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionMapperTest.java b/transaction-service/src/test/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionMapperTest.java
new file mode 100644
index 0000000000..db5d542117
--- /dev/null
+++ b/transaction-service/src/test/java/com/yape/transaction/infrastructure/adapter/out/persistence/TransactionMapperTest.java
@@ -0,0 +1,102 @@
+package com.yape.transaction.infrastructure.adapter.out.persistence;
+
+import com.yape.transaction.domain.model.Transaction;
+import com.yape.transaction.domain.model.TransactionStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Transaction Mapper Tests")
+class TransactionMapperTest {
+
+ private TransactionMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new TransactionMapper();
+ }
+
+ @Test
+ @DisplayName("Should map domain to entity")
+ void shouldMapDomainToEntity() {
+ // Given
+ Transaction domain = new Transaction();
+ domain.setTransactionExternalId(UUID.randomUUID());
+ domain.setAccountExternalIdDebit(UUID.randomUUID());
+ domain.setAccountExternalIdCredit(UUID.randomUUID());
+ domain.setTransferTypeId(1);
+ domain.setValue(new BigDecimal("500.00"));
+ domain.setStatus(TransactionStatus.PENDING);
+ domain.setCreatedAt(LocalDateTime.now());
+ domain.setUpdatedAt(LocalDateTime.now());
+
+ // When
+ TransactionEntity entity = mapper.toEntity(domain);
+
+ // Then
+ assertEquals(domain.getTransactionExternalId(), entity.getTransactionExternalId());
+ assertEquals(domain.getAccountExternalIdDebit(), entity.getAccountExternalIdDebit());
+ assertEquals(domain.getAccountExternalIdCredit(), entity.getAccountExternalIdCredit());
+ assertEquals(domain.getTransferTypeId(), entity.getTransferTypeId());
+ assertEquals(domain.getValue(), entity.getValue());
+ assertEquals("pending", entity.getStatus());
+ assertEquals(domain.getCreatedAt(), entity.getCreatedAt());
+ assertEquals(domain.getUpdatedAt(), entity.getUpdatedAt());
+ }
+
+ @Test
+ @DisplayName("Should map entity to domain")
+ void shouldMapEntityToDomain() {
+ // Given
+ TransactionEntity entity = new TransactionEntity();
+ entity.setId(1L);
+ entity.setTransactionExternalId(UUID.randomUUID());
+ entity.setAccountExternalIdDebit(UUID.randomUUID());
+ entity.setAccountExternalIdCredit(UUID.randomUUID());
+ entity.setTransferTypeId(2);
+ entity.setValue(new BigDecimal("1500.00"));
+ entity.setStatus("rejected");
+ entity.setCreatedAt(LocalDateTime.now());
+ entity.setUpdatedAt(LocalDateTime.now());
+ entity.setVersion(1L);
+
+ // When
+ Transaction domain = mapper.toDomain(entity);
+
+ // Then
+ assertEquals(entity.getTransactionExternalId(), domain.getTransactionExternalId());
+ assertEquals(entity.getAccountExternalIdDebit(), domain.getAccountExternalIdDebit());
+ assertEquals(entity.getAccountExternalIdCredit(), domain.getAccountExternalIdCredit());
+ assertEquals(entity.getTransferTypeId(), domain.getTransferTypeId());
+ assertEquals(entity.getValue(), domain.getValue());
+ assertEquals(TransactionStatus.REJECTED, domain.getStatus());
+ assertEquals(entity.getCreatedAt(), domain.getCreatedAt());
+ assertEquals(entity.getUpdatedAt(), domain.getUpdatedAt());
+ }
+
+ @Test
+ @DisplayName("Should update entity from domain")
+ void shouldUpdateEntityFromDomain() {
+ // Given
+ TransactionEntity entity = new TransactionEntity();
+ entity.setStatus("pending");
+ entity.setUpdatedAt(LocalDateTime.now().minusHours(1));
+
+ Transaction domain = new Transaction();
+ domain.setStatus(TransactionStatus.APPROVED);
+ domain.setUpdatedAt(LocalDateTime.now());
+
+ // When
+ mapper.updateEntity(entity, domain);
+
+ // Then
+ assertEquals("approved", entity.getStatus());
+ assertEquals(domain.getUpdatedAt(), entity.getUpdatedAt());
+ }
+}
diff --git a/transaction-service/src/test/resources/application.properties b/transaction-service/src/test/resources/application.properties
new file mode 100644
index 0000000000..9ceee0dabe
--- /dev/null
+++ b/transaction-service/src/test/resources/application.properties
@@ -0,0 +1,22 @@
+# Database
+quarkus.datasource.db-kind=h2
+quarkus.datasource.jdbc.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
+quarkus.datasource.username=sa
+quarkus.datasource.password=
+
+# Hibernate
+quarkus.hibernate-orm.database.generation=drop-and-create
+quarkus.hibernate-orm.log.sql=false
+
+quarkus.flyway.migrate-at-start=false
+
+quarkus.redis.hosts=redis://localhost:6379
+quarkus.redis.devservices.enabled=true
+
+# Kafka
+mp.messaging.outgoing.transaction-created-out.connector=smallrye-in-memory
+mp.messaging.incoming.transaction-status-in.connector=smallrye-in-memory
+
+# Logging
+quarkus.log.level=WARN
+quarkus.log.category."com.yape".level=DEBUG
diff --git a/transaction-service/transaction-service.iml b/transaction-service/transaction-service.iml
new file mode 100644
index 0000000000..9e3449c9d8
--- /dev/null
+++ b/transaction-service/transaction-service.iml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file