diff --git a/README.md b/README.md index 24b1e62..0312026 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,15 @@ threshold. `npm run test:e2e` пока не является passing gate: Prism engine останавливает migration до запуска Jest. Точный статус и trigger пересмотра находятся в [remediation registry](../docs/development/audit-remediation.md). +Общий `tsconfig.json` включает `strict: true` и `noFallthroughCasesInSwitch` +для приложения, тестов и сборок. ESLint запрещает non-null assertions +(`value!`) и definite-assignment assertions (`field!: Type`). +Поля DTO, заполняемые вне конструктора, описываются через `declare`: +входные значения заполняет и проверяет Nest, ответные DTO описывают форму +возвращаемых объектов. `declare` не создаёт значение и не заменяет +runtime-валидацию. В обычном коде нужны инициализация или сужение типа +после проверки значения. + ## Документация - [Router conventions](.ai/CONVENTIONS.md) и [источники](.ai/SOURCES.md) diff --git a/eslint.config.mjs b/eslint.config.mjs index 0abea89..1f292a7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,6 +27,16 @@ export default tseslint.config( { rules: { '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'error', + 'no-restricted-syntax': [ + 'error', + { + selector: + 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', + message: + 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', + }, + ], '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', "prettier/prettier": ["error", { endOfLine: "auto" }], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 3f52899..80079e3 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -28,18 +28,18 @@ const COOKIE_PATH = '/api/v1/auth'; class UserDto { @ApiProperty() - id: string; + declare id: string; @ApiProperty() - email: string; + declare email: string; } class AuthenticationDto { @ApiProperty() - accessToken: string; + declare accessToken: string; @ApiProperty({ type: UserDto }) - user: UserDto; + declare user: UserDto; } @ApiTags('auth') diff --git a/src/auth/auth.dto.ts b/src/auth/auth.dto.ts index 3902d0f..7597a05 100644 --- a/src/auth/auth.dto.ts +++ b/src/auth/auth.dto.ts @@ -47,11 +47,11 @@ export class CredentialsDto { typeof value === 'string' ? value.trim().toLowerCase() : value, ) @Validate(EmailConstraint) - email: string; + declare email: string; @ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' }) @IsString() @MinLength(12) @Validate(PasswordBytesConstraint) - password: string; + declare password: string; } diff --git a/src/bank-cards/bank-card.dto.ts b/src/bank-cards/bank-card.dto.ts index e79c7c4..7aa77ad 100644 --- a/src/bank-cards/bank-card.dto.ts +++ b/src/bank-cards/bank-card.dto.ts @@ -35,20 +35,20 @@ export const bankOperationKinds = [ ] as const; export type BankOperationKind = (typeof bankOperationKinds)[number]; export class RubMoneyInputDto { - @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) currency: string; + @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @IsString() @Matches(/^-?\d+(\.\d{1,2})?$/) - value: string; + declare value: string; } export class CreateBankCardDto { - @ApiProperty({ format: 'uuid' }) @IsUUID() id: string; + @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @ApiProperty({ minLength: 1, maxLength: 100 }) @Transform(trim) @IsString() @MinLength(1) @MaxLength(100) - name: string; + declare name: string; @ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 }) @IsOptional() @Transform(trim) @@ -67,17 +67,17 @@ export class CreateBankCardDto { @IsDefined() @ValidateNested() @Type(() => RubMoneyInputDto) - openingBalance: MoneyDto; + declare openingBalance: MoneyDto; @ApiProperty({ format: 'date' }) @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) - openingDate: string; + declare openingDate: string; } export class UpdateBankCardDto extends PartialType( OmitType(CreateBankCardDto, ['id'] as const), { skipNullProperties: false }, ) { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number; + @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; @ApiPropertyOptional() @ValidateIf((_object, value: unknown) => value !== undefined) @IsBoolean() @@ -89,17 +89,17 @@ export class CategoryNameDto { @IsString() @MinLength(1) @MaxLength(80) - name: string; + declare name: string; } export class UpdateExpenseCategoryDto extends CategoryNameDto { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number; + @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; } export class CreateBankOperationDto { - @ApiProperty({ format: 'uuid' }) @IsUUID() id: string; + @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @ApiProperty({ enum: bankOperationKinds }) @IsIn(bankOperationKinds) - kind: BankOperationKind; - @ApiProperty({ format: 'uuid' }) @IsUUID() cardId: string; + declare kind: BankOperationKind; + @ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string; @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() @@ -116,11 +116,11 @@ export class CreateBankOperationDto { @IsDefined() @ValidateNested() @Type(() => RubMoneyInputDto) - amount: MoneyDto; + declare amount: MoneyDto; @ApiProperty({ format: 'date' }) @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) - date: string; + declare date: string; @ApiPropertyOptional({ maxLength: 500 }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsString() @@ -131,7 +131,7 @@ export class UpdateBankOperationDto extends PartialType( OmitType(CreateBankOperationDto, ['id'] as const), { skipNullProperties: false }, ) { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number; + @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; } export class BankCardsQueryDto { @ApiPropertyOptional({ enum: ['true', 'false'] }) @@ -192,68 +192,76 @@ export class BankVersionQueryDto { @Type(() => Number) @IsInt() @Min(1) - version: number; + declare 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; + @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 }) id: string; - @ApiProperty({ type: String }) name: string; - @ApiProperty({ type: String, nullable: true }) bankName: string | null; - @ApiProperty({ type: String, nullable: true }) lastFour: string | null; - @ApiProperty({ enum: bankPaymentSystems, nullable: true }) paymentSystem: + @ApiProperty({ type: String }) declare id: string; + @ApiProperty({ type: String }) declare name: string; + @ApiProperty({ type: String, nullable: true }) declare bankName: 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; + @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 }) 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: + @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: MoneyDto }) amount: MoneyDto; + @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 }) - 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; + 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 }) - items: BankOperationDto[]; - @ApiProperty({ type: Number }) page: number; - @ApiProperty({ type: Number }) total: number; - @ApiProperty({ type: Boolean }) hasNext: boolean; + 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 }) categoryId: string; - @ApiProperty({ type: String }) categoryName: string; - @ApiProperty({ type: MoneyDto }) expenses: MoneyDto; - @ApiProperty({ type: MoneyDto }) refunds: MoneyDto; - @ApiProperty({ type: MoneyDto }) net: MoneyDto; + @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 }) cardId: string | null; - @ApiProperty({ type: MoneyDto }) expenses: MoneyDto; - @ApiProperty({ type: MoneyDto }) refunds: MoneyDto; - @ApiProperty({ type: MoneyDto }) net: MoneyDto; + @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 }) - categories: BankSummaryCategoryDto[]; + declare categories: BankSummaryCategoryDto[]; } diff --git a/src/bank-cards/bank-cards.service.spec.ts b/src/bank-cards/bank-cards.service.spec.ts new file mode 100644 index 0000000..2932736 --- /dev/null +++ b/src/bank-cards/bank-cards.service.spec.ts @@ -0,0 +1,45 @@ +jest.mock('../prisma/prisma.service', () => ({ PrismaService: class {} })); +jest.mock('../../generated/prisma/client', () => ({ Prisma: {} })); +import { PrismaService } from '../prisma/prisma.service'; +import { BankCardsService } from './bank-cards.service'; + +describe('bank card summary categories', () => { + it('resolves category names and rejects an inconsistent category reference', async () => { + const categories = jest + .fn() + .mockResolvedValue([{ id: 'food', name: 'Продукты' }]); + const tx = { + bankCardOperation: { + findMany: jest.fn().mockResolvedValue([ + { + kind: 'expense', + categoryId: 'food', + amountKopecks: 123n, + date: '2026-09-13', + originalExpense: null, + }, + ]), + }, + expenseCategory: { findMany: categories }, + }; + const service = new BankCardsService({ + $transaction: (run: (client: typeof tx) => unknown) => run(tx), + } as unknown as PrismaService); + + await expect( + service.summary('user', { month: '2026-09' }), + ).resolves.toMatchObject({ + categories: [ + { + categoryId: 'food', + categoryName: 'Продукты', + net: { value: '1.23' }, + }, + ], + }); + categories.mockResolvedValue([]); + await expect(service.summary('user', { month: '2026-09' })).rejects.toThrow( + 'Bank operation category is missing', + ); + }); +}); diff --git a/src/bank-cards/bank-cards.service.ts b/src/bank-cards/bank-cards.service.ts index ec1d9b0..cd60a10 100644 --- a/src/bank-cards/bank-cards.service.ts +++ b/src/bank-cards/bank-cards.service.ts @@ -45,9 +45,9 @@ const seeds = [ 'Развлечения', 'Другое', ]; -const fail = (code = 'BANK_INVALID_INPUT'): never => { +function fail(code = 'BANK_INVALID_INPUT'): never { throw new BadRequestException({ code }); -}; +} function periodBounds(query: BankPeriodQueryDto) { if (query.month && !query.from && !query.to) { const from = `${query.month}-01`; @@ -67,12 +67,12 @@ function periodBounds(query: BankPeriodQueryDto) { return fail(); return { from: query.from, to: query.to }; } -const conflict = (code = 'BANK_VERSION_CONFLICT'): never => { +function conflict(code = 'BANK_VERSION_CONFLICT'): never { throw new ConflictException({ code }); -}; -const missing = (): never => { +} +function missing(): never { throw new NotFoundException({ code: 'BANK_NOT_FOUND' }); -}; +} const fingerprint = (value: unknown) => createHash('sha256') .update( @@ -417,7 +417,7 @@ export class BankCardsService { return { id, name: label, - isDefault: row!.seedKey === 'other', + isDefault: row.seedKey === 'other', version: version + 1, }; }); @@ -464,7 +464,7 @@ export class BankCardsService { where: { userId_seedKey: { userId, seedKey: 'other' } }, }); if (!category) missing(); - input.categoryId = category!.id; + input.categoryId = category.id; } if ( !(await tx.expenseCategory.findFirst({ @@ -476,7 +476,7 @@ export class BankCardsService { if (input.kind !== 'refund' && input.originalExpenseId) fail(); if (input.kind === 'refund') { if (!input.originalExpenseId) fail(); - const source = await this.operation(tx, userId, input.originalExpenseId!); + const source = await this.operation(tx, userId, input.originalExpenseId); if ( source.deletedAt || source.kind !== 'expense' || @@ -643,9 +643,9 @@ export class BankCardsService { userId, deletedAt: null, OR: [{ cardId: id }, { targetCardId: id }], - ...(expenses - ? { kind: 'expense' } - : { date: { gte: period!.from, lte: period!.to } }), + ...(period + ? { date: { gte: period.from, lte: period.to } } + : { kind: 'expense' }), }; const total = await tx.bankCardOperation.count({ where }); const rows = await tx.bankCardOperation.findMany({ @@ -695,13 +695,17 @@ export class BankCardsService { 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), - })), + categories: totals.categories.map((row) => { + const category = categories.find((c) => c.id === row.categoryId); + if (!category) throw new Error('Bank operation category is missing'); + return { + categoryId: row.categoryId, + categoryName: category.name, + expenses: rubMoney(row.expenses), + refunds: rubMoney(row.refunds), + net: rubMoney(row.net), + }; + }), }; }); } diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index a490415..3b97dca 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -8,10 +8,10 @@ import { export class ProblemDetailsDto { @ApiProperty({ example: 401 }) - status: number; + declare status: number; @ApiProperty({ example: 'UNAUTHORIZED' }) - code: string; + declare code: string; @ApiProperty({ required: false, diff --git a/src/common/dto-validation.spec.ts b/src/common/dto-validation.spec.ts new file mode 100644 index 0000000..160ddf4 --- /dev/null +++ b/src/common/dto-validation.spec.ts @@ -0,0 +1,61 @@ +import { BadRequestException, ValidationPipe } from '@nestjs/common'; +import { CredentialsDto } from '../auth/auth.dto'; +import { + CreateBankCardDto, + UpdateBankCardDto, +} from '../bank-cards/bank-card.dto'; + +describe('DTO validation', () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + + it('requires credentials and normalizes a valid email', async () => { + const metadata = { type: 'body' as const, metatype: CredentialsDto }; + await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect( + pipe.transform({ email: 'user@example.com' }, metadata), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + pipe.transform( + { email: ' User@Example.com ', password: 'long-enough-password' }, + metadata, + ), + ).resolves.toEqual({ + email: 'user@example.com', + password: 'long-enough-password', + }); + }); + + it('requires and validates nested money in a new card', async () => { + const metadata = { type: 'body' as const, metatype: CreateBankCardDto }; + const card = { + id: '00000000-0000-4000-8000-000000000001', + name: 'Everyday', + openingDate: '2026-09-13', + }; + for (const openingBalance of [undefined, null, {}, { currency: 'RUB' }]) { + await expect( + pipe.transform({ ...card, openingBalance }, metadata), + ).rejects.toBeInstanceOf(BadRequestException); + } + const valid = { + ...card, + openingBalance: { currency: 'RUB', value: '100.00' }, + }; + await expect(pipe.transform(valid, metadata)).resolves.toEqual(valid); + }); + + it('keeps update fields optional while requiring version and rejecting null', async () => { + const metadata = { type: 'body' as const, metatype: UpdateBankCardDto }; + await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect( + pipe.transform({ version: 1, name: null }, metadata), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + pipe.transform({ version: 1 }, metadata), + ).resolves.toMatchObject({ version: 1 }); + }); +}); diff --git a/src/common/money.dto.ts b/src/common/money.dto.ts index 1dd816e..b35b82b 100644 --- a/src/common/money.dto.ts +++ b/src/common/money.dto.ts @@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; export class MoneyDto { @ApiProperty() - currency: string; + declare currency: string; @ApiProperty() - value: string; + declare value: string; } diff --git a/src/tbank/account-reference.dto.ts b/src/tbank/account-reference.dto.ts index 591493e..02148ef 100644 --- a/src/tbank/account-reference.dto.ts +++ b/src/tbank/account-reference.dto.ts @@ -3,8 +3,8 @@ import { ApiProperty } from '@nestjs/swagger'; /** Source account of a row in an all-accounts response. */ export class AccountReferenceDto { @ApiProperty() - id: string; + declare id: string; @ApiProperty() - name: string; + declare name: string; } diff --git a/src/tbank/all-accounts.spec.ts b/src/tbank/all-accounts.spec.ts index 45f5b0b..10f554c 100644 --- a/src/tbank/all-accounts.spec.ts +++ b/src/tbank/all-accounts.spec.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { BadRequestException, ConflictException, @@ -130,6 +131,7 @@ describe('All accounts', () => { instrumentUid: 'common', limit: 1, }); + assert.ok(first.nextCursor); expect(first.items).toHaveLength(1); expect(first.items[0].instrumentUid).toBe('common'); expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( @@ -143,13 +145,13 @@ describe('All accounts', () => { operations.getAll('user', { instrumentUid: 'preferred', limit: 1, - cursor: first.nextCursor!, + cursor: first.nextCursor, }), ).rejects.toBeInstanceOf(BadRequestException); const second = await operations.getAll('user', { instrumentUid: 'common', limit: 1, - cursor: first.nextCursor!, + cursor: first.nextCursor, }); expect(second.items[0].instrumentUid).toBe('common'); expect(second.items[0].id).not.toBe(first.items[0].id); @@ -246,17 +248,19 @@ describe('All accounts', () => { }, ); const first = await operations.getAll('user', query); + assert.ok(first.nextCursor); const second = await operations.getAll('user', { ...query, - cursor: first.nextCursor!, + cursor: first.nextCursor, }); const repeated = await operations.getAll('user', { ...query, - cursor: first.nextCursor!, + cursor: first.nextCursor, }); + assert.ok(second.nextCursor); const third = await operations.getAll('user', { ...query, - cursor: second.nextCursor!, + cursor: second.nextCursor, }); expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([ ['same', 'a'], @@ -312,7 +316,8 @@ describe('All accounts', () => { items: [operation('first', 8), operation('last', 4)], }); const first = await operations.getAll('user', query); - const next = { ...query, cursor: first.nextCursor! }; + assert.ok(first.nextCursor); + const next = { ...query, cursor: first.nextCursor }; await expect(operations.getAll('other', next)).rejects.toBeInstanceOf( BadRequestException, ); diff --git a/src/tbank/bond.dto.ts b/src/tbank/bond.dto.ts index bd87594..4f8c4b6 100644 --- a/src/tbank/bond.dto.ts +++ b/src/tbank/bond.dto.ts @@ -14,39 +14,51 @@ export class BondCandlesQueryDto extends StockCandlesQueryDto {} export class BondCandlesDto extends StockCandlesDto {} export class BondPositionDto extends StockPositionDto {} export class BondDto { - @ApiProperty({ format: 'uuid' }) instrumentUid: string; + @ApiProperty({ format: 'uuid' }) declare instrumentUid: string; @ApiProperty({ description: 'Unambiguous ticker URL segment, or the exact instrument UID.', }) - slug: string; - @ApiProperty() name: string; - @ApiProperty({ type: String, nullable: true }) isin: string | null; - @ApiProperty({ type: String, nullable: true }) classCode: string | null; - @ApiProperty({ type: String, nullable: true }) realExchange: string | null; - @ApiProperty({ type: Boolean, nullable: true }) apiTradeAvailable: + declare slug: string; + @ApiProperty() declare name: string; + @ApiProperty({ type: String, nullable: true }) declare isin: string | null; + @ApiProperty({ type: String, nullable: true }) declare classCode: + string | null; + @ApiProperty({ type: String, nullable: true }) declare realExchange: + string | null; + @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: boolean | null; - @ApiProperty({ type: String, nullable: true }) ticker: string | null; - @ApiProperty({ type: String, nullable: true }) logoName: string | null; - @ApiProperty({ type: String, nullable: true }) currency: string | null; - @ApiProperty({ type: String, nullable: true }) sector: string | null; - @ApiProperty({ type: Number, nullable: true }) lot: number | null; + @ApiProperty({ type: String, nullable: true }) declare ticker: string | null; + @ApiProperty({ type: String, nullable: true }) declare logoName: + string | null; + @ApiProperty({ type: String, nullable: true }) declare currency: + string | null; + @ApiProperty({ type: String, nullable: true }) declare sector: string | null; + @ApiProperty({ type: Number, nullable: true }) declare lot: number | null; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - historyFrom: string | null; - @ApiProperty({ type: MoneyDto, nullable: true }) nominal: MoneyDto | null; + declare historyFrom: string | null; @ApiProperty({ type: MoneyDto, nullable: true }) - initialNominal: MoneyDto | null; - @ApiProperty({ type: MoneyDto, nullable: true }) aci: MoneyDto | null; - @ApiProperty({ type: String, nullable: true }) maturityDate: string | null; - @ApiProperty({ type: String, nullable: true }) callDate: string | null; - @ApiProperty({ type: String, nullable: true }) bondType: string | null; - @ApiProperty({ type: String, nullable: true }) riskLevel: string | null; - @ApiProperty({ type: Number, nullable: true }) couponQuantityPerYear: + declare nominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + declare initialNominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare maturityDate: + string | null; + @ApiProperty({ type: String, nullable: true }) declare callDate: + string | null; + @ApiProperty({ type: String, nullable: true }) declare bondType: + string | null; + @ApiProperty({ type: String, nullable: true }) declare riskLevel: + string | null; + @ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear: number | null; - @ApiProperty({ type: Boolean, nullable: true }) floatingCoupon: + @ApiProperty({ type: Boolean, nullable: true }) declare floatingCoupon: + boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare perpetual: + boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare amortization: + boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare subordinated: boolean | null; - @ApiProperty({ type: Boolean, nullable: true }) perpetual: boolean | null; - @ApiProperty({ type: Boolean, nullable: true }) amortization: boolean | null; - @ApiProperty({ type: Boolean, nullable: true }) subordinated: boolean | null; } export class BondSearchResultDto extends BondDto { @@ -55,89 +67,99 @@ export class BondSearchResultDto extends BondDto { nullable: true, description: 'Clean price in percent of nominal, not money.', }) - pricePercent: string | null; - @ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: - string | null; + declare pricePercent: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + declare priceAt: string | null; } export class BondQuoteDto { - @ApiProperty({ type: String, nullable: true }) pricePercent: string | null; + @ApiProperty({ type: String, nullable: true }) declare pricePercent: + string | null; @ApiProperty({ type: MoneyDto, nullable: true, description: 'Indicative clean value at the current nominal; no FX conversion.', }) - cleanPrice: MoneyDto | null; - @ApiProperty({ type: MoneyDto, nullable: true }) aci: MoneyDto | null; + declare cleanPrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true, description: 'Indicative clean value plus current accrued interest, excluding fees.', }) - dirtyPrice: MoneyDto | null; - @ApiProperty({ type: String, nullable: true }) changePercentagePoints: + declare dirtyPrice: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) + declare changePercentagePoints: string | null; + @ApiProperty({ type: String, nullable: true }) declare previousCloseDate: string | null; - @ApiProperty({ type: String, nullable: true }) previousCloseDate: + @ApiProperty({ type: String, nullable: true }) declare tradingStatus: string | null; - @ApiProperty({ type: String, nullable: true }) tradingStatus: string | null; - @ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: - string | null; - @ApiProperty({ type: [String] }) unavailable: string[]; - @ApiProperty({ format: 'date-time' }) observedAt: string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + declare priceAt: string | null; + @ApiProperty({ type: [String] }) declare unavailable: string[]; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class BondCouponDto { - @ApiProperty({ type: String, nullable: true }) number: string | null; - @ApiProperty({ type: String, nullable: true }) paymentDate: string | null; - @ApiProperty({ type: String, nullable: true }) recordDate: string | null; - @ApiProperty({ type: String, nullable: true }) couponType: string | null; - @ApiProperty({ type: Number, nullable: true }) periodDays: number | null; - @ApiProperty({ type: MoneyDto, nullable: true }) perBond: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare number: string | null; + @ApiProperty({ type: String, nullable: true }) declare paymentDate: + string | null; + @ApiProperty({ type: String, nullable: true }) declare recordDate: + string | null; + @ApiProperty({ type: String, nullable: true }) declare couponType: + string | null; + @ApiProperty({ type: Number, nullable: true }) declare periodDays: + number | null; @ApiProperty({ type: MoneyDto, nullable: true }) - estimatedAmount: MoneyDto | null; - @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) timing: + declare perBond: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + declare estimatedAmount: MoneyDto | null; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: 'upcoming' | 'past' | 'undated'; } export class BondCouponsDto { - @ApiProperty({ type: [BondCouponDto] }) coupons: BondCouponDto[]; - @ApiProperty() from: string; - @ApiProperty() to: string; - @ApiProperty() observedAt: string; - @ApiProperty({ type: String, nullable: true }) positionAt: string | null; - @ApiProperty() estimateUnavailable: boolean; - @ApiProperty() positionIsStale: boolean; + @ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[]; + @ApiProperty() declare from: string; + @ApiProperty() declare to: string; + @ApiProperty() declare observedAt: string; + @ApiProperty({ type: String, nullable: true }) declare positionAt: + string | null; + @ApiProperty() declare estimateUnavailable: boolean; + @ApiProperty() declare positionIsStale: boolean; } export class BondYieldDto { @ApiProperty({ description: 'MOEX effective annual yield in percent.' }) - value: string; - @ApiProperty() pricePercent: string; - @ApiProperty({ format: 'date' }) date: string; + declare value: string; + @ApiProperty() declare pricePercent: string; + @ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ type: String, nullable: true, description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.', }) - dateType: string | null; - @ApiProperty({ format: 'date-time' }) tradeAt: string; + declare dateType: string | null; + @ApiProperty({ format: 'date-time' }) declare tradeAt: string; } export class BondCreditRatingDto { - @ApiProperty({ enum: ['NCR'] }) agency: 'NCR'; - @ApiProperty({ type: String, nullable: true }) value: string | null; - @ApiProperty({ enum: ['active', 'withdrawn'] }) status: + @ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR'; + @ApiProperty({ type: String, nullable: true }) declare value: string | null; + @ApiProperty({ enum: ['active', 'withdrawn'] }) declare status: 'active' | 'withdrawn'; - @ApiProperty({ format: 'date' }) date: string; - @ApiProperty({ format: 'uri' }) sourceUrl: string; - @ApiProperty({ format: 'date-time' }) observedAt: string; + @ApiProperty({ format: 'date' }) declare date: string; + @ApiProperty({ format: 'uri' }) declare sourceUrl: string; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class BondMetricsDto { @ApiProperty({ type: BondCreditRatingDto, nullable: true }) - creditRating: BondCreditRatingDto | null; - @ApiProperty({ type: MoneyDto, nullable: true }) coupon: MoneyDto | null; - @ApiProperty({ type: String, nullable: true, format: 'date' }) couponDate: + declare creditRating: BondCreditRatingDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + declare coupon: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + declare couponDate: string | null; + @ApiProperty({ type: String, nullable: true }) declare couponType: string | null; - @ApiProperty({ type: String, nullable: true }) couponType: string | null; - @ApiProperty({ type: Number, nullable: true }) couponPeriodDays: + @ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays: number | null; @ApiProperty({ type: String, @@ -145,11 +167,11 @@ export class BondMetricsDto { description: 'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.', }) - couponYieldPercent: string | null; + declare couponYieldPercent: string | null; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - couponYieldPriceAt: string | null; + declare couponYieldPriceAt: string | null; @ApiProperty({ type: BondYieldDto, nullable: true }) - effectiveYield: BondYieldDto | null; - @ApiProperty({ type: [String] }) unavailable: string[]; - @ApiProperty({ format: 'date-time' }) observedAt: string; + declare effectiveYield: BondYieldDto | null; + @ApiProperty({ type: [String] }) declare unavailable: string[]; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } diff --git a/src/tbank/bond.service.spec.ts b/src/tbank/bond.service.spec.ts index 310c0a3..68d4b26 100644 --- a/src/tbank/bond.service.spec.ts +++ b/src/tbank/bond.service.spec.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; jest.mock('./tbank-access.service', () => ({ TbankAccessService: class TbankAccessService {}, })); @@ -269,7 +270,7 @@ describe('bond market use case', () => { }); it('keeps late metadata responses within their original connection generation', async () => { const { service, tbank, changeGeneration } = setup(); - let resolveOld!: (value: typeof bond) => void; + let resolveOld: ((value: typeof bond) => void) | undefined; tbank.getBond.mockReturnValueOnce( new Promise((resolve) => { resolveOld = resolve; @@ -281,6 +282,7 @@ describe('bond market use case', () => { expect(await service.quote('user', 'uid')).toMatchObject({ cleanPrice: { value: '392.5' }, }); + assert.ok(resolveOld); resolveOld(bond); await oldQuote; expect(await service.quote('user', 'uid')).toMatchObject({ @@ -338,6 +340,8 @@ describe('bond market use case', () => { { ...bond, uid: 'uid', apiTradeAvailableFlag: true }, { ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' }, { ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, + { ...bond, uid: undefined }, + { ...bond, uid: '' }, ]; tbank.findBonds.mockResolvedValue(listings); tbank.getBond.mockImplementation((_token: string, uid: string) => diff --git a/src/tbank/bond.service.ts b/src/tbank/bond.service.ts index b793cca..5be0936 100644 --- a/src/tbank/bond.service.ts +++ b/src/tbank/bond.service.ts @@ -42,6 +42,7 @@ import { } from './bond.dto'; const dayMs = 86_400_000; +type IdentifiedBond = TbankBond & { uid: string }; @Injectable() export class BondService { @@ -71,7 +72,7 @@ export class BondService { async (bond) => (await optional( () => - this.bond(userId, connection, bond.uid!) + this.bond(userId, connection, bond.uid) .then(toBond) .catch((error: unknown) => { if (error instanceof NotFoundException) @@ -87,7 +88,7 @@ export class BondService { () => this.tbank.getLastPrices( token, - bonds.map((bond) => bond.uid!), + bonds.map((bond) => bond.uid), ), 'prices', [], @@ -135,11 +136,11 @@ export class BondService { async getByTicker(userId: string, ticker: string): Promise { const match = await this.tickerMatch(userId, ticker); return { - ...(await this.get(userId, match.uid!)), + ...(await this.get(userId, match.uid)), slug: ticker.trim().toUpperCase(), }; } - private tickerMatch(userId: string, ticker: string): Promise { + private tickerMatch(userId: string, ticker: string): Promise { const normalized = ticker.trim().toUpperCase(); return this.cached(userId, `ticker:${normalized}`, async (connection) => { const { token } = connection; @@ -247,6 +248,7 @@ export class BondService { metrics(userId: string, uid: string): Promise { return this.cached(userId, `metrics:${uid}`, async (connection) => { const instrument = await this.bond(userId, connection, uid); + const { ticker, isin, classCode } = instrument; const today = moscowDay(new Date().toISOString()); const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; @@ -258,24 +260,15 @@ export class BondService { unavailable, ), optional(() => this.quote(userId, uid), 'quote', unavailable), - instrument.ticker && instrument.isin && instrument.classCode + ticker && isin && classCode ? optional( - () => - this.moex.bondYield( - instrument.ticker!, - instrument.isin!, - instrument.classCode!, - ), + () => this.moex.bondYield(ticker, isin, classCode), 'yield', unavailable, ) : null, - instrument.isin - ? optional( - () => this.ratings.rating(instrument.isin!), - 'rating', - unavailable, - ) + isin + ? optional(() => this.ratings.rating(isin), 'rating', unavailable) : null, ]); const next = events @@ -482,7 +475,7 @@ export class BondService { userId: string, connection: TbankConnectionContext, uid: string, - ): Promise { + ): Promise { return this.cachedForConnection( userId, connection, @@ -490,7 +483,7 @@ export class BondService { async () => { const bond = await this.tbank.getBond(connection.token, uid); if (!bond || bond.uid !== uid) throw new NotFoundException(); - return bond; + return { ...bond, uid }; }, ); } @@ -523,11 +516,12 @@ export class BondService { } } -function selectBonds(found: TbankBond[], query: string): TbankBond[] { +function selectBonds(found: TbankBond[], query: string): IdentifiedBond[] { const seen = new Set(); return found .filter( - (bond) => bond.uid && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND', + (bond): bond is IdentifiedBond => + Boolean(bond.uid) && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND', ) .sort( (a, b) => @@ -536,24 +530,24 @@ function selectBonds(found: TbankBond[], query: string): TbankBond[] { Number(b.ticker?.toUpperCase() === query.toUpperCase()) - Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') || - a.uid!.localeCompare(b.uid!), + a.uid.localeCompare(b.uid), ) .filter((bond) => { // A missing ISIN must not collapse unrelated catalog instruments. const key = bond.isin?.trim() ? `isin:${bond.isin.trim().toUpperCase()}` - : `uid:${bond.uid!}`; + : `uid:${bond.uid}`; if (seen.has(key)) return false; seen.add(key); return true; }); } -function toBond(bond: TbankBond): BondDto { +function toBond(bond: IdentifiedBond): BondDto { return { - instrumentUid: bond.uid!, - slug: bond.uid!, - name: bond.name?.trim() || bond.ticker || bond.uid!, + instrumentUid: bond.uid, + slug: bond.uid, + name: bond.name?.trim() || bond.ticker || bond.uid, ticker: bond.ticker?.trim() || null, isin: bond.isin?.trim() || null, classCode: bond.classCode?.trim() || null, diff --git a/src/tbank/brokerage-account.dto.ts b/src/tbank/brokerage-account.dto.ts index 868cdbb..dfa4812 100644 --- a/src/tbank/brokerage-account.dto.ts +++ b/src/tbank/brokerage-account.dto.ts @@ -3,29 +3,29 @@ import { MoneyDto } from '../common/money.dto'; export class PortfolioSummaryDto { @ApiProperty({ type: MoneyDto, isArray: true }) - totalsByCurrency: MoneyDto[]; + declare totalsByCurrency: MoneyDto[]; @ApiProperty({ type: 'integer', minimum: 0, description: 'Number of non-zero stock positions, not the number of units.', }) - stockPositionCount: number; + declare stockPositionCount: number; @ApiProperty({ type: 'integer', minimum: 0, description: 'Number of non-zero bond positions, not the number of units.', }) - bondPositionCount: number; + declare bondPositionCount: number; } export class BrokerageAccountDto { @ApiProperty() - id: string; + declare id: string; @ApiProperty() - name: string; + declare name: string; @ApiProperty({ enum: [ @@ -35,11 +35,11 @@ export class BrokerageAccountDto { 'MONEY_MARKET_FUND', ], }) - type: + declare type: 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; @ApiProperty({ type: String, nullable: true, format: 'date' }) - openedOn: string | null; + declare openedOn: string | null; @ApiProperty({ type: PortfolioSummaryDto, nullable: true }) portfolioSummary?: PortfolioSummaryDto | null; diff --git a/src/tbank/brokerage-operation.dto.ts b/src/tbank/brokerage-operation.dto.ts index 169d592..883a409 100644 --- a/src/tbank/brokerage-operation.dto.ts +++ b/src/tbank/brokerage-operation.dto.ts @@ -75,64 +75,64 @@ export class BrokerageOperationDto { account?: AccountReferenceDto; @ApiProperty() - id: string; + declare id: string; @ApiProperty({ format: 'date-time' }) - occurredAt: string; + declare occurredAt: string; @ApiProperty() - type: string; + declare type: string; @ApiProperty({ enum: operationCategories }) - category: OperationCategory; + declare category: OperationCategory; @ApiProperty() - description: string; + declare description: string; @ApiProperty({ type: String, nullable: true }) - figi: string | null; + declare figi: string | null; @ApiProperty({ type: String, nullable: true }) - instrumentUid: string | null; + declare instrumentUid: string | null; @ApiProperty({ type: String, nullable: true }) - ticker: string | null; + declare ticker: string | null; @ApiProperty({ type: String, nullable: true }) - instrumentType: string | null; + declare instrumentType: string | null; @ApiProperty({ type: String, nullable: true }) - logoName: string | null; + declare logoName: string | null; @ApiProperty({ type: MoneyDto, nullable: true }) - payment: MoneyDto | null; + declare payment: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) - quantity: string | null; + declare quantity: string | null; @ApiProperty({ type: MoneyDto, nullable: true }) - price: MoneyDto | null; + declare price: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) - accruedInterest: MoneyDto | null; + declare accruedInterest: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) - commission: MoneyDto | null; + declare commission: MoneyDto | null; } export class BrokerageOperationsPageDto { @ApiProperty() - accountId: string; + declare accountId: string; @ApiProperty({ type: BrokerageOperationDto, isArray: true }) - items: BrokerageOperationDto[]; + declare items: BrokerageOperationDto[]; @ApiProperty({ type: String, nullable: true }) - nextCursor: string | null; + declare nextCursor: string | null; @ApiProperty() - hasNext: boolean; + declare hasNext: boolean; @ApiProperty({ format: 'date-time' }) - observedAt: string; + declare observedAt: string; } diff --git a/src/tbank/brokerage-operations.service.ts b/src/tbank/brokerage-operations.service.ts index fdeddc4..9157b70 100644 --- a/src/tbank/brokerage-operations.service.ts +++ b/src/tbank/brokerage-operations.service.ts @@ -222,7 +222,7 @@ export class BrokerageOperationsService { left.account.id.localeCompare(right.account.id), )[0]; if (!next) break; - items.push(next.pending.shift()!); + items.push(...next.pending.splice(0, 1)); } const hasNext = streams.some( (stream) => stream.pending.length || !stream.finished, @@ -262,7 +262,9 @@ export class BrokerageOperationsService { : undefined; const selected = typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges; - const pages: BrokerageOperationsPageDto[][] = selected.map(() => []); + const pages: { + items: (BrokerageOperationDto & { account: AccountReferenceDto })[]; + }[][] = selected.map(() => []); for (const account of accounts) { for (const [index, range] of selected.entries()) { const collected = await this.collectRange( @@ -294,7 +296,7 @@ export class BrokerageOperationsService { (left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt) || - left.account!.id.localeCompare(right.account!.id), + left.account.id.localeCompare(right.account.id), ), }, ]), diff --git a/src/tbank/income-analytics.dto.ts b/src/tbank/income-analytics.dto.ts index 82a8846..91057ae 100644 --- a/src/tbank/income-analytics.dto.ts +++ b/src/tbank/income-analytics.dto.ts @@ -19,123 +19,123 @@ export class IncomeAnalyticsQueryDto { export class IncomePeriodDto { @ApiProperty({ format: 'date' }) - from: string; + declare from: string; @ApiProperty({ format: 'date' }) - to: string; + declare to: string; } export class IncomeCurrencySummaryDto { @ApiProperty() - currency: string; + declare currency: string; @ApiProperty({ type: MoneyDto }) - coupon: MoneyDto; + declare coupon: MoneyDto; @ApiProperty({ type: MoneyDto }) - dividend: MoneyDto; + declare dividend: MoneyDto; @ApiProperty({ type: MoneyDto }) - total: MoneyDto; + declare total: MoneyDto; } export class IncomeSummaryDto { @ApiProperty() - accountId: string; + declare accountId: string; @ApiProperty({ type: IncomePeriodDto }) - period: IncomePeriodDto; + declare period: IncomePeriodDto; @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true }) - totalsByCurrency: IncomeCurrencySummaryDto[]; + declare totalsByCurrency: IncomeCurrencySummaryDto[]; @ApiProperty({ format: 'date-time' }) - calculatedAt: string; + declare calculatedAt: string; } export class IncomeComparisonCurrencyDto { @ApiProperty() - currency: string; + declare currency: string; @ApiProperty({ type: IncomeCurrencySummaryDto }) - selected: IncomeCurrencySummaryDto; + declare selected: IncomeCurrencySummaryDto; @ApiProperty({ type: IncomeCurrencySummaryDto }) - compared: IncomeCurrencySummaryDto; + declare compared: IncomeCurrencySummaryDto; @ApiProperty({ type: IncomeCurrencySummaryDto }) - difference: IncomeCurrencySummaryDto; + declare difference: IncomeCurrencySummaryDto; } export class IncomeComparisonDto { @ApiProperty({ type: IncomePeriodDto }) - period: IncomePeriodDto; + declare period: IncomePeriodDto; @ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true }) - totalsByCurrency: IncomeComparisonCurrencyDto[]; + declare totalsByCurrency: IncomeComparisonCurrencyDto[]; } export class IncomeBucketDto { @ApiProperty({ type: IncomePeriodDto }) - period: IncomePeriodDto; + declare period: IncomePeriodDto; @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true }) - totalsByCurrency: IncomeCurrencySummaryDto[]; + declare totalsByCurrency: IncomeCurrencySummaryDto[]; } export class IncomeInstrumentDto { @ApiProperty() - key: string; + declare key: string; @ApiProperty({ type: String, nullable: true }) - figi: string | null; + declare figi: string | null; @ApiProperty({ type: String, nullable: true }) - instrumentUid: string | null; + declare instrumentUid: string | null; @ApiProperty({ type: String, nullable: true }) - ticker: string | null; + declare ticker: string | null; @ApiProperty({ type: String, nullable: true }) - instrumentType: string | null; + declare instrumentType: string | null; @ApiProperty() - displayName: string; + declare displayName: string; @ApiProperty({ type: MoneyDto }) - coupon: MoneyDto; + declare coupon: MoneyDto; @ApiProperty({ type: MoneyDto }) - dividend: MoneyDto; + declare dividend: MoneyDto; @ApiProperty({ type: MoneyDto }) - total: MoneyDto; + declare total: MoneyDto; @ApiProperty() - receiptCount: number; + declare receiptCount: number; } export class IncomeAnalyticsEntryDto extends BrokerageOperationDto { @ApiProperty() - instrumentKey: string; + declare instrumentKey: string; } export class IncomeAnalyticsDto extends IncomeSummaryDto { @ApiProperty({ type: IncomeComparisonDto }) - previous: IncomeComparisonDto; + declare previous: IncomeComparisonDto; @ApiProperty({ type: IncomeComparisonDto, nullable: true }) - yearComparison: IncomeComparisonDto | null; + declare yearComparison: IncomeComparisonDto | null; @ApiProperty({ enum: ['day', 'week', 'month'] }) - bucketGranularity: 'day' | 'week' | 'month'; + declare bucketGranularity: 'day' | 'week' | 'month'; @ApiProperty({ type: IncomeBucketDto, isArray: true }) - buckets: IncomeBucketDto[]; + declare buckets: IncomeBucketDto[]; @ApiProperty({ type: IncomeInstrumentDto, isArray: true }) - instruments: IncomeInstrumentDto[]; + declare instruments: IncomeInstrumentDto[]; @ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true }) - entries: IncomeAnalyticsEntryDto[]; + declare entries: IncomeAnalyticsEntryDto[]; } diff --git a/src/tbank/instrument-metadata.service.spec.ts b/src/tbank/instrument-metadata.service.spec.ts index 769a43d..f1f03ec 100644 --- a/src/tbank/instrument-metadata.service.spec.ts +++ b/src/tbank/instrument-metadata.service.spec.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { InstrumentMetadataService } from './instrument-metadata.service'; import { ServiceUnavailableException } from '@nestjs/common'; import type { LRUCache } from 'lru-cache'; @@ -178,7 +179,7 @@ describe('InstrumentMetadataService', () => { it('isolates in-flight credentials while sharing successful public metadata', async () => { const error = new Error('INVALID_TBANK_TOKEN'); const known = { name: 'Облигация' }; - let rejectInvalid!: (reason: Error) => void; + let rejectInvalid: ((reason: Error) => void) | undefined; tbank.getInstrumentByUid.mockImplementation((token: string) => token === 'invalid-token' ? new Promise((_resolve, reject) => { @@ -191,6 +192,7 @@ describe('InstrumentMetadataService', () => { service.resolve('invalid-token', 'uid-1', null), ).rejects.toBe(error); const valid = service.resolve('valid-token', 'uid-1', null); + assert.ok(rejectInvalid); rejectInvalid(error); await invalid; @@ -237,7 +239,7 @@ describe('InstrumentMetadataService', () => { it('keeps a shared fetch alive after one caller aborts and caches its result', async () => { const known = { name: 'Облигация' }; - let finish!: (value: TbankInstrument) => void; + let finish: ((value: TbankInstrument) => void) | undefined; tbank.getInstrumentByUid.mockReturnValue( new Promise((resolve) => { finish = resolve; @@ -254,6 +256,7 @@ describe('InstrumentMetadataService', () => { await expect(aborted).resolves.toBeNull(); const waiting = service.resolve('token', 'uid-1', null); + assert.ok(finish); finish(known); await expect(waiting).resolves.toEqual(known); @@ -265,7 +268,7 @@ describe('InstrumentMetadataService', () => { }); it('does not start FIGI lookup after an aborted UID lookup fails later', async () => { - let fail!: (reason: Error) => void; + let fail: ((reason: Error) => void) | undefined; tbank.getInstrumentByUid.mockReturnValue( new Promise((_resolve, reject) => { fail = reject; @@ -281,6 +284,7 @@ describe('InstrumentMetadataService', () => { controller.abort(); await expect(result).resolves.toBeNull(); + assert.ok(fail); fail(new Error('INVALID_TBANK_TOKEN')); await Promise.resolve(); await Promise.resolve(); diff --git a/src/tbank/instrument-position.ts b/src/tbank/instrument-position.ts index b083ec0..99735d1 100644 --- a/src/tbank/instrument-position.ts +++ b/src/tbank/instrument-position.ts @@ -70,13 +70,16 @@ function cost(position: PortfolioPositionDto): MoneyDto | null { } function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { const currency = values[0]?.currency; - if (!currency || values.some((v) => !v || v.currency !== currency)) + if ( + !currency || + !values.every((v): v is MoneyDto => v !== null && v.currency === currency) + ) return null; return { currency, value: formatDecimal( values.reduce( - (sum, value) => add(sum, decimalFromString(value!.value)), + (sum, value) => add(sum, decimalFromString(value.value)), decimalFromString('0'), ), ), diff --git a/src/tbank/portfolio.dto.ts b/src/tbank/portfolio.dto.ts index a161bb9..24dd2fa 100644 --- a/src/tbank/portfolio.dto.ts +++ b/src/tbank/portfolio.dto.ts @@ -10,10 +10,10 @@ export class PortfolioPositionDto { account?: AccountReferenceDto; @ApiProperty({ type: String, nullable: true }) - figi: string | null; + declare figi: string | null; @ApiProperty() - displayName: string; + declare displayName: string; @ApiProperty({ enum: [ @@ -28,36 +28,36 @@ export class PortfolioPositionDto { 'OTHER', ], }) - type: string; + declare type: string; @ApiProperty() - quantity: string; + declare quantity: string; @ApiProperty({ type: MoneyDto, nullable: true }) - currentValue: MoneyDto | null; + declare currentValue: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) - averagePurchasePrice: MoneyDto | null; + declare averagePurchasePrice: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) - absoluteReturn: MoneyDto | null; + declare absoluteReturn: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) - isin: string | null; + declare isin: string | null; @ApiProperty({ type: String, nullable: true }) - ticker: string | null; + declare ticker: string | null; @ApiProperty({ type: String, nullable: true }) - logoName: string | null; + declare logoName: string | null; } export class PortfolioStructureCategoryDto { @ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER', 'CASH'] }) - type: string; + declare type: string; @ApiProperty({ description: 'Exact known value as a decimal string.' }) - value: string; + declare value: string; @ApiProperty({ type: String, @@ -65,61 +65,61 @@ export class PortfolioStructureCategoryDto { description: 'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.', }) - sharePercent: string | null; + declare sharePercent: string | null; } export class PortfolioStructureCurrencyDto { @ApiProperty() - currency: string; + declare currency: string; @ApiProperty() - totalValue: string; + declare totalValue: string; @ApiProperty({ description: 'Any negative constituent, even if netted positive.', }) - hasNegativeValues: boolean; + declare hasNegativeValues: boolean; @ApiProperty({ type: [PortfolioStructureCategoryDto] }) - categories: PortfolioStructureCategoryDto[]; + declare categories: PortfolioStructureCategoryDto[]; } export class PortfolioUnvaluedCategoryDto { @ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] }) - type: string; + declare type: string; @ApiProperty({ minimum: 1 }) - count: number; + declare count: number; } export class PortfolioStructureDto { @ApiProperty({ type: [PortfolioStructureCurrencyDto] }) - currencies: PortfolioStructureCurrencyDto[]; + declare currencies: PortfolioStructureCurrencyDto[]; @ApiProperty({ type: [PortfolioUnvaluedCategoryDto], description: 'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.', }) - unvaluedCategories: PortfolioUnvaluedCategoryDto[]; + declare unvaluedCategories: PortfolioUnvaluedCategoryDto[]; } export class PortfolioDto { @ApiProperty({ format: 'date-time' }) - lastSuccessfulAt: string; + declare lastSuccessfulAt: string; @ApiProperty() - isStale: boolean; + declare isStale: boolean; @ApiProperty({ type: MoneyDto, isArray: true }) - totalsByCurrency: MoneyDto[]; + declare totalsByCurrency: MoneyDto[]; @ApiProperty({ type: PortfolioPositionDto, isArray: true }) - positions: PortfolioPositionDto[]; + declare positions: PortfolioPositionDto[]; @ApiProperty({ type: MoneyDto, isArray: true }) - cashBalances: MoneyDto[]; + declare cashBalances: MoneyDto[]; @ApiProperty({ type: PortfolioStructureDto }) - structure: PortfolioStructureDto; + declare structure: PortfolioStructureDto; } diff --git a/src/tbank/portfolio.service.spec.ts b/src/tbank/portfolio.service.spec.ts index 8b77529..6c9ddad 100644 --- a/src/tbank/portfolio.service.spec.ts +++ b/src/tbank/portfolio.service.spec.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { BadRequestException, ConflictException, @@ -270,7 +271,9 @@ describe('PortfolioService', () => { const snapshots = ( service as never as { snapshots: Map } ).snapshots; - snapshots.get('user:account')!.storedAt = Date.now() - 30_001; + const snapshot = snapshots.get('user:account'); + assert.ok(snapshot); + snapshot.storedAt = Date.now() - 30_001; tbank.getPortfolio.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); @@ -284,7 +287,9 @@ describe('PortfolioService', () => { const snapshots = ( service as never as { snapshots: Map } ).snapshots; - snapshots.get('user:account')!.storedAt = Date.now() - 30_001; + const snapshot = snapshots.get('user:account'); + assert.ok(snapshot); + snapshot.storedAt = Date.now() - 30_001; access.withAccountForConnection.mockRejectedValueOnce( new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), ); @@ -298,7 +303,9 @@ describe('PortfolioService', () => { const snapshots = ( service as never as { snapshots: Map } ).snapshots; - snapshots.get('user:account')!.storedAt = Date.now() - 30_001; + const snapshot = snapshots.get('user:account'); + assert.ok(snapshot); + snapshot.storedAt = Date.now() - 30_001; generation = 'generation-2'; tbank.getPortfolio.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), @@ -314,7 +321,9 @@ describe('PortfolioService', () => { const snapshots = ( service as never as { snapshots: Map } ).snapshots; - snapshots.get('user:account')!.storedAt = Date.now() - 30_001; + const snapshot = snapshots.get('user:account'); + assert.ok(snapshot); + snapshot.storedAt = Date.now() - 30_001; access.withAccountForConnection.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts index fe67f17..2013505 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/tbank/portfolio.service.ts @@ -222,8 +222,8 @@ export class PortfolioService { value: formatDecimal( multiply( subtract( - decimal(position.currentPrice!), - decimal(position.averagePositionPrice!), + decimalFromString(currentPrice.value), + decimalFromString(averagePurchasePrice.value), ), quantity, ), diff --git a/src/tbank/stock.dto.ts b/src/tbank/stock.dto.ts index 275c7c7..8c17353 100644 --- a/src/tbank/stock.dto.ts +++ b/src/tbank/stock.dto.ts @@ -19,7 +19,7 @@ export class StockSearchQueryDto { @IsString() @MinLength(2) @MaxLength(80) - query: string; + declare query: string; } export class StockTickerParamsDto { @ApiProperty({ @@ -31,7 +31,7 @@ export class StockTickerParamsDto { @MinLength(1) @MaxLength(80) @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) - ticker: string; + declare ticker: string; } export class StockCandlesQueryDto { @ApiPropertyOptional({ @@ -43,108 +43,126 @@ export class StockCandlesQueryDto { period?: 'month' | 'halfYear' | 'year' | 'all'; } export class StockDto { - @ApiProperty({ format: 'uuid' }) instrumentUid: string; + @ApiProperty({ format: 'uuid' }) declare instrumentUid: string; @ApiProperty({ description: 'Unambiguous ticker URL segment, or the exact instrument UID.', }) - slug: string; - @ApiProperty() name: string; - @ApiProperty({ type: String, nullable: true }) isin: string | null; - @ApiProperty({ type: String, nullable: true }) classCode: string | null; - @ApiProperty({ type: String, nullable: true }) realExchange: string | null; - @ApiProperty({ type: Boolean, nullable: true }) apiTradeAvailable: + declare slug: string; + @ApiProperty() declare name: string; + @ApiProperty({ type: String, nullable: true }) declare isin: string | null; + @ApiProperty({ type: String, nullable: true }) declare classCode: + string | null; + @ApiProperty({ type: String, nullable: true }) declare realExchange: + string | null; + @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: boolean | null; - @ApiProperty({ type: String, nullable: true }) ticker: string | null; - @ApiProperty({ type: String, nullable: true }) logoName: string | null; - @ApiProperty({ type: String, nullable: true }) currency: string | null; - @ApiProperty({ type: String, nullable: true }) shareType: string | null; - @ApiProperty({ type: String, nullable: true }) sector: string | null; - @ApiProperty({ type: Number, nullable: true }) lot: number | null; + @ApiProperty({ type: String, nullable: true }) declare ticker: string | null; + @ApiProperty({ type: String, nullable: true }) declare logoName: + string | null; + @ApiProperty({ type: String, nullable: true }) declare currency: + string | null; + @ApiProperty({ type: String, nullable: true }) declare shareType: + string | null; + @ApiProperty({ type: String, nullable: true }) declare sector: string | null; + @ApiProperty({ type: Number, nullable: true }) declare lot: number | null; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - historyFrom: string | null; + declare historyFrom: string | null; } export class StockSearchResultDto extends StockDto { - @ApiProperty({ type: MoneyDto, nullable: true }) price: MoneyDto | null; - @ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: - string | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + declare price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + declare priceAt: string | null; } export class StockQuoteDto { - @ApiProperty({ type: MoneyDto, nullable: true }) price: MoneyDto | null; - @ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: - string | null; - @ApiProperty({ type: String, nullable: true }) tradingStatus: string | null; - @ApiProperty({ type: MoneyDto, nullable: true }) change: MoneyDto | null; - @ApiProperty({ type: String, nullable: true }) changePercent: string | null; - @ApiProperty({ type: String, nullable: true, format: 'date' }) - previousCloseDate: string | null; @ApiProperty({ type: MoneyDto, nullable: true }) - previousClose: MoneyDto | null; - @ApiProperty({ type: [String] }) unavailable: string[]; - @ApiProperty({ format: 'date-time' }) observedAt: string; + declare price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + declare priceAt: string | null; + @ApiProperty({ type: String, nullable: true }) declare tradingStatus: + string | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + declare change: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare changePercent: + string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + declare previousCloseDate: string | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + declare previousClose: MoneyDto | null; + @ApiProperty({ type: [String] }) declare unavailable: string[]; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockCandleDto { - @ApiProperty({ format: 'date-time' }) time: string; - @ApiProperty() close: string; - @ApiProperty() isComplete: boolean; + @ApiProperty({ format: 'date-time' }) declare time: string; + @ApiProperty() declare close: string; + @ApiProperty() declare isComplete: boolean; } export class StockCandlesDto { - @ApiProperty({ type: [StockCandleDto] }) candles: StockCandleDto[]; - @ApiProperty({ type: String, nullable: true }) currency: string | null; + @ApiProperty({ type: [StockCandleDto] }) declare candles: StockCandleDto[]; + @ApiProperty({ type: String, nullable: true }) declare currency: + string | null; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - historyFrom: string | null; - @ApiProperty({ format: 'date-time' }) observedAt: string; + declare historyFrom: string | null; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockPositionRowDto extends PortfolioPositionDto { - @ApiProperty({ type: String, nullable: true }) returnPercent: string | null; + @ApiProperty({ type: String, nullable: true }) declare returnPercent: + string | null; } export class StockPositionDto { - @ApiProperty() quantity: string; + @ApiProperty() declare quantity: string; @ApiProperty({ type: MoneyDto, nullable: true }) - currentValue: MoneyDto | null; + declare currentValue: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) - averagePurchasePrice: MoneyDto | null; + declare averagePurchasePrice: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) - absoluteReturn: MoneyDto | null; - @ApiProperty({ type: String, nullable: true }) returnPercent: string | null; - @ApiProperty({ type: [StockPositionRowDto] }) accounts: StockPositionRowDto[]; - @ApiProperty() isStale: boolean; - @ApiProperty({ format: 'date-time' }) observedAt: string; + declare absoluteReturn: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare returnPercent: + string | null; + @ApiProperty({ type: [StockPositionRowDto] }) + declare accounts: StockPositionRowDto[]; + @ApiProperty() declare isStale: boolean; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockMetricDto { - @ApiProperty() key: string; - @ApiProperty() value: number; - @ApiProperty({ enum: ['money', 'ratio', 'percent'] }) unit: + @ApiProperty() declare key: string; + @ApiProperty() declare value: number; + @ApiProperty({ enum: ['money', 'ratio', 'percent'] }) declare unit: 'money' | 'ratio' | 'percent'; - @ApiProperty({ type: String, nullable: true }) currency: string | null; - @ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] }) period: - 'TTM' | 'MRQ' | null; + @ApiProperty({ type: String, nullable: true }) declare currency: + string | null; + @ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] }) + declare period: 'TTM' | 'MRQ' | null; } export class StockCompanyDto { - @ApiProperty({ type: String, nullable: true }) description: string | null; - @ApiProperty({ type: [StockMetricDto] }) metrics: StockMetricDto[]; - @ApiProperty({ type: [String] }) unavailable: string[]; - @ApiProperty({ format: 'date-time' }) observedAt: string; + @ApiProperty({ type: String, nullable: true }) declare description: + string | null; + @ApiProperty({ type: [StockMetricDto] }) declare metrics: StockMetricDto[]; + @ApiProperty({ type: [String] }) declare unavailable: string[]; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockDividendDto { - @ApiProperty({ type: MoneyDto, nullable: true }) perShare: MoneyDto | null; - @ApiProperty({ type: String, nullable: true, format: 'date' }) recordDate: - string | null; - @ApiProperty({ type: String, nullable: true, format: 'date' }) lastBuyDate: - string | null; - @ApiProperty({ type: String, nullable: true, format: 'date' }) paymentDate: - string | null; - @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) timing: + @ApiProperty({ type: MoneyDto, nullable: true }) + declare perShare: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + declare recordDate: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + declare lastBuyDate: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + declare paymentDate: string | null; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: 'upcoming' | 'past' | 'undated'; @ApiProperty({ type: MoneyDto, nullable: true }) - estimatedAmount: MoneyDto | null; + declare estimatedAmount: MoneyDto | null; } export class StockDividendsDto { - @ApiProperty({ type: [StockDividendDto] }) dividends: StockDividendDto[]; - @ApiProperty({ format: 'date' }) from: string; - @ApiProperty({ format: 'date' }) to: string; - @ApiProperty({ format: 'date-time' }) observedAt: string; + @ApiProperty({ type: [StockDividendDto] }) + declare dividends: StockDividendDto[]; + @ApiProperty({ format: 'date' }) declare from: string; + @ApiProperty({ format: 'date' }) declare to: string; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - positionAt: string | null; - @ApiProperty() estimateUnavailable: boolean; - @ApiProperty() positionIsStale: boolean; + declare positionAt: string | null; + @ApiProperty() declare estimateUnavailable: boolean; + @ApiProperty() declare positionIsStale: boolean; } diff --git a/src/tbank/stock.service.spec.ts b/src/tbank/stock.service.spec.ts index 543bb8a..cb5a685 100644 --- a/src/tbank/stock.service.spec.ts +++ b/src/tbank/stock.service.spec.ts @@ -97,6 +97,8 @@ describe('stock market use case', () => { tbank.findShares.mockResolvedValue([ ...listings, { uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' }, + { instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, + { uid: '', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, ]); tbank.getShare.mockImplementation((_token: string, uid: string) => Promise.resolve({ diff --git a/src/tbank/stock.service.ts b/src/tbank/stock.service.ts index 015946d..135b1b2 100644 --- a/src/tbank/stock.service.ts +++ b/src/tbank/stock.service.ts @@ -39,6 +39,7 @@ import { } from './stock.dto'; const dayMs = 86_400_000; +type IdentifiedShare = TbankShare & { uid: string }; @Injectable() export class StockService { @@ -64,7 +65,7 @@ export class StockService { async (share) => (await optional( () => - this.get(userId, share.uid!).catch((error: unknown) => { + this.get(userId, share.uid).catch((error: unknown) => { if (error instanceof NotFoundException) return stock(share); throw error; }), @@ -77,7 +78,7 @@ export class StockService { () => this.tbank.getLastPrices( token, - shares.map((share) => share.uid!), + shares.map((share) => share.uid), ), 'prices', [], @@ -131,11 +132,14 @@ export class StockService { async getByTicker(userId: string, ticker: string): Promise { const match = await this.tickerMatch(userId, ticker); return { - ...(await this.get(userId, match.uid!)), + ...(await this.get(userId, match.uid)), slug: ticker.trim().toUpperCase(), }; } - private tickerMatch(userId: string, ticker: string): Promise { + private tickerMatch( + userId: string, + ticker: string, + ): Promise { const normalized = ticker.trim().toUpperCase(); return this.cached(userId, `ticker:${normalized}`, async (token) => { const found = await this.tbank.findShares(token, normalized); @@ -295,7 +299,8 @@ export class StockService { return this.cached(userId, `company:${uid}`, async (token) => { const share = await this.share(token, uid); const unavailable: string[] = []; - if (!share.assetUid) + const assetUid = share.assetUid; + if (!assetUid) return { description: null, metrics: [], @@ -304,12 +309,12 @@ export class StockService { }; const [description, fundamentals] = await Promise.all([ optional( - () => this.tbank.getAssetDescription(token, share.assetUid!), + () => this.tbank.getAssetDescription(token, assetUid), 'description', unavailable, ), optional( - () => this.tbank.getFundamentals(token, share.assetUid!), + () => this.tbank.getFundamentals(token, assetUid), 'fundamentals', unavailable, ), @@ -353,12 +358,12 @@ export class StockService { ? { currency, value: formatDecimal(decimal(event.dividendNet)) } : null; // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. - const timing: StockDividendDto['timing'] = - (paymentDate ?? recordDate) - ? (paymentDate ?? recordDate)! >= today - ? 'upcoming' - : 'past' - : 'undated'; + const eventDate = paymentDate ?? recordDate; + const timing: StockDividendDto['timing'] = eventDate + ? eventDate >= today + ? 'upcoming' + : 'past' + : 'undated'; const estimatedAmount = quantity && perShare && @@ -393,10 +398,10 @@ export class StockService { }; }); } - private async share(token: string, uid: string): Promise { + private async share(token: string, uid: string): Promise { const share = await this.tbank.getShare(token, uid); if (!share || share.uid !== uid) throw new NotFoundException(); - return share; + return { ...share, uid }; } private cached( userId: string, @@ -417,11 +422,12 @@ export class StockService { } } -function selectShares(found: TbankShare[], query: string): TbankShare[] { +function selectShares(found: TbankShare[], query: string): IdentifiedShare[] { const seen = new Set(); return found .filter( - (share) => share.uid && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE', + (share): share is IdentifiedShare => + Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE', ) .sort( (a, b) => @@ -430,24 +436,24 @@ function selectShares(found: TbankShare[], query: string): TbankShare[] { Number(b.ticker?.toUpperCase() === query.toUpperCase()) - Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') || - a.uid!.localeCompare(b.uid!), + a.uid.localeCompare(b.uid), ) .filter((share) => { // A missing ISIN must not collapse unrelated catalog instruments. const key = share.isin?.trim() ? `isin:${share.isin.trim().toUpperCase()}` - : `uid:${share.uid!}`; + : `uid:${share.uid}`; if (seen.has(key)) return false; seen.add(key); return true; }); } -function stock(share: TbankShare): StockDto { +function stock(share: IdentifiedShare): StockDto { return { - instrumentUid: share.uid!, - slug: share.uid!, - name: share.name?.trim() || share.ticker || share.uid!, + instrumentUid: share.uid, + slug: share.uid, + name: share.name?.trim() || share.ticker || share.uid, ticker: share.ticker?.trim() || null, isin: share.isin?.trim() || null, classCode: share.classCode?.trim() || null, diff --git a/src/tbank/tbank-connection.dto.ts b/src/tbank/tbank-connection.dto.ts index 0b8d599..32a8aae 100644 --- a/src/tbank/tbank-connection.dto.ts +++ b/src/tbank/tbank-connection.dto.ts @@ -6,13 +6,13 @@ export class PutTbankConnectionDto { @IsString() @IsNotEmpty() @MaxLength(4096) - token: string; + declare token: string; } export class TbankConnectionStatusDto { @ApiProperty() - connected: boolean; + declare connected: boolean; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - connectedAt: string | null; + declare connectedAt: string | null; } diff --git a/src/tbank/tbank.client.ts b/src/tbank/tbank.client.ts index 7b3e18b..573bde8 100644 --- a/src/tbank/tbank.client.ts +++ b/src/tbank/tbank.client.ts @@ -233,11 +233,12 @@ export class TbankClient { } getOpenAccounts(token: string): Promise { - return this.call(token, this.users, 'getAccounts', { - status: 'ACCOUNT_STATUS_OPEN', - }).then( - (response: { accounts?: TbankAccount[] }) => response.accounts ?? [], - ); + return this.call<{ accounts?: TbankAccount[] }>( + token, + this.users, + 'getAccounts', + { status: 'ACCOUNT_STATUS_OPEN' }, + ).then((response) => response.accounts ?? []); } getPortfolio(token: string, accountId: string): Promise { @@ -275,13 +276,16 @@ export class TbankClient { from: string, to: string, ): Promise { - return this.call(token, this.instruments, 'getDividends', { - instrumentId, - from: { seconds: String(utcSeconds(from)) }, - to: { seconds: String(utcSeconds(to)) }, - }).then( - (response: { dividends?: TbankDividend[] }) => response.dividends ?? [], - ); + return this.call<{ dividends?: TbankDividend[] }>( + token, + this.instruments, + 'getDividends', + { + instrumentId, + from: { seconds: String(utcSeconds(from)) }, + to: { seconds: String(utcSeconds(to)) }, + }, + ).then((response) => response.dividends ?? []); } findShares(token: string, query: string): Promise { diff --git a/src/tbank/upcoming-events.dto.ts b/src/tbank/upcoming-events.dto.ts index c0cb27d..67875c4 100644 --- a/src/tbank/upcoming-events.dto.ts +++ b/src/tbank/upcoming-events.dto.ts @@ -29,46 +29,46 @@ export class UpcomingEventDto { account?: AccountReferenceDto; @ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] }) - type: 'coupon' | 'dividend' | 'maturity' | 'offer'; + declare type: 'coupon' | 'dividend' | 'maturity' | 'offer'; @ApiProperty({ format: 'date' }) - date: string; + declare date: string; @ApiProperty({ enum: ['event', 'registry'] }) - dateKind: 'event' | 'registry'; + declare dateKind: 'event' | 'registry'; @ApiProperty({ type: String, nullable: true }) - figi: string | null; + declare figi: string | null; @ApiProperty({ type: String, nullable: true }) - instrumentUid: string | null; + declare instrumentUid: string | null; @ApiProperty() - ticker: string; + declare ticker: string; @ApiProperty() - displayName: string; + declare displayName: string; @ApiProperty({ type: String, nullable: true }) - isin: string | null; + declare isin: string | null; @ApiProperty({ type: String, nullable: true }) - logoName: string | null; + declare logoName: string | null; @ApiProperty({ type: MoneyDto, nullable: true }) - projectedAmount: MoneyDto | null; + declare projectedAmount: MoneyDto | null; } export class UpcomingEventsDto { @ApiProperty() - accountId: string; + declare accountId: string; @ApiProperty({ format: 'date-time' }) - calculatedAt: string; + declare calculatedAt: string; @ApiProperty({ type: UpcomingEventDto, isArray: true }) - events: UpcomingEventDto[]; + declare events: UpcomingEventDto[]; @ApiProperty({ type: MoneyDto, isArray: true }) - incomeTotalsByCurrency: MoneyDto[]; + declare incomeTotalsByCurrency: MoneyDto[]; } diff --git a/src/tbank/upcoming-events.service.ts b/src/tbank/upcoming-events.service.ts index 84021b1..9fa24a8 100644 --- a/src/tbank/upcoming-events.service.ts +++ b/src/tbank/upcoming-events.service.ts @@ -71,7 +71,9 @@ export class UpcomingEventsService { return this.access.withSupportedAccounts( userId, async ({ token, accounts }) => { - const events: UpcomingEventDto[] = []; + const events: (UpcomingEventDto & { + account: { id: string; name: string }; + })[] = []; for (const account of accounts) { const result = await this.forAccount(token, account.id, window); events.push( @@ -84,7 +86,7 @@ export class UpcomingEventsService { events.sort( (left, right) => left.date.localeCompare(right.date) || - left.account!.id.localeCompare(right.account!.id), + left.account.id.localeCompare(right.account.id), ); return { accountId: 'all', diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 259f90e..9ff99d4 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; @@ -333,7 +334,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { expect((await getCard(own.id)).balance.value).toBe('1000'); }); it('renames default category without role loss; duplicates and stale rename conflict', async () => { - const category = (await categories()).find((c) => c.isDefault)!; + const category = (await categories()).find((c) => c.isDefault); + assert.ok(category); await auth('patch', '/categories/' + category.id) .send({ version: category.version, name: 'Остальное' }) .expect(200); @@ -474,7 +476,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { 'delete', `/operations/${expense.id}?version=${current.version}`, ).expect(409); - const category = (await categories()).find((c) => !c.isDefault)!; + const category = (await categories()).find((c) => !c.isDefault); + assert.ok(category); await auth('patch', '/operations/' + expense.id) .send({ version: current.version, categoryId: category.id }) .expect(200); diff --git a/tsconfig.json b/tsconfig.json index aba29b0..cccdcae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,10 +16,8 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": true, + "strict": true, "forceConsistentCasingInFileNames": true, - "noImplicitAny": false, - "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": true } }