diff --git a/.gitignore b/.gitignore index 67045665db..892c767017 100644 --- a/.gitignore +++ b/.gitignore @@ -1,104 +1,57 @@ -# Logs -logs +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Java +*.class *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* +replay_pid* + +# IDE +.idea/ +*.iws +*.iml +*.ipr +.vscode/ +.classpath +.project +.settings/ +bin/ + +# Spring Boot +spring-output/ + +# Docker +.docker/ + +# OS +.DS_Store +Thumbs.db +*.swp +*.swo +*~ -# Optional eslint cache -.eslintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity +# Logs +logs/ +*.log -# dotenv environment variables file +# Environment .env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# Next.js build output -.next - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and *not* Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ +.env.local -# TernJS port file -.tern-port diff --git a/README.md b/README.md index b067a71026..5e5b99581b 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,318 @@ -# Yape Code Challenge :rocket: +# Sistema de Transacciones con Validación Anti-Fraude -Our code challenge will let you marvel us with your Jedi coding skills :smile:. +## Descripción -Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !! +Sistema de transacciones financieras con validación anti-fraude en tiempo real, implementado con arquitectura de microservicios y comunicación asíncrona mediante Apache Kafka. -- [Problem](#problem) -- [Tech Stack](#tech_stack) -- [Send us your challenge](#send_us_your_challenge) +### Arquitectura -# Problem +``` +Cliente → Transaction Service (REST API) + ↓ (guarda en PostgreSQL) + ↓ (publica evento en Kafka) + Anti-Fraud Service + ↓ (valida: value > 1000 = reject) + ↓ (publica resultado en Kafka) + Transaction Service + ↓ (actualiza estado en PostgreSQL) +``` + +### Stack Tecnológico + +- **Java 17** +- **Spring Boot 3.2.1** +- **PostgreSQL 14** +- **Apache Kafka** +- **Maven 3.9+** +- **Docker & Docker Compose** + +--- + +## Inicio Rápido + +### Prerrequisitos + +```bash +- Java 17 o superior (JDK) +- Maven 3.9+ +- Docker Desktop +- Git +``` + +### 1. Levantar Infraestructura (PostgreSQL + Kafka) + +```powershell +# Desde el directorio raíz del proyecto +docker-compose up -d + +# Verificar que los servicios estén corriendo +docker-compose ps + +# Verificar logs +docker-compose logs -f +``` + +Servicios disponibles: +- PostgreSQL: `localhost:5432` +- Kafka: `localhost:9092` +- Zookeeper: `localhost:2181` + +### 2. Compilar el Proyecto + +```powershell +# Compilar todos los módulos +mvn clean install +``` + +### 3. Ejecutar Transaction Service + +```powershell +# Opción 1: Desde Maven +cd transaction-service +mvn spring-boot:run + +# Opción 2: Desde JAR compilado +cd transaction-service\target +java -jar transaction-service-1.0.0.jar +``` -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: +El servicio estará disponible en: `http://localhost:8080` -
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. -
+### 4. Ejecutar Anti-Fraud Service (en otra terminal) -Every transaction with a value greater than 1000 should be rejected. +```powershell +# Opción 1: Desde Maven +cd anti-fraud-service +mvn spring-boot:run -```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)] +# Opción 2: Desde JAR compilado +cd anti-fraud-service\target +java -jar anti-fraud-service-1.0.0.jar ``` -# Tech Stack +El servicio estará disponible en: `http://localhost:8081` + +--- -
    -
  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. -
