diff --git a/.gitignore b/.gitignore index 67045665db..db8ab5f3a2 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,20 @@ dist # TernJS port file .tern-port + +# Java / Maven +target/ +*.class +*.jar +*.war +*.ear +.settings/ +.project +.classpath +.idea/ +*.iml +.factorypath + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index b067a71026..a51cb31959 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,482 @@ -# Yape Code Challenge :rocket: +# Yape Anti-Fraud Transaction System -Our code challenge will let you marvel us with your Jedi coding skills :smile:. +A microservices-based financial transaction system with real-time anti-fraud validation, built with **Java 17** and **Spring Boot 3**. Designed as a solution for the Yape Code Challenge (Java Backend Developer - Semi-Senior). -Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !! +## Problem Statement -- [Problem](#problem) -- [Tech Stack](#tech_stack) -- [Send us your challenge](#send_us_your_challenge) +Every time a financial transaction is created, it must be validated by an anti-fraud microservice before being finalized. The anti-fraud service evaluates the transaction asynchronously and sends the result back to update the transaction status. Transactions exceeding a value of **1000** are automatically **rejected**; otherwise, they are **approved**. Communication between services is fully event-driven via **Apache Kafka**, ensuring loose coupling, resilience, and scalability. -# 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: +## Architecture -
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. -
+``` +┌─────────────┐ POST /transactions ┌─────────────────────┐ +│ Client │ ─────────────────────────► │ Transaction Service │ +│ │ ◄───────────────────────── │ (port 8080) │ +└─────────────┘ 201 Created (PENDING) │ │ + │ ┌──────────────┐ │ + GET /transactions/:id │ │ PostgreSQL │ │ +┌─────────────┐ ────────────────────────► │ │ (postgres-tx)│ │ +│ Client │ ◄──────────────────────── │ └──────────────┘ │ +└─────────────┘ 200 OK │ ┌──────────────┐ │ + │ │ Redis │ │ + │ │ (cache) │ │ + │ └──────────────┘ │ + └──────────┬──────────┘ + │ + Kafka: transaction.created + │ + ▼ + ┌─────────────────────┐ + │ Antifraud Service │ + │ (port 8081) │ + │ │ + │ ┌──────────────┐ │ + │ │ PostgreSQL │ │ + │ │ (postgres-af)│ │ + │ └──────────────┘ │ + └──────────┬──────────┘ + │ + Kafka: transaction.status.updated + │ + ▼ + ┌─────────────────────┐ + │ Transaction Service │ + │ (updates status) │ + └─────────────────────┘ +``` + +--- + +## Tech Stack + +| Technology | Version | Purpose | +|---|---|---| +| **Java** | 17 | Core language with modern features (records, sealed classes, pattern matching) | +| **Spring Boot** | 3.2.5 | Application framework with auto-configuration and embedded server | +| **Spring Data JPA** | 3.2.x | ORM and repository abstraction over Hibernate | +| **Spring Kafka** | 3.1.x | Kafka producer/consumer integration with manual offset commit | +| **Spring Data Redis** | 3.2.x | Redis client for caching with TTL support | +| **PostgreSQL** | 15 (Alpine) | Relational database for persistent storage (one per service) | +| **Redis** | 7 (Alpine) | In-memory cache for transaction reads | +| **HikariCP** | 5.x | High-performance JDBC connection pool (Spring Boot default) | +| **Flyway** | 10.x | Version-controlled database migrations | +| **Apache Kafka** | 7.5.0 (Confluent) | Event streaming platform for inter-service communication | +| **Docker + Docker Compose** | - | Containerization and multi-service orchestration | +| **Testcontainers** | 1.19.7 | Integration testing with real infrastructure (PostgreSQL, Kafka) | +| **Lombok** | - | Boilerplate reduction (builders, getters, loggers) | +| **Maven** | 3.9 | Build tool and dependency management | + +--- + +## Project Structure -Every transaction with a value greater than 1000 should be rejected. +Both services follow **Hexagonal Architecture** (Ports & Adapters), ensuring clean separation between business logic and infrastructure concerns. -```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)] ``` +app-nodejs-codechallenge/ +├── docker-compose.yml +├── README.md +│ +├── transaction-service/ +│ ├── Dockerfile +│ ├── pom.xml +│ └── src/main/java/com/yape/transaction/ +│ ├── TransactionServiceApplication.java +│ │ +│ ├── domain/ # Core business logic (no framework dependencies) +│ │ ├── model/ +│ │ │ ├── Transaction.java # Domain entity +│ │ │ ├── TransactionStatus.java # Enum: PENDING, APPROVED, REJECTED +│ │ │ └── TransferType.java # Transfer type value object +│ │ └── port/ +│ │ ├── in/ # Inbound ports (use cases) +│ │ │ ├── CreateTransactionUseCase.java +│ │ │ └── GetTransactionUseCase.java +│ │ └── out/ # Outbound ports (driven adapters) +│ │ ├── TransactionRepository.java +│ │ ├── TransactionEventPublisher.java +│ │ └── TransactionCachePort.java +│ │ +│ ├── application/ # Use case implementations (orchestration) +│ │ └── usecase/ +│ │ ├── CreateTransactionUseCaseImpl.java +│ │ └── GetTransactionUseCaseImpl.java +│ │ +│ └── infrastructure/ # Framework & technology adapters +│ ├── rest/ # REST API (driving adapter) +│ │ ├── TransactionController.java +│ │ └── dto/ +│ │ ├── CreateTransactionRequest.java +│ │ └── TransactionResponse.java +│ ├── persistence/ # JPA/PostgreSQL (driven adapter) +│ │ ├── adapter/ +│ │ │ └── TransactionRepositoryAdapter.java +│ │ ├── entity/ +│ │ │ ├── TransactionEntity.java +│ │ │ └── TransferTypeEntity.java +│ │ └── repository/ +│ │ └── JpaTransactionRepository.java +│ ├── kafka/ # Kafka producer & consumer +│ │ ├── KafkaConfig.java +│ │ ├── consumer/ +│ │ │ └── TransactionStatusConsumer.java +│ │ ├── producer/ +│ │ │ └── TransactionEventProducer.java +│ │ └── dto/ +│ │ ├── TransactionCreatedEvent.java +│ │ └── TransactionStatusUpdatedEvent.java +│ └── cache/ # Redis cache (driven adapter) +│ └── TransactionCacheAdapter.java +│ +└── antifraud-service/ + ├── Dockerfile + ├── pom.xml + └── src/main/java/com/yape/antifraud/ + ├── AntifraudServiceApplication.java + │ + ├── domain/ # Core business logic + │ ├── model/ + │ │ └── EvaluatedTransaction.java # Domain entity + │ └── port/ + │ ├── in/ + │ │ └── EvaluateTransactionUseCase.java + │ └── out/ + │ ├── EvaluatedTransactionRepository.java + │ └── TransactionStatusPublisher.java + │ + ├── application/ # Use case implementations + │ └── usecase/ + │ └── EvaluateTransactionUseCaseImpl.java + │ + └── infrastructure/ # Framework & technology adapters + ├── kafka/ + │ ├── KafkaConfig.java + │ ├── consumer/ + │ │ └── TransactionCreatedConsumer.java + │ ├── producer/ + │ │ └── TransactionStatusProducer.java + │ └── dto/ + │ ├── TransactionCreatedEvent.java + │ └── TransactionStatusUpdatedEvent.java + └── persistence/ + ├── adapter/ + │ └── EvaluatedTransactionRepositoryAdapter.java + ├── entity/ + │ └── EvaluatedTransactionEntity.java + └── repository/ + └── JpaEvaluatedTransactionRepository.java +``` + +--- + +## Prerequisites -# Tech Stack +- **Docker** >= 20.10 +- **Docker Compose** >= 2.0 -
    -
  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. -
