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