From 02e0d560c84606d6372cbac001dc94edc348995e Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 4 Nov 2026 17:47:36 +0300 Subject: [PATCH] refactor(api): describe bank operation create body as oneOf by kind (WealthLedger/backend#172) (#173) --- .../bank-cards/presentation/bank-card.dto.ts | 44 +++++++++++++++++++ .../presentation/bank-cards.controller.ts | 14 ++++++ 2 files changed, 58 insertions(+) diff --git a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts index cb44a0f..defd049 100644 --- a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional, + getSchemaPath, IntersectionType, OmitType, PartialType, @@ -153,6 +154,49 @@ export class CreateBankOperationDto { @MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH) description?: string; } + +// OpenAPI-only variants. Runtime validation stays on CreateBankOperationDto so +// invalid combinations keep the existing BANK_INVALID_INPUT response. +class BankOperationCreateBaseDto extends OmitType(CreateBankOperationDto, [ + 'kind', 'targetCardId', 'categoryId', 'originalExpenseId', +] as const) {} + +export class CreateExpenseOperationDto extends BankOperationCreateBaseDto { + @ApiProperty({ enum: [BankOperationKind.Expense] }) declare kind: 'expense'; + @ApiPropertyOptional({ format: 'uuid' }) declare categoryId?: string; +} + +export class CreateTopupOperationDto extends BankOperationCreateBaseDto { + @ApiProperty({ enum: [BankOperationKind.Topup] }) declare kind: 'topup'; +} + +export class CreateTransferOperationDto extends BankOperationCreateBaseDto { + @ApiProperty({ enum: [BankOperationKind.Transfer] }) declare kind: 'transfer'; + @ApiProperty({ format: 'uuid' }) declare targetCardId: string; +} + +export class CreateRefundOperationDto extends BankOperationCreateBaseDto { + @ApiProperty({ enum: [BankOperationKind.Refund] }) declare kind: 'refund'; + @ApiProperty({ format: 'uuid' }) declare originalExpenseId: string; +} + +export const createBankOperationSchema = { + oneOf: [ + CreateExpenseOperationDto, + CreateTopupOperationDto, + CreateTransferOperationDto, + CreateRefundOperationDto, + ].map((model) => ({ $ref: getSchemaPath(model) })), + discriminator: { + propertyName: 'kind', + mapping: { + expense: getSchemaPath(CreateExpenseOperationDto), + topup: getSchemaPath(CreateTopupOperationDto), + transfer: getSchemaPath(CreateTransferOperationDto), + refund: getSchemaPath(CreateRefundOperationDto), + }, + }, +}; export class UpdateBankOperationDto extends PartialType( OmitType(CreateBankOperationDto, ['id'] as const), { skipNullProperties: false }, diff --git a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts index 0a3e19a..2d55e2c 100644 --- a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts +++ b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts @@ -16,7 +16,9 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, + ApiBody, ApiCreatedResponse, + ApiExtraModels, ApiNoContentResponse, ApiOkResponse, ApiResponse, @@ -46,6 +48,11 @@ import { CategoryNameDto, CreateBankCardDto, CreateBankOperationDto, + createBankOperationSchema, + CreateExpenseOperationDto, + CreateRefundOperationDto, + CreateTopupOperationDto, + CreateTransferOperationDto, ExpenseCategoryDto, UpdateBankCardDto, UpdateBankOperationDto, @@ -73,6 +80,12 @@ type AuthRequest = Request & { user: AccessPayload }; }, }) @ApiTags('bank-cards') +@ApiExtraModels( + CreateExpenseOperationDto, + CreateTopupOperationDto, + CreateTransferOperationDto, + CreateRefundOperationDto, +) @ApiBearerAuth() @ApiProblemResponses() @UseGuards(JwtAuthGuard) @@ -131,6 +144,7 @@ export class BankCardsController { } @Post('operations') + @ApiBody({ schema: createBankOperationSchema }) @ApiCreatedResponse({ type: BankOperationDto }) createOperation( @Req() req: AuthRequest,