From 0acbe78fd6cebed0a36c8950fe19032f4dd86217 Mon Sep 17 00:00:00 2001 From: Miguel Date: Sun, 8 Jun 2025 21:59:01 -0500 Subject: [PATCH 1/2] challenge resolved --- .eslintrc.js | 25 ++++++ .gitignore | 3 + .prettierrc | 4 + docker-compose.yml | 28 ++++--- nest-cli.json | 8 ++ package.json | 77 +++++++++++++++++++ src/app.module.ts | 26 +++++++ src/main.ts | 24 ++++++ src/shared/enum/enum.ts | 13 ++++ src/shared/kafka/kafka.module.ts | 8 ++ src/shared/kafka/kafka.producer.ts | 29 +++++++ src/transaction/dto/create-transaction.dto.ts | 16 ++++ src/transaction/transaction.consumer.ts | 16 ++++ src/transaction/transaction.controller.ts | 34 ++++++++ src/transaction/transaction.entity.ts | 28 +++++++ src/transaction/transaction.module.ts | 20 +++++ src/transaction/transaction.service.ts | 36 +++++++++ tsconfig.build.json | 4 + tsconfig.json | 21 +++++ 19 files changed, 408 insertions(+), 12 deletions(-) create mode 100644 .eslintrc.js create mode 100644 .prettierrc create mode 100644 nest-cli.json create mode 100644 package.json create mode 100644 src/app.module.ts create mode 100644 src/main.ts create mode 100644 src/shared/enum/enum.ts create mode 100644 src/shared/kafka/kafka.module.ts create mode 100644 src/shared/kafka/kafka.producer.ts create mode 100644 src/transaction/dto/create-transaction.dto.ts create mode 100644 src/transaction/transaction.consumer.ts create mode 100644 src/transaction/transaction.controller.ts create mode 100644 src/transaction/transaction.entity.ts create mode 100644 src/transaction/transaction.module.ts create mode 100644 src/transaction/transaction.service.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..259de13c73 --- /dev/null +++ b/.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/.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/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..dcb72794f5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..c0f3599419 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,18 +8,22 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres zookeeper: - image: confluentinc/cp-zookeeper:5.5.3 + image: bitnami/zookeeper:3.9 + container_name: zookeeper + ports: + - '2181:2181' environment: - ZOOKEEPER_CLIENT_PORT: 2181 + - ALLOW_ANONYMOUS_LOGIN=yes kafka: - image: confluentinc/cp-enterprise-kafka:5.5.3 - depends_on: [zookeeper] - environment: - KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" - 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_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_JMX_PORT: 9991 + image: bitnami/kafka:3.6 + container_name: kafka ports: - - 9092:9092 + - '9092:9092' + environment: + - KAFKA_BROKER_ID=1 + - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181 + - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 + - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092 + - ALLOW_PLAINTEXT_LISTENER=yes + depends_on: + - zookeeper diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/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/package.json b/package.json new file mode 100644 index 0000000000..27f4b4a4e2 --- /dev/null +++ b/package.json @@ -0,0 +1,77 @@ +{ + "name": "app-nodejs-codechallenge", + "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/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000000..642e5828ec --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TransactionModule } from './transaction/transaction.module'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + 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, + autoLoadEntities: true, + synchronize: true, + }), + TransactionModule, + ], + controllers: [], + providers: [], +}) +export class AppModule { } diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000000..45004a728e --- /dev/null +++ b/src/main.ts @@ -0,0 +1,24 @@ +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: ['localhost:9092'], + }, + consumer: { + groupId: 'transaction-consumer-group', + }, + }, + }); + + await app.startAllMicroservices(); + await app.listen(process.env.PORT ?? 3000); +} +bootstrap(); diff --git a/src/shared/enum/enum.ts b/src/shared/enum/enum.ts new file mode 100644 index 0000000000..298502e8a9 --- /dev/null +++ b/src/shared/enum/enum.ts @@ -0,0 +1,13 @@ +export enum TransferType { + TRANSFER = 1, + PAYMENT = 2, + REFUND = 3, + WITHDRAWAL = 4 +} + +export const TransferTypeLabel = { + [TransferType.TRANSFER]: 'transfer', + [TransferType.PAYMENT]: 'payment', + [TransferType.REFUND]: 'refund', + [TransferType.WITHDRAWAL]: 'withdrawal', +} as const; \ No newline at end of file diff --git a/src/shared/kafka/kafka.module.ts b/src/shared/kafka/kafka.module.ts new file mode 100644 index 0000000000..cee49f0426 --- /dev/null +++ b/src/shared/kafka/kafka.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { KafkaProducerService } from './kafka.producer'; + +@Module({ + providers: [KafkaProducerService], + exports: [KafkaProducerService], +}) +export class KafkaModule {} \ No newline at end of file diff --git a/src/shared/kafka/kafka.producer.ts b/src/shared/kafka/kafka.producer.ts new file mode 100644 index 0000000000..41be770bde --- /dev/null +++ b/src/shared/kafka/kafka.producer.ts @@ -0,0 +1,29 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Kafka, Producer } from 'kafkajs'; + +@Injectable() +export class KafkaProducerService implements OnModuleInit, OnModuleDestroy { + private readonly kafka = new Kafka({ + brokers: ['localhost:9092'], + }); + + private readonly producer: Producer = this.kafka.producer(); + + async onModuleInit() { + await this.producer.connect(); + console.log('[KafkaProducer] Conectado'); + } + + async emit(topic: string, message: any) { + await this.producer.send({ + topic, + messages: [{ value: JSON.stringify(message) }], + }); + + console.log(`[KafkaProducer] Emitido a ${topic}`, message); + } + + async onModuleDestroy() { + await this.producer.disconnect(); + } +} \ No newline at end of file diff --git a/src/transaction/dto/create-transaction.dto.ts b/src/transaction/dto/create-transaction.dto.ts new file mode 100644 index 0000000000..3ecade927c --- /dev/null +++ b/src/transaction/dto/create-transaction.dto.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsPositive, IsUUID } from 'class-validator'; +import { TransferType } from 'src/shared/enum/enum'; + +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/src/transaction/transaction.consumer.ts b/src/transaction/transaction.consumer.ts new file mode 100644 index 0000000000..050e98a821 --- /dev/null +++ b/src/transaction/transaction.consumer.ts @@ -0,0 +1,16 @@ +import { Controller } from '@nestjs/common'; +import { MessagePattern, Payload } from '@nestjs/microservices'; +import { TransactionService } from './transaction.service'; + +@Controller() +export class TransactionConsumer { + constructor(private readonly transactionService: TransactionService) {} + + @MessagePattern('transaction.created') + async handleTransactionCreated(@Payload() message: any) { + console.log('[Kafka] Recibido:', message); + const status = message.value > 1000 ? 'rejected' : 'approved'; + await this.transactionService.updateStatus(message.id, status); + console.log(`[Kafka] Transacción ${message.id} ${status}`); + } +} \ No newline at end of file diff --git a/src/transaction/transaction.controller.ts b/src/transaction/transaction.controller.ts new file mode 100644 index 0000000000..49ee28d9a1 --- /dev/null +++ b/src/transaction/transaction.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Post, Body, NotFoundException, Param, Get } from '@nestjs/common'; +import { TransactionService } from './transaction.service'; +import { CreateTransactionDto } from './dto/create-transaction.dto'; +import { TransferTypeLabel } from 'src/shared/enum/enum'; + +@Controller('transaction') +export class TransactionController { + constructor(private readonly transactionService: TransactionService) { } + + @Post() + async create(@Body() dto: CreateTransactionDto): Promise { + await this.transactionService.create(dto); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + const transaction = await this.transactionService.findById(id); + if (!transaction) { + throw new NotFoundException('Transaction not found'); + } + + return { + transactionExternalId: transaction.id, + transactionType: { + name: TransferTypeLabel[transaction.tranferTypeId], + }, + transactionStatus: { + name: transaction.status, + }, + value: transaction.value, + createdAt: transaction.createdAt, + }; + } +} \ No newline at end of file diff --git a/src/transaction/transaction.entity.ts b/src/transaction/transaction.entity.ts new file mode 100644 index 0000000000..ea72a26d0a --- /dev/null +++ b/src/transaction/transaction.entity.ts @@ -0,0 +1,28 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity() +export class Transaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + accountExternalIdDebit: string; + + @Column() + accountExternalIdCredit: string; + + @Column() + tranferTypeId: number; + + @Column('decimal') + value: number; + + @Column({ default: 'pending' }) + status: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/transaction/transaction.module.ts b/src/transaction/transaction.module.ts new file mode 100644 index 0000000000..a68090ce2b --- /dev/null +++ b/src/transaction/transaction.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TransactionController } from './transaction.controller'; +import { TransactionService } from './transaction.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Transaction } from './transaction.entity'; +import { TransactionConsumer } from './transaction.consumer'; +import { KafkaModule } from 'src/shared/kafka/kafka.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Transaction]), + KafkaModule + ], + controllers: [ + TransactionController, + TransactionConsumer + ], + providers: [TransactionService] +}) +export class TransactionModule { } diff --git a/src/transaction/transaction.service.ts b/src/transaction/transaction.service.ts new file mode 100644 index 0000000000..587bc4889b --- /dev/null +++ b/src/transaction/transaction.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { CreateTransactionDto } from './dto/create-transaction.dto'; +import { Transaction } from './transaction.entity'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { KafkaProducerService } from 'src/shared/kafka/kafka.producer'; +@Injectable() +export class TransactionService { + constructor( + @InjectRepository(Transaction) + readonly transactionRepository: Repository, + readonly kafkaProducer: KafkaProducerService + ) { } + + async create(dto: CreateTransactionDto): Promise { + const transaction = this.transactionRepository.create({ + ...dto + }); + const savedTransaction = await this.transactionRepository.save(transaction); + await this.kafkaProducer.emit('transaction.created', { + id: savedTransaction.id, + value: savedTransaction.value, + accountExternalIdDebit: savedTransaction.accountExternalIdDebit, + accountExternalIdCredit: savedTransaction.accountExternalIdCredit, + tranferTypeId: savedTransaction.tranferTypeId, + }); + } + + async findById(id: string) { + return this.transactionRepository.findOneBy({ id }); + } + + async updateStatus(id: string, status: string): Promise { + await this.transactionRepository.update(id, { status }); + } +} \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..95f5641cf7 --- /dev/null +++ b/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 + } +} From a9bb083b38fbf2f3e8a399de5f8eb46c140d2895 Mon Sep 17 00:00:00 2001 From: Miguel Date: Sun, 8 Jun 2025 22:21:05 -0500 Subject: [PATCH 2/2] modify readme instructions --- README.md | 90 ++++++++++++++++--------------------------------------- 1 file changed, 25 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index b067a71026..2d1cbefd18 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,42 @@ -# Yape Code Challenge :rocket: +## 🚀 Instrucciones para levantar el entorno -Our code challenge will let you marvel us with your Jedi coding skills :smile:. +### 1. Crear archivo .env en la raiz -Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !! +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=postgres -- [Problem](#problem) -- [Tech Stack](#tech_stack) -- [Send us your challenge](#send_us_your_challenge) +### 2. Instalar las dependencias -# Problem +npm install -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: +# o -
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. -
+yarn install -Every transaction with a value greater than 1000 should be rejected. +### 3. Levantar los servicios de base de datos, zookeeper y kafka -```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)] -``` +docker-compose up -d -# Tech Stack +### 4. Correr la aplicacion -
    -
  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. -
+npm run local -We do provide a `Dockerfile` to help you get started with a dev environment. +# o -You must have two resources: +yarn local -1. Resource to create a transaction that must containt: +### 5. Probar los servicios usando los siguientes curl -```json -{ - "accountExternalIdDebit": "Guid", - "accountExternalIdCredit": "Guid", +curl --location 'localhost:3000/transaction' \ +--header 'Content-Type: application/json' \ +--data '{ + "accountExternalIdDebit": "5429d629-c239-45fa-8235-1a386258c536", + "accountExternalIdCredit": "d6cd54da-8ce3-4f79-abda-bd5be9b19e68", "tranferTypeId": 1, "value": 120 -} -``` +}' -2. Resource to retrieve a transaction - -```json -{ - "transactionExternalId": "Guid", - "transactionType": { - "name": "" - }, - "transactionStatus": { - "name": "" - }, - "value": 120, - "createdAt": "Date" -} -``` - -## Optional - -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? - -You can use Graphql; - -# Send us your challenge - -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. - -If you have any questions, please let us know. +curl --location 'localhost:3000/transaction/5429d629-c239-45fa-8235-1a386258c536' \ No newline at end of file