refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,267 @@
import {
ApiProperty,
ApiPropertyOptional,
IntersectionType,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const;
export const bankOperationKinds = [
'expense',
'topup',
'transfer',
'refund',
] as const;
export type BankOperationKind = (typeof bankOperationKinds)[number];
export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString()
@Matches(/^-?\d+(\.\d{1,2})?$/)
declare value: string;
}
export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: 100 })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(100)
declare name: string;
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 })
@IsOptional()
@Transform(trim)
@IsString()
@MaxLength(100)
bankName?: string | null;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional()
@Matches(/^[0-9]{4}$/)
lastFour?: string | null;
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
@IsOptional()
@IsIn(bankPaymentSystems)
paymentSystem?: string | null;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
declare openingBalance: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
declare openingDate: string;
}
export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
@ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean()
archived?: boolean;
}
export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: 80 })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(80)
declare name: string;
}
export class UpdateExpenseCategoryDto extends CategoryNameDto {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
}
export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds)
declare kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
targetCardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
categoryId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
originalExpenseId?: string;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
declare amount: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string;
@ApiPropertyOptional({ maxLength: 500 })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsString()
@MaxLength(500)
description?: string;
}
export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
}
export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] })
@IsOptional()
@IsIn(['true', 'false'])
includeArchived?: string;
}
export class BankPageQueryDto {
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
export class BankPeriodQueryDto {
@ApiPropertyOptional({
pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$',
description: 'Use month or both from and to, exclusively.',
})
@IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive start date; requires to.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
from?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive end date; requires from.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
to?: string;
}
export class BankOperationsQueryDto extends IntersectionType(
BankPageQueryDto,
BankPeriodQueryDto,
) {}
export class BankSummaryQueryDto extends BankPeriodQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cardId?: string;
}
export class BankVersionQueryDto {
@ApiProperty({ minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
declare version: number;
}
export class ExpenseCategoryDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: Boolean }) declare isDefault: boolean;
@ApiProperty({ type: Number }) declare version: number;
}
export class BankCardDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: String, nullable: true }) declare bankName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare lastFour:
string | null;
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
declare paymentSystem: string | null;
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
@ApiProperty({ type: String }) declare openingDate: string;
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
string | null;
@ApiProperty({ type: Number }) declare version: number;
}
export class BankOperationDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ enum: bankOperationKinds }) declare kind: BankOperationKind;
@ApiProperty({ type: String }) declare cardId: string;
@ApiProperty({ type: String }) declare cardName: string;
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
string | null;
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare categoryId:
string | null;
@ApiProperty({ type: String, nullable: true }) declare categoryName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
string | null;
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
@ApiProperty({ type: MoneyDto, nullable: true })
declare availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) declare date: string;
@ApiProperty({ type: String, nullable: true }) declare description:
string | null;
@ApiProperty({ type: Number }) declare version: number;
@ApiProperty({ type: String }) declare createdAt: string;
}
export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true })
declare items: BankOperationDto[];
@ApiProperty({ type: Number }) declare page: number;
@ApiProperty({ type: Number }) declare total: number;
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
}
export class BankSummaryCategoryDto {
@ApiProperty({ type: String }) declare categoryId: string;
@ApiProperty({ type: String }) declare categoryName: string;
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
}
export class BankSummaryDto {
@ApiPropertyOptional({ type: String }) month?: string;
@ApiPropertyOptional({ format: 'date' }) from?: string;
@ApiPropertyOptional({ format: 'date' }) to?: string;
@ApiProperty({ type: String, nullable: true }) declare cardId: string | null;
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
declare categories: BankSummaryCategoryDto[];
}
@@ -0,0 +1,187 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import type { Request } from 'express';
import {
ApiProblemResponses,
ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import {
BankCardDto,
BankCardsQueryDto,
BankOperationDto,
BankOperationsPageDto,
BankOperationsQueryDto,
BankPageQueryDto,
BankSummaryDto,
BankSummaryQueryDto,
BankVersionQueryDto,
CategoryNameDto,
CreateBankCardDto,
CreateBankOperationDto,
ExpenseCategoryDto,
UpdateBankCardDto,
UpdateBankOperationDto,
UpdateExpenseCategoryDto,
} from './bank-card.dto';
import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service';
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({
status: 409,
description:
'BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT',
content: {
'application/problem+json': {
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
},
},
})
@ApiResponse({
status: 410,
description:
'BANK_OPERATION_DELETED: creation replay refers to a deleted operation',
content: {
'application/problem+json': {
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
},
},
})
@ApiTags('bank-cards')
@ApiBearerAuth()
@ApiProblemResponses()
@UseGuards(JwtAuthGuard)
@Controller('bank-cards')
export class BankCardsController {
constructor(
private readonly cards: BankCardsService,
private readonly operations: BankOperationsService,
private readonly expenseCategories: ExpenseCategoriesService,
private readonly bankSummary: GetBankSummaryUseCase,
) {}
@Get()
@ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
return this.cards.list(req.user.sub, query.includeArchived === 'true');
}
@Post()
@ApiCreatedResponse({ type: BankCardDto })
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
return this.cards.createCard(req.user.sub, body);
}
@Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) {
return this.expenseCategories.categories(req.user.sub);
}
@Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.expenseCategories.createCategory(req.user.sub, body.name);
}
@Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto })
updateCategory(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateExpenseCategoryDto,
) {
return this.expenseCategories.updateCategory(
req.user.sub,
id,
body.name,
body.version,
);
}
@Get('summary')
@ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.bankSummary.execute(req.user.sub, query);
}
@Post('operations')
@ApiCreatedResponse({ type: BankOperationDto })
createOperation(
@Req() req: AuthRequest,
@Body() body: CreateBankOperationDto,
) {
return this.operations.createOperation(req.user.sub, body);
}
@Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.operations.getOperation(req.user.sub, id);
}
@Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
updateOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankOperationDto,
) {
return this.operations.updateOperation(req.user.sub, id, body);
}
@Delete('operations/:id')
@HttpCode(204)
@ApiNoContentResponse()
deleteOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankVersionQueryDto,
) {
return this.operations.deleteOperation(req.user.sub, id, query.version);
}
@Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto })
history(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankOperationsQueryDto,
) {
return this.operations.history(req.user.sub, id, query);
}
@Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto })
expenses(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankPageQueryDto,
) {
return this.operations.history(req.user.sub, id, query, true);
}
@Get(':id')
@ApiOkResponse({ type: BankCardDto })
get(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.get(req.user.sub, id);
}
@Patch(':id')
@ApiOkResponse({ type: BankCardDto })
update(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankCardDto,
) {
return this.cards.updateCard(req.user.sub, id, body);
}
}