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