+## API Endpoints -We do provide a `Dockerfile` to help you get started with a dev environment. +### Base URL +``` +http://localhost:8080/api/v1 +``` -You must have two resources: +### 1. Crear Transacción -1. Resource to create a transaction that must containt: +**POST** `/transactions` +**Request Body:** ```json { - "accountExternalIdDebit": "Guid", - "accountExternalIdCredit": "Guid", + "accountExternalIdDebit": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "accountExternalIdCredit": "7ba95f64-8821-4562-b3fc-2c963f66afa9", "tranferTypeId": 1, - "value": 120 + "value": 500.50 } ``` -2. Resource to retrieve a transaction +**Response (201 Created):** +```json +{ + "transactionExternalId": "9ca85f64-1234-4562-b3fc-2c963f66afa1", + "transactionType": { + "name": "transfer" + }, + "transactionStatus": { + "name": "pending" + }, + "value": 500.50, + "createdAt": "2026-01-03T10:30:00" +} +``` + +### 2. Obtener Transacción + +**GET** `/transactions/{transactionExternalId}` +**Response (200 OK):** ```json { - "transactionExternalId": "Guid", + "transactionExternalId": "9ca85f64-1234-4562-b3fc-2c963f66afa1", "transactionType": { - "name": "" + "name": "transfer" }, "transactionStatus": { - "name": "" + "name": "approved" }, - "value": 120, - "createdAt": "Date" + "value": 500.50, + "createdAt": "2026-01-03T10:30:00" +} +``` + +**Response (404 Not Found):** +```json +{ + "error": "Transaction not found", + "message": "Transaction not found with id: 9ca85f64-1234-4562-b3fc-2c963f66afa1", + "timestamp": "2026-01-03T10:30:00Z" } ``` -## Optional +--- + +## Testing Manual con cURL + +### Crear transacción APROBADA (value <= 1000) + +```powershell +curl -X POST http://localhost:8080/api/v1/transactions ` + -H "Content-Type: application/json" ` + -d '{ + "accountExternalIdDebit": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "accountExternalIdCredit": "7ba95f64-8821-4562-b3fc-2c963f66afa9", + "tranferTypeId": 1, + "value": 800.50 + }' +``` + +### Crear transacción RECHAZADA (value > 1000) + +```powershell +curl -X POST http://localhost:8080/api/v1/transactions ` + -H "Content-Type: application/json" ` + -d '{ + "accountExternalIdDebit": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "accountExternalIdCredit": "7ba95f64-8821-4562-b3fc-2c963f66afa9", + "tranferTypeId": 1, + "value": 1500.00 + }' +``` + +### Consultar transacción + +```powershell +# Reemplazar {UUID} con el transactionExternalId recibido +curl http://localhost:8080/api/v1/transactions/{UUID} +``` + +--- + +## Flujo de Procesamiento + +1. **Cliente crea transacción** → POST a Transaction Service +2. **Transaction Service:** + - Valida datos + - Guarda transacción con estado `pending` en PostgreSQL + - Publica evento `transaction-created` en Kafka + - Retorna respuesta al cliente +3. **Anti-Fraud Service:** + - Consume evento `transaction-created` + - Aplica regla: `value > 1000 ? "rejected" : "approved"` + - Publica evento `transaction-status-updated` en Kafka +4. **Transaction Service:** + - Consume evento `transaction-status-updated` + - Actualiza estado de transacción en PostgreSQL +5. **Cliente consulta transacción** → GET a Transaction Service + - Retorna transacción con estado actualizado + +--- + +## Arquitectura del Código + +### Transaction Service + +``` +transaction-service/ +├── domain/ # Lógica de negocio pura +│ ├── model/ # Entidades de dominio +│ ├── port/ # Interfaces (Hexagonal) +│ └── exception/ # Excepciones de dominio +├── application/ # Casos de uso +│ ├── dto/ # DTOs de aplicación +│ └── service/ # Servicios de aplicación +└── infrastructure/ # Adaptadores + ├── api/ # REST Controllers + ├── persistence/ # JPA + PostgreSQL + └── messaging/ # Kafka Producer/Consumer +``` + +### Anti-Fraud Service + +``` +anti-fraud-service/ +├── domain/ # Lógica de validación +│ └── service/ # FraudValidatorService +└── infrastructure/ # Adaptadores + └── messaging/ # Kafka Consumer/Producer + ├── consumer/ + ├── producer/ + └── config/ +``` + +--- + +## Modelo de Datos + +### Estados de Transacción +- `pending`: Estado inicial al crear la transacción +- `approved`: Transacción aprobada por anti-fraude (value <= 1000) +- `rejected`: Transacción rechazada por anti-fraude (value > 1000) + +### Tipos de Transacción +- `1`: Transfer +- `2`: Deposit +- `3`: Withdrawal + +--- + +## Health Checks + +### Transaction Service +```bash +curl http://localhost:8080/actuator/health +``` + +### Anti-Fraud Service +```bash +curl http://localhost:8081/actuator/health +``` + + + +## Limpieza + +```powershell +# Detener servicios +docker-compose down + +# Detener servicios y eliminar volúmenes (borra datos) +docker-compose down -v + +# Limpiar build de Maven +mvn clean +``` + +--- + +## Características Implementadas + +- ✅ Clean Architecture / Hexagonal Pattern +- ✅ Separación de capas (Domain, Application, Infrastructure) +- ✅ Comunicación asíncrona con Kafka +- ✅ Persistencia con PostgreSQL + Flyway migrations +- ✅ Validación de datos con Bean Validation +- ✅ Manejo de errores global +- ✅ Optimistic Locking para concurrencia +- ✅ Health checks con Spring Actuator +- ✅ Logging estructurado +- ✅ Configuración por ambientes +- ✅ Docker Compose para infraestructura -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? +--- -You can use Graphql; +## Autor -# Send us your challenge +**Ricardo** - Reto Técnico Yape Backend Java -When you finish your challenge, after forking a repository, you **must** open a pull request to our repository. There are no limitations to the implementation, you can follow the programming paradigm, modularization, and style that you feel is the most appropriate solution. +---. -If you have any questions, please let us know. diff --git a/anti-fraud-service/pom.xml b/anti-fraud-service/pom.xml new file mode 100644 index 0000000000..59d2fccaba --- /dev/null +++ b/anti-fraud-service/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + + com.yape + transaction-system + 1.0.0 + + + anti-fraud-service + Anti-Fraud Service + Servicio de validación anti-fraude + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.kafka + spring-kafka + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.kafka + spring-kafka-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java b/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java new file mode 100644 index 0000000000..5c25b612c8 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/AntiFraudServiceApplication.java @@ -0,0 +1,18 @@ +package com.yape.antifraud; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.kafka.annotation.EnableKafka; + +/** + * Aplicación principal - Anti-Fraud Service + */ +@EnableKafka +@SpringBootApplication +public class AntiFraudServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AntiFraudServiceApplication.class, args); + } +} + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/domain/service/FraudValidatorService.java b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/service/FraudValidatorService.java new file mode 100644 index 0000000000..883eef353b --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/domain/service/FraudValidatorService.java @@ -0,0 +1,59 @@ +package com.yape.antifraud.domain.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Servicio de validación anti-fraude + */ +@Slf4j +@Service +public class FraudValidatorService { + + @Value("${anti-fraud.max-transaction-value}") + private BigDecimal maxTransactionValue; + + /** + * Valida si una transacción es fraudulenta + * Regla: Si el valor es mayor a 1000, se rechaza + */ + public ValidationResult validate(UUID transactionId, BigDecimal transactionValue) { + log.info("Validating transaction {} with value: {}", transactionId, transactionValue); + + if (transactionValue.compareTo(maxTransactionValue) > 0) { + log.warn("Transaction {} REJECTED - Value {} exceeds threshold {}", + transactionId, transactionValue, maxTransactionValue); + return ValidationResult.rejected( + String.format("Transaction value %.2f exceeds fraud threshold (%.2f)", + transactionValue, maxTransactionValue) + ); + } + + log.info("Transaction {} APPROVED - Value {} is within threshold", + transactionId, transactionValue); + return ValidationResult.approved("Transaction passed fraud validation"); + } + + /** + * Resultado de la validación + */ + @lombok.Data + @lombok.AllArgsConstructor + public static class ValidationResult { + private String status; // "approved" or "rejected" + private String reason; + + public static ValidationResult approved(String reason) { + return new ValidationResult("approved", reason); + } + + public static ValidationResult rejected(String reason) { + return new ValidationResult("rejected", reason); + } + } +} + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/config/KafkaConfig.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/config/KafkaConfig.java new file mode 100644 index 0000000000..bad784dc23 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/config/KafkaConfig.java @@ -0,0 +1,77 @@ +package com.yape.antifraud.infrastructure.messaging.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.*; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuración de Kafka para Anti-Fraud Service + */ +@EnableKafka +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + // Producer Configuration + @Bean + public ProducerFactory producerFactory() { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + config.put(ProducerConfig.ACKS_CONFIG, "all"); + config.put(ProducerConfig.RETRIES_CONFIG, 3); + config.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + return new DefaultKafkaProducerFactory<>(config); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } + + // Consumer Configuration + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + config.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + config.put(JsonDeserializer.VALUE_DEFAULT_TYPE, "com.yape.antifraud.infrastructure.messaging.event.TransactionCreatedEvent"); + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } +} + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/consumer/TransactionCreatedConsumer.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/consumer/TransactionCreatedConsumer.java new file mode 100644 index 0000000000..587e984928 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/consumer/TransactionCreatedConsumer.java @@ -0,0 +1,64 @@ +package com.yape.antifraud.infrastructure.messaging.consumer; + +import com.yape.antifraud.domain.service.FraudValidatorService; +import com.yape.antifraud.infrastructure.messaging.event.TransactionCreatedEvent; +import com.yape.antifraud.infrastructure.messaging.event.TransactionStatusUpdatedEvent; +import com.yape.antifraud.infrastructure.messaging.producer.FraudResultProducer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Consumer de Kafka para eventos de transacciones creadas + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TransactionCreatedConsumer { + + private final FraudValidatorService fraudValidatorService; + private final FraudResultProducer fraudResultProducer; + + @KafkaListener( + topics = "${kafka.topics.transaction-created}", + groupId = "${spring.kafka.consumer.group-id}", + containerFactory = "kafkaListenerContainerFactory" + ) + public void consume(TransactionCreatedEvent event, Acknowledgment acknowledgment) { + try { + log.info("Received TransactionCreatedEvent: {}", event); + + // Validar transacción + FraudValidatorService.ValidationResult result = fraudValidatorService.validate( + event.getTransactionExternalId(), + event.getValue() + ); + + // Publicar resultado + TransactionStatusUpdatedEvent statusEvent = TransactionStatusUpdatedEvent.builder() + .eventId(UUID.randomUUID().toString()) + .eventType("TRANSACTION_STATUS_UPDATED") + .timestamp(java.time.Instant.now().toString()) + .correlationId(event.getTransactionExternalId()) + .transactionExternalId(event.getTransactionExternalId()) + .newStatus(result.getStatus()) + .reason(result.getReason()) + .build(); + + fraudResultProducer.publishValidationResult(statusEvent); + + // Confirmar mensaje procesado + acknowledgment.acknowledge(); + log.info("TransactionCreatedEvent processed successfully"); + + } catch (Exception e) { + log.error("Error processing TransactionCreatedEvent: {}", event, e); + acknowledgment.acknowledge(); // Confirmar para evitar loops infinitos + } + } +} + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/event/TransactionCreatedEvent.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/event/TransactionCreatedEvent.java new file mode 100644 index 0000000000..6ded1e13d5 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/event/TransactionCreatedEvent.java @@ -0,0 +1,28 @@ +package com.yape.antifraud.infrastructure.messaging.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Evento recibido cuando se crea una transacción + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionCreatedEvent { + private String eventId; + private String eventType; + private String timestamp; + private UUID transactionExternalId; + private UUID accountExternalIdDebit; + private UUID accountExternalIdCredit; + private Integer transferTypeId; + private BigDecimal value; +} + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/event/TransactionStatusUpdatedEvent.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/event/TransactionStatusUpdatedEvent.java new file mode 100644 index 0000000000..88c1c55521 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/event/TransactionStatusUpdatedEvent.java @@ -0,0 +1,26 @@ +package com.yape.antifraud.infrastructure.messaging.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * Evento publicado cuando se valida una transacción + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionStatusUpdatedEvent { + private String eventId; + private String eventType; + private String timestamp; + private UUID correlationId; + private UUID transactionExternalId; + private String newStatus; + private String reason; +} + diff --git a/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/producer/FraudResultProducer.java b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/producer/FraudResultProducer.java new file mode 100644 index 0000000000..6cdf600204 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/antifraud/infrastructure/messaging/producer/FraudResultProducer.java @@ -0,0 +1,46 @@ +package com.yape.antifraud.infrastructure.messaging.producer; + +import com.yape.antifraud.infrastructure.messaging.event.TransactionStatusUpdatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +/** + * Producer de Kafka para publicar resultados de validación + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FraudResultProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topics.transaction-status-updated}") + private String transactionStatusUpdatedTopic; + + public void publishValidationResult(TransactionStatusUpdatedEvent event) { + log.info("Publishing validation result for transaction: {}", event.getTransactionExternalId()); + + CompletableFuture> future = kafkaTemplate.send( + transactionStatusUpdatedTopic, + event.getTransactionExternalId().toString(), + event + ); + + future.whenComplete((result, ex) -> { + if (ex == null) { + log.info("Validation result published successfully for transaction: {} with status: {}", + event.getTransactionExternalId(), event.getNewStatus()); + } else { + log.error("Failed to publish validation result for transaction: {}", + event.getTransactionExternalId(), ex); + } + }); + } +} + diff --git a/anti-fraud-service/src/main/resources/application.yml b/anti-fraud-service/src/main/resources/application.yml new file mode 100644 index 0000000000..216d04eeb9 --- /dev/null +++ b/anti-fraud-service/src/main/resources/application.yml @@ -0,0 +1,55 @@ +spring: + application: + name: anti-fraud-service + + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: anti-fraud-service-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + enable-auto-commit: false + properties: + spring.json.trusted.packages: '*' + spring.json.type.mapping: transaction-created:com.yape.antifraud.infrastructure.messaging.event.TransactionCreatedEvent + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: all + retries: 3 + properties: + enable.idempotence: true + spring.json.add.type.headers: false + listener: + ack-mode: manual + +server: + port: 8081 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +logging: + level: + com.yape: INFO + org.springframework.kafka: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + +# Kafka Topics Configuration +kafka: + topics: + transaction-created: transaction-created + transaction-status-updated: transaction-status-updated + +# Anti-Fraud Business Rules +anti-fraud: + max-transaction-value: 1000 + diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..3b4e4fd276 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,17 +2,28 @@ version: "3.7" services: postgres: image: postgres:14 + container_name: yape-postgres ports: - "5432:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=transactiondb + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 zookeeper: image: confluentinc/cp-zookeeper:5.5.3 + container_name: yape-zookeeper environment: ZOOKEEPER_CLIENT_PORT: 2181 kafka: image: confluentinc/cp-enterprise-kafka:5.5.3 + container_name: yape-kafka depends_on: [zookeeper] environment: KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" @@ -23,3 +34,11 @@ services: KAFKA_JMX_PORT: 9991 ports: - 9092:9092 + healthcheck: + test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092"] + interval: 10s + timeout: 10s + retries: 5 + +volumes: + postgres-data: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000000..724316a6c5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.1 + + + + com.yape + transaction-system + 1.0.0 + pom + + Yape Transaction System + Sistema de transacciones con validación anti-fraude + + + transaction-service + anti-fraud-service + + + + 17 + 17 + 17 + UTF-8 + 3.2.1 + 1.18.30 + 1.5.5.Final + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + org.projectlombok + lombok + ${lombok.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + + + + diff --git a/transaction-service/pom.xml b/transaction-service/pom.xml new file mode 100644 index 0000000000..7f4dd46051 --- /dev/null +++ b/transaction-service/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + + com.yape + transaction-system + 1.0.0 + + + transaction-service + Transaction Service + Servicio de gestión de transacciones + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.kafka + spring-kafka + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.postgresql + postgresql + runtime + + + org.flywaydb + flyway-core + + + + + org.projectlombok + lombok + true + + + org.mapstruct + mapstruct + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.kafka + spring-kafka-test + test + + + org.testcontainers + testcontainers + 1.19.3 + test + + + org.testcontainers + postgresql + 1.19.3 + test + + + org.testcontainers + kafka + 1.19.3 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + 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..43cb70d70f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/TransactionServiceApplication.java @@ -0,0 +1,18 @@ +package com.yape.transaction; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.kafka.annotation.EnableKafka; + +/** + * Aplicación principal - Transaction Service + */ +@EnableKafka +@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/dto/CreateTransactionCommand.java b/transaction-service/src/main/java/com/yape/transaction/application/dto/CreateTransactionCommand.java new file mode 100644 index 0000000000..bb4a66f93f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/application/dto/CreateTransactionCommand.java @@ -0,0 +1,24 @@ +package com.yape.transaction.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Comando para crear una transacción + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateTransactionCommand { + private UUID accountExternalIdDebit; + private UUID accountExternalIdCredit; + private Integer tranferTypeId; + private BigDecimal value; +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/application/dto/TransactionResponse.java b/transaction-service/src/main/java/com/yape/transaction/application/dto/TransactionResponse.java new file mode 100644 index 0000000000..769f1b95bc --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/application/dto/TransactionResponse.java @@ -0,0 +1,42 @@ +package com.yape.transaction.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * DTO de respuesta de transacción + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionResponse { + private UUID transactionExternalId; + private TransactionTypeDTO transactionType; + private TransactionStatusDTO transactionStatus; + private BigDecimal value; + private LocalDateTime createdAt; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TransactionTypeDTO { + private String name; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TransactionStatusDTO { + private String name; + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/application/service/TransactionService.java b/transaction-service/src/main/java/com/yape/transaction/application/service/TransactionService.java new file mode 100644 index 0000000000..3abbdc964a --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/application/service/TransactionService.java @@ -0,0 +1,146 @@ +package com.yape.transaction.application.service; + +import com.yape.transaction.application.dto.CreateTransactionCommand; +import com.yape.transaction.application.dto.TransactionResponse; +import com.yape.transaction.domain.exception.InvalidTransactionException; +import com.yape.transaction.domain.exception.TransactionNotFoundException; +import com.yape.transaction.domain.model.Transaction; +import com.yape.transaction.domain.model.TransactionType; +import com.yape.transaction.domain.port.EventPublisher; +import com.yape.transaction.domain.port.TransactionRepository; +import com.yape.transaction.infrastructure.messaging.event.TransactionCreatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Servicio de aplicación para gestión de transacciones (Use Cases) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TransactionService { + + private final TransactionRepository transactionRepository; + private final EventPublisher eventPublisher; + + @Value("${kafka.topics.transaction-created}") + private String transactionCreatedTopic; + + /** + * Caso de uso: Crear transacción + */ + @Transactional + public TransactionResponse createTransaction(CreateTransactionCommand command) { + log.info("Creating transaction for debit account: {}", command.getAccountExternalIdDebit()); + + // Validaciones de negocio + validateTransactionCommand(command); + + // Crear transacción en estado PENDING + TransactionType transactionType = TransactionType.fromId(command.getTranferTypeId()); + Transaction transaction = Transaction.createPending( + command.getAccountExternalIdDebit(), + command.getAccountExternalIdCredit(), + transactionType, + command.getValue() + ); + + // Persistir transacción + Transaction savedTransaction = transactionRepository.save(transaction); + log.info("Transaction created with ID: {}", savedTransaction.getTransactionExternalId()); + + // Publicar evento a Kafka + publishTransactionCreatedEvent(savedTransaction); + + // Retornar respuesta + return mapToResponse(savedTransaction); + } + + /** + * Caso de uso: Obtener transacción por ID externo + */ + @Transactional(readOnly = true) + public TransactionResponse getTransaction(UUID transactionExternalId) { + log.info("Getting transaction: {}", transactionExternalId); + + Transaction transaction = transactionRepository.findByExternalId(transactionExternalId) + .orElseThrow(() -> new TransactionNotFoundException(transactionExternalId)); + + return mapToResponse(transaction); + } + + /** + * Actualiza el estado de una transacción (llamado por el consumer de Kafka) + */ + @Transactional + public void updateTransactionStatus(UUID transactionExternalId, String newStatus) { + log.info("Updating transaction {} status to: {}", transactionExternalId, newStatus); + + Transaction transaction = transactionRepository.findByExternalId(transactionExternalId) + .orElseThrow(() -> new TransactionNotFoundException(transactionExternalId)); + + // Actualizar estado + transaction.updateStatus(com.yape.transaction.domain.model.TransactionStatus.fromName(newStatus)); + + // Persistir cambios + transactionRepository.update(transaction); + log.info("Transaction {} updated to status: {}", transactionExternalId, newStatus); + } + + private void validateTransactionCommand(CreateTransactionCommand command) { + if (command.getAccountExternalIdDebit() == null) { + throw new InvalidTransactionException("Debit account ID cannot be null"); + } + if (command.getAccountExternalIdCredit() == null) { + throw new InvalidTransactionException("Credit account ID cannot be null"); + } + if (command.getValue() == null || command.getValue().compareTo(BigDecimal.ZERO) <= 0) { + throw new InvalidTransactionException("Transaction value must be greater than zero"); + } + if (command.getTranferTypeId() == null) { + throw new InvalidTransactionException("Transfer type ID cannot be null"); + } + } + + private void publishTransactionCreatedEvent(Transaction transaction) { + TransactionCreatedEvent event = TransactionCreatedEvent.builder() + .eventId(UUID.randomUUID().toString()) + .eventType("TRANSACTION_CREATED") + .timestamp(java.time.Instant.now().toString()) + .transactionExternalId(transaction.getTransactionExternalId()) + .accountExternalIdDebit(transaction.getAccountExternalIdDebit()) + .accountExternalIdCredit(transaction.getAccountExternalIdCredit()) + .transferTypeId(transaction.getTransactionType().getId()) + .value(transaction.getValue()) + .build(); + + eventPublisher.publish( + transactionCreatedTopic, + transaction.getTransactionExternalId().toString(), + event + ); + + log.info("Published TransactionCreatedEvent for transaction: {}", transaction.getTransactionExternalId()); + } + + private TransactionResponse mapToResponse(Transaction transaction) { + return TransactionResponse.builder() + .transactionExternalId(transaction.getTransactionExternalId()) + .transactionType(TransactionResponse.TransactionTypeDTO.builder() + .name(transaction.getTransactionType().getName()) + .build()) + .transactionStatus(TransactionResponse.TransactionStatusDTO.builder() + .name(transaction.getTransactionStatus().getName()) + .build()) + .value(transaction.getValue()) + .createdAt(transaction.getCreatedAt()) + .build(); + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/exception/DomainException.java b/transaction-service/src/main/java/com/yape/transaction/domain/exception/DomainException.java new file mode 100644 index 0000000000..184d853513 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/exception/DomainException.java @@ -0,0 +1,16 @@ +package com.yape.transaction.domain.exception; + +/** + * Excepción base de dominio + */ +public class DomainException extends RuntimeException { + + public DomainException(String message) { + super(message); + } + + public DomainException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/exception/InvalidTransactionException.java b/transaction-service/src/main/java/com/yape/transaction/domain/exception/InvalidTransactionException.java new file mode 100644 index 0000000000..3b607bda5a --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/exception/InvalidTransactionException.java @@ -0,0 +1,12 @@ +package com.yape.transaction.domain.exception; + +/** + * Excepción cuando los datos de la transacción son inválidos + */ +public class InvalidTransactionException extends DomainException { + + public InvalidTransactionException(String message) { + super(message); + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/exception/TransactionNotFoundException.java b/transaction-service/src/main/java/com/yape/transaction/domain/exception/TransactionNotFoundException.java new file mode 100644 index 0000000000..4dd92ad08c --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/exception/TransactionNotFoundException.java @@ -0,0 +1,14 @@ +package com.yape.transaction.domain.exception; + +import java.util.UUID; + +/** + * Excepción cuando no se encuentra una transacción + */ +public class TransactionNotFoundException extends DomainException { + + public TransactionNotFoundException(UUID transactionExternalId) { + super("Transaction not found with id: " + transactionExternalId); + } +} + 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..508d64c8a0 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/Transaction.java @@ -0,0 +1,83 @@ +package com.yape.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entidad de dominio Transaction - Modelo rico con lógica de negocio + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Transaction { + + private Long id; + private UUID transactionExternalId; + private UUID accountExternalIdDebit; + private UUID accountExternalIdCredit; + private TransactionType transactionType; + private TransactionStatus transactionStatus; + private BigDecimal value; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long version; + + /** + * Crea una nueva transacción en estado PENDING + */ + public static Transaction createPending( + UUID accountExternalIdDebit, + UUID accountExternalIdCredit, + TransactionType transactionType, + BigDecimal value) { + + return Transaction.builder() + .transactionExternalId(UUID.randomUUID()) + .accountExternalIdDebit(accountExternalIdDebit) + .accountExternalIdCredit(accountExternalIdCredit) + .transactionType(transactionType) + .transactionStatus(TransactionStatus.PENDING) + .value(value) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .version(0L) + .build(); + } + + /** + * Actualiza el estado de la transacción + */ + public void updateStatus(TransactionStatus newStatus) { + this.transactionStatus = newStatus; + this.updatedAt = LocalDateTime.now(); + } + + /** + * Verifica si la transacción está pendiente + */ + public boolean isPending() { + return TransactionStatus.PENDING.equals(this.transactionStatus); + } + + /** + * Verifica si la transacción está aprobada + */ + public boolean isApproved() { + return TransactionStatus.APPROVED.equals(this.transactionStatus); + } + + /** + * Verifica si la transacción está rechazada + */ + public boolean isRejected() { + return TransactionStatus.REJECTED.equals(this.transactionStatus); + } +} + 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..d202287f93 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionStatus.java @@ -0,0 +1,27 @@ +package com.yape.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Enum de estados de transacción + */ +@Getter +@AllArgsConstructor +public enum TransactionStatus { + PENDING("pending"), + APPROVED("approved"), + REJECTED("rejected"); + + private final String name; + + public static TransactionStatus fromName(String name) { + for (TransactionStatus status : values()) { + if (status.name.equalsIgnoreCase(name)) { + return status; + } + } + throw new IllegalArgumentException("Invalid transaction status: " + name); + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionType.java b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionType.java new file mode 100644 index 0000000000..0f0148a2a3 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/model/TransactionType.java @@ -0,0 +1,37 @@ +package com.yape.transaction.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Enum de tipos de transacción + */ +@Getter +@AllArgsConstructor +public enum TransactionType { + TRANSFER(1, "transfer"), + DEPOSIT(2, "deposit"), + WITHDRAWAL(3, "withdrawal"); + + private final int id; + private final String name; + + public static TransactionType fromId(int id) { + for (TransactionType type : values()) { + if (type.id == id) { + return type; + } + } + throw new IllegalArgumentException("Invalid transaction type id: " + id); + } + + public static TransactionType fromName(String name) { + for (TransactionType type : values()) { + if (type.name.equalsIgnoreCase(name)) { + return type; + } + } + throw new IllegalArgumentException("Invalid transaction type: " + name); + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/EventPublisher.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/EventPublisher.java new file mode 100644 index 0000000000..c1585f08a6 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/EventPublisher.java @@ -0,0 +1,13 @@ +package com.yape.transaction.domain.port; + +/** + * Port de salida para publicación de eventos (Hexagonal Architecture) + */ +public interface EventPublisher { + + /** + * Publica un evento en el sistema de mensajería + */ + void publish(String topic, String key, T event); +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/domain/port/TransactionRepository.java b/transaction-service/src/main/java/com/yape/transaction/domain/port/TransactionRepository.java new file mode 100644 index 0000000000..a54ec4b6cf --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/domain/port/TransactionRepository.java @@ -0,0 +1,28 @@ +package com.yape.transaction.domain.port; + +import com.yape.transaction.domain.model.Transaction; + +import java.util.Optional; +import java.util.UUID; + +/** + * Port de salida para persistencia de transacciones (Hexagonal Architecture) + */ +public interface TransactionRepository { + + /** + * Guarda una transacción + */ + Transaction save(Transaction transaction); + + /** + * Busca una transacción por su ID externo + */ + Optional findByExternalId(UUID transactionExternalId); + + /** + * Actualiza una transacción existente + */ + Transaction update(Transaction transaction); +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/controller/TransactionController.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/controller/TransactionController.java new file mode 100644 index 0000000000..d006aeee6f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/controller/TransactionController.java @@ -0,0 +1,62 @@ +package com.yape.transaction.infrastructure.api.controller; + +import com.yape.transaction.application.dto.CreateTransactionCommand; +import com.yape.transaction.application.dto.TransactionResponse; +import com.yape.transaction.application.service.TransactionService; +import com.yape.transaction.infrastructure.api.dto.CreateTransactionRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * REST Controller para gestión de transacciones + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/transactions") +@RequiredArgsConstructor +public class TransactionController { + + private final TransactionService transactionService; + + /** + * Endpoint para crear una transacción + */ + @PostMapping + public ResponseEntity createTransaction( + @Valid @RequestBody CreateTransactionRequest request) { + + log.info("POST /api/v1/transactions - Creating transaction"); + + CreateTransactionCommand command = CreateTransactionCommand.builder() + .accountExternalIdDebit(request.getAccountExternalIdDebit()) + .accountExternalIdCredit(request.getAccountExternalIdCredit()) + .tranferTypeId(request.getTranferTypeId()) + .value(request.getValue()) + .build(); + + TransactionResponse response = transactionService.createTransaction(command); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * Endpoint para obtener una transacción por ID + */ + @GetMapping("/{transactionExternalId}") + public ResponseEntity getTransaction( + @PathVariable UUID transactionExternalId) { + + log.info("GET /api/v1/transactions/{} - Getting transaction", transactionExternalId); + + TransactionResponse response = transactionService.getTransaction(transactionExternalId); + + return ResponseEntity.ok(response); + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/dto/CreateTransactionRequest.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/dto/CreateTransactionRequest.java new file mode 100644 index 0000000000..62b819fec7 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/dto/CreateTransactionRequest.java @@ -0,0 +1,35 @@ +package com.yape.transaction.infrastructure.api.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * DTO de request para crear transacción + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateTransactionRequest { + + @NotNull(message = "accountExternalIdDebit is required") + private UUID accountExternalIdDebit; + + @NotNull(message = "accountExternalIdCredit is required") + private UUID accountExternalIdCredit; + + @NotNull(message = "tranferTypeId is required") + private Integer tranferTypeId; + + @NotNull(message = "value is required") + @DecimalMin(value = "0.01", message = "value must be greater than zero") + private BigDecimal value; +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/dto/ErrorResponse.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/dto/ErrorResponse.java new file mode 100644 index 0000000000..a1e990cb89 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/dto/ErrorResponse.java @@ -0,0 +1,20 @@ +package com.yape.transaction.infrastructure.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO de respuesta de error + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + private String error; + private String message; + private String timestamp; +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/exception/GlobalExceptionHandler.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000000..1dcd66107f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/api/exception/GlobalExceptionHandler.java @@ -0,0 +1,80 @@ +package com.yape.transaction.infrastructure.api.exception; + +import com.yape.transaction.domain.exception.InvalidTransactionException; +import com.yape.transaction.domain.exception.TransactionNotFoundException; +import com.yape.transaction.infrastructure.api.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; +import java.util.stream.Collectors; + +/** + * Manejador global de excepciones + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(TransactionNotFoundException.class) + public ResponseEntity handleTransactionNotFound(TransactionNotFoundException ex) { + log.error("Transaction not found: {}", ex.getMessage()); + + ErrorResponse error = ErrorResponse.builder() + .error("Transaction not found") + .message(ex.getMessage()) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + @ExceptionHandler(InvalidTransactionException.class) + public ResponseEntity handleInvalidTransaction(InvalidTransactionException ex) { + log.error("Invalid transaction: {}", ex.getMessage()); + + ErrorResponse error = ErrorResponse.builder() + .error("Invalid transaction") + .message(ex.getMessage()) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + String errors = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + log.error("Validation error: {}", errors); + + ErrorResponse error = ErrorResponse.builder() + .error("Validation error") + .message(errors) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error", ex); + + ErrorResponse error = ErrorResponse.builder() + .error("Internal server error") + .message("An unexpected error occurred") + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/config/KafkaConfig.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/config/KafkaConfig.java new file mode 100644 index 0000000000..c687738419 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/config/KafkaConfig.java @@ -0,0 +1,77 @@ +package com.yape.transaction.infrastructure.messaging.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.*; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuración de Kafka + */ +@EnableKafka +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + // Producer Configuration + @Bean + public ProducerFactory producerFactory() { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + config.put(ProducerConfig.ACKS_CONFIG, "all"); + config.put(ProducerConfig.RETRIES_CONFIG, 3); + config.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + return new DefaultKafkaProducerFactory<>(config); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } + + // Consumer Configuration + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + config.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + config.put(JsonDeserializer.VALUE_DEFAULT_TYPE, "com.yape.transaction.infrastructure.messaging.event.TransactionStatusUpdatedEvent"); + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/consumer/TransactionStatusConsumer.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/consumer/TransactionStatusConsumer.java new file mode 100644 index 0000000000..4928658df9 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/consumer/TransactionStatusConsumer.java @@ -0,0 +1,47 @@ +package com.yape.transaction.infrastructure.messaging.consumer; + +import com.yape.transaction.application.service.TransactionService; +import com.yape.transaction.infrastructure.messaging.event.TransactionStatusUpdatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +/** + * Consumer de Kafka para eventos de actualización de estado + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TransactionStatusConsumer { + + private final TransactionService transactionService; + + @KafkaListener( + topics = "${kafka.topics.transaction-status-updated}", + groupId = "${spring.kafka.consumer.group-id}", + containerFactory = "kafkaListenerContainerFactory" + ) + public void consume(TransactionStatusUpdatedEvent event, Acknowledgment acknowledgment) { + try { + log.info("Received TransactionStatusUpdatedEvent: {}", event); + + // Actualizar estado de la transacción + transactionService.updateTransactionStatus( + event.getTransactionExternalId(), + event.getNewStatus() + ); + + // Confirmar mensaje procesado + acknowledgment.acknowledge(); + log.info("TransactionStatusUpdatedEvent processed successfully"); + + } catch (Exception e) { + log.error("Error processing TransactionStatusUpdatedEvent: {}", event, e); + // En un escenario real, aquí implementarías retry logic o DLQ + acknowledgment.acknowledge(); // Por ahora, confirmamos para evitar loops infinitos + } + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/event/TransactionCreatedEvent.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/event/TransactionCreatedEvent.java new file mode 100644 index 0000000000..cc4bf8e33f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/event/TransactionCreatedEvent.java @@ -0,0 +1,28 @@ +package com.yape.transaction.infrastructure.messaging.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Evento publicado cuando se crea una transacción + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionCreatedEvent { + private String eventId; + private String eventType; + private String timestamp; + private UUID transactionExternalId; + private UUID accountExternalIdDebit; + private UUID accountExternalIdCredit; + private Integer transferTypeId; + private BigDecimal value; +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/event/TransactionStatusUpdatedEvent.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/event/TransactionStatusUpdatedEvent.java new file mode 100644 index 0000000000..47883e6d2d --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/event/TransactionStatusUpdatedEvent.java @@ -0,0 +1,26 @@ +package com.yape.transaction.infrastructure.messaging.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * Evento recibido cuando se actualiza el estado de una transacción + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionStatusUpdatedEvent { + private String eventId; + private String eventType; + private String timestamp; + private UUID correlationId; // transactionExternalId + private UUID transactionExternalId; + private String newStatus; + private String reason; +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/producer/KafkaEventPublisher.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/producer/KafkaEventPublisher.java new file mode 100644 index 0000000000..ebd8b51168 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/messaging/producer/KafkaEventPublisher.java @@ -0,0 +1,38 @@ +package com.yape.transaction.infrastructure.messaging.producer; + +import com.yape.transaction.domain.port.EventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +/** + * Adaptador de Kafka Producer - Implementa el port EventPublisher + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KafkaEventPublisher implements EventPublisher { + + private final KafkaTemplate kafkaTemplate; + + @Override + public void publish(String topic, String key, T event) { + log.info("Publishing event to topic: {} with key: {}", topic, key); + + CompletableFuture> future = kafkaTemplate.send(topic, key, event); + + future.whenComplete((result, ex) -> { + if (ex == null) { + log.info("Event published successfully to topic: {} with offset: {}", + topic, result.getRecordMetadata().offset()); + } else { + log.error("Failed to publish event to topic: {}", topic, ex); + } + }); + } +} + diff --git a/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/adapter/TransactionPersistenceAdapter.java b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/adapter/TransactionPersistenceAdapter.java new file mode 100644 index 0000000000..bbd882b42f --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/adapter/TransactionPersistenceAdapter.java @@ -0,0 +1,93 @@ +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.model.TransactionType; +import com.yape.transaction.domain.port.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 java.util.Optional; +import java.util.UUID; + +/** + * Adaptador de persistencia - Implementa el port TransactionRepository + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TransactionPersistenceAdapter implements TransactionRepository { + + private final JpaTransactionRepository jpaRepository; + + @Override + public Transaction save(Transaction transaction) { + TransactionEntity entity = toEntity(transaction); + TransactionEntity savedEntity = jpaRepository.save(entity); + return toDomain(savedEntity); + } + + @Override + public Optional findByExternalId(UUID transactionExternalId) { + return jpaRepository.findByTransactionExternalId(transactionExternalId) + .map(this::toDomain); + } + + @Override + public Transaction update(Transaction transaction) { + TransactionEntity entity = toEntity(transaction); + TransactionEntity updatedEntity = jpaRepository.save(entity); + return toDomain(updatedEntity); + } + + private TransactionEntity toEntity(Transaction transaction) { + return TransactionEntity.builder() + .id(transaction.getId()) + .transactionExternalId(transaction.getTransactionExternalId()) + .accountExternalIdDebit(transaction.getAccountExternalIdDebit()) + .accountExternalIdCredit(transaction.getAccountExternalIdCredit()) + .transferTypeId((long) transaction.getTransactionType().getId()) + .transactionStatusId(mapStatusToId(transaction.getTransactionStatus())) + .value(transaction.getValue()) + .version(transaction.getVersion()) + .createdAt(transaction.getCreatedAt()) + .updatedAt(transaction.getUpdatedAt()) + .build(); + } + + private Transaction toDomain(TransactionEntity entity) { + return Transaction.builder() + .id(entity.getId()) + .transactionExternalId(entity.getTransactionExternalId()) + .accountExternalIdDebit(entity.getAccountExternalIdDebit()) + .accountExternalIdCredit(entity.getAccountExternalIdCredit()) + .transactionType(TransactionType.fromId(entity.getTransferTypeId().intValue())) + .transactionStatus(mapIdToStatus(entity.getTransactionStatusId())) + .value(entity.getValue()) + .version(entity.getVersion()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + private Long mapStatusToId(TransactionStatus status) { + return switch (status) { + case PENDING -> 1L; + case APPROVED -> 2L; + case REJECTED -> 3L; + }; + } + + private TransactionStatus mapIdToStatus(Long id) { + return switch (id.intValue()) { + case 1 -> TransactionStatus.PENDING; + case 2 -> TransactionStatus.APPROVED; + case 3 -> TransactionStatus.REJECTED; + default -> throw new IllegalArgumentException("Invalid status id: " + id); + }; + } +} + 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..6ca59b3e46 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/entity/TransactionEntity.java @@ -0,0 +1,74 @@ +package com.yape.transaction.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entidad JPA para transacciones + */ +@Entity +@Table(name = "transaction") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "transaction_external_id", nullable = false, unique = true) + private UUID transactionExternalId; + + @Column(name = "account_external_id_debit", nullable = false) + private UUID accountExternalIdDebit; + + @Column(name = "account_external_id_credit", nullable = false) + private UUID accountExternalIdCredit; + + @Column(name = "transfer_type_id", nullable = false) + private Long transferTypeId; + + @Column(name = "transaction_status_id", nullable = false) + private Long transactionStatusId; + + @Column(name = "value", nullable = false, precision = 19, scale = 2) + private BigDecimal value; + + @Version + @Column(name = "version") + private Long version; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + if (transactionExternalId == null) { + transactionExternalId = UUID.randomUUID(); + } + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + if (updatedAt == null) { + updatedAt = LocalDateTime.now(); + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} + 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..52e08b3967 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/transaction/infrastructure/persistence/repository/JpaTransactionRepository.java @@ -0,0 +1,18 @@ +package com.yape.transaction.infrastructure.persistence.repository; + +import com.yape.transaction.infrastructure.persistence.entity.TransactionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +/** + * Repositorio JPA para transacciones + */ +@Repository +public interface JpaTransactionRepository extends JpaRepository { + + Optional findByTransactionExternalId(UUID transactionExternalId); +} + diff --git a/transaction-service/src/main/resources/application.yml b/transaction-service/src/main/resources/application.yml new file mode 100644 index 0000000000..494cc4bb73 --- /dev/null +++ b/transaction-service/src/main/resources/application.yml @@ -0,0 +1,78 @@ +spring: + application: + name: transaction-service + + datasource: + url: jdbc:postgresql://localhost:5432/transactiondb + username: postgres + password: postgres + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 10 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + + kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: all + retries: 3 + properties: + enable.idempotence: true + spring.json.add.type.headers: false + consumer: + group-id: transaction-service-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + enable-auto-commit: false + properties: + spring.json.trusted.packages: '*' + spring.json.type.mapping: transaction-status-updated:com.yape.transaction.infrastructure.messaging.event.TransactionStatusUpdatedEvent + listener: + ack-mode: manual + +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +logging: + level: + com.yape: INFO + org.springframework.kafka: INFO + org.hibernate.SQL: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + +# Kafka Topics Configuration +kafka: + topics: + transaction-created: transaction-created + transaction-status-updated: transaction-status-updated + diff --git a/transaction-service/src/main/resources/db/migration/V1__create_transaction_tables.sql b/transaction-service/src/main/resources/db/migration/V1__create_transaction_tables.sql new file mode 100644 index 0000000000..c510b0b53e --- /dev/null +++ b/transaction-service/src/main/resources/db/migration/V1__create_transaction_tables.sql @@ -0,0 +1,50 @@ +-- Tabla de tipos de transacción +CREATE TABLE transaction_type ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Tabla de estados de transacción +CREATE TABLE transaction_status ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(20) NOT NULL UNIQUE + CHECK (name IN ('pending', 'approved', 'rejected')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Tabla principal de transacciones +CREATE TABLE transaction ( + id BIGSERIAL PRIMARY KEY, + transaction_external_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + account_external_id_debit UUID NOT NULL, + account_external_id_credit UUID NOT NULL, + transfer_type_id BIGINT NOT NULL REFERENCES transaction_type(id), + transaction_status_id BIGINT NOT NULL REFERENCES transaction_status(id), + value DECIMAL(19,2) NOT NULL CHECK (value > 0), + version BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Índices para optimizar búsquedas +CREATE INDEX idx_transaction_external_id ON transaction(transaction_external_id); +CREATE INDEX idx_transaction_status ON transaction(transaction_status_id); +CREATE INDEX idx_transaction_created_at ON transaction(created_at DESC); +CREATE INDEX idx_transaction_debit_account ON transaction(account_external_id_debit); + +-- Función para actualizar updated_at automáticamente +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Trigger para actualizar updated_at en cada UPDATE +CREATE TRIGGER update_transaction_updated_at + BEFORE UPDATE ON transaction + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + diff --git a/transaction-service/src/main/resources/db/migration/V2__insert_initial_data.sql b/transaction-service/src/main/resources/db/migration/V2__insert_initial_data.sql new file mode 100644 index 0000000000..2a870f98d8 --- /dev/null +++ b/transaction-service/src/main/resources/db/migration/V2__insert_initial_data.sql @@ -0,0 +1,12 @@ +-- Insertar estados de transacción +INSERT INTO transaction_status (name) VALUES + ('pending'), + ('approved'), + ('rejected'); + +-- Insertar tipos de transacción +INSERT INTO transaction_type (name) VALUES + ('transfer'), + ('deposit'), + ('withdrawal'); +