diff --git a/.gitignore b/.gitignore index 67045665db..e60b05b888 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,7 @@ # 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 @@ -15,90 +9,8 @@ pids *.seed *.pid.lock -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov +# Compilation +target -# 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 +# VSCode +.vscode \ No newline at end of file diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000000..0e83a29a8d --- /dev/null +++ b/TESTING.md @@ -0,0 +1,36 @@ +# How to Test the Project (Windows CMD) + +## Prerequisites +- Docker & Docker Compose +- Java 17+ +- Maven + +## 1. Start Infrastructure +Start using Docker Compose: + +docker compose up -d + +## 2. Test Transaction Creation +Send a POST request to create a transaction. + +**Scenario A: Approved Transaction (Value <= 1000)** + +curl -X POST http://localhost:8081/transactions ^ + -H "Content-Type: application/json" ^ + -d "{\"accountExternalIdDebit\":\"26066299-4467-4d70-a8af-2415dd537af5\",\"accountExternalIdCredit\":\"d26e431c-b72e-436f-8898-7509e5399580\",\"transferTypeId\":1,\"value\":500}" + +**Scenario B: Rejected Transaction (Value > 1000)** + +curl -X POST http://localhost:8081/transactions ^ + -H "Content-Type: application/json" ^ + -d "{\"accountExternalIdDebit\":\"26066299-4467-4d70-a8af-2415dd537af5\",\"accountExternalIdCredit\":\"d26e431c-b72e-436f-8898-7509e5399580\",\"transferTypeId\":1,\"value\":1500}" + +## 3. Verify Status +The response from the initial POST will be PENDING. +To check the final status (updated by Anti-Fraud service via Kafka), copy the transactionExternalId from the response and run: + +curl -X GET "http://localhost:8081/transactions/{transactionExternalId}" + +Replace {transactionExternalId} with the UUID returned from the POST. +- Scenario A should be APPROVED. +- Scenario B should be REJECTED. diff --git a/anti-fraud-service/Dockerfile b/anti-fraud-service/Dockerfile new file mode 100644 index 0000000000..84cfeed4ba --- /dev/null +++ b/anti-fraud-service/Dockerfile @@ -0,0 +1,20 @@ +# ---------- Build stage ---------- +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /build +COPY pom.xml . +RUN mvn -B dependency:go-offline + +COPY src ./src +RUN mvn -B package + +# ---------- Runtime stage ---------- +FROM eclipse-temurin:17-jre +WORKDIR /work + +COPY --from=build /build/target/quarkus-app/lib/ lib/ +COPY --from=build /build/target/quarkus-app/app/ app/ +COPY --from=build /build/target/quarkus-app/quarkus/ quarkus/ +COPY --from=build /build/target/quarkus-app/quarkus-run.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java","-jar","app.jar"] diff --git a/anti-fraud-service/pom.xml b/anti-fraud-service/pom.xml new file mode 100644 index 0000000000..a9ba1a4ba0 --- /dev/null +++ b/anti-fraud-service/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + com.yape.codechallenge + anti-fraud-service + 1.0.0-SNAPSHOT + + 3.13.0 + 17 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.15.1 + true + 3.5.0 + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + + io.quarkus + quarkus-smallrye-reactive-messaging-kafka + + + + io.quarkus + quarkus-rest-jsonb + + + + io.quarkus + quarkus-arc + + + + io.quarkus + quarkus-junit5 + test + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + native + + + native + + + + false + native + + + + diff --git a/anti-fraud-service/src/main/java/com/yape/codechallenge/event/TransactionEvent.java b/anti-fraud-service/src/main/java/com/yape/codechallenge/event/TransactionEvent.java new file mode 100644 index 0000000000..d003fe4f93 --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/codechallenge/event/TransactionEvent.java @@ -0,0 +1,9 @@ +package com.yape.codechallenge.event; + +import java.util.UUID; + +public record TransactionEvent( + UUID transactionExternalId, + Double value, + String status) { +} diff --git a/anti-fraud-service/src/main/java/com/yape/codechallenge/service/AntiFraudService.java b/anti-fraud-service/src/main/java/com/yape/codechallenge/service/AntiFraudService.java new file mode 100644 index 0000000000..ef1b66b68d --- /dev/null +++ b/anti-fraud-service/src/main/java/com/yape/codechallenge/service/AntiFraudService.java @@ -0,0 +1,28 @@ +package com.yape.codechallenge.service; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Outgoing; + +import com.yape.codechallenge.event.TransactionEvent; + +import io.smallrye.reactive.messaging.annotations.Blocking; + +@ApplicationScoped +public class AntiFraudService { + + @Incoming("transaction-created-in") + @Outgoing("transaction-status-out") + @Blocking + public TransactionEvent validateTransaction(TransactionEvent event) { + + String newStatus = event.value() > 1000 + ? "rejected" + : "approved"; + + return new TransactionEvent( + event.transactionExternalId(), + event.value(), + newStatus); + } +} diff --git a/anti-fraud-service/src/main/resources/application.properties b/anti-fraud-service/src/main/resources/application.properties new file mode 100644 index 0000000000..999915e91d --- /dev/null +++ b/anti-fraud-service/src/main/resources/application.properties @@ -0,0 +1,15 @@ +# Kafka +mp.messaging.connector.smallrye-kafka.bootstrap.servers=kafka:29092 + +# Incoming +mp.messaging.incoming.transaction-created-in.connector=smallrye-kafka +mp.messaging.incoming.transaction-created-in.topic=transactions +mp.messaging.incoming.transaction-created-in.value.deserializer.value.type=com.yape.codechallenge.TransactionEvent +mp.messaging.incoming.transaction-created-in.group.id=anti-fraud-group + +# Outgoing +mp.messaging.outgoing.transaction-status-out.connector=smallrye-kafka +mp.messaging.outgoing.transaction-status-out.topic=transaction-status +mp.messaging.outgoing.transaction-status-out.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer + +quarkus.http.port=8998 diff --git a/anti-fraud-service/src/test/java/com/yape/codechallenge/service/AntiFraudServiceTest.java b/anti-fraud-service/src/test/java/com/yape/codechallenge/service/AntiFraudServiceTest.java new file mode 100644 index 0000000000..4fa0756fed --- /dev/null +++ b/anti-fraud-service/src/test/java/com/yape/codechallenge/service/AntiFraudServiceTest.java @@ -0,0 +1,40 @@ +package com.yape.codechallenge.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.yape.codechallenge.event.TransactionEvent; + +class AntiFraudServiceTest { + + private final AntiFraudService antiFraudService = new AntiFraudService(); + + @Test + void shouldApproveTransactionWhenValueIsLessOrEqualThan1000() { + UUID transactionId = UUID.randomUUID(); + TransactionEvent event = new TransactionEvent(transactionId, 1000.0, "pending"); + TransactionEvent result = antiFraudService.validateTransaction(event); + + assertNotNull(result); + assertEquals(transactionId, result.transactionExternalId()); + assertEquals(1000.0, result.value()); + assertEquals("approved", result.status()); + } + + @Test + void shouldRejectTransactionWhenValueIsGreaterThan1000() { + UUID transactionId = UUID.randomUUID(); + TransactionEvent event = new TransactionEvent(transactionId, 1000.01, "pending"); + + TransactionEvent result = antiFraudService.validateTransaction(event); + + assertNotNull(result); + assertEquals(transactionId, result.transactionExternalId()); + assertEquals(1000.01, result.value()); + assertEquals("rejected", result.status()); + } +} diff --git a/anti-fraud-service/src/test/resources/application.properties b/anti-fraud-service/src/test/resources/application.properties new file mode 100644 index 0000000000..b5301a7acb --- /dev/null +++ b/anti-fraud-service/src/test/resources/application.properties @@ -0,0 +1,6 @@ +# Kafka Configuration +%test.mp.messaging.incoming.transaction-created-in.connector=smallrye-in-memory +%test.mp.messaging.incoming.transaction-created-in.topic=transactions + +%test.mp.messaging.outgoing.transaction-status-out.connector=smallrye-in-memory +%test.mp.messaging.outgoing.transaction-status-out.topic=transaction-status diff --git a/db/init/001_init.sql b/db/init/001_init.sql new file mode 100644 index 0000000000..63d45d5e2b --- /dev/null +++ b/db/init/001_init.sql @@ -0,0 +1,20 @@ +-- ========================================= +-- Init DB for transaction services +-- ========================================= +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS transactions ( + transactionExternalId UUID PRIMARY KEY, + accountExternalIdDebit UUID NOT NULL, + accountExternalIdCredit UUID NOT NULL, + transferTypeId INTEGER NOT NULL, + value NUMERIC(12,2) NOT NULL, + status VARCHAR(20) NOT NULL, + createdAt TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_transactions_status + ON transactions(status); + +CREATE INDEX IF NOT EXISTS idx_transactions_created_at + ON transactions(createdAt); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..0bd28e6dfd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,75 @@ -version: "3.7" services: postgres: image: postgres:14 + networks: + - kafka-net ports: - "5432:5432" environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d + zookeeper: image: confluentinc/cp-zookeeper:5.5.3 + networks: + - kafka-net environment: ZOOKEEPER_CLIENT_PORT: 2181 + kafka: image: confluentinc/cp-enterprise-kafka:5.5.3 - depends_on: [zookeeper] + networks: + - kafka-net + depends_on: + - zookeeper + ports: + - "9092:9092" environment: - KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,PLAINTEXT_HOST://0.0.0.0:9092 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT - KAFKA_BROKER_ID: 1 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_JMX_PORT: 9991 + healthcheck: + test: [ "CMD", "bash", "-c", "kafka-topics --bootstrap-server localhost:9092 --list" ] + interval: 10s + timeout: 5s + retries: 10 + transaction-service: + build: ./transaction-service + networks: + - kafka-net + depends_on: + kafka: + condition: service_healthy + postgres: + condition: service_started + ports: + - "8081:8998" + + anti-fraud-service: + networks: + - kafka-net + build: ./anti-fraud-service + depends_on: + postgres: + condition: service_started + kafka: + condition: service_healthy ports: - - 9092:9092 + - "8082:8998" + +volumes: + pgdata: +networks: + kafka-net: + driver: bridge diff --git a/transaction-service/Dockerfile b/transaction-service/Dockerfile new file mode 100644 index 0000000000..a57c4d9292 --- /dev/null +++ b/transaction-service/Dockerfile @@ -0,0 +1,23 @@ +# ---------- Build stage ---------- +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /build + +COPY pom.xml . +RUN mvn -B dependency:go-offline + +COPY src ./src +RUN mvn -B package + + +# ---------- Runtime stage ---------- +FROM eclipse-temurin:17-jre +WORKDIR /work + +COPY --from=build /build/target/quarkus-app/lib/ lib/ +COPY --from=build /build/target/quarkus-app/app/ app/ +COPY --from=build /build/target/quarkus-app/quarkus/ quarkus/ +COPY --from=build /build/target/quarkus-app/quarkus-run.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java","-Dquarkus.profile=prod","-jar","app.jar"] diff --git a/transaction-service/pom.xml b/transaction-service/pom.xml new file mode 100644 index 0000000000..2c31b02087 --- /dev/null +++ b/transaction-service/pom.xml @@ -0,0 +1,155 @@ + + + 4.0.0 + com.yape.codechallenge + transaction-service + 1.0.0-SNAPSHOT + + 3.13.0 + 17 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.15.1 + true + 3.5.0 + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-smallrye-reactive-messaging-kafka + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + io.quarkus + quarkus-jdbc-h2 + test + + + io.smallrye.reactive + smallrye-reactive-messaging-in-memory + test + + + org.mockito + mockito-core + 5.5.0 + test + + + org.mockito + mockito-junit-jupiter + 5.5.0 + test + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + native + + + native + + + + false + native + + + + diff --git a/transaction-service/src/main/java/com/yape/codechallenge/enums/TransactionStatus.java b/transaction-service/src/main/java/com/yape/codechallenge/enums/TransactionStatus.java new file mode 100644 index 0000000000..0dca45f1c4 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/enums/TransactionStatus.java @@ -0,0 +1,7 @@ +package com.yape.codechallenge.enums; + +public enum TransactionStatus { + APPROVED, + REJECTED, + PENDING +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/codechallenge/enums/TransactionType.java b/transaction-service/src/main/java/com/yape/codechallenge/enums/TransactionType.java new file mode 100644 index 0000000000..248701e3ca --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/enums/TransactionType.java @@ -0,0 +1,7 @@ +package com.yape.codechallenge.enums; + +public enum TransactionType { + PAYMENT, + TRANSFER, + REFUND +} \ No newline at end of file diff --git a/transaction-service/src/main/java/com/yape/codechallenge/event/TransactionEvent.java b/transaction-service/src/main/java/com/yape/codechallenge/event/TransactionEvent.java new file mode 100644 index 0000000000..a5a89c4aaa --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/event/TransactionEvent.java @@ -0,0 +1,10 @@ +package com.yape.codechallenge.event; + +import java.math.BigDecimal; +import java.util.UUID; + +public record TransactionEvent( + UUID transactionExternalId, + BigDecimal value, + String status) { +} diff --git a/transaction-service/src/main/java/com/yape/codechallenge/model/Transaction.java b/transaction-service/src/main/java/com/yape/codechallenge/model/Transaction.java new file mode 100644 index 0000000000..4f626e6597 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/model/Transaction.java @@ -0,0 +1,40 @@ +package com.yape.codechallenge.model; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import java.math.BigDecimal; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@jakarta.persistence.Table(name = "transactions") +public class Transaction extends PanacheEntityBase { + + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + public UUID transactionExternalId; + + public UUID accountExternalIdDebit; + public UUID accountExternalIdCredit; + public Integer transferTypeId; + @Column(precision = 12, scale = 2) + public BigDecimal value; + public String status; + public LocalDateTime createdAt; + + public Transaction() { + } + + public Transaction(UUID accountDebit, UUID accountCredit, Integer typeId, BigDecimal val) { + this.accountExternalIdDebit = accountDebit; + this.accountExternalIdCredit = accountCredit; + this.transferTypeId = typeId; + this.value = val; + this.status = "pending"; + this.createdAt = LocalDateTime.now(); + } +} diff --git a/transaction-service/src/main/java/com/yape/codechallenge/repository/TransactionRepository.java b/transaction-service/src/main/java/com/yape/codechallenge/repository/TransactionRepository.java new file mode 100644 index 0000000000..865ca878fa --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/repository/TransactionRepository.java @@ -0,0 +1,11 @@ +package com.yape.codechallenge.repository; + +import com.yape.codechallenge.model.Transaction; + +import java.util.UUID; + +public interface TransactionRepository { + Transaction save(Transaction transaction); + + Transaction findById(UUID id); +} diff --git a/transaction-service/src/main/java/com/yape/codechallenge/repository/TransactionRepositoryImpl.java b/transaction-service/src/main/java/com/yape/codechallenge/repository/TransactionRepositoryImpl.java new file mode 100644 index 0000000000..b6c8f740bf --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/repository/TransactionRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.yape.codechallenge.repository; + +import com.yape.codechallenge.model.Transaction; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.UUID; + +@ApplicationScoped +public class TransactionRepositoryImpl implements TransactionRepository { + + @Override + public Transaction save(Transaction transaction) { + transaction.persist(); + return transaction; + } + + @Override + public Transaction findById(UUID id) { + return Transaction.findById(id); + } +} diff --git a/transaction-service/src/main/java/com/yape/codechallenge/request/CreateTransactionRequest.java b/transaction-service/src/main/java/com/yape/codechallenge/request/CreateTransactionRequest.java new file mode 100644 index 0000000000..70a3a3437b --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/request/CreateTransactionRequest.java @@ -0,0 +1,11 @@ +package com.yape.codechallenge.request; + +import java.math.BigDecimal; +import java.util.UUID; + +public class CreateTransactionRequest { + public UUID accountExternalIdDebit; + public UUID accountExternalIdCredit; + public Integer transferTypeId; + public BigDecimal value; +} diff --git a/transaction-service/src/main/java/com/yape/codechallenge/resource/TransactionResource.java b/transaction-service/src/main/java/com/yape/codechallenge/resource/TransactionResource.java new file mode 100644 index 0000000000..cfc7f17a96 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/resource/TransactionResource.java @@ -0,0 +1,48 @@ +package com.yape.codechallenge.resource; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.UUID; + +import com.yape.codechallenge.enums.TransactionStatus; +import com.yape.codechallenge.enums.TransactionType; +import com.yape.codechallenge.model.Transaction; +import com.yape.codechallenge.request.CreateTransactionRequest; +import com.yape.codechallenge.response.TransactionResponse; +import com.yape.codechallenge.service.TransactionService; + +@Path("/transactions") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class TransactionResource { + + @Inject + TransactionService transactionService; + + @POST + public Response create(CreateTransactionRequest request) { + Transaction transaction = transactionService.createTransaction(request); + return Response.status(201).entity(toResponse(transaction)).build(); + } + + @GET + @Path("/{id}") + public Response get(@PathParam("id") UUID id) { + Transaction transaction = transactionService.getTransaction(id); + if (transaction == null) { + return Response.status(404).build(); + } + return Response.ok(toResponse(transaction)).build(); + } + + private TransactionResponse toResponse(Transaction t) { + return new TransactionResponse( + t.transactionExternalId, + TransactionType.TRANSFER, + TransactionStatus.valueOf(t.status.toUpperCase()), + t.value, + t.createdAt); + } +} diff --git a/transaction-service/src/main/java/com/yape/codechallenge/response/TransactionResponse.java b/transaction-service/src/main/java/com/yape/codechallenge/response/TransactionResponse.java new file mode 100644 index 0000000000..b98dea8664 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/response/TransactionResponse.java @@ -0,0 +1,16 @@ +package com.yape.codechallenge.response; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import com.yape.codechallenge.enums.TransactionStatus; +import com.yape.codechallenge.enums.TransactionType; + +public record TransactionResponse( + UUID transactionExternalId, + TransactionType transactionType, + TransactionStatus transactionStatus, + BigDecimal value, + LocalDateTime createdAt) { +} diff --git a/transaction-service/src/main/java/com/yape/codechallenge/service/TransactionService.java b/transaction-service/src/main/java/com/yape/codechallenge/service/TransactionService.java new file mode 100644 index 0000000000..e31a12bc54 --- /dev/null +++ b/transaction-service/src/main/java/com/yape/codechallenge/service/TransactionService.java @@ -0,0 +1,65 @@ +package com.yape.codechallenge.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.codechallenge.event.TransactionEvent; +import com.yape.codechallenge.model.Transaction; +import com.yape.codechallenge.repository.TransactionRepository; +import com.yape.codechallenge.request.CreateTransactionRequest; + +import java.io.IOException; +import java.util.UUID; + +@ApplicationScoped +public class TransactionService { + + @Inject + @Channel("transaction-created-out") + Emitter transactionEmitter; + + @Inject + ObjectMapper objectMapper; + + @Inject + TransactionRepository transactionRepository; + + @Transactional + public Transaction createTransaction(CreateTransactionRequest request) { + Transaction transaction = new Transaction( + request.accountExternalIdDebit, + request.accountExternalIdCredit, + request.transferTypeId, + request.value); + transactionRepository.save(transaction); + + TransactionEvent event = new TransactionEvent(transaction.transactionExternalId, transaction.value, + transaction.status); + transactionEmitter.send(event); + + return transaction; + } + + public Transaction getTransaction(UUID id) { + return transactionRepository.findById(id); + } + + @Incoming("transaction-status-in") + @Transactional + public void consumeStatusUpdate(String eventJson) { + try { + TransactionEvent event = objectMapper.readValue(eventJson, TransactionEvent.class); + + Transaction transaction = transactionRepository.findById(event.transactionExternalId()); + if (transaction != null) { + transaction.status = event.status(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/transaction-service/src/main/resources/application.properties b/transaction-service/src/main/resources/application.properties new file mode 100644 index 0000000000..767010debf --- /dev/null +++ b/transaction-service/src/main/resources/application.properties @@ -0,0 +1,38 @@ +#Quarkus +quarkus.datasource.db-kind=postgresql +quarkus.datasource.username=postgres +quarkus.datasource.password=postgres + +quarkus.kafka.devservices.enabled=false +quarkus.datasource.devservices.enabled=true + +quarkus.http.port=8998 + +#Hibernate +%dev.quarkus.hibernate-orm.database.generation=drop-and-create +%prod.quarkus.hibernate-orm.database.generation=validate + +#Datasource +%dev.quarkus.datasource.jdbc.url=jdbc:postgresql://postgres:5432/postgres +%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://postgres:5432/postgres + +#Kafka - Bootstrap servers +%prod.mp.messaging.connector.smallrye-kafka.bootstrap.servers=kafka:29092 +%dev.mp.messaging.connector.smallrye-kafka.bootstrap.servers=localhost:9092 + +#Kafka - Outgoing +mp.messaging.outgoing.transaction-created-out.connector=smallrye-kafka +mp.messaging.outgoing.transaction-created-out.topic=transactions +mp.messaging.outgoing.transaction-created-out.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer + +#Kafka - Incoming +mp.messaging.incoming.transaction-status-in.connector=smallrye-kafka +mp.messaging.incoming.transaction-status-in.topic=transaction-status +mp.messaging.incoming.transaction-status-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer + +# Test Configuration +%test.quarkus.datasource.db-kind=h2 +%test.quarkus.datasource.jdbc.url=jdbc:h2:mem:testdb +%test.quarkus.datasource.username=sa +%test.quarkus.datasource.password=password +%test.quarkus.hibernate-orm.database.generation=drop-and-create diff --git a/transaction-service/src/test/java/com/yape/codechallenge/service/TransactionServiceUnitTest.java b/transaction-service/src/test/java/com/yape/codechallenge/service/TransactionServiceUnitTest.java new file mode 100644 index 0000000000..f410adedb4 --- /dev/null +++ b/transaction-service/src/test/java/com/yape/codechallenge/service/TransactionServiceUnitTest.java @@ -0,0 +1,67 @@ +package com.yape.codechallenge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yape.codechallenge.event.TransactionEvent; +import com.yape.codechallenge.model.Transaction; +import com.yape.codechallenge.repository.TransactionRepository; +import com.yape.codechallenge.request.CreateTransactionRequest; + +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class TransactionServiceUnitTest { + + @Mock + TransactionRepository transactionRepository; + + @Mock + Emitter transactionEmitter; + + @Mock + ObjectMapper objectMapper; + + @InjectMocks + TransactionService transactionService; + + @Test + public void testCreateTransaction() { + CreateTransactionRequest request = new CreateTransactionRequest(); + request.accountExternalIdDebit = UUID.randomUUID(); + request.accountExternalIdCredit = UUID.randomUUID(); + request.transferTypeId = 1; + request.value = new BigDecimal("100"); + + Transaction fakeTransaction = new Transaction( + request.accountExternalIdDebit, + request.accountExternalIdCredit, + request.transferTypeId, + request.value); + fakeTransaction.transactionExternalId = UUID.randomUUID(); + fakeTransaction.status = "pending"; + + when(transactionRepository.save(any())).thenReturn(fakeTransaction); + when(transactionEmitter.send(any(TransactionEvent.class))).thenReturn(CompletableFuture.completedFuture(null)); + + Transaction result = transactionService.createTransaction(request); + + assertNotNull(result); + assertEquals("pending", result.status); + + verify(transactionRepository).save(any()); + verify(transactionEmitter).send(any(TransactionEvent.class)); + } + +} diff --git a/transaction-service/src/test/resources/application.properties b/transaction-service/src/test/resources/application.properties new file mode 100644 index 0000000000..a918bb6629 --- /dev/null +++ b/transaction-service/src/test/resources/application.properties @@ -0,0 +1,12 @@ +quarkus.datasource.db-kind=h2 +quarkus.datasource.username=sa +quarkus.datasource.password=sa +quarkus.datasource.jdbc.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +quarkus.hibernate-orm.database.generation=update + +# Kafka Configuration +%test.mp.messaging.outgoing.transaction-created-out.connector=smallrye-in-memory +%test.mp.messaging.outgoing.transaction-created-out.topic=transactions + +%test.mp.messaging.incoming.transaction-status-in.connector=smallrye-in-memory +%test.mp.messaging.incoming.transaction-status-in.topic=transaction-status