diff --git a/src/modules/gateway/api/output.ts b/src/modules/gateway/api/output.ts new file mode 100644 index 0000000..48de4cd --- /dev/null +++ b/src/modules/gateway/api/output.ts @@ -0,0 +1,16 @@ +import { TransactionStatusEnum } from '../../../shared/validations/transaction/status'; + +export interface TransactionGatewayOutput { + transactionId: number; + transactionStatus: TransactionStatusEnum; +} + +export interface GatewayOutput { + status: number; + statusText: string; + data: TransactionGatewayOutput; +} + +export interface GatewayResult { + result: GatewayOutput; +} diff --git a/src/modules/gateway/app/index.ts b/src/modules/gateway/app/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/gateway/domain/gateway.service.ts b/src/modules/gateway/domain/gateway.service.ts new file mode 100644 index 0000000..5332de0 --- /dev/null +++ b/src/modules/gateway/domain/gateway.service.ts @@ -0,0 +1,73 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AxiosResponse } from 'axios'; +import { firstValueFrom } from 'rxjs'; +import { jsonStringifyReplacer } from 'src/shared/utils/json.utils'; +import { WebhookPayload } from '../../../modules/transaction/app/input'; +import { EnvVariables } from '../../../shared/config/envEnums'; +import { AppLoggerService } from '../../../shared/logger/app-logger.service'; +import { GatewayOutput, GatewayResult } from '../api/output'; + +@Injectable() +export class GatewayService { + private readonly gatewayUrl: string; + private readonly webhook: string; + private get logPrefix(): string { + return `[${this.constructor.name}]`; + } + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly logger: AppLoggerService, + ) { + this.logger.debug(this.logPrefix, 'GatewayService constructor'); + this.gatewayUrl = this.setGatewayUrl(); + this.webhook = this.setWebhook(); + } + + public async getStatus(payload: WebhookPayload): Promise { + const response: AxiosResponse = await firstValueFrom( + this.httpService.post(this.gatewayUrl, payload, { + headers: { + webhook: this.webhook, + }, + }), + ); + this.logger.debug( + this.logPrefix, + `Received response - Status: ${response.status}, Data: ${JSON.stringify(response.data, jsonStringifyReplacer)}`, + ); + return { + status: response.status, + statusText: response.statusText, + data: { + transactionId: response.data.result.data.transactionId, + transactionStatus: response.data.result.data.transactionStatus, + }, + }; + } + + private setGatewayUrl(): string { + const gatewayUrl = this.configService.get(EnvVariables.GATEWAY_URL); + if (!gatewayUrl) { + throw new Error( + `${EnvVariables.GATEWAY_URL} not visible in .env file. Please update and restart the app`, + ); + } + return gatewayUrl; + } + + private setWebhook(): string { + const webhookUrl = this.configService.get( + EnvVariables.INTERNAL_WEBHOOk_URL, + ); + if (!webhookUrl) { + throw new Error( + `${EnvVariables.INTERNAL_WEBHOOk_URL} not visible in .env file. Please update and restart the app`, + ); + } + this.logger.debug(this.logPrefix, `Using webhook URL: ${webhookUrl}`); + return webhookUrl; + } +} diff --git a/src/modules/gateway/domain/index.ts b/src/modules/gateway/domain/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/gateway/gateway.module.ts b/src/modules/gateway/gateway.module.ts new file mode 100644 index 0000000..e3a0ba3 --- /dev/null +++ b/src/modules/gateway/gateway.module.ts @@ -0,0 +1,11 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '../../shared/logger/logger.module'; +import { GatewayService } from './domain/gateway.service'; + +@Module({ + imports: [HttpModule, LoggerModule], + providers: [GatewayService], + exports: [GatewayService], +}) +export class GatewayModule {} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts index cea29eb..c999e59 100644 --- a/src/modules/health/health.module.ts +++ b/src/modules/health/health.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { AppLoggerService } from 'src/shared/logger/app-logger.service'; +import { LoggerModule } from 'src/shared/logger/logger.module'; import { HealthController } from './interfaces/controllers/health.controller'; @Module({ + imports: [LoggerModule], providers: [AppLoggerService], controllers: [HealthController], }) diff --git a/src/modules/transaction/api/transaction.controller.ts b/src/modules/transaction/api/transaction.controller.ts index d842aa5..2f147cd 100644 --- a/src/modules/transaction/api/transaction.controller.ts +++ b/src/modules/transaction/api/transaction.controller.ts @@ -12,6 +12,8 @@ import { Post, ValidationPipe, } from '@nestjs/common'; +import { GatewayOutput } from 'src/modules/gateway/api/output'; +import { TransactionWebhookDto } from 'src/shared/dto/transaction-webhook-payload.dto'; import { FundsRepresentation } from '../../../modules/wallet/api/representation'; import { CreateTransactionDto } from '../../../shared/dto/create-transaction.dto'; import { AppLoggerService } from '../../../shared/logger/app-logger.service'; @@ -21,12 +23,10 @@ import { CancelTransactionUseCase } from '../app/cancel-transaction-use-case/can import { CompleteTransactionUseCase } from '../app/complete-transaction-use-case/complete-transaction.use-case'; import { CreateTransactionUseCase } from '../app/create-transaction-use-case/create-transaction.use-case'; import { CreateTransactionInput } from '../app/input'; +import { UpdateTransactionFromWebhookUseCase } from '../app/update-transaction-from-webhook-use-case/update-transaction-from-webhook.use-case'; import { TransactionService } from '../domain/services/transaction.service'; import { Transaction } from '../domain/transaction.entity'; -import { - OutputRepresentation, - TransactionRepresentation, -} from './representation'; +import { TransactionRepresentation } from './representation'; import { TransactionRepresentationMapper } from './representationMapper'; @Controller(ApiRoutes.TRANSACTION) @@ -41,6 +41,7 @@ export class TransactionController { private readonly completeTransactionUseCase: CompleteTransactionUseCase, private readonly transactionRepresentationMapper: TransactionRepresentationMapper, private readonly cancelTransactionUseCase: CancelTransactionUseCase, + private readonly updateTransactionFromWebhookUseCase: UpdateTransactionFromWebhookUseCase, ) {} @Post() @@ -101,19 +102,17 @@ export class TransactionController { }; } - @Patch(TransactionRoutes.COMPLETE) + @Post(TransactionRoutes.COMPLETE) @HttpCode(HttpStatus.OK) async completeTransactions( @Param('walletId', ParseIntPipe) walletId: number, - ): Promise<{ elements: number; data: OutputRepresentation[] }> { + ): Promise<{ elements: number; data: GatewayOutput[] }> { this.logger.log( this.logPrefix, `Completing all transactions for wallet: ${walletId}`, ); const results = await this.completeTransactionUseCase.run(walletId); - const representation = - this.transactionRepresentationMapper.getOutput(results); - return { elements: representation.length, data: representation }; + return { elements: results.length, data: results }; } @Patch(TransactionRoutes.CANCEL) @@ -124,4 +123,42 @@ export class TransactionController { const result = await this.cancelTransactionUseCase.run(transactionId); return result; } + + @Post(TransactionRoutes.WEBHOOK) + @HttpCode(HttpStatus.OK) + async webhook( + @Body( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ) + input: TransactionWebhookDto, + ): Promise { + try { + this.logger.log( + this.logPrefix, + `Webhook called with input: ${JSON.stringify(input, jsonStringifyReplacer)}`, + ); + const transaction = + await this.updateTransactionFromWebhookUseCase.run(input); + this.logger.log( + this.logPrefix, + `Processes transaction: ${JSON.stringify(transaction, jsonStringifyReplacer)}`, + ); + } catch (error) { + this.logger.error( + this.logPrefix, + `Error processing webhook: ${error instanceof Error ? error.message : String(error)}`, + ); + if (error instanceof Error && error.stack) { + this.logger.error(this.logPrefix, error.stack); + } + throw error; + } + } } diff --git a/src/modules/transaction/app/complete-transaction-use-case/complete-transaction.use-case.ts b/src/modules/transaction/app/complete-transaction-use-case/complete-transaction.use-case.ts index f393c57..1d21be2 100644 --- a/src/modules/transaction/app/complete-transaction-use-case/complete-transaction.use-case.ts +++ b/src/modules/transaction/app/complete-transaction-use-case/complete-transaction.use-case.ts @@ -1,10 +1,12 @@ -import { Injectable } from '@nestjs/common'; -import { WalletService } from 'src/modules/wallet/app/services/app-wallet.service'; -import { TransactionStatusEnum } from 'src/shared/validations/transaction/status'; +import { BadGatewayException, Injectable } from '@nestjs/common'; import { AppLoggerService } from '../../../../shared/logger/app-logger.service'; +import { TransactionStatusEnum } from '../../../../shared/validations/transaction/status'; +import { GatewayOutput } from '../../../gateway/api/output'; +import { GatewayService } from '../../../gateway/domain/gateway.service'; +import { WalletService } from '../../../wallet/app/services/app-wallet.service'; import { TransactionService } from '../../domain/services/transaction.service'; import { Transaction } from '../../domain/transaction.entity'; -import { TransactionOutput } from './output'; +import { WebhookPayload } from '../input'; @Injectable() export class CompleteTransactionUseCase { @@ -15,17 +17,18 @@ export class CompleteTransactionUseCase { private readonly logger: AppLoggerService, private readonly transactionService: TransactionService, private readonly walletService: WalletService, + private readonly gatewayService: GatewayService, ) {} public async run( walletId: number, transactionStatus?: TransactionStatusEnum, - ): Promise { + ): Promise { this.logger.log( this.logPrefix, `Updating ${TransactionStatusEnum.PENDING} transactions for wallet: ${walletId}`, ); - const results: TransactionOutput[] = []; + const results: GatewayOutput[] = []; const transactions: Transaction[] = await this.transactionService.getAllTransactionsByWallet( walletId, @@ -34,36 +37,83 @@ export class CompleteTransactionUseCase { for (const transaction of transactions) { if (transaction.status === TransactionStatusEnum.PENDING) { - const transactionResult = await this.updateTransaction(transaction); + const transactionResult = await this.callGateway(transaction); results.push(transactionResult); } } return results; } - private async updateTransaction( - transaction: Transaction, - ): Promise { + /** + * The function `callGateway` processes a transaction by checking the wallet balance, determining the + * transaction status, and interacting with a gateway service. + * @param {Transaction} transaction - The `transaction` parameter in the `callGateway` function + * represents an object containing information about a financial transaction. It likely includes + * properties such as `id`, `walletId`, `amount`, and other relevant details needed to process the + * transaction. This function is responsible for interacting with a gateway service to process + * @returns The function `callGateway` returns a `Promise` that resolves to a `GatewayOutput` object. + */ + private async callGateway(transaction: Transaction): Promise { const wallet = await this.walletService.getByWalletId(transaction.walletId); const newBalance = wallet.balance + transaction.amount; const status: TransactionStatusEnum = newBalance < 0 ? TransactionStatusEnum.FAILED - : TransactionStatusEnum.COMPLETED; + : TransactionStatusEnum.PENDING; + + if (status === TransactionStatusEnum.FAILED) { + return this.updateFailedTransaction(transaction, newBalance); + } + + const gatewayPayload = this.buildWebhookPayload(transaction, status); + const gatewayResponse = await this.gatewayService.getStatus(gatewayPayload); + if (gatewayResponse.status !== 200) { + const err = `Gateway response status: ${gatewayResponse.status} for transaction: ${transaction.id}`; + this.logger.error(this.logPrefix, err); + throw new BadGatewayException(err); + } + return gatewayResponse; + } + + private buildWebhookPayload( + transaction: Transaction, + status: TransactionStatusEnum, + ): WebhookPayload { + const payload: WebhookPayload = { + id: transaction.id, + status, + amount: Number(transaction.amount), + currency: transaction.currentCurrency, + originCreatedAt: transaction.clientTransactionDate + ? transaction.clientTransactionDate + : new Date(), + }; + return payload; + } + private async updateFailedTransaction( + transaction: Transaction, + newBalance: bigint, + ): Promise { + this.logger.warn( + this.logPrefix, + `Transaction: ${transaction.id} failed to complete. New balance: ${Number(newBalance)} is less than 0.`, + ); const updatedTransaction = await this.transactionService.update({ transactionId: transaction.id, - status, + status: TransactionStatusEnum.FAILED, }); - - if (updatedTransaction.status === TransactionStatusEnum.COMPLETED) { - const funds = await this.walletService.updateBalance( - wallet.tokenId, - transaction.amount, - transaction.currentCurrency, - ); - return { transaction: updatedTransaction, funds }; - } - return { transaction: updatedTransaction, funds: null }; + this.logger.log( + this.logPrefix, + `Transaction: ${updatedTransaction.id} updated to status: ${updatedTransaction.status} successfully.`, + ); + return { + status: 200, + statusText: 'OK', + data: { + transactionId: updatedTransaction.id, + transactionStatus: updatedTransaction.status, + }, + }; } } diff --git a/src/modules/transaction/app/input.ts b/src/modules/transaction/app/input.ts index 13a5ee6..78d90ba 100644 --- a/src/modules/transaction/app/input.ts +++ b/src/modules/transaction/app/input.ts @@ -25,3 +25,11 @@ export interface TransactionInput { clientTransactionDate?: Date; idempotencyKey?: string; } + +export interface WebhookPayload { + id: number; + status: TransactionStatusEnum; + amount: number; + currency: CurrencyEnum; + originCreatedAt: Date; +} diff --git a/src/modules/transaction/app/update-transaction-from-webhook-use-case/update-transaction-from-webhook.use-case.ts b/src/modules/transaction/app/update-transaction-from-webhook-use-case/update-transaction-from-webhook.use-case.ts new file mode 100644 index 0000000..87cab9a --- /dev/null +++ b/src/modules/transaction/app/update-transaction-from-webhook-use-case/update-transaction-from-webhook.use-case.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { WalletService } from '../../../../modules/wallet/app/services/app-wallet.service'; +import { TransactionWebhookDto } from '../../../../shared/dto/transaction-webhook-payload.dto'; +import { AppLoggerService } from '../../../../shared/logger/app-logger.service'; +import { TransactionStatusEnum } from '../../../../shared/validations/transaction/status'; +import { TransactionService } from '../../domain/services/transaction.service'; +import { TransactionOutput } from '../complete-transaction-use-case/output'; +import { UpdateTransactionInput } from '../input'; + +@Injectable() +export class UpdateTransactionFromWebhookUseCase { + private get logPrefix(): string { + return `[${this.constructor.name}] - `; + } + constructor( + private readonly appLogger: AppLoggerService, + private readonly service: TransactionService, + private readonly walletService: WalletService, + ) {} + + /** + * This TypeScript function updates a transaction based on a webhook payload and handles balance + * updates for a wallet. + * @param {TransactionWebhookDto} webhookPayload - The `webhookPayload` parameter in the `run` function + * represents the data received from a webhook. It contains information about a transaction, such as + * the transaction ID and status. + * @returns The `run` function returns a `TransactionOutput` object, which contains two properties: + * 1. `transaction`: This property contains the updated transaction object after processing the + * webhook payload. + * 2. `funds`: This property contains the updated funds in the wallet if the transaction status is + * `COMPLETED`, otherwise it is `null`. + */ + public async run( + webhookPayload: TransactionWebhookDto, + ): Promise { + // Here we only receive the transaction with sufficient balance which can be failed only at Gateway + this.appLogger.log( + this.logPrefix, + `Updating transaction from webhook: ${JSON.stringify(webhookPayload)}`, + ); + const transactionId: number = webhookPayload.id; + const transactionInput = await this.service.getById(transactionId); + + const wallet = await this.walletService.getByWalletId( + transactionInput.walletId, + ); + + const updateTransactionInput: UpdateTransactionInput = { + transactionId: transactionId, + status: webhookPayload.status, + }; + + const transaction = await this.service.update(updateTransactionInput); + + if (transaction.status === TransactionStatusEnum.COMPLETED) { + const funds = await this.walletService.updateBalance( + wallet.tokenId, + transaction.amount, + transaction.currentCurrency, + ); + return { transaction: transaction, funds }; + } + return { transaction: transaction, funds: null }; + } +} diff --git a/src/modules/transaction/transaction.module.ts b/src/modules/transaction/transaction.module.ts index 1b1d67f..7d59d77 100644 --- a/src/modules/transaction/transaction.module.ts +++ b/src/modules/transaction/transaction.module.ts @@ -1,17 +1,19 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '../../shared/logger/logger.module'; +import { GatewayModule } from '../gateway/gateway.module'; import { WalletModule } from '../wallet/wallet.module'; import { TransactionRepresentationMapper } from './api/representationMapper'; import { TransactionController } from './api/transaction.controller'; import { CancelTransactionUseCase } from './app/cancel-transaction-use-case/cancel-transaction.use-case'; import { CompleteTransactionUseCase } from './app/complete-transaction-use-case/complete-transaction.use-case'; import { CreateTransactionUseCase } from './app/create-transaction-use-case/create-transaction.use-case'; +import { UpdateTransactionFromWebhookUseCase } from './app/update-transaction-from-webhook-use-case/update-transaction-from-webhook.use-case'; import { TransactionService } from './domain/services/transaction.service'; import { TRANSACTION_REPOSITORY_TOKEN } from './domain/transaction.repo'; import { TransactionRepoImpl } from './infra/repo/transaction.postgres.repo.impl'; @Module({ - imports: [LoggerModule, WalletModule], + imports: [LoggerModule, WalletModule, GatewayModule], controllers: [TransactionController], providers: [ TransactionService, @@ -23,6 +25,7 @@ import { TransactionRepoImpl } from './infra/repo/transaction.postgres.repo.impl provide: TRANSACTION_REPOSITORY_TOKEN, useClass: TransactionRepoImpl, }, + UpdateTransactionFromWebhookUseCase, ], exports: [ TransactionService, @@ -30,6 +33,7 @@ import { TransactionRepoImpl } from './infra/repo/transaction.postgres.repo.impl CompleteTransactionUseCase, TransactionRepresentationMapper, CancelTransactionUseCase, + UpdateTransactionFromWebhookUseCase, ], }) export class TransactionModule {} diff --git a/src/shared/clients/currencyExchange/services/currency.service.ts b/src/shared/clients/currencyExchange/services/currency.service.ts index bb33f3a..75cb367 100644 --- a/src/shared/clients/currencyExchange/services/currency.service.ts +++ b/src/shared/clients/currencyExchange/services/currency.service.ts @@ -14,6 +14,7 @@ import { EnvVariables } from '../../../config/envEnums'; import { AppLoggerService } from '../../../logger/app-logger.service'; import { CurrencyEnum, validCurrencies } from '../../../validations/currency'; import { CurrencyApiResponse, ExchangeRate } from '../output'; + @Injectable() export class CurrencyClientService { private readonly baseUrl: string; @@ -72,7 +73,6 @@ export class CurrencyClientService { `Fetching exchange rates for base currency: ${baseCurrency}`, ); const response: AxiosResponse = await firstValueFrom( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access this.httpService.get( `${this.baseUrl}${baseCurrency}`, ), diff --git a/src/shared/config/envEnums.ts b/src/shared/config/envEnums.ts index f907d31..afa00b2 100644 --- a/src/shared/config/envEnums.ts +++ b/src/shared/config/envEnums.ts @@ -2,4 +2,6 @@ export enum EnvVariables { TOKEN_KEY = 'TOKEN_SECRET_KEY', TOKEN_ALGORITHM = 'TOKEN_ALGORITHM', API_KEY = 'EXCHANGE_RATE_API_KEY', + GATEWAY_URL = 'GATEWAY_URL', + INTERNAL_WEBHOOk_URL = 'INTERNAL_WEBHOOk_URL', } diff --git a/src/shared/dto/transaction-webhook-payload.dto.ts b/src/shared/dto/transaction-webhook-payload.dto.ts new file mode 100644 index 0000000..f0819e4 --- /dev/null +++ b/src/shared/dto/transaction-webhook-payload.dto.ts @@ -0,0 +1,36 @@ +import { Type } from 'class-transformer'; +import { IsDate, IsEnum, IsNotEmpty, IsNumber } from 'class-validator'; +import { CurrencyEnum } from '../validations/currency'; +import { TransactionStatusEnum } from '../validations/transaction/status'; + +const ALLOWED_CURRENCIES = Object.values(CurrencyEnum).join(', '); +const ALLOWED_STATUSES = Object.values(TransactionStatusEnum).join(', '); + +export class TransactionWebhookDto { + @IsNotEmpty({ message: 'Transaction ID must be provided.' }) + @Type(() => Number) + @IsNumber() + id: number; + + @IsEnum(TransactionStatusEnum, { + message: `Invalid transaction status. Allowed statuses are: ${ALLOWED_STATUSES}`, + }) + @IsNotEmpty({ message: 'TransactionStatus must be provided.' }) + status: TransactionStatusEnum; + + @IsNotEmpty({ message: 'Amount must be provided.' }) + @Type(() => Number) + @IsNumber() + amount: number; + + @IsEnum(CurrencyEnum, { + message: `Invalid currency provided. Allowed currencies are: ${ALLOWED_CURRENCIES}`, + }) + @IsNotEmpty({ message: 'Currency must be provided.' }) + currency: CurrencyEnum; + + @Type(() => Date) + @IsNotEmpty({ message: 'Client transaction date must be provided.' }) + @IsDate() + originCreatedAt: Date; +} diff --git a/src/shared/router/routes.ts b/src/shared/router/routes.ts index a41e12a..fe966d2 100644 --- a/src/shared/router/routes.ts +++ b/src/shared/router/routes.ts @@ -14,4 +14,5 @@ export enum WalletRoutes { export enum TransactionRoutes { COMPLETE = 'complete/:walletId', CANCEL = 'cancel/:transactionId', + WEBHOOK = 'webhook', }