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/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000000..2733fb773c --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000000..63e9001932 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000000..178046e38e --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000..82dbec8ad2 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..a9c09d3cb6 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..35eb1ddfbb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..1d2a032dcd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM maven:3.9.6-eclipse-temurin-17 AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline +COPY src ./src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/app-nodejs-codechallenge.iml b/app-nodejs-codechallenge.iml new file mode 100644 index 0000000000..68a9707efd --- /dev/null +++ b/app-nodejs-codechallenge.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000000..a8320df3e5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + com.transactions + transaction-system + 1.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.kafka + spring-kafka + + + + com.h2database + h2 + runtime + + + + + org.projectlombok + lombok + true + + + com.fasterxml.jackson.core + jackson-databind + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/src/main/java/com/transactions/TransactionApplication.java b/src/main/java/com/transactions/TransactionApplication.java new file mode 100644 index 0000000000..404cd1ab97 --- /dev/null +++ b/src/main/java/com/transactions/TransactionApplication.java @@ -0,0 +1,18 @@ +package com.transactions; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; + +@SpringBootApplication +@EnableAsync +public class TransactionApplication { + public static void main(String[] args) { + SpringApplication.run(TransactionApplication.class, args); + System.out.println("🚀 Transaction Service started on port 8080"); + System.out.println("📊 H2 Console: http://localhost:8080/h2-console"); + System.out.println("📝 JDBC URL: jdbc:h2:mem:transactionsdb"); + System.out.println("👤 Username: sa"); + System.out.println("🔑 Password: (empty)"); + } +} \ No newline at end of file diff --git a/src/main/java/com/transactions/controller/TransactionController.java b/src/main/java/com/transactions/controller/TransactionController.java new file mode 100644 index 0000000000..f9f24e243d --- /dev/null +++ b/src/main/java/com/transactions/controller/TransactionController.java @@ -0,0 +1,49 @@ +package com.transactions.controller; + +import com.transactions.model.dto.CreateTransactionRequest; +import com.transactions.model.dto.TransactionResponse; +import com.transactions.service.TransactionService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/transactions") +@RequiredArgsConstructor +public class TransactionController { + + private final TransactionService transactionService; + + @PostMapping + public ResponseEntity createTransaction( + @Valid @RequestBody CreateTransactionRequest request) { + TransactionResponse response = transactionService.createTransaction(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{id}") + public ResponseEntity getTransaction(@PathVariable UUID id) { + TransactionResponse response = transactionService.getTransaction(id); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> getAllTransactions() { + List responses = transactionService.getAllTransactions(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/health") + public ResponseEntity> healthCheck() { + return ResponseEntity.ok(Map.of( + "status", "ok", + "timestamp", java.time.Instant.now(), + "service", "transaction-anti-fraud-service" + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/transactions/model/Transaction.java b/src/main/java/com/transactions/model/Transaction.java new file mode 100644 index 0000000000..10ddf96cda --- /dev/null +++ b/src/main/java/com/transactions/model/Transaction.java @@ -0,0 +1,59 @@ +package com.transactions.model; + +import com.transactions.model.enums.TransactionStatus; +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "transactions") +@Data +public class Transaction { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "transaction_external_id", unique = true) + private UUID transactionExternalId; + + @Column(name = "account_external_id_debit") + private UUID accountExternalIdDebit; + + @Column(name = "account_external_id_credit") + private UUID accountExternalIdCredit; + + @Column(name = "transfer_type_id") + private Integer transferTypeId; + + @Column(name = "transaction_value", precision = 10, scale = 2) + private BigDecimal transactionValue; + + @Enumerated(EnumType.STRING) + private TransactionStatus status; + + @Column(name = "rejection_reason") + private String rejectionReason; + + @CreationTimestamp + @Column(name = "created_at") + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + if (transactionExternalId == null) { + transactionExternalId = UUID.randomUUID(); + } + if (status == null) { + status = TransactionStatus.PENDING; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/transactions/model/dto/CreateTransactionRequest.java b/src/main/java/com/transactions/model/dto/CreateTransactionRequest.java new file mode 100644 index 0000000000..daa189fca9 --- /dev/null +++ b/src/main/java/com/transactions/model/dto/CreateTransactionRequest.java @@ -0,0 +1,36 @@ +package com.transactions.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.*; +import lombok.Data; +import java.math.BigDecimal; +import java.util.UUID; + +@Data +public class CreateTransactionRequest { + + @NotNull(message = "accountExternalIdDebit is required") + @JsonProperty("accountExternalIdDebit") + private UUID accountExternalIdDebit; + + @NotNull(message = "accountExternalIdCredit is required") + @JsonProperty("accountExternalIdCredit") + private UUID accountExternalIdCredit; + + @NotNull(message = "transferTypeId is required") + @Min(value = 1, message = "transferTypeId must be at least 1") + @Max(value = 4, message = "transferTypeId must be at most 4") + @JsonProperty("transferTypeId") + private Integer transferTypeId; + + @NotNull(message = "value is required") + @Positive(message = "value must be positive") + @Digits(integer = 10, fraction = 2, message = "value must have max 10 integer digits and 2 decimal digits") + @JsonProperty("value") + private BigDecimal value; + + // Validación personalizada para el límite de 1000 + public boolean exceedsMaxAmount() { + return value != null && value.compareTo(new BigDecimal("1000")) > 0; + } +} \ No newline at end of file diff --git a/src/main/java/com/transactions/model/dto/TransactionResponse.java b/src/main/java/com/transactions/model/dto/TransactionResponse.java new file mode 100644 index 0000000000..88e77e44fb --- /dev/null +++ b/src/main/java/com/transactions/model/dto/TransactionResponse.java @@ -0,0 +1,49 @@ +package com.transactions.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +public class TransactionResponse { + + @JsonProperty("transactionExternalId") + private UUID transactionExternalId; + + @JsonProperty("transactionType") + private TransactionTypeDTO transactionType; + + @JsonProperty("transactionStatus") + private TransactionStatusDTO transactionStatus; + + @JsonProperty("value") + private BigDecimal value; + + @JsonProperty("createdAt") + private LocalDateTime createdAt; + + @Data + public static class TransactionTypeDTO { + private String name; + + public TransactionTypeDTO(Integer typeId) { + this.name = switch (typeId) { + case 1 -> "Deposit"; + case 2 -> "Withdrawal"; + case 3 -> "Transfer"; + default -> "Unknown"; + }; + } + } + + @Data + public static class TransactionStatusDTO { + private String name; + + public TransactionStatusDTO(String status) { + this.name = status; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/transactions/model/enums/TransactionStatus.java b/src/main/java/com/transactions/model/enums/TransactionStatus.java new file mode 100644 index 0000000000..de35917051 --- /dev/null +++ b/src/main/java/com/transactions/model/enums/TransactionStatus.java @@ -0,0 +1,7 @@ +package com.transactions.model.enums; + +public enum TransactionStatus { + PENDING, + APPROVED, + REJECTED +} \ No newline at end of file diff --git a/src/main/java/com/transactions/repository/TransactionRepository.java b/src/main/java/com/transactions/repository/TransactionRepository.java new file mode 100644 index 0000000000..7c540f2953 --- /dev/null +++ b/src/main/java/com/transactions/repository/TransactionRepository.java @@ -0,0 +1,12 @@ +package com.transactions.repository; + +import com.transactions.model.Transaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface TransactionRepository extends JpaRepository { + Optional findByTransactionExternalId(UUID transactionExternalId); +} \ No newline at end of file diff --git a/src/main/java/com/transactions/service/AntiFraudService.java b/src/main/java/com/transactions/service/AntiFraudService.java new file mode 100644 index 0000000000..7d02384a3b --- /dev/null +++ b/src/main/java/com/transactions/service/AntiFraudService.java @@ -0,0 +1,48 @@ +package com.transactions.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AntiFraudService { + + private static final BigDecimal MAX_AMOUNT = new BigDecimal("1000"); + private final TransactionService transactionService; + + @KafkaListener(topics = "transaction.created", groupId = "anti-fraud-group") + public void validateTransaction(Map message) { + try { + UUID transactionExternalId = UUID.fromString((String) message.get("transactionExternalId")); + BigDecimal value = new BigDecimal(message.get("value").toString()); + + log.info("Validating transaction: {}, value: {}", transactionExternalId, value); + + // Regla: valor > 1000 = rechazado + if (value.compareTo(MAX_AMOUNT) > 0) { + transactionService.updateTransactionStatus( + transactionExternalId, + com.transactions.model.enums.TransactionStatus.REJECTED, + "Transaction value exceeds maximum allowed limit of 1000" + ); + log.warn("Transaction {} REJECTED: value {} > {}", + transactionExternalId, value, MAX_AMOUNT); + } else { + transactionService.updateTransactionStatus( + transactionExternalId, + com.transactions.model.enums.TransactionStatus.APPROVED, + null + ); + log.info("Transaction {} APPROVED", transactionExternalId); + } + } catch (Exception e) { + log.error("Error validating transaction: {}", e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/transactions/service/TransactionService.java b/src/main/java/com/transactions/service/TransactionService.java new file mode 100644 index 0000000000..8a085b3daf --- /dev/null +++ b/src/main/java/com/transactions/service/TransactionService.java @@ -0,0 +1,85 @@ +package com.transactions.service; + +import com.transactions.model.Transaction; +import com.transactions.model.dto.CreateTransactionRequest; +import com.transactions.model.dto.TransactionResponse; +import com.transactions.model.enums.TransactionStatus; +import com.transactions.repository.TransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TransactionService { + + private final TransactionRepository transactionRepository; + private final KafkaTemplate kafkaTemplate; + + @Transactional + public TransactionResponse createTransaction(CreateTransactionRequest request) { + log.info("Creating transaction with value: {}", request.getValue()); + + Transaction transaction = new Transaction(); + transaction.setAccountExternalIdDebit(request.getAccountExternalIdDebit()); + transaction.setAccountExternalIdCredit(request.getAccountExternalIdCredit()); + transaction.setTransferTypeId(request.getTransferTypeId()); + transaction.setTransactionValue(request.getValue()); + transaction.setStatus(TransactionStatus.PENDING); + + Transaction savedTransaction = transactionRepository.save(transaction); + + // Enviar evento a Kafka para validación antifraude + kafkaTemplate.send("transaction.created", Map.of( + "transactionExternalId", savedTransaction.getTransactionExternalId(), + "value", savedTransaction.getTransactionValue(), + "timestamp", java.time.Instant.now() + )); + + log.info("Transaction created: {}", savedTransaction.getTransactionExternalId()); + + return mapToResponse(savedTransaction); + } + + public TransactionResponse getTransaction(UUID transactionExternalId) { + Transaction transaction = transactionRepository.findByTransactionExternalId(transactionExternalId) + .orElseThrow(() -> new RuntimeException("Transaction not found")); + + return mapToResponse(transaction); + } + + public List getAllTransactions() { + return transactionRepository.findAll().stream() + .map(this::mapToResponse) + .collect(Collectors.toList()); + } + + @Transactional + public void updateTransactionStatus(UUID transactionExternalId, TransactionStatus status, String rejectionReason) { + transactionRepository.findByTransactionExternalId(transactionExternalId) + .ifPresent(transaction -> { + transaction.setStatus(status); + transaction.setRejectionReason(rejectionReason); + transactionRepository.save(transaction); + log.info("Transaction {} updated to {}", transactionExternalId, status); + }); + } + + private TransactionResponse mapToResponse(Transaction transaction) { + TransactionResponse response = new TransactionResponse(); + response.setTransactionExternalId(transaction.getTransactionExternalId()); + response.setTransactionType(new TransactionResponse.TransactionTypeDTO(transaction.getTransferTypeId())); + response.setTransactionStatus(new TransactionResponse.TransactionStatusDTO(transaction.getStatus().name())); + response.setValue(transaction.getTransactionValue()); + response.setCreatedAt(transaction.getCreatedAt()); + return response; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000000..0298b3d781 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,45 @@ +spring: + application: + name: transaction-service + + datasource: + url: jdbc:h2:mem:transactionsdb + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + h2: + console: + enabled: true + path: /h2-console + settings: + trace: false + web-allow-others: false + + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: anti-fraud-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + +server: + port: 8080 + +logging: + level: + com.transactions: DEBUG + org.springframework.kafka: INFO \ No newline at end of file diff --git a/target/classes/application.yml b/target/classes/application.yml new file mode 100644 index 0000000000..0298b3d781 --- /dev/null +++ b/target/classes/application.yml @@ -0,0 +1,45 @@ +spring: + application: + name: transaction-service + + datasource: + url: jdbc:h2:mem:transactionsdb + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + h2: + console: + enabled: true + path: /h2-console + settings: + trace: false + web-allow-others: false + + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: anti-fraud-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + +server: + port: 8080 + +logging: + level: + com.transactions: DEBUG + org.springframework.kafka: INFO \ No newline at end of file diff --git a/target/classes/com/transactions/TransactionApplication.class b/target/classes/com/transactions/TransactionApplication.class new file mode 100644 index 0000000000..c857f9fcf9 Binary files /dev/null and b/target/classes/com/transactions/TransactionApplication.class differ diff --git a/target/classes/com/transactions/controller/TransactionController.class b/target/classes/com/transactions/controller/TransactionController.class new file mode 100644 index 0000000000..fb12bfcefe Binary files /dev/null and b/target/classes/com/transactions/controller/TransactionController.class differ diff --git a/target/classes/com/transactions/model/Transaction.class b/target/classes/com/transactions/model/Transaction.class new file mode 100644 index 0000000000..856bc144ff Binary files /dev/null and b/target/classes/com/transactions/model/Transaction.class differ diff --git a/target/classes/com/transactions/model/dto/CreateTransactionRequest.class b/target/classes/com/transactions/model/dto/CreateTransactionRequest.class new file mode 100644 index 0000000000..5b41a19a55 Binary files /dev/null and b/target/classes/com/transactions/model/dto/CreateTransactionRequest.class differ diff --git a/target/classes/com/transactions/model/dto/TransactionResponse$TransactionStatusDTO.class b/target/classes/com/transactions/model/dto/TransactionResponse$TransactionStatusDTO.class new file mode 100644 index 0000000000..72651bd551 Binary files /dev/null and b/target/classes/com/transactions/model/dto/TransactionResponse$TransactionStatusDTO.class differ diff --git a/target/classes/com/transactions/model/dto/TransactionResponse$TransactionTypeDTO.class b/target/classes/com/transactions/model/dto/TransactionResponse$TransactionTypeDTO.class new file mode 100644 index 0000000000..9b42685f3f Binary files /dev/null and b/target/classes/com/transactions/model/dto/TransactionResponse$TransactionTypeDTO.class differ diff --git a/target/classes/com/transactions/model/dto/TransactionResponse.class b/target/classes/com/transactions/model/dto/TransactionResponse.class new file mode 100644 index 0000000000..7c77abacef Binary files /dev/null and b/target/classes/com/transactions/model/dto/TransactionResponse.class differ diff --git a/target/classes/com/transactions/model/enums/TransactionStatus.class b/target/classes/com/transactions/model/enums/TransactionStatus.class new file mode 100644 index 0000000000..70bbffd1fa Binary files /dev/null and b/target/classes/com/transactions/model/enums/TransactionStatus.class differ diff --git a/target/classes/com/transactions/repository/TransactionRepository.class b/target/classes/com/transactions/repository/TransactionRepository.class new file mode 100644 index 0000000000..873f615814 Binary files /dev/null and b/target/classes/com/transactions/repository/TransactionRepository.class differ diff --git a/target/classes/com/transactions/service/AntiFraudService.class b/target/classes/com/transactions/service/AntiFraudService.class new file mode 100644 index 0000000000..fe0045ff04 Binary files /dev/null and b/target/classes/com/transactions/service/AntiFraudService.class differ diff --git a/target/classes/com/transactions/service/TransactionService.class b/target/classes/com/transactions/service/TransactionService.class new file mode 100644 index 0000000000..94d3c0f77e Binary files /dev/null and b/target/classes/com/transactions/service/TransactionService.class differ diff --git a/transactions.db b/transactions.db new file mode 100644 index 0000000000..e69de29bb2