+No local Java or Maven installation required -- everything builds inside Docker containers. -We do provide a `Dockerfile` to help you get started with a dev environment. +--- -You must have two resources: +## How to Run -1. Resource to create a transaction that must containt: +```bash +# Clone the repository +git clone https://github.com/ChristopherEspejo/app-nodejs-codechallenge.git +cd app-nodejs-codechallenge + +# Start all services +docker-compose up --build + +# Services will be available at: +# Transaction Service: http://localhost:8080 +# Antifraud Service: http://localhost:8081 (no REST API - event-driven only) +``` + +Wait until you see both services log `Started *Application` before sending requests. The health checks ensure proper startup ordering: PostgreSQL and Redis must be healthy before the application services start, and Kafka must be ready before consumers begin listening. + +To stop all services: + +```bash +docker-compose down + +# To also remove persisted data volumes: +docker-compose down -v +``` + +--- + +## API Endpoints + +### Create Transaction + +**POST** `/transactions` + +Creates a new transaction with `PENDING` status and publishes an event for anti-fraud evaluation. + +**Request:** ```json { - "accountExternalIdDebit": "Guid", - "accountExternalIdCredit": "Guid", + "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000", + "accountExternalIdCredit": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "tranferTypeId": 1, "value": 120 } ``` -2. Resource to retrieve a transaction +**Response (201 Created):** + +```json +{ + "transactionExternalId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "transactionType": { + "name": "TRANSFER" + }, + "transactionStatus": { + "name": "PENDING" + }, + "value": 120, + "createdAt": "2026-02-27T10:30:00" +} +``` + +**cURL Example:** + +```bash +curl -X POST http://localhost:8080/transactions \ + -H "Content-Type: application/json" \ + -d '{ + "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000", + "accountExternalIdCredit": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "tranferTypeId": 1, + "value": 120 + }' +``` + +--- + +### Retrieve Transaction + +**GET** `/transactions/{id}` + +Retrieves a transaction by its external UUID. Returns the current status, which may have been updated asynchronously by the anti-fraud service. + +**Response (200 OK):** ```json { - "transactionExternalId": "Guid", + "transactionExternalId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "transactionType": { - "name": "" + "name": "TRANSFER" }, "transactionStatus": { - "name": "" + "name": "APPROVED" }, "value": 120, - "createdAt": "Date" + "createdAt": "2026-02-27T10:30:00" } ``` -## Optional +**cURL Example:** + +```bash +curl http://localhost:8080/transactions/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +> **Note:** The status transitions from `PENDING` to `APPROVED` or `REJECTED` within milliseconds of creation, depending on Kafka and service processing latency. + +--- + +## Event Flow + +The following sequence describes the complete lifecycle of a transaction, from creation to final status: + +``` + Client Transaction Service Kafka Antifraud Service + │ │ │ │ + │ 1. POST /transactions │ │ + │─────────────────────►│ │ │ + │ │ │ │ + │ │ 2. Save (PENDING) │ │ + │ │────► PostgreSQL │ │ + │ │ │ │ + │ │ 3. Publish event │ │ + │ │─────────────────────►│ │ + │ │ transaction.created │ │ + │ 4. 201 Created │ │ │ + │◄─────────────────────│ │ │ + │ (PENDING) │ │ 5. Consume event │ + │ │ │──────────────────────►│ + │ │ │ │ + │ │ │ 6. Idempotency check │ + │ │ │ 7. Evaluate value │ + │ │ │ > 1000? REJECTED │ + │ │ │ <= 1000? APPROVED │ + │ │ │ │ + │ │ │ 8. Publish result │ + │ │ │◄──────────────────────│ + │ │ transaction.status │ │ + │ │ .updated │ │ + │ │◄─────────────────────│ │ + │ │ 9. Update status │ │ + │ │────► PostgreSQL │ │ + │ │────► Redis cache │ │ + │ │ │ │ + │ 10. GET /transactions/{id} │ │ + │─────────────────────►│ │ │ + │ 200 OK (APPROVED │ │ │ + │ or REJECTED) │ │ │ + │◄─────────────────────│ │ │ +``` + +**Step-by-step:** + +1. **User creates transaction** via `POST /transactions` +2. **Transaction saved** to PostgreSQL with `PENDING` status +3. **Event published** to `transaction.created` Kafka topic +4. **Response returned immediately** to the client (non-blocking) -- the client does not wait for fraud evaluation +5. **Antifraud Service consumes** the event from Kafka +6. **Idempotency check** -- if the transaction was already evaluated (exists in `evaluated_transactions` table), skip processing +7. **Fraud evaluation** -- value > 1000 results in `REJECTED`, value <= 1000 results in `APPROVED` +8. **Result published** to `transaction.status.updated` Kafka topic +9. **Transaction Service updates** the status in PostgreSQL and refreshes the Redis cache +10. **GET endpoint** returns the final status (served from Redis cache when available) + +--- + +## Kafka Topics + +| Topic | Producer | Consumer | Payload | Purpose | +|---|---|---|---|---| +| `transaction.created` | Transaction Service | Antifraud Service | `{ transactionId, value }` | New transaction requiring fraud evaluation | +| `transaction.status.updated` | Antifraud Service | Transaction Service | `{ transactionId, status }` | Fraud evaluation result (APPROVED/REJECTED) | + +Both services use **manual offset commit** (`ack-mode: MANUAL_IMMEDIATE`) to ensure messages are only acknowledged after successful processing and downstream publishing. + +--- + +## Database Schema + +### Transaction Service (`transactions_db`) + +```sql +CREATE TABLE transactions ( + id BIGSERIAL PRIMARY KEY, + external_id UUID NOT NULL UNIQUE, + account_debit UUID NOT NULL, + account_credit UUID NOT NULL, + transfer_type_id INT NOT NULL, + value DECIMAL(15,2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP +); + +CREATE INDEX idx_transactions_external_id ON transactions(external_id); + +CREATE TABLE transfer_types ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL +); +``` + +### Antifraud Service (`antifraud_db`) + +```sql +CREATE TABLE evaluated_transactions ( + transaction_id UUID PRIMARY KEY, + status VARCHAR(20) NOT NULL, + evaluated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +All schemas are managed through **Flyway migrations**, ensuring version-controlled, repeatable database changes across environments. + +--- + +## Technical Decisions + +### Kafka over HTTP for Inter-Service Communication + +Asynchronous, non-blocking communication via Kafka decouples the two services entirely. The `POST /transactions` endpoint returns immediately with a `PENDING` status without waiting for fraud validation. This provides resilience: if the antifraud service is temporarily unavailable, messages queue in Kafka and are processed once the service recovers. There is no temporal coupling between producer and consumer. + +### Manual Kafka Offset Commit + +Both consumers use `enable-auto-commit: false` with `ack-mode: MANUAL_IMMEDIATE`. The offset is committed **only after** the message has been fully processed (database write + downstream event published). If the service crashes mid-processing, Kafka redelivers the unacknowledged message on restart. This prevents message loss at the cost of potential duplicates, which is handled by the idempotency mechanism. + +### Idempotency in the Antifraud Service + +Kafka guarantees **at-least-once** delivery, meaning duplicate messages can occur during rebalances or retries. The antifraud service records each evaluated `transactionId` in a dedicated database table. Before processing, it checks whether the transaction has already been evaluated, skipping duplicates. This makes the consumer safely idempotent. + +### Redis Cache with Differentiated TTL + +Transactions are cached in Redis with TTLs based on their status. `PENDING` transactions get a **30-second TTL** because the status will change shortly. `APPROVED` and `REJECTED` transactions receive a **10-minute TTL** because they represent a final, immutable state. This strategy optimizes read performance while preventing stale data. + +### HikariCP Tuned Configuration + +Both services configure HikariCP explicitly with `maximum-pool-size: 20` and `minimum-idle: 5`, rather than relying on Spring Boot defaults (10 max). This prevents connection pool exhaustion under concurrent load and ensures stable database access. Connection timeout (30s), idle timeout (10m), and max lifetime (30m) are tuned for a containerized environment. + +### PostgreSQL Index on `external_id` + +A B-tree index on `transactions.external_id` provides **O(log n)** lookups for the `GET /transactions/{id}` endpoint. Without this index, every retrieval would require a full table scan, which degrades rapidly as transaction volume grows. + +### Hexagonal Architecture (Ports & Adapters) + +The domain layer (`domain/model`, `domain/port`) contains pure business logic with **zero framework dependencies**. Use cases in the application layer orchestrate the flow. Infrastructure adapters (REST controllers, JPA repositories, Kafka consumers/producers, Redis cache) are pluggable and can be replaced without modifying business logic. This makes the codebase testable, maintainable, and framework-agnostic at its core. + +### Flyway Database Migrations + +Schema changes are version-controlled SQL files (`V1__create_transactions_table.sql`, etc.) executed automatically on startup. This guarantees database consistency across local, staging, and production environments and eliminates manual DDL execution. + +### Multi-Stage Docker Builds + +Both services use multi-stage Dockerfiles: Maven builds the JAR in the first stage, and a minimal `eclipse-temurin:17-jre-alpine` image runs it in the second stage. This reduces the final image size significantly and runs the application as a non-root user (`appuser`) for security. + +### Separate Databases per Service + +Each microservice owns its database (`transactions_db` and `antifraud_db`), following the **Database per Service** pattern. This ensures loose coupling at the data layer -- services cannot directly query each other's tables, forcing all communication through Kafka events. + +--- + +## Running Tests + +Both services include integration test dependencies with **Testcontainers** for testing against real PostgreSQL and Kafka instances. + +```bash +# Run tests for transaction-service +cd transaction-service +mvn test + +# Run tests for antifraud-service +cd antifraud-service +mvn test + +# Run tests for both services from the project root +(cd transaction-service && mvn test) && (cd antifraud-service && mvn test) +``` + +> **Note:** Running tests locally requires Docker to be running, as Testcontainers spins up PostgreSQL and Kafka containers during test execution. + +--- -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? +## Future Improvements -You can use Graphql; +| Improvement | Description | +|---|---| +| **CQRS** | Separate read and write databases for independent scalability of query and command workloads | +| **Circuit Breaker** | Resilience4j integration for fault tolerance, preventing cascading failures between services | +| **Metrics & Monitoring** | Micrometer + Prometheus + Grafana stack for real-time observability, latency tracking, and alerting | +| **Dead Letter Queue (DLQ)** | Route messages that fail processing after N retries to a dead letter topic for manual inspection | +| **API Gateway** | Centralized entry point with rate limiting, authentication (JWT), and request routing | +| **Schema Registry** | Confluent Schema Registry with Avro or Protobuf schemas for Kafka events, enabling safe schema evolution | +| **Event Sourcing** | Store all state changes as an immutable event log for full audit trail and temporal queries | +| **Distributed Tracing** | OpenTelemetry integration for end-to-end request tracing across services and Kafka | -# 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. +## Author -If you have any questions, please let us know. +**Christopher Espejo** -- [GitHub](https://github.com/ChristopherEspejo) diff --git a/antifraud-service/Dockerfile b/antifraud-service/Dockerfile new file mode 100644 index 0000000000..d39b0ef11b --- /dev/null +++ b/antifraud-service/Dockerfile @@ -0,0 +1,29 @@ +# Stage 1: Build +FROM maven:3.9-eclipse-temurin-17 AS build + +WORKDIR /app + +# Copy POM first for dependency caching +COPY pom.xml . +RUN mvn dependency:go-offline -B + +# Copy source code and build +COPY src ./src +RUN mvn clean package -DskipTests -B + +# Stage 2: Run +FROM eclipse-temurin:17-jre-jammy + +WORKDIR /app + +RUN groupadd -r appgroup && useradd -r -g appgroup appuser + +COPY --from=build /app/target/*.jar app.jar + +RUN chown -R appuser:appgroup /app + +USER appuser + +EXPOSE 8081 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/antifraud-service/pom.xml b/antifraud-service/pom.xml new file mode 100644 index 0000000000..a5d656de6d --- /dev/null +++ b/antifraud-service/pom.xml @@ -0,0 +1,135 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.yape + antifraud-service + 1.0.0 + antifraud-service + Anti-fraud microservice for Yape transaction validation + + + 17 + 1.19.7 + + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.kafka + spring-kafka + + + + + org.flywaydb + flyway-core + + + + + org.postgresql + postgresql + runtime + + + + + org.projectlombok + lombok + true + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.kafka + spring-kafka-test + test + + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + + org.testcontainers + kafka + ${testcontainers.version} + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/antifraud-service/src/main/java/com/yape/antifraud/AntifraudServiceApplication.java b/antifraud-service/src/main/java/com/yape/antifraud/AntifraudServiceApplication.java new file mode 100644 index 0000000000..e33a623286 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/AntifraudServiceApplication.java @@ -0,0 +1,19 @@ +package com.yape.antifraud; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Main entry point for the Anti-fraud microservice. + * This service consumes transaction events from Kafka, evaluates them + * against anti-fraud rules, and publishes the evaluation result. + * + * No REST endpoints are exposed - communication is event-driven via Kafka. + */ +@SpringBootApplication +public class AntifraudServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AntifraudServiceApplication.class, args); + } +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/application/usecase/EvaluateTransactionUseCaseImpl.java b/antifraud-service/src/main/java/com/yape/antifraud/application/usecase/EvaluateTransactionUseCaseImpl.java new file mode 100644 index 0000000000..44f5fdd884 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/application/usecase/EvaluateTransactionUseCaseImpl.java @@ -0,0 +1,31 @@ +package com.yape.antifraud.application.usecase; + +import com.yape.antifraud.domain.port.in.EvaluateTransactionUseCase; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.UUID; + +@Service +@Slf4j +public class EvaluateTransactionUseCaseImpl implements EvaluateTransactionUseCase { + + private static final BigDecimal FRAUD_THRESHOLD = new BigDecimal("1000"); + + @Override + public String evaluate(UUID transactionId, BigDecimal value) { + log.info("Evaluating transaction {} with value {}", transactionId, value); + + String status; + if (value.compareTo(FRAUD_THRESHOLD) > 0) { + status = "REJECTED"; + log.warn("Transaction {} REJECTED - value {} exceeds threshold {}", transactionId, value, FRAUD_THRESHOLD); + } else { + status = "APPROVED"; + log.info("Transaction {} APPROVED - value {} within threshold {}", transactionId, value, FRAUD_THRESHOLD); + } + + return status; + } +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/model/EvaluatedTransaction.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/model/EvaluatedTransaction.java new file mode 100644 index 0000000000..3deee45a09 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/model/EvaluatedTransaction.java @@ -0,0 +1,24 @@ +package com.yape.antifraud.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Pure domain entity representing an evaluated transaction. + * No JPA or infrastructure annotations - this belongs to the domain layer. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EvaluatedTransaction { + + private UUID transactionId; + private String status; + private LocalDateTime evaluatedAt; +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateTransactionUseCase.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateTransactionUseCase.java new file mode 100644 index 0000000000..68f76c1d43 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/in/EvaluateTransactionUseCase.java @@ -0,0 +1,20 @@ +package com.yape.antifraud.domain.port.in; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Input port for evaluating a transaction against anti-fraud rules. + * Returns "APPROVED" if the transaction passes validation, or "REJECTED" otherwise. + */ +public interface EvaluateTransactionUseCase { + + /** + * Evaluates a transaction based on its value. + * + * @param transactionId the unique identifier of the transaction + * @param value the monetary value of the transaction + * @return "APPROVED" if value <= 1000, "REJECTED" if value > 1000 + */ + String evaluate(UUID transactionId, BigDecimal value); +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/EvaluatedTransactionRepository.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/EvaluatedTransactionRepository.java new file mode 100644 index 0000000000..e89cb0ff2e --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/EvaluatedTransactionRepository.java @@ -0,0 +1,36 @@ +package com.yape.antifraud.domain.port.out; + +import com.yape.antifraud.domain.model.EvaluatedTransaction; + +import java.util.Optional; +import java.util.UUID; + +/** + * Output port for persisting evaluated transactions. + * This interface is defined in the domain layer and implemented by the infrastructure layer. + */ +public interface EvaluatedTransactionRepository { + + /** + * Checks if a transaction has already been evaluated (idempotency check). + * + * @param transactionId the transaction identifier + * @return true if the transaction has already been evaluated + */ + boolean existsByTransactionId(UUID transactionId); + + /** + * Persists an evaluated transaction. + * + * @param evaluatedTransaction the evaluated transaction to save + */ + void save(EvaluatedTransaction evaluatedTransaction); + + /** + * Finds an evaluated transaction by its transaction ID. + * + * @param transactionId the transaction identifier + * @return the evaluated transaction if found + */ + Optional findByTransactionId(UUID transactionId); +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/TransactionStatusPublisher.java b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/TransactionStatusPublisher.java new file mode 100644 index 0000000000..d9d198fee1 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/domain/port/out/TransactionStatusPublisher.java @@ -0,0 +1,18 @@ +package com.yape.antifraud.domain.port.out; + +import java.util.UUID; + +/** + * Output port for publishing transaction status updates. + * Implemented by the Kafka producer in the infrastructure layer. + */ +public interface TransactionStatusPublisher { + + /** + * Publishes a status update for a given transaction. + * + * @param transactionId the transaction identifier + * @param status the evaluation status ("APPROVED" or "REJECTED") + */ + void publishStatusUpdate(UUID transactionId, String status); +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/config/JacksonConfig.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/config/JacksonConfig.java new file mode 100644 index 0000000000..277041ab9e --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/config/JacksonConfig.java @@ -0,0 +1,19 @@ +package com.yape.antifraud.infrastructure.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/KafkaConfig.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/KafkaConfig.java new file mode 100644 index 0000000000..38f0440a4b --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/KafkaConfig.java @@ -0,0 +1,30 @@ +package com.yape.antifraud.infrastructure.kafka; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +/** + * Kafka topic configuration. + * Ensures required topics are created on application startup. + */ +@Configuration +public class KafkaConfig { + + @Bean + public NewTopic transactionCreatedTopic() { + return TopicBuilder.name("transaction.created") + .partitions(3) + .replicas(1) + .build(); + } + + @Bean + public NewTopic transactionStatusUpdatedTopic() { + return TopicBuilder.name("transaction.status.updated") + .partitions(3) + .replicas(1) + .build(); + } +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/consumer/TransactionCreatedConsumer.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/consumer/TransactionCreatedConsumer.java new file mode 100644 index 0000000000..032d8452b2 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/consumer/TransactionCreatedConsumer.java @@ -0,0 +1,98 @@ +package com.yape.antifraud.infrastructure.kafka.consumer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.antifraud.domain.model.EvaluatedTransaction; +import com.yape.antifraud.domain.port.in.EvaluateTransactionUseCase; +import com.yape.antifraud.domain.port.out.EvaluatedTransactionRepository; +import com.yape.antifraud.domain.port.out.TransactionStatusPublisher; +import com.yape.antifraud.infrastructure.kafka.dto.TransactionCreatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Kafka consumer that listens for transaction creation events. + * Orchestrates the anti-fraud evaluation flow with manual offset acknowledgment. + * + * Flow: + * 1. Deserialize the incoming event + * 2. Check idempotency (skip if already processed) + * 3. Delegate evaluation to the use case + * 4. Persist the evaluation result + * 5. Publish the status update + * 6. Acknowledge the message (commit offset) + * + * No business logic resides here - this is purely orchestration. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TransactionCreatedConsumer { + + private final EvaluateTransactionUseCase evaluateTransactionUseCase; + private final EvaluatedTransactionRepository evaluatedTransactionRepository; + private final TransactionStatusPublisher transactionStatusPublisher; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = "transaction.created", + groupId = "antifraud-group" + ) + public void consume(ConsumerRecord record, Acknowledgment acknowledgment) { + log.info("Received transaction event from topic={}, partition={}, offset={}", + record.topic(), record.partition(), record.offset()); + + try { + // 1. Deserialize the event + TransactionCreatedEvent event = objectMapper.readValue(record.value(), TransactionCreatedEvent.class); + UUID transactionId = UUID.fromString(event.getTransactionId()); + + log.info("Processing transaction: id={}, value={}", transactionId, event.getValue()); + + // 2. Idempotency check: skip if already processed + if (evaluatedTransactionRepository.existsByTransactionId(transactionId)) { + log.warn("Transaction {} already evaluated, skipping (idempotent)", transactionId); + acknowledgment.acknowledge(); + return; + } + + // 3. Delegate evaluation to the use case (pure business logic) + String status = evaluateTransactionUseCase.evaluate(transactionId, event.getValue()); + + // 4. Save the evaluation result to the database + EvaluatedTransaction evaluatedTransaction = EvaluatedTransaction.builder() + .transactionId(transactionId) + .status(status) + .evaluatedAt(LocalDateTime.now()) + .build(); + evaluatedTransactionRepository.save(evaluatedTransaction); + + // 5. Publish the status update to Kafka + transactionStatusPublisher.publishStatusUpdate(transactionId, status); + + // 6. Acknowledge the message (commit offset) only after successful processing + acknowledgment.acknowledge(); + log.info("Successfully processed transaction {}: status={}", transactionId, status); + + } catch (JsonProcessingException e) { + log.error("Failed to deserialize transaction event: {}", e.getMessage(), e); + // Acknowledge to avoid reprocessing a permanently malformed message + acknowledgment.acknowledge(); + } catch (IllegalArgumentException e) { + log.error("Invalid transaction ID format in event: {}", e.getMessage(), e); + // Acknowledge to avoid reprocessing a permanently invalid message + acknowledgment.acknowledge(); + } catch (Exception e) { + log.error("Unexpected error processing transaction event: {}", e.getMessage(), e); + // Do NOT acknowledge - the message will be redelivered for retry + throw e; + } + } +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/dto/TransactionCreatedEvent.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/dto/TransactionCreatedEvent.java new file mode 100644 index 0000000000..856e3f11af --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/dto/TransactionCreatedEvent.java @@ -0,0 +1,40 @@ +package com.yape.antifraud.infrastructure.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * DTO representing a transaction creation event consumed from Kafka. + * Maps to the event published by the transaction-service when a new transaction is created. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class TransactionCreatedEvent { + + @JsonProperty("transactionId") + private String transactionId; + + @JsonProperty("value") + private BigDecimal value; + + @JsonProperty("transactionTypeId") + private int transactionTypeId; + + @JsonProperty("accountExternalIdDebit") + private String accountExternalIdDebit; + + @JsonProperty("accountExternalIdCredit") + private String accountExternalIdCredit; + + @JsonProperty("createdAt") + private String createdAt; +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/dto/TransactionStatusUpdatedEvent.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/dto/TransactionStatusUpdatedEvent.java new file mode 100644 index 0000000000..af963686e7 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/dto/TransactionStatusUpdatedEvent.java @@ -0,0 +1,24 @@ +package com.yape.antifraud.infrastructure.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO representing a transaction status update event published to Kafka. + * Sent after the anti-fraud evaluation is completed. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionStatusUpdatedEvent { + + @JsonProperty("transactionId") + private String transactionId; + + @JsonProperty("status") + private String status; +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/producer/TransactionStatusProducer.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/producer/TransactionStatusProducer.java new file mode 100644 index 0000000000..fd8e228e55 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/kafka/producer/TransactionStatusProducer.java @@ -0,0 +1,53 @@ +package com.yape.antifraud.infrastructure.kafka.producer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.antifraud.domain.port.out.TransactionStatusPublisher; +import com.yape.antifraud.infrastructure.kafka.dto.TransactionStatusUpdatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Kafka producer that implements the TransactionStatusPublisher domain port. + * Publishes transaction status updates to the "transaction.status.updated" topic. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TransactionStatusProducer implements TransactionStatusPublisher { + + private static final String TOPIC = "transaction.status.updated"; + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Override + public void publishStatusUpdate(UUID transactionId, String status) { + TransactionStatusUpdatedEvent event = TransactionStatusUpdatedEvent.builder() + .transactionId(transactionId.toString()) + .status(status) + .build(); + + try { + String payload = objectMapper.writeValueAsString(event); + kafkaTemplate.send(TOPIC, transactionId.toString(), payload) + .whenComplete((result, ex) -> { + if (ex != null) { + log.error("Failed to publish status update for transaction {}: {}", + transactionId, ex.getMessage(), ex); + } else { + log.info("Published status update for transaction {} to topic {}: status={}", + transactionId, TOPIC, status); + } + }); + } catch (JsonProcessingException e) { + log.error("Failed to serialize status update event for transaction {}: {}", + transactionId, e.getMessage(), e); + throw new RuntimeException("Failed to serialize transaction status event", e); + } + } +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/adapter/EvaluatedTransactionRepositoryAdapter.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/adapter/EvaluatedTransactionRepositoryAdapter.java new file mode 100644 index 0000000000..61bafcdd1f --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/adapter/EvaluatedTransactionRepositoryAdapter.java @@ -0,0 +1,58 @@ +package com.yape.antifraud.infrastructure.persistence.adapter; + +import com.yape.antifraud.domain.model.EvaluatedTransaction; +import com.yape.antifraud.domain.port.out.EvaluatedTransactionRepository; +import com.yape.antifraud.infrastructure.persistence.entity.EvaluatedTransactionEntity; +import com.yape.antifraud.infrastructure.persistence.repository.JpaEvaluatedTransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; + +/** + * Adapter that bridges the domain port (EvaluatedTransactionRepository) + * with the Spring Data JPA infrastructure (JpaEvaluatedTransactionRepository). + * Handles conversion between domain model and JPA entity. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class EvaluatedTransactionRepositoryAdapter implements EvaluatedTransactionRepository { + + private final JpaEvaluatedTransactionRepository jpaRepository; + + @Override + public boolean existsByTransactionId(UUID transactionId) { + return jpaRepository.existsByTransactionId(transactionId); + } + + @Override + public void save(EvaluatedTransaction evaluatedTransaction) { + EvaluatedTransactionEntity entity = toEntity(evaluatedTransaction); + jpaRepository.save(entity); + log.debug("Saved evaluated transaction: {}", evaluatedTransaction.getTransactionId()); + } + + @Override + public Optional findByTransactionId(UUID transactionId) { + return jpaRepository.findById(transactionId).map(this::toDomain); + } + + private EvaluatedTransactionEntity toEntity(EvaluatedTransaction domain) { + return EvaluatedTransactionEntity.builder() + .transactionId(domain.getTransactionId()) + .status(domain.getStatus()) + .evaluatedAt(domain.getEvaluatedAt()) + .build(); + } + + private EvaluatedTransaction toDomain(EvaluatedTransactionEntity entity) { + return EvaluatedTransaction.builder() + .transactionId(entity.getTransactionId()) + .status(entity.getStatus()) + .evaluatedAt(entity.getEvaluatedAt()) + .build(); + } +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/entity/EvaluatedTransactionEntity.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/entity/EvaluatedTransactionEntity.java new file mode 100644 index 0000000000..e8a8786ad8 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/entity/EvaluatedTransactionEntity.java @@ -0,0 +1,36 @@ +package com.yape.antifraud.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * JPA entity mapped to the "evaluated_transactions" table. + * This is an infrastructure concern and should not leak into the domain layer. + */ +@Entity +@Table(name = "evaluated_transactions") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EvaluatedTransactionEntity { + + @Id + @Column(name = "transaction_id", nullable = false, updatable = false) + private UUID transactionId; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "evaluated_at", nullable = false) + private LocalDateTime evaluatedAt; +} diff --git a/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/repository/JpaEvaluatedTransactionRepository.java b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/repository/JpaEvaluatedTransactionRepository.java new file mode 100644 index 0000000000..d1c9678ee5 --- /dev/null +++ b/antifraud-service/src/main/java/com/yape/antifraud/infrastructure/persistence/repository/JpaEvaluatedTransactionRepository.java @@ -0,0 +1,24 @@ +package com.yape.antifraud.infrastructure.persistence.repository; + +import com.yape.antifraud.infrastructure.persistence.entity.EvaluatedTransactionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +/** + * Spring Data JPA repository for evaluated transaction entities. + * Since transactionId is the primary key, existsById and findById are used directly. + */ +@Repository +public interface JpaEvaluatedTransactionRepository extends JpaRepository { + + /** + * Checks if a transaction has already been evaluated. + * Delegates to existsById since transactionId is the primary key. + * + * @param transactionId the transaction identifier + * @return true if the transaction exists + */ + boolean existsByTransactionId(UUID transactionId); +} diff --git a/antifraud-service/src/main/resources/application.yml b/antifraud-service/src/main/resources/application.yml new file mode 100644 index 0000000000..43d836eb5e --- /dev/null +++ b/antifraud-service/src/main/resources/application.yml @@ -0,0 +1,39 @@ +server: + port: 8081 + +spring: + application: + name: antifraud-service + datasource: + url: jdbc:postgresql://postgres-af:5432/antifraud_db + username: postgres + password: postgres + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + locations: classpath:db/migration + kafka: + bootstrap-servers: kafka:9092 + consumer: + group-id: antifraud-group + auto-offset-reset: earliest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + listener: + ack-mode: MANUAL_IMMEDIATE diff --git a/antifraud-service/src/main/resources/db/migration/V1__create_evaluated_transactions_table.sql b/antifraud-service/src/main/resources/db/migration/V1__create_evaluated_transactions_table.sql new file mode 100644 index 0000000000..ab7634b9c7 --- /dev/null +++ b/antifraud-service/src/main/resources/db/migration/V1__create_evaluated_transactions_table.sql @@ -0,0 +1,5 @@ +CREATE TABLE evaluated_transactions ( + transaction_id UUID PRIMARY KEY, + status VARCHAR(20) NOT NULL, + evaluated_at TIMESTAMP NOT NULL DEFAULT NOW() +); diff --git a/antifraud-service/src/test/java/com/yape/antifraud/application/usecase/EvaluateTransactionUseCaseTest.java b/antifraud-service/src/test/java/com/yape/antifraud/application/usecase/EvaluateTransactionUseCaseTest.java new file mode 100644 index 0000000000..faf2e80f0d --- /dev/null +++ b/antifraud-service/src/test/java/com/yape/antifraud/application/usecase/EvaluateTransactionUseCaseTest.java @@ -0,0 +1,84 @@ +package com.yape.antifraud.application.usecase; + +import org.junit.jupiter.api.BeforeEach; +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.assertEquals; + +@DisplayName("EvaluateTransactionUseCase Unit Tests") +class EvaluateTransactionUseCaseTest { + + private EvaluateTransactionUseCaseImpl useCase; + + @BeforeEach + void setUp() { + useCase = new EvaluateTransactionUseCaseImpl(); + } + + @Test + @DisplayName("Should APPROVE transaction when value is less than 1000") + void shouldApproveTransactionWhenValueIsLessThan1000() { + String result = useCase.evaluate(UUID.randomUUID(), new BigDecimal("500.00")); + assertEquals("APPROVED", result); + } + + @Test + @DisplayName("Should APPROVE transaction when value is exactly 1000 (boundary)") + void shouldApproveTransactionWhenValueIsExactly1000() { + String result = useCase.evaluate(UUID.randomUUID(), new BigDecimal("1000.00")); + assertEquals("APPROVED", result); + } + + @Test + @DisplayName("Should REJECT transaction when value is 1001 (boundary)") + void shouldRejectTransactionWhenValueIs1001() { + String result = useCase.evaluate(UUID.randomUUID(), new BigDecimal("1001.00")); + assertEquals("REJECTED", result); + } + + @Test + @DisplayName("Should REJECT transaction when value is greater than 1000") + void shouldRejectTransactionWhenValueIsGreaterThan1000() { + String result = useCase.evaluate(UUID.randomUUID(), new BigDecimal("5000.00")); + assertEquals("REJECTED", result); + } + + @Test + @DisplayName("Should REJECT transaction when value is very large") + void shouldRejectTransactionWhenValueIsVeryLarge() { + String result = useCase.evaluate(UUID.randomUUID(), new BigDecimal("999999.99")); + assertEquals("REJECTED", result); + } + + @Test + @DisplayName("Should APPROVE transaction when value is very small") + void shouldApproveTransactionWhenValueIsVerySmall() { + String result = useCase.evaluate(UUID.randomUUID(), new BigDecimal("0.01")); + assertEquals("APPROVED", result); + } + + @Test + @DisplayName("Should APPROVE transaction when value is zero") + void shouldApproveTransactionWhenValueIsZero() { + String result = useCase.evaluate(UUID.randomUUID(), BigDecimal.ZERO); + assertEquals("APPROVED", result); + } + + @Test + @DisplayName("Should APPROVE transaction when value is 999.99") + void shouldApproveTransactionWhenValueIs999_99() { + String result = useCase.evaluate(UUID.randomUUID(), new BigDecimal("999.99")); + assertEquals("APPROVED", result); + } + + @Test + @DisplayName("Should REJECT transaction when value is 1000.01") + void shouldRejectTransactionWhenValueIs1000_01() { + String result = useCase.evaluate(UUID.randomUUID(), new BigDecimal("1000.01")); + assertEquals("REJECTED", result); + } +} diff --git a/antifraud-service/src/test/resources/application-test.yml b/antifraud-service/src/test/resources/application-test.yml new file mode 100644 index 0000000000..7d1c7d5ca4 --- /dev/null +++ b/antifraud-service/src/test/resources/application-test.yml @@ -0,0 +1,19 @@ +server: + port: 0 + +spring: + application: + name: antifraud-service-test + jpa: + hibernate: + ddl-auto: validate + show-sql: true + flyway: + enabled: true + locations: classpath:db/migration + kafka: + consumer: + auto-offset-reset: earliest + enable-auto-commit: false + listener: + ack-mode: MANUAL_IMMEDIATE diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..8a655b6205 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,123 @@ -version: "3.7" services: - postgres: - image: postgres:14 + postgres-tx: + image: postgres:15-alpine + container_name: yape-postgres-tx ports: - "5432:5432" environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres + POSTGRES_DB: transactions_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - postgres_tx_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d transactions_db"] + interval: 5s + timeout: 5s + retries: 10 + + postgres-af: + image: postgres:15-alpine + container_name: yape-postgres-af + ports: + - "5433:5432" + environment: + POSTGRES_DB: antifraud_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - postgres_af_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d antifraud_db"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: yape-redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + 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: ["CMD", "bash", "-c", "echo ruok | nc localhost 2181"] + interval: 5s + timeout: 5s + retries: 10 + 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_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_JMX_PORT: 9991 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + healthcheck: + test: ["CMD", "kafka-topics", "--bootstrap-server", "localhost:9092", "--list"] + interval: 10s + timeout: 10s + retries: 10 + + transaction-service: + build: + context: ./transaction-service + dockerfile: Dockerfile + container_name: yape-transaction-service + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-tx:5432/transactions_db + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + depends_on: + postgres-tx: + condition: service_healthy + kafka: + condition: service_healthy + redis: + condition: service_healthy + + antifraud-service: + build: + context: ./antifraud-service + dockerfile: Dockerfile + container_name: yape-antifraud-service ports: - - 9092:9092 + - "8081:8081" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-af:5432/antifraud_db + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + depends_on: + postgres-af: + condition: service_healthy + kafka: + condition: service_healthy + +volumes: + postgres_tx_data: + postgres_af_data: diff --git a/transaction-service/Dockerfile b/transaction-service/Dockerfile new file mode 100644 index 0000000000..45431dba5f --- /dev/null +++ b/transaction-service/Dockerfile @@ -0,0 +1,27 @@ +# Stage 1: Build +FROM maven:3.9-eclipse-temurin-17 AS build + +WORKDIR /app + +COPY pom.xml . +RUN mvn dependency:go-offline -B + +COPY src ./src +RUN mvn clean package -DskipTests -B + +# Stage 2: Run +FROM eclipse-temurin:17-jre-jammy + +WORKDIR /app + +RUN groupadd -r appgroup && useradd -r -g appgroup appuser + +COPY --from=build /app/target/*.jar app.jar + +RUN chown -R appuser:appgroup /app + +USER appuser + +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..375bf00503 --- /dev/null +++ b/transaction-service/pom.xml @@ -0,0 +1,138 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.yape + transaction-service + 1.0.0-SNAPSHOT + transaction-service + Yape Transaction Microservice + + + 17 + 1.19.7 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.kafka + spring-kafka + + + + + org.flywaydb + flyway-core + + + + + org.postgresql + postgresql + runtime + + + + + org.projectlombok + lombok + true + + + + + com.fasterxml.jackson.core + jackson-databind + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.kafka + spring-kafka-test + test + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + + org.testcontainers + kafka + ${testcontainers.version} + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java b/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java new file mode 100644 index 0000000000..ae998b71b2 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java @@ -0,0 +1,12 @@ +package com.yape.transaction; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TransactionServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(TransactionServiceApplication.class, args); + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCaseImpl.java b/transaction-service/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCaseImpl.java new file mode 100644 index 0000000000..09649c259c --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/application/usecase/CreateTransactionUseCaseImpl.java @@ -0,0 +1,37 @@ +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CreateTransactionUseCaseImpl implements CreateTransactionUseCase { + + private final TransactionRepository transactionRepository; + private final TransactionEventPublisher transactionEventPublisher; + + @Override + public Transaction create(Transaction transaction) { + transaction.setExternalId(UUID.randomUUID()); + transaction.setStatus(TransactionStatus.PENDING); + transaction.setCreatedAt(LocalDateTime.now()); + + Transaction savedTransaction = transactionRepository.save(transaction); + log.info("Transaction created with externalId: {}", savedTransaction.getExternalId()); + + transactionEventPublisher.publishTransactionCreated(savedTransaction); + log.info("Transaction created event published for externalId: {}", savedTransaction.getExternalId()); + + return savedTransaction; + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCaseImpl.java b/transaction-service/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCaseImpl.java new file mode 100644 index 0000000000..91f5f32bc7 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/application/usecase/GetTransactionUseCaseImpl.java @@ -0,0 +1,41 @@ +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.TransactionCachePort; +import com.yape.transaction.domain.port.out.TransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GetTransactionUseCaseImpl implements GetTransactionUseCase { + + private final TransactionRepository transactionRepository; + private final TransactionCachePort transactionCachePort; + + @Override + public Transaction getByExternalId(UUID externalId) { + Optional cached = transactionCachePort.get(externalId); + if (cached.isPresent()) { + log.debug("Transaction found in cache for externalId: {}", externalId); + return cached.get(); + } + + Transaction transaction = transactionRepository.findByExternalId(externalId) + .orElseThrow(() -> { + log.warn("Transaction not found for externalId: {}", externalId); + return new RuntimeException("Transaction not found with externalId: " + externalId); + }); + + transactionCachePort.put(transaction); + log.debug("Transaction cached for externalId: {}", externalId); + + return 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..36f8941d3b --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java @@ -0,0 +1,27 @@ +package com.yape.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Transaction { + + private Long id; + private UUID externalId; + private UUID accountDebit; + private UUID accountCredit; + private int transferTypeId; + private BigDecimal value; + private TransactionStatus status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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..4bd38b0060 --- /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/TransferType.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransferType.java new file mode 100644 index 0000000000..141cd7ea92 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransferType.java @@ -0,0 +1,16 @@ +package com.yape.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransferType { + + private int id; + private String 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..67ce23d864 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/in/CreateTransactionUseCase.java @@ -0,0 +1,8 @@ +package com.yape.transaction.domain.port.in; + +import com.yape.transaction.domain.model.Transaction; + +public interface CreateTransactionUseCase { + + Transaction create(Transaction transaction); +} 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..6d5248a959 --- /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 getByExternalId(UUID externalId); +} 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..5e05665a3f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionCachePort.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 TransactionCachePort { + + Optional get(UUID externalId); + + void put(Transaction transaction); + + void evict(UUID externalId); +} 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..93787f1750 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/out/TransactionRepository.java @@ -0,0 +1,16 @@ +package com.yape.transaction.domain.port.out; + +import com.yape.transaction.domain.model.Transaction; +import com.yape.transaction.domain.model.TransactionStatus; + +import java.util.Optional; +import java.util.UUID; + +public interface TransactionRepository { + + Transaction save(Transaction transaction); + + Optional findByExternalId(UUID externalId); + + Transaction updateStatus(UUID externalId, TransactionStatus status); +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/cache/TransactionCacheAdapter.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/cache/TransactionCacheAdapter.java new file mode 100644 index 0000000000..c996a65eec --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/cache/TransactionCacheAdapter.java @@ -0,0 +1,93 @@ +package com.yape.transaction.infrastructure.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.transaction.domain.model.Transaction; +import com.yape.transaction.domain.model.TransactionStatus; +import com.yape.transaction.domain.port.out.TransactionCachePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TransactionCacheAdapter implements TransactionCachePort { + + private static final String KEY_PREFIX = "transaction:"; + private static final Duration TTL_PENDING = Duration.ofSeconds(30); + private static final Duration TTL_FINAL = Duration.ofMinutes(10); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional get(UUID externalId) { + try { + String key = buildKey(externalId); + String json = redisTemplate.opsForValue().get(key); + + if (json == null) { + log.debug("Cache miss for transaction with externalId: {}", externalId); + return Optional.empty(); + } + + Transaction transaction = objectMapper.readValue(json, Transaction.class); + log.debug("Cache hit for transaction with externalId: {}", externalId); + return Optional.of(transaction); + + } catch (JsonProcessingException e) { + log.error("Failed to deserialize cached transaction for externalId: {}", externalId, e); + return Optional.empty(); + } catch (Exception e) { + log.error("Error reading from cache for externalId: {}", externalId, e); + return Optional.empty(); + } + } + + @Override + public void put(Transaction transaction) { + try { + String key = buildKey(transaction.getExternalId()); + String json = objectMapper.writeValueAsString(transaction); + + Duration ttl = resolveTtl(transaction.getStatus()); + redisTemplate.opsForValue().set(key, json, ttl); + + log.debug("Transaction cached with externalId: {} and TTL: {}", transaction.getExternalId(), ttl); + + } catch (JsonProcessingException e) { + log.error("Failed to serialize transaction for caching with externalId: {}", + transaction.getExternalId(), e); + } catch (Exception e) { + log.error("Error writing to cache for externalId: {}", transaction.getExternalId(), e); + } + } + + @Override + public void evict(UUID externalId) { + try { + String key = buildKey(externalId); + Boolean deleted = redisTemplate.delete(key); + log.debug("Cache evicted for externalId: {}, result: {}", externalId, deleted); + } catch (Exception e) { + log.error("Error evicting cache for externalId: {}", externalId, e); + } + } + + private String buildKey(UUID externalId) { + return KEY_PREFIX + externalId.toString(); + } + + private Duration resolveTtl(TransactionStatus status) { + if (status == TransactionStatus.PENDING) { + return TTL_PENDING; + } + return TTL_FINAL; + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/KafkaConfig.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/KafkaConfig.java new file mode 100644 index 0000000000..8caad57d1a --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/KafkaConfig.java @@ -0,0 +1,26 @@ +package com.yape.transaction.infrastructure.kafka; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +@Configuration +public class KafkaConfig { + + @Bean + public NewTopic transactionCreatedTopic() { + return TopicBuilder.name("transaction.created") + .partitions(3) + .replicas(1) + .build(); + } + + @Bean + public NewTopic transactionStatusUpdatedTopic() { + return TopicBuilder.name("transaction.status.updated") + .partitions(3) + .replicas(1) + .build(); + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/consumer/TransactionStatusConsumer.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/consumer/TransactionStatusConsumer.java new file mode 100644 index 0000000000..753d449a39 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/consumer/TransactionStatusConsumer.java @@ -0,0 +1,58 @@ +package com.yape.transaction.infrastructure.kafka.consumer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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.TransactionRepository; +import com.yape.transaction.infrastructure.kafka.dto.TransactionStatusUpdatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TransactionStatusConsumer { + + private final TransactionRepository transactionRepository; + private final TransactionCachePort transactionCachePort; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = "transaction.status.updated", + groupId = "transaction-group" + ) + public void consume(@Payload String message, Acknowledgment acknowledgment) { + log.info("Received transaction status update event: {}", message); + + try { + TransactionStatusUpdatedEvent event = objectMapper.readValue(message, TransactionStatusUpdatedEvent.class); + + TransactionStatus newStatus = TransactionStatus.valueOf(event.getStatus()); + Transaction updatedTransaction = transactionRepository.updateStatus(event.getTransactionId(), newStatus); + + transactionCachePort.evict(event.getTransactionId()); + transactionCachePort.put(updatedTransaction); + + log.info("Transaction status updated to {} for transactionId: {}", + newStatus, event.getTransactionId()); + + acknowledgment.acknowledge(); + + } catch (JsonProcessingException e) { + log.error("Failed to deserialize transaction status update event: {}", message, e); + acknowledgment.acknowledge(); + } catch (IllegalArgumentException e) { + log.error("Invalid transaction status in event: {}", message, e); + acknowledgment.acknowledge(); + } catch (Exception e) { + log.error("Error processing transaction status update event: {}", message, e); + throw e; + } + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/dto/TransactionCreatedEvent.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/dto/TransactionCreatedEvent.java new file mode 100644 index 0000000000..667db0e17c --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/dto/TransactionCreatedEvent.java @@ -0,0 +1,36 @@ +package com.yape.transaction.infrastructure.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionCreatedEvent { + + @JsonProperty("transactionId") + private UUID transactionId; + + @JsonProperty("value") + private BigDecimal value; + + @JsonProperty("transactionTypeId") + private int transactionTypeId; + + @JsonProperty("accountExternalIdDebit") + private UUID accountExternalIdDebit; + + @JsonProperty("accountExternalIdCredit") + private UUID accountExternalIdCredit; + + @JsonProperty("createdAt") + private LocalDateTime createdAt; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/dto/TransactionStatusUpdatedEvent.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/dto/TransactionStatusUpdatedEvent.java new file mode 100644 index 0000000000..8ce6b83ad2 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/dto/TransactionStatusUpdatedEvent.java @@ -0,0 +1,22 @@ +package com.yape.transaction.infrastructure.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionStatusUpdatedEvent { + + @JsonProperty("transactionId") + private UUID transactionId; + + @JsonProperty("status") + private String status; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/producer/TransactionEventProducer.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/producer/TransactionEventProducer.java new file mode 100644 index 0000000000..62804c1a02 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/kafka/producer/TransactionEventProducer.java @@ -0,0 +1,54 @@ +package com.yape.transaction.infrastructure.kafka.producer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.transaction.domain.model.Transaction; +import com.yape.transaction.domain.port.out.TransactionEventPublisher; +import com.yape.transaction.infrastructure.kafka.dto.TransactionCreatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TransactionEventProducer implements TransactionEventPublisher { + + private static final String TOPIC_TRANSACTION_CREATED = "transaction.created"; + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Override + public void publishTransactionCreated(Transaction transaction) { + TransactionCreatedEvent event = TransactionCreatedEvent.builder() + .transactionId(transaction.getExternalId()) + .value(transaction.getValue()) + .transactionTypeId(transaction.getTransferTypeId()) + .accountExternalIdDebit(transaction.getAccountDebit()) + .accountExternalIdCredit(transaction.getAccountCredit()) + .createdAt(transaction.getCreatedAt()) + .build(); + + try { + String payload = objectMapper.writeValueAsString(event); + kafkaTemplate.send(TOPIC_TRANSACTION_CREATED, transaction.getExternalId().toString(), payload) + .whenComplete((result, ex) -> { + if (ex != null) { + log.error("Failed to publish transaction created event for transactionId: {}", + transaction.getExternalId(), ex); + } else { + log.info("Transaction created event published successfully for transactionId: {} to partition: {} with offset: {}", + transaction.getExternalId(), + result.getRecordMetadata().partition(), + result.getRecordMetadata().offset()); + } + }); + } catch (JsonProcessingException e) { + log.error("Failed to serialize transaction created event for transactionId: {}", + transaction.getExternalId(), e); + throw new RuntimeException("Failed to serialize transaction created event", e); + } + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/adapter/TransactionRepositoryAdapter.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/adapter/TransactionRepositoryAdapter.java new file mode 100644 index 0000000000..a990cb3e38 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/adapter/TransactionRepositoryAdapter.java @@ -0,0 +1,79 @@ +package com.yape.transaction.infrastructure.persistence.adapter; + +import com.yape.transaction.domain.model.Transaction; +import com.yape.transaction.domain.model.TransactionStatus; +import com.yape.transaction.domain.port.out.TransactionRepository; +import com.yape.transaction.infrastructure.persistence.entity.TransactionEntity; +import com.yape.transaction.infrastructure.persistence.repository.JpaTransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TransactionRepositoryAdapter implements TransactionRepository { + + private final JpaTransactionRepository jpaTransactionRepository; + + @Override + @Transactional + public Transaction save(Transaction transaction) { + TransactionEntity entity = toEntity(transaction); + TransactionEntity savedEntity = jpaTransactionRepository.save(entity); + log.debug("Transaction entity saved with id: {}", savedEntity.getId()); + return toDomain(savedEntity); + } + + @Override + @Transactional(readOnly = true) + public Optional findByExternalId(UUID externalId) { + return jpaTransactionRepository.findByExternalId(externalId) + .map(this::toDomain); + } + + @Override + @Transactional + public Transaction updateStatus(UUID externalId, TransactionStatus status) { + int updated = jpaTransactionRepository.updateStatusByExternalId(externalId, status); + if (updated == 0) { + throw new RuntimeException("Transaction not found with externalId: " + externalId); + } + + return jpaTransactionRepository.findByExternalId(externalId) + .map(this::toDomain) + .orElseThrow(() -> new RuntimeException("Transaction not found after update with externalId: " + externalId)); + } + + private TransactionEntity toEntity(Transaction transaction) { + return TransactionEntity.builder() + .id(transaction.getId()) + .externalId(transaction.getExternalId()) + .accountDebit(transaction.getAccountDebit()) + .accountCredit(transaction.getAccountCredit()) + .transferTypeId(transaction.getTransferTypeId()) + .value(transaction.getValue()) + .status(transaction.getStatus()) + .createdAt(transaction.getCreatedAt()) + .updatedAt(transaction.getUpdatedAt()) + .build(); + } + + private Transaction toDomain(TransactionEntity entity) { + return Transaction.builder() + .id(entity.getId()) + .externalId(entity.getExternalId()) + .accountDebit(entity.getAccountDebit()) + .accountCredit(entity.getAccountCredit()) + .transferTypeId(entity.getTransferTypeId()) + .value(entity.getValue()) + .status(entity.getStatus()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/entity/TransactionEntity.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/entity/TransactionEntity.java new file mode 100644 index 0000000000..c3557e4d5e --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/entity/TransactionEntity.java @@ -0,0 +1,71 @@ +package com.yape.transaction.infrastructure.persistence.entity; + +import com.yape.transaction.domain.model.TransactionStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "transactions") +public class TransactionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "external_id", nullable = false, unique = true) + private UUID externalId; + + @Column(name = "account_debit", nullable = false) + private UUID accountDebit; + + @Column(name = "account_credit", nullable = false) + private UUID accountCredit; + + @Column(name = "transfer_type_id", nullable = false) + private int transferTypeId; + + @Column(name = "value", nullable = false, precision = 15, scale = 2) + private BigDecimal value; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private TransactionStatus status; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/entity/TransferTypeEntity.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/entity/TransferTypeEntity.java new file mode 100644 index 0000000000..ad68f6c287 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/entity/TransferTypeEntity.java @@ -0,0 +1,28 @@ +package com.yape.transaction.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "transfer_types") +public class TransferTypeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column(name = "name", nullable = false, length = 50) + private String name; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/repository/JpaTransactionRepository.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/repository/JpaTransactionRepository.java new file mode 100644 index 0000000000..4715984f79 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/repository/JpaTransactionRepository.java @@ -0,0 +1,22 @@ +package com.yape.transaction.infrastructure.persistence.repository; + +import com.yape.transaction.domain.model.TransactionStatus; +import com.yape.transaction.infrastructure.persistence.entity.TransactionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface JpaTransactionRepository extends JpaRepository { + + Optional findByExternalId(UUID externalId); + + @Modifying + @Query("UPDATE TransactionEntity t SET t.status = :status, t.updatedAt = CURRENT_TIMESTAMP WHERE t.externalId = :externalId") + int updateStatusByExternalId(@Param("externalId") UUID externalId, @Param("status") TransactionStatus status); +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/TransactionController.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/TransactionController.java new file mode 100644 index 0000000000..5863885366 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/TransactionController.java @@ -0,0 +1,80 @@ +package com.yape.transaction.infrastructure.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.rest.dto.CreateTransactionRequest; +import com.yape.transaction.infrastructure.rest.dto.TransactionResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@Slf4j +@RestController +@RequestMapping("/transactions") +@RequiredArgsConstructor +public class TransactionController { + + private final CreateTransactionUseCase createTransactionUseCase; + private final GetTransactionUseCase getTransactionUseCase; + + @PostMapping + public ResponseEntity createTransaction(@RequestBody CreateTransactionRequest request) { + log.info("Received create transaction request for debit account: {}", request.getAccountExternalIdDebit()); + + Transaction transaction = toDomain(request); + Transaction created = createTransactionUseCase.create(transaction); + + TransactionResponse response = toResponse(created); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{id}") + public ResponseEntity getTransaction(@PathVariable("id") UUID externalId) { + log.info("Received get transaction request for externalId: {}", externalId); + + Transaction transaction = getTransactionUseCase.getByExternalId(externalId); + + TransactionResponse response = toResponse(transaction); + return ResponseEntity.ok(response); + } + + private Transaction toDomain(CreateTransactionRequest request) { + return Transaction.builder() + .accountDebit(request.getAccountExternalIdDebit()) + .accountCredit(request.getAccountExternalIdCredit()) + .transferTypeId(request.getTranferTypeId()) + .value(request.getValue()) + .build(); + } + + private TransactionResponse toResponse(Transaction transaction) { + return TransactionResponse.builder() + .transactionExternalId(transaction.getExternalId()) + .transactionType(TransactionResponse.TransactionTypeResponse.builder() + .name(resolveTransferTypeName(transaction.getTransferTypeId())) + .build()) + .transactionStatus(TransactionResponse.TransactionStatusResponse.builder() + .name(transaction.getStatus().name()) + .build()) + .value(transaction.getValue()) + .createdAt(transaction.getCreatedAt()) + .build(); + } + + private String resolveTransferTypeName(int transferTypeId) { + return switch (transferTypeId) { + case 1 -> "TRANSFER"; + default -> "UNKNOWN"; + }; + } +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/dto/CreateTransactionRequest.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/dto/CreateTransactionRequest.java new file mode 100644 index 0000000000..640e55a5cd --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/dto/CreateTransactionRequest.java @@ -0,0 +1,29 @@ +package com.yape.transaction.infrastructure.rest.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateTransactionRequest { + + @JsonProperty("accountExternalIdDebit") + private UUID accountExternalIdDebit; + + @JsonProperty("accountExternalIdCredit") + private UUID accountExternalIdCredit; + + @JsonProperty("tranferTypeId") + private int tranferTypeId; + + @JsonProperty("value") + private BigDecimal value; +} diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/dto/TransactionResponse.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/dto/TransactionResponse.java new file mode 100644 index 0000000000..45ac2301d9 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/rest/dto/TransactionResponse.java @@ -0,0 +1,53 @@ +package com.yape.transaction.infrastructure.rest.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionResponse { + + @JsonProperty("transactionExternalId") + private UUID transactionExternalId; + + @JsonProperty("transactionType") + private TransactionTypeResponse transactionType; + + @JsonProperty("transactionStatus") + private TransactionStatusResponse transactionStatus; + + @JsonProperty("value") + private BigDecimal value; + + @JsonProperty("createdAt") + private LocalDateTime createdAt; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TransactionTypeResponse { + + @JsonProperty("name") + private String name; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TransactionStatusResponse { + + @JsonProperty("name") + private String name; + } +} diff --git a/transaction-service/src/main/resources/application.yml b/transaction-service/src/main/resources/application.yml new file mode 100644 index 0000000000..7deaf6770e --- /dev/null +++ b/transaction-service/src/main/resources/application.yml @@ -0,0 +1,43 @@ +server: + port: 8080 + +spring: + application: + name: transaction-service + datasource: + url: jdbc:postgresql://postgres-tx:5432/transactions_db + username: postgres + password: postgres + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + locations: classpath:db/migration + kafka: + bootstrap-servers: kafka:9092 + consumer: + group-id: transaction-group + auto-offset-reset: earliest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + listener: + ack-mode: MANUAL_IMMEDIATE + data: + redis: + host: redis + port: 6379 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..9e38671eed --- /dev/null +++ b/transaction-service/src/main/resources/db/migration/V1__create_transactions_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE transactions ( + id BIGSERIAL PRIMARY KEY, + external_id UUID NOT NULL UNIQUE, + account_debit UUID NOT NULL, + account_credit UUID NOT NULL, + transfer_type_id INT NOT NULL, + value DECIMAL(15,2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP +); + +CREATE INDEX idx_transactions_external_id ON transactions(external_id); 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..758dd8c66a --- /dev/null +++ b/transaction-service/src/main/resources/db/migration/V2__create_transfer_types_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE transfer_types ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL +); + +INSERT INTO transfer_types (name) VALUES ('TRANSFER'); diff --git a/transaction-service/src/test/java/com/yape/transaction/TestRedisConfig.java b/transaction-service/src/test/java/com/yape/transaction/TestRedisConfig.java new file mode 100644 index 0000000000..a6fb9ad803 --- /dev/null +++ b/transaction-service/src/test/java/com/yape/transaction/TestRedisConfig.java @@ -0,0 +1,42 @@ +package com.yape.transaction; + +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +/** + * Test configuration that provides mock Redis beans. + * This avoids the need for a real Redis instance or Testcontainers Redis + * during integration tests. The mock StringRedisTemplate returns null for + * all cache lookups, effectively simulating a permanent cache miss. + */ +@TestConfiguration +public class TestRedisConfig { + + @Bean + @Primary + public RedisConnectionFactory redisConnectionFactory() { + return Mockito.mock(RedisConnectionFactory.class); + } + + @SuppressWarnings("unchecked") + @Bean + @Primary + public StringRedisTemplate stringRedisTemplate() { + StringRedisTemplate mock = Mockito.mock(StringRedisTemplate.class); + ValueOperations valueOps = Mockito.mock(ValueOperations.class); + when(mock.opsForValue()).thenReturn(valueOps); + when(valueOps.get(anyString())).thenReturn(null); + doNothing().when(valueOps).set(anyString(), anyString(), Mockito.any()); + when(mock.delete(anyString())).thenReturn(true); + return mock; + } +} diff --git a/transaction-service/src/test/java/com/yape/transaction/application/usecase/CreateTransactionUseCaseTest.java b/transaction-service/src/test/java/com/yape/transaction/application/usecase/CreateTransactionUseCaseTest.java new file mode 100644 index 0000000000..74db4f0356 --- /dev/null +++ b/transaction-service/src/test/java/com/yape/transaction/application/usecase/CreateTransactionUseCaseTest.java @@ -0,0 +1,157 @@ +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.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.ArgumentCaptor; +import org.mockito.Captor; +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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateTransactionUseCase Unit Tests") +class CreateTransactionUseCaseTest { + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private TransactionEventPublisher transactionEventPublisher; + + @InjectMocks + private CreateTransactionUseCaseImpl createTransactionUseCase; + + @Captor + private ArgumentCaptor transactionCaptor; + + private Transaction inputTransaction; + + @BeforeEach + void setUp() { + inputTransaction = Transaction.builder() + .accountDebit(UUID.randomUUID()) + .accountCredit(UUID.randomUUID()) + .transferTypeId(1) + .value(new BigDecimal("500.00")) + .build(); + } + + @Test + @DisplayName("Should set PENDING status on new transaction") + void shouldSetPendingStatusOnNewTransaction() { + when(transactionRepository.save(any(Transaction.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Transaction result = createTransactionUseCase.create(inputTransaction); + + assertEquals(TransactionStatus.PENDING, result.getStatus()); + } + + @Test + @DisplayName("Should generate UUID as external ID") + void shouldGenerateUuidAsExternalId() { + when(transactionRepository.save(any(Transaction.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Transaction result = createTransactionUseCase.create(inputTransaction); + + assertNotNull(result.getExternalId()); + } + + @Test + @DisplayName("Should set createdAt timestamp") + void shouldSetCreatedAtTimestamp() { + when(transactionRepository.save(any(Transaction.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Transaction result = createTransactionUseCase.create(inputTransaction); + + assertNotNull(result.getCreatedAt()); + } + + @Test + @DisplayName("Should save transaction to repository") + void shouldSaveTransactionToRepository() { + when(transactionRepository.save(any(Transaction.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + createTransactionUseCase.create(inputTransaction); + + verify(transactionRepository).save(transactionCaptor.capture()); + Transaction savedTransaction = transactionCaptor.getValue(); + assertEquals(TransactionStatus.PENDING, savedTransaction.getStatus()); + assertNotNull(savedTransaction.getExternalId()); + assertEquals(new BigDecimal("500.00"), savedTransaction.getValue()); + } + + @Test + @DisplayName("Should publish transaction created event after saving") + void shouldPublishTransactionCreatedEventAfterSaving() { + Transaction savedTransaction = Transaction.builder() + .id(1L) + .externalId(UUID.randomUUID()) + .accountDebit(inputTransaction.getAccountDebit()) + .accountCredit(inputTransaction.getAccountCredit()) + .transferTypeId(1) + .value(new BigDecimal("500.00")) + .status(TransactionStatus.PENDING) + .build(); + + when(transactionRepository.save(any(Transaction.class))).thenReturn(savedTransaction); + + createTransactionUseCase.create(inputTransaction); + + verify(transactionEventPublisher).publishTransactionCreated(savedTransaction); + } + + @Test + @DisplayName("Should return the saved transaction") + void shouldReturnTheSavedTransaction() { + Transaction savedTransaction = Transaction.builder() + .id(1L) + .externalId(UUID.randomUUID()) + .accountDebit(inputTransaction.getAccountDebit()) + .accountCredit(inputTransaction.getAccountCredit()) + .transferTypeId(1) + .value(new BigDecimal("500.00")) + .status(TransactionStatus.PENDING) + .build(); + + when(transactionRepository.save(any(Transaction.class))).thenReturn(savedTransaction); + + Transaction result = createTransactionUseCase.create(inputTransaction); + + assertEquals(savedTransaction.getId(), result.getId()); + assertEquals(savedTransaction.getExternalId(), result.getExternalId()); + assertEquals(savedTransaction.getValue(), result.getValue()); + } + + @Test + @DisplayName("Should preserve original account and transfer details") + void shouldPreserveOriginalAccountAndTransferDetails() { + when(transactionRepository.save(any(Transaction.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Transaction result = createTransactionUseCase.create(inputTransaction); + + assertEquals(inputTransaction.getAccountDebit(), result.getAccountDebit()); + assertEquals(inputTransaction.getAccountCredit(), result.getAccountCredit()); + assertEquals(inputTransaction.getTransferTypeId(), result.getTransferTypeId()); + assertEquals(inputTransaction.getValue(), result.getValue()); + } +} diff --git a/transaction-service/src/test/java/com/yape/transaction/application/usecase/GetTransactionUseCaseTest.java b/transaction-service/src/test/java/com/yape/transaction/application/usecase/GetTransactionUseCaseTest.java new file mode 100644 index 0000000000..23fc7b3cf5 --- /dev/null +++ b/transaction-service/src/test/java/com/yape/transaction/application/usecase/GetTransactionUseCaseTest.java @@ -0,0 +1,139 @@ +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.out.TransactionCachePort; +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.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetTransactionUseCase Unit Tests") +class GetTransactionUseCaseTest { + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private TransactionCachePort transactionCachePort; + + @InjectMocks + private GetTransactionUseCaseImpl getTransactionUseCase; + + private UUID externalId; + private Transaction sampleTransaction; + + @BeforeEach + void setUp() { + externalId = UUID.randomUUID(); + sampleTransaction = Transaction.builder() + .id(1L) + .externalId(externalId) + .accountDebit(UUID.randomUUID()) + .accountCredit(UUID.randomUUID()) + .transferTypeId(1) + .value(new BigDecimal("750.00")) + .status(TransactionStatus.PENDING) + .createdAt(LocalDateTime.now()) + .build(); + } + + @Test + @DisplayName("Should return transaction from cache when cache hit occurs") + void shouldReturnTransactionFromCacheWhenCacheHitOccurs() { + when(transactionCachePort.get(externalId)).thenReturn(Optional.of(sampleTransaction)); + + Transaction result = getTransactionUseCase.getByExternalId(externalId); + + assertNotNull(result); + assertEquals(externalId, result.getExternalId()); + assertEquals(sampleTransaction.getValue(), result.getValue()); + assertEquals(sampleTransaction.getStatus(), result.getStatus()); + + verify(transactionCachePort).get(externalId); + verifyNoInteractions(transactionRepository); + } + + @Test + @DisplayName("Should not call repository when transaction is found in cache") + void shouldNotCallRepositoryWhenTransactionIsFoundInCache() { + when(transactionCachePort.get(externalId)).thenReturn(Optional.of(sampleTransaction)); + + getTransactionUseCase.getByExternalId(externalId); + + verify(transactionRepository, never()).findByExternalId(externalId); + } + + @Test + @DisplayName("Should retrieve from database and cache on cache miss") + void shouldRetrieveFromDatabaseAndCacheOnCacheMiss() { + when(transactionCachePort.get(externalId)).thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(externalId)).thenReturn(Optional.of(sampleTransaction)); + + Transaction result = getTransactionUseCase.getByExternalId(externalId); + + assertNotNull(result); + assertEquals(externalId, result.getExternalId()); + assertEquals(sampleTransaction.getValue(), result.getValue()); + + verify(transactionCachePort).get(externalId); + verify(transactionRepository).findByExternalId(externalId); + verify(transactionCachePort).put(sampleTransaction); + } + + @Test + @DisplayName("Should cache the transaction after retrieving from database") + void shouldCacheTransactionAfterRetrievingFromDatabase() { + when(transactionCachePort.get(externalId)).thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(externalId)).thenReturn(Optional.of(sampleTransaction)); + + getTransactionUseCase.getByExternalId(externalId); + + verify(transactionCachePort).put(sampleTransaction); + } + + @Test + @DisplayName("Should throw exception when transaction is not found") + void shouldThrowExceptionWhenTransactionIsNotFound() { + UUID nonExistentId = UUID.randomUUID(); + when(transactionCachePort.get(nonExistentId)).thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(nonExistentId)).thenReturn(Optional.empty()); + + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> getTransactionUseCase.getByExternalId(nonExistentId) + ); + + assertEquals("Transaction not found with externalId: " + nonExistentId, exception.getMessage()); + } + + @Test + @DisplayName("Should not cache when transaction is not found in database") + void shouldNotCacheWhenTransactionIsNotFoundInDatabase() { + UUID nonExistentId = UUID.randomUUID(); + when(transactionCachePort.get(nonExistentId)).thenReturn(Optional.empty()); + when(transactionRepository.findByExternalId(nonExistentId)).thenReturn(Optional.empty()); + + assertThrows(RuntimeException.class, () -> getTransactionUseCase.getByExternalId(nonExistentId)); + + verify(transactionCachePort, never()).put(sampleTransaction); + } +} diff --git a/transaction-service/src/test/java/com/yape/transaction/infrastructure/rest/TransactionControllerIntegrationTest.java b/transaction-service/src/test/java/com/yape/transaction/infrastructure/rest/TransactionControllerIntegrationTest.java new file mode 100644 index 0000000000..578564b722 --- /dev/null +++ b/transaction-service/src/test/java/com/yape/transaction/infrastructure/rest/TransactionControllerIntegrationTest.java @@ -0,0 +1,211 @@ +package com.yape.transaction.infrastructure.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.transaction.TestRedisConfig; +import com.yape.transaction.infrastructure.rest.dto.CreateTransactionRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for the TransactionController. + * Uses Testcontainers for PostgreSQL and Kafka, and a mock Redis configuration + * to simulate the full application context without requiring a real Redis instance. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +@DisplayName("TransactionController Integration Tests") +class TransactionControllerIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:15-alpine")) + .withDatabaseName("transactions_test_db") + .withUsername("test") + .withPassword("test"); + + @Container + static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0")); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + // PostgreSQL properties + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + + // Kafka properties + registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers); + + // Redis properties (mock will override, but these prevent connection errors during startup) + registry.add("spring.data.redis.host", () -> "localhost"); + registry.add("spring.data.redis.port", () -> "6379"); + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("POST /transactions - Should create transaction and return 201 with correct body") + void shouldCreateTransactionAndReturn201() throws Exception { + CreateTransactionRequest request = CreateTransactionRequest.builder() + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .tranferTypeId(1) + .value(new BigDecimal("500.00")) + .build(); + + mockMvc.perform(post("/transactions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.transactionExternalId").isNotEmpty()) + .andExpect(jsonPath("$.transactionStatus.name").value("PENDING")) + .andExpect(jsonPath("$.transactionType.name").value("TRANSFER")) + .andExpect(jsonPath("$.value").value(500.00)) + .andExpect(jsonPath("$.createdAt").isNotEmpty()); + } + + @Test + @DisplayName("POST /transactions - Should create transaction with large value and return 201") + void shouldCreateTransactionWithLargeValueAndReturn201() throws Exception { + CreateTransactionRequest request = CreateTransactionRequest.builder() + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .tranferTypeId(1) + .value(new BigDecimal("99999.99")) + .build(); + + mockMvc.perform(post("/transactions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.transactionExternalId").isNotEmpty()) + .andExpect(jsonPath("$.transactionStatus.name").value("PENDING")) + .andExpect(jsonPath("$.value").value(99999.99)); + } + + @Test + @DisplayName("GET /transactions/{id} - Should get transaction and return 200 with correct body") + void shouldGetTransactionAndReturn200() throws Exception { + // First, create a transaction + CreateTransactionRequest request = CreateTransactionRequest.builder() + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .tranferTypeId(1) + .value(new BigDecimal("200.00")) + .build(); + + String responseBody = mockMvc.perform(post("/transactions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + // Extract the external ID from the creation response + String externalId = objectMapper.readTree(responseBody).get("transactionExternalId").asText(); + + // Then retrieve it by external ID + mockMvc.perform(get("/transactions/" + externalId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.transactionExternalId").value(externalId)) + .andExpect(jsonPath("$.transactionStatus.name").value("PENDING")) + .andExpect(jsonPath("$.transactionType.name").value("TRANSFER")) + .andExpect(jsonPath("$.value").value(200.00)) + .andExpect(jsonPath("$.createdAt").isNotEmpty()); + } + + @Test + @DisplayName("GET /transactions/{id} - Should return error for non-existent transaction") + void shouldReturnErrorForNonExistentTransaction() throws Exception { + UUID nonExistentId = UUID.randomUUID(); + + mockMvc.perform(get("/transactions/" + nonExistentId)) + .andExpect(status().is5xxServerError()); + } + + @Test + @DisplayName("POST /transactions - Should handle unknown transfer type ID") + void shouldHandleUnknownTransferTypeId() throws Exception { + CreateTransactionRequest request = CreateTransactionRequest.builder() + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .tranferTypeId(99) + .value(new BigDecimal("100.00")) + .build(); + + mockMvc.perform(post("/transactions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.transactionType.name").value("UNKNOWN")); + } + + @Test + @DisplayName("POST /transactions - Should create multiple independent transactions") + void shouldCreateMultipleIndependentTransactions() throws Exception { + CreateTransactionRequest request1 = CreateTransactionRequest.builder() + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .tranferTypeId(1) + .value(new BigDecimal("100.00")) + .build(); + + CreateTransactionRequest request2 = CreateTransactionRequest.builder() + .accountExternalIdDebit(UUID.randomUUID()) + .accountExternalIdCredit(UUID.randomUUID()) + .tranferTypeId(1) + .value(new BigDecimal("300.00")) + .build(); + + String response1 = mockMvc.perform(post("/transactions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request1))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + String response2 = mockMvc.perform(post("/transactions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request2))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + String externalId1 = objectMapper.readTree(response1).get("transactionExternalId").asText(); + String externalId2 = objectMapper.readTree(response2).get("transactionExternalId").asText(); + + // Verify each transaction can be retrieved independently + mockMvc.perform(get("/transactions/" + externalId1)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value").value(100.00)); + + mockMvc.perform(get("/transactions/" + externalId2)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.value").value(300.00)); + } +} diff --git a/transaction-service/src/test/resources/application-test.yml b/transaction-service/src/test/resources/application-test.yml new file mode 100644 index 0000000000..329fc798ee --- /dev/null +++ b/transaction-service/src/test/resources/application-test.yml @@ -0,0 +1,19 @@ +server: + port: 0 + +spring: + application: + name: transaction-service-test + jpa: + hibernate: + ddl-auto: validate + show-sql: true + flyway: + enabled: true + locations: classpath:db/migration + kafka: + consumer: + auto-offset-reset: earliest + enable-auto-commit: false + listener: + ack-mode: MANUAL_IMMEDIATE