From 43a28a9646f6695a53d37f366316faf495f89fa9 Mon Sep 17 00:00:00 2001 From: JTKaduma Date: Thu, 30 Jul 2026 02:29:45 +0100 Subject: [PATCH] feat(payments): add dry-run validation endpoint --- docs/PAYMENT-DRY-RUN.md | 63 ++++++++ src/payments/dto/create-payment.dto.ts | 19 +-- .../dto/payment-dry-run-response.dto.ts | 53 ++++++ src/payments/payments.controller.spec.ts | 47 +++++- src/payments/payments.controller.ts | 37 +++++ .../payments.dry-run.integration.spec.ts | 152 ++++++++++++++++++ src/payments/payments.module.ts | 1 + src/payments/payments.service.spec.ts | 95 ++++++++++- src/payments/payments.service.ts | 135 ++++++++++------ 9 files changed, 536 insertions(+), 66 deletions(-) create mode 100644 docs/PAYMENT-DRY-RUN.md create mode 100644 src/payments/dto/payment-dry-run-response.dto.ts create mode 100644 src/payments/payments.dry-run.integration.spec.ts diff --git a/docs/PAYMENT-DRY-RUN.md b/docs/PAYMENT-DRY-RUN.md new file mode 100644 index 0000000..4b8221b --- /dev/null +++ b/docs/PAYMENT-DRY-RUN.md @@ -0,0 +1,63 @@ +# Payment dry-run + +`POST /v1/payments/dry-run` validates a payment request without creating a +payment or submitting anything to Stellar. The endpoint requires the same +`Authorization: Bearer ` authentication and uses the same request body as +`POST /v1/payments`. + +The dry-run performs the checks that happen immediately before persistence: + +- the sender wallet exists and is `ACTIVE`; +- self-payment policy permits the transfer; +- the receiver wallet exists; and +- configured per-transaction and daily wallet limits permit the amount. + +Example request: + +```http +POST /v1/payments/dry-run +Authorization: Bearer mux_test_example +Content-Type: application/json + +{ + "walletId": "123e4567-e89b-12d3-a456-426614174000", + "receiverWalletId": "123e4567-e89b-12d3-a456-426614174001", + "amount": 25, + "currency": "USD", + "description": "Invoice preview", + "fromId": 1, + "toId": 2 +} +``` + +Successful response (`200 OK`): + +```json +{ + "dryRun": true, + "valid": true, + "preview": { + "senderWalletId": "123e4567-e89b-12d3-a456-426614174000", + "receiverWalletId": "123e4567-e89b-12d3-a456-426614174001", + "fromId": 1, + "toId": 2, + "amount": 25, + "currency": "USD", + "status": "PENDING" + }, + "checks": { + "senderWallet": "ACTIVE", + "receiverWallet": "FOUND", + "paymentLimits": "PASSED" + } +} +``` + +Validation errors use the API's normal error envelope. Missing or invalid API +keys return `401`; malformed input and inactive senders return `400`; missing +wallets return `404`; and wallet-limit failures return `422`. + +Dry-run does not reserve funds, guarantee later submission, query or return +custody key material, write a payment row, sign a transaction, submit to +Horizon, or emit payment domain events. A later create request is validated +again because wallet state and limits may have changed. diff --git a/src/payments/dto/create-payment.dto.ts b/src/payments/dto/create-payment.dto.ts index d3e5ebf..f0a2816 100644 --- a/src/payments/dto/create-payment.dto.ts +++ b/src/payments/dto/create-payment.dto.ts @@ -6,7 +6,6 @@ import { IsOptional, IsInt, Min, - ValidateBy, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; @@ -30,19 +29,15 @@ export class CreatePaymentDto { receiverWalletId: string; @ApiProperty({ - example: 100.50, - description: 'Payment amount - must be positive with max 2 decimal places (e.g., 100.50)', + example: 100.5, + description: + 'Payment amount - must be positive with max 2 decimal places (e.g., 100.50)', }) - @IsNumber({}, { message: 'amount must be a number' }) - @IsPositive({ message: 'amount must be positive' }) - @ValidateBy( - (value: any) => { - if (typeof value !== 'number') return false; - const decimalPlaces = (value.toString().split('.')[1] || '').length; - return decimalPlaces <= 2; - }, - { message: 'amount must have maximum 2 decimal places' }, + @IsNumber( + { maxDecimalPlaces: 2 }, + { message: 'amount must be a number with maximum 2 decimal places' }, ) + @IsPositive({ message: 'amount must be positive' }) amount: number; @ApiProperty({ diff --git a/src/payments/dto/payment-dry-run-response.dto.ts b/src/payments/dto/payment-dry-run-response.dto.ts new file mode 100644 index 0000000..3234a46 --- /dev/null +++ b/src/payments/dto/payment-dry-run-response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaymentStatus } from '../entities/payment.entity'; + +export class PaymentDryRunPreviewDto { + @ApiProperty() + senderWalletId: string; + + @ApiProperty() + receiverWalletId: string; + + @ApiProperty() + fromId: number; + + @ApiProperty() + toId: number; + + @ApiProperty() + amount: number; + + @ApiProperty() + currency: string; + + @ApiPropertyOptional() + assetCode?: string; + + @ApiProperty({ enum: PaymentStatus, example: PaymentStatus.PENDING }) + status: PaymentStatus; +} + +export class PaymentDryRunChecksDto { + @ApiProperty({ example: 'ACTIVE' }) + senderWallet: 'ACTIVE'; + + @ApiProperty({ example: 'FOUND' }) + receiverWallet: 'FOUND'; + + @ApiProperty({ example: 'PASSED' }) + paymentLimits: 'PASSED'; +} + +export class PaymentDryRunResponseDto { + @ApiProperty({ example: true }) + dryRun: true; + + @ApiProperty({ example: true }) + valid: true; + + @ApiProperty({ type: PaymentDryRunPreviewDto }) + preview: PaymentDryRunPreviewDto; + + @ApiProperty({ type: PaymentDryRunChecksDto }) + checks: PaymentDryRunChecksDto; +} diff --git a/src/payments/payments.controller.spec.ts b/src/payments/payments.controller.spec.ts index ffa5c1c..3f4a903 100644 --- a/src/payments/payments.controller.spec.ts +++ b/src/payments/payments.controller.spec.ts @@ -14,6 +14,7 @@ describe('PaymentsController', () => { beforeEach(async () => { paymentsService = { create: jest.fn(), + dryRun: jest.fn(), findAll: jest.fn(), findOne: jest.fn(), update: jest.fn(), @@ -42,6 +43,24 @@ describe('PaymentsController', () => { expect(controller).toBeDefined(); }); + describe('dryRun', () => { + it('delegates payment validation to the service', async () => { + const dto = { + walletId: 'sender-wallet', + receiverWalletId: 'receiver-wallet', + fromId: 1, + toId: 2, + amount: 25, + currency: 'USD', + }; + const response = { dryRun: true, valid: true }; + paymentsService.dryRun.mockResolvedValue(response); + + await expect(controller.dryRun(dto)).resolves.toEqual(response); + expect(paymentsService.dryRun).toHaveBeenCalledWith(dto); + }); + }); + describe('update', () => { it('should delegate to service and return updated payment', async () => { const updated = { id: 1, status: PaymentStatus.CONFIRMED }; @@ -106,7 +125,14 @@ describe('PaymentsController', () => { describe('swagger decorators', () => { it('should have @ApiResponse decorators on all routes', () => { - const routes = ['create', 'findAll', 'findOne', 'update', 'remove']; + const routes = [ + 'create', + 'dryRun', + 'findAll', + 'findOne', + 'update', + 'remove', + ]; routes.forEach((route) => { const descriptor = Object.getOwnPropertyDescriptor( @@ -115,13 +141,23 @@ describe('PaymentsController', () => { ); expect(descriptor).toBeDefined(); - const metadata = Reflect.getMetadata('swagger/apiResponse', descriptor.value); + const metadata = Reflect.getMetadata( + 'swagger/apiResponse', + descriptor.value, + ); expect(metadata).toBeDefined(); }); }); it('should have @ApiOperation on all routes', () => { - const routes = ['create', 'findAll', 'findOne', 'update', 'remove']; + const routes = [ + 'create', + 'dryRun', + 'findAll', + 'findOne', + 'update', + 'remove', + ]; routes.forEach((route) => { const descriptor = Object.getOwnPropertyDescriptor( @@ -130,7 +166,10 @@ describe('PaymentsController', () => { ); expect(descriptor).toBeDefined(); - const metadata = Reflect.getMetadata('swagger/apiOperation', descriptor.value); + const metadata = Reflect.getMetadata( + 'swagger/apiOperation', + descriptor.value, + ); expect(metadata).toBeDefined(); }); }); diff --git a/src/payments/payments.controller.ts b/src/payments/payments.controller.ts index ca23cc8..744811e 100644 --- a/src/payments/payments.controller.ts +++ b/src/payments/payments.controller.ts @@ -8,6 +8,8 @@ import { Delete, Query, UseGuards, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { ApiTags, @@ -19,6 +21,7 @@ import { } from '@nestjs/swagger'; import { PaymentsService } from './payments.service'; import { CreatePaymentDto } from './dto/create-payment.dto'; +import { PaymentDryRunResponseDto } from './dto/payment-dry-run-response.dto'; import { BatchPaymentDto } from './dto/batch-payment.dto'; import { UpdatePaymentDto } from './dto/update-payment.dto'; import { PaymentsFilterDto } from './dto/payments-filter.dto'; @@ -83,6 +86,40 @@ export class PaymentsController { return this.paymentsService.create(createPaymentDto); } + @ApiOperation({ + summary: 'Validate a payment without creating or submitting it', + description: + 'Runs the same wallet-state, self-payment, receiver, and payment-limit checks as payment creation. No payment is persisted, no transaction is signed or submitted, and no domain event is emitted.', + }) + @ApiBody({ type: CreatePaymentDto }) + @ApiResponse({ + status: 200, + description: 'The payment passed all pre-creation checks.', + type: PaymentDryRunResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Bad request - invalid input or inactive sender wallet.', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid API key.', + }) + @ApiResponse({ + status: 404, + description: 'Sender or receiver wallet not found.', + }) + @ApiResponse({ + status: 422, + description: 'The payment exceeds a configured wallet limit.', + }) + @Post('dry-run') + @HttpCode(HttpStatus.OK) + @SensitiveEndpoint() + dryRun(@Body() createPaymentDto: CreatePaymentDto) { + return this.paymentsService.dryRun(createPaymentDto); + } + @ApiOperation({ summary: 'Create a batch of payments', description: diff --git a/src/payments/payments.dry-run.integration.spec.ts b/src/payments/payments.dry-run.integration.spec.ts new file mode 100644 index 0000000..87dc46c --- /dev/null +++ b/src/payments/payments.dry-run.integration.spec.ts @@ -0,0 +1,152 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { PaymentsController } from './payments.controller'; +import { PaymentsService } from './payments.service'; +import { ApiKeyGuard } from '../api-keys/api-key.guard'; +import { ApiKeyService } from '../api-keys/api-key.service'; +import { RateLimitGuard } from '../rate-limit/rate-limit.guard'; +import { FeatureFlagGuard } from '../common/feature-flags/feature-flag.guard'; + +describe('Payment dry-run HTTP contract', () => { + let app: INestApplication; + const dryRun = jest.fn(); + const validateApiKey = jest.fn(); + + beforeAll(async () => { + validateApiKey.mockResolvedValue({ + apiKey: { id: 'api-key-id' }, + project: { id: 'project-id', rateLimitRpm: 100 }, + }); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [PaymentsController], + providers: [ + ApiKeyGuard, + Reflector, + { + provide: PaymentsService, + useValue: { + dryRun, + create: jest.fn(), + createBatch: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }, + }, + { + provide: ApiKeyService, + useValue: { validateApiKey, recordUsage: jest.fn() }, + }, + ], + }) + .overrideGuard(RateLimitGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(FeatureFlagGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = module.createNestApplication(); + app.setGlobalPrefix('v1'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + dryRun.mockReset(); + }); + + it('returns a sanitized preview to an authorized caller', async () => { + dryRun.mockResolvedValue({ + dryRun: true, + valid: true, + preview: { + senderWalletId: 'sender-wallet', + receiverWalletId: 'receiver-wallet', + fromId: 1, + toId: 2, + amount: 25, + currency: 'USD', + status: 'PENDING', + }, + checks: { + senderWallet: 'ACTIVE', + receiverWallet: 'FOUND', + paymentLimits: 'PASSED', + }, + }); + + const response = await request(app.getHttpServer()) + .post('/v1/payments/dry-run') + .set('Authorization', 'Bearer mux_test_valid') + .send({ + walletId: 'sender-wallet', + receiverWalletId: 'receiver-wallet', + fromId: 1, + toId: 2, + amount: 25, + currency: 'USD', + }) + .expect(200); + + expect(response.body).toMatchObject({ dryRun: true, valid: true }); + expect(response.body).not.toHaveProperty('privateKey'); + expect(response.body).not.toHaveProperty('encryptedSecret'); + expect(dryRun).toHaveBeenCalledTimes(1); + }); + + it('returns the standard 400 response for invalid input', async () => { + const response = await request(app.getHttpServer()) + .post('/v1/payments/dry-run') + .set('Authorization', 'Bearer mux_test_valid') + .send({ + walletId: 'sender-wallet', + receiverWalletId: 'receiver-wallet', + fromId: 1, + toId: 2, + amount: -1, + currency: 'USD', + }) + .expect(400); + + expect(response.body).toMatchObject({ + statusCode: 400, + error: 'Bad Request', + }); + expect(dryRun).not.toHaveBeenCalled(); + }); + + it('returns the standard 401 response without authorization', async () => { + const response = await request(app.getHttpServer()) + .post('/v1/payments/dry-run') + .send({ + walletId: 'sender-wallet', + receiverWalletId: 'receiver-wallet', + fromId: 1, + toId: 2, + amount: 25, + currency: 'USD', + }) + .expect(401); + + expect(response.body).toMatchObject({ + statusCode: 401, + message: 'API key is required', + error: 'Unauthorized', + }); + expect(dryRun).not.toHaveBeenCalled(); + }); +}); diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index a1c73bf..81fc325 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -18,6 +18,7 @@ import { PaymentMetricsService } from './payment-metrics.service'; providers: [ PaymentsService, PaymentMetricsService, + PaymentStatusHistoryService, { provide: PAYMENT_LIMITS_PORT, useExisting: LimitsService }, RequestContextService, FeatureFlagService, diff --git a/src/payments/payments.service.spec.ts b/src/payments/payments.service.spec.ts index db575da..f7d83c7 100644 --- a/src/payments/payments.service.spec.ts +++ b/src/payments/payments.service.spec.ts @@ -7,6 +7,9 @@ import { WalletsService } from '../wallets/wallets.service'; import { MetricsService } from '../metrics/metrics.service'; import { PAYMENT_LIMITS_PORT } from './ports/payment-limits.port'; import { RequestContextService } from '../common/request-context/request-context.service'; +import { PaymentMetricsService } from './payment-metrics.service'; +import { ConfigService } from '@nestjs/config'; +import { PaymentStatusHistoryService } from './payment-status-history.service'; import { WalletStatus } from '../wallets/domain/wallet.model'; import { PaymentStatus } from './entities/payment.entity'; import { PaymentCreatedEvent } from './events/payment-created.event'; @@ -37,6 +40,9 @@ describe('PaymentsService', () => { let eventEmitter: any; let metrics: any; let requestContext: any; + let paymentMetrics: any; + let configService: any; + let statusHistory: any; beforeEach(async () => { prisma = { @@ -58,6 +64,9 @@ describe('PaymentsService', () => { incrementPaymentIdempotencyHit: jest.fn(), }; requestContext = { getRequestId: jest.fn().mockReturnValue('req-1') }; + paymentMetrics = { record: jest.fn() }; + configService = { get: jest.fn().mockReturnValue(false) }; + statusHistory = { recordStatusChange: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -68,6 +77,9 @@ describe('PaymentsService', () => { { provide: EventEmitter2, useValue: eventEmitter }, { provide: MetricsService, useValue: metrics }, { provide: RequestContextService, useValue: requestContext }, + { provide: PaymentMetricsService, useValue: paymentMetrics }, + { provide: ConfigService, useValue: configService }, + { provide: PaymentStatusHistoryService, useValue: statusHistory }, ], }).compile(); @@ -78,6 +90,84 @@ describe('PaymentsService', () => { expect(service).toBeDefined(); }); + describe('dryRun', () => { + it('validates the payment and returns a sanitized preview without side effects', async () => { + const secret = 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + walletsService.findWalletById + .mockResolvedValueOnce({ + ...ACTIVE_WALLET, + encryptedSecret: 'encrypted-wallet-secret', + privateKey: secret, + }) + .mockResolvedValueOnce({ + ...RECEIVER_WALLET, + encryptedSecret: 'encrypted-receiver-secret', + }); + paymentLimitsPort.checkLimits.mockResolvedValue(undefined); + + const result = await service.dryRun(BASE_DTO); + + expect(result).toEqual({ + dryRun: true, + valid: true, + preview: { + senderWalletId: BASE_DTO.walletId, + receiverWalletId: BASE_DTO.receiverWalletId, + fromId: BASE_DTO.fromId, + toId: BASE_DTO.toId, + amount: BASE_DTO.amount, + currency: BASE_DTO.currency, + status: PaymentStatus.PENDING, + }, + checks: { + senderWallet: 'ACTIVE', + receiverWallet: 'FOUND', + paymentLimits: 'PASSED', + }, + }); + expect(paymentLimitsPort.checkLimits).toHaveBeenCalledWith( + BASE_DTO.walletId, + BASE_DTO.amount, + ); + expect(prisma.payment.findUnique).not.toHaveBeenCalled(); + expect(prisma.payment.create).not.toHaveBeenCalled(); + expect(eventEmitter.emit).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain(secret); + expect(JSON.stringify(result)).not.toContain('encrypted-wallet-secret'); + }); + + it('rejects an inactive sender without persisting a payment', async () => { + walletsService.findWalletById.mockResolvedValue({ + ...ACTIVE_WALLET, + status: WalletStatus.SUSPENDED, + }); + + await expect(service.dryRun(BASE_DTO)).rejects.toThrow( + new BadRequestException( + 'Sender wallet is not active (status: SUSPENDED)', + ), + ); + expect(paymentLimitsPort.checkLimits).not.toHaveBeenCalled(); + expect(prisma.payment.create).not.toHaveBeenCalled(); + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }); + + it('propagates payment-limit failures without persistence', async () => { + walletsService.findWalletById + .mockResolvedValueOnce(ACTIVE_WALLET) + .mockResolvedValueOnce(RECEIVER_WALLET); + paymentLimitsPort.checkLimits.mockRejectedValue( + new BadRequestException('Payment limit exceeded'), + ); + + await expect(service.dryRun(BASE_DTO)).rejects.toThrow( + 'Payment limit exceeded', + ); + expect(prisma.payment.create).not.toHaveBeenCalled(); + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }); + }); + describe('create', () => { it('should create payment when sender wallet is ACTIVE and limits pass', async () => { walletsService.findWalletById @@ -142,6 +232,7 @@ describe('PaymentsService', () => { description: dtoWithAsset.description, userId: dtoWithAsset.fromId, status: PaymentStatus.PENDING, + idempotencyKey: null, }, }); expect(result.assetCode).toBe('EUR'); @@ -395,7 +486,9 @@ describe('PaymentsService', () => { await service.update('1', { status: PaymentStatus.FAILED }); - expect(metrics.incrementPaymentsFailed).toHaveBeenCalledWith('user_action'); + expect(metrics.incrementPaymentsFailed).toHaveBeenCalledWith( + 'user_action', + ); expect(eventEmitter.emit).toHaveBeenCalledWith( 'payment.failed', expect.any(PaymentFailedEvent), diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts index 9432efa..59d70dd 100644 --- a/src/payments/payments.service.ts +++ b/src/payments/payments.service.ts @@ -3,15 +3,14 @@ import { Injectable, NotFoundException, BadRequestException, - Logger, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreatePaymentDto } from './dto/create-payment.dto'; +import { PaymentDryRunResponseDto } from './dto/payment-dry-run-response.dto'; import { BatchPaymentDto } from './dto/batch-payment.dto'; import { UpdatePaymentDto } from './dto/update-payment.dto'; import { PrismaService } from '../prisma/prisma.service'; -import { LimitsService } from '../limits/limits.service'; import { WalletsService } from '../wallets/wallets.service'; import { PAYMENT_LIMITS_PORT, @@ -28,10 +27,8 @@ import { retryWithBackoff } from '../common/utils/retry'; import { MetricsService } from '../metrics/metrics.service'; import { RequestContextService } from '../common/request-context/request-context.service'; import { PaymentMetricsService } from './payment-metrics.service'; -import { - StructuredLogger, - LogContext, -} from '../common/logging/structured-logger'; +import { StructuredLogger } from '../common/logging/structured-logger'; +import { PaymentStatusHistoryService } from './payment-status-history.service'; // Only PENDING payments can be transitioned; terminal states are immutable. const ALLOWED_TRANSITIONS: Record = { @@ -54,14 +51,45 @@ export class PaymentsService { private readonly requestContext: RequestContextService, private readonly paymentMetrics: PaymentMetricsService, private readonly configService: ConfigService, + private readonly statusHistory: PaymentStatusHistoryService, ) {} + /** + * Validate a payment exactly as creation does, without signing, submitting, + * persisting a payment, or emitting a domain event. + */ + async dryRun( + createPaymentDto: CreatePaymentDto, + ): Promise { + await this.validateForCreation(createPaymentDto); + + return { + dryRun: true, + valid: true, + preview: { + senderWalletId: createPaymentDto.walletId, + receiverWalletId: createPaymentDto.receiverWalletId, + fromId: createPaymentDto.fromId, + toId: createPaymentDto.toId, + amount: createPaymentDto.amount, + currency: createPaymentDto.currency, + ...(createPaymentDto.assetCode + ? { assetCode: createPaymentDto.assetCode } + : {}), + status: PaymentStatus.PENDING, + }, + checks: { + senderWallet: 'ACTIVE', + receiverWallet: 'FOUND', + paymentLimits: 'PASSED', + }, + }; + } + async create(createPaymentDto: CreatePaymentDto) { const requestId = this.requestContext.getRequestId(); const start = Date.now(); const { - walletId, - receiverWalletId, fromId, toId, amount, @@ -76,13 +104,16 @@ export class PaymentsService { where: { idempotencyKey }, }); if (existing) { - this.logger.logWithContext('Idempotency hit, returning existing payment', { - requestId, - entityId: existing.id.toString(), - entityType: 'payment', - operation: 'create', - outcome: 'idempotent', - }); + this.logger.logWithContext( + 'Idempotency hit, returning existing payment', + { + requestId, + entityId: existing.id.toString(), + entityType: 'payment', + operation: 'create', + outcome: 'idempotent', + }, + ); this.metrics.incrementPaymentIdempotencyHit(); this.paymentMetrics.record({ operation: 'create', @@ -95,40 +126,7 @@ export class PaymentsService { } try { - const senderWallet = await retryWithBackoff( - () => this.walletsService.findWalletById(walletId), - 3, - 100, - this.logger, - ); - if (senderWallet.status !== WalletStatus.ACTIVE) { - throw new BadRequestException( - `Sender wallet is not active (status: ${senderWallet.status})`, - ); - } - - const blockSelfPayments = this.configService.get( - 'BLOCK_SELF_PAYMENTS', - false, - ); - if (blockSelfPayments && fromId === toId) { - throw new BadRequestException( - 'Payments to self are not allowed', - ); - } - - await retryWithBackoff( - () => this.walletsService.findWalletById(receiverWalletId), - 3, - 100, - this.logger, - ); - await retryWithBackoff( - () => this.paymentLimitsPort.checkLimits(walletId, amount), - 3, - 100, - this.logger, - ); + await this.validateForCreation(createPaymentDto); const payment = await this.prisma.payment.create({ data: { @@ -184,6 +182,45 @@ export class PaymentsService { return Promise.all(dto.payments.map((p) => this.create(p))); } + private async validateForCreation( + createPaymentDto: CreatePaymentDto, + ): Promise { + const { walletId, receiverWalletId, fromId, toId, amount } = + createPaymentDto; + const senderWallet = await retryWithBackoff( + () => this.walletsService.findWalletById(walletId), + 3, + 100, + this.logger, + ); + if (senderWallet.status !== WalletStatus.ACTIVE) { + throw new BadRequestException( + `Sender wallet is not active (status: ${senderWallet.status})`, + ); + } + + const blockSelfPayments = this.configService.get( + 'BLOCK_SELF_PAYMENTS', + false, + ); + if (blockSelfPayments && fromId === toId) { + throw new BadRequestException('Payments to self are not allowed'); + } + + await retryWithBackoff( + () => this.walletsService.findWalletById(receiverWalletId), + 3, + 100, + this.logger, + ); + await retryWithBackoff( + () => this.paymentLimitsPort.checkLimits(walletId, amount), + 3, + 100, + this.logger, + ); + } + async findAll( pagination: PaginationDto, filters: PaymentsFilterDto,