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:
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MoneyDto {
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
declare currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
value: string;
|
||||
declare value: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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[]> {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user