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/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
-
- - pending
- - approved
- - rejected
-
+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
-
- - Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
- - Any database
- - Kafka
-
+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
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
+ }
+}