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