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