From 79c0adf7ce9ae375c01be8c5274c8cf23ba262ea Mon Sep 17 00:00:00 2001 From: sinester09 <60792457+sinester09@users.noreply.github.com> Date: Thu, 24 Apr 2025 16:44:54 -0500 Subject: [PATCH] mejoras challenge --- README.md | 2 +- docker-compose.yml | 48 ++++++++-- ms-antifraude/eslint.config.mjs | 37 ++++++++ ms-antifraude/nest-cli.json | 8 ++ ms-antifraude/package.json | 78 ++++++++++++++++ ms-antifraude/readme.md | 49 ++++++++++ ms-antifraude/src/app.module.ts | 27 ++++++ ms-antifraude/src/main.ts | 50 ++++++++++ .../dto/transaction-validated.dto.ts | 20 ++++ .../src/validator/dto/validator.dto.ts | 17 ++++ .../validator/rules/rule.engine.service.ts | 59 ++++++++++++ .../src/validator/rules/rule.interface.ts | 7 ++ .../src/validator/rules/rules/account.rule.ts | 54 +++++++++++ .../src/validator/rules/rules/amount.rule.ts | 40 ++++++++ .../rules/rules/transfer-type.rule.ts | 48 ++++++++++ .../src/validator/validator.controller.ts | 32 +++++++ .../src/validator/validator.module.ts | 44 +++++++++ .../src/validator/validator.service.ts | 31 +++++++ ms-antifraude/test/app.e2e-spec.ts | 85 +++++++++++++++++ ms-antifraude/test/jest-e2e.json | 9 ++ ms-antifraude/tsconfig.build.json | 4 + ms-antifraude/tsconfig.json | 21 +++++ ms-transaction/.prettierrc | 4 + ms-transaction/eslint.config.mjs | 36 ++++++++ ms-transaction/nest-cli.json | 8 ++ ms-transaction/package.json | 91 +++++++++++++++++++ ms-transaction/readme.md | 39 ++++++++ ms-transaction/src/app.module.ts | 28 ++++++ ms-transaction/src/main.ts | 24 +++++ ms-transaction/src/schema.gql | 33 +++++++ .../dto/create-transaction.input.ts | 27 ++++++ .../dto/update-transaction-event.ts | 12 +++ .../entities/transaction.entity.ts | 69 ++++++++++++++ .../graphql/models/transaction.model.ts | 25 +++++ .../repositories/transaction.repository.ts | 27 ++++++ .../transaction/transaction-status.enum.ts | 7 ++ .../src/transaction/transaction.controller.ts | 37 ++++++++ .../src/transaction/transaction.module.ts | 37 ++++++++ .../src/transaction/transaction.resolver.ts | 23 +++++ .../src/transaction/transaction.service.ts | 84 +++++++++++++++++ ms-transaction/tsconfig.build.json | 4 + ms-transaction/tsconfig.json | 28 ++++++ 42 files changed, 1403 insertions(+), 10 deletions(-) create mode 100644 ms-antifraude/eslint.config.mjs create mode 100644 ms-antifraude/nest-cli.json create mode 100644 ms-antifraude/package.json create mode 100644 ms-antifraude/readme.md create mode 100644 ms-antifraude/src/app.module.ts create mode 100644 ms-antifraude/src/main.ts create mode 100644 ms-antifraude/src/validator/dto/transaction-validated.dto.ts create mode 100644 ms-antifraude/src/validator/dto/validator.dto.ts create mode 100644 ms-antifraude/src/validator/rules/rule.engine.service.ts create mode 100644 ms-antifraude/src/validator/rules/rule.interface.ts create mode 100644 ms-antifraude/src/validator/rules/rules/account.rule.ts create mode 100644 ms-antifraude/src/validator/rules/rules/amount.rule.ts create mode 100644 ms-antifraude/src/validator/rules/rules/transfer-type.rule.ts create mode 100644 ms-antifraude/src/validator/validator.controller.ts create mode 100644 ms-antifraude/src/validator/validator.module.ts create mode 100644 ms-antifraude/src/validator/validator.service.ts create mode 100644 ms-antifraude/test/app.e2e-spec.ts create mode 100644 ms-antifraude/test/jest-e2e.json create mode 100644 ms-antifraude/tsconfig.build.json create mode 100644 ms-antifraude/tsconfig.json create mode 100644 ms-transaction/.prettierrc create mode 100644 ms-transaction/eslint.config.mjs create mode 100644 ms-transaction/nest-cli.json create mode 100644 ms-transaction/package.json create mode 100644 ms-transaction/readme.md create mode 100644 ms-transaction/src/app.module.ts create mode 100644 ms-transaction/src/main.ts create mode 100644 ms-transaction/src/schema.gql create mode 100644 ms-transaction/src/transaction/dto/create-transaction.input.ts create mode 100644 ms-transaction/src/transaction/dto/update-transaction-event.ts create mode 100644 ms-transaction/src/transaction/entities/transaction.entity.ts create mode 100644 ms-transaction/src/transaction/interfaces/graphql/models/transaction.model.ts create mode 100644 ms-transaction/src/transaction/repositories/transaction.repository.ts create mode 100644 ms-transaction/src/transaction/transaction-status.enum.ts create mode 100644 ms-transaction/src/transaction/transaction.controller.ts create mode 100644 ms-transaction/src/transaction/transaction.module.ts create mode 100644 ms-transaction/src/transaction/transaction.resolver.ts create mode 100644 ms-transaction/src/transaction/transaction.service.ts create mode 100644 ms-transaction/tsconfig.build.json create mode 100644 ms-transaction/tsconfig.json diff --git a/README.md b/README.md index b067a71026..92beec12a8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Yape Code Challenge :rocket: +# Yape Code Challenge :rocket: ALO Our code challenge will let you marvel us with your Jedi coding skills :smile:. diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..5382ccc762 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,55 @@ -version: "3.7" +version: "3.8" + services: postgres: - image: postgres:14 + image: postgres:14-alpine ports: - "5432:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=transaction_db + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + zookeeper: - image: confluentinc/cp-zookeeper:5.5.3 + image: confluentinc/cp-zookeeper:7.3.0 environment: ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + healthcheck: + test: echo srvr | nc zookeeper 2181 || exit 1 + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + kafka: - image: confluentinc/cp-enterprise-kafka:5.5.3 - depends_on: [zookeeper] + image: confluentinc/cp-kafka:7.3.0 + depends_on: + - zookeeper + ports: + - "9092:9092" environment: + KAFKA_BROKER_ID: 1 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_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_JMX_PORT: 9991 - ports: - - 9092:9092 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + healthcheck: + test: kafka-topics --bootstrap-server kafka:29092 --list || exit 1 + interval: 30s + timeout: 10s + retries: 5 + restart: unless-stopped + +volumes: + postgres_data: \ No newline at end of file diff --git a/ms-antifraude/eslint.config.mjs b/ms-antifraude/eslint.config.mjs new file mode 100644 index 0000000000..a5bb12732e --- /dev/null +++ b/ms-antifraude/eslint.config.mjs @@ -0,0 +1,37 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + ecmaVersion: 5, + sourceType: 'module', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + 'prettier/prettier': 'off' + }, + }, +); \ No newline at end of file diff --git a/ms-antifraude/nest-cli.json b/ms-antifraude/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/ms-antifraude/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/ms-antifraude/package.json b/ms-antifraude/package.json new file mode 100644 index 0000000000..6111d87c68 --- /dev/null +++ b/ms-antifraude/package.json @@ -0,0 +1,78 @@ +{ + "name": "ms-antifraud-rules", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "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": "^11.0.1", + "@nestjs/core": "^11.0.1", + "@nestjs/microservices": "^11.0.21", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/typeorm": "^11.0.0", + "kafkajs": "^2.2.4", + "pg": "^8.15.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^0.3.22" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@swc/cli": "^0.6.0", + "@swc/core": "^1.10.7", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^15.14.0", + "jest": "^29.7.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "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/ms-antifraude/readme.md b/ms-antifraude/readme.md new file mode 100644 index 0000000000..da0b8774c7 --- /dev/null +++ b/ms-antifraude/readme.md @@ -0,0 +1,49 @@ +1. Arquitectura basada en reglas +He implementado un sistema de reglas flexible y extensible: + +Motor de reglas (RuleEngine): Coordina la ejecución de múltiples reglas de validación +Reglas específicas: + +AmountRule: Valida los montos de las transacciones +AccountRule: Verifica cuentas bloqueadas y validaciones de cuentas +TransferTypeRule: Aplica restricciones según el tipo de transferencia + + + +Esta arquitectura te permite agregar fácilmente nuevas reglas sin modificar el código existente. +2. Mejoras en manejo de eventos + +Control de errores robusto en la recepción y emisión de eventos +Formato consistente para eventos de respuesta +Logging detallado de todas las operaciones + +3. DTOs y validación + +DTOs bien definidos con validaciones usando class-validator +Enumeraciones para estados de transacción para evitar errores tipográficos +Interfaces claras entre componentes + +4. Observabilidad + +Logger estructurado en todos los componentes +Trazabilidad de transacciones a través del sistema +Mensajes de error descriptivos + +5. Configuración centralizada + +Uso de ConfigService para acceder a variables de entorno +Parámetros configurables (como límites de montos) + +Integración con el microservicio de transacciones +El servicio de antifraud ahora se conecta adecuadamente con el microservicio de transacciones: + +Recibe eventos transaction-created con los detalles de la transacción +Aplica reglas de negocio para validar la transacción +Emite eventos transaction-validated con el resultado de la validación + +Esta arquitectura es mucho más mantenible y escalable que la implementación original, permitiendo: + +Agregar nuevas reglas fácilmente +Configurar parámetros sin cambiar código +Manejar errores de forma robusta +Facilitar las pruebas unitarias \ No newline at end of file diff --git a/ms-antifraude/src/app.module.ts b/ms-antifraude/src/app.module.ts new file mode 100644 index 0000000000..6af46a9c24 --- /dev/null +++ b/ms-antifraude/src/app.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ValidatorModule } from './validator/validator.module'; +import { LoggerModule } from 'nestjs-pino'; + +@Module({ + imports: [ + // Configuración centralizada + ConfigModule.forRoot({ + isGlobal: true, + }), + + // Logging estructurado + LoggerModule.forRoot({ + pinoHttp: { + transport: process.env.NODE_ENV !== 'production' + ? { target: 'pino-pretty' } + : undefined, + level: process.env.LOG_LEVEL || 'info', + }, + }), + + // Módulos de la aplicación + ValidatorModule, + ], +}) +export class AppModule {} \ No newline at end of file diff --git a/ms-antifraude/src/main.ts b/ms-antifraude/src/main.ts new file mode 100644 index 0000000000..d1e8fcb2a1 --- /dev/null +++ b/ms-antifraude/src/main.ts @@ -0,0 +1,50 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); + const logger = new Logger('Bootstrap'); + + // Kafka brokers desde configuración + const kafkaBrokers = configService.get('KAFKA_BROKERS', 'localhost:9092').split(','); + + // Validación global + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + // Configurar microservicio + app.connectMicroservice({ + transport: Transport.KAFKA, + options: { + client: { + clientId: 'antifraud-consumer', + brokers: kafkaBrokers, + }, + consumer: { + groupId: 'antifraud-consumer-group', + }, + }, + }); + + // Iniciar microservicios + await app.startAllMicroservices(); + logger.log(`Microservicio Kafka iniciado con brokers: ${kafkaBrokers.join(',')}`); + + // Puerto HTTP desde configuración + const port = configService.get('PORT', 3001); + + // Iniciar aplicación HTTP + await app.listen(port); + logger.log(`Aplicación iniciada en: ${await app.getUrl()}`); +} + +bootstrap(); \ No newline at end of file diff --git a/ms-antifraude/src/validator/dto/transaction-validated.dto.ts b/ms-antifraude/src/validator/dto/transaction-validated.dto.ts new file mode 100644 index 0000000000..4acbcf3785 --- /dev/null +++ b/ms-antifraude/src/validator/dto/transaction-validated.dto.ts @@ -0,0 +1,20 @@ +import { IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +export enum TransactionStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED' +} + +export class TransactionValidatedDto { + @IsNotEmpty() + @IsUUID(4, { message: 'transactionExternalId debe ser un UUID válido' }) + transactionExternalId: string; + + @IsNotEmpty() + @IsEnum(TransactionStatus, { message: 'Estado debe ser APPROVED o REJECTED' }) + status: TransactionStatus; + + @IsString() + reason?: string; +} diff --git a/ms-antifraude/src/validator/dto/validator.dto.ts b/ms-antifraude/src/validator/dto/validator.dto.ts new file mode 100644 index 0000000000..2a30751eb3 --- /dev/null +++ b/ms-antifraude/src/validator/dto/validator.dto.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty, IsNumber, IsPositive, IsUUID } from 'class-validator'; + +export class ValidatorDto { + @IsNotEmpty() + @IsUUID(4, { message: 'transactionExternalId debe ser un UUID válido' }) + transactionExternalId: string; + + @IsNotEmpty() + @IsNumber() + @IsPositive({ message: 'El valor debe ser un número positivo' }) + value: number; + + // Campos adicionales que podrían ser útiles para reglas más complejas + accountExternalIdDebit?: string; + accountExternalIdCredit?: string; + tranferTypeId?: number; +} diff --git a/ms-antifraude/src/validator/rules/rule.engine.service.ts b/ms-antifraude/src/validator/rules/rule.engine.service.ts new file mode 100644 index 0000000000..55f8a581db --- /dev/null +++ b/ms-antifraude/src/validator/rules/rule.engine.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ValidatorDto } from '../dto/validator.dto'; +import { Rule } from './rule.interface'; +import { AmountRule } from './rules/amount.rule'; +import { AccountRule } from './rules/account.rule'; +import { TransferTypeRule } from './rules/transfer-type.rule'; + +export interface ValidationResult { + isValid: boolean; + reason?: string; +} + +@Injectable() +export class RuleEngine { + private readonly logger = new Logger(RuleEngine.name); + private rules: Rule[]; + + constructor( + private readonly amountRule: AmountRule, + private readonly accountRule: AccountRule, + private readonly transferTypeRule: TransferTypeRule + ) { + // Inicializar las reglas en el constructor + this.rules = [ + this.amountRule, + this.accountRule, + this.transferTypeRule + ]; + } + + async evaluateTransaction(transaction: ValidatorDto): Promise { + this.logger.log(`Evaluando transacción según reglas de negocio: ${transaction.transactionExternalId}`); + + // Evaluar cada regla secuencialmente + for (const rule of this.rules) { + try { + const result = await rule.evaluate(transaction); + + // Si alguna regla falla, retornar el resultado negativo inmediatamente + if (!result.isValid) { + this.logger.warn(`Regla ${rule.name} falló para la transacción ${transaction.transactionExternalId}: ${result.reason}`); + return result; + } + + this.logger.log(`Regla ${rule.name} pasó para la transacción ${transaction.transactionExternalId}`); + } catch (error) { + this.logger.error(`Error evaluando regla ${rule.name}: ${error.message}`, error.stack); + return { + isValid: false, + reason: `Error evaluando regla ${rule.name}: ${error.message}` + }; + } + } + + // Si todas las reglas pasan, la transacción es válida + this.logger.log(`Todas las reglas pasaron para la transacción ${transaction.transactionExternalId}`); + return { isValid: true }; + } +} \ No newline at end of file diff --git a/ms-antifraude/src/validator/rules/rule.interface.ts b/ms-antifraude/src/validator/rules/rule.interface.ts new file mode 100644 index 0000000000..94cb797e21 --- /dev/null +++ b/ms-antifraude/src/validator/rules/rule.interface.ts @@ -0,0 +1,7 @@ +import { ValidatorDto } from '../dto/validator.dto'; +import { ValidationResult } from './rule.engine.service'; + +export interface Rule { + name: string; + evaluate(transaction: ValidatorDto): Promise; +} diff --git a/ms-antifraude/src/validator/rules/rules/account.rule.ts b/ms-antifraude/src/validator/rules/rules/account.rule.ts new file mode 100644 index 0000000000..af5a22a725 --- /dev/null +++ b/ms-antifraude/src/validator/rules/rules/account.rule.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ValidatorDto } from '../../dto/validator.dto'; +import { Rule } from '../rule.interface +import { ValidationResult } from '../rule.engine.service'; + +@Injectable() +export class AccountRule implements Rule { + name = 'AccountRule'; + private readonly logger = new Logger(AccountRule.name); + + // Lista simulada de cuentas bloqueadas (en un caso real, esto vendría de una base de datos) + private readonly blockedAccounts: string[] = [ + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002' + ]; + + async evaluate(transaction: ValidatorDto): Promise { + this.logger.log(`Evaluando regla de cuentas para transacción: ${transaction.transactionExternalId}`); + + // Validar que las cuentas de origen y destino están presentes + if (!transaction.accountExternalIdDebit || !transaction.accountExternalIdCredit) { + return { + isValid: true, // Por ahora permitimos que sean opcionales + }; + } + + // Verificar si alguna cuenta está en la lista de bloqueadas + if (transaction.accountExternalIdDebit && + this.blockedAccounts.includes(transaction.accountExternalIdDebit)) { + return { + isValid: false, + reason: 'La cuenta de origen está bloqueada para transacciones' + }; + } + + if (transaction.accountExternalIdCredit && + this.blockedAccounts.includes(transaction.accountExternalIdCredit)) { + return { + isValid: false, + reason: 'La cuenta de destino está bloqueada para transacciones' + }; + } + + // Verificar que las cuentas de origen y destino no sean iguales + if (transaction.accountExternalIdDebit === transaction.accountExternalIdCredit) { + return { + isValid: false, + reason: 'Las cuentas de origen y destino no pueden ser iguales' + }; + } + + return { isValid: true }; + } +} \ No newline at end of file diff --git a/ms-antifraude/src/validator/rules/rules/amount.rule.ts b/ms-antifraude/src/validator/rules/rules/amount.rule.ts new file mode 100644 index 0000000000..7bfed24d79 --- /dev/null +++ b/ms-antifraude/src/validator/rules/rules/amount.rule.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ValidatorDto } from '../../dto/validator.dto'; +import { Rule } from '../rule.interface'; +import { ValidationResult } from '../rule-engine.service'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class AmountRule implements Rule { + name = 'AmountRule'; + private readonly logger = new Logger(AmountRule.name); + + // Valores límite para transacciones + private readonly maxAmount: number; + + constructor(private configService: ConfigService) { + // Obtener el límite de configuración o usar valor por defecto + this.maxAmount = this.configService.get('TRANSACTION_MAX_AMOUNT', 1000); + this.logger.log(`Regla de monto inicializada con límite: ${this.maxAmount}`); + } + + async evaluate(transaction: ValidatorDto): Promise { + this.logger.log(`Evaluando regla de monto para transacción: ${transaction.transactionExternalId}`); + + if (!transaction.value || transaction.value <= 0) { + return { + isValid: false, + reason: 'El monto de la transacción debe ser positivo' + }; + } + + if (transaction.value > this.maxAmount) { + return { + isValid: false, + reason: `El monto de la transacción (${transaction.value}) excede el límite permitido (${this.maxAmount})` + }; + } + + return { isValid: true }; + } +} \ No newline at end of file diff --git a/ms-antifraude/src/validator/rules/rules/transfer-type.rule.ts b/ms-antifraude/src/validator/rules/rules/transfer-type.rule.ts new file mode 100644 index 0000000000..af123baa69 --- /dev/null +++ b/ms-antifraude/src/validator/rules/rules/transfer-type.rule.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ValidatorDto } from '../../dto/validator.dto'; +import { Rule } from '../rule.interface'; +import { ValidationResult } from '../rule-engine.service'; + +@Injectable() +export class TransferTypeRule implements Rule { + name = 'TransferTypeRule'; + private readonly logger = new Logger(TransferTypeRule.name); + + // Tipos de transferencia permitidos (simulado) + private readonly allowedTransferTypes: number[] = [1, 2, 3]; + + // Límites por tipo de transferencia + private readonly transferTypeLimits: Record = { + 1: 500, // Transferencia regular + 2: 1000, // Transferencia premium + 3: 5000 // Transferencia corporativa + }; + + async evaluate(transaction: ValidatorDto): Promise { + this.logger.log(`Evaluando regla de tipo de transferencia para transacción: ${transaction.transactionExternalId}`); + + // Si no viene el tipo de transferencia, lo consideramos válido (para compatibilidad) + if (transaction.tranferTypeId === undefined) { + return { isValid: true }; + } + + // Verificar si el tipo de transferencia es permitido + if (!this.allowedTransferTypes.includes(transaction.tranferTypeId)) { + return { + isValid: false, + reason: `El tipo de transferencia ${transaction.tranferTypeId} no es válido` + }; + } + + // Verificar el límite según el tipo de transferencia + const limit = this.transferTypeLimits[transaction.tranferTypeId]; + if (transaction.value > limit) { + return { + isValid: false, + reason: `El monto ${transaction.value} excede el límite de ${limit} para el tipo de transferencia ${transaction.tranferTypeId}` + }; + } + + return { isValid: true }; + } +} \ No newline at end of file diff --git a/ms-antifraude/src/validator/validator.controller.ts b/ms-antifraude/src/validator/validator.controller.ts new file mode 100644 index 0000000000..bca5532fd0 --- /dev/null +++ b/ms-antifraude/src/validator/validator.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Logger } from '@nestjs/common'; +import { EventPattern, Payload } from '@nestjs/microservices'; +import { ValidatorService } from './validator.service'; +import { ValidatorDto } from './dto/validator.dto'; + +@Controller() +export class ValidatorController { + private readonly logger = new Logger(ValidatorController.name); + + constructor(private readonly validatorService: ValidatorService) {} + + @EventPattern('transaction-created') + async handleTransactionCreated(@Payload() message: string) { + try { + this.logger.log(`Recibido evento transaction-created: ${message}`); + + // Parsear el mensaje y validar datos + const payload = JSON.parse(message) as ValidatorDto; + + if (!payload.transactionExternalId || !payload.value) { + this.logger.error(`Datos incompletos en el mensaje: ${message}`); + return; + } + + // Procesar la validación + await this.validatorService.validateTransaction(payload); + + } catch (error) { + this.logger.error(`Error procesando transaction-created: ${error.message}`, error.stack); + } + } +} \ No newline at end of file diff --git a/ms-antifraude/src/validator/validator.module.ts b/ms-antifraude/src/validator/validator.module.ts new file mode 100644 index 0000000000..7236c07fc2 --- /dev/null +++ b/ms-antifraude/src/validator/validator.module.ts @@ -0,0 +1,44 @@ +import { Module } from '@nestjs/common'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ValidatorService } from './validator.service'; +import { ValidatorController } from './validator.controller'; +import { RuleEngine } from './rules/rule.engine.service'; +import { AmountRule } from './rules/rules/amount.rule'; +import { AccountRule } from './rules/rules/account.rule'; +import { TransferTypeRule } from './rules/rules/transfer-type.rule' + +@Module({ + imports: [ + ConfigModule.forRoot(), + ClientsModule.registerAsync([ + { + name: 'TRANSACTION_SERVICE', + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + transport: Transport.KAFKA, + options: { + client: { + clientId: 'antifraud-producer', + brokers: configService.get('KAFKA_BROKERS', 'localhost:9092').split(','), + }, + consumer: { + groupId: 'antifraud-consumer-group', + }, + }, + }), + inject: [ConfigService], + }, + ]), + ], + controllers: [ValidatorController], + providers: [ + ValidatorService, + RuleEngine, + AmountRule, + AccountRule, + TransferTypeRule + ], + exports: [ValidatorService], +}) +export class ValidatorModule {} \ No newline at end of file diff --git a/ms-antifraude/src/validator/validator.service.ts b/ms-antifraude/src/validator/validator.service.ts new file mode 100644 index 0000000000..b5e9332988 --- /dev/null +++ b/ms-antifraude/src/validator/validator.service.ts @@ -0,0 +1,31 @@ +import { + Inject, + Injectable, + Logger, + OnModuleInit +} from '@nestjs/common'; +import { ValidatorDto } from './dto/validator.dto'; +import { ClientKafka } from '@nestjs/microservices'; + +@Injectable() +export class ValidatorService implements OnModuleInit { + private readonly logger = new Logger(ValidatorService.name); + + constructor( + @Inject('TRANSACTION_SERVICE') private readonly kafkaClient: ClientKafka, + ) {} + + async onModuleInit() { + await this.kafkaClient.connect(); + } + + validateTransaction({ transactionExternalId, value }: ValidatorDto) { + const status = value > 1000 ? 'REJECTED' : 'APPROVED'; + this.kafkaClient.emit('transaction-validated', { + transactionExternalId, + status, + }); + + this.logger.log(`Transacción ${transactionExternalId} actualizada al estado ${status}`); + } +} diff --git a/ms-antifraude/test/app.e2e-spec.ts b/ms-antifraude/test/app.e2e-spec.ts new file mode 100644 index 0000000000..0f538fc63e --- /dev/null +++ b/ms-antifraude/test/app.e2e-spec.ts @@ -0,0 +1,85 @@ +mport { Test, TestingModule } from '@nestjs/testing'; +import { ValidatorService } from '../src/validator/validator.service'; +import { RuleEngine } from '../src/validator/rules/rule.engine.service'; +import { AmountRule } from '../src/validator/rules/rules/amount.rule'; +import { AccountRule } from '../src/validator/rules/rules/account.rule'; +import { TransferTypeRule } from '../src/validator/rules/rules/transfer-type.rule'; +import { ClientKafka } from '@nestjs/microservices'; +import { ConfigService } from '@nestjs/config'; + +describe('ValidatorService (integration)', () => { + let validatorService: ValidatorService; + let ruleEngine: RuleEngine; + let kafkaClientMock: ClientKafka; + + beforeEach(async () => { + // Crear mock para el cliente Kafka + kafkaClientMock = { + emit: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + } as unknown as ClientKafka; + + // Crear mock para ConfigService + const configServiceMock = { + get: jest.fn((key, defaultValue) => { + if (key === 'TRANSACTION_MAX_AMOUNT') return 1000; + return defaultValue; + }), + } as unknown as ConfigService; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ValidatorService, + RuleEngine, + AmountRule, + AccountRule, + TransferTypeRule, + { + provide: 'TRANSACTION_SERVICE', + useValue: kafkaClientMock, + }, + { + provide: ConfigService, + useValue: configServiceMock, + }, + ], + }).compile(); + + validatorService = module.get(ValidatorService); + ruleEngine = module.get(RuleEngine); + }); + + it('should approve transactions with amounts less than 1000', async () => { + // Arrange + const transaction = { + transactionExternalId: '123e4567-e89b-12d3-a456-426614174000', + value: 500, + }; + + // Act + await validatorService.validateTransaction(transaction); + + // Assert + expect(kafkaClientMock.emit).toHaveBeenCalledWith( + 'transaction-validated', + expect.stringContaining('"status":"APPROVED"') + ); + }); + + it('should reject transactions with amounts greater than 1000', async () => { + // Arrange + const transaction = { + transactionExternalId: '123e4567-e89b-12d3-a456-426614174000', + value: 1500, + }; + + // Act + await validatorService.validateTransaction(transaction); + + // Assert + expect(kafkaClientMock.emit).toHaveBeenCalledWith( + 'transaction-validated', + expect.stringContaining('"status":"REJECTED"') + ); + }); +}); \ No newline at end of file diff --git a/ms-antifraude/test/jest-e2e.json b/ms-antifraude/test/jest-e2e.json new file mode 100644 index 0000000000..e9d912f3e3 --- /dev/null +++ b/ms-antifraude/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/ms-antifraude/tsconfig.build.json b/ms-antifraude/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/ms-antifraude/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/ms-antifraude/tsconfig.json b/ms-antifraude/tsconfig.json new file mode 100644 index 0000000000..2169963924 --- /dev/null +++ b/ms-antifraude/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": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/ms-transaction/.prettierrc b/ms-transaction/.prettierrc new file mode 100644 index 0000000000..dcb72794f5 --- /dev/null +++ b/ms-transaction/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/ms-transaction/eslint.config.mjs b/ms-transaction/eslint.config.mjs new file mode 100644 index 0000000000..3922e3e6bf --- /dev/null +++ b/ms-transaction/eslint.config.mjs @@ -0,0 +1,36 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + ecmaVersion: 5, + sourceType: 'module', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + 'prettier/prettier': 'off' + }, + }, +); \ No newline at end of file diff --git a/ms-transaction/nest-cli.json b/ms-transaction/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/ms-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/ms-transaction/package.json b/ms-transaction/package.json new file mode 100644 index 0000000000..2a999bfdd2 --- /dev/null +++ b/ms-transaction/package.json @@ -0,0 +1,91 @@ +{ + "name": "app-nodejs-codechallenge", + "version": "0.0.1", + "description": "Microservicio de transacciones financieras", + "author": "sinester09", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "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/apollo": "^13.1.0", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^11.0.1", + "@nestjs/graphql": "^13.1.0", + "@nestjs/microservices": "^11.0.21", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/typeorm": "^11.0.0", + "apollo-server-express": "^3.13.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "graphql": "^16.10.0", + "joi": "^17.12.2", + "kafkajs": "^2.2.4", + "nestjs-pino": "^4.0.0", + "pg": "^8.15.1", + "pino-http": "^9.0.0", + "pino-pretty": "^11.0.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^0.3.22", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@swc/cli": "^0.6.0", + "@swc/core": "^1.10.7", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "@types/uuid": "^9.0.8", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^15.14.0", + "jest": "^29.7.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} \ No newline at end of file diff --git a/ms-transaction/readme.md b/ms-transaction/readme.md new file mode 100644 index 0000000000..ee763034c6 --- /dev/null +++ b/ms-transaction/readme.md @@ -0,0 +1,39 @@ +1. Enum de estados de transacción +He creado un enum TransactionStatus para tener estados bien definidos y evitar errores tipográficos. +2. Entidad de transacción mejorada + +Ahora incluye métodos de dominio como approve() y reject() que encapsulan la lógica de negocio +Se mantienen las anotaciones de GraphQL junto con TypeORM para compatibilidad con tu estructura +Se agregan métodos auxiliares para verificar estados (isPending(), isApproved(), etc.) + +3. Repositorio de transacción + +Proporciona métodos específicos para las operaciones comunes como findByExternalId y save +Encapsula la lógica de acceso a datos + +4. Servicio de transacción mejorado + +Incluye validaciones para los datos de entrada +Manejo mejorado de la comunicación con Kafka +Logging estructurado +Implementación de métodos de dominio + +5. Controlador de eventos + +Manejo robusto de eventos con validación y tratamiento de errores +Logging completo + +6. Módulo de transacción + +Configuración apropiada para TypeORM y Kafka +Exportación del servicio para uso en otros módulos + +7. Resolver GraphQL + +Más limpio y con validación mediante pipes +Maneja directamente los objetos del dominio + +8. DTOs mejorados + +Validación usando class-validator +Tipado fuerte con GraphQL \ No newline at end of file diff --git a/ms-transaction/src/app.module.ts b/ms-transaction/src/app.module.ts new file mode 100644 index 0000000000..de9466579c --- /dev/null +++ b/ms-transaction/src/app.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TransactionModule } from './transaction/transaction.module'; +import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { join } from 'path'; + +@Module({ + imports: [ + GraphQLModule.forRoot({ + driver: ApolloDriver, + autoSchemaFile: join(process.cwd(), 'src/schema.gql'), + playground: true, + }), + TypeOrmModule.forRoot({ + type: 'postgres', + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'transaction_db', + entities: [__dirname + '/**/*.entity{.ts,.js}'], + synchronize: true, + }), + TransactionModule, + ], +}) +export class AppModule {} diff --git a/ms-transaction/src/main.ts b/ms-transaction/src/main.ts new file mode 100644 index 0000000000..27a9372ea2 --- /dev/null +++ b/ms-transaction/src/main.ts @@ -0,0 +1,24 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module.js'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.connectMicroservice({ + transport: Transport.KAFKA, + options: { + client: { + clientId: 'transaction-producer', + brokers: ['localhost:9092'], + }, + consumer: { + groupId: 'transaction-consumer-group', + }, + }, + }); + + await app.startAllMicroservices(); + await app.listen(process.env.PORT ?? 3000); +} + +bootstrap(); \ No newline at end of file diff --git a/ms-transaction/src/schema.gql b/ms-transaction/src/schema.gql new file mode 100644 index 0000000000..712ab6ae99 --- /dev/null +++ b/ms-transaction/src/schema.gql @@ -0,0 +1,33 @@ +# ------------------------------------------------------ +# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) +# ------------------------------------------------------ + +type Transaction { + transactionExternalId: String! + accountExternalIdDebit: String! + accountExternalIdCredit: String! + value: Float! + tranferTypeId: Int! + transactionStatus: String! + createdAt: DateTime! +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + +type Query { + transaction(id: String!): Transaction! +} + +type Mutation { + createTransaction(createTransactionInput: CreateTransactionInput!): Transaction! +} + +input CreateTransactionInput { + accountExternalIdDebit: String! + accountExternalIdCredit: String! + tranferTypeId: Int! + value: Float! +} \ No newline at end of file diff --git a/ms-transaction/src/transaction/dto/create-transaction.input.ts b/ms-transaction/src/transaction/dto/create-transaction.input.ts new file mode 100644 index 0000000000..519810cd0f --- /dev/null +++ b/ms-transaction/src/transaction/dto/create-transaction.input.ts @@ -0,0 +1,27 @@ +import { Field, Float, InputType, Int } from '@nestjs/graphql'; +import { IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator'; + +@InputType() +export class CreateTransactionInput { + @Field() + @IsNotEmpty() + @IsUUID(4, { message: 'ID de cuenta débito debe ser un UUID válido' }) + accountExternalIdDebit: string; + + @Field() + @IsNotEmpty() + @IsUUID(4, { message: 'ID de cuenta crédito debe ser un UUID válido' }) + accountExternalIdCredit: string; + + @Field(() => Int) + @IsNotEmpty() + @IsNumber() + @IsPositive({ message: 'El tipo de transferencia debe ser un número positivo' }) + tranferTypeId: number; + + @Field(() => Float) + @IsNotEmpty() + @IsNumber() + @IsPositive({ message: 'El valor debe ser un número positivo' }) + value: number; +} \ No newline at end of file diff --git a/ms-transaction/src/transaction/dto/update-transaction-event.ts b/ms-transaction/src/transaction/dto/update-transaction-event.ts new file mode 100644 index 0000000000..4ed5d02a92 --- /dev/null +++ b/ms-transaction/src/transaction/dto/update-transaction-event.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsNotEmpty, IsUUID } from 'class-validator'; +import { TransactionStatus } from '../transaction-status.enum'; + +export class UpdateTransactionEvent { + @IsNotEmpty() + @IsUUID(4, { message: 'ID de transacción debe ser un UUID válido' }) + transactionExternalId: string; + + @IsNotEmpty() + @IsEnum(TransactionStatus, { message: 'Estado de transacción inválido' }) + status: string; +} \ No newline at end of file diff --git a/ms-transaction/src/transaction/entities/transaction.entity.ts b/ms-transaction/src/transaction/entities/transaction.entity.ts new file mode 100644 index 0000000000..4ca1e6a3bb --- /dev/null +++ b/ms-transaction/src/transaction/entities/transaction.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; +import { Field, Float, Int, ObjectType } from '@nestjs/graphql'; +import { TransactionStatus } from '../transaction-status.enum'; + +@ObjectType() +@Entity('transactions') +export class Transaction { + @Field() + @PrimaryGeneratedColumn('uuid') + transactionExternalId: string; + + @Field() + @Column() + accountExternalIdDebit: string; + + @Field() + @Column() + accountExternalIdCredit: string; + + @Field(() => Float) + @Column('decimal', { precision: 10, scale: 2 }) + value: number; + + @Field(() => Int) + @Column() + tranferTypeId: number; + + @Field() + @Column({ + default: TransactionStatus.PENDING + }) + transactionStatus: string; + + @Field() + @CreateDateColumn() + createdAt: Date; + + // Métodos de dominio + public approve(): void { + if (this.transactionStatus !== TransactionStatus.PENDING) { + throw new Error('Solo se pueden aprobar transacciones pendientes'); + } + this.transactionStatus = TransactionStatus.APPROVED; + } + + public reject(reason?: string): void { + if (this.transactionStatus !== TransactionStatus.PENDING) { + throw new Error('Solo se pueden rechazar transacciones pendientes'); + } + this.transactionStatus = TransactionStatus.REJECTED; + } + + public isPending(): boolean { + return this.transactionStatus === TransactionStatus.PENDING; + } + + public isApproved(): boolean { + return this.transactionStatus === TransactionStatus.APPROVED; + } + + public isRejected(): boolean { + return this.transactionStatus === TransactionStatus.REJECTED; + } +} \ No newline at end of file diff --git a/ms-transaction/src/transaction/interfaces/graphql/models/transaction.model.ts b/ms-transaction/src/transaction/interfaces/graphql/models/transaction.model.ts new file mode 100644 index 0000000000..849550d1ef --- /dev/null +++ b/ms-transaction/src/transaction/interfaces/graphql/models/transaction.model.ts @@ -0,0 +1,25 @@ +import { Field, Float, Int, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class TransactionModel { + @Field() + transactionExternalId: string; + + @Field() + accountExternalIdDebit: string; + + @Field() + accountExternalIdCredit: string; + + @Field(() => Float) + value: number; + + @Field(() => Int) + tranferTypeId: number; + + @Field() + transactionStatus: string; + + @Field() + createdAt: Date; +} \ No newline at end of file diff --git a/ms-transaction/src/transaction/repositories/transaction.repository.ts b/ms-transaction/src/transaction/repositories/transaction.repository.ts new file mode 100644 index 0000000000..a739722915 --- /dev/null +++ b/ms-transaction/src/transaction/repositories/transaction.repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Transaction } from '../entities/transaction.entity'; + +@Injectable() +export class TransactionRepository { + constructor( + @InjectRepository(Transaction) + private readonly repository: Repository, + ) {} + + async create(transactionData: Partial): Promise { + const transaction = this.repository.create(transactionData); + return this.repository.save(transaction); + } + + async findByExternalId(id: string): Promise { + return this.repository.findOne({ + where: { transactionExternalId: id }, + }); + } + + async save(transaction: Transaction): Promise { + return this.repository.save(transaction); + } +} \ No newline at end of file diff --git a/ms-transaction/src/transaction/transaction-status.enum.ts b/ms-transaction/src/transaction/transaction-status.enum.ts new file mode 100644 index 0000000000..8b3d965033 --- /dev/null +++ b/ms-transaction/src/transaction/transaction-status.enum.ts @@ -0,0 +1,7 @@ +export enum TransactionStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + PROCESSING = 'PROCESSING', + FAILED = 'FAILED' + } \ No newline at end of file diff --git a/ms-transaction/src/transaction/transaction.controller.ts b/ms-transaction/src/transaction/transaction.controller.ts new file mode 100644 index 0000000000..6c752f08ce --- /dev/null +++ b/ms-transaction/src/transaction/transaction.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Logger } from '@nestjs/common'; +import { EventPattern, Payload } from '@nestjs/microservices'; +import { TransactionService } from './transaction.service'; +import { TransactionStatus } from './transaction-status.enum'; + +interface UpdateTransactionEvent { + transactionExternalId: string; + status: string; +} + +@Controller('transaction') +export class TransactionController { + private readonly logger = new Logger(TransactionController.name); + + constructor(private readonly transactionService: TransactionService) {} + + @EventPattern('transaction-validated') + async handleTransactionValidated(@Payload() message: string) { + try { + const payload = JSON.parse(message) as UpdateTransactionEvent; + this.logger.log(`Recibido evento transaction-validated: ${JSON.stringify(payload)}`); + + const { transactionExternalId, status } = payload; + + // Validar que el estado es válido + if (!Object.values(TransactionStatus).includes(status as TransactionStatus)) { + this.logger.error(`Estado de transacción inválido: ${status}`); + return; + } + + await this.transactionService.updateStatus(transactionExternalId, status); + this.logger.log(`Transacción ${transactionExternalId} actualizada a ${status}`); + } catch (error) { + this.logger.error(`Error procesando evento transaction-validated: ${error.message}`, error.stack); + } + } +} \ No newline at end of file diff --git a/ms-transaction/src/transaction/transaction.module.ts b/ms-transaction/src/transaction/transaction.module.ts new file mode 100644 index 0000000000..8f5500d2ae --- /dev/null +++ b/ms-transaction/src/transaction/transaction.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { TransactionService } from './transaction.service'; +import { TransactionController } from './transaction.controller'; +import { TransactionResolver } from './transaction.resolver'; +import { Transaction } from './entities/transaction.entity'; +import { TransactionRepository } from './repositories/transaction.repository'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Transaction]), + ClientsModule.register([ + { + name: 'ANTIFRAUD_RULES_SERVICE', + transport: Transport.KAFKA, + options: { + client: { + clientId: 'transaction-service', + brokers: process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'], + }, + consumer: { + groupId: 'transaction-consumer', + }, + }, + }, + ]), + ], + providers: [ + TransactionService, + TransactionResolver, + TransactionRepository, + ], + controllers: [TransactionController], + exports: [TransactionService], +}) +export class TransactionModule {} diff --git a/ms-transaction/src/transaction/transaction.resolver.ts b/ms-transaction/src/transaction/transaction.resolver.ts new file mode 100644 index 0000000000..2509aec103 --- /dev/null +++ b/ms-transaction/src/transaction/transaction.resolver.ts @@ -0,0 +1,23 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { UsePipes, ValidationPipe } from '@nestjs/common'; +import { TransactionService } from './transaction.service'; +import { Transaction } from './entities/transaction.entity'; +import { CreateTransactionInput } from './dto/create-transaction.input'; + +@Resolver(() => Transaction) +export class TransactionResolver { + constructor(private readonly transactionService: TransactionService) {} + + @Mutation(() => Transaction) + @UsePipes(new ValidationPipe({ transform: true })) + async createTransaction( + @Args('createTransactionInput') createTransactionInput: CreateTransactionInput, + ): Promise { + return this.transactionService.create(createTransactionInput); + } + + @Query(() => Transaction, { name: 'transaction' }) + async findOne(@Args('id') id: string): Promise { + return this.transactionService.findOne(id); + } +} \ No newline at end of file diff --git a/ms-transaction/src/transaction/transaction.service.ts b/ms-transaction/src/transaction/transaction.service.ts new file mode 100644 index 0000000000..e1a5445a51 --- /dev/null +++ b/ms-transaction/src/transaction/transaction.service.ts @@ -0,0 +1,84 @@ +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { ClientKafka } from '@nestjs/microservices'; +import { Transaction } from './entities/transaction.entity'; +import { TransactionRepository } from './repositories/transaction.repository'; +import { CreateTransactionInput } from './dto/create-transaction.input'; +import { TransactionStatus } from './transaction-status.enum'; + +@Injectable() +export class TransactionService { + private readonly logger = new Logger(TransactionService.name); + + constructor( + private readonly transactionRepository: TransactionRepository, + @Inject('ANTIFRAUD_RULES_SERVICE') private readonly kafkaClient: ClientKafka, + ) {} + + async create(createTransactionInput: CreateTransactionInput): Promise { + this.logger.log(`Creando nueva transacción: ${JSON.stringify(createTransactionInput)}`); + + // Validar datos de entrada + this.validateTransactionInput(createTransactionInput); + + // Crear transacción en base de datos + const transaction = await this.transactionRepository.create({ + ...createTransactionInput, + transactionStatus: TransactionStatus.PENDING, + }); + + // Emitir evento para validación antifraude + if (transaction) { + this.kafkaClient.emit('transaction-created', JSON.stringify({ + transactionExternalId: transaction.transactionExternalId, + value: transaction.value, + accountExternalIdDebit: transaction.accountExternalIdDebit, + accountExternalIdCredit: transaction.accountExternalIdCredit, + tranferTypeId: transaction.tranferTypeId, + })); + } + + return transaction; + } + + async findOne(id: string): Promise { + const transaction = await this.transactionRepository.findByExternalId(id); + + if (!transaction) { + this.logger.warn(`Transacción no encontrada: ${id}`); + throw new NotFoundException(`Transacción '${id}' no encontrada`); + } + + return transaction; + } + + async updateStatus(id: string, status: string): Promise { + const transaction = await this.findOne(id); + + // Verificar si el estado es válido + if (!Object.values(TransactionStatus).includes(status as TransactionStatus)) { + throw new Error(`Estado de transacción inválido: ${status}`); + } + + // Actualizar el estado usando métodos de dominio + if (status === TransactionStatus.APPROVED) { + transaction.approve(); + } else if (status === TransactionStatus.REJECTED) { + transaction.reject(); + } else { + transaction.transactionStatus = status; + } + + this.logger.log(`Actualizando estado de transacción ${id} a ${status}`); + return this.transactionRepository.save(transaction); + } + + private validateTransactionInput(dto: CreateTransactionInput): void { + if (dto.value <= 0) { + throw new Error('El valor de la transacción debe ser mayor a cero'); + } + + if (dto.accountExternalIdDebit === dto.accountExternalIdCredit) { + throw new Error('Las cuentas de origen y destino no pueden ser iguales'); + } + } +} \ No newline at end of file diff --git a/ms-transaction/tsconfig.build.json b/ms-transaction/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/ms-transaction/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/ms-transaction/tsconfig.json b/ms-transaction/tsconfig.json new file mode 100644 index 0000000000..6f12d47a69 --- /dev/null +++ b/ms-transaction/tsconfig.json @@ -0,0 +1,28 @@ +{ + "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": true, + "strictPropertyInitialization": false, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@app/*": ["src/*"], + "@transaction/*": ["src/transaction/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} \ No newline at end of file