From dd5790c6733614bff7e463d70058b36e3506a623 Mon Sep 17 00:00:00 2001 From: msucre Date: Sun, 11 Jan 2026 21:41:21 -0500 Subject: [PATCH] Yape challenge implementation --- .gitignore | 141 +++-------- Dockerfile.antifraud | 27 ++ Dockerfile.transaction | 31 +++ README.md | 234 ++++++++++++++---- YapeChallenge/AntiFraudManager/pom.xml | 54 ++++ .../antifraud/AntiFraudApplication.java | 13 + .../transaction/ValidateTransactionCase.java | 36 +++ .../domain/service/EventService.java | 15 ++ .../service/ValidateTransactionService.java | 8 + .../kafka/configuration/KafkaConfig.java | 48 ++++ .../kafka/service/KafkaEventService.java | 46 ++++ .../ValidateTransactionServiceImpl.java | 16 ++ .../src/main/resources/application.properties | 7 + .../antifraud/AntiFraudApplicationTests.java | 9 + YapeChallenge/SharedModel/pom.xml | 32 +++ .../model/dto/event/TransactionEventDto.java | 4 + .../event/TransactionEventResponseDto.java | 4 + .../model/kafka/KafkaSharedConstants.java | 12 + .../src/main/resources/application.properties | 1 + YapeChallenge/TransactionManager/pom.xml | 75 ++++++ .../TransactionManagerApplication.java | 17 ++ .../transaction/CreateTransactionCase.java | 60 +++++ .../transaction/FindTransactionCase.java | 27 ++ .../FindTransactionsByAccountCase.java | 22 ++ .../UpdateValidatedTransactionCase.java | 50 ++++ .../transaction/domain/model/Transaction.java | 23 ++ .../domain/model/type/TransactionStatus.java | 7 + .../domain/model/type/TransactionType.java | 5 + .../model/ui/CreateTransactionResponse.java | 7 + .../TransactionByAccountRepository.java | 17 ++ .../TransactionCacheRepository.java | 14 ++ .../repository/TransactionRepository.java | 16 ++ .../domain/service/EventService.java | 14 ++ .../entity/TransactionByAccountEntity.java | 34 +++ .../mapper/TransactionByAccountMapper.java | 31 +++ .../TransactionByAccountEntityRepository.java | 23 ++ .../TransactionByAccountRepositoryImpl.java | 42 ++++ .../controller/TransactionController.java | 45 ++++ .../kafka/configuration/KafkaConfig.java | 47 ++++ .../kafka/service/KafkaEventService.java | 47 ++++ .../postgresql/entity/AccountEntity.java | 19 ++ .../postgresql/entity/TransactionEntity.java | 31 +++ .../postgresql/mapper/TransactionMapper.java | 31 +++ .../TransactionEntityRepository.java | 12 + .../impl/TransactionRepositoryImpl.java | 43 ++++ .../redis/config/RedisConfig.java | 23 ++ .../RedisTransactionCacheRepository.java | 43 ++++ .../src/main/resources/application.properties | 21 ++ .../TransactionManagerApplicationTests.java | 9 + YapeChallenge/pom.xml | 79 ++++++ docker-compose.yml | 91 ++++++- docker/cassandra/init/baseline.cql | 12 + docker/postgres/init/baseline.sql | 9 + 53 files changed, 1625 insertions(+), 159 deletions(-) create mode 100644 Dockerfile.antifraud create mode 100644 Dockerfile.transaction create mode 100644 YapeChallenge/AntiFraudManager/pom.xml create mode 100644 YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/AntiFraudApplication.java create mode 100644 YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/application/usecase/transaction/ValidateTransactionCase.java create mode 100644 YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/domain/service/EventService.java create mode 100644 YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/domain/service/ValidateTransactionService.java create mode 100644 YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/kafka/configuration/KafkaConfig.java create mode 100644 YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/kafka/service/KafkaEventService.java create mode 100644 YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/validation/service/ValidateTransactionServiceImpl.java create mode 100644 YapeChallenge/AntiFraudManager/src/main/resources/application.properties create mode 100644 YapeChallenge/AntiFraudManager/src/test/java/com/msucre/antifraud/AntiFraudApplicationTests.java create mode 100644 YapeChallenge/SharedModel/pom.xml create mode 100644 YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/dto/event/TransactionEventDto.java create mode 100644 YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/dto/event/TransactionEventResponseDto.java create mode 100644 YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/kafka/KafkaSharedConstants.java create mode 100644 YapeChallenge/SharedModel/src/main/resources/application.properties create mode 100644 YapeChallenge/TransactionManager/pom.xml create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/TransactionManagerApplication.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/CreateTransactionCase.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/FindTransactionCase.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/FindTransactionsByAccountCase.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/UpdateValidatedTransactionCase.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/Transaction.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/type/TransactionStatus.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/type/TransactionType.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/ui/CreateTransactionResponse.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionByAccountRepository.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionCacheRepository.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionRepository.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/service/EventService.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/entity/TransactionByAccountEntity.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/mapper/TransactionByAccountMapper.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/repository/TransactionByAccountEntityRepository.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/repository/impl/TransactionByAccountRepositoryImpl.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/controller/TransactionController.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/kafka/configuration/KafkaConfig.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/kafka/service/KafkaEventService.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/entity/AccountEntity.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/entity/TransactionEntity.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/mapper/TransactionMapper.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/repository/TransactionEntityRepository.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/repository/impl/TransactionRepositoryImpl.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/redis/config/RedisConfig.java create mode 100644 YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/redis/repository/RedisTransactionCacheRepository.java create mode 100644 YapeChallenge/TransactionManager/src/main/resources/application.properties create mode 100644 YapeChallenge/TransactionManager/src/test/java/com/msucre/transaction/TransactionManagerApplicationTests.java create mode 100644 YapeChallenge/pom.xml create mode 100644 docker/cassandra/init/baseline.cql create mode 100644 docker/postgres/init/baseline.sql diff --git a/.gitignore b/.gitignore index 67045665db..32bf344217 100644 --- a/.gitignore +++ b/.gitignore @@ -1,104 +1,37 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# Next.js build output -.next - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and *not* Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +.mvn + +.idea \ No newline at end of file diff --git a/Dockerfile.antifraud b/Dockerfile.antifraud new file mode 100644 index 0000000000..bc3b1e30b8 --- /dev/null +++ b/Dockerfile.antifraud @@ -0,0 +1,27 @@ +FROM maven:3.9.12-amazoncorretto-21-alpine AS build + +WORKDIR /antifraud + +COPY /YapeChallenge/pom.xml . + +COPY /YapeChallenge/TransactionManager/pom.xml ./TransactionManager/pom.xml +COPY /YapeChallenge/TransactionManager/src ./TransactionManager/src + +COPY /YapeChallenge/AntiFraudManager/pom.xml ./AntiFraudManager/pom.xml +COPY /YapeChallenge/AntiFraudManager/src ./AntiFraudManager/src + + +COPY /YapeChallenge/SharedModel/pom.xml ./SharedModel/pom.xml +COPY /YapeChallenge/SharedModel/src ./SharedModel/src + +RUN mvn clean package -pl AntiFraudManager -am -DskipTests=true + +FROM eclipse-temurin:21-jdk + +WORKDIR /antifraud + +COPY --from=build /antifraud/AntiFraudManager/target/antifraud-1.0.0.jar . + +EXPOSE 8081 + +ENTRYPOINT ["java", "-jar", "antifraud-1.0.0.jar"] \ No newline at end of file diff --git a/Dockerfile.transaction b/Dockerfile.transaction new file mode 100644 index 0000000000..7d91a139a1 --- /dev/null +++ b/Dockerfile.transaction @@ -0,0 +1,31 @@ +FROM maven:3.9.12-eclipse-temurin-21-alpine AS build + +WORKDIR /transaction + +COPY /YapeChallenge/pom.xml . + +COPY /YapeChallenge/TransactionManager/pom.xml ./TransactionManager/pom.xml +COPY /YapeChallenge/TransactionManager/src ./TransactionManager/src + +COPY /YapeChallenge/AntiFraudManager/pom.xml ./AntiFraudManager/pom.xml +COPY /YapeChallenge/AntiFraudManager/src ./AntiFraudManager/src + + +COPY /YapeChallenge/SharedModel/pom.xml ./SharedModel/pom.xml +COPY /YapeChallenge/SharedModel/src ./SharedModel/src + +RUN mvn clean package -pl TransactionManager -am -DskipTests=true + +FROM eclipse-temurin:21-jdk + +WORKDIR /transaction + +ENV POSTGRES_USER= +ENV POSTGRES_PASSWORD= +ENV POSTGRES_DB_NAME= + +COPY --from=build /transaction/TransactionManager/target/transaction-manager-1.0.0.jar . + +EXPOSE 8081 + +ENTRYPOINT ["java", "-jar", "transaction-manager-1.0.0.jar"] \ No newline at end of file diff --git a/README.md b/README.md index b067a71026..b855a82ef8 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,5 @@ # Yape Code Challenge :rocket: -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. @@ -30,53 +22,203 @@ Every transaction with a value greater than 1000 should be rejected. Transaction -- Update transaction Status event--> transactionDatabase[(Database)] ``` -# 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. -
- -We do provide a `Dockerfile` to help you get started with a dev environment. - -You must have two resources: - -1. Resource to create a transaction that must containt: - -```json +# Solution +* To solve this problem, 2 microservices were created: + * AntiFraudManager microservice + * TransactionManager microservice +* The microservices are implemented using reactive programing to ensure asynchronicity and improve their performance. +* Additionally, the used technologies allow an horizonal scalation to increase performance. + +## Tech stack +* Java 21 +* Spring Boot 3.5.9 +* Spring Web Flux +* Postgres 18 +* Kafka +* Redis 8.4 +* Cassandra (Additional for possible high volume read/write scenarios) + +### Main dependencies +* spring-boot-starter (3.5.9) +* spring-boot-starter-webflux (3.5.9) +* spring-kafka (3.3.11) +* lz4-java (1.10.2) +* reactor-kafka (1.3.25) +* spring-boot-starter-data-r2dbc (3.5.9) +* r2dbc-postgresql (1.1.1.RELEASE) +* spring-boot-starter-data-redis-reactive (3.5.9) +* spring-boot-starter-data-cassandra (3.5.9) + +# Microservices +## TransactionManager +* Allows creating transactions +### Endpoints + +#### [POST] /transaction +* Creates a transaction +``` +[POST] /transaction (Request) { - "accountExternalIdDebit": "Guid", - "accountExternalIdCredit": "Guid", - "tranferTypeId": 1, - "value": 120 + "sourceAccountId": "{UUID}", + "destinationAccountId":"{UUID}", + "amount": {decimal-number} } ``` - -2. Resource to retrieve a transaction - -```json +``` +[POST] /transaction (Response) { - "transactionExternalId": "Guid", - "transactionType": { - "name": "" - }, - "transactionStatus": { - "name": "" - }, - "value": 120, - "createdAt": "Date" + "message": "Transaction created successfully", + "transactionId": "{UUID}" } ``` -## Optional +#### [GET] /transaction/{transactionId} +* Returns the details from a transaction +``` +[GET] /transaction/{transactionId} (Response) +{ + "id": "{UUID}", + "sourceAccountId": "{UUID}", + "destinationAccountId": "{UUID}", + "status": "REJECTED" | "PENDING" | "APPROVED", + "type": "DEPOSIT", + "amount": {decimal-number}, + "createdAt": {time-in-millis} +} +``` -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? +#### [GET] /transaction/account/{transactionId} +* Retrieves the transactions for a given account + * _**Additional endpoint to validate Cassandra usage**_ +``` +[GET] /transaction/account/{transactionId} (Response) +[ + { + "id": "{UUID}", + "sourceAccountId": "{UUID}", + "destinationAccountId": "{UUID}", + "status": "REJECTED" | "PENDING" | "APPROVED", + "type": "DEPOSIT", + "amount": {decimal-number}, + "createdAt": {time-in-millis} + }, + ... +] +``` -You can use Graphql; +### Flow +#### Create a Transaction +* A transaction is created using the endpoint `[POST] /transaction` +* The transaction, with `PENDING` status, is persisted into the `transaction` table in the PostgreSQL database +* A transaction entry is stored in Redis (to improve performance when retrieving transaction details) +* The transaction is also stored into the `transaction_by_account` table in the Cassandra database + * Using the field `sourceAccountId` as the partition key and the fields `createdAt` and `id` for the clustering columns +* A `transaction event` is sent to Kafka topic `event` + * The `transaction event` have the following fields: + * transactionId (String) + * amount (float) + +#### Consume response events +* A Kafka consumer is subscribed to the Kafka topic `event-response` + * The AntiFraudManager microservice publishes the transaction event response validations into this topic + * The `transaction event response` have the following fields: + * transactionId (String) + * isValid (Boolean) +* Once a `transaction event response` is consumed,the `transaction` will be updated according to the `isValid` value: + * `APPROVED` if `true` + * `REJECTED` if `false` +* The transaction will be updated in: + * The `transaction` table in PostgreSQL database + * The entry in Redis + * The `transaction_by_account` table in the Cassandra database + +#### Get transaction details +* The transaction details are requested using the endpoint `[POST] /transaction/{transactionId}` +* If the transaction is available in Redis, it will be retrieved. Otherwise, it will be retrieved from the database. + +#### Get transaction details by account +* The transactions by a given account are requested using the endpoint `[POST] /transaction/account/{accountId}` +* They will be retrieved from the required partition based on the `accountId` value from the Cassandra database. + * The table `transaction_by_account` was build specifically for this query. + +## AntiFraud +### Flow +#### Consume events +* A Kafka consumer is subscribed to the Kafka topic `event` +* Once the transaction event is consumed from Kafka, the `amount` field will be validated to set the response field `isValid`. + * If the amount is lower or equal than 1000 it will be `true` otherwise it will be `false` +* A `transaction event response` is sent to Kafka topic `event-response` + * The `transaction event response` have the following fields: + * transactionId (String) + * isValid (Boolean) + +# Build and run +## Requirements +* Install Docker for your current OS (https://www.docker.com/get-started/) +## Build Steps +* Clone the repository and set into the `yape-challenge` directory +* Set the following environment variables (credentials for the database): + * POSTGRES_DB_NAME + * POSTGRES_USER + * POSTGRES_PASSWORD +``` +export POSTGRES_DB_NAME={db-name} +export POSTGRES_DB_USER={db-user} +export POSTGRES_DB_PASSWORD={db-password} +``` +* In the `/yape-challenge/Dockerfile.transaction` file update the following values: +``` +ENV POSTGRES_USER={db-user} +ENV POSTGRES_PASSWORD={db-password} +ENV POSTGRES_DB_NAME={db-name} +``` +* Execute the docker command `docker compose up -d` +* Wait until all the Docker containers are running +``` +~/yape-challenge$ docker compose up -d +WARN[0000] No services to build +[+] up 8/8 + ✔ Container cassandra Healthy 0.6s + ✔ Container postgres Running 0.0s + ✔ Container redis Running 0.0s + ✔ Container zookeeper Running 0.0s + ✔ Container kafka Running 0.0s + ✔ Container transaction Running 0.0s + ✔ Container antifraud Running 0.0s + ✔ Container cassandra-baseline Exited +``` +## Usage +* The TransactionManager endpoints will be available in the `localhost:8082` host +* You can manually validate the microservices endpoints using Postman +* Here are some examples: +``` +[POST] http://localhost:8082/transaction (Request) +{ + "sourceAccountId": "8fcb1a7d-bc3a-4cf1-9077-e1eed948ac93", + "destinationAccountId":"8fcb1a7d-bc3a-4cf1-9077-e1eed948ac93", + "amount":11110.50 +} -# Send us your challenge +[POST] http://localhost:8082/transaction (Response) +{ + "message": "Transaction created successfully", + "transactionId": "76e3f045-674a-466a-800a-28722bd0d13b" +} +``` -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. +``` +[GET] http://localhost:8082/transaction/76e3f045-674a-466a-800a-28722bd0d13b (Response) +{ + "id": "76e3f045-674a-466a-800a-28722bd0d13b", + "sourceAccountId": "8fcb1a7d-bc3a-4cf1-9077-e1eed948ac93", + "destinationAccountId": "8fcb1a7d-bc3a-4cf1-9077-e1eed948ac93", + "status": "REJECTED", + "type": "DEPOSIT", + "amount": 11110.5, + "createdAt": 1768181711540 +} +``` -If you have any questions, please let us know. +# Future improvements +* Use Spring Cloud Gateway (https://spring.io/projects/spring-cloud-gateway) with the Eureka discovery client (https://docs.spring.io/spring-cloud-netflix/docs/current/reference/html/) to build a load balancer so multiple instances of the microservices can be initiated. +* Define specific queries that will require huge amount of writes or reads so the required tables can be designed in Cassandra. diff --git a/YapeChallenge/AntiFraudManager/pom.xml b/YapeChallenge/AntiFraudManager/pom.xml new file mode 100644 index 0000000000..9dd3da50ae --- /dev/null +++ b/YapeChallenge/AntiFraudManager/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + com.msucre + yape-challenge + 1.0.0 + + + antifraud + jar + + AntiFraud + AntiFraund Manager + + + + com.msucre + shared-model + 1.0.0 + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.kafka + spring-kafka + + + + + + org.lz4 + lz4-java + + + + + + + + at.yawk.lz4 + lz4-java + ${lz4-version} + + + io.projectreactor.kafka + reactor-kafka + ${react-kafka-version} + + + diff --git a/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/AntiFraudApplication.java b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/AntiFraudApplication.java new file mode 100644 index 0000000000..aa5c8db7dd --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/AntiFraudApplication.java @@ -0,0 +1,13 @@ +package com.msucre.antifraud; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AntiFraudApplication { + + public static void main(String[] args) { + SpringApplication.run(AntiFraudApplication.class, args); + } + +} diff --git a/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/application/usecase/transaction/ValidateTransactionCase.java b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/application/usecase/transaction/ValidateTransactionCase.java new file mode 100644 index 0000000000..a74815a497 --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/application/usecase/transaction/ValidateTransactionCase.java @@ -0,0 +1,36 @@ +package com.msucre.antifraud.application.usecase.transaction; + +import com.msucre.antifraud.domain.service.EventService; +import com.msucre.antifraud.domain.service.ValidateTransactionService; +import com.msucre.shared.model.dto.event.TransactionEventResponseDto; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; + +@Component +public class ValidateTransactionCase { + + private final ValidateTransactionService validateTransactionService; + + private final EventService eventService; + + public ValidateTransactionCase(ValidateTransactionService validateTransactionService, EventService eventService) { + this.validateTransactionService = validateTransactionService; + this.eventService = eventService; + } + + @PostConstruct + private void init() { + execute().subscribe(); + } + + private Flux execute() { + return eventService.consumeTransactionEvents() + .map(transactionEvent -> { + boolean isValid = validateTransactionService.isValid(transactionEvent); + return new TransactionEventResponseDto(transactionEvent.transactionId(), + isValid); + }) + .flatMap(eventService::sendTransactionEventResponse); + } +} \ No newline at end of file diff --git a/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/domain/service/EventService.java b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/domain/service/EventService.java new file mode 100644 index 0000000000..a662fe71f3 --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/domain/service/EventService.java @@ -0,0 +1,15 @@ +package com.msucre.antifraud.domain.service; + + +import com.msucre.shared.model.dto.event.TransactionEventDto; +import com.msucre.shared.model.dto.event.TransactionEventResponseDto; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface EventService { + + Mono sendTransactionEventResponse( + TransactionEventResponseDto transactionEventResponseDto); + + Flux consumeTransactionEvents(); +} diff --git a/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/domain/service/ValidateTransactionService.java b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/domain/service/ValidateTransactionService.java new file mode 100644 index 0000000000..ed4a4bb079 --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/domain/service/ValidateTransactionService.java @@ -0,0 +1,8 @@ +package com.msucre.antifraud.domain.service; + +import com.msucre.shared.model.dto.event.TransactionEventDto; + +public interface ValidateTransactionService { + + boolean isValid(TransactionEventDto transactionEventDto); +} \ No newline at end of file diff --git a/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/kafka/configuration/KafkaConfig.java b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/kafka/configuration/KafkaConfig.java new file mode 100644 index 0000000000..0c343fa534 --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/kafka/configuration/KafkaConfig.java @@ -0,0 +1,48 @@ +package com.msucre.antifraud.infrastructure.kafka.configuration; + + +import com.msucre.shared.model.kafka.KafkaSharedConstants; +import com.msucre.shared.model.dto.event.TransactionEventDto; +import com.msucre.shared.model.dto.event.TransactionEventResponseDto; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; +import reactor.kafka.receiver.ReceiverOptions; +import reactor.kafka.sender.SenderOptions; + +import java.util.Collections; +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Bean + public ReactiveKafkaProducerTemplate reactiveKafkaProducerTemplate( + KafkaProperties properties) { + Map producerProperties = properties.buildProducerProperties(); + producerProperties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + producerProperties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + return new ReactiveKafkaProducerTemplate<>(SenderOptions.create(producerProperties)); + } + + @Bean + public ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate( + KafkaProperties properties) { + Map consumerProperties = properties.buildProducerProperties(); + consumerProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + consumerProperties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + consumerProperties.put(ConsumerConfig.GROUP_ID_CONFIG, KafkaSharedConstants.KAFKA_GROUP_ID); + consumerProperties.put(JsonDeserializer.TRUSTED_PACKAGES, KafkaSharedConstants.MODEL_TRUSTED_PACKAGE); + ReceiverOptions receiverOptions = ReceiverOptions.create( + consumerProperties).subscription(Collections.singletonList(KafkaSharedConstants.EVENT_TOPIC)); + return new ReactiveKafkaConsumerTemplate<>(receiverOptions); + } +} diff --git a/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/kafka/service/KafkaEventService.java b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/kafka/service/KafkaEventService.java new file mode 100644 index 0000000000..e77b92a90d --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/kafka/service/KafkaEventService.java @@ -0,0 +1,46 @@ +package com.msucre.antifraud.infrastructure.kafka.service; + + +import com.msucre.antifraud.domain.service.EventService; +import com.msucre.shared.model.kafka.KafkaSharedConstants; +import com.msucre.shared.model.dto.event.TransactionEventDto; +import com.msucre.shared.model.dto.event.TransactionEventResponseDto; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Service +public class KafkaEventService implements EventService { + + private static final Logger logger = LoggerFactory.getLogger(KafkaEventService.class); + + private final ReactiveKafkaProducerTemplate kafkaProducerTemplate; + + private final ReactiveKafkaConsumerTemplate kafkaConsumerTemplate; + + public KafkaEventService(ReactiveKafkaProducerTemplate kafkaProducerTemplate, + ReactiveKafkaConsumerTemplate kafkaConsumerTemplate) { + this.kafkaProducerTemplate = kafkaProducerTemplate; + this.kafkaConsumerTemplate = kafkaConsumerTemplate; + } + + @Override + public Mono sendTransactionEventResponse(final TransactionEventResponseDto transactionEventResponseDto) { + return kafkaProducerTemplate.send(KafkaSharedConstants.EVENT_RESPONSE_TOPIC, transactionEventResponseDto).doOnSuccess( + unused -> logger.debug("Sending response: [{} - {}]", transactionEventResponseDto.transactionId(), + transactionEventResponseDto.isValid())).then(); + } + + @Override + public Flux consumeTransactionEvents() { + return kafkaConsumerTemplate.receiveAutoAck().map(ConsumerRecord::value).doOnNext( + transactionEvent -> logger.debug("Transaction received: {} - {}", transactionEvent.transactionId(), + transactionEvent.amount())) + .onErrorContinue((throwable, o) -> logger.error("Error while consuming transaction event", throwable)); + } +} \ No newline at end of file diff --git a/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/validation/service/ValidateTransactionServiceImpl.java b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/validation/service/ValidateTransactionServiceImpl.java new file mode 100644 index 0000000000..d2cc479484 --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/main/java/com/msucre/antifraud/infrastructure/validation/service/ValidateTransactionServiceImpl.java @@ -0,0 +1,16 @@ +package com.msucre.antifraud.infrastructure.validation.service; + +import com.msucre.antifraud.domain.service.ValidateTransactionService; +import com.msucre.shared.model.dto.event.TransactionEventDto; +import org.springframework.stereotype.Service; + +@Service +public class ValidateTransactionServiceImpl implements ValidateTransactionService { + + private static final float MAX_ALLOWED_AMOUNT = 1000; + + @Override + public boolean isValid(final TransactionEventDto transactionEventDto) { + return transactionEventDto.amount() <= MAX_ALLOWED_AMOUNT; + } +} \ No newline at end of file diff --git a/YapeChallenge/AntiFraudManager/src/main/resources/application.properties b/YapeChallenge/AntiFraudManager/src/main/resources/application.properties new file mode 100644 index 0000000000..b1ee335b43 --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/main/resources/application.properties @@ -0,0 +1,7 @@ +spring.application.name=AntiFraud +server.port=8081 + +spring.kafka.bootstrap-servers=kafka:9092 + +#logging level +logging.level.com.msucre.antifraud=DEBUG \ No newline at end of file diff --git a/YapeChallenge/AntiFraudManager/src/test/java/com/msucre/antifraud/AntiFraudApplicationTests.java b/YapeChallenge/AntiFraudManager/src/test/java/com/msucre/antifraud/AntiFraudApplicationTests.java new file mode 100644 index 0000000000..db1cd005f3 --- /dev/null +++ b/YapeChallenge/AntiFraudManager/src/test/java/com/msucre/antifraud/AntiFraudApplicationTests.java @@ -0,0 +1,9 @@ +package com.msucre.antifraud; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AntiFraudApplicationTests { + +} diff --git a/YapeChallenge/SharedModel/pom.xml b/YapeChallenge/SharedModel/pom.xml new file mode 100644 index 0000000000..01d36496e7 --- /dev/null +++ b/YapeChallenge/SharedModel/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + com.msucre + shared-model + jar + 1.0.0 + SharedModel + Classes shared between microservices + + + 21 + 3.13.0 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${compiler-plugin-version} + + ${java.version} + ${java.version} + + + + + + diff --git a/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/dto/event/TransactionEventDto.java b/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/dto/event/TransactionEventDto.java new file mode 100644 index 0000000000..2435429c78 --- /dev/null +++ b/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/dto/event/TransactionEventDto.java @@ -0,0 +1,4 @@ +package com.msucre.shared.model.dto.event; + +public record TransactionEventDto(String transactionId, float amount) { +} diff --git a/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/dto/event/TransactionEventResponseDto.java b/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/dto/event/TransactionEventResponseDto.java new file mode 100644 index 0000000000..a4313287d0 --- /dev/null +++ b/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/dto/event/TransactionEventResponseDto.java @@ -0,0 +1,4 @@ +package com.msucre.shared.model.dto.event; + +public record TransactionEventResponseDto(String transactionId, boolean isValid) { +} diff --git a/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/kafka/KafkaSharedConstants.java b/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/kafka/KafkaSharedConstants.java new file mode 100644 index 0000000000..33c101f225 --- /dev/null +++ b/YapeChallenge/SharedModel/src/main/java/com/msucre/shared/model/kafka/KafkaSharedConstants.java @@ -0,0 +1,12 @@ +package com.msucre.shared.model.kafka; + +public class KafkaSharedConstants { + + public static final String EVENT_RESPONSE_TOPIC = "event-response"; + + public static final String EVENT_TOPIC = "event"; + + public static final String KAFKA_GROUP_ID = "antifraud-group"; + + public static final String MODEL_TRUSTED_PACKAGE = "com.msucre.shared.model.dto.event"; +} diff --git a/YapeChallenge/SharedModel/src/main/resources/application.properties b/YapeChallenge/SharedModel/src/main/resources/application.properties new file mode 100644 index 0000000000..dfcac877c5 --- /dev/null +++ b/YapeChallenge/SharedModel/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=SharedModel diff --git a/YapeChallenge/TransactionManager/pom.xml b/YapeChallenge/TransactionManager/pom.xml new file mode 100644 index 0000000000..ccce436b96 --- /dev/null +++ b/YapeChallenge/TransactionManager/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + com.msucre + yape-challenge + 1.0.0 + + + transaction-manager + jar + + TransactionManager + Transaction Manager + + + 1.1.1.RELEASE + + + + + com.msucre + shared-model + 1.0.0 + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.kafka + spring-kafka + + + + + + org.lz4 + lz4-java + + + + + + + + at.yawk.lz4 + lz4-java + ${lz4-version} + + + io.projectreactor.kafka + reactor-kafka + ${react-kafka-version} + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + org.postgresql + r2dbc-postgresql + ${r2dbc-postgresql-version} + + + org.springframework.boot + spring-boot-starter-data-cassandra + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/TransactionManagerApplication.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/TransactionManagerApplication.java new file mode 100644 index 0000000000..f0d81802c3 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/TransactionManagerApplication.java @@ -0,0 +1,17 @@ +package com.msucre.transaction; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; + +@SpringBootApplication +@EnableR2dbcRepositories( + basePackages = "com.company.transaction.infrastructure.postgres.repository" +) +public class TransactionManagerApplication { + + public static void main(String[] args) { + SpringApplication.run(TransactionManagerApplication.class, args); + } + +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/CreateTransactionCase.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/CreateTransactionCase.java new file mode 100644 index 0000000000..0d8b73cd5c --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/CreateTransactionCase.java @@ -0,0 +1,60 @@ +package com.msucre.transaction.application.usecase.transaction; + +import com.msucre.shared.model.dto.event.TransactionEventDto; +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.model.type.TransactionStatus; +import com.msucre.transaction.domain.model.type.TransactionType; +import com.msucre.transaction.domain.repository.TransactionByAccountRepository; +import com.msucre.transaction.domain.repository.TransactionCacheRepository; +import com.msucre.transaction.domain.repository.TransactionRepository; +import com.msucre.transaction.domain.service.EventService; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.UUID; + +@Component +public class CreateTransactionCase { + + private final TransactionRepository transactionRepository; + + private final TransactionCacheRepository transactionCacheRepository; + + private final TransactionByAccountRepository transactionByAccountRepository; + + private final EventService eventService; + + public CreateTransactionCase(TransactionRepository transactionRepository, + TransactionCacheRepository transactionCacheRepository, + TransactionByAccountRepository transactionByAccountRepository, + EventService eventService) { + this.transactionRepository = transactionRepository; + this.transactionCacheRepository = transactionCacheRepository; + this.transactionByAccountRepository = transactionByAccountRepository; + this.eventService = eventService; + } + + public Mono execute(final Transaction transaction) { + return Mono.fromCallable(() -> { + transaction.createdAt = System.currentTimeMillis(); + transaction.status = TransactionStatus.PENDING; + transaction.type = TransactionType.DEPOSIT; + return transaction; + }).flatMap(this::createAndProcessTransaction).map(createdTransaction -> createdTransaction.id); + } + + private Mono createAndProcessTransaction(final Transaction transaction) { + return transactionRepository.save(transaction) + .doOnNext(createdTransaction -> + processTransaction(createdTransaction).subscribeOn(Schedulers.boundedElastic()).subscribe() + ); + } + + private Mono processTransaction(final Transaction transaction) { + return transactionCacheRepository.save(transaction).then(transactionByAccountRepository.save(transaction)) + .then( + eventService.sendTransactionEvent(new TransactionEventDto(transaction.id.toString(), transaction.amount))); + } +} + diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/FindTransactionCase.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/FindTransactionCase.java new file mode 100644 index 0000000000..db3e72a13a --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/FindTransactionCase.java @@ -0,0 +1,27 @@ +package com.msucre.transaction.application.usecase.transaction; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.repository.TransactionCacheRepository; +import com.msucre.transaction.domain.repository.TransactionRepository; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +public class FindTransactionCase { + + private final TransactionCacheRepository transactionCacheRepository; + private final TransactionRepository transactionRepository; + + + public FindTransactionCase(TransactionCacheRepository transactionCacheRepository, + TransactionRepository transactionRepository) { + this.transactionCacheRepository = transactionCacheRepository; + this.transactionRepository = transactionRepository; + } + + public Mono execute(final String transactionId) { + return transactionCacheRepository.findById(transactionId).switchIfEmpty( + transactionRepository.findById(transactionId) + .flatMap(transaction -> transactionCacheRepository.save(transaction).thenReturn(transaction))); + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/FindTransactionsByAccountCase.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/FindTransactionsByAccountCase.java new file mode 100644 index 0000000000..760626129c --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/FindTransactionsByAccountCase.java @@ -0,0 +1,22 @@ +package com.msucre.transaction.application.usecase.transaction; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.repository.TransactionByAccountRepository; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; + +import java.util.UUID; + +@Component +public class FindTransactionsByAccountCase { + + private final TransactionByAccountRepository transactionByAccountRepository; + + public FindTransactionsByAccountCase(TransactionByAccountRepository transactionByAccountRepository) { + this.transactionByAccountRepository = transactionByAccountRepository; + } + + public Flux execute(String accountId) { + return transactionByAccountRepository.findTransactionsByAccount(UUID.fromString(accountId)); + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/UpdateValidatedTransactionCase.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/UpdateValidatedTransactionCase.java new file mode 100644 index 0000000000..5d9288cb26 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/application/usecase/transaction/UpdateValidatedTransactionCase.java @@ -0,0 +1,50 @@ +package com.msucre.transaction.application.usecase.transaction; + +import com.msucre.transaction.domain.model.type.TransactionStatus; +import com.msucre.transaction.domain.repository.TransactionByAccountRepository; +import com.msucre.transaction.domain.repository.TransactionRepository; +import com.msucre.transaction.domain.service.EventService; +import com.msucre.transaction.infrastructure.redis.repository.RedisTransactionCacheRepository; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Component +public class UpdateValidatedTransactionCase { + + private final EventService eventService; + + private final TransactionRepository transactionRepository; + + private final RedisTransactionCacheRepository redisTransactionCacheRepository; + + private final TransactionByAccountRepository transactionByAccountRepository; + + public UpdateValidatedTransactionCase(EventService eventService, TransactionRepository transactionRepository, + RedisTransactionCacheRepository redisTransactionCacheRepository, + TransactionByAccountRepository transactionByAccountRepository) { + this.eventService = eventService; + this.transactionRepository = transactionRepository; + this.redisTransactionCacheRepository = redisTransactionCacheRepository; + this.transactionByAccountRepository = transactionByAccountRepository; + } + + @PostConstruct + public void init() { + execute().subscribe(); + } + + public Flux execute() { + return eventService.consumeTransactionEventResponses().flatMap( + transactionEventResponseDto -> processTransaction(transactionEventResponseDto.transactionId(), + transactionEventResponseDto.isValid() ? TransactionStatus.APPROVED : TransactionStatus.REJECTED)); + } + + private Mono processTransaction(final String id, final TransactionStatus status) { + return transactionRepository.updateStatusById(id, status) + .then(redisTransactionCacheRepository.updateStatusById(id, status)) + .then(transactionRepository.findById(id)) + .flatMap(transaction -> transactionByAccountRepository.updateStatus(transaction, status)); + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/Transaction.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/Transaction.java new file mode 100644 index 0000000000..d03bd24aaf --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/Transaction.java @@ -0,0 +1,23 @@ +package com.msucre.transaction.domain.model; + +import com.msucre.transaction.domain.model.type.TransactionStatus; +import com.msucre.transaction.domain.model.type.TransactionType; + +import java.util.UUID; + +public class Transaction { + + public UUID id; + + public UUID sourceAccountId; + + public UUID destinationAccountId; + + public TransactionStatus status; + + public TransactionType type; + + public float amount; + + public Long createdAt; +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/type/TransactionStatus.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/type/TransactionStatus.java new file mode 100644 index 0000000000..2a22c9030c --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/type/TransactionStatus.java @@ -0,0 +1,7 @@ +package com.msucre.transaction.domain.model.type; + +public enum TransactionStatus { + PENDING, + APPROVED, + REJECTED +} \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/type/TransactionType.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/type/TransactionType.java new file mode 100644 index 0000000000..06552ad96a --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/type/TransactionType.java @@ -0,0 +1,5 @@ +package com.msucre.transaction.domain.model.type; + +public enum TransactionType { + DEPOSIT +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/ui/CreateTransactionResponse.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/ui/CreateTransactionResponse.java new file mode 100644 index 0000000000..1ce37abdb1 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/model/ui/CreateTransactionResponse.java @@ -0,0 +1,7 @@ +package com.msucre.transaction.domain.model.ui; + +import java.util.UUID; + +public record CreateTransactionResponse(String message, UUID transactionId) { + +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionByAccountRepository.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionByAccountRepository.java new file mode 100644 index 0000000000..a5c59bbccc --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionByAccountRepository.java @@ -0,0 +1,17 @@ +package com.msucre.transaction.domain.repository; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.model.type.TransactionStatus; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +public interface TransactionByAccountRepository { + + Mono save(Transaction transaction); + + Flux findTransactionsByAccount(UUID accountId); + + Mono updateStatus(Transaction transaction, TransactionStatus status); +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionCacheRepository.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionCacheRepository.java new file mode 100644 index 0000000000..936c3faae9 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionCacheRepository.java @@ -0,0 +1,14 @@ +package com.msucre.transaction.domain.repository; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.model.type.TransactionStatus; +import reactor.core.publisher.Mono; + +public interface TransactionCacheRepository { + + Mono save(Transaction transaction); + + Mono updateStatusById(String id, TransactionStatus status); + + Mono findById(String id); +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionRepository.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionRepository.java new file mode 100644 index 0000000000..f698a0f412 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/repository/TransactionRepository.java @@ -0,0 +1,16 @@ +package com.msucre.transaction.domain.repository; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.model.type.TransactionStatus; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +public interface TransactionRepository { + + Mono save(Transaction transaction); + + Mono updateStatusById(String id, TransactionStatus status); + + Mono findById(String id); +} \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/service/EventService.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/service/EventService.java new file mode 100644 index 0000000000..4c1159039c --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/domain/service/EventService.java @@ -0,0 +1,14 @@ +package com.msucre.transaction.domain.service; + + +import com.msucre.shared.model.dto.event.TransactionEventDto; +import com.msucre.shared.model.dto.event.TransactionEventResponseDto; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface EventService { + + Mono sendTransactionEvent(TransactionEventDto transactionEventDto); + + Flux consumeTransactionEventResponses(); +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/entity/TransactionByAccountEntity.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/entity/TransactionByAccountEntity.java new file mode 100644 index 0000000000..f094dec4a7 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/entity/TransactionByAccountEntity.java @@ -0,0 +1,34 @@ +package com.msucre.transaction.infrastructure.cassandra.entity; + +import com.msucre.transaction.domain.model.type.TransactionStatus; +import com.msucre.transaction.domain.model.type.TransactionType; +import org.springframework.data.cassandra.core.cql.Ordering; +import org.springframework.data.cassandra.core.cql.PrimaryKeyType; +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; +import org.springframework.data.cassandra.core.mapping.Table; + +import java.util.UUID; + + +@Table("transaction_by_account") +public class TransactionByAccountEntity { + + @PrimaryKeyColumn(name = "source_account_id", type = PrimaryKeyType.PARTITIONED) + public UUID sourceAccountId; + + @PrimaryKeyColumn(name = "created_at", type = PrimaryKeyType.CLUSTERED, ordering = Ordering.DESCENDING) + public Long createdAt; + + @PrimaryKeyColumn(name = "transaction_id", type = PrimaryKeyType.CLUSTERED) + public UUID transactionId; + + @Column("destination_account_id") + public UUID destinationAccountId; + + public TransactionStatus status; + + public TransactionType type; + + public float amount; +} \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/mapper/TransactionByAccountMapper.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/mapper/TransactionByAccountMapper.java new file mode 100644 index 0000000000..dce1581bfe --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/mapper/TransactionByAccountMapper.java @@ -0,0 +1,31 @@ +package com.msucre.transaction.infrastructure.cassandra.mapper; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.infrastructure.cassandra.entity.TransactionByAccountEntity; + +public class TransactionByAccountMapper { + + public static TransactionByAccountEntity toEntity(final Transaction transaction) { + TransactionByAccountEntity transactionByAccountEntity = new TransactionByAccountEntity(); + transactionByAccountEntity.transactionId = transaction.id; + transactionByAccountEntity.sourceAccountId = transaction.sourceAccountId; + transactionByAccountEntity.destinationAccountId = transaction.destinationAccountId; + transactionByAccountEntity.createdAt = transaction.createdAt; + transactionByAccountEntity.status = transaction.status; + transactionByAccountEntity.amount = transaction.amount; + transactionByAccountEntity.type = transaction.type; + return transactionByAccountEntity; + } + + public static Transaction toTransaction(final TransactionByAccountEntity transactionByAccountEntity) { + Transaction transaction = new Transaction(); + transaction.id = transactionByAccountEntity.transactionId; + transaction.sourceAccountId = transactionByAccountEntity.sourceAccountId; + transaction.destinationAccountId = transactionByAccountEntity.destinationAccountId; + transaction.createdAt = transactionByAccountEntity.createdAt; + transaction.status = transactionByAccountEntity.status; + transaction.amount = transactionByAccountEntity.amount; + transaction.type = transactionByAccountEntity.type; + return transaction; + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/repository/TransactionByAccountEntityRepository.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/repository/TransactionByAccountEntityRepository.java new file mode 100644 index 0000000000..951e0489c5 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/repository/TransactionByAccountEntityRepository.java @@ -0,0 +1,23 @@ +package com.msucre.transaction.infrastructure.cassandra.repository; + +import com.msucre.transaction.infrastructure.cassandra.entity.TransactionByAccountEntity; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.cassandra.repository.ReactiveCassandraRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +import java.util.UUID; + +@Repository +public interface TransactionByAccountEntityRepository extends ReactiveCassandraRepository { + + @Query(""" + SELECT + * + FROM + transaction_by_account + WHERE + source_account_id = ?0 + """) + Flux findAllBySourceAccountId(final UUID sourceAccountId); +} \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/repository/impl/TransactionByAccountRepositoryImpl.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/repository/impl/TransactionByAccountRepositoryImpl.java new file mode 100644 index 0000000000..bdfdabdfce --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/cassandra/repository/impl/TransactionByAccountRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.msucre.transaction.infrastructure.cassandra.repository.impl; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.model.type.TransactionStatus; +import com.msucre.transaction.domain.repository.TransactionByAccountRepository; +import com.msucre.transaction.infrastructure.cassandra.mapper.TransactionByAccountMapper; +import com.msucre.transaction.infrastructure.cassandra.repository.TransactionByAccountEntityRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collections; +import java.util.UUID; + +@Repository +public class TransactionByAccountRepositoryImpl implements TransactionByAccountRepository { + + private final TransactionByAccountEntityRepository transactionByAccountEntityRepository; + + public TransactionByAccountRepositoryImpl(TransactionByAccountEntityRepository transactionByAccountEntityRepository) { + this.transactionByAccountEntityRepository = transactionByAccountEntityRepository; + } + + @Override + public Mono save(Transaction transaction) { + return transactionByAccountEntityRepository.save(TransactionByAccountMapper.toEntity(transaction)).then(); + } + + @Override + public Flux findTransactionsByAccount(UUID accountId) { + return transactionByAccountEntityRepository.findAllBySourceAccountId(accountId) + .map(TransactionByAccountMapper::toTransaction); + } + + @Override + public Mono updateStatus(Transaction transaction, TransactionStatus status) { + return Mono.fromCallable(() -> { + transaction.status = status; + return TransactionByAccountMapper.toEntity(transaction); + }).flatMap(transactionByAccountEntityRepository::save).then(); + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/controller/TransactionController.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/controller/TransactionController.java new file mode 100644 index 0000000000..5fd5191cd7 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/controller/TransactionController.java @@ -0,0 +1,45 @@ +package com.msucre.transaction.infrastructure.controller; + +import com.msucre.transaction.application.usecase.transaction.CreateTransactionCase; +import com.msucre.transaction.application.usecase.transaction.FindTransactionCase; +import com.msucre.transaction.application.usecase.transaction.FindTransactionsByAccountCase; +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.model.ui.CreateTransactionResponse; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/transaction") +public class TransactionController { + + private final CreateTransactionCase createTransactionCase; + + private final FindTransactionCase findTransactionCase; + + private final FindTransactionsByAccountCase findTransactionsByAccountCase; + + + public TransactionController(CreateTransactionCase createTransactionCase, FindTransactionCase findTransactionCase, + FindTransactionsByAccountCase findTransactionsByAccountCase) { + this.createTransactionCase = createTransactionCase; + this.findTransactionCase = findTransactionCase; + this.findTransactionsByAccountCase = findTransactionsByAccountCase; + } + + @PostMapping + public Mono handleCreateTransaction(@RequestBody Transaction transaction) { + return createTransactionCase.execute(transaction) + .map(transactionId -> new CreateTransactionResponse("Transaction created successfully", transactionId)); + } + + @GetMapping("/{id}") + public Mono handleGetTransaction(@PathVariable("id") String id) { + return findTransactionCase.execute(id); + } + + @GetMapping("/account/{accountId}") + public Flux handleGetTransactionsByAccount(@PathVariable("accountId") String accountId) { + return findTransactionsByAccountCase.execute(accountId); + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/kafka/configuration/KafkaConfig.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/kafka/configuration/KafkaConfig.java new file mode 100644 index 0000000000..4eb117c0cd --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/kafka/configuration/KafkaConfig.java @@ -0,0 +1,47 @@ +package com.msucre.transaction.infrastructure.kafka.configuration; + +import com.msucre.shared.model.dto.event.TransactionEventDto; +import com.msucre.shared.model.dto.event.TransactionEventResponseDto; +import com.msucre.shared.model.kafka.KafkaSharedConstants; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; +import reactor.kafka.receiver.ReceiverOptions; +import reactor.kafka.sender.SenderOptions; + +import java.util.Collections; +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Bean + public ReactiveKafkaProducerTemplate reactiveKafkaProducerTemplate( + KafkaProperties properties) { + Map producerProperties = properties.buildProducerProperties(); + producerProperties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + producerProperties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + return new ReactiveKafkaProducerTemplate<>(SenderOptions.create(producerProperties)); + } + + @Bean + public ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate( + KafkaProperties properties) { + Map consumerProperties = properties.buildProducerProperties(); + consumerProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + consumerProperties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + consumerProperties.put(ConsumerConfig.GROUP_ID_CONFIG, KafkaSharedConstants.KAFKA_GROUP_ID); + consumerProperties.put(JsonDeserializer.TRUSTED_PACKAGES, KafkaSharedConstants.MODEL_TRUSTED_PACKAGE); + ReceiverOptions receiverOptions = ReceiverOptions.create( + consumerProperties).subscription(Collections.singletonList(KafkaSharedConstants.EVENT_RESPONSE_TOPIC)); + return new ReactiveKafkaConsumerTemplate<>(receiverOptions); + } +} \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/kafka/service/KafkaEventService.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/kafka/service/KafkaEventService.java new file mode 100644 index 0000000000..842e9e4e27 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/kafka/service/KafkaEventService.java @@ -0,0 +1,47 @@ +package com.msucre.transaction.infrastructure.kafka.service; + + +import com.msucre.transaction.domain.service.EventService; +import com.msucre.shared.model.dto.event.TransactionEventDto; +import com.msucre.shared.model.dto.event.TransactionEventResponseDto; +import com.msucre.shared.model.kafka.KafkaSharedConstants; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.kafka.core.reactive.ReactiveKafkaProducerTemplate; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Service +public class KafkaEventService implements EventService { + + private static final Logger logger = LoggerFactory.getLogger(KafkaEventService.class); + + private final ReactiveKafkaProducerTemplate kafkaProducerTemplate; + + private final ReactiveKafkaConsumerTemplate kafkaConsumerTemplate; + + public KafkaEventService(ReactiveKafkaProducerTemplate kafkaProducerTemplate, + ReactiveKafkaConsumerTemplate kafkaConsumerTemplate) { + this.kafkaProducerTemplate = kafkaProducerTemplate; + this.kafkaConsumerTemplate = kafkaConsumerTemplate; + } + + @Override + public Mono sendTransactionEvent(final TransactionEventDto transactionEventDto) { + return kafkaProducerTemplate.send(KafkaSharedConstants.EVENT_TOPIC, transactionEventDto).doOnSuccess( + unused -> logger.debug("Sending event: [{} - {}]", transactionEventDto.transactionId(), + transactionEventDto.amount())).then(); + } + + @Override + public Flux consumeTransactionEventResponses() { + return kafkaConsumerTemplate.receiveAutoAck().map(ConsumerRecord::value).doOnNext( + transactionEventResponse -> logger.debug("Transaction validated: {} - {}", + transactionEventResponse.transactionId(), + transactionEventResponse.isValid())) + .onErrorContinue((throwable, o) -> logger.error("Error while consuming transaction event", throwable)); + } +} \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/entity/AccountEntity.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/entity/AccountEntity.java new file mode 100644 index 0000000000..6c483ae418 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/entity/AccountEntity.java @@ -0,0 +1,19 @@ +package com.msucre.transaction.infrastructure.postgresql.entity; + + +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.util.UUID; + + +@Table(name = "account") +public class AccountEntity { + + @Id + public UUID id; + + @Column + public String owner; +} \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/entity/TransactionEntity.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/entity/TransactionEntity.java new file mode 100644 index 0000000000..da283c4b2d --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/entity/TransactionEntity.java @@ -0,0 +1,31 @@ +package com.msucre.transaction.infrastructure.postgresql.entity; + +import com.msucre.transaction.domain.model.type.TransactionStatus; +import com.msucre.transaction.domain.model.type.TransactionType; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.util.UUID; + +@Table(name = "transaction") +public class TransactionEntity { + + @Id + public UUID id; + + @Column("source_account_id") + public UUID sourceAccountId; + + @Column("created_at") + public Long createdAt; + + @Column("destination_account_id") + public UUID destinationAccountId; + + public TransactionStatus status; + + public TransactionType type; + + public float amount; +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/mapper/TransactionMapper.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/mapper/TransactionMapper.java new file mode 100644 index 0000000000..f15b382b63 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/mapper/TransactionMapper.java @@ -0,0 +1,31 @@ +package com.msucre.transaction.infrastructure.postgresql.mapper; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.infrastructure.postgresql.entity.TransactionEntity; + +public class TransactionMapper { + + public static TransactionEntity toEntity(Transaction transaction) { + TransactionEntity transactionEntity = new TransactionEntity(); + transactionEntity.id = transaction.id; + transactionEntity.sourceAccountId = transaction.sourceAccountId; + transactionEntity.destinationAccountId = transaction.destinationAccountId; + transactionEntity.createdAt = transaction.createdAt; + transactionEntity.status = transaction.status; + transactionEntity.type = transaction.type; + transactionEntity.amount = transaction.amount; + return transactionEntity; + } + + public static Transaction toTransaction(TransactionEntity transactionEntity) { + Transaction transaction = new Transaction(); + transaction.id = transactionEntity.id; + transaction.sourceAccountId = transactionEntity.sourceAccountId; + transaction.destinationAccountId = transactionEntity.destinationAccountId; + transaction.createdAt = transactionEntity.createdAt; + transaction.status = transactionEntity.status; + transaction.type = transactionEntity.type; + transaction.amount = transactionEntity.amount; + return transaction; + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/repository/TransactionEntityRepository.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/repository/TransactionEntityRepository.java new file mode 100644 index 0000000000..ec08178218 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/repository/TransactionEntityRepository.java @@ -0,0 +1,12 @@ +package com.msucre.transaction.infrastructure.postgresql.repository; + +import com.msucre.transaction.infrastructure.postgresql.entity.TransactionEntity; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface TransactionEntityRepository extends ReactiveCrudRepository { + +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/repository/impl/TransactionRepositoryImpl.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/repository/impl/TransactionRepositoryImpl.java new file mode 100644 index 0000000000..b7d94d7460 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/postgresql/repository/impl/TransactionRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.msucre.transaction.infrastructure.postgresql.repository.impl; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.model.type.TransactionStatus; +import com.msucre.transaction.domain.repository.TransactionRepository; +import com.msucre.transaction.infrastructure.postgresql.mapper.TransactionMapper; +import com.msucre.transaction.infrastructure.postgresql.repository.TransactionEntityRepository; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +@Component +public class TransactionRepositoryImpl implements TransactionRepository { + + private final TransactionEntityRepository transactionEntityRepository; + + public TransactionRepositoryImpl(TransactionEntityRepository transactionEntityRepository) { + this.transactionEntityRepository = transactionEntityRepository; + } + + @Override + public Mono save(Transaction transaction) { + return transactionEntityRepository.save(TransactionMapper.toEntity(transaction)).map(transactionEntity -> { + transaction.id = transactionEntity.id; + return transaction; + }); + } + + @Override + public Mono updateStatusById(String id, TransactionStatus status) { + return transactionEntityRepository.findById(UUID.fromString(id)).map(transactionEntity -> { + transactionEntity.status = status; + return transactionEntity; + }).flatMap(transactionEntityRepository::save) + .then(); + } + + @Override + public Mono findById(String id) { + return transactionEntityRepository.findById(UUID.fromString(id)).map(TransactionMapper::toTransaction); + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/redis/config/RedisConfig.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/redis/config/RedisConfig.java new file mode 100644 index 0000000000..9948f17da0 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/redis/config/RedisConfig.java @@ -0,0 +1,23 @@ +package com.msucre.transaction.infrastructure.redis.config; + +import com.msucre.transaction.domain.model.Transaction; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + ReactiveRedisTemplate redisOperations(ReactiveRedisConnectionFactory factory) { + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(Transaction.class); + RedisSerializationContext.RedisSerializationContextBuilder builder = + RedisSerializationContext.newSerializationContext(new StringRedisSerializer()); + RedisSerializationContext context = builder.value(serializer).build(); + return new ReactiveRedisTemplate<>(factory, context); + } +} diff --git a/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/redis/repository/RedisTransactionCacheRepository.java b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/redis/repository/RedisTransactionCacheRepository.java new file mode 100644 index 0000000000..07cff7783c --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/java/com/msucre/transaction/infrastructure/redis/repository/RedisTransactionCacheRepository.java @@ -0,0 +1,43 @@ +package com.msucre.transaction.infrastructure.redis.repository; + +import com.msucre.transaction.domain.model.Transaction; +import com.msucre.transaction.domain.model.type.TransactionStatus; +import com.msucre.transaction.domain.repository.TransactionCacheRepository; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Repository +public class RedisTransactionCacheRepository implements TransactionCacheRepository { + + private static final long TTL_MINUTES = 5; + + private final ReactiveRedisTemplate reactiveRedisTemplate; + + + public RedisTransactionCacheRepository(ReactiveRedisTemplate reactiveRedisTemplate) { + this.reactiveRedisTemplate = reactiveRedisTemplate; + } + + @Override + public Mono save(Transaction transaction) { + return reactiveRedisTemplate.opsForValue() + .set(transaction.id.toString(), transaction, Duration.ofMinutes(TTL_MINUTES)).then(); + } + + @Override + public Mono updateStatusById(String id, TransactionStatus status) { + return reactiveRedisTemplate.opsForValue().get(id).map(transaction -> { + transaction.status = status; + return transaction; + }) + .flatMap(updatedTransaction -> reactiveRedisTemplate.opsForValue().set(id, updatedTransaction)).then(); + } + + @Override + public Mono findById(String id) { + return reactiveRedisTemplate.opsForValue().get(id); + } +} diff --git a/YapeChallenge/TransactionManager/src/main/resources/application.properties b/YapeChallenge/TransactionManager/src/main/resources/application.properties new file mode 100644 index 0000000000..c6825f69dd --- /dev/null +++ b/YapeChallenge/TransactionManager/src/main/resources/application.properties @@ -0,0 +1,21 @@ +spring.application.name=TransactionManager +server.port=8082 + +spring.cassandra.contact-points=cassandra +spring.cassandra.port=9042 +spring.cassandra.local-datacenter=dc1 +spring.cassandra.keyspace-name=yape_challenge + +spring.r2dbc.url=r2dbc:postgresql://postgres:5432/${POSTGRES_DB_NAME} +spring.r2dbc.username=${POSTGRES_USER} +spring.r2dbc.password=${POSTGRES_PASSWORD} + +spring.data.redis.host=redis +spring.data.redis.port=6379 +spring.data.redis.timeout=2s + +spring.kafka.bootstrap-servers=kafka:9092 + +# Set root logging level +logging.level.com.msucre.transaction=DEBUG +logging.level.org.apache.kafka.clients.producer=WARN \ No newline at end of file diff --git a/YapeChallenge/TransactionManager/src/test/java/com/msucre/transaction/TransactionManagerApplicationTests.java b/YapeChallenge/TransactionManager/src/test/java/com/msucre/transaction/TransactionManagerApplicationTests.java new file mode 100644 index 0000000000..e94d961373 --- /dev/null +++ b/YapeChallenge/TransactionManager/src/test/java/com/msucre/transaction/TransactionManagerApplicationTests.java @@ -0,0 +1,9 @@ +package com.msucre.transaction; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class TransactionManagerApplicationTests { + +} diff --git a/YapeChallenge/pom.xml b/YapeChallenge/pom.xml new file mode 100644 index 0000000000..904d850200 --- /dev/null +++ b/YapeChallenge/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.9 + + + + com.msucre + yape-challenge + 1.0.0 + pom + YapeChallenge + project for Spring Boot + + + + AntiFraudManager + SharedModel + TransactionManager + + + + + + + + + + + + + + + + + 21 + 1.3.25 + 1.10.2 + + + + + + + org.springframework.boot + spring-boot-dependencies + 3.5.9 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..f9d5087b1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,98 @@ -version: "3.7" services: postgres: - image: postgres:14 + image: postgres:18 + container_name: postgres ports: - "5432:5432" environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB_NAME} + volumes: + - ./docker/postgres/init:/docker-entrypoint-initdb.d + networks: + - yape-network zookeeper: - image: confluentinc/cp-zookeeper:5.5.3 + image: confluentinc/cp-zookeeper:7.7.7 + container_name: zookeeper environment: ZOOKEEPER_CLIENT_PORT: 2181 + networks: + - yape-network kafka: - image: confluentinc/cp-enterprise-kafka:5.5.3 + image: confluentinc/cp-enterprise-kafka:7.7.7 + container_name: kafka depends_on: [zookeeper] environment: KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://kafka:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT - KAFKA_BROKER_ID: 1 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_JMX_PORT: 9991 + KAFKA_CREATE_TOPICS: "event:1:1,event-response:1:1" ports: - 9092:9092 + - 29092:29092 + networks: + - yape-network + cassandra: + image: cassandra:5.0 + container_name: cassandra + ports: + - "9042:9042" + environment: + CASSANDRA_CLUSTER_NAME: "yape-challenge-cluster" + CASSANDRA_DC: dc1 + CASSANDRA_ENDPOINT_SNITCH: GossipingPropertyFileSnitch + CASSANDRA_NUM_TOKENS: 1 + CASSANDRA_RACK: rack1 + healthcheck: + test: ["CMD-SHELL", "cqlsh localhost 9042 -e 'DESCRIBE KEYSPACES'"] + interval: 30s + timeout: 10s + retries: 10 + networks: + - yape-network + cassandra-baseline: + image: cassandra:5.0 + container_name: cassandra-baseline + depends_on: + cassandra: + condition: service_healthy + volumes: + - ./docker/cassandra/init/baseline.cql:/baseline.cql + command: /bin/bash -c "echo loading cassandra keyspace && cqlsh cassandra -f /baseline.cql && echo done" + networks: + - yape-network + redis: + image: redis:8.4 + container_name: redis + ports: + - "6379:6379" + networks: + - yape-network + antifraud: + build: + dockerfile: ./Dockerfile.antifraud + container_name: antifraud + depends_on: + cassandra-baseline: + condition: service_completed_successfully + ports: + - "8081:8081" + networks: + - yape-network + transaction: + build: + dockerfile: ./Dockerfile.transaction + container_name: transaction + depends_on: + cassandra-baseline: + condition: service_completed_successfully + ports: + - "8082:8082" + networks: + - yape-network +networks: + yape-network: + driver: bridge \ No newline at end of file diff --git a/docker/cassandra/init/baseline.cql b/docker/cassandra/init/baseline.cql new file mode 100644 index 0000000000..47788fc7ff --- /dev/null +++ b/docker/cassandra/init/baseline.cql @@ -0,0 +1,12 @@ +CREATE KEYSPACE IF NOT EXISTS yape_challenge WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}; + +CREATE TABLE IF NOT EXISTS yape_challenge.transaction_by_account ( + source_account_id uuid, + created_at bigint, + transaction_id uuid, + destination_account_id uuid, + status text, + type text, + amount float, + PRIMARY KEY (source_account_id, created_at, transaction_id) +) WITH CLUSTERING ORDER BY (created_at DESC, transaction_id ASC); \ No newline at end of file diff --git a/docker/postgres/init/baseline.sql b/docker/postgres/init/baseline.sql new file mode 100644 index 0000000000..9177387ce7 --- /dev/null +++ b/docker/postgres/init/baseline.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS transaction ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + source_account_id uuid NOT NULL, + created_at bigint NOT NULL, + destination_account_id uuid NOT NULL, + status text NOT NULL, + type text NOT NULL, + amount float NOT NULL +); \ No newline at end of file