Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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");
24 changes: 14 additions & 10 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion src/modules/transaction/api/transaction.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
BadRequestException,
Body,
Controller,
Get,
Headers,
HttpCode,
HttpStatus,
Param,
Expand All @@ -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,
Expand All @@ -45,15 +47,24 @@ export class TransactionController {
@HttpCode(HttpStatus.CREATED)
async saveTransaction(
@Body(new ValidationPipe()) input: CreateTransactionDto,
@Headers('Idempotency-Key') idempotencyKey: string,
): Promise<FundsRepresentation> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/modules/transaction/app/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface CreateTransactionInput {
tokenId: string;
targetCurrency: CurrencyEnum;
amount: bigint;
clientTransactionDate: Date;
idempotencyKey: string;
}

export interface UpdateTransactionInput {
Expand All @@ -20,4 +22,6 @@ export interface TransactionInput {
originCurrency: CurrencyEnum;
currentCurrency: CurrencyEnum;
amount: bigint;
clientTransactionDate?: Date;
idempotencyKey?: string;
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -91,4 +96,25 @@ export class TransactionService {
}
return transaction;
}

public async checkIdempotency(
idempotencyKey: string,
clientTransactionDate: Date,
amount: bigint,
): Promise<void> {
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.`,
);
}
}
6 changes: 6 additions & 0 deletions src/modules/transaction/domain/transaction.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);
Expand All @@ -48,6 +52,8 @@ export class Transaction {
origin,
current,
params.amount,
params.clientTransactionDate,
params.idempotencyKey,
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/modules/transaction/domain/transaction.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface TransactionRepository {
): Promise<Transaction[]>;
getById(transactionId: number): Promise<Transaction | null>;
updateStatus(input: UpdateTransactionInput): Promise<Transaction>;
getByIdempotencyKey(
idempotencyKey: string,
clientTransactionDate: Date,
amount: bigint,
): Promise<Transaction | null>;
}

export const TRANSACTION_REPOSITORY_TOKEN: InjectionToken<TransactionRepository> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,39 @@ export class TransactionRepoImpl implements TransactionRepository {
});
return Transaction.create(updatedTransaction);
}

async getByIdempotencyKey(
idempotencyKey: string,
clientTransactionDate: Date,
amount: bigint,
): Promise<Transaction | null> {
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);
}
}
2 changes: 1 addition & 1 deletion src/modules/transaction/transaction.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
18 changes: 17 additions & 1 deletion src/shared/dto/create-transaction.dto.ts
Original file line number Diff line number Diff line change
@@ -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(', ');
Expand All @@ -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;
}