From 8c8d53b1ccde405394cbda37963278b21b980338 Mon Sep 17 00:00:00 2001 From: Lukasz Chmielewski Date: Mon, 1 Sep 2025 12:59:43 +0200 Subject: [PATCH] Added idempotency --- .../migration.sql | 12 +++++++ .../migration.sql | 14 ++++++++ prisma/schema.prisma | 24 +++++++------ .../transaction/api/transaction.controller.ts | 13 ++++++- .../cancel-transaction.use-case.ts | 2 +- .../complete-transaction.use-case.ts | 2 +- .../create-transaction.use-case.ts | 20 ++++++++--- src/modules/transaction/app/input.ts | 4 +++ .../services/transaction.service.ts | 30 ++++++++++++++-- .../transaction/domain/transaction.entity.ts | 6 ++++ .../transaction/domain/transaction.repo.ts | 5 +++ .../repo/transaction.postgres.repo.impl.ts | 35 +++++++++++++++++++ src/modules/transaction/transaction.module.ts | 2 +- src/shared/dto/create-transaction.dto.ts | 18 +++++++++- 14 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20250901084409_add_idempotency_key_to_transaction/migration.sql create mode 100644 prisma/migrations/20250901104619_add_idempotency_index_and_unq_constraint/migration.sql rename src/modules/transaction/{app => domain}/services/transaction.service.ts (80%) diff --git a/prisma/migrations/20250901084409_add_idempotency_key_to_transaction/migration.sql b/prisma/migrations/20250901084409_add_idempotency_key_to_transaction/migration.sql new file mode 100644 index 0000000..7364b26 --- /dev/null +++ b/prisma/migrations/20250901084409_add_idempotency_key_to_transaction/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[idempotencyKey]` on the table `Transaction` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "public"."Transaction" ADD COLUMN "clientTransactionDate" TIMESTAMP(3), +ADD COLUMN "idempotencyKey" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Transaction_idempotencyKey_key" ON "public"."Transaction"("idempotencyKey"); diff --git a/prisma/migrations/20250901104619_add_idempotency_index_and_unq_constraint/migration.sql b/prisma/migrations/20250901104619_add_idempotency_index_and_unq_constraint/migration.sql new file mode 100644 index 0000000..435cc12 --- /dev/null +++ b/prisma/migrations/20250901104619_add_idempotency_index_and_unq_constraint/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[idempotencyKey,clientTransactionDate,amount]` on the table `Transaction` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "public"."Transaction_idempotencyKey_key"; + +-- CreateIndex +CREATE INDEX "idempotency_lookup" ON "public"."Transaction"("idempotencyKey", "clientTransactionDate", "amount"); + +-- CreateIndex +CREATE UNIQUE INDEX "Transaction_idempotencyKey_clientTransactionDate_amount_key" ON "public"."Transaction"("idempotencyKey", "clientTransactionDate", "amount"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 967a63e..856daea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,20 +25,24 @@ model Wallet { } model Transaction { - id Int @id @default(autoincrement()) - walletId Int - wallet Wallet @relation(fields: [walletId], references: [id]) - type TransactionType - status TransactionStatus @default(PENDING) - createdAt DateTime @default(now()) - originCurrency Currency - currentCurrency Currency - amount BigInt - updatedAt DateTime? @updatedAt + id Int @id @default(autoincrement()) + walletId Int + wallet Wallet @relation(fields: [walletId], references: [id]) + type TransactionType + status TransactionStatus @default(PENDING) + createdAt DateTime @default(now()) + originCurrency Currency + currentCurrency Currency + amount BigInt + updatedAt DateTime? @updatedAt + idempotencyKey String? + clientTransactionDate DateTime? + @@unique([idempotencyKey, clientTransactionDate, amount], name: "idempotency_unique_constraint") @@index([walletId]) @@index([createdAt]) @@index([walletId, createdAt]) + @@index([idempotencyKey, clientTransactionDate, amount], name: "idempotency_lookup") } enum Currency { diff --git a/src/modules/transaction/api/transaction.controller.ts b/src/modules/transaction/api/transaction.controller.ts index 023c654..d842aa5 100644 --- a/src/modules/transaction/api/transaction.controller.ts +++ b/src/modules/transaction/api/transaction.controller.ts @@ -1,7 +1,9 @@ import { + BadRequestException, Body, Controller, Get, + Headers, HttpCode, HttpStatus, Param, @@ -19,7 +21,7 @@ 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 { TransactionService } from '../app/services/transaction.service'; +import { TransactionService } from '../domain/services/transaction.service'; import { Transaction } from '../domain/transaction.entity'; import { OutputRepresentation, @@ -45,15 +47,24 @@ export class TransactionController { @HttpCode(HttpStatus.CREATED) async saveTransaction( @Body(new ValidationPipe()) input: CreateTransactionDto, + @Headers('Idempotency-Key') idempotencyKey: string, ): Promise { this.logger.debug( this.logPrefix, `Creating the transaction use case: ${JSON.stringify(input, jsonStringifyReplacer)}`, ); + + if (!idempotencyKey) { + const err = 'Required Idempotency-Key header is missing.'; + this.logger.error(this.logPrefix, err); + throw new BadRequestException(err); + } const transactionInput: CreateTransactionInput = { tokenId: input.tokenId, amount: input.amount, targetCurrency: input.currency, + clientTransactionDate: input.clientTransactionDate, + idempotencyKey, }; const fundsInWallet = await this.createTransactionUseCase.run(transactionInput); diff --git a/src/modules/transaction/app/cancel-transaction-use-case/cancel-transaction.use-case.ts b/src/modules/transaction/app/cancel-transaction-use-case/cancel-transaction.use-case.ts index 6b4d088..c2a316b 100644 --- a/src/modules/transaction/app/cancel-transaction-use-case/cancel-transaction.use-case.ts +++ b/src/modules/transaction/app/cancel-transaction-use-case/cancel-transaction.use-case.ts @@ -3,7 +3,7 @@ import { AppLoggerService } from '../../../../shared/logger/app-logger.service'; import { TransactionStatusEnum } from '../../../../shared/validations/transaction/status'; import { TransactionRepresentation } from '../../api/representation'; import { TransactionRepresentationMapper } from '../../api/representationMapper'; -import { TransactionService } from '../services/transaction.service'; +import { TransactionService } from '../../domain/services/transaction.service'; @Injectable() export class CancelTransactionUseCase { 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 323e5ce..f393c57 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 @@ -2,8 +2,8 @@ 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 { AppLoggerService } from '../../../../shared/logger/app-logger.service'; +import { TransactionService } from '../../domain/services/transaction.service'; import { Transaction } from '../../domain/transaction.entity'; -import { TransactionService } from '../services/transaction.service'; import { TransactionOutput } from './output'; @Injectable() diff --git a/src/modules/transaction/app/create-transaction-use-case/create-transaction.use-case.ts b/src/modules/transaction/app/create-transaction-use-case/create-transaction.use-case.ts index 69ef9dd..6b8f714 100644 --- a/src/modules/transaction/app/create-transaction-use-case/create-transaction.use-case.ts +++ b/src/modules/transaction/app/create-transaction-use-case/create-transaction.use-case.ts @@ -1,4 +1,8 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + UnprocessableEntityException, +} from '@nestjs/common'; import { jsonStringifyReplacer } from 'src/shared/utils/json.utils'; import { TriggerExchange } from '../../../../modules/wallet/app/input'; import { @@ -11,9 +15,9 @@ import { AppLoggerService } from '../../../../shared/logger/app-logger.service'; import { CurrencyEnum } from '../../../../shared/validations/currency'; import { TransactionStatusEnum } from '../../../../shared/validations/transaction/status'; import { TransactionTypeEnum } from '../../../../shared/validations/transaction/type'; +import { TransactionService } from '../../domain/services/transaction.service'; import { Transaction } from '../../domain/transaction.entity'; import { CreateTransactionInput, TransactionInput } from '../input'; -import { TransactionService } from '../services/transaction.service'; @Injectable() export class CreateTransactionUseCase { @@ -31,7 +35,11 @@ export class CreateTransactionUseCase { this.logPrefix, `Attempting to save transaction: ${JSON.stringify(input, jsonStringifyReplacer)}`, ); - + await this.transactionService.checkIdempotency( + input.idempotencyKey, + input.clientTransactionDate, + input.amount, + ); const wallet = await this.walletService.getByTokenId(input.tokenId); if (!wallet) { @@ -92,7 +100,7 @@ export class CreateTransactionUseCase { ) { const err = `Requested to change created transaction for: ${Number(input.amount)} for currency: ${input.targetCurrency} which is the same as ${wallet.currency}. No action needed.`; this.logger.error(this.logPrefix, err); - throw new BadRequestException(err); + throw new UnprocessableEntityException(err); } const type = this.getTransactionType( wallet.currency, @@ -103,7 +111,7 @@ export class CreateTransactionUseCase { if (type === TransactionTypeEnum.EXCHANGE && Number(input.amount) !== 0) { const err = `For ${type} transaction amount must be 0, but received: ${Number(input.amount)}`; this.logger.error(this.logPrefix, err); - throw new BadRequestException(err); + throw new UnprocessableEntityException(err); } const transactionInput: TransactionInput = { walletId: wallet.id, @@ -115,6 +123,8 @@ export class CreateTransactionUseCase { originCurrency: wallet.currency, currentCurrency: input.targetCurrency, amount: input.amount, + clientTransactionDate: new Date(input.clientTransactionDate), + idempotencyKey: input.idempotencyKey, }; return transactionInput; } diff --git a/src/modules/transaction/app/input.ts b/src/modules/transaction/app/input.ts index 71fee75..13a5ee6 100644 --- a/src/modules/transaction/app/input.ts +++ b/src/modules/transaction/app/input.ts @@ -6,6 +6,8 @@ export interface CreateTransactionInput { tokenId: string; targetCurrency: CurrencyEnum; amount: bigint; + clientTransactionDate: Date; + idempotencyKey: string; } export interface UpdateTransactionInput { @@ -20,4 +22,6 @@ export interface TransactionInput { originCurrency: CurrencyEnum; currentCurrency: CurrencyEnum; amount: bigint; + clientTransactionDate?: Date; + idempotencyKey?: string; } diff --git a/src/modules/transaction/app/services/transaction.service.ts b/src/modules/transaction/domain/services/transaction.service.ts similarity index 80% rename from src/modules/transaction/app/services/transaction.service.ts rename to src/modules/transaction/domain/services/transaction.service.ts index a363af8..f9d1957 100644 --- a/src/modules/transaction/app/services/transaction.service.ts +++ b/src/modules/transaction/domain/services/transaction.service.ts @@ -1,11 +1,16 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, +} from '@nestjs/common'; import { AppLoggerService } from '../../../../shared/logger/app-logger.service'; import { jsonStringifyReplacer } from '../../../../shared/utils/json.utils'; import { TransactionStatusEnum } from '../../../../shared/validations/transaction/status'; +import { TransactionInput, UpdateTransactionInput } from '../../app/input'; import { Transaction } from '../../domain/transaction.entity'; import type { TransactionRepository } from '../../domain/transaction.repo'; import { TRANSACTION_REPOSITORY_TOKEN } from '../../domain/transaction.repo'; -import { TransactionInput, UpdateTransactionInput } from '../input'; @Injectable() export class TransactionService { @@ -91,4 +96,25 @@ export class TransactionService { } return transaction; } + + public async checkIdempotency( + idempotencyKey: string, + clientTransactionDate: Date, + amount: bigint, + ): Promise { + const transaction = await this.transactionRepo.getByIdempotencyKey( + idempotencyKey, + clientTransactionDate, + amount, + ); + if (transaction) { + const err = `Transaction found for Idempotency Key: ${idempotencyKey}`; + this.logger.error(this.logPrefix, err); + throw new ConflictException(err); + } + this.logger.debug( + this.logPrefix, + `No existing transaction found for Idempotency Key: ${idempotencyKey}. Proceeding to create a new one.`, + ); + } } diff --git a/src/modules/transaction/domain/transaction.entity.ts b/src/modules/transaction/domain/transaction.entity.ts index 100c4c1..f14da17 100644 --- a/src/modules/transaction/domain/transaction.entity.ts +++ b/src/modules/transaction/domain/transaction.entity.ts @@ -24,6 +24,8 @@ export class Transaction { public readonly originCurrency: CurrencyType, public readonly currentCurrency: CurrencyType, public readonly amount: bigint, + public readonly clientTransactionDate: Date | null, + public readonly idempotencyKey: string | null, ) {} public static create(params: { @@ -34,6 +36,8 @@ export class Transaction { originCurrency: string; currentCurrency: string; amount: bigint; + clientTransactionDate: Date | null; + idempotencyKey: string | null; }): Transaction { const type = this.validateTransactionType(params.type); const status = this.validateTransactionStatus(params.status); @@ -48,6 +52,8 @@ export class Transaction { origin, current, params.amount, + params.clientTransactionDate, + params.idempotencyKey, ); } diff --git a/src/modules/transaction/domain/transaction.repo.ts b/src/modules/transaction/domain/transaction.repo.ts index f53a80c..417cdfe 100644 --- a/src/modules/transaction/domain/transaction.repo.ts +++ b/src/modules/transaction/domain/transaction.repo.ts @@ -11,6 +11,11 @@ export interface TransactionRepository { ): Promise; getById(transactionId: number): Promise; updateStatus(input: UpdateTransactionInput): Promise; + getByIdempotencyKey( + idempotencyKey: string, + clientTransactionDate: Date, + amount: bigint, + ): Promise; } export const TRANSACTION_REPOSITORY_TOKEN: InjectionToken = diff --git a/src/modules/transaction/infra/repo/transaction.postgres.repo.impl.ts b/src/modules/transaction/infra/repo/transaction.postgres.repo.impl.ts index 22b8ec4..f839fa8 100644 --- a/src/modules/transaction/infra/repo/transaction.postgres.repo.impl.ts +++ b/src/modules/transaction/infra/repo/transaction.postgres.repo.impl.ts @@ -99,4 +99,39 @@ export class TransactionRepoImpl implements TransactionRepository { }); return Transaction.create(updatedTransaction); } + + async getByIdempotencyKey( + idempotencyKey: string, + clientTransactionDate: Date, + amount: bigint, + ): Promise { + this.logger.log( + this.logPrefix, + `Searching for transaction with idempotencyKey: ${idempotencyKey}`, + ); + const result = await this.db.transaction.findFirst({ + where: { + AND: [ + { idempotencyKey: idempotencyKey }, + { clientTransactionDate: new Date(clientTransactionDate) }, + { amount: amount }, + ], + }, + }); + + if (!result) { + this.logger.debug( + this.logPrefix, + `No transaction found with idempotencyKey: ${idempotencyKey}`, + ); + return null; + } + + this.logger.warn( + this.logPrefix, + `Found transaction with idempotencyKey: ${result.idempotencyKey}`, + ); + + return Transaction.create(result); + } } diff --git a/src/modules/transaction/transaction.module.ts b/src/modules/transaction/transaction.module.ts index 58f09ac..1b1d67f 100644 --- a/src/modules/transaction/transaction.module.ts +++ b/src/modules/transaction/transaction.module.ts @@ -6,7 +6,7 @@ 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 { TransactionService } from './app/services/transaction.service'; +import { TransactionService } from './domain/services/transaction.service'; import { TRANSACTION_REPOSITORY_TOKEN } from './domain/transaction.repo'; import { TransactionRepoImpl } from './infra/repo/transaction.postgres.repo.impl'; diff --git a/src/shared/dto/create-transaction.dto.ts b/src/shared/dto/create-transaction.dto.ts index 7f3ec8c..6baad69 100644 --- a/src/shared/dto/create-transaction.dto.ts +++ b/src/shared/dto/create-transaction.dto.ts @@ -1,4 +1,11 @@ -import { IsEnum, IsNotEmpty, IsNumber, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + IsDate, + IsEnum, + IsNotEmpty, + IsNumber, + IsString, +} from 'class-validator'; import { CurrencyEnum } from '../validations/currency'; const ALLOWED_CURRENCIES = Object.values(CurrencyEnum).join(', '); @@ -7,11 +14,20 @@ export class CreateTransactionDto { @IsNotEmpty({ message: 'Token ID must be provided.' }) @IsString() tokenId: string; + @IsEnum(CurrencyEnum, { message: `Invalid currency provided. Allowed currencies are: ${ALLOWED_CURRENCIES}`, }) + @IsNotEmpty({ message: 'Currency must be provided.' }) @IsNotEmpty() currency: CurrencyEnum; + + @IsNotEmpty({ message: 'Amount must be provided.' }) @IsNumber() amount: bigint; + + @Type(() => Date) + @IsNotEmpty({ message: 'Client transaction date must be provided.' }) + @IsDate() + clientTransactionDate: Date; }