From 2f00ab838ee4cbcc2c4e0b9adf389d2a5011ad39 Mon Sep 17 00:00:00 2001 From: Miguel Date: Sun, 8 Jun 2025 21:59:01 -0500 Subject: [PATCH] challenge resolved --- .gitignore | 3 + README.md | 174 ++++++++++++------ anti-fraud/.eslintrc.js | 25 +++ anti-fraud/.prettierrc | 4 + anti-fraud/nest-cli.json | 8 + anti-fraud/package.json | 77 ++++++++ anti-fraud/src/app.controller.ts | 17 ++ anti-fraud/src/app.module.ts | 29 +++ anti-fraud/src/app.service.ts | 24 +++ anti-fraud/src/main.ts | 19 ++ .../models/transaction-approved.event.ts.ts | 17 ++ .../src/models/transaction-created.event.ts | 11 ++ .../src/models/transaction-rejected.event.ts | 17 ++ anti-fraud/tsconfig.build.json | 4 + anti-fraud/tsconfig.json | 21 +++ docker-compose.yml | 6 +- transaction/.eslintrc.js | 25 +++ transaction/.prettierrc | 4 + transaction/nest-cli.json | 8 + transaction/package.json | 77 ++++++++ transaction/src/app.module.ts | 24 +++ transaction/src/main.ts | 25 +++ .../models/create-transaction.dto.ts | 22 +++ .../models/transaction-created.event.ts | 18 ++ .../transaction/models/transaction.entity.ts | 34 ++++ .../src/transaction/transaction.controller.ts | 31 ++++ .../src/transaction/transaction.module.ts | 35 ++++ .../src/transaction/transaction.service.ts | 45 +++++ transaction/tsconfig.build.json | 4 + transaction/tsconfig.json | 21 +++ 30 files changed, 772 insertions(+), 57 deletions(-) create mode 100644 anti-fraud/.eslintrc.js create mode 100644 anti-fraud/.prettierrc create mode 100644 anti-fraud/nest-cli.json create mode 100644 anti-fraud/package.json create mode 100644 anti-fraud/src/app.controller.ts create mode 100644 anti-fraud/src/app.module.ts create mode 100644 anti-fraud/src/app.service.ts create mode 100644 anti-fraud/src/main.ts create mode 100644 anti-fraud/src/models/transaction-approved.event.ts.ts create mode 100644 anti-fraud/src/models/transaction-created.event.ts create mode 100644 anti-fraud/src/models/transaction-rejected.event.ts create mode 100644 anti-fraud/tsconfig.build.json create mode 100644 anti-fraud/tsconfig.json create mode 100644 transaction/.eslintrc.js create mode 100644 transaction/.prettierrc create mode 100644 transaction/nest-cli.json create mode 100644 transaction/package.json create mode 100644 transaction/src/app.module.ts create mode 100644 transaction/src/main.ts create mode 100644 transaction/src/transaction/models/create-transaction.dto.ts create mode 100644 transaction/src/transaction/models/transaction-created.event.ts create mode 100644 transaction/src/transaction/models/transaction.entity.ts create mode 100644 transaction/src/transaction/transaction.controller.ts create mode 100644 transaction/src/transaction/transaction.module.ts create mode 100644 transaction/src/transaction/transaction.service.ts create mode 100644 transaction/tsconfig.build.json create mode 100644 transaction/tsconfig.json 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: -
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. -
+``` +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): -
    -
  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. -
+```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 + } +}