refactor: enforce strict backend typing

Enable strict TypeScript and reject non-null and definite-assignment assertions. Declare externally populated DTO fields, preserve runtime validation, and narrow values at their owners.

Verified: backend verify (257 tests), unchanged OpenAPI, and 21 SQLite/HTTP tests on retry. The initial SQLite run had two timeouts; baseline HEAD passed 21 tests as well.
This commit is contained in:
2026-09-13 12:17:46 +03:00
parent 3cf883efbc
commit 346a6f0057
33 changed files with 647 additions and 434 deletions
+9
View File
@@ -35,6 +35,15 @@ threshold. `npm run test:e2e` пока не является passing gate: Prism
engine останавливает migration до запуска Jest. Точный статус и trigger engine останавливает migration до запуска Jest. Точный статус и trigger
пересмотра находятся в [remediation registry](../docs/development/audit-remediation.md). пересмотра находятся в [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) - [Router conventions](.ai/CONVENTIONS.md) и [источники](.ai/SOURCES.md)
+10
View File
@@ -27,6 +27,16 @@ export default tseslint.config(
{ {
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'off', '@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-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }], "prettier/prettier": ["error", { endOfLine: "auto" }],
+4 -4
View File
@@ -28,18 +28,18 @@ const COOKIE_PATH = '/api/v1/auth';
class UserDto { class UserDto {
@ApiProperty() @ApiProperty()
id: string; declare id: string;
@ApiProperty() @ApiProperty()
email: string; declare email: string;
} }
class AuthenticationDto { class AuthenticationDto {
@ApiProperty() @ApiProperty()
accessToken: string; declare accessToken: string;
@ApiProperty({ type: UserDto }) @ApiProperty({ type: UserDto })
user: UserDto; declare user: UserDto;
} }
@ApiTags('auth') @ApiTags('auth')
+2 -2
View File
@@ -47,11 +47,11 @@ export class CredentialsDto {
typeof value === 'string' ? value.trim().toLowerCase() : value, typeof value === 'string' ? value.trim().toLowerCase() : value,
) )
@Validate(EmailConstraint) @Validate(EmailConstraint)
email: string; declare email: string;
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' }) @ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' })
@IsString() @IsString()
@MinLength(12) @MinLength(12)
@Validate(PasswordBytesConstraint) @Validate(PasswordBytesConstraint)
password: string; declare password: string;
} }
+67 -59
View File
@@ -35,20 +35,20 @@ export const bankOperationKinds = [
] as const; ] as const;
export type BankOperationKind = (typeof bankOperationKinds)[number]; export type BankOperationKind = (typeof bankOperationKinds)[number];
export class RubMoneyInputDto { 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})?$' }) @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString() @IsString()
@Matches(/^-?\d+(\.\d{1,2})?$/) @Matches(/^-?\d+(\.\d{1,2})?$/)
value: string; declare value: string;
} }
export class CreateBankCardDto { export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: 100 }) @ApiProperty({ minLength: 1, maxLength: 100 })
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(100) @MaxLength(100)
name: string; declare name: string;
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 }) @ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 })
@IsOptional() @IsOptional()
@Transform(trim) @Transform(trim)
@@ -67,17 +67,17 @@ export class CreateBankCardDto {
@IsDefined() @IsDefined()
@ValidateNested() @ValidateNested()
@Type(() => RubMoneyInputDto) @Type(() => RubMoneyInputDto)
openingBalance: MoneyDto; declare openingBalance: MoneyDto;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
openingDate: string; declare openingDate: string;
} }
export class UpdateBankCardDto extends PartialType( export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const), OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
) { ) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number; @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean() @IsBoolean()
@@ -89,17 +89,17 @@ export class CategoryNameDto {
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(80) @MaxLength(80)
name: string; declare name: string;
} }
export class UpdateExpenseCategoryDto extends CategoryNameDto { 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 { export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ enum: bankOperationKinds }) @ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds) @IsIn(bankOperationKinds)
kind: BankOperationKind; declare kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() cardId: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
@@ -116,11 +116,11 @@ export class CreateBankOperationDto {
@IsDefined() @IsDefined()
@ValidateNested() @ValidateNested()
@Type(() => RubMoneyInputDto) @Type(() => RubMoneyInputDto)
amount: MoneyDto; declare amount: MoneyDto;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
date: string; declare date: string;
@ApiPropertyOptional({ maxLength: 500 }) @ApiPropertyOptional({ maxLength: 500 })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsString() @IsString()
@@ -131,7 +131,7 @@ export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const), OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
) { ) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number; @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
} }
export class BankCardsQueryDto { export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] }) @ApiPropertyOptional({ enum: ['true', 'false'] })
@@ -192,68 +192,76 @@ export class BankVersionQueryDto {
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
version: number; declare version: number;
} }
export class ExpenseCategoryDto { export class ExpenseCategoryDto {
@ApiProperty({ type: String }) id: string; @ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) name: string; @ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: Boolean }) isDefault: boolean; @ApiProperty({ type: Boolean }) declare isDefault: boolean;
@ApiProperty({ type: Number }) version: number; @ApiProperty({ type: Number }) declare version: number;
} }
export class BankCardDto { export class BankCardDto {
@ApiProperty({ type: String }) id: string; @ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) name: string; @ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: String, nullable: true }) bankName: string | null; @ApiProperty({ type: String, nullable: true }) declare bankName:
@ApiProperty({ type: String, nullable: true }) lastFour: string | null;
@ApiProperty({ enum: bankPaymentSystems, nullable: true }) paymentSystem:
string | null; string | null;
@ApiProperty({ type: MoneyDto }) openingBalance: MoneyDto; @ApiProperty({ type: String, nullable: true }) declare lastFour:
@ApiProperty({ type: String }) openingDate: string; string | null;
@ApiProperty({ type: MoneyDto }) balance: MoneyDto; @ApiProperty({ enum: bankPaymentSystems, nullable: true })
@ApiProperty({ type: String, nullable: true }) archivedAt: string | null; declare paymentSystem: string | null;
@ApiProperty({ type: Number }) version: number; @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 { export class BankOperationDto {
@ApiProperty({ type: String }) id: string; @ApiProperty({ type: String }) declare id: string;
@ApiProperty({ enum: bankOperationKinds }) kind: BankOperationKind; @ApiProperty({ enum: bankOperationKinds }) declare kind: BankOperationKind;
@ApiProperty({ type: String }) cardId: string; @ApiProperty({ type: String }) declare cardId: string;
@ApiProperty({ type: String }) cardName: string; @ApiProperty({ type: String }) declare cardName: string;
@ApiProperty({ type: String, nullable: true }) targetCardId: string | null; @ApiProperty({ type: String, nullable: true }) declare targetCardId:
@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; 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 }) @ApiProperty({ type: MoneyDto, nullable: true })
availableRefund: MoneyDto | null; declare availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) date: string; @ApiProperty({ type: String }) declare date: string;
@ApiProperty({ type: String, nullable: true }) description: string | null; @ApiProperty({ type: String, nullable: true }) declare description:
@ApiProperty({ type: Number }) version: number; string | null;
@ApiProperty({ type: String }) createdAt: string; @ApiProperty({ type: Number }) declare version: number;
@ApiProperty({ type: String }) declare createdAt: string;
} }
export class BankOperationsPageDto { export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true }) @ApiProperty({ type: BankOperationDto, isArray: true })
items: BankOperationDto[]; declare items: BankOperationDto[];
@ApiProperty({ type: Number }) page: number; @ApiProperty({ type: Number }) declare page: number;
@ApiProperty({ type: Number }) total: number; @ApiProperty({ type: Number }) declare total: number;
@ApiProperty({ type: Boolean }) hasNext: boolean; @ApiProperty({ type: Boolean }) declare hasNext: boolean;
} }
export class BankSummaryCategoryDto { export class BankSummaryCategoryDto {
@ApiProperty({ type: String }) categoryId: string; @ApiProperty({ type: String }) declare categoryId: string;
@ApiProperty({ type: String }) categoryName: string; @ApiProperty({ type: String }) declare categoryName: string;
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto; @ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) net: MoneyDto; @ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
} }
export class BankSummaryDto { export class BankSummaryDto {
@ApiPropertyOptional({ type: String }) month?: string; @ApiPropertyOptional({ type: String }) month?: string;
@ApiPropertyOptional({ format: 'date' }) from?: string; @ApiPropertyOptional({ format: 'date' }) from?: string;
@ApiPropertyOptional({ format: 'date' }) to?: string; @ApiPropertyOptional({ format: 'date' }) to?: string;
@ApiProperty({ type: String, nullable: true }) cardId: string | null; @ApiProperty({ type: String, nullable: true }) declare cardId: string | null;
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto; @ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) net: MoneyDto; @ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true }) @ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
categories: BankSummaryCategoryDto[]; declare categories: BankSummaryCategoryDto[];
} }
+45
View File
@@ -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',
);
});
});
+23 -19
View File
@@ -45,9 +45,9 @@ const seeds = [
'Развлечения', 'Развлечения',
'Другое', 'Другое',
]; ];
const fail = (code = 'BANK_INVALID_INPUT'): never => { function fail(code = 'BANK_INVALID_INPUT'): never {
throw new BadRequestException({ code }); throw new BadRequestException({ code });
}; }
function periodBounds(query: BankPeriodQueryDto) { function periodBounds(query: BankPeriodQueryDto) {
if (query.month && !query.from && !query.to) { if (query.month && !query.from && !query.to) {
const from = `${query.month}-01`; const from = `${query.month}-01`;
@@ -67,12 +67,12 @@ function periodBounds(query: BankPeriodQueryDto) {
return fail(); return fail();
return { from: query.from, to: query.to }; 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 }); throw new ConflictException({ code });
}; }
const missing = (): never => { function missing(): never {
throw new NotFoundException({ code: 'BANK_NOT_FOUND' }); throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
}; }
const fingerprint = (value: unknown) => const fingerprint = (value: unknown) =>
createHash('sha256') createHash('sha256')
.update( .update(
@@ -417,7 +417,7 @@ export class BankCardsService {
return { return {
id, id,
name: label, name: label,
isDefault: row!.seedKey === 'other', isDefault: row.seedKey === 'other',
version: version + 1, version: version + 1,
}; };
}); });
@@ -464,7 +464,7 @@ export class BankCardsService {
where: { userId_seedKey: { userId, seedKey: 'other' } }, where: { userId_seedKey: { userId, seedKey: 'other' } },
}); });
if (!category) missing(); if (!category) missing();
input.categoryId = category!.id; input.categoryId = category.id;
} }
if ( if (
!(await tx.expenseCategory.findFirst({ !(await tx.expenseCategory.findFirst({
@@ -476,7 +476,7 @@ export class BankCardsService {
if (input.kind !== 'refund' && input.originalExpenseId) fail(); if (input.kind !== 'refund' && input.originalExpenseId) fail();
if (input.kind === 'refund') { if (input.kind === 'refund') {
if (!input.originalExpenseId) fail(); if (!input.originalExpenseId) fail();
const source = await this.operation(tx, userId, input.originalExpenseId!); const source = await this.operation(tx, userId, input.originalExpenseId);
if ( if (
source.deletedAt || source.deletedAt ||
source.kind !== 'expense' || source.kind !== 'expense' ||
@@ -643,9 +643,9 @@ export class BankCardsService {
userId, userId,
deletedAt: null, deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
...(expenses ...(period
? { kind: 'expense' } ? { date: { gte: period.from, lte: period.to } }
: { date: { gte: period!.from, lte: period!.to } }), : { kind: 'expense' }),
}; };
const total = await tx.bankCardOperation.count({ where }); const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
@@ -695,13 +695,17 @@ export class BankCardsService {
expenses: rubMoney(totals.expenses), expenses: rubMoney(totals.expenses),
refunds: rubMoney(totals.refunds), refunds: rubMoney(totals.refunds),
net: rubMoney(totals.net), net: rubMoney(totals.net),
categories: totals.categories.map((row) => ({ categories: totals.categories.map((row) => {
categoryId: row.categoryId, const category = categories.find((c) => c.id === row.categoryId);
categoryName: categories.find((c) => c.id === row.categoryId)!.name, if (!category) throw new Error('Bank operation category is missing');
expenses: rubMoney(row.expenses), return {
refunds: rubMoney(row.refunds), categoryId: row.categoryId,
net: rubMoney(row.net), categoryName: category.name,
})), expenses: rubMoney(row.expenses),
refunds: rubMoney(row.refunds),
net: rubMoney(row.net),
};
}),
}; };
}); });
} }
@@ -8,10 +8,10 @@ import {
export class ProblemDetailsDto { export class ProblemDetailsDto {
@ApiProperty({ example: 401 }) @ApiProperty({ example: 401 })
status: number; declare status: number;
@ApiProperty({ example: 'UNAUTHORIZED' }) @ApiProperty({ example: 'UNAUTHORIZED' })
code: string; declare code: string;
@ApiProperty({ @ApiProperty({
required: false, required: false,
+61
View File
@@ -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 });
});
});
+2 -2
View File
@@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
export class MoneyDto { export class MoneyDto {
@ApiProperty() @ApiProperty()
currency: string; declare currency: string;
@ApiProperty() @ApiProperty()
value: string; declare value: string;
} }
+2 -2
View File
@@ -3,8 +3,8 @@ import { ApiProperty } from '@nestjs/swagger';
/** Source account of a row in an all-accounts response. */ /** Source account of a row in an all-accounts response. */
export class AccountReferenceDto { export class AccountReferenceDto {
@ApiProperty() @ApiProperty()
id: string; declare id: string;
@ApiProperty() @ApiProperty()
name: string; declare name: string;
} }
+11 -6
View File
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -130,6 +131,7 @@ describe('All accounts', () => {
instrumentUid: 'common', instrumentUid: 'common',
limit: 1, limit: 1,
}); });
assert.ok(first.nextCursor);
expect(first.items).toHaveLength(1); expect(first.items).toHaveLength(1);
expect(first.items[0].instrumentUid).toBe('common'); expect(first.items[0].instrumentUid).toBe('common');
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
@@ -143,13 +145,13 @@ describe('All accounts', () => {
operations.getAll('user', { operations.getAll('user', {
instrumentUid: 'preferred', instrumentUid: 'preferred',
limit: 1, limit: 1,
cursor: first.nextCursor!, cursor: first.nextCursor,
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
const second = await operations.getAll('user', { const second = await operations.getAll('user', {
instrumentUid: 'common', instrumentUid: 'common',
limit: 1, limit: 1,
cursor: first.nextCursor!, cursor: first.nextCursor,
}); });
expect(second.items[0].instrumentUid).toBe('common'); expect(second.items[0].instrumentUid).toBe('common');
expect(second.items[0].id).not.toBe(first.items[0].id); 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); const first = await operations.getAll('user', query);
assert.ok(first.nextCursor);
const second = await operations.getAll('user', { const second = await operations.getAll('user', {
...query, ...query,
cursor: first.nextCursor!, cursor: first.nextCursor,
}); });
const repeated = await operations.getAll('user', { const repeated = await operations.getAll('user', {
...query, ...query,
cursor: first.nextCursor!, cursor: first.nextCursor,
}); });
assert.ok(second.nextCursor);
const third = await operations.getAll('user', { const third = await operations.getAll('user', {
...query, ...query,
cursor: second.nextCursor!, cursor: second.nextCursor,
}); });
expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([ expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([
['same', 'a'], ['same', 'a'],
@@ -312,7 +316,8 @@ describe('All accounts', () => {
items: [operation('first', 8), operation('last', 4)], items: [operation('first', 8), operation('last', 4)],
}); });
const first = await operations.getAll('user', query); 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( await expect(operations.getAll('other', next)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
+97 -75
View File
@@ -14,39 +14,51 @@ export class BondCandlesQueryDto extends StockCandlesQueryDto {}
export class BondCandlesDto extends StockCandlesDto {} export class BondCandlesDto extends StockCandlesDto {}
export class BondPositionDto extends StockPositionDto {} export class BondPositionDto extends StockPositionDto {}
export class BondDto { export class BondDto {
@ApiProperty({ format: 'uuid' }) instrumentUid: string; @ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
@ApiProperty({ @ApiProperty({
description: 'Unambiguous ticker URL segment, or the exact instrument UID.', description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
}) })
slug: string; declare slug: string;
@ApiProperty() name: string; @ApiProperty() declare name: string;
@ApiProperty({ type: String, nullable: true }) isin: string | null; @ApiProperty({ type: String, nullable: true }) declare isin: string | null;
@ApiProperty({ type: String, nullable: true }) classCode: string | null; @ApiProperty({ type: String, nullable: true }) declare classCode:
@ApiProperty({ type: String, nullable: true }) realExchange: string | null; string | null;
@ApiProperty({ type: Boolean, nullable: true }) apiTradeAvailable: @ApiProperty({ type: String, nullable: true }) declare realExchange:
string | null;
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
boolean | null; boolean | null;
@ApiProperty({ type: String, nullable: true }) ticker: string | null; @ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) logoName: string | null; @ApiProperty({ type: String, nullable: true }) declare logoName:
@ApiProperty({ type: String, nullable: true }) currency: string | null; string | null;
@ApiProperty({ type: String, nullable: true }) sector: string | null; @ApiProperty({ type: String, nullable: true }) declare currency:
@ApiProperty({ type: Number, nullable: true }) lot: number | null; 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' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
historyFrom: string | null; declare historyFrom: string | null;
@ApiProperty({ type: MoneyDto, nullable: true }) nominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
initialNominal: MoneyDto | null; declare nominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) aci: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true })
@ApiProperty({ type: String, nullable: true }) maturityDate: string | null; declare initialNominal: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) callDate: string | null; @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) bondType: string | null; @ApiProperty({ type: String, nullable: true }) declare maturityDate:
@ApiProperty({ type: String, nullable: true }) riskLevel: string | null; string | null;
@ApiProperty({ type: Number, nullable: true }) couponQuantityPerYear: @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; 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; 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 { export class BondSearchResultDto extends BondDto {
@@ -55,89 +67,99 @@ export class BondSearchResultDto extends BondDto {
nullable: true, nullable: true,
description: 'Clean price in percent of nominal, not money.', description: 'Clean price in percent of nominal, not money.',
}) })
pricePercent: string | null; declare pricePercent: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: @ApiProperty({ type: String, nullable: true, format: 'date-time' })
string | null; declare priceAt: string | null;
} }
export class BondQuoteDto { export class BondQuoteDto {
@ApiProperty({ type: String, nullable: true }) pricePercent: string | null; @ApiProperty({ type: String, nullable: true }) declare pricePercent:
string | null;
@ApiProperty({ @ApiProperty({
type: MoneyDto, type: MoneyDto,
nullable: true, nullable: true,
description: description:
'Indicative clean value at the current nominal; no FX conversion.', 'Indicative clean value at the current nominal; no FX conversion.',
}) })
cleanPrice: MoneyDto | null; declare cleanPrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) aci: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
@ApiProperty({ @ApiProperty({
type: MoneyDto, type: MoneyDto,
nullable: true, nullable: true,
description: description:
'Indicative clean value plus current accrued interest, excluding fees.', 'Indicative clean value plus current accrued interest, excluding fees.',
}) })
dirtyPrice: MoneyDto | null; declare dirtyPrice: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) changePercentagePoints: @ApiProperty({ type: String, nullable: true })
declare changePercentagePoints: string | null;
@ApiProperty({ type: String, nullable: true }) declare previousCloseDate:
string | null; string | null;
@ApiProperty({ type: String, nullable: true }) previousCloseDate: @ApiProperty({ type: String, nullable: true }) declare tradingStatus:
string | null; string | null;
@ApiProperty({ type: String, nullable: true }) tradingStatus: string | null; @ApiProperty({ type: String, nullable: true, format: 'date-time' })
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: declare priceAt: string | null;
string | null; @ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ type: [String] }) unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string;
@ApiProperty({ format: 'date-time' }) observedAt: string;
} }
export class BondCouponDto { export class BondCouponDto {
@ApiProperty({ type: String, nullable: true }) number: string | null; @ApiProperty({ type: String, nullable: true }) declare number: string | null;
@ApiProperty({ type: String, nullable: true }) paymentDate: string | null; @ApiProperty({ type: String, nullable: true }) declare paymentDate:
@ApiProperty({ type: String, nullable: true }) recordDate: string | null; string | null;
@ApiProperty({ type: String, nullable: true }) couponType: string | null; @ApiProperty({ type: String, nullable: true }) declare recordDate:
@ApiProperty({ type: Number, nullable: true }) periodDays: number | null; string | null;
@ApiProperty({ type: MoneyDto, nullable: true }) perBond: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) declare couponType:
string | null;
@ApiProperty({ type: Number, nullable: true }) declare periodDays:
number | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
estimatedAmount: MoneyDto | null; declare perBond: MoneyDto | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) timing: @ApiProperty({ type: MoneyDto, nullable: true })
declare estimatedAmount: MoneyDto | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
'upcoming' | 'past' | 'undated'; 'upcoming' | 'past' | 'undated';
} }
export class BondCouponsDto { export class BondCouponsDto {
@ApiProperty({ type: [BondCouponDto] }) coupons: BondCouponDto[]; @ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[];
@ApiProperty() from: string; @ApiProperty() declare from: string;
@ApiProperty() to: string; @ApiProperty() declare to: string;
@ApiProperty() observedAt: string; @ApiProperty() declare observedAt: string;
@ApiProperty({ type: String, nullable: true }) positionAt: string | null; @ApiProperty({ type: String, nullable: true }) declare positionAt:
@ApiProperty() estimateUnavailable: boolean; string | null;
@ApiProperty() positionIsStale: boolean; @ApiProperty() declare estimateUnavailable: boolean;
@ApiProperty() declare positionIsStale: boolean;
} }
export class BondYieldDto { export class BondYieldDto {
@ApiProperty({ description: 'MOEX effective annual yield in percent.' }) @ApiProperty({ description: 'MOEX effective annual yield in percent.' })
value: string; declare value: string;
@ApiProperty() pricePercent: string; @ApiProperty() declare pricePercent: string;
@ApiProperty({ format: 'date' }) date: string; @ApiProperty({ format: 'date' }) declare date: string;
@ApiProperty({ @ApiProperty({
type: String, type: String,
nullable: true, nullable: true,
description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.', description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.',
}) })
dateType: string | null; declare dateType: string | null;
@ApiProperty({ format: 'date-time' }) tradeAt: string; @ApiProperty({ format: 'date-time' }) declare tradeAt: string;
} }
export class BondCreditRatingDto { export class BondCreditRatingDto {
@ApiProperty({ enum: ['NCR'] }) agency: 'NCR'; @ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR';
@ApiProperty({ type: String, nullable: true }) value: string | null; @ApiProperty({ type: String, nullable: true }) declare value: string | null;
@ApiProperty({ enum: ['active', 'withdrawn'] }) status: @ApiProperty({ enum: ['active', 'withdrawn'] }) declare status:
'active' | 'withdrawn'; 'active' | 'withdrawn';
@ApiProperty({ format: 'date' }) date: string; @ApiProperty({ format: 'date' }) declare date: string;
@ApiProperty({ format: 'uri' }) sourceUrl: string; @ApiProperty({ format: 'uri' }) declare sourceUrl: string;
@ApiProperty({ format: 'date-time' }) observedAt: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string;
} }
export class BondMetricsDto { export class BondMetricsDto {
@ApiProperty({ type: BondCreditRatingDto, nullable: true }) @ApiProperty({ type: BondCreditRatingDto, nullable: true })
creditRating: BondCreditRatingDto | null; declare creditRating: BondCreditRatingDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) coupon: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true })
@ApiProperty({ type: String, nullable: true, format: 'date' }) couponDate: declare coupon: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare couponDate: string | null;
@ApiProperty({ type: String, nullable: true }) declare couponType:
string | null; string | null;
@ApiProperty({ type: String, nullable: true }) couponType: string | null; @ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays:
@ApiProperty({ type: Number, nullable: true }) couponPeriodDays:
number | null; number | null;
@ApiProperty({ @ApiProperty({
type: String, type: String,
@@ -145,11 +167,11 @@ export class BondMetricsDto {
description: description:
'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.', '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' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
couponYieldPriceAt: string | null; declare couponYieldPriceAt: string | null;
@ApiProperty({ type: BondYieldDto, nullable: true }) @ApiProperty({ type: BondYieldDto, nullable: true })
effectiveYield: BondYieldDto | null; declare effectiveYield: BondYieldDto | null;
@ApiProperty({ type: [String] }) unavailable: string[]; @ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) observedAt: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string;
} }
+5 -1
View File
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict';
jest.mock('./tbank-access.service', () => ({ jest.mock('./tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {}, TbankAccessService: class TbankAccessService {},
})); }));
@@ -269,7 +270,7 @@ describe('bond market use case', () => {
}); });
it('keeps late metadata responses within their original connection generation', async () => { it('keeps late metadata responses within their original connection generation', async () => {
const { service, tbank, changeGeneration } = setup(); const { service, tbank, changeGeneration } = setup();
let resolveOld!: (value: typeof bond) => void; let resolveOld: ((value: typeof bond) => void) | undefined;
tbank.getBond.mockReturnValueOnce( tbank.getBond.mockReturnValueOnce(
new Promise((resolve) => { new Promise((resolve) => {
resolveOld = resolve; resolveOld = resolve;
@@ -281,6 +282,7 @@ describe('bond market use case', () => {
expect(await service.quote('user', 'uid')).toMatchObject({ expect(await service.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' }, cleanPrice: { value: '392.5' },
}); });
assert.ok(resolveOld);
resolveOld(bond); resolveOld(bond);
await oldQuote; await oldQuote;
expect(await service.quote('user', 'uid')).toMatchObject({ expect(await service.quote('user', 'uid')).toMatchObject({
@@ -338,6 +340,8 @@ describe('bond market use case', () => {
{ ...bond, uid: 'uid', apiTradeAvailableFlag: true }, { ...bond, uid: 'uid', apiTradeAvailableFlag: true },
{ ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' }, { ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' },
{ ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, { ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
{ ...bond, uid: undefined },
{ ...bond, uid: '' },
]; ];
tbank.findBonds.mockResolvedValue(listings); tbank.findBonds.mockResolvedValue(listings);
tbank.getBond.mockImplementation((_token: string, uid: string) => tbank.getBond.mockImplementation((_token: string, uid: string) =>
+21 -27
View File
@@ -42,6 +42,7 @@ import {
} from './bond.dto'; } from './bond.dto';
const dayMs = 86_400_000; const dayMs = 86_400_000;
type IdentifiedBond = TbankBond & { uid: string };
@Injectable() @Injectable()
export class BondService { export class BondService {
@@ -71,7 +72,7 @@ export class BondService {
async (bond) => async (bond) =>
(await optional( (await optional(
() => () =>
this.bond(userId, connection, bond.uid!) this.bond(userId, connection, bond.uid)
.then(toBond) .then(toBond)
.catch((error: unknown) => { .catch((error: unknown) => {
if (error instanceof NotFoundException) if (error instanceof NotFoundException)
@@ -87,7 +88,7 @@ export class BondService {
() => () =>
this.tbank.getLastPrices( this.tbank.getLastPrices(
token, token,
bonds.map((bond) => bond.uid!), bonds.map((bond) => bond.uid),
), ),
'prices', 'prices',
[], [],
@@ -135,11 +136,11 @@ export class BondService {
async getByTicker(userId: string, ticker: string): Promise<BondDto> { async getByTicker(userId: string, ticker: string): Promise<BondDto> {
const match = await this.tickerMatch(userId, ticker); const match = await this.tickerMatch(userId, ticker);
return { return {
...(await this.get(userId, match.uid!)), ...(await this.get(userId, match.uid)),
slug: ticker.trim().toUpperCase(), slug: ticker.trim().toUpperCase(),
}; };
} }
private tickerMatch(userId: string, ticker: string): Promise<TbankBond> { private tickerMatch(userId: string, ticker: string): Promise<IdentifiedBond> {
const normalized = ticker.trim().toUpperCase(); const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (connection) => { return this.cached(userId, `ticker:${normalized}`, async (connection) => {
const { token } = connection; const { token } = connection;
@@ -247,6 +248,7 @@ export class BondService {
metrics(userId: string, uid: string): Promise<BondMetricsDto> { metrics(userId: string, uid: string): Promise<BondMetricsDto> {
return this.cached(userId, `metrics:${uid}`, async (connection) => { return this.cached(userId, `metrics:${uid}`, async (connection) => {
const instrument = await this.bond(userId, connection, uid); const instrument = await this.bond(userId, connection, uid);
const { ticker, isin, classCode } = instrument;
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
@@ -258,24 +260,15 @@ export class BondService {
unavailable, unavailable,
), ),
optional(() => this.quote(userId, uid), 'quote', unavailable), optional(() => this.quote(userId, uid), 'quote', unavailable),
instrument.ticker && instrument.isin && instrument.classCode ticker && isin && classCode
? optional( ? optional(
() => () => this.moex.bondYield(ticker, isin, classCode),
this.moex.bondYield(
instrument.ticker!,
instrument.isin!,
instrument.classCode!,
),
'yield', 'yield',
unavailable, unavailable,
) )
: null, : null,
instrument.isin isin
? optional( ? optional(() => this.ratings.rating(isin), 'rating', unavailable)
() => this.ratings.rating(instrument.isin!),
'rating',
unavailable,
)
: null, : null,
]); ]);
const next = events const next = events
@@ -482,7 +475,7 @@ export class BondService {
userId: string, userId: string,
connection: TbankConnectionContext, connection: TbankConnectionContext,
uid: string, uid: string,
): Promise<TbankBond> { ): Promise<IdentifiedBond> {
return this.cachedForConnection( return this.cachedForConnection(
userId, userId,
connection, connection,
@@ -490,7 +483,7 @@ export class BondService {
async () => { async () => {
const bond = await this.tbank.getBond(connection.token, uid); const bond = await this.tbank.getBond(connection.token, uid);
if (!bond || bond.uid !== uid) throw new NotFoundException(); 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<string>(); const seen = new Set<string>();
return found return found
.filter( .filter(
(bond) => bond.uid && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND', (bond): bond is IdentifiedBond =>
Boolean(bond.uid) && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND',
) )
.sort( .sort(
(a, b) => (a, b) =>
@@ -536,24 +530,24 @@ function selectBonds(found: TbankBond[], query: string): TbankBond[] {
Number(b.ticker?.toUpperCase() === query.toUpperCase()) - Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') || Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') ||
a.uid!.localeCompare(b.uid!), a.uid.localeCompare(b.uid),
) )
.filter((bond) => { .filter((bond) => {
// A missing ISIN must not collapse unrelated catalog instruments. // A missing ISIN must not collapse unrelated catalog instruments.
const key = bond.isin?.trim() const key = bond.isin?.trim()
? `isin:${bond.isin.trim().toUpperCase()}` ? `isin:${bond.isin.trim().toUpperCase()}`
: `uid:${bond.uid!}`; : `uid:${bond.uid}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
}); });
} }
function toBond(bond: TbankBond): BondDto { function toBond(bond: IdentifiedBond): BondDto {
return { return {
instrumentUid: bond.uid!, instrumentUid: bond.uid,
slug: bond.uid!, slug: bond.uid,
name: bond.name?.trim() || bond.ticker || bond.uid!, name: bond.name?.trim() || bond.ticker || bond.uid,
ticker: bond.ticker?.trim() || null, ticker: bond.ticker?.trim() || null,
isin: bond.isin?.trim() || null, isin: bond.isin?.trim() || null,
classCode: bond.classCode?.trim() || null, classCode: bond.classCode?.trim() || null,
+7 -7
View File
@@ -3,29 +3,29 @@ import { MoneyDto } from '../common/money.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
totalsByCurrency: MoneyDto[]; declare totalsByCurrency: MoneyDto[];
@ApiProperty({ @ApiProperty({
type: 'integer', type: 'integer',
minimum: 0, minimum: 0,
description: 'Number of non-zero stock positions, not the number of units.', description: 'Number of non-zero stock positions, not the number of units.',
}) })
stockPositionCount: number; declare stockPositionCount: number;
@ApiProperty({ @ApiProperty({
type: 'integer', type: 'integer',
minimum: 0, minimum: 0,
description: 'Number of non-zero bond positions, not the number of units.', description: 'Number of non-zero bond positions, not the number of units.',
}) })
bondPositionCount: number; declare bondPositionCount: number;
} }
export class BrokerageAccountDto { export class BrokerageAccountDto {
@ApiProperty() @ApiProperty()
id: string; declare id: string;
@ApiProperty() @ApiProperty()
name: string; declare name: string;
@ApiProperty({ @ApiProperty({
enum: [ enum: [
@@ -35,11 +35,11 @@ export class BrokerageAccountDto {
'MONEY_MARKET_FUND', 'MONEY_MARKET_FUND',
], ],
}) })
type: declare type:
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
@ApiProperty({ type: String, nullable: true, format: 'date' }) @ApiProperty({ type: String, nullable: true, format: 'date' })
openedOn: string | null; declare openedOn: string | null;
@ApiProperty({ type: PortfolioSummaryDto, nullable: true }) @ApiProperty({ type: PortfolioSummaryDto, nullable: true })
portfolioSummary?: PortfolioSummaryDto | null; portfolioSummary?: PortfolioSummaryDto | null;
+20 -20
View File
@@ -75,64 +75,64 @@ export class BrokerageOperationDto {
account?: AccountReferenceDto; account?: AccountReferenceDto;
@ApiProperty() @ApiProperty()
id: string; declare id: string;
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
occurredAt: string; declare occurredAt: string;
@ApiProperty() @ApiProperty()
type: string; declare type: string;
@ApiProperty({ enum: operationCategories }) @ApiProperty({ enum: operationCategories })
category: OperationCategory; declare category: OperationCategory;
@ApiProperty() @ApiProperty()
description: string; declare description: string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
figi: string | null; declare figi: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
instrumentUid: string | null; declare instrumentUid: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
ticker: string | null; declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
instrumentType: string | null; declare instrumentType: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
logoName: string | null; declare logoName: string | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
payment: MoneyDto | null; declare payment: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
quantity: string | null; declare quantity: string | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
price: MoneyDto | null; declare price: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
accruedInterest: MoneyDto | null; declare accruedInterest: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
commission: MoneyDto | null; declare commission: MoneyDto | null;
} }
export class BrokerageOperationsPageDto { export class BrokerageOperationsPageDto {
@ApiProperty() @ApiProperty()
accountId: string; declare accountId: string;
@ApiProperty({ type: BrokerageOperationDto, isArray: true }) @ApiProperty({ type: BrokerageOperationDto, isArray: true })
items: BrokerageOperationDto[]; declare items: BrokerageOperationDto[];
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
nextCursor: string | null; declare nextCursor: string | null;
@ApiProperty() @ApiProperty()
hasNext: boolean; declare hasNext: boolean;
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
observedAt: string; declare observedAt: string;
} }
+5 -3
View File
@@ -222,7 +222,7 @@ export class BrokerageOperationsService {
left.account.id.localeCompare(right.account.id), left.account.id.localeCompare(right.account.id),
)[0]; )[0];
if (!next) break; if (!next) break;
items.push(next.pending.shift()!); items.push(...next.pending.splice(0, 1));
} }
const hasNext = streams.some( const hasNext = streams.some(
(stream) => stream.pending.length || !stream.finished, (stream) => stream.pending.length || !stream.finished,
@@ -262,7 +262,9 @@ export class BrokerageOperationsService {
: undefined; : undefined;
const selected = const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges; 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 account of accounts) {
for (const [index, range] of selected.entries()) { for (const [index, range] of selected.entries()) {
const collected = await this.collectRange( const collected = await this.collectRange(
@@ -294,7 +296,7 @@ export class BrokerageOperationsService {
(left, right) => (left, right) =>
Date.parse(right.occurredAt) - Date.parse(right.occurredAt) -
Date.parse(left.occurredAt) || Date.parse(left.occurredAt) ||
left.account!.id.localeCompare(right.account!.id), left.account.id.localeCompare(right.account.id),
), ),
}, },
]), ]),
+35 -35
View File
@@ -19,123 +19,123 @@ export class IncomeAnalyticsQueryDto {
export class IncomePeriodDto { export class IncomePeriodDto {
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
from: string; declare from: string;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
to: string; declare to: string;
} }
export class IncomeCurrencySummaryDto { export class IncomeCurrencySummaryDto {
@ApiProperty() @ApiProperty()
currency: string; declare currency: string;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
coupon: MoneyDto; declare coupon: MoneyDto;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
dividend: MoneyDto; declare dividend: MoneyDto;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
total: MoneyDto; declare total: MoneyDto;
} }
export class IncomeSummaryDto { export class IncomeSummaryDto {
@ApiProperty() @ApiProperty()
accountId: string; declare accountId: string;
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true }) @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
totalsByCurrency: IncomeCurrencySummaryDto[]; declare totalsByCurrency: IncomeCurrencySummaryDto[];
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
calculatedAt: string; declare calculatedAt: string;
} }
export class IncomeComparisonCurrencyDto { export class IncomeComparisonCurrencyDto {
@ApiProperty() @ApiProperty()
currency: string; declare currency: string;
@ApiProperty({ type: IncomeCurrencySummaryDto }) @ApiProperty({ type: IncomeCurrencySummaryDto })
selected: IncomeCurrencySummaryDto; declare selected: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto }) @ApiProperty({ type: IncomeCurrencySummaryDto })
compared: IncomeCurrencySummaryDto; declare compared: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto }) @ApiProperty({ type: IncomeCurrencySummaryDto })
difference: IncomeCurrencySummaryDto; declare difference: IncomeCurrencySummaryDto;
} }
export class IncomeComparisonDto { export class IncomeComparisonDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true }) @ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true })
totalsByCurrency: IncomeComparisonCurrencyDto[]; declare totalsByCurrency: IncomeComparisonCurrencyDto[];
} }
export class IncomeBucketDto { export class IncomeBucketDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true }) @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
totalsByCurrency: IncomeCurrencySummaryDto[]; declare totalsByCurrency: IncomeCurrencySummaryDto[];
} }
export class IncomeInstrumentDto { export class IncomeInstrumentDto {
@ApiProperty() @ApiProperty()
key: string; declare key: string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
figi: string | null; declare figi: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
instrumentUid: string | null; declare instrumentUid: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
ticker: string | null; declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
instrumentType: string | null; declare instrumentType: string | null;
@ApiProperty() @ApiProperty()
displayName: string; declare displayName: string;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
coupon: MoneyDto; declare coupon: MoneyDto;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
dividend: MoneyDto; declare dividend: MoneyDto;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
total: MoneyDto; declare total: MoneyDto;
@ApiProperty() @ApiProperty()
receiptCount: number; declare receiptCount: number;
} }
export class IncomeAnalyticsEntryDto extends BrokerageOperationDto { export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
@ApiProperty() @ApiProperty()
instrumentKey: string; declare instrumentKey: string;
} }
export class IncomeAnalyticsDto extends IncomeSummaryDto { export class IncomeAnalyticsDto extends IncomeSummaryDto {
@ApiProperty({ type: IncomeComparisonDto }) @ApiProperty({ type: IncomeComparisonDto })
previous: IncomeComparisonDto; declare previous: IncomeComparisonDto;
@ApiProperty({ type: IncomeComparisonDto, nullable: true }) @ApiProperty({ type: IncomeComparisonDto, nullable: true })
yearComparison: IncomeComparisonDto | null; declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] }) @ApiProperty({ enum: ['day', 'week', 'month'] })
bucketGranularity: 'day' | 'week' | 'month'; declare bucketGranularity: 'day' | 'week' | 'month';
@ApiProperty({ type: IncomeBucketDto, isArray: true }) @ApiProperty({ type: IncomeBucketDto, isArray: true })
buckets: IncomeBucketDto[]; declare buckets: IncomeBucketDto[];
@ApiProperty({ type: IncomeInstrumentDto, isArray: true }) @ApiProperty({ type: IncomeInstrumentDto, isArray: true })
instruments: IncomeInstrumentDto[]; declare instruments: IncomeInstrumentDto[];
@ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true }) @ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true })
entries: IncomeAnalyticsEntryDto[]; declare entries: IncomeAnalyticsEntryDto[];
} }
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict';
import { InstrumentMetadataService } from './instrument-metadata.service'; import { InstrumentMetadataService } from './instrument-metadata.service';
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import type { LRUCache } from 'lru-cache'; import type { LRUCache } from 'lru-cache';
@@ -178,7 +179,7 @@ describe('InstrumentMetadataService', () => {
it('isolates in-flight credentials while sharing successful public metadata', async () => { it('isolates in-flight credentials while sharing successful public metadata', async () => {
const error = new Error('INVALID_TBANK_TOKEN'); const error = new Error('INVALID_TBANK_TOKEN');
const known = { name: 'Облигация' }; const known = { name: 'Облигация' };
let rejectInvalid!: (reason: Error) => void; let rejectInvalid: ((reason: Error) => void) | undefined;
tbank.getInstrumentByUid.mockImplementation((token: string) => tbank.getInstrumentByUid.mockImplementation((token: string) =>
token === 'invalid-token' token === 'invalid-token'
? new Promise((_resolve, reject) => { ? new Promise((_resolve, reject) => {
@@ -191,6 +192,7 @@ describe('InstrumentMetadataService', () => {
service.resolve('invalid-token', 'uid-1', null), service.resolve('invalid-token', 'uid-1', null),
).rejects.toBe(error); ).rejects.toBe(error);
const valid = service.resolve('valid-token', 'uid-1', null); const valid = service.resolve('valid-token', 'uid-1', null);
assert.ok(rejectInvalid);
rejectInvalid(error); rejectInvalid(error);
await invalid; await invalid;
@@ -237,7 +239,7 @@ describe('InstrumentMetadataService', () => {
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => { it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
const known = { name: 'Облигация' }; const known = { name: 'Облигация' };
let finish!: (value: TbankInstrument) => void; let finish: ((value: TbankInstrument) => void) | undefined;
tbank.getInstrumentByUid.mockReturnValue( tbank.getInstrumentByUid.mockReturnValue(
new Promise((resolve) => { new Promise((resolve) => {
finish = resolve; finish = resolve;
@@ -254,6 +256,7 @@ describe('InstrumentMetadataService', () => {
await expect(aborted).resolves.toBeNull(); await expect(aborted).resolves.toBeNull();
const waiting = service.resolve('token', 'uid-1', null); const waiting = service.resolve('token', 'uid-1', null);
assert.ok(finish);
finish(known); finish(known);
await expect(waiting).resolves.toEqual(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 () => { 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( tbank.getInstrumentByUid.mockReturnValue(
new Promise((_resolve, reject) => { new Promise((_resolve, reject) => {
fail = reject; fail = reject;
@@ -281,6 +284,7 @@ describe('InstrumentMetadataService', () => {
controller.abort(); controller.abort();
await expect(result).resolves.toBeNull(); await expect(result).resolves.toBeNull();
assert.ok(fail);
fail(new Error('INVALID_TBANK_TOKEN')); fail(new Error('INVALID_TBANK_TOKEN'));
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
+5 -2
View File
@@ -70,13 +70,16 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
} }
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency; 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 null;
return { return {
currency, currency,
value: formatDecimal( value: formatDecimal(
values.reduce( values.reduce(
(sum, value) => add(sum, decimalFromString(value!.value)), (sum, value) => add(sum, decimalFromString(value.value)),
decimalFromString('0'), decimalFromString('0'),
), ),
), ),
+27 -27
View File
@@ -10,10 +10,10 @@ export class PortfolioPositionDto {
account?: AccountReferenceDto; account?: AccountReferenceDto;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
figi: string | null; declare figi: string | null;
@ApiProperty() @ApiProperty()
displayName: string; declare displayName: string;
@ApiProperty({ @ApiProperty({
enum: [ enum: [
@@ -28,36 +28,36 @@ export class PortfolioPositionDto {
'OTHER', 'OTHER',
], ],
}) })
type: string; declare type: string;
@ApiProperty() @ApiProperty()
quantity: string; declare quantity: string;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
currentValue: MoneyDto | null; declare currentValue: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
averagePurchasePrice: MoneyDto | null; declare averagePurchasePrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
absoluteReturn: MoneyDto | null; declare absoluteReturn: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
isin: string | null; declare isin: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
ticker: string | null; declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
logoName: string | null; declare logoName: string | null;
} }
export class PortfolioStructureCategoryDto { export class PortfolioStructureCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER', 'CASH'] }) @ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER', 'CASH'] })
type: string; declare type: string;
@ApiProperty({ description: 'Exact known value as a decimal string.' }) @ApiProperty({ description: 'Exact known value as a decimal string.' })
value: string; declare value: string;
@ApiProperty({ @ApiProperty({
type: String, type: String,
@@ -65,61 +65,61 @@ export class PortfolioStructureCategoryDto {
description: description:
'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.', '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 { export class PortfolioStructureCurrencyDto {
@ApiProperty() @ApiProperty()
currency: string; declare currency: string;
@ApiProperty() @ApiProperty()
totalValue: string; declare totalValue: string;
@ApiProperty({ @ApiProperty({
description: 'Any negative constituent, even if netted positive.', description: 'Any negative constituent, even if netted positive.',
}) })
hasNegativeValues: boolean; declare hasNegativeValues: boolean;
@ApiProperty({ type: [PortfolioStructureCategoryDto] }) @ApiProperty({ type: [PortfolioStructureCategoryDto] })
categories: PortfolioStructureCategoryDto[]; declare categories: PortfolioStructureCategoryDto[];
} }
export class PortfolioUnvaluedCategoryDto { export class PortfolioUnvaluedCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] }) @ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] })
type: string; declare type: string;
@ApiProperty({ minimum: 1 }) @ApiProperty({ minimum: 1 })
count: number; declare count: number;
} }
export class PortfolioStructureDto { export class PortfolioStructureDto {
@ApiProperty({ type: [PortfolioStructureCurrencyDto] }) @ApiProperty({ type: [PortfolioStructureCurrencyDto] })
currencies: PortfolioStructureCurrencyDto[]; declare currencies: PortfolioStructureCurrencyDto[];
@ApiProperty({ @ApiProperty({
type: [PortfolioUnvaluedCategoryDto], type: [PortfolioUnvaluedCategoryDto],
description: description:
'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.', 'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.',
}) })
unvaluedCategories: PortfolioUnvaluedCategoryDto[]; declare unvaluedCategories: PortfolioUnvaluedCategoryDto[];
} }
export class PortfolioDto { export class PortfolioDto {
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
lastSuccessfulAt: string; declare lastSuccessfulAt: string;
@ApiProperty() @ApiProperty()
isStale: boolean; declare isStale: boolean;
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
totalsByCurrency: MoneyDto[]; declare totalsByCurrency: MoneyDto[];
@ApiProperty({ type: PortfolioPositionDto, isArray: true }) @ApiProperty({ type: PortfolioPositionDto, isArray: true })
positions: PortfolioPositionDto[]; declare positions: PortfolioPositionDto[];
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
cashBalances: MoneyDto[]; declare cashBalances: MoneyDto[];
@ApiProperty({ type: PortfolioStructureDto }) @ApiProperty({ type: PortfolioStructureDto })
structure: PortfolioStructureDto; declare structure: PortfolioStructureDto;
} }
+13 -4
View File
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -270,7 +271,9 @@ describe('PortfolioService', () => {
const snapshots = ( const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> } service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots; ).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( tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
); );
@@ -284,7 +287,9 @@ describe('PortfolioService', () => {
const snapshots = ( const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> } service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots; ).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( access.withAccountForConnection.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
); );
@@ -298,7 +303,9 @@ describe('PortfolioService', () => {
const snapshots = ( const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> } service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots; ).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'; generation = 'generation-2';
tbank.getPortfolio.mockRejectedValueOnce( tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
@@ -314,7 +321,9 @@ describe('PortfolioService', () => {
const snapshots = ( const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> } service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots; ).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( access.withAccountForConnection.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
); );
+2 -2
View File
@@ -222,8 +222,8 @@ export class PortfolioService {
value: formatDecimal( value: formatDecimal(
multiply( multiply(
subtract( subtract(
decimal(position.currentPrice!), decimalFromString(currentPrice.value),
decimal(position.averagePositionPrice!), decimalFromString(averagePurchasePrice.value),
), ),
quantity, quantity,
), ),
+90 -72
View File
@@ -19,7 +19,7 @@ export class StockSearchQueryDto {
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(80) @MaxLength(80)
query: string; declare query: string;
} }
export class StockTickerParamsDto { export class StockTickerParamsDto {
@ApiProperty({ @ApiProperty({
@@ -31,7 +31,7 @@ export class StockTickerParamsDto {
@MinLength(1) @MinLength(1)
@MaxLength(80) @MaxLength(80)
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
ticker: string; declare ticker: string;
} }
export class StockCandlesQueryDto { export class StockCandlesQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -43,108 +43,126 @@ export class StockCandlesQueryDto {
period?: 'month' | 'halfYear' | 'year' | 'all'; period?: 'month' | 'halfYear' | 'year' | 'all';
} }
export class StockDto { export class StockDto {
@ApiProperty({ format: 'uuid' }) instrumentUid: string; @ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
@ApiProperty({ @ApiProperty({
description: 'Unambiguous ticker URL segment, or the exact instrument UID.', description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
}) })
slug: string; declare slug: string;
@ApiProperty() name: string; @ApiProperty() declare name: string;
@ApiProperty({ type: String, nullable: true }) isin: string | null; @ApiProperty({ type: String, nullable: true }) declare isin: string | null;
@ApiProperty({ type: String, nullable: true }) classCode: string | null; @ApiProperty({ type: String, nullable: true }) declare classCode:
@ApiProperty({ type: String, nullable: true }) realExchange: string | null; string | null;
@ApiProperty({ type: Boolean, nullable: true }) apiTradeAvailable: @ApiProperty({ type: String, nullable: true }) declare realExchange:
string | null;
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
boolean | null; boolean | null;
@ApiProperty({ type: String, nullable: true }) ticker: string | null; @ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) logoName: string | null; @ApiProperty({ type: String, nullable: true }) declare logoName:
@ApiProperty({ type: String, nullable: true }) currency: string | null; string | null;
@ApiProperty({ type: String, nullable: true }) shareType: string | null; @ApiProperty({ type: String, nullable: true }) declare currency:
@ApiProperty({ type: String, nullable: true }) sector: string | null; string | null;
@ApiProperty({ type: Number, nullable: true }) lot: number | 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' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
historyFrom: string | null; declare historyFrom: string | null;
} }
export class StockSearchResultDto extends StockDto { export class StockSearchResultDto extends StockDto {
@ApiProperty({ type: MoneyDto, nullable: true }) price: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true })
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: declare price: MoneyDto | null;
string | null; @ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
} }
export class StockQuoteDto { 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 }) @ApiProperty({ type: MoneyDto, nullable: true })
previousClose: MoneyDto | null; declare price: MoneyDto | null;
@ApiProperty({ type: [String] }) unavailable: string[]; @ApiProperty({ type: String, nullable: true, format: 'date-time' })
@ApiProperty({ format: 'date-time' }) observedAt: string; 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 { export class StockCandleDto {
@ApiProperty({ format: 'date-time' }) time: string; @ApiProperty({ format: 'date-time' }) declare time: string;
@ApiProperty() close: string; @ApiProperty() declare close: string;
@ApiProperty() isComplete: boolean; @ApiProperty() declare isComplete: boolean;
} }
export class StockCandlesDto { export class StockCandlesDto {
@ApiProperty({ type: [StockCandleDto] }) candles: StockCandleDto[]; @ApiProperty({ type: [StockCandleDto] }) declare candles: StockCandleDto[];
@ApiProperty({ type: String, nullable: true }) currency: string | null; @ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
historyFrom: string | null; declare historyFrom: string | null;
@ApiProperty({ format: 'date-time' }) observedAt: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string;
} }
export class StockPositionRowDto extends PortfolioPositionDto { 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 { export class StockPositionDto {
@ApiProperty() quantity: string; @ApiProperty() declare quantity: string;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
currentValue: MoneyDto | null; declare currentValue: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
averagePurchasePrice: MoneyDto | null; declare averagePurchasePrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
absoluteReturn: MoneyDto | null; declare absoluteReturn: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) returnPercent: string | null; @ApiProperty({ type: String, nullable: true }) declare returnPercent:
@ApiProperty({ type: [StockPositionRowDto] }) accounts: StockPositionRowDto[]; string | null;
@ApiProperty() isStale: boolean; @ApiProperty({ type: [StockPositionRowDto] })
@ApiProperty({ format: 'date-time' }) observedAt: string; declare accounts: StockPositionRowDto[];
@ApiProperty() declare isStale: boolean;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
} }
export class StockMetricDto { export class StockMetricDto {
@ApiProperty() key: string; @ApiProperty() declare key: string;
@ApiProperty() value: number; @ApiProperty() declare value: number;
@ApiProperty({ enum: ['money', 'ratio', 'percent'] }) unit: @ApiProperty({ enum: ['money', 'ratio', 'percent'] }) declare unit:
'money' | 'ratio' | 'percent'; 'money' | 'ratio' | 'percent';
@ApiProperty({ type: String, nullable: true }) currency: string | null; @ApiProperty({ type: String, nullable: true }) declare currency:
@ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] }) period: string | null;
'TTM' | 'MRQ' | null; @ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] })
declare period: 'TTM' | 'MRQ' | null;
} }
export class StockCompanyDto { export class StockCompanyDto {
@ApiProperty({ type: String, nullable: true }) description: string | null; @ApiProperty({ type: String, nullable: true }) declare description:
@ApiProperty({ type: [StockMetricDto] }) metrics: StockMetricDto[]; string | null;
@ApiProperty({ type: [String] }) unavailable: string[]; @ApiProperty({ type: [StockMetricDto] }) declare metrics: StockMetricDto[];
@ApiProperty({ format: 'date-time' }) observedAt: string; @ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
} }
export class StockDividendDto { export class StockDividendDto {
@ApiProperty({ type: MoneyDto, nullable: true }) perShare: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true })
@ApiProperty({ type: String, nullable: true, format: 'date' }) recordDate: declare perShare: MoneyDto | null;
string | null; @ApiProperty({ type: String, nullable: true, format: 'date' })
@ApiProperty({ type: String, nullable: true, format: 'date' }) lastBuyDate: declare recordDate: string | null;
string | null; @ApiProperty({ type: String, nullable: true, format: 'date' })
@ApiProperty({ type: String, nullable: true, format: 'date' }) paymentDate: declare lastBuyDate: string | null;
string | null; @ApiProperty({ type: String, nullable: true, format: 'date' })
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) timing: declare paymentDate: string | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
'upcoming' | 'past' | 'undated'; 'upcoming' | 'past' | 'undated';
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
estimatedAmount: MoneyDto | null; declare estimatedAmount: MoneyDto | null;
} }
export class StockDividendsDto { export class StockDividendsDto {
@ApiProperty({ type: [StockDividendDto] }) dividends: StockDividendDto[]; @ApiProperty({ type: [StockDividendDto] })
@ApiProperty({ format: 'date' }) from: string; declare dividends: StockDividendDto[];
@ApiProperty({ format: 'date' }) to: string; @ApiProperty({ format: 'date' }) declare from: string;
@ApiProperty({ format: 'date-time' }) observedAt: string; @ApiProperty({ format: 'date' }) declare to: string;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
positionAt: string | null; declare positionAt: string | null;
@ApiProperty() estimateUnavailable: boolean; @ApiProperty() declare estimateUnavailable: boolean;
@ApiProperty() positionIsStale: boolean; @ApiProperty() declare positionIsStale: boolean;
} }
+2
View File
@@ -97,6 +97,8 @@ describe('stock market use case', () => {
tbank.findShares.mockResolvedValue([ tbank.findShares.mockResolvedValue([
...listings, ...listings,
{ uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' }, { uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' },
{ instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
{ uid: '', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
]); ]);
tbank.getShare.mockImplementation((_token: string, uid: string) => tbank.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve({ Promise.resolve({
+29 -23
View File
@@ -39,6 +39,7 @@ import {
} from './stock.dto'; } from './stock.dto';
const dayMs = 86_400_000; const dayMs = 86_400_000;
type IdentifiedShare = TbankShare & { uid: string };
@Injectable() @Injectable()
export class StockService { export class StockService {
@@ -64,7 +65,7 @@ export class StockService {
async (share) => async (share) =>
(await optional( (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); if (error instanceof NotFoundException) return stock(share);
throw error; throw error;
}), }),
@@ -77,7 +78,7 @@ export class StockService {
() => () =>
this.tbank.getLastPrices( this.tbank.getLastPrices(
token, token,
shares.map((share) => share.uid!), shares.map((share) => share.uid),
), ),
'prices', 'prices',
[], [],
@@ -131,11 +132,14 @@ export class StockService {
async getByTicker(userId: string, ticker: string): Promise<StockDto> { async getByTicker(userId: string, ticker: string): Promise<StockDto> {
const match = await this.tickerMatch(userId, ticker); const match = await this.tickerMatch(userId, ticker);
return { return {
...(await this.get(userId, match.uid!)), ...(await this.get(userId, match.uid)),
slug: ticker.trim().toUpperCase(), slug: ticker.trim().toUpperCase(),
}; };
} }
private tickerMatch(userId: string, ticker: string): Promise<TbankShare> { private tickerMatch(
userId: string,
ticker: string,
): Promise<IdentifiedShare> {
const normalized = ticker.trim().toUpperCase(); const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (token) => { return this.cached(userId, `ticker:${normalized}`, async (token) => {
const found = await this.tbank.findShares(token, normalized); const found = await this.tbank.findShares(token, normalized);
@@ -295,7 +299,8 @@ export class StockService {
return this.cached(userId, `company:${uid}`, async (token) => { return this.cached(userId, `company:${uid}`, async (token) => {
const share = await this.share(token, uid); const share = await this.share(token, uid);
const unavailable: string[] = []; const unavailable: string[] = [];
if (!share.assetUid) const assetUid = share.assetUid;
if (!assetUid)
return { return {
description: null, description: null,
metrics: [], metrics: [],
@@ -304,12 +309,12 @@ export class StockService {
}; };
const [description, fundamentals] = await Promise.all([ const [description, fundamentals] = await Promise.all([
optional( optional(
() => this.tbank.getAssetDescription(token, share.assetUid!), () => this.tbank.getAssetDescription(token, assetUid),
'description', 'description',
unavailable, unavailable,
), ),
optional( optional(
() => this.tbank.getFundamentals(token, share.assetUid!), () => this.tbank.getFundamentals(token, assetUid),
'fundamentals', 'fundamentals',
unavailable, unavailable,
), ),
@@ -353,12 +358,12 @@ export class StockService {
? { currency, value: formatDecimal(decimal(event.dividendNet)) } ? { currency, value: formatDecimal(decimal(event.dividendNet)) }
: null; : null;
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. // 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'] = const eventDate = paymentDate ?? recordDate;
(paymentDate ?? recordDate) const timing: StockDividendDto['timing'] = eventDate
? (paymentDate ?? recordDate)! >= today ? eventDate >= today
? 'upcoming' ? 'upcoming'
: 'past' : 'past'
: 'undated'; : 'undated';
const estimatedAmount = const estimatedAmount =
quantity && quantity &&
perShare && perShare &&
@@ -393,10 +398,10 @@ export class StockService {
}; };
}); });
} }
private async share(token: string, uid: string): Promise<TbankShare> { private async share(token: string, uid: string): Promise<IdentifiedShare> {
const share = await this.tbank.getShare(token, uid); const share = await this.tbank.getShare(token, uid);
if (!share || share.uid !== uid) throw new NotFoundException(); if (!share || share.uid !== uid) throw new NotFoundException();
return share; return { ...share, uid };
} }
private cached<T extends object>( private cached<T extends object>(
userId: string, 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<string>(); const seen = new Set<string>();
return found return found
.filter( .filter(
(share) => share.uid && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE', (share): share is IdentifiedShare =>
Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE',
) )
.sort( .sort(
(a, b) => (a, b) =>
@@ -430,24 +436,24 @@ function selectShares(found: TbankShare[], query: string): TbankShare[] {
Number(b.ticker?.toUpperCase() === query.toUpperCase()) - Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') || Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') ||
a.uid!.localeCompare(b.uid!), a.uid.localeCompare(b.uid),
) )
.filter((share) => { .filter((share) => {
// A missing ISIN must not collapse unrelated catalog instruments. // A missing ISIN must not collapse unrelated catalog instruments.
const key = share.isin?.trim() const key = share.isin?.trim()
? `isin:${share.isin.trim().toUpperCase()}` ? `isin:${share.isin.trim().toUpperCase()}`
: `uid:${share.uid!}`; : `uid:${share.uid}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
}); });
} }
function stock(share: TbankShare): StockDto { function stock(share: IdentifiedShare): StockDto {
return { return {
instrumentUid: share.uid!, instrumentUid: share.uid,
slug: share.uid!, slug: share.uid,
name: share.name?.trim() || share.ticker || share.uid!, name: share.name?.trim() || share.ticker || share.uid,
ticker: share.ticker?.trim() || null, ticker: share.ticker?.trim() || null,
isin: share.isin?.trim() || null, isin: share.isin?.trim() || null,
classCode: share.classCode?.trim() || null, classCode: share.classCode?.trim() || null,
+3 -3
View File
@@ -6,13 +6,13 @@ export class PutTbankConnectionDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(4096) @MaxLength(4096)
token: string; declare token: string;
} }
export class TbankConnectionStatusDto { export class TbankConnectionStatusDto {
@ApiProperty() @ApiProperty()
connected: boolean; declare connected: boolean;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
connectedAt: string | null; declare connectedAt: string | null;
} }
+16 -12
View File
@@ -233,11 +233,12 @@ export class TbankClient {
} }
getOpenAccounts(token: string): Promise<TbankAccount[]> { getOpenAccounts(token: string): Promise<TbankAccount[]> {
return this.call(token, this.users, 'getAccounts', { return this.call<{ accounts?: TbankAccount[] }>(
status: 'ACCOUNT_STATUS_OPEN', token,
}).then( this.users,
(response: { accounts?: TbankAccount[] }) => response.accounts ?? [], 'getAccounts',
); { status: 'ACCOUNT_STATUS_OPEN' },
).then((response) => response.accounts ?? []);
} }
getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> { getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> {
@@ -275,13 +276,16 @@ export class TbankClient {
from: string, from: string,
to: string, to: string,
): Promise<TbankDividend[]> { ): Promise<TbankDividend[]> {
return this.call(token, this.instruments, 'getDividends', { return this.call<{ dividends?: TbankDividend[] }>(
instrumentId, token,
from: { seconds: String(utcSeconds(from)) }, this.instruments,
to: { seconds: String(utcSeconds(to)) }, 'getDividends',
}).then( {
(response: { dividends?: TbankDividend[] }) => response.dividends ?? [], instrumentId,
); from: { seconds: String(utcSeconds(from)) },
to: { seconds: String(utcSeconds(to)) },
},
).then((response) => response.dividends ?? []);
} }
findShares(token: string, query: string): Promise<TbankShare[]> { findShares(token: string, query: string): Promise<TbankShare[]> {
+14 -14
View File
@@ -29,46 +29,46 @@ export class UpcomingEventDto {
account?: AccountReferenceDto; account?: AccountReferenceDto;
@ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] }) @ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] })
type: 'coupon' | 'dividend' | 'maturity' | 'offer'; declare type: 'coupon' | 'dividend' | 'maturity' | 'offer';
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
date: string; declare date: string;
@ApiProperty({ enum: ['event', 'registry'] }) @ApiProperty({ enum: ['event', 'registry'] })
dateKind: 'event' | 'registry'; declare dateKind: 'event' | 'registry';
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
figi: string | null; declare figi: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
instrumentUid: string | null; declare instrumentUid: string | null;
@ApiProperty() @ApiProperty()
ticker: string; declare ticker: string;
@ApiProperty() @ApiProperty()
displayName: string; declare displayName: string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
isin: string | null; declare isin: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
logoName: string | null; declare logoName: string | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
projectedAmount: MoneyDto | null; declare projectedAmount: MoneyDto | null;
} }
export class UpcomingEventsDto { export class UpcomingEventsDto {
@ApiProperty() @ApiProperty()
accountId: string; declare accountId: string;
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
calculatedAt: string; declare calculatedAt: string;
@ApiProperty({ type: UpcomingEventDto, isArray: true }) @ApiProperty({ type: UpcomingEventDto, isArray: true })
events: UpcomingEventDto[]; declare events: UpcomingEventDto[];
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
incomeTotalsByCurrency: MoneyDto[]; declare incomeTotalsByCurrency: MoneyDto[];
} }
+4 -2
View File
@@ -71,7 +71,9 @@ export class UpcomingEventsService {
return this.access.withSupportedAccounts( return this.access.withSupportedAccounts(
userId, userId,
async ({ token, accounts }) => { async ({ token, accounts }) => {
const events: UpcomingEventDto[] = []; const events: (UpcomingEventDto & {
account: { id: string; name: string };
})[] = [];
for (const account of accounts) { for (const account of accounts) {
const result = await this.forAccount(token, account.id, window); const result = await this.forAccount(token, account.id, window);
events.push( events.push(
@@ -84,7 +86,7 @@ export class UpcomingEventsService {
events.sort( events.sort(
(left, right) => (left, right) =>
left.date.localeCompare(right.date) || left.date.localeCompare(right.date) ||
left.account!.id.localeCompare(right.account!.id), left.account.id.localeCompare(right.account.id),
); );
return { return {
accountId: 'all', accountId: 'all',
+5 -2
View File
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing'; 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'); expect((await getCard(own.id)).balance.value).toBe('1000');
}); });
it('renames default category without role loss; duplicates and stale rename conflict', async () => { 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) await auth('patch', '/categories/' + category.id)
.send({ version: category.version, name: 'Остальное' }) .send({ version: category.version, name: 'Остальное' })
.expect(200); .expect(200);
@@ -474,7 +476,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
'delete', 'delete',
`/operations/${expense.id}?version=${current.version}`, `/operations/${expense.id}?version=${current.version}`,
).expect(409); ).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) await auth('patch', '/operations/' + expense.id)
.send({ version: current.version, categoryId: category.id }) .send({ version: current.version, categoryId: category.id })
.expect(200); .expect(200);
+2 -4
View File
@@ -16,10 +16,8 @@
"baseUrl": "./", "baseUrl": "./",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictNullChecks": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "noFallthroughCasesInSwitch": true
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
} }
} }