diff --git a/.gitignore b/.gitignore
index 67045665db..a9195474eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -102,3 +102,6 @@ dist
# TernJS port file
.tern-port
+
+#package-lock.json
+package-lock.json
diff --git a/README.md b/README.md
index b067a71026..7f4ebf60e2 100644
--- a/README.md
+++ b/README.md
@@ -1,82 +1,148 @@
-# Yape Code Challenge :rocket:
+# app-nodejs-codechallenge
-Our code challenge will let you marvel us with your Jedi coding skills :smile:.
+Este repositorio es un microservicio en NestJS para manejar transacciones financieras. Usa PostgreSQL para persistencia y Kafka para la validación asíncrona (anti-fraud).
-Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !!
+Este README describe cómo levantar el entorno, cómo usar los servicios (endpoints) y cómo comprobar el flujo de mensajes con Kafka.
-- [Problem](#problem)
-- [Tech Stack](#tech_stack)
-- [Send us your challenge](#send_us_your_challenge)
+## Resumen técnico
+- Framework: NestJS
+- Persistencia: PostgreSQL (TypeORM)
+- Mensajería: Kafka
+- Arquitectura: monolito modular con flujo event-driven para validación anti-fraud
-# Problem
+## Requisitos
+- Docker & Docker Compose
+- Node 18+ y npm o yarn
-Every time a financial transaction is created it must be validated by our anti-fraud microservice and then the same service sends a message back to update the transaction status.
-For now, we have only three transaction statuses:
+## Configuración (.env)
+Crear un archivo `.env` en la raíz con las variables de Postgres:
-
- - pending
- - approved
- - rejected
-
+```
+POSTGRES_HOST=localhost
+POSTGRES_PORT=5432
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+POSTGRES_DB=postgres
+```
+
+Nota: la app por defecto usa `localhost:9092` como broker Kafka. Si ejecutas la app dentro de Docker, usa `kafka:29092` como broker.
+
+## Levantar el entorno
-Every transaction with a value greater than 1000 should be rejected.
+1. Instala dependencias:
-```mermaid
- flowchart LR
- Transaction -- Save Transaction with pending Status --> transactionDatabase[(Database)]
- Transaction --Send transaction Created event--> Anti-Fraud
- Anti-Fraud -- Send transaction Status Approved event--> Transaction
- Anti-Fraud -- Send transaction Status Rejected event--> Transaction
- Transaction -- Update transaction Status event--> transactionDatabase[(Database)]
+```bash
+npm install
+# o
+yarn install
```
-# Tech Stack
+2. Levanta infra con Docker Compose (Postgres, Zookeeper, Kafka):
-
- - Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
- - Any database
- - Kafka
-
+```bash
+docker compose up -d
+```
-We do provide a `Dockerfile` to help you get started with a dev environment.
+3. Ejecuta la aplicación en modo desarrollo:
-You must have two resources:
+```bash
+npm run local
+# o
+yarn local
+```
-1. Resource to create a transaction that must containt:
+### Levantar los dos servicios (transaction + anti-fraud)
-```json
-{
- "accountExternalIdDebit": "Guid",
- "accountExternalIdCredit": "Guid",
- "tranferTypeId": 1,
- "value": 120
-}
+Abre dos terminales separados y en cada uno ejecuta:
+
+Terminal A (transaction):
+
+```bash
+cd transaction
+npm install
+npm run local
+```
+
+Terminal B (anti-fraud):
+
+```bash
+cd anti-fraud
+npm install
+npm run local
```
-2. Resource to retrieve a transaction
+Ambos servicios deben poder conectar al broker Kafka que levantaste con `docker compose up -d`.
+
+## Cómo usar los servicios (endpoints)
+
+Base URL: `http://localhost:3000`
+
+1) Crear transacción — POST `/transaction`
+
+- Descripción: crea una transacción y publica el evento para validación anti-fraud.
+- Body (JSON):
```json
{
- "transactionExternalId": "Guid",
- "transactionType": {
- "name": ""
- },
- "transactionStatus": {
- "name": ""
- },
- "value": 120,
- "createdAt": "Date"
+ "accountExternalIdDebit": "5429d629-c239-45fa-8235-1a386258c536",
+ "accountExternalIdCredit": "d6cd54da-8ce3-4f79-abda-bd5be9b19e68",
+ "tranferTypeId": 1,
+ "value": 120
}
```
-## Optional
+Ejemplos `curl`:
+
+```bash
+curl -i -X POST http://localhost:3000/transaction \
+ -H "Content-Type: application/json" \
+ -d '{
+ "accountExternalIdDebit": "5429d629-c239-45fa-8235-1a386258c536",
+ "accountExternalIdCredit": "d6cd54da-8ce3-4f79-abda-bd5be9b19e68",
+ "tranferTypeId": 1,
+ "value": 120
+ }'
+```
+
+2) Listar transacciones — GET `/transaction`
-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?
+- Descripción: devuelve todas las transacciones guardadas (útil para debug local).
-You can use Graphql;
+```bash
+curl http://localhost:3000/transaction
+```
-# Send us your challenge
+## Flujo Kafka (anti-fraud)
-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.
+- Al crear una transacción se publica un evento `transaction.created` con los datos básicos.
+- Un consumidor (anti-fraud) valida la transacción y emite `transaction.validated` con `{ id, status }`.
+- La app escucha `transaction.validated` y actualiza el `status` en la base de datos.
+
+Regla implementada en el anti-fraud (ejemplo): `if value > 1000 => rejected`; else `approved`.
+
+3. Consultar la última transacción:
+
+```sql
+select * from transaction where "accountExternalIdDebit" = {{ID}};
+```
+
+## DDL (esquema) para probar localmente
+
+```sql
+
+CREATE TABLE public.transaction
+(
+ "accountExternalIdDebit" varchar NOT NULL,
+ "accountExternalIdCredit" varchar NOT NULL,
+ "tranferTypeId" integer NOT NULL,
+ value numeric NOT NULL,
+ id uuid DEFAULT uuid_generate_v4() NOT NULL
+ CONSTRAINT "PK_89eadb93a89810556e1cbcd6ab9"
+ PRIMARY KEY,
+ "createdAt" timestamp DEFAULT now() NOT NULL,
+ "updatedAt" timestamp DEFAULT now() NOT NULL,
+ status integer NOT NULL
+);
+```
-If you have any questions, please let us know.
+Si usas TypeORM, la entidad del proyecto crea automáticamente la tabla al iniciar (si `synchronize: true`). Usa este DDL solo si quieres preparar la tabla manualmente o probar fuera del ciclo de arranque de la app.
\ No newline at end of file
diff --git a/anti-fraud/.eslintrc.js b/anti-fraud/.eslintrc.js
new file mode 100644
index 0000000000..259de13c73
--- /dev/null
+++ b/anti-fraud/.eslintrc.js
@@ -0,0 +1,25 @@
+module.exports = {
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: 'tsconfig.json',
+ tsconfigRootDir: __dirname,
+ sourceType: 'module',
+ },
+ plugins: ['@typescript-eslint/eslint-plugin'],
+ extends: [
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:prettier/recommended',
+ ],
+ root: true,
+ env: {
+ node: true,
+ jest: true,
+ },
+ ignorePatterns: ['.eslintrc.js'],
+ rules: {
+ '@typescript-eslint/interface-name-prefix': 'off',
+ '@typescript-eslint/explicit-function-return-type': 'off',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+};
diff --git a/anti-fraud/.prettierrc b/anti-fraud/.prettierrc
new file mode 100644
index 0000000000..dcb72794f5
--- /dev/null
+++ b/anti-fraud/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all"
+}
\ No newline at end of file
diff --git a/anti-fraud/nest-cli.json b/anti-fraud/nest-cli.json
new file mode 100644
index 0000000000..f9aa683b1a
--- /dev/null
+++ b/anti-fraud/nest-cli.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json.schemastore.org/nest-cli",
+ "collection": "@nestjs/schematics",
+ "sourceRoot": "src",
+ "compilerOptions": {
+ "deleteOutDir": true
+ }
+}
diff --git a/anti-fraud/package.json b/anti-fraud/package.json
new file mode 100644
index 0000000000..9afb2b8c93
--- /dev/null
+++ b/anti-fraud/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "anti-fraud",
+ "version": "0.0.1",
+ "description": "",
+ "author": "",
+ "private": true,
+ "license": "UNLICENSED",
+ "scripts": {
+ "build": "nest build",
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
+ "start": "nest start",
+ "local": "nest start --watch",
+ "start:debug": "nest start --debug --watch",
+ "start:prod": "node dist/main",
+ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:cov": "jest --coverage",
+ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
+ "test:e2e": "jest --config ./test/jest-e2e.json"
+ },
+ "dependencies": {
+ "@nestjs/common": "^10.0.0",
+ "@nestjs/config": "^4.0.2",
+ "@nestjs/core": "^10.0.0",
+ "@nestjs/microservices": "^10.4.18",
+ "@nestjs/platform-express": "^10.0.0",
+ "@nestjs/typeorm": "^11.0.0",
+ "class-transformer": "^0.5.1",
+ "class-validator": "^0.14.2",
+ "kafkajs": "^2.2.4",
+ "pg": "^8.16.0",
+ "reflect-metadata": "^0.2.0",
+ "rxjs": "^7.8.1",
+ "typeorm": "^0.3.24"
+ },
+ "devDependencies": {
+ "@nestjs/cli": "^10.0.0",
+ "@nestjs/schematics": "^10.0.0",
+ "@nestjs/testing": "^10.0.0",
+ "@types/express": "^5.0.0",
+ "@types/jest": "^29.5.2",
+ "@types/node": "^20.3.1",
+ "@types/supertest": "^6.0.0",
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
+ "@typescript-eslint/parser": "^8.0.0",
+ "eslint": "^8.0.0",
+ "eslint-config-prettier": "^9.0.0",
+ "eslint-plugin-prettier": "^5.0.0",
+ "jest": "^29.5.0",
+ "prettier": "^3.0.0",
+ "source-map-support": "^0.5.21",
+ "supertest": "^7.0.0",
+ "ts-jest": "^29.1.0",
+ "ts-loader": "^9.4.3",
+ "ts-node": "^10.9.1",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "^5.1.3"
+ },
+ "jest": {
+ "moduleFileExtensions": [
+ "js",
+ "json",
+ "ts"
+ ],
+ "rootDir": "src",
+ "testRegex": ".*\\.spec\\.ts$",
+ "transform": {
+ "^.+\\.(t|j)s$": "ts-jest"
+ },
+ "collectCoverageFrom": [
+ "**/*.(t|j)s"
+ ],
+ "coverageDirectory": "../coverage",
+ "testEnvironment": "node"
+ }
+}
diff --git a/anti-fraud/src/app.controller.ts b/anti-fraud/src/app.controller.ts
new file mode 100644
index 0000000000..ada64feb5c
--- /dev/null
+++ b/anti-fraud/src/app.controller.ts
@@ -0,0 +1,17 @@
+import { Controller, Logger } from '@nestjs/common';
+import { MessagePattern, Payload } from '@nestjs/microservices';
+import { AppService } from './app.service';
+import { TransactionCreatedEvent } from './models/transaction-created.event';
+
+@Controller()
+export class AppController {
+ constructor(private readonly appService: AppService) {}
+
+ @MessagePattern('transaction.created')
+ async approvedTransaction(
+ @Payload() payload: TransactionCreatedEvent,
+ ): Promise {
+ Logger.debug(payload);
+ await this.appService.evaluateTransaction(payload);
+ }
+}
\ No newline at end of file
diff --git a/anti-fraud/src/app.module.ts b/anti-fraud/src/app.module.ts
new file mode 100644
index 0000000000..970d8878a4
--- /dev/null
+++ b/anti-fraud/src/app.module.ts
@@ -0,0 +1,29 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { ClientsModule, Transport } from '@nestjs/microservices';
+import { AppController } from './app.controller';
+import { AppService } from './app.service';
+
+@Module({
+ imports: [
+ ConfigModule.forRoot(),
+ ClientsModule.register([
+ {
+ name: 'KAFKA',
+ transport: Transport.KAFKA,
+ options: {
+ client: {
+ clientId: 'anti-fraud',
+ brokers: [`${process.env.KAFKA_HOST}:9092`],
+ },
+ consumer: {
+ groupId: 'anti-fraud-consumer',
+ },
+ },
+ },
+ ]),
+ ],
+ controllers: [AppController],
+ providers: [AppService]
+})
+export class AppModule { }
diff --git a/anti-fraud/src/app.service.ts b/anti-fraud/src/app.service.ts
new file mode 100644
index 0000000000..0dc77ad491
--- /dev/null
+++ b/anti-fraud/src/app.service.ts
@@ -0,0 +1,24 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { ClientKafka } from '@nestjs/microservices';
+import { TransactionCreatedEvent } from './models/transaction-created.event';
+import { TransactionRejectedEvent } from './models/transaction-rejected.event';
+import { TransactionApprovedEvent } from './models/transaction-approved.event.ts';
+
+@Injectable()
+export class AppService {
+ constructor(
+ @Inject('KAFKA')
+ private readonly eventBus: ClientKafka,
+ ) {}
+
+ async evaluateTransaction(payload: TransactionCreatedEvent): Promise {
+ const evaluatedEvent =
+ payload.value > 1000
+ ? TransactionRejectedEvent
+ : TransactionApprovedEvent;
+ this.eventBus.emit(
+ evaluatedEvent.getName(),
+ evaluatedEvent.toEvent(payload),
+ );
+ }
+}
\ No newline at end of file
diff --git a/anti-fraud/src/main.ts b/anti-fraud/src/main.ts
new file mode 100644
index 0000000000..1e66cd38c2
--- /dev/null
+++ b/anti-fraud/src/main.ts
@@ -0,0 +1,19 @@
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from './app.module';
+import { MicroserviceOptions, Transport } from '@nestjs/microservices';
+
+async function bootstrap() {
+ const app = await NestFactory.createMicroservice(AppModule, {
+ transport: Transport.KAFKA,
+ options: {
+ client: {
+ brokers: [`${process.env.KAFKA_HOST}:9092`],
+ },
+ consumer: {
+ groupId: 'transaction-consumer',
+ },
+ },
+ } as MicroserviceOptions);
+ await app.listen();
+}
+bootstrap();
diff --git a/anti-fraud/src/models/transaction-approved.event.ts.ts b/anti-fraud/src/models/transaction-approved.event.ts.ts
new file mode 100644
index 0000000000..8473070122
--- /dev/null
+++ b/anti-fraud/src/models/transaction-approved.event.ts.ts
@@ -0,0 +1,17 @@
+import { TransactionCreatedEvent } from './transaction-created.event';
+
+export class TransactionApprovedEvent {
+ static getName(): string {
+ return 'transaction.approved';
+ }
+
+ static toEvent(transaction: TransactionCreatedEvent): string {
+ return JSON.stringify({
+ transactionExternalId: transaction.transactionExternalId,
+ transactionType: transaction.transactionType,
+ transactionStatus: transaction.transactionStatus,
+ value: transaction.value,
+ createdAt: transaction.createdAt,
+ });
+ }
+}
\ No newline at end of file
diff --git a/anti-fraud/src/models/transaction-created.event.ts b/anti-fraud/src/models/transaction-created.event.ts
new file mode 100644
index 0000000000..55dff7c4e4
--- /dev/null
+++ b/anti-fraud/src/models/transaction-created.event.ts
@@ -0,0 +1,11 @@
+export interface TransactionCreatedEvent {
+ transactionExternalId: string;
+ transactionType: {
+ name: number;
+ };
+ transactionStatus: {
+ name: number;
+ };
+ value: number;
+ createdAt: Date;
+}
\ No newline at end of file
diff --git a/anti-fraud/src/models/transaction-rejected.event.ts b/anti-fraud/src/models/transaction-rejected.event.ts
new file mode 100644
index 0000000000..bff14ba3a8
--- /dev/null
+++ b/anti-fraud/src/models/transaction-rejected.event.ts
@@ -0,0 +1,17 @@
+import { TransactionCreatedEvent } from './transaction-created.event';
+
+export class TransactionRejectedEvent {
+ static getName(): string {
+ return 'transaction.rejected';
+ }
+
+ static toEvent(transaction: TransactionCreatedEvent): string {
+ return JSON.stringify({
+ transactionExternalId: transaction.transactionExternalId,
+ transactionType: transaction.transactionType,
+ transactionStatus: transaction.transactionStatus,
+ value: transaction.value,
+ createdAt: transaction.createdAt,
+ });
+ }
+}
\ No newline at end of file
diff --git a/anti-fraud/tsconfig.build.json b/anti-fraud/tsconfig.build.json
new file mode 100644
index 0000000000..64f86c6bd2
--- /dev/null
+++ b/anti-fraud/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
+}
diff --git a/anti-fraud/tsconfig.json b/anti-fraud/tsconfig.json
new file mode 100644
index 0000000000..95f5641cf7
--- /dev/null
+++ b/anti-fraud/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "declaration": true,
+ "removeComments": true,
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true,
+ "allowSyntheticDefaultImports": true,
+ "target": "ES2021",
+ "sourceMap": true,
+ "outDir": "./dist",
+ "baseUrl": "./",
+ "incremental": true,
+ "skipLibCheck": true,
+ "strictNullChecks": false,
+ "noImplicitAny": false,
+ "strictBindCallApply": false,
+ "forceConsistentCasingInFileNames": false,
+ "noFallthroughCasesInSwitch": false
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..a2274149c0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,11 +8,11 @@ services:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
zookeeper:
- image: confluentinc/cp-zookeeper:5.5.3
+ image: confluentinc/cp-zookeeper:7.4.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
kafka:
- image: confluentinc/cp-enterprise-kafka:5.5.3
+ image: confluentinc/cp-enterprise-kafka:7.4.14
depends_on: [zookeeper]
environment:
KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
@@ -22,4 +22,4 @@ services:
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_JMX_PORT: 9991
ports:
- - 9092:9092
+ - 9092:9092
\ No newline at end of file
diff --git a/transaction/.eslintrc.js b/transaction/.eslintrc.js
new file mode 100644
index 0000000000..259de13c73
--- /dev/null
+++ b/transaction/.eslintrc.js
@@ -0,0 +1,25 @@
+module.exports = {
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: 'tsconfig.json',
+ tsconfigRootDir: __dirname,
+ sourceType: 'module',
+ },
+ plugins: ['@typescript-eslint/eslint-plugin'],
+ extends: [
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:prettier/recommended',
+ ],
+ root: true,
+ env: {
+ node: true,
+ jest: true,
+ },
+ ignorePatterns: ['.eslintrc.js'],
+ rules: {
+ '@typescript-eslint/interface-name-prefix': 'off',
+ '@typescript-eslint/explicit-function-return-type': 'off',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+};
diff --git a/transaction/.prettierrc b/transaction/.prettierrc
new file mode 100644
index 0000000000..dcb72794f5
--- /dev/null
+++ b/transaction/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all"
+}
\ No newline at end of file
diff --git a/transaction/nest-cli.json b/transaction/nest-cli.json
new file mode 100644
index 0000000000..f9aa683b1a
--- /dev/null
+++ b/transaction/nest-cli.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json.schemastore.org/nest-cli",
+ "collection": "@nestjs/schematics",
+ "sourceRoot": "src",
+ "compilerOptions": {
+ "deleteOutDir": true
+ }
+}
diff --git a/transaction/package.json b/transaction/package.json
new file mode 100644
index 0000000000..d78255775d
--- /dev/null
+++ b/transaction/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "transaction",
+ "version": "0.0.1",
+ "description": "",
+ "author": "",
+ "private": true,
+ "license": "UNLICENSED",
+ "scripts": {
+ "build": "nest build",
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
+ "start": "nest start",
+ "local": "nest start --watch",
+ "start:debug": "nest start --debug --watch",
+ "start:prod": "node dist/main",
+ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:cov": "jest --coverage",
+ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
+ "test:e2e": "jest --config ./test/jest-e2e.json"
+ },
+ "dependencies": {
+ "@nestjs/common": "^10.0.0",
+ "@nestjs/config": "^4.0.2",
+ "@nestjs/core": "^10.0.0",
+ "@nestjs/microservices": "^10.4.18",
+ "@nestjs/platform-express": "^10.0.0",
+ "@nestjs/typeorm": "^11.0.0",
+ "class-transformer": "^0.5.1",
+ "class-validator": "^0.14.2",
+ "kafkajs": "^2.2.4",
+ "pg": "^8.16.0",
+ "reflect-metadata": "^0.2.0",
+ "rxjs": "^7.8.1",
+ "typeorm": "^0.3.24"
+ },
+ "devDependencies": {
+ "@nestjs/cli": "^10.0.0",
+ "@nestjs/schematics": "^10.0.0",
+ "@nestjs/testing": "^10.0.0",
+ "@types/express": "^5.0.0",
+ "@types/jest": "^29.5.2",
+ "@types/node": "^20.3.1",
+ "@types/supertest": "^6.0.0",
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
+ "@typescript-eslint/parser": "^8.0.0",
+ "eslint": "^8.0.0",
+ "eslint-config-prettier": "^9.0.0",
+ "eslint-plugin-prettier": "^5.0.0",
+ "jest": "^29.5.0",
+ "prettier": "^3.0.0",
+ "source-map-support": "^0.5.21",
+ "supertest": "^7.0.0",
+ "ts-jest": "^29.1.0",
+ "ts-loader": "^9.4.3",
+ "ts-node": "^10.9.1",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "^5.1.3"
+ },
+ "jest": {
+ "moduleFileExtensions": [
+ "js",
+ "json",
+ "ts"
+ ],
+ "rootDir": "src",
+ "testRegex": ".*\\.spec\\.ts$",
+ "transform": {
+ "^.+\\.(t|j)s$": "ts-jest"
+ },
+ "collectCoverageFrom": [
+ "**/*.(t|j)s"
+ ],
+ "coverageDirectory": "../coverage",
+ "testEnvironment": "node"
+ }
+}
diff --git a/transaction/src/app.module.ts b/transaction/src/app.module.ts
new file mode 100644
index 0000000000..ca50970f10
--- /dev/null
+++ b/transaction/src/app.module.ts
@@ -0,0 +1,24 @@
+import { Module } from '@nestjs/common';
+import { TransactionModule } from './transaction/transaction.module';
+import { ConfigModule } from '@nestjs/config';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Transaction } from './transaction/models/transaction.entity';
+
+@Module({
+ imports: [
+ ConfigModule.forRoot(),
+ TypeOrmModule.forRoot({
+ type: 'postgres',
+ host: process.env.POSTGRES_HOST,
+ port: parseInt(process.env.POSTGRES_PORT, 10),
+ username: process.env.POSTGRES_USER,
+ password: process.env.POSTGRES_PASSWORD,
+ database: process.env.POSTGRES_DB,
+ entities: [Transaction],
+ synchronize: true,
+ }),
+ TransactionModule,
+ ],
+ providers: [],
+})
+export class AppModule { }
diff --git a/transaction/src/main.ts b/transaction/src/main.ts
new file mode 100644
index 0000000000..671b40c543
--- /dev/null
+++ b/transaction/src/main.ts
@@ -0,0 +1,25 @@
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from './app.module';
+import { MicroserviceOptions, Transport } from '@nestjs/microservices';
+import { ValidationPipe } from '@nestjs/common';
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule);
+ // app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
+ app.connectMicroservice({
+ transport: Transport.KAFKA,
+ options: {
+ client: {
+ brokers: [`${process.env.KAFKA_HOST}:9092`],
+ },
+ consumer: {
+ groupId: 'antifraud-consumer',
+ },
+ },
+ } as MicroserviceOptions);
+
+ await app.startAllMicroservices();
+ app.enableCors();
+ await app.listen(process.env.PORT || 3000);
+}
+bootstrap();
diff --git a/transaction/src/transaction/models/create-transaction.dto.ts b/transaction/src/transaction/models/create-transaction.dto.ts
new file mode 100644
index 0000000000..eaa1c89b49
--- /dev/null
+++ b/transaction/src/transaction/models/create-transaction.dto.ts
@@ -0,0 +1,22 @@
+import { IsEnum, IsPositive, IsUUID } from 'class-validator';
+
+export enum TransferType {
+ TRANSFER = 1,
+ PAYMENT = 2,
+ REFUND = 3,
+ WITHDRAWAL = 4
+}
+
+export class CreateTransactionDto {
+ @IsUUID()
+ accountExternalIdDebit: string;
+
+ @IsUUID()
+ accountExternalIdCredit: string;
+
+ @IsEnum(TransferType)
+ tranferTypeId: number;
+
+ @IsPositive()
+ value: number
+}
\ No newline at end of file
diff --git a/transaction/src/transaction/models/transaction-created.event.ts b/transaction/src/transaction/models/transaction-created.event.ts
new file mode 100644
index 0000000000..98a5f20366
--- /dev/null
+++ b/transaction/src/transaction/models/transaction-created.event.ts
@@ -0,0 +1,18 @@
+import { Transaction } from './transaction.entity';
+
+export class TransactionCreatedEvent {
+ static getName(): string {
+ return 'transaction.created';
+ }
+
+ static toEvent(transaction: Transaction): string {
+ return JSON.stringify({
+ transactionExternalId: transaction.id,
+ transactionStatus: {
+ name: transaction.status,
+ },
+ value: transaction.value,
+ createdAt: transaction.createdAt,
+ });
+ }
+}
\ No newline at end of file
diff --git a/transaction/src/transaction/models/transaction.entity.ts b/transaction/src/transaction/models/transaction.entity.ts
new file mode 100644
index 0000000000..89d8f3a4a8
--- /dev/null
+++ b/transaction/src/transaction/models/transaction.entity.ts
@@ -0,0 +1,34 @@
+import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+
+export enum TransactionStatus {
+ PENDING = 1,
+ APPROVED = 2,
+ REJECTED = 3,
+}
+
+@Entity()
+export class Transaction {
+ @PrimaryGeneratedColumn('uuid')
+ id: string;
+
+ @Column()
+ accountExternalIdDebit: string;
+
+ @Column()
+ accountExternalIdCredit: string;
+
+ @Column()
+ tranferTypeId: number;
+
+ @Column('decimal')
+ value: number;
+
+ @Column()
+ status: TransactionStatus;
+
+ @CreateDateColumn()
+ createdAt: Date;
+
+ @UpdateDateColumn()
+ updatedAt: Date;
+}
\ No newline at end of file
diff --git a/transaction/src/transaction/transaction.controller.ts b/transaction/src/transaction/transaction.controller.ts
new file mode 100644
index 0000000000..b8d78cf683
--- /dev/null
+++ b/transaction/src/transaction/transaction.controller.ts
@@ -0,0 +1,31 @@
+import { Controller, Post, Body, NotFoundException, Param, Get } from '@nestjs/common';
+import { TransactionService } from './transaction.service';
+import { CreateTransactionDto } from './models/create-transaction.dto';
+import { MessagePattern, Payload } from '@nestjs/microservices';
+
+@Controller('transaction')
+export class TransactionController {
+ constructor(private readonly transactionService: TransactionService) { }
+
+ @Post()
+ async create(@Body() dto: CreateTransactionDto): Promise {
+ await this.transactionService.create(dto);
+ }
+
+ @Get()
+ async find(): Promise {
+ return this.transactionService.find();
+ }
+
+ @MessagePattern('transaction.approved')
+ async transactionApproved(
+ @Payload() payload: any,
+ ): Promise {
+ await this.transactionService.approveTransaction(payload.transactionExternalId);
+ }
+
+ @MessagePattern('transaction.rejected')
+ async transactionRejected(@Payload() payload: any): Promise {
+ await this.transactionService.rejectTransaction(payload.transactionExternalId);
+ }
+}
\ No newline at end of file
diff --git a/transaction/src/transaction/transaction.module.ts b/transaction/src/transaction/transaction.module.ts
new file mode 100644
index 0000000000..6e94123b3e
--- /dev/null
+++ b/transaction/src/transaction/transaction.module.ts
@@ -0,0 +1,35 @@
+import { Module } from '@nestjs/common';
+import { TransactionController } from './transaction.controller';
+import { TransactionService } from './transaction.service';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Transaction } from './models/transaction.entity';
+import { ConfigModule } from '@nestjs/config';
+import { ClientsModule, Transport } from '@nestjs/microservices';
+
+@Module({
+ imports: [
+ ConfigModule.forRoot(),
+ ClientsModule.register([
+ {
+ name: 'KAFKA',
+ transport: Transport.KAFKA,
+ options: {
+ client: {
+ clientId: 'transaction',
+ brokers: [`${process.env.KAFKA_HOST}:9092`],
+ },
+ consumer: {
+ groupId: 'transaction-consumer',
+ },
+ },
+ },
+ ]),
+ TypeOrmModule.forFeature([Transaction])
+ ],
+ controllers: [
+ TransactionController
+ ],
+ providers: [TransactionService],
+ exports: [TransactionService],
+})
+export class TransactionModule { }
diff --git a/transaction/src/transaction/transaction.service.ts b/transaction/src/transaction/transaction.service.ts
new file mode 100644
index 0000000000..e16c990c1c
--- /dev/null
+++ b/transaction/src/transaction/transaction.service.ts
@@ -0,0 +1,45 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { CreateTransactionDto } from './models/create-transaction.dto';
+import { Transaction, TransactionStatus } from './models/transaction.entity';
+import { Repository } from 'typeorm';
+import { InjectRepository } from '@nestjs/typeorm';
+import { ClientKafka } from '@nestjs/microservices';
+import { TransactionCreatedEvent } from './models/transaction-created.event';
+@Injectable()
+export class TransactionService {
+ constructor(
+ @Inject('KAFKA') private readonly eventBus: ClientKafka,
+ @InjectRepository(Transaction)
+ readonly transactionRepository: Repository
+ ) { }
+
+ async create(dto: CreateTransactionDto): Promise {
+ const transaction = new Transaction();
+ transaction.accountExternalIdDebit = dto.accountExternalIdDebit;
+ transaction.accountExternalIdCredit = dto.accountExternalIdCredit;
+ transaction.tranferTypeId = dto.tranferTypeId;
+ transaction.createdAt = new Date();
+ transaction.status = TransactionStatus.PENDING;
+ transaction.value = dto.value;
+ const savedTransaction = await this.transactionRepository.save(transaction);
+ await this.eventBus.emit(TransactionCreatedEvent.getName(), TransactionCreatedEvent.toEvent(savedTransaction));
+ }
+
+ async approveTransaction(id: string): Promise {
+ await this.transactionRepository.update(
+ { id },
+ { status: TransactionStatus.APPROVED },
+ );
+ }
+
+ async rejectTransaction(id: string): Promise {
+ await this.transactionRepository.update(
+ { id },
+ { status: TransactionStatus.REJECTED },
+ );
+ }
+
+ async find(): Promise {
+ return this.transactionRepository.find();
+ }
+}
\ No newline at end of file
diff --git a/transaction/tsconfig.build.json b/transaction/tsconfig.build.json
new file mode 100644
index 0000000000..64f86c6bd2
--- /dev/null
+++ b/transaction/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
+}
diff --git a/transaction/tsconfig.json b/transaction/tsconfig.json
new file mode 100644
index 0000000000..95f5641cf7
--- /dev/null
+++ b/transaction/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "declaration": true,
+ "removeComments": true,
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true,
+ "allowSyntheticDefaultImports": true,
+ "target": "ES2021",
+ "sourceMap": true,
+ "outDir": "./dist",
+ "baseUrl": "./",
+ "incremental": true,
+ "skipLibCheck": true,
+ "strictNullChecks": false,
+ "noImplicitAny": false,
+ "strictBindCallApply": false,
+ "forceConsistentCasingInFileNames": false,
+ "noFallthroughCasesInSwitch": false
+ }
+}