feat: add manual bank card ledger
Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
This commit is contained in:
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { BankCardsModule } from './bank-cards/bank-cards.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { LoggingModule } from './common/logging/logging.module';
|
||||
import { Environment, validateEnv } from './config/env.validation';
|
||||
@@ -49,6 +50,7 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
|
||||
LoggingModule,
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
BankCardsModule,
|
||||
TbankConnectionModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
bankBalance,
|
||||
bankSummary,
|
||||
isCalendarDate,
|
||||
moscowToday,
|
||||
} from './bank-card.calculations';
|
||||
|
||||
describe('bank card calculations', () => {
|
||||
it('uses Moscow midnight for today and the current month', () => {
|
||||
expect(moscowToday(new Date('2026-08-31T20:59:59.999Z'))).toBe(
|
||||
'2026-08-31',
|
||||
);
|
||||
expect(moscowToday(new Date('2026-08-31T21:00:00.000Z'))).toBe(
|
||||
'2026-09-01',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'0001-01-01',
|
||||
'9999-12-31',
|
||||
'2024-02-29',
|
||||
'2000-02-29',
|
||||
'2026-09-12',
|
||||
])('accepts calendar date %s', (value) => {
|
||||
expect(isCalendarDate(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'0000-01-01',
|
||||
'10000-01-01',
|
||||
'2026-02-29',
|
||||
'1900-02-29',
|
||||
'2026-04-31',
|
||||
'2026-00-01',
|
||||
'2026-13-01',
|
||||
'2026-01-00',
|
||||
'2026-01-32',
|
||||
'2026-1-01',
|
||||
'2026-09-12\n',
|
||||
'2026-09-12T00:00:00Z',
|
||||
'',
|
||||
])('rejects invalid calendar date %s', (value) => {
|
||||
expect(isCalendarDate(value)).toBe(false);
|
||||
});
|
||||
|
||||
it('folds expenses, topups, refunds and both transfer sides exactly', () => {
|
||||
const operations = [
|
||||
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 10n },
|
||||
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 20n },
|
||||
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 15n },
|
||||
{ kind: 'refund', cardId: 'a', targetCardId: null, amountKopecks: 5n },
|
||||
{ kind: 'transfer', cardId: 'a', targetCardId: 'b', amountKopecks: 120n },
|
||||
{ kind: 'expense', cardId: 'c', targetCardId: null, amountKopecks: 300n },
|
||||
];
|
||||
expect(bankBalance('a', 100n, operations.slice(0, 2))).toBe(70n);
|
||||
expect(bankBalance('a', 100n, operations)).toBe(-30n);
|
||||
expect(bankBalance('b', 0n, operations)).toBe(120n);
|
||||
expect(bankBalance('unused', -1n, operations)).toBe(-1n);
|
||||
});
|
||||
|
||||
it('does not bound computed balances by the storage range', () => {
|
||||
expect(
|
||||
bankBalance('a', 2n ** 63n - 1n, [
|
||||
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 1n },
|
||||
]),
|
||||
).toBe(2n ** 63n);
|
||||
expect(
|
||||
bankBalance('a', -(2n ** 63n), [
|
||||
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 1n },
|
||||
]),
|
||||
).toBe(-(2n ** 63n) - 1n);
|
||||
});
|
||||
|
||||
it('counts refunds in their own month and inherited category, excluding transfers and topups', () => {
|
||||
const operations = [
|
||||
{
|
||||
kind: 'expense',
|
||||
date: '2026-08-31',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 1000n,
|
||||
},
|
||||
{
|
||||
kind: 'refund',
|
||||
date: '2026-09-01',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 500n,
|
||||
},
|
||||
{
|
||||
kind: 'expense',
|
||||
date: '2026-09-30',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 100n,
|
||||
},
|
||||
{
|
||||
kind: 'expense',
|
||||
date: '2026-09-03',
|
||||
categoryId: 'travel',
|
||||
amountKopecks: 20n,
|
||||
},
|
||||
{
|
||||
kind: 'transfer',
|
||||
date: '2026-09-03',
|
||||
categoryId: null,
|
||||
amountKopecks: 2000n,
|
||||
},
|
||||
{
|
||||
kind: 'topup',
|
||||
date: '2026-09-03',
|
||||
categoryId: null,
|
||||
amountKopecks: 3000n,
|
||||
},
|
||||
{
|
||||
kind: 'expense',
|
||||
date: '2026-10-01',
|
||||
categoryId: 'travel',
|
||||
amountKopecks: 5000n,
|
||||
},
|
||||
];
|
||||
expect(bankSummary('2026-09', operations)).toEqual({
|
||||
expenses: 120n,
|
||||
refunds: 500n,
|
||||
net: -380n,
|
||||
categories: [
|
||||
{ categoryId: 'food', expenses: 100n, refunds: 500n, net: -400n },
|
||||
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
|
||||
],
|
||||
});
|
||||
expect(bankSummary('2026-07', operations)).toEqual({
|
||||
expenses: 0n,
|
||||
refunds: 0n,
|
||||
net: 0n,
|
||||
categories: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps summary sums beyond signed 64-bit exact', () => {
|
||||
const operation = {
|
||||
kind: 'expense',
|
||||
date: '2026-09-01',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 2n ** 63n - 1n,
|
||||
};
|
||||
const total = 2n * (2n ** 63n - 1n);
|
||||
expect(bankSummary('2026-09', [operation, operation])).toEqual({
|
||||
expenses: total,
|
||||
refunds: 0n,
|
||||
net: total,
|
||||
categories: [
|
||||
{ categoryId: 'food', expenses: total, refunds: 0n, net: total },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
export function moscowToday(now = new Date()): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Moscow',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now);
|
||||
}
|
||||
|
||||
export function isCalendarDate(value: string): boolean {
|
||||
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
if (value.startsWith('0000')) return false;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return (
|
||||
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
);
|
||||
}
|
||||
|
||||
export function bankBalance(
|
||||
cardId: string,
|
||||
opening: bigint,
|
||||
operations: Array<{
|
||||
kind: string;
|
||||
cardId: string;
|
||||
targetCardId: string | null;
|
||||
amountKopecks: bigint;
|
||||
}>,
|
||||
): bigint {
|
||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||
let balance = opening;
|
||||
for (const operation of operations) {
|
||||
if (operation.cardId === cardId) {
|
||||
if (operation.kind === 'expense' || operation.kind === 'transfer')
|
||||
balance -= operation.amountKopecks;
|
||||
else if (operation.kind === 'topup' || operation.kind === 'refund')
|
||||
balance += operation.amountKopecks;
|
||||
}
|
||||
if (operation.kind === 'transfer' && operation.targetCardId === cardId)
|
||||
balance += operation.amountKopecks;
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
export function bankSummary(
|
||||
month: string,
|
||||
operations: Array<{
|
||||
kind: string;
|
||||
date: string;
|
||||
amountKopecks: bigint;
|
||||
categoryId: string | null;
|
||||
}>,
|
||||
) {
|
||||
let expenses = 0n;
|
||||
let refunds = 0n;
|
||||
const categories = new Map<
|
||||
string,
|
||||
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
|
||||
>();
|
||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||
for (const operation of operations) {
|
||||
if (operation.date.slice(0, 7) !== month) continue;
|
||||
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
|
||||
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
|
||||
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
|
||||
expenses += expense;
|
||||
refunds += refund;
|
||||
if (operation.categoryId !== null) {
|
||||
const category = categories.get(operation.categoryId) ?? {
|
||||
categoryId: operation.categoryId,
|
||||
expenses: 0n,
|
||||
refunds: 0n,
|
||||
net: 0n,
|
||||
};
|
||||
category.expenses += expense;
|
||||
category.refunds += refund;
|
||||
category.net = category.expenses - category.refunds;
|
||||
categories.set(category.categoryId, category);
|
||||
}
|
||||
}
|
||||
return {
|
||||
expenses,
|
||||
refunds,
|
||||
net: expenses - refunds,
|
||||
categories: [...categories.values()],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
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 bankOperationKinds = [
|
||||
'expense',
|
||||
'topup',
|
||||
'transfer',
|
||||
'refund',
|
||||
] as const;
|
||||
export type BankOperationKind = (typeof bankOperationKinds)[number];
|
||||
export class RubMoneyInputDto {
|
||||
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) currency: string;
|
||||
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
||||
@IsString()
|
||||
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
||||
value: string;
|
||||
}
|
||||
export class CreateBankCardDto {
|
||||
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
|
||||
@ApiProperty({ minLength: 1, maxLength: 100 })
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
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;
|
||||
@ApiProperty({ type: RubMoneyInputDto })
|
||||
@IsDefined()
|
||||
@ValidateNested()
|
||||
@Type(() => RubMoneyInputDto)
|
||||
openingBalance: MoneyDto;
|
||||
@ApiProperty({ format: 'date' })
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
openingDate: string;
|
||||
}
|
||||
export class UpdateBankCardDto extends PartialType(
|
||||
OmitType(CreateBankCardDto, ['id'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {
|
||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) 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)
|
||||
name: string;
|
||||
}
|
||||
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
|
||||
}
|
||||
export class CreateBankOperationDto {
|
||||
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
|
||||
@ApiProperty({ enum: bankOperationKinds })
|
||||
@IsIn(bankOperationKinds)
|
||||
kind: BankOperationKind;
|
||||
@ApiProperty({ format: 'uuid' }) @IsUUID() 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)
|
||||
amount: MoneyDto;
|
||||
@ApiProperty({ format: 'date' })
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
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) 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 BankOperationsQueryDto extends BankPageQueryDto {
|
||||
@ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' })
|
||||
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||
month: string;
|
||||
}
|
||||
export class BankSummaryQueryDto {
|
||||
@ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' })
|
||||
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||
month: string;
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cardId?: string;
|
||||
}
|
||||
export class BankVersionQueryDto {
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
version: number;
|
||||
}
|
||||
export class ExpenseCategoryDto {
|
||||
@ApiProperty({ type: String }) id: string;
|
||||
@ApiProperty({ type: String }) name: string;
|
||||
@ApiProperty({ type: Boolean }) isDefault: boolean;
|
||||
@ApiProperty({ type: Number }) version: number;
|
||||
}
|
||||
export class BankCardDto {
|
||||
@ApiProperty({ type: String }) id: string;
|
||||
@ApiProperty({ type: String }) name: string;
|
||||
@ApiProperty({ type: String, nullable: true }) bankName: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) lastFour: string | null;
|
||||
@ApiProperty({ type: MoneyDto }) openingBalance: MoneyDto;
|
||||
@ApiProperty({ type: String }) openingDate: string;
|
||||
@ApiProperty({ type: MoneyDto }) balance: MoneyDto;
|
||||
@ApiProperty({ type: String, nullable: true }) archivedAt: string | null;
|
||||
@ApiProperty({ type: Number }) version: number;
|
||||
}
|
||||
export class BankOperationDto {
|
||||
@ApiProperty({ type: String }) id: string;
|
||||
@ApiProperty({ enum: bankOperationKinds }) kind: BankOperationKind;
|
||||
@ApiProperty({ type: String }) cardId: string;
|
||||
@ApiProperty({ type: String }) cardName: string;
|
||||
@ApiProperty({ type: String, nullable: true }) targetCardId: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) targetCardName: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) categoryId: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) categoryName: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) originalExpenseId:
|
||||
string | null;
|
||||
@ApiProperty({ type: MoneyDto }) amount: MoneyDto;
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
availableRefund: MoneyDto | null;
|
||||
@ApiProperty({ type: String }) date: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description: string | null;
|
||||
@ApiProperty({ type: Number }) version: number;
|
||||
@ApiProperty({ type: String }) createdAt: string;
|
||||
}
|
||||
export class BankOperationsPageDto {
|
||||
@ApiProperty({ type: BankOperationDto, isArray: true })
|
||||
items: BankOperationDto[];
|
||||
@ApiProperty({ type: Number }) page: number;
|
||||
@ApiProperty({ type: Number }) total: number;
|
||||
@ApiProperty({ type: Boolean }) hasNext: boolean;
|
||||
}
|
||||
export class BankSummaryCategoryDto {
|
||||
@ApiProperty({ type: String }) categoryId: string;
|
||||
@ApiProperty({ type: String }) categoryName: string;
|
||||
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
|
||||
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
|
||||
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
|
||||
}
|
||||
export class BankSummaryDto {
|
||||
@ApiProperty({ type: String }) month: string;
|
||||
@ApiProperty({ type: String, nullable: true }) cardId: string | null;
|
||||
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
|
||||
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
|
||||
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
|
||||
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
|
||||
categories: BankSummaryCategoryDto[];
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
ApiResponse,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { AccessPayload } from '../auth/auth.types';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import {
|
||||
ApiProblemResponses,
|
||||
ProblemDetailsDto,
|
||||
} from '../common/decorators/api-safe-error-response.decorator';
|
||||
import {
|
||||
BankCardDto,
|
||||
BankCardsQueryDto,
|
||||
BankOperationDto,
|
||||
BankOperationsPageDto,
|
||||
BankOperationsQueryDto,
|
||||
BankPageQueryDto,
|
||||
BankSummaryDto,
|
||||
BankSummaryQueryDto,
|
||||
BankVersionQueryDto,
|
||||
CategoryNameDto,
|
||||
CreateBankCardDto,
|
||||
CreateBankOperationDto,
|
||||
ExpenseCategoryDto,
|
||||
UpdateBankCardDto,
|
||||
UpdateBankOperationDto,
|
||||
UpdateExpenseCategoryDto,
|
||||
} from './bank-card.dto';
|
||||
import { BankCardsService } from './bank-cards.service';
|
||||
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) {}
|
||||
@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.cards.categories(req.user.sub);
|
||||
}
|
||||
@Post('categories')
|
||||
@ApiCreatedResponse({ type: ExpenseCategoryDto })
|
||||
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
|
||||
return this.cards.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.cards.updateCategory(req.user.sub, id, body.name, body.version);
|
||||
}
|
||||
@Get('summary')
|
||||
@ApiOkResponse({ type: BankSummaryDto })
|
||||
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
|
||||
return this.cards.summary(req.user.sub, query);
|
||||
}
|
||||
@Post('operations')
|
||||
@ApiCreatedResponse({ type: BankOperationDto })
|
||||
createOperation(
|
||||
@Req() req: AuthRequest,
|
||||
@Body() body: CreateBankOperationDto,
|
||||
) {
|
||||
return this.cards.createOperation(req.user.sub, body);
|
||||
}
|
||||
@Get('operations/:id')
|
||||
@ApiOkResponse({ type: BankOperationDto })
|
||||
operation(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||
return this.cards.getOperation(req.user.sub, id);
|
||||
}
|
||||
@Patch('operations/:id')
|
||||
@ApiOkResponse({ type: BankOperationDto })
|
||||
updateOperation(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateBankOperationDto,
|
||||
) {
|
||||
return this.cards.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.cards.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.cards.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.cards.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BankCardsController } from './bank-cards.controller';
|
||||
import { BankCardsService } from './bank-cards.service';
|
||||
@Module({ controllers: [BankCardsController], providers: [BankCardsService] })
|
||||
export class BankCardsModule {}
|
||||
@@ -0,0 +1,670 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
GoneException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
Prisma,
|
||||
BankCard,
|
||||
BankCardOperation,
|
||||
} from '../../generated/prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { parseRubMoney, rubMoney } from '../common/rub-money';
|
||||
import {
|
||||
bankBalance,
|
||||
bankSummary,
|
||||
isCalendarDate,
|
||||
moscowToday,
|
||||
} from './bank-card.calculations';
|
||||
import {
|
||||
BankCardDto,
|
||||
BankOperationDto,
|
||||
BankOperationsQueryDto,
|
||||
BankPageQueryDto,
|
||||
BankSummaryQueryDto,
|
||||
CreateBankCardDto,
|
||||
CreateBankOperationDto,
|
||||
UpdateBankCardDto,
|
||||
UpdateBankOperationDto,
|
||||
} from './bank-card.dto';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
const seeds = [
|
||||
'Продукты',
|
||||
'Кафе',
|
||||
'Такси',
|
||||
'Транспорт',
|
||||
'Жильё',
|
||||
'Здоровье',
|
||||
'Покупки',
|
||||
'Развлечения',
|
||||
'Другое',
|
||||
];
|
||||
const fail = (code = 'BANK_INVALID_INPUT'): never => {
|
||||
throw new BadRequestException({ code });
|
||||
};
|
||||
const conflict = (code = 'BANK_VERSION_CONFLICT'): never => {
|
||||
throw new ConflictException({ code });
|
||||
};
|
||||
const missing = (): never => {
|
||||
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
|
||||
};
|
||||
const fingerprint = (value: unknown) =>
|
||||
createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
typeof item === 'bigint' ? item.toString() : item,
|
||||
),
|
||||
)
|
||||
.digest('hex');
|
||||
function amount(value: Parameters<typeof parseRubMoney>[0], positive = false) {
|
||||
try {
|
||||
return parseRubMoney(value, positive);
|
||||
} catch {
|
||||
return fail('BANK_INVALID_MONEY');
|
||||
}
|
||||
}
|
||||
function calendar(value: string) {
|
||||
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
|
||||
return value;
|
||||
}
|
||||
function currentDate(value: string) {
|
||||
calendar(value);
|
||||
if (value > moscowToday()) fail('BANK_INVALID_DATE');
|
||||
}
|
||||
function name(value: string, max: number) {
|
||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
|
||||
fail();
|
||||
return value.trim();
|
||||
}
|
||||
function cardInput(dto: CreateBankCardDto) {
|
||||
const bankName = dto.bankName?.trim() || null;
|
||||
if (bankName && bankName.length > 100) fail();
|
||||
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
|
||||
return {
|
||||
name: name(dto.name, 100),
|
||||
bankName,
|
||||
lastFour: dto.lastFour || null,
|
||||
openingBalanceKopecks: amount(dto.openingBalance),
|
||||
openingDate: calendar(dto.openingDate),
|
||||
};
|
||||
}
|
||||
function operationInput(dto: CreateBankOperationDto) {
|
||||
if (
|
||||
dto.description != null &&
|
||||
(typeof dto.description !== 'string' || dto.description.length > 500)
|
||||
)
|
||||
fail();
|
||||
return {
|
||||
kind: dto.kind,
|
||||
cardId: dto.cardId,
|
||||
targetCardId: dto.targetCardId ?? null,
|
||||
categoryId: dto.categoryId ?? null,
|
||||
originalExpenseId: dto.originalExpenseId ?? null,
|
||||
amountKopecks: amount(dto.amount, true),
|
||||
date: calendar(dto.date),
|
||||
description: dto.description || null,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BankCardsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
private async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await this.prisma.$transaction(run, {
|
||||
maxWait: 10000,
|
||||
timeout: 15000,
|
||||
});
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof Prisma.PrismaClientKnownRequestError
|
||||
? error.code
|
||||
: '';
|
||||
const busy =
|
||||
code === 'P2034' ||
|
||||
code === 'P2028' ||
|
||||
(error instanceof Error &&
|
||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||
if ((busy || code === 'P2002') && attempt < 2) continue;
|
||||
if (busy) conflict('BANK_WRITE_CONFLICT');
|
||||
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
private async card(tx: Tx, userId: string, id: string, active = false) {
|
||||
const card = await tx.bankCard.findFirst({ where: { id, userId } });
|
||||
if (!card) return missing();
|
||||
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
|
||||
return card;
|
||||
}
|
||||
private async operation(tx: Tx, userId: string, id: string) {
|
||||
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
||||
if (!row) return missing();
|
||||
return row;
|
||||
}
|
||||
private async seed(tx: Tx, userId: string) {
|
||||
for (const [index, label] of seeds.entries()) {
|
||||
const seedKey = index === 8 ? 'other' : String(index);
|
||||
if (
|
||||
!(await tx.expenseCategory.findUnique({
|
||||
where: { userId_seedKey: { userId, seedKey } },
|
||||
}))
|
||||
)
|
||||
await tx.expenseCategory.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
seedKey,
|
||||
name: label,
|
||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: card.userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: card.id }, { targetCardId: card.id }],
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
bankName: card.bankName,
|
||||
lastFour: card.lastFour,
|
||||
openingDate: card.openingDate,
|
||||
openingBalance: rubMoney(card.openingBalanceKopecks),
|
||||
balance: rubMoney(bankBalance(card.id, card.openingBalanceKopecks, rows)),
|
||||
version: card.version,
|
||||
archivedAt: card.archivedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
private async operationView(
|
||||
tx: Tx,
|
||||
row: BankCardOperation,
|
||||
): Promise<BankOperationDto> {
|
||||
const card = await this.card(tx, row.userId, row.cardId);
|
||||
const target = row.targetCardId
|
||||
? await this.card(tx, row.userId, row.targetCardId)
|
||||
: null;
|
||||
const source = row.originalExpenseId
|
||||
? await this.operation(tx, row.userId, row.originalExpenseId)
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const category = categoryId
|
||||
? await tx.expenseCategory.findFirst({
|
||||
where: { id: categoryId, userId: row.userId },
|
||||
})
|
||||
: null;
|
||||
const refunds =
|
||||
row.kind === 'expense' || source
|
||||
? await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: row.userId,
|
||||
originalExpenseId: source?.id ?? row.id,
|
||||
deletedAt: null,
|
||||
...(source ? { id: { not: row.id } } : {}),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
cardId: row.cardId,
|
||||
cardName: card.name,
|
||||
targetCardId: row.targetCardId,
|
||||
targetCardName: target?.name ?? null,
|
||||
categoryId,
|
||||
categoryName: category?.name ?? null,
|
||||
originalExpenseId: row.originalExpenseId,
|
||||
amount: rubMoney(row.amountKopecks),
|
||||
availableRefund:
|
||||
row.kind === 'expense' || source
|
||||
? rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
|
||||
)
|
||||
: null,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
list(userId: string, includeArchived = false) {
|
||||
return this.prisma.$transaction(async (tx) =>
|
||||
Promise.all(
|
||||
(
|
||||
await tx.bankCard.findMany({
|
||||
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
).map((card) => this.cardView(tx, card)),
|
||||
),
|
||||
);
|
||||
}
|
||||
get(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) =>
|
||||
this.cardView(tx, await this.card(tx, userId, id)),
|
||||
);
|
||||
}
|
||||
createCard(userId: string, dto: CreateBankCardDto) {
|
||||
const input = cardInput(dto),
|
||||
hash = fingerprint(input);
|
||||
return this.write(async (tx) => {
|
||||
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
||||
if (existing) {
|
||||
if (existing.userId !== userId) missing();
|
||||
if (existing.createFingerprint !== hash)
|
||||
conflict('BANK_INTENT_CONFLICT');
|
||||
return this.cardView(tx, existing);
|
||||
}
|
||||
currentDate(input.openingDate);
|
||||
await this.seed(tx, userId);
|
||||
return this.cardView(
|
||||
tx,
|
||||
await tx.bankCard.create({
|
||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
|
||||
return this.write(async (tx) => {
|
||||
const previous = await this.card(tx, userId, id);
|
||||
if (previous.version !== dto.version) conflict();
|
||||
const input = cardInput({
|
||||
id,
|
||||
name: dto.name ?? previous.name,
|
||||
bankName:
|
||||
dto.bankName === undefined
|
||||
? (previous.bankName ?? undefined)
|
||||
: (dto.bankName ?? undefined),
|
||||
lastFour:
|
||||
dto.lastFour === undefined
|
||||
? (previous.lastFour ?? undefined)
|
||||
: (dto.lastFour ?? undefined),
|
||||
openingBalance:
|
||||
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
|
||||
openingDate: dto.openingDate ?? previous.openingDate,
|
||||
});
|
||||
const financial =
|
||||
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
|
||||
input.openingDate !== previous.openingDate;
|
||||
if (financial && previous.archivedAt) conflict('BANK_CARD_ARCHIVED');
|
||||
currentDate(input.openingDate);
|
||||
if (
|
||||
await tx.bankCardOperation.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: { lt: input.openingDate },
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
},
|
||||
})
|
||||
)
|
||||
fail('BANK_OPENING_DATE_CONFLICT');
|
||||
const changed = await tx.bankCard.updateMany({
|
||||
where: { id, userId, version: dto.version },
|
||||
data: {
|
||||
...input,
|
||||
version: { increment: 1 },
|
||||
...(dto.archived === undefined
|
||||
? {}
|
||||
: {
|
||||
archivedAt: dto.archived
|
||||
? (previous.archivedAt ?? new Date())
|
||||
: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) conflict();
|
||||
return this.cardView(tx, await this.card(tx, userId, id));
|
||||
});
|
||||
}
|
||||
async categories(userId: string) {
|
||||
return (
|
||||
await this.prisma.expenseCategory.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
).map(({ id, name, seedKey, version }) => ({
|
||||
id,
|
||||
name,
|
||||
isDefault: seedKey === 'other',
|
||||
version,
|
||||
}));
|
||||
}
|
||||
createCategory(userId: string, value: string) {
|
||||
const label = name(value, 80);
|
||||
return this.write(async (tx) => {
|
||||
await this.seed(tx, userId);
|
||||
const row = await tx.expenseCategory.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
name: label,
|
||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: false,
|
||||
version: row.version,
|
||||
};
|
||||
});
|
||||
}
|
||||
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||
const label = name(value, 80);
|
||||
return this.write(async (tx) => {
|
||||
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
|
||||
if (!row) missing();
|
||||
if (
|
||||
(
|
||||
await tx.expenseCategory.updateMany({
|
||||
where: { id, userId, version },
|
||||
data: {
|
||||
name: label,
|
||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||
version: { increment: 1 },
|
||||
},
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
return {
|
||||
id,
|
||||
name: label,
|
||||
isDefault: row!.seedKey === 'other',
|
||||
version: version + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
private async validateOperation(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
input: ReturnType<typeof operationInput>,
|
||||
old?: BankCardOperation,
|
||||
) {
|
||||
const cards = new Set(
|
||||
[input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter(
|
||||
(id): id is string => Boolean(id),
|
||||
),
|
||||
);
|
||||
currentDate(input.date);
|
||||
for (const id of cards) {
|
||||
const card = await this.card(tx, userId, id, true);
|
||||
if (
|
||||
(id === input.cardId || id === input.targetCardId) &&
|
||||
input.date < card.openingDate
|
||||
)
|
||||
fail('BANK_INVALID_DATE');
|
||||
}
|
||||
if (
|
||||
old &&
|
||||
(old.kind !== input.kind ||
|
||||
old.originalExpenseId !== input.originalExpenseId)
|
||||
)
|
||||
fail('BANK_IMMUTABLE_OPERATION');
|
||||
if (input.kind === 'transfer') {
|
||||
if (
|
||||
!input.targetCardId ||
|
||||
input.targetCardId === input.cardId ||
|
||||
input.categoryId ||
|
||||
input.originalExpenseId
|
||||
)
|
||||
fail();
|
||||
} else if (input.targetCardId) fail();
|
||||
if (input.kind === 'expense') {
|
||||
if (input.originalExpenseId) fail();
|
||||
if (!input.categoryId) {
|
||||
const category = await tx.expenseCategory.findUnique({
|
||||
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||
});
|
||||
if (!category) missing();
|
||||
input.categoryId = category!.id;
|
||||
}
|
||||
if (
|
||||
!(await tx.expenseCategory.findFirst({
|
||||
where: { userId, id: input.categoryId },
|
||||
}))
|
||||
)
|
||||
missing();
|
||||
} else if (input.categoryId) fail();
|
||||
if (input.kind !== 'refund' && input.originalExpenseId) fail();
|
||||
if (input.kind === 'refund') {
|
||||
if (!input.originalExpenseId) fail();
|
||||
const source = await this.operation(tx, userId, input.originalExpenseId!);
|
||||
if (
|
||||
source.deletedAt ||
|
||||
source.kind !== 'expense' ||
|
||||
source.cardId !== input.cardId
|
||||
)
|
||||
fail('BANK_INVALID_REFUND');
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: {
|
||||
id: source.id,
|
||||
userId,
|
||||
version: source.version,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
originalExpenseId: source.id,
|
||||
deletedAt: null,
|
||||
...(old ? { id: { not: old.id } } : {}),
|
||||
},
|
||||
});
|
||||
if (input.date < source.date) fail('BANK_INVALID_DATE');
|
||||
if (
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
|
||||
input.amountKopecks >
|
||||
source.amountKopecks
|
||||
)
|
||||
conflict('BANK_REFUND_LIMIT');
|
||||
}
|
||||
if (old?.kind === 'expense') {
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
||||
});
|
||||
if (
|
||||
refunds.length &&
|
||||
(input.cardId !== old.cardId ||
|
||||
refunds.some((row) => row.date < input.date) ||
|
||||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
|
||||
input.amountKopecks)
|
||||
)
|
||||
conflict('BANK_DEPENDENT_REFUNDS');
|
||||
}
|
||||
}
|
||||
createOperation(userId: string, dto: CreateBankOperationDto) {
|
||||
const normalized = operationInput(dto),
|
||||
hash = fingerprint(normalized);
|
||||
return this.write(async (tx) => {
|
||||
const existing = await tx.bankCardOperation.findUnique({
|
||||
where: { id: dto.id },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.userId !== userId) missing();
|
||||
if (existing.createFingerprint !== hash)
|
||||
conflict('BANK_INTENT_CONFLICT');
|
||||
if (existing.deletedAt)
|
||||
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
|
||||
return this.operationView(tx, existing);
|
||||
}
|
||||
const input = { ...normalized };
|
||||
await this.validateOperation(tx, userId, input);
|
||||
return this.operationView(
|
||||
tx,
|
||||
await tx.bankCardOperation.create({
|
||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
getOperation(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const row = await this.operation(tx, userId, id);
|
||||
if (row.deletedAt) missing();
|
||||
return this.operationView(tx, row);
|
||||
});
|
||||
}
|
||||
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
||||
return this.write(async (tx) => {
|
||||
const old = await this.operation(tx, userId, id);
|
||||
if (old.deletedAt) missing();
|
||||
if (old.version !== dto.version) conflict();
|
||||
const input = operationInput({
|
||||
id,
|
||||
kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']),
|
||||
cardId: dto.cardId ?? old.cardId,
|
||||
targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined,
|
||||
categoryId: dto.categoryId ?? old.categoryId ?? undefined,
|
||||
originalExpenseId:
|
||||
dto.originalExpenseId ?? old.originalExpenseId ?? undefined,
|
||||
amount: dto.amount ?? rubMoney(old.amountKopecks),
|
||||
date: dto.date ?? old.date,
|
||||
description: dto.description ?? old.description ?? undefined,
|
||||
});
|
||||
await this.validateOperation(tx, userId, input, old);
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id, userId, version: dto.version, deletedAt: null },
|
||||
data: { ...input, version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
return this.operationView(tx, await this.operation(tx, userId, id));
|
||||
});
|
||||
}
|
||||
deleteOperation(userId: string, id: string, version: number) {
|
||||
return this.write(async (tx) => {
|
||||
const row = await this.operation(tx, userId, id);
|
||||
if (row.deletedAt) return;
|
||||
if (row.version !== version) conflict();
|
||||
await this.card(tx, userId, row.cardId, true);
|
||||
if (row.targetCardId) await this.card(tx, userId, row.targetCardId, true);
|
||||
if (
|
||||
row.kind === 'expense' &&
|
||||
(await tx.bankCardOperation.findFirst({
|
||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
||||
}))
|
||||
)
|
||||
conflict('BANK_DEPENDENT_REFUNDS');
|
||||
if (row.originalExpenseId) {
|
||||
const source = await this.operation(tx, userId, row.originalExpenseId);
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id: source.id, userId, version: source.version },
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
}
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id, userId, version, deletedAt: null },
|
||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
conflict();
|
||||
});
|
||||
}
|
||||
history(
|
||||
userId: string,
|
||||
id: string,
|
||||
query: BankOperationsQueryDto | BankPageQueryDto,
|
||||
expenses = false,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.card(tx, userId, id);
|
||||
const page = query.page ?? 1,
|
||||
limit = query.limit ?? 50;
|
||||
const where: Prisma.BankCardOperationWhereInput = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
...(expenses
|
||||
? { kind: 'expense' }
|
||||
: { date: { startsWith: (query as BankOperationsQueryDto).month } }),
|
||||
};
|
||||
const total = await tx.bankCardOperation.count({ where });
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return {
|
||||
items: await Promise.all(
|
||||
rows.map((row) => this.operationView(tx, row)),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
hasNext: page * limit < total,
|
||||
};
|
||||
});
|
||||
}
|
||||
summary(userId: string, query: BankSummaryQueryDto) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (query.cardId) await this.card(tx, userId, query.cardId);
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: { startsWith: query.month },
|
||||
kind: { in: ['expense', 'refund'] },
|
||||
...(query.cardId ? { cardId: query.cardId } : {}),
|
||||
},
|
||||
include: { originalExpense: { select: { categoryId: true } } },
|
||||
});
|
||||
const totals = bankSummary(
|
||||
query.month,
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
|
||||
})),
|
||||
);
|
||||
const categories = await tx.expenseCategory.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
return {
|
||||
month: query.month,
|
||||
cardId: query.cardId ?? null,
|
||||
expenses: rubMoney(totals.expenses),
|
||||
refunds: rubMoney(totals.refunds),
|
||||
net: rubMoney(totals.net),
|
||||
categories: totals.categories.map((row) => ({
|
||||
categoryId: row.categoryId,
|
||||
categoryName: categories.find((c) => c.id === row.categoryId)!.name,
|
||||
expenses: rubMoney(row.expenses),
|
||||
refunds: rubMoney(row.refunds),
|
||||
net: rubMoney(row.net),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type Decimal = { value: bigint; scale: number };
|
||||
|
||||
export function decimalFromString(value: string): Decimal {
|
||||
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
|
||||
throw new Error('Invalid decimal string');
|
||||
const negative = value.startsWith('-');
|
||||
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
||||
return {
|
||||
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
||||
scale: fraction.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function add(left: Decimal, right: Decimal): Decimal {
|
||||
const scale = Math.max(left.scale, right.scale);
|
||||
return {
|
||||
value:
|
||||
left.value * 10n ** BigInt(scale - left.scale) +
|
||||
right.value * 10n ** BigInt(scale - right.scale),
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function subtract(left: Decimal, right: Decimal): Decimal {
|
||||
return add(left, { value: -right.value, scale: right.scale });
|
||||
}
|
||||
|
||||
export function multiply(left: Decimal, right: Decimal): Decimal {
|
||||
return { value: left.value * right.value, scale: left.scale + right.scale };
|
||||
}
|
||||
|
||||
export function isZero(value: Decimal): boolean {
|
||||
return value.value === 0n;
|
||||
}
|
||||
|
||||
export function compare(left: Decimal, right: Decimal): number {
|
||||
const difference = subtract(left, right).value;
|
||||
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
|
||||
}
|
||||
|
||||
export function formatDecimal({ value, scale }: Decimal): string {
|
||||
if (value === 0n) return '0';
|
||||
const sign = value < 0n ? '-' : '';
|
||||
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
|
||||
const digits = (value < 0n ? -value : value)
|
||||
.toString()
|
||||
.padStart(scale + 1, '0');
|
||||
const whole = digits.slice(0, -scale) || '0';
|
||||
const fraction = digits.slice(-scale).replace(/0+$/, '');
|
||||
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
|
||||
}
|
||||
|
||||
// Round half away from zero; callers choose presentation precision explicitly.
|
||||
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
||||
if (right.value === 0n) throw new Error('Division by zero');
|
||||
const numerator = left.value * 10n ** BigInt(right.scale + scale);
|
||||
const denominator = right.value * 10n ** BigInt(left.scale);
|
||||
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
|
||||
const n = numerator < 0n ? -numerator : numerator;
|
||||
const d = denominator < 0n ? -denominator : denominator;
|
||||
return { value: sign * ((n + d / 2n) / d), scale };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MoneyDto {
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { parseRubMoney, rubMoney } from './rub-money';
|
||||
|
||||
describe('RUB money', () => {
|
||||
it.each([
|
||||
['0', 0n],
|
||||
['-100', -10000n],
|
||||
['0.01', 1n],
|
||||
['123.4', 12340n],
|
||||
['90071992547409.93', 9007199254740993n],
|
||||
['92233720368547758.07', 2n ** 63n - 1n],
|
||||
['-92233720368547758.08', -(2n ** 63n)],
|
||||
])('roundtrips %s exactly', (value, kopecks) => {
|
||||
expect(parseRubMoney({ currency: 'RUB', value })).toBe(kopecks);
|
||||
expect(rubMoney(kopecks)).toEqual({ currency: 'RUB', value });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'92233720368547758.08',
|
||||
'-92233720368547758.09',
|
||||
'1.001',
|
||||
'1.000',
|
||||
'NaN',
|
||||
'Infinity',
|
||||
'1e2',
|
||||
'1,23',
|
||||
'',
|
||||
' 1',
|
||||
'1\n',
|
||||
'01',
|
||||
])('rejects invalid input %s without rounding', (value) => {
|
||||
expect(() => parseRubMoney({ currency: 'RUB', value })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects other currencies and non-string values', () => {
|
||||
expect(() => parseRubMoney({ currency: 'USD', value: '1' })).toThrow();
|
||||
expect(() =>
|
||||
parseRubMoney({ currency: 'RUB', value: 1 as unknown as string }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('requires positive operation amounts, including at the maximum', () => {
|
||||
for (const value of ['0', '-0.00', '-0.01'])
|
||||
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
|
||||
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
|
||||
expect(
|
||||
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
|
||||
).toBe(2n ** 63n - 1n);
|
||||
});
|
||||
|
||||
it('formats computed sums beyond the storage range without losing precision', () => {
|
||||
expect(rubMoney(2n * (2n ** 63n - 1n))).toEqual({
|
||||
currency: 'RUB',
|
||||
value: '184467440737095516.14',
|
||||
});
|
||||
expect(rubMoney(-1n)).toEqual({ currency: 'RUB', value: '-0.01' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { decimalFromString, formatDecimal } from './decimal';
|
||||
import { MoneyDto } from './money.dto';
|
||||
|
||||
export function parseRubMoney(money: MoneyDto, positive = false): bigint {
|
||||
if (
|
||||
money?.currency !== 'RUB' ||
|
||||
typeof money.value !== 'string' ||
|
||||
money.value !== money.value.trim()
|
||||
)
|
||||
throw new TypeError('Expected RUB money with a decimal string');
|
||||
const amount = decimalFromString(money.value);
|
||||
if (amount.scale > 2)
|
||||
throw new RangeError('RUB money supports at most two decimal places');
|
||||
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
|
||||
if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n)
|
||||
throw new RangeError('RUB money is outside the signed 64-bit range');
|
||||
if (positive && kopecks <= 0n)
|
||||
throw new RangeError('Operation amount must be positive');
|
||||
return kopecks;
|
||||
}
|
||||
|
||||
export function rubMoney(kopecks: bigint): MoneyDto {
|
||||
return {
|
||||
currency: 'RUB',
|
||||
value: formatDecimal({ value: kopecks, scale: 2 }),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
|
||||
import {
|
||||
StockSearchQueryDto,
|
||||
StockTickerParamsDto,
|
||||
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import {
|
||||
decimal,
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
subtract,
|
||||
add,
|
||||
} from './decimal';
|
||||
} from '../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||
import { MoexClient } from './moex.client';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||
import { decimal, isZero } from './decimal';
|
||||
import { isZero } from '../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
import { totalsByCurrency } from './portfolio-totals';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
|
||||
import { AccountReferenceDto } from './account-reference.dto';
|
||||
|
||||
export const operationCategories = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { decimal, formatDecimal } from './decimal';
|
||||
import { formatDecimal } from '../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
import {
|
||||
BrokerageOperationDto,
|
||||
OperationCategory,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { add, decimal, formatDecimal, multiply, subtract } from './decimal';
|
||||
import { add, formatDecimal, multiply, subtract } from '../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
|
||||
describe('decimal', () => {
|
||||
it('keeps signed nanos exact without floats', () => {
|
||||
|
||||
+2
-62
@@ -1,18 +1,8 @@
|
||||
import { Decimal } from '../common/decimal';
|
||||
|
||||
const NANO = 1_000_000_000n;
|
||||
|
||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||
export type Decimal = { value: bigint; scale: number };
|
||||
|
||||
export function decimalFromString(value: string): Decimal {
|
||||
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
|
||||
throw new Error('Invalid decimal string');
|
||||
const negative = value.startsWith('-');
|
||||
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
||||
return {
|
||||
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
||||
scale: fraction.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function decimal(value: ProtoDecimal): Decimal {
|
||||
return {
|
||||
@@ -20,53 +10,3 @@ export function decimal(value: ProtoDecimal): Decimal {
|
||||
scale: 9,
|
||||
};
|
||||
}
|
||||
|
||||
export function add(left: Decimal, right: Decimal): Decimal {
|
||||
const scale = Math.max(left.scale, right.scale);
|
||||
return {
|
||||
value:
|
||||
left.value * 10n ** BigInt(scale - left.scale) +
|
||||
right.value * 10n ** BigInt(scale - right.scale),
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function subtract(left: Decimal, right: Decimal): Decimal {
|
||||
return add(left, { value: -right.value, scale: right.scale });
|
||||
}
|
||||
|
||||
export function multiply(left: Decimal, right: Decimal): Decimal {
|
||||
return { value: left.value * right.value, scale: left.scale + right.scale };
|
||||
}
|
||||
|
||||
export function isZero(value: Decimal): boolean {
|
||||
return value.value === 0n;
|
||||
}
|
||||
|
||||
export function compare(left: Decimal, right: Decimal): number {
|
||||
const difference = subtract(left, right).value;
|
||||
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
|
||||
}
|
||||
|
||||
export function formatDecimal({ value, scale }: Decimal): string {
|
||||
if (value === 0n) return '0';
|
||||
const sign = value < 0n ? '-' : '';
|
||||
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
|
||||
const digits = (value < 0n ? -value : value)
|
||||
.toString()
|
||||
.padStart(scale + 1, '0');
|
||||
const whole = digits.slice(0, -scale) || '0';
|
||||
const fraction = digits.slice(-scale).replace(/0+$/, '');
|
||||
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
|
||||
}
|
||||
|
||||
// Round half away from zero; callers choose presentation precision explicitly.
|
||||
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
||||
if (right.value === 0n) throw new Error('Division by zero');
|
||||
const numerator = left.value * 10n ** BigInt(right.scale + scale);
|
||||
const denominator = right.value * 10n ** BigInt(left.scale);
|
||||
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
|
||||
const n = numerator < 0n ? -numerator : numerator;
|
||||
const d = denominator < 0n ? -denominator : denominator;
|
||||
return { value: sign * ((n + d / 2n) / d), scale };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, Matches } from 'class-validator';
|
||||
import { BrokerageOperationDto } from './brokerage-operation.dto';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
subtract,
|
||||
} from './decimal';
|
||||
} from '../common/decimal';
|
||||
|
||||
import {
|
||||
DateOnlyRange,
|
||||
IncomeBucketGranularity,
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from './decimal';
|
||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||
} from '../common/decimal';
|
||||
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||
import { StockPositionDto } from './stock.dto';
|
||||
|
||||
export function instrumentPosition(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { add, decimalFromString, formatDecimal } from './decimal';
|
||||
import { add, decimalFromString, formatDecimal } from '../common/decimal';
|
||||
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
import {
|
||||
MoneyDto,
|
||||
PortfolioPositionDto,
|
||||
PortfolioStructureDto,
|
||||
PortfolioStructureCategoryDto,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
add,
|
||||
decimal,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
} from './decimal';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
} from '../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
|
||||
import { TbankPortfolio, TbankPositions } from './tbank.client';
|
||||
|
||||
function totalsByCurrency(
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AccountReferenceDto } from './account-reference.dto';
|
||||
|
||||
export class MoneyDto {
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class PortfolioPositionDto {
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
instrumentUid?: string | null;
|
||||
|
||||
@@ -2,15 +2,16 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { RequestContextService } from '../common/logging/request-context.service';
|
||||
import {
|
||||
add,
|
||||
decimal,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
subtract,
|
||||
} from './decimal';
|
||||
} from '../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
|
||||
import { portfolioStructure } from './portfolio-structure';
|
||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankConnectionContext,
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
Matches,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { MoneyDto, PortfolioPositionDto } from './portfolio.dto';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
import { PortfolioPositionDto } from './portfolio.dto';
|
||||
|
||||
export class StockSearchQueryDto {
|
||||
@ApiProperty({ minLength: 2, maxLength: 80 })
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import {
|
||||
decimal,
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
subtract,
|
||||
} from './decimal';
|
||||
} from '../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { stockPosition } from './stock-position';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, Matches } from 'class-validator';
|
||||
import { AccountReferenceDto } from './account-reference.dto';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
|
||||
export class UpcomingEventsQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
|
||||
@@ -3,14 +3,15 @@ import { RequestContextService } from '../common/logging/request-context.service
|
||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
||||
import {
|
||||
add,
|
||||
decimal,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
} from './decimal';
|
||||
} from '../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
import { MoexBondEvent, MoexClient } from './moex.client';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
import { MoneyDto } from '../common/money.dto';
|
||||
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import {
|
||||
TbankClient,
|
||||
|
||||
Reference in New Issue
Block a user