refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,150 @@
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto';
jest.mock('bcrypt', () => ({
hash: jest.fn(() => Promise.resolve('password-hash')),
compare: jest.fn(() => Promise.resolve(true)),
}));
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { AuthService } from './auth.service';
const user = {
id: 'user',
email: 'user@example.com',
passwordHash: 'password-hash',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
function hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function twoCallBarrier<T>(value: T): () => Promise<T> {
let calls = 0;
let release: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
return async () => {
calls += 1;
if (calls === 2) release();
await ready;
return value;
};
}
describe('AuthService', () => {
const jwt = new JwtService({ secret: 'test-secret-that-is-long-enough' });
const prisma = {
user: { findUnique: jest.fn(), create: jest.fn() },
session: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
delete: jest.fn(),
},
};
const service = new AuthService(prisma as never, jwt);
beforeEach(() => {
jest.clearAllMocks();
});
it('maps the database loser of a concurrent registration to EMAIL_ALREADY_REGISTERED', async () => {
prisma.user.findUnique.mockImplementation(twoCallBarrier(null));
prisma.user.create
.mockResolvedValueOnce(user)
.mockRejectedValueOnce(
Object.assign(new Error('duplicate email'), { code: 'P2002' }),
);
prisma.session.create.mockResolvedValue({
id: 'session',
userId: user.id,
refreshTokenHash: '',
expiresAt: new Date(),
revokedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
});
prisma.session.update.mockResolvedValue({});
const results = await Promise.allSettled([
service.register(user.email, 'secure password'),
service.register(user.email, 'secure password'),
]);
const rejected = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
expect(rejected?.reason).toEqual(expect.any(ConflictException));
expect((rejected?.reason as ConflictException).getResponse()).toEqual({
code: 'EMAIL_ALREADY_REGISTERED',
});
});
it('allows one concurrent refresh winner without revoking its session', async () => {
const oldToken = await jwt.signAsync(
{ sub: user.id, sid: 'session', type: 'refresh', jti: 'old-token' },
{ expiresIn: '7d', algorithm: 'HS256' },
);
let currentHash = hash(oldToken);
const session = {
id: 'session',
userId: user.id,
refreshTokenHash: currentHash,
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
user,
};
prisma.session.findUnique.mockImplementation(twoCallBarrier(session));
prisma.session.updateMany.mockImplementation(
({
where,
data,
}: {
where: { id?: string; refreshTokenHash?: string };
data: { refreshTokenHash: string };
}) => {
if (where.id === session.id && where.refreshTokenHash === currentHash) {
currentHash = data.refreshTokenHash;
return Promise.resolve({ count: 1 });
}
return Promise.resolve({ count: 0 });
},
);
const results = await Promise.allSettled([
service.refresh(oldToken),
service.refresh(oldToken),
]);
const rejected = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
expect(rejected?.reason).toEqual(expect.any(UnauthorizedException));
expect(prisma.session.updateMany).toHaveBeenCalledTimes(2);
expect(
prisma.session.updateMany.mock.calls.map(
([input]) =>
(input as { where: { id?: string; refreshTokenHash?: string } })
.where,
),
).toContainEqual({ id: session.id, refreshTokenHash: hash(oldToken) });
expect(prisma.session.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,203 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types';
const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH =
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async register(
email: string,
password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const normalizedEmail = this.normalizeEmail(email);
const existing = await this.prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (existing)
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
let user: User;
try {
user = await this.prisma.user.create({
data: {
id: randomUUID(),
email: normalizedEmail,
passwordHash: await bcrypt.hash(password, 12),
},
});
} catch (error) {
if (isUniqueConstraintError(error))
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
throw error;
}
return this.createSession(user);
}
async login(
email: string,
password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const user = await this.prisma.user.findUnique({
where: { email: this.normalizeEmail(email) },
});
const valid = await bcrypt.compare(
password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
);
if (!user || !valid)
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
return this.createSession(user);
}
async refresh(
token: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
let payload: RefreshPayload;
try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
} catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
if (payload.type !== 'refresh')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
include: { user: true },
});
if (
!session ||
session.userId !== payload.sub ||
session.revokedAt ||
session.expiresAt <= new Date()
) {
if (session && !session.revokedAt)
await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
if (session.refreshTokenHash !== this.hash(token))
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.rotateSession(session, session.refreshTokenHash);
}
async logout(token?: string): Promise<void> {
if (!token) return;
try {
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
}
} catch {
// Logout is intentionally idempotent.
}
}
toUser(user: User): AuthUser {
return { id: user.id, email: user.email };
}
async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return user;
}
private async createSession(
user: User,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const session = await this.prisma.session.create({
data: {
id: randomUUID(),
userId: user.id,
refreshTokenHash: '',
expiresAt: new Date(),
},
});
return this.rotateSession({ ...session, user });
}
private async rotateSession(
session: Session & { user: User },
expectedRefreshTokenHash?: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const refreshToken = await this.jwt.signAsync(
{
sub: session.userId,
sid: session.id,
type: 'refresh',
jti: randomUUID(),
},
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
);
const data = {
refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
};
if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
if (updated.count !== 1)
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
} else {
await this.prisma.session.update({ where: { id: session.id }, data });
}
return {
user: this.toUser(session.user),
accessToken: await this.jwt.signAsync(
{ sub: session.userId, sid: session.id, type: 'access' },
{ expiresIn: ACCESS_TTL, algorithm: 'HS256' },
),
refreshToken,
};
}
private normalizeEmail(email: string): string {
return email;
}
private hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'P2002'
);
}
@@ -0,0 +1,17 @@
export interface AuthUser {
id: string;
email: string;
}
export interface AccessPayload {
sub: string;
sid: string;
type: 'access';
}
export interface RefreshPayload {
sub: string;
sid: string;
type: 'refresh';
jti: string;
}
+3
View File
@@ -0,0 +1,3 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { type AccessPayload } from './application/auth.types';
export { JwtAuthGuard } from './presentation/jwt-auth.guard';
+26
View File
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation';
import { AuthController } from './presentation/auth.controller';
import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './infrastructure/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
secret: config.getOrThrow('JWT_SECRET'),
}),
}),
],
exports: [JwtAuthGuard],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard],
})
export class AuthModule {}
@@ -0,0 +1,24 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService<Environment, true>) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow('JWT_SECRET'),
algorithms: ['HS256'],
});
}
validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return payload;
}
}
@@ -0,0 +1,21 @@
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
jest.mock('../application/auth.service', () => ({
AuthService: class AuthService {},
}));
import { AuthController } from './auth.controller';
describe('AuthController response statuses', () => {
it.each(['register', 'login', 'refresh'] as const)(
'%s explicitly returns HTTP 200',
(handler) => {
// Nest stores @HttpCode metadata on the unbound handler function.
const handlerTarget = Object.getOwnPropertyDescriptor(
AuthController.prototype,
handler,
)?.value as object;
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200);
},
);
});
@@ -0,0 +1,143 @@
import {
Body,
Controller,
Get,
HttpCode,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiProperty,
ApiTags,
} from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto';
import { AuthService } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types';
import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token';
const COOKIE_PATH = '/api/v1/auth';
class UserDto {
@ApiProperty()
declare id: string;
@ApiProperty()
declare email: string;
}
class AuthenticationDto {
@ApiProperty()
declare accessToken: string;
@ApiProperty({ type: UserDto })
declare user: UserDto;
}
@ApiTags('auth')
@ApiProblemResponses()
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
@HttpCode(200)
@ApiOkResponse({ type: AuthenticationDto })
async register(
@Body() credentials: CredentialsDto,
@Res({ passthrough: true }) response: Response,
) {
return this.respond(
response,
await this.auth.register(credentials.email, credentials.password),
);
}
@Post('login')
@HttpCode(200)
@ApiOkResponse({ type: AuthenticationDto })
async login(
@Body() credentials: CredentialsDto,
@Res({ passthrough: true }) response: Response,
) {
return this.respond(
response,
await this.auth.login(credentials.email, credentials.password),
);
}
@Post('refresh')
@HttpCode(200)
@ApiOkResponse({ type: AuthenticationDto })
async refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
try {
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
this.clearCookie(response);
throw error;
}
}
@Post('logout')
@HttpCode(204)
@ApiNoContentResponse()
async logout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
await this.auth.logout(this.refreshToken(request));
this.clearCookie(response);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: UserDto })
async me(@Req() request: Request & { user: AccessPayload }) {
const user = await this.auth.userById(request.user.sub);
return this.auth.toUser(user);
}
private respond(
response: Response,
result: {
user: { id: string; email: string };
accessToken: string;
refreshToken: string;
},
) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
return { user: result.user, accessToken: result.accessToken };
}
private clearCookie(response: Response): void {
response.clearCookie(REFRESH_COOKIE, this.cookieOptions());
}
private refreshToken(request: Request): string | undefined {
const token: unknown = request.cookies?.[REFRESH_COOKIE];
return typeof token === 'string' ? token : undefined;
}
private cookieOptions() {
return {
httpOnly: true,
sameSite: 'lax' as const,
secure: process.env['NODE_ENV'] !== 'development',
path: COOKIE_PATH,
};
}
}
+57
View File
@@ -0,0 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsString,
MinLength,
Validate,
ValidatorConstraint,
} from 'class-validator';
@ValidatorConstraint({ name: 'email' })
class EmailConstraint {
validate(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length <= 254 &&
value.split('@').length === 2 &&
!value
.split('')
.some(
(character) =>
/\s/.test(character) ||
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127,
) &&
value.split('@').every(Boolean)
);
}
defaultMessage(): string {
return 'must be a valid email address';
}
}
@ValidatorConstraint({ name: 'passwordBytes' })
class PasswordBytesConstraint {
validate(value: unknown): boolean {
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72;
}
defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes';
}
}
export class CredentialsDto {
@ApiProperty({ example: 'user@example.com' })
@IsString()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@Validate(EmailConstraint)
declare email: string;
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' })
@IsString()
@MinLength(12)
@Validate(PasswordBytesConstraint)
declare password: string;
}
@@ -0,0 +1,21 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const allowed = await super.canActivate(context);
const request = context
.switchToHttp()
.getRequest<{ user?: AccessPayload }>();
if (request.user?.sub)
this.requestContext.set({ userId: request.user.sub });
return Boolean(allowed);
}
}
@@ -0,0 +1,109 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import {
bankPaymentSystems,
BankPeriodQueryDto,
CreateBankCardDto,
CreateBankOperationDto,
} from '../presentation/bank-card.dto';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
export function fail(code = 'BANK_INVALID_INPUT'): never {
throw new BadRequestException({ code });
}
export function periodBounds(query: BankPeriodQueryDto) {
if (query.month && !query.from && !query.to) {
const from = `${query.month}-01`;
if (!isCalendarDate(from)) return fail();
const end = new Date(`${from}T00:00:00Z`);
end.setUTCMonth(end.getUTCMonth() + 1, 0);
return { from, to: end.toISOString().slice(0, 10) };
}
if (
query.month ||
!query.from ||
!query.to ||
!isCalendarDate(query.from) ||
!isCalendarDate(query.to) ||
query.from > query.to
)
return fail();
return { from: query.from, to: query.to };
}
export function conflict(code = 'BANK_VERSION_CONFLICT'): never {
throw new ConflictException({ code });
}
export function missing(): never {
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
}
export const fingerprint = (value: unknown) =>
createHash('sha256')
.update(
JSON.stringify(value, (_key, item: unknown) =>
typeof item === 'bigint' ? item.toString() : item,
),
)
.digest('hex');
export function amount(
value: Parameters<typeof parseRubMoney>[0],
positive = false,
) {
try {
return parseRubMoney(value, positive);
} catch {
return fail('BANK_INVALID_MONEY');
}
}
export function calendar(value: string) {
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
return value;
}
export function currentDate(value: string) {
calendar(value);
if (value > moscowToday()) fail('BANK_INVALID_DATE');
}
export function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
fail();
return value.trim();
}
export function cardInput(dto: CreateBankCardDto) {
const bankName = dto.bankName?.trim() || null;
if (bankName && bankName.length > 100) fail();
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
if (
dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
)
fail();
return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, 100),
bankName,
lastFour: dto.lastFour || null,
openingBalanceKopecks: amount(dto.openingBalance),
openingDate: calendar(dto.openingDate),
};
}
export function operationInput(dto: CreateBankOperationDto) {
if (
dto.description != null &&
(typeof dto.description !== 'string' || dto.description.length > 500)
)
fail();
return {
kind: dto.kind,
cardId: dto.cardId,
targetCardId: dto.targetCardId ?? null,
categoryId: dto.categoryId ?? null,
originalExpenseId: dto.originalExpenseId ?? null,
amountKopecks: amount(dto.amount, true),
date: calendar(dto.date),
description: dto.description || null,
};
}
@@ -0,0 +1,145 @@
import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankCardDto,
CreateBankCardDto,
UpdateBankCardDto,
} from '../presentation/bank-card.dto';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import {
cardInput,
conflict,
currentDate,
fail,
fingerprint,
missing,
} from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankCardsService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
return {
id: card.id,
name: card.name,
bankName: card.bankName,
lastFour: card.lastFour,
paymentSystem: card.paymentSystem,
openingDate: card.openingDate,
openingBalance: rubMoney(card.openingBalanceKopecks),
balance: rubMoney(bankBalance(card.id, card.openingBalanceKopecks, rows)),
version: card.version,
archivedAt: card.archivedAt?.toISOString() ?? null,
};
}
list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) =>
Promise.all(
(
await tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
).map((card) => this.cardView(tx, card)),
),
);
}
get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)),
);
}
createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto),
hash = fingerprint(input);
return this.repository.write(async (tx) => {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
return this.cardView(tx, existing);
}
currentDate(input.openingDate);
await this.repository.seed(tx, userId);
return this.cardView(
tx,
await tx.bankCard.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.repository.write(async (tx) => {
const previous = await this.repository.card(tx, userId, id);
if (previous.version !== dto.version) conflict();
const input = cardInput({
id,
name: dto.name ?? previous.name,
bankName:
dto.bankName === undefined
? (previous.bankName ?? undefined)
: (dto.bankName ?? undefined),
lastFour:
dto.lastFour === undefined
? (previous.lastFour ?? undefined)
: (dto.lastFour ?? undefined),
paymentSystem:
dto.paymentSystem === undefined
? previous.paymentSystem
: dto.paymentSystem,
openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate,
});
const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) conflict('BANK_CARD_ARCHIVED');
currentDate(input.openingDate);
if (
await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
)
fail('BANK_OPENING_DATE_CONFLICT');
const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version },
data: {
...input,
paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 },
...(dto.archived === undefined
? {}
: {
archivedAt: dto.archived
? (previous.archivedAt ?? new Date())
: null,
}),
},
});
if (changed.count !== 1) conflict();
return this.cardView(tx, await this.repository.card(tx, userId, id));
});
}
}
@@ -0,0 +1,339 @@
import { GoneException, Injectable } from '@nestjs/common';
import {
BankCardOperation,
Prisma,
} from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankOperationDto,
BankOperationsQueryDto,
BankPageQueryDto,
CreateBankOperationDto,
UpdateBankOperationDto,
} from '../presentation/bank-card.dto';
import { rubMoney } from '../domain/rub-money';
import {
conflict,
currentDate,
fail,
fingerprint,
missing,
operationInput,
periodBounds,
} from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankOperationsService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async operationView(
tx: Tx,
row: BankCardOperation,
): Promise<BankOperationDto> {
const card = await this.repository.card(tx, row.userId, row.cardId);
const target = row.targetCardId
? await this.repository.card(tx, row.userId, row.targetCardId)
: null;
const source = row.originalExpenseId
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
: null;
const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId
? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId },
})
: null;
const refunds =
row.kind === 'expense' || source
? await tx.bankCardOperation.findMany({
where: {
userId: row.userId,
originalExpenseId: source?.id ?? row.id,
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
cardId: row.cardId,
cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: category?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === 'expense' || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
}
private async validateOperation(
tx: Tx,
userId: string,
input: ReturnType<typeof operationInput>,
old?: BankCardOperation,
) {
const cards = new Set(
[input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter(
(id): id is string => Boolean(id),
),
);
currentDate(input.date);
for (const id of cards) {
const card = await this.repository.card(tx, userId, id, true);
if (
(id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate
)
fail('BANK_INVALID_DATE');
}
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
)
fail('BANK_IMMUTABLE_OPERATION');
if (input.kind === 'transfer') {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
)
fail();
} else if (input.targetCardId) fail();
if (input.kind === 'expense') {
if (input.originalExpenseId) fail();
if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing();
input.categoryId = category.id;
}
if (
!(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId },
}))
)
missing();
} else if (input.categoryId) fail();
if (input.kind !== 'refund' && input.originalExpenseId) fail();
if (input.kind === 'refund') {
if (!input.originalExpenseId) fail();
const source = await this.repository.operation(
tx,
userId,
input.originalExpenseId,
);
if (
source.deletedAt ||
source.kind !== 'expense' ||
source.cardId !== input.cardId
)
fail('BANK_INVALID_REFUND');
if (
(
await tx.bankCardOperation.updateMany({
where: {
id: source.id,
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== 1
)
conflict();
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: source.id,
deletedAt: null,
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail('BANK_INVALID_DATE');
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks >
source.amountKopecks
)
conflict('BANK_REFUND_LIMIT');
}
if (old?.kind === 'expense') {
const refunds = await tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if (
refunds.length &&
(input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks)
)
conflict('BANK_DEPENDENT_REFUNDS');
}
}
createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto),
hash = fingerprint(normalized);
return this.repository.write(async (tx) => {
const existing = await tx.bankCardOperation.findUnique({
where: { id: dto.id },
});
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
if (existing.deletedAt)
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
return this.operationView(tx, existing);
}
const input = { ...normalized };
await this.validateOperation(tx, userId, input);
return this.operationView(
tx,
await tx.bankCardOperation.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => {
const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) missing();
return this.operationView(tx, row);
});
}
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.repository.write(async (tx) => {
const old = await this.repository.operation(tx, userId, id);
if (old.deletedAt) missing();
if (old.version !== dto.version) conflict();
const input = operationInput({
id,
kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']),
cardId: dto.cardId ?? old.cardId,
targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined,
categoryId: dto.categoryId ?? old.categoryId ?? undefined,
originalExpenseId:
dto.originalExpenseId ?? old.originalExpenseId ?? undefined,
amount: dto.amount ?? rubMoney(old.amountKopecks),
date: dto.date ?? old.date,
description: dto.description ?? old.description ?? undefined,
});
await this.validateOperation(tx, userId, input, old);
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } },
})
).count !== 1
)
conflict();
return this.operationView(
tx,
await this.repository.operation(tx, userId, id),
);
});
}
deleteOperation(userId: string, id: string, version: number) {
return this.repository.write(async (tx) => {
const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) return;
if (row.version !== version) conflict();
await this.repository.card(tx, userId, row.cardId, true);
if (row.targetCardId)
await this.repository.card(tx, userId, row.targetCardId, true);
if (
row.kind === 'expense' &&
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
)
conflict('BANK_DEPENDENT_REFUNDS');
if (row.originalExpenseId) {
const source = await this.repository.operation(
tx,
userId,
row.originalExpenseId,
);
if (
(
await tx.bankCardOperation.updateMany({
where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } },
})
).count !== 1
)
conflict();
}
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== 1
)
conflict();
});
}
history(
userId: string,
id: string,
query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false,
) {
const period = expenses
? undefined
: periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id);
const page = query.page ?? 1,
limit = query.limit ?? 50;
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: 'expense' }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limit,
take: limit,
});
return {
items: await Promise.all(
rows.map((row) => this.operationView(tx, row)),
),
total,
page,
hasNext: page * limit < total,
};
});
}
}
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
import { conflict, missing, name } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@Injectable()
export class ExpenseCategoriesService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
async categories(userId: string) {
return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
}
createCategory(userId: string, value: string) {
const label = name(value, 80);
return this.repository.write(async (tx) => {
await this.repository.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, 80);
return this.repository.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== 1
)
conflict();
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + 1,
};
});
}
}
@@ -0,0 +1,50 @@
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '../../../../prisma/prisma.service';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
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 GetBankSummaryUseCase(
new BankCardsRepository({
$transaction: (run: (client: typeof tx) => unknown) => run(tx),
} as unknown as PrismaService),
);
await expect(
service.execute('user', { month: '2026-09' }),
).resolves.toMatchObject({
categories: [
{
categoryId: 'food',
categoryName: 'Продукты',
net: { value: '1.23' },
},
],
});
categories.mockResolvedValue([]);
await expect(service.execute('user', { month: '2026-09' })).rejects.toThrow(
'Bank operation category is missing',
);
});
});
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { bankSummary } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { periodBounds } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@Injectable()
export class GetBankSummaryUseCase {
constructor(private readonly repository: BankCardsRepository) {}
async execute(userId: string, query: BankSummaryQueryDto) {
const period = periodBounds(query);
const { operations, categories } = await this.repository.summaryData(
userId,
period,
query.cardId,
);
const totals = bankSummary(period, operations);
return {
month: query.month,
...period,
cardId: query.cardId ?? null,
expenses: rubMoney(totals.expenses),
refunds: rubMoney(totals.refunds),
net: rubMoney(totals.net),
categories: totals.categories.map((row) => {
const category = categories.find((c) => c.id === row.categoryId);
if (!category) throw new Error('Bank operation category is missing');
return {
categoryId: row.categoryId,
categoryName: category.name,
expenses: rubMoney(row.expenses),
refunds: rubMoney(row.refunds),
net: rubMoney(row.net),
};
}),
};
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { BankCardsController } from './presentation/bank-cards.controller';
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
import { BankCardsService } from './application/bank-cards.service';
import { BankOperationsService } from './application/bank-operations.service';
import { ExpenseCategoriesService } from './application/expense-categories.service';
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
@Module({
imports: [AuthModule],
controllers: [BankCardsController],
providers: [
GetBankSummaryUseCase,
ExpenseCategoriesService,
BankOperationsService,
BankCardsRepository,
BankCardsService,
],
})
export class BankCardsModule {}
@@ -0,0 +1,153 @@
import {
bankBalance,
bankSummary,
isCalendarDate,
moscowToday,
} from './bank-card.calculations';
describe('bank card calculations', () => {
it('uses Moscow midnight for today and the current month', () => {
expect(moscowToday(new Date('2026-08-31T20:59:59.999Z'))).toBe(
'2026-08-31',
);
expect(moscowToday(new Date('2026-08-31T21:00:00.000Z'))).toBe(
'2026-09-01',
);
});
it.each([
'0001-01-01',
'9999-12-31',
'2024-02-29',
'2000-02-29',
'2026-09-12',
])('accepts calendar date %s', (value) => {
expect(isCalendarDate(value)).toBe(true);
});
it.each([
'0000-01-01',
'10000-01-01',
'2026-02-29',
'1900-02-29',
'2026-04-31',
'2026-00-01',
'2026-13-01',
'2026-01-00',
'2026-01-32',
'2026-1-01',
'2026-09-12\n',
'2026-09-12T00:00:00Z',
'',
])('rejects invalid calendar date %s', (value) => {
expect(isCalendarDate(value)).toBe(false);
});
it('folds expenses, topups, refunds and both transfer sides exactly', () => {
const operations = [
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 10n },
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 20n },
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 15n },
{ kind: 'refund', cardId: 'a', targetCardId: null, amountKopecks: 5n },
{ kind: 'transfer', cardId: 'a', targetCardId: 'b', amountKopecks: 120n },
{ kind: 'expense', cardId: 'c', targetCardId: null, amountKopecks: 300n },
];
expect(bankBalance('a', 100n, operations.slice(0, 2))).toBe(70n);
expect(bankBalance('a', 100n, operations)).toBe(-30n);
expect(bankBalance('b', 0n, operations)).toBe(120n);
expect(bankBalance('unused', -1n, operations)).toBe(-1n);
});
it('does not bound computed balances by the storage range', () => {
expect(
bankBalance('a', 2n ** 63n - 1n, [
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 1n },
]),
).toBe(2n ** 63n);
expect(
bankBalance('a', -(2n ** 63n), [
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 1n },
]),
).toBe(-(2n ** 63n) - 1n);
});
it('counts refunds in their own month and inherited category, excluding transfers and topups', () => {
const operations = [
{
kind: 'expense',
date: '2026-08-31',
categoryId: 'food',
amountKopecks: 1000n,
},
{
kind: 'refund',
date: '2026-09-01',
categoryId: 'food',
amountKopecks: 500n,
},
{
kind: 'expense',
date: '2026-09-30',
categoryId: 'food',
amountKopecks: 100n,
},
{
kind: 'expense',
date: '2026-09-03',
categoryId: 'travel',
amountKopecks: 20n,
},
{
kind: 'transfer',
date: '2026-09-03',
categoryId: null,
amountKopecks: 2000n,
},
{
kind: 'topup',
date: '2026-09-03',
categoryId: null,
amountKopecks: 3000n,
},
{
kind: 'expense',
date: '2026-10-01',
categoryId: 'travel',
amountKopecks: 5000n,
},
];
expect(bankSummary('2026-09', operations)).toEqual({
expenses: 120n,
refunds: 500n,
net: -380n,
categories: [
{ categoryId: 'food', expenses: 100n, refunds: 500n, net: -400n },
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
],
});
expect(bankSummary('2026-07', operations)).toEqual({
expenses: 0n,
refunds: 0n,
net: 0n,
categories: [],
});
});
it('keeps summary sums beyond signed 64-bit exact', () => {
const operation = {
kind: 'expense',
date: '2026-09-01',
categoryId: 'food',
amountKopecks: 2n ** 63n - 1n,
};
const total = 2n * (2n ** 63n - 1n);
expect(bankSummary('2026-09', [operation, operation])).toEqual({
expenses: total,
refunds: 0n,
net: total,
categories: [
{ categoryId: 'food', expenses: total, refunds: 0n, net: total },
],
});
});
});
@@ -0,0 +1,91 @@
export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Moscow',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
}
export function isCalendarDate(value: string): boolean {
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
if (value.startsWith('0000')) return false;
const date = new Date(`${value}T00:00:00.000Z`);
return (
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
);
}
export function bankBalance(
cardId: string,
opening: bigint,
operations: Array<{
kind: string;
cardId: string;
targetCardId: string | null;
amountKopecks: bigint;
}>,
): bigint {
// ponytail: O(N) history fold; optimize only after measuring growing histories.
let balance = opening;
for (const operation of operations) {
if (operation.cardId === cardId) {
if (operation.kind === 'expense' || operation.kind === 'transfer')
balance -= operation.amountKopecks;
else if (operation.kind === 'topup' || operation.kind === 'refund')
balance += operation.amountKopecks;
}
if (operation.kind === 'transfer' && operation.targetCardId === cardId)
balance += operation.amountKopecks;
}
return balance;
}
export function bankSummary(
period: string | { from: string; to: string },
operations: Array<{
kind: string;
date: string;
amountKopecks: bigint;
categoryId: string | null;
}>,
) {
let expenses = 0n;
let refunds = 0n;
const categories = new Map<
string,
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
>();
// ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) {
if (
typeof period === 'string'
? operation.date.slice(0, 7) !== period
: operation.date < period.from || operation.date > period.to
)
continue;
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
expenses += expense;
refunds += refund;
if (operation.categoryId !== null) {
const category = categories.get(operation.categoryId) ?? {
categoryId: operation.categoryId,
expenses: 0n,
refunds: 0n,
net: 0n,
};
category.expenses += expense;
category.refunds += refund;
category.net = category.expenses - category.refunds;
categories.set(category.categoryId, category);
}
}
return {
expenses,
refunds,
net: expenses - refunds,
categories: [...categories.values()],
};
}
@@ -0,0 +1,57 @@
import { parseRubMoney, rubMoney } from './rub-money';
describe('RUB money', () => {
it.each([
['0', 0n],
['-100', -10000n],
['0.01', 1n],
['123.4', 12340n],
['90071992547409.93', 9007199254740993n],
['92233720368547758.07', 2n ** 63n - 1n],
['-92233720368547758.08', -(2n ** 63n)],
])('roundtrips %s exactly', (value, kopecks) => {
expect(parseRubMoney({ currency: 'RUB', value })).toBe(kopecks);
expect(rubMoney(kopecks)).toEqual({ currency: 'RUB', value });
});
it.each([
'92233720368547758.08',
'-92233720368547758.09',
'1.001',
'1.000',
'NaN',
'Infinity',
'1e2',
'1,23',
'',
' 1',
'1\n',
'01',
])('rejects invalid input %s without rounding', (value) => {
expect(() => parseRubMoney({ currency: 'RUB', value })).toThrow();
});
it('rejects other currencies and non-string values', () => {
expect(() => parseRubMoney({ currency: 'USD', value: '1' })).toThrow();
expect(() =>
parseRubMoney({ currency: 'RUB', value: 1 as unknown as string }),
).toThrow();
});
it('requires positive operation amounts, including at the maximum', () => {
for (const value of ['0', '-0.00', '-0.01'])
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
expect(
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
).toBe(2n ** 63n - 1n);
});
it('formats computed sums beyond the storage range without losing precision', () => {
expect(rubMoney(2n * (2n ** 63n - 1n))).toEqual({
currency: 'RUB',
value: '184467440737095516.14',
});
expect(rubMoney(-1n)).toEqual({ currency: 'RUB', value: '-0.01' });
});
});
@@ -0,0 +1,27 @@
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types';
export function parseRubMoney(money: Money, positive = false): bigint {
if (
money?.currency !== 'RUB' ||
typeof money.value !== 'string' ||
money.value !== money.value.trim()
)
throw new TypeError('Expected RUB money with a decimal string');
const amount = decimalFromString(money.value);
if (amount.scale > 2)
throw new RangeError('RUB money supports at most two decimal places');
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n)
throw new RangeError('RUB money is outside the signed 64-bit range');
if (positive && kopecks <= 0n)
throw new RangeError('Operation amount must be positive');
return kopecks;
}
export function rubMoney(kopecks: bigint): Money {
return {
currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }),
};
}
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { conflict, missing } from '../application/bank-card-input';
type Tx = Prisma.TransactionClient;
const seeds = [
'Продукты',
'Кафе',
'Такси',
'Транспорт',
'Жильё',
'Здоровье',
'Покупки',
'Развлечения',
'Другое',
];
@Injectable()
export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await this.prisma.$transaction(run, {
maxWait: 10000,
timeout: 15000,
});
} catch (error) {
const code =
error instanceof Prisma.PrismaClientKnownRequestError
? error.code
: '';
const busy =
code === 'P2034' ||
code === 'P2028' ||
(error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < 2) continue;
if (busy) conflict('BANK_WRITE_CONFLICT');
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
throw error;
}
}
}
async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing();
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
return card;
}
async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing();
return row;
}
async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) {
const seedKey = index === 8 ? 'other' : String(index);
if (
!(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } },
}))
)
await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
seedKey,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
}
}
summaryData(
userId: string,
period: { from: string; to: string },
cardId?: string,
) {
return this.prisma.$transaction(async (tx) => {
if (cardId) await this.card(tx, userId, cardId);
const rows = await tx.bankCardOperation.findMany({
where: {
userId,
deletedAt: null,
date: { gte: period.from, lte: period.to },
kind: { in: ['expense', 'refund'] },
...(cardId ? { cardId } : {}),
},
include: { originalExpense: { select: { categoryId: true } } },
});
const categories = await tx.expenseCategory.findMany({
where: { userId },
});
return {
operations: rows.map((row) => ({
...row,
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
})),
categories,
};
});
}
}
@@ -0,0 +1,267 @@
import {
ApiProperty,
ApiPropertyOptional,
IntersectionType,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const;
export const bankOperationKinds = [
'expense',
'topup',
'transfer',
'refund',
] as const;
export type BankOperationKind = (typeof bankOperationKinds)[number];
export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString()
@Matches(/^-?\d+(\.\d{1,2})?$/)
declare value: string;
}
export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: 100 })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(100)
declare name: string;
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 })
@IsOptional()
@Transform(trim)
@IsString()
@MaxLength(100)
bankName?: string | null;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional()
@Matches(/^[0-9]{4}$/)
lastFour?: string | null;
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
@IsOptional()
@IsIn(bankPaymentSystems)
paymentSystem?: string | null;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
declare openingBalance: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
declare openingDate: string;
}
export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
@ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean()
archived?: boolean;
}
export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: 80 })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(80)
declare name: string;
}
export class UpdateExpenseCategoryDto extends CategoryNameDto {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
}
export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds)
declare kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
targetCardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
categoryId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
originalExpenseId?: string;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
declare amount: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string;
@ApiPropertyOptional({ maxLength: 500 })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsString()
@MaxLength(500)
description?: string;
}
export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
}
export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] })
@IsOptional()
@IsIn(['true', 'false'])
includeArchived?: string;
}
export class BankPageQueryDto {
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
export class BankPeriodQueryDto {
@ApiPropertyOptional({
pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$',
description: 'Use month or both from and to, exclusively.',
})
@IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive start date; requires to.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
from?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive end date; requires from.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
to?: string;
}
export class BankOperationsQueryDto extends IntersectionType(
BankPageQueryDto,
BankPeriodQueryDto,
) {}
export class BankSummaryQueryDto extends BankPeriodQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cardId?: string;
}
export class BankVersionQueryDto {
@ApiProperty({ minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
declare version: number;
}
export class ExpenseCategoryDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: Boolean }) declare isDefault: boolean;
@ApiProperty({ type: Number }) declare version: number;
}
export class BankCardDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: String, nullable: true }) declare bankName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare lastFour:
string | null;
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
declare paymentSystem: string | null;
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
@ApiProperty({ type: String }) declare openingDate: string;
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
string | null;
@ApiProperty({ type: Number }) declare version: number;
}
export class BankOperationDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ enum: bankOperationKinds }) declare kind: BankOperationKind;
@ApiProperty({ type: String }) declare cardId: string;
@ApiProperty({ type: String }) declare cardName: string;
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
string | null;
@ApiProperty({ type: 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 })
declare availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) declare date: string;
@ApiProperty({ type: String, nullable: true }) declare description:
string | null;
@ApiProperty({ type: Number }) declare version: number;
@ApiProperty({ type: String }) declare createdAt: string;
}
export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true })
declare items: BankOperationDto[];
@ApiProperty({ type: Number }) declare page: number;
@ApiProperty({ type: Number }) declare total: number;
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
}
export class BankSummaryCategoryDto {
@ApiProperty({ type: String }) declare categoryId: string;
@ApiProperty({ type: String }) declare categoryName: string;
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
}
export class BankSummaryDto {
@ApiPropertyOptional({ type: String }) month?: string;
@ApiPropertyOptional({ format: 'date' }) from?: string;
@ApiPropertyOptional({ format: 'date' }) to?: string;
@ApiProperty({ type: String, nullable: true }) declare cardId: string | null;
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
declare categories: BankSummaryCategoryDto[];
}
@@ -0,0 +1,187 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import type { Request } from 'express';
import {
ApiProblemResponses,
ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import {
BankCardDto,
BankCardsQueryDto,
BankOperationDto,
BankOperationsPageDto,
BankOperationsQueryDto,
BankPageQueryDto,
BankSummaryDto,
BankSummaryQueryDto,
BankVersionQueryDto,
CategoryNameDto,
CreateBankCardDto,
CreateBankOperationDto,
ExpenseCategoryDto,
UpdateBankCardDto,
UpdateBankOperationDto,
UpdateExpenseCategoryDto,
} from './bank-card.dto';
import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service';
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({
status: 409,
description:
'BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT',
content: {
'application/problem+json': {
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
},
},
})
@ApiResponse({
status: 410,
description:
'BANK_OPERATION_DELETED: creation replay refers to a deleted operation',
content: {
'application/problem+json': {
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
},
},
})
@ApiTags('bank-cards')
@ApiBearerAuth()
@ApiProblemResponses()
@UseGuards(JwtAuthGuard)
@Controller('bank-cards')
export class BankCardsController {
constructor(
private readonly cards: BankCardsService,
private readonly operations: BankOperationsService,
private readonly expenseCategories: ExpenseCategoriesService,
private readonly bankSummary: GetBankSummaryUseCase,
) {}
@Get()
@ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
return this.cards.list(req.user.sub, query.includeArchived === 'true');
}
@Post()
@ApiCreatedResponse({ type: BankCardDto })
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
return this.cards.createCard(req.user.sub, body);
}
@Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) {
return this.expenseCategories.categories(req.user.sub);
}
@Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.expenseCategories.createCategory(req.user.sub, body.name);
}
@Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto })
updateCategory(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateExpenseCategoryDto,
) {
return this.expenseCategories.updateCategory(
req.user.sub,
id,
body.name,
body.version,
);
}
@Get('summary')
@ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.bankSummary.execute(req.user.sub, query);
}
@Post('operations')
@ApiCreatedResponse({ type: BankOperationDto })
createOperation(
@Req() req: AuthRequest,
@Body() body: CreateBankOperationDto,
) {
return this.operations.createOperation(req.user.sub, body);
}
@Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.operations.getOperation(req.user.sub, id);
}
@Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
updateOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankOperationDto,
) {
return this.operations.updateOperation(req.user.sub, id, body);
}
@Delete('operations/:id')
@HttpCode(204)
@ApiNoContentResponse()
deleteOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankVersionQueryDto,
) {
return this.operations.deleteOperation(req.user.sub, id, query.version);
}
@Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto })
history(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankOperationsQueryDto,
) {
return this.operations.history(req.user.sub, id, query);
}
@Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto })
expenses(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankPageQueryDto,
) {
return this.operations.history(req.user.sub, id, query, true);
}
@Get(':id')
@ApiOkResponse({ type: BankCardDto })
get(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.get(req.user.sub, id);
}
@Patch(':id')
@ApiOkResponse({ type: BankCardDto })
update(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankCardDto,
) {
return this.cards.updateCard(req.user.sub, id, body);
}
}
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
/** Source account of a row in an all-accounts response. */
export class AccountReferenceDto {
@ApiProperty()
declare id: string;
@ApiProperty()
declare name: string;
}
@@ -0,0 +1,429 @@
import {
BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
import assert from 'node:assert/strict';
jest.mock('../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class {},
}));
import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service';
import { IncomeAnalyticsService } from './income/income-analytics.service';
import { BrokerageOperationsService } from './operations/application/brokerage-operations.service';
import { PortfolioService } from './portfolio/application/portfolio.service';
import { UpcomingEventsService } from './upcoming-events/upcoming-events.service';
function setup() {
const context = {
token: 'token',
generation: 'first',
accounts: [
{ id: 'a', name: 'Основной', openedOn: '2020-01-01' },
{ id: 'b', name: 'ИИС', openedOn: '2026-01-01' },
],
};
const access = {
withSupportedAccounts: jest.fn(
(_user: string, handler: (value: typeof context) => unknown) =>
handler(context),
),
withAccountForConnection: jest.fn(
(
_connection: TbankConnectionContext,
_id: string,
handler: () => unknown,
) => handler(),
),
};
const tbank = {
getOperationsByCursor: jest.fn(),
getPortfolio: jest.fn().mockResolvedValue({ positions: [] }),
getPositions: jest.fn().mockResolvedValue({ money: [] }),
getDividends: jest.fn().mockResolvedValue([]),
getInstrumentByFigi: jest.fn().mockResolvedValue({ name: 'Облигация' }),
};
const metadata = { resolve: jest.fn().mockResolvedValue(null) };
const requestContext = { set: jest.fn() };
const operations = new BrokerageOperationsService(
access as never,
tbank as never,
requestContext as never,
metadata as never,
);
const portfolio = new PortfolioService(
access as never,
tbank as never,
requestContext as never,
metadata as never,
);
const moex = { bondEvents: jest.fn().mockResolvedValue([]) };
const events = new UpcomingEventsService(
access as never,
tbank as never,
moex as never,
requestContext as never,
{ info: jest.fn(), event: jest.fn() } as never,
);
return {
context,
access,
tbank,
operations,
portfolio,
events,
moex,
income: new IncomeAnalyticsService(operations),
};
}
const operation = (
id: string,
second: number,
type = 'OPERATION_TYPE_COUPON',
value = 1,
) => ({
id,
date: { seconds: String(second) },
type,
payment: { currency: 'RUB', units: value },
});
const query = { from: '2025-01-01', to: '2026-12-31', limit: 2 };
describe('All accounts', () => {
it('does not truncate instrument history when an account opening date is missing', async () => {
const { context, operations, tbank } = setup();
Object.assign(context.accounts[0], { openedOn: null });
tbank.getOperationsByCursor.mockResolvedValue({ items: [] });
await operations.getAll('user', { instrumentUid: 'common' });
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
from: { seconds: '0' },
instrumentId: 'common',
}),
);
});
it('filters upstream by UID, keeps sold-position history and binds continuations to the instrument', async () => {
const { operations, tbank } = setup();
tbank.getOperationsByCursor.mockImplementation(
(
_token: string,
request: { instrumentId: string; accountId: string },
) => ({
items: [
{
...operation(
request.accountId + '-dividend',
9,
'OPERATION_TYPE_DIVIDEND',
),
instrumentUid: request.instrumentId,
},
{
...operation('wrong-paper', 8, 'OPERATION_TYPE_DIVIDEND'),
instrumentUid: 'preferred',
},
],
}),
);
const first = await operations.getAll('user', {
instrumentUid: 'common',
limit: 1,
});
assert.ok(first.nextCursor);
expect(first.items).toHaveLength(1);
expect(first.items[0].instrumentUid).toBe('common');
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
instrumentId: 'common',
from: { seconds: '1577836800' },
}),
);
await expect(
operations.getAll('user', {
instrumentUid: 'preferred',
limit: 1,
cursor: first.nextCursor,
}),
).rejects.toBeInstanceOf(BadRequestException);
const second = await operations.getAll('user', {
instrumentUid: 'common',
limit: 1,
cursor: first.nextCursor,
});
expect(second.items[0].instrumentUid).toBe('common');
expect(second.items[0].id).not.toBe(first.items[0].id);
expect(second.hasNext).toBe(false);
});
afterEach(() => jest.useRealTimers());
it('merges exact amounts by currency and keeps equal instruments on different accounts', async () => {
const { portfolio, tbank } = setup();
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'SAME',
instrumentType: 'share',
quantity: { units: 1 },
currentPrice: { currency: 'RUB', nano: 100000000 },
},
],
});
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }],
}));
const result = await portfolio.getAll('user');
expect(result.structure.currencies).toMatchObject([
{
currency: 'RUB',
totalValue: '0.4',
categories: [
{ type: 'STOCK', value: '0.2', sharePercent: '50' },
{ type: 'BOND', value: '0' },
{ type: 'OTHER', value: '0' },
{ type: 'CASH', value: '0.2', sharePercent: '50' },
],
},
{ currency: 'USD', totalValue: '0.2' },
]);
expect(result.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '0.4' },
{ currency: 'USD', value: '0.2' },
]);
expect(result.cashBalances).toEqual([
{ currency: 'RUB', value: '0.2' },
{ currency: 'USD', value: '0.2' },
]);
expect(result.positions.map((position) => position.account?.id)).toEqual([
'a',
'b',
]);
});
it('rejects missing access and a partial portfolio instead of publishing incomplete totals', async () => {
const { portfolio, access, tbank } = setup();
access.withSupportedAccounts.mockImplementationOnce(() => {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
});
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('marks an aggregate stale and uses its oldest snapshot timestamp', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-01T10:00:00Z'));
const { portfolio, tbank } = setup();
const first = await portfolio.getAll('user');
jest.setSystemTime(new Date('2026-09-01T10:01:00Z'));
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
const result = await portfolio.getAll('user');
expect(result.isStale).toBe(true);
expect(result.lastSuccessfulAt).toBe(first.lastSuccessfulAt);
});
it('paginates interleaved streams without dropping buffered rows, preserving duplicate IDs', async () => {
const { operations, tbank } = setup();
tbank.getOperationsByCursor.mockImplementation(
(
_token: string,
{ accountId, cursor }: { accountId: string; cursor?: string },
) => {
if (accountId === 'a')
return cursor
? { items: [operation('last', 3)] }
: {
items: [operation('same', 9), operation('a2', 5)],
hasNext: true,
nextCursor: 'a-next',
};
return { items: [operation('same', 8), operation('b2', 4)] };
},
);
const first = await operations.getAll('user', query);
assert.ok(first.nextCursor);
const second = await operations.getAll('user', {
...query,
cursor: first.nextCursor,
});
const repeated = await operations.getAll('user', {
...query,
cursor: first.nextCursor,
});
assert.ok(second.nextCursor);
const third = await operations.getAll('user', {
...query,
cursor: second.nextCursor,
});
expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([
['same', 'a'],
['same', 'b'],
]);
expect(second.items.map((item) => item.id)).toEqual(['a2', 'b2']);
expect(repeated).toEqual(second);
expect(third.items.map((item) => item.id)).toEqual(['last']);
expect(third.hasNext).toBe(false);
});
it('continues through empty filtered pages and breaks ties by account', async () => {
const { operations, tbank } = setup();
tbank.getOperationsByCursor.mockImplementation(
(_token: string, { cursor }: { cursor?: string }) =>
cursor
? { items: [operation('income', 5)] }
: {
items: [operation('fee', 8, 'OPERATION_TYPE_BROKER_FEE')],
hasNext: true,
nextCursor: 'next',
},
);
const page = await operations.getAll('user', {
...query,
categories: ['income'],
});
expect(page.items.map((item) => item.account?.id)).toEqual(['a', 'b']);
expect(page.items.every((item) => item.category === 'income')).toBe(true);
});
it('rejects damaged cursor chains and partial streams', async () => {
const { operations, tbank } = setup();
tbank.getOperationsByCursor.mockResolvedValue({
items: [],
hasNext: true,
nextCursor: 'loop',
});
await expect(operations.getAll('user', query)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
tbank.getOperationsByCursor.mockRejectedValue(
new ServiceUnavailableException(),
);
await expect(
operations.getAll('another-user', query),
).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('binds continuations to user, connection, accounts and filters', async () => {
const { operations, context, tbank } = setup();
tbank.getOperationsByCursor.mockResolvedValue({
items: [operation('first', 8), operation('last', 4)],
});
const first = await operations.getAll('user', query);
assert.ok(first.nextCursor);
const next = { ...query, cursor: first.nextCursor };
await expect(operations.getAll('other', next)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
operations.getAll('user', { ...next, categories: ['fee'] }),
).rejects.toBeInstanceOf(BadRequestException);
context.generation = 'replaced';
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
BadRequestException,
);
context.generation = 'first';
context.accounts.pop();
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('collects income across the same cohort and computes comparisons from all receipts', async () => {
const { income, tbank } = setup();
tbank.getOperationsByCursor.mockImplementation(
(
_token: string,
{ accountId, from }: { accountId: string; from: { seconds: string } },
) => {
const year = new Date(Number(from.seconds) * 1000).getUTCFullYear();
const second =
Date.parse(
year === 2025 ? '2025-08-15T12:00:00Z' : '2026-08-15T12:00:00Z',
) / 1000;
return {
items:
year === 2025 && accountId === 'b'
? []
: [
operation(
'same',
second,
'OPERATION_TYPE_COUPON',
year === 2025 ? 2 : 3,
),
],
};
},
);
const result = await income.analytics(
'user',
'all',
'2026-08-01',
'2026-08-31',
);
expect(result.totalsByCurrency[0].total.value).toBe('6');
expect(
result.yearComparison?.totalsByCurrency[0].compared.total.value,
).toBe('2');
expect(
result.yearComparison?.totalsByCurrency[0].difference.total.value,
).toBe('4');
expect(result.entries.map((entry) => entry.account?.id)).toEqual([
'a',
'b',
]);
expect(
new Set(result.entries.map((entry) => entry.instrumentKey)).size,
).toBe(2);
});
it('preserves separate future events and fails the section when one source fails', async () => {
const { events, tbank, moex } = setup();
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'BOND',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 2 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: today, value: '0.1', currency: 'RUB' },
]);
const result = await events.getAll('user');
expect(result.events.map((event) => event.account?.id)).toEqual(['a', 'b']);
expect(result.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '0.4' },
]);
tbank.getPortfolio
.mockResolvedValueOnce({ positions: [] })
.mockRejectedValueOnce(new ServiceUnavailableException());
await expect(events.getAll('user')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('returns valid empty results for an empty account set', async () => {
const { context, portfolio, operations, income, events, tbank } = setup();
context.accounts = [];
expect((await portfolio.getAll('user')).positions).toEqual([]);
expect((await operations.getAll('user', query)).hasNext).toBe(false);
expect((await income.summary('user', 'all')).totalsByCurrency).toEqual([]);
expect((await events.getAll('user')).events).toEqual([]);
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto';
export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true })
declare totalsByCurrency: MoneyDto[];
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero stock positions, not the number of units.',
})
declare stockPositionCount: number;
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero bond positions, not the number of units.',
})
declare bondPositionCount: number;
}
export class BrokerageAccountDto {
@ApiProperty()
declare id: string;
@ApiProperty()
declare name: string;
@ApiProperty({
enum: [
'BROKERAGE',
'INDIVIDUAL_INVESTMENT',
'INVEST_BOX',
'MONEY_MARKET_FUND',
],
})
declare type:
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare openedOn: string | null;
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
portfolioSummary?: PortfolioSummaryDto | null;
}
@@ -0,0 +1,22 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class BrokerageAccountsController {
constructor(private readonly accounts: BrokerageAccountsService) {}
@Get()
@ApiOkResponse({ type: BrokerageAccountDto, isArray: true })
list(@Req() request: Request & { user: AccessPayload }) {
return this.accounts.list(request.user.sub);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({
imports: [AuthModule, TbankModule],
controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService],
})
export class BrokerageAccountsModule {}
@@ -0,0 +1,249 @@
import {
BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service';
import { BrokerageAccountsService } from './brokerage-accounts.service';
describe('BrokerageAccountsService', () => {
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => encryptionKey) };
const tbank = {
getOpenAccounts: jest.fn(),
getPortfolio: jest.fn(),
getPositions: jest.fn(),
};
const tokens = new TbankTokenService(config as never);
const access = new TbankAccessService(
prisma as never,
tokens,
tbank as never,
);
const service = new BrokerageAccountsService(access, tbank as never);
beforeEach(() => {
jest.clearAllMocks();
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
});
it('does not call T-Bank without a connection', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null);
await expect(service.list('user-1')).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('normalizes supported accounts and ignores unsupported ones', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{
id: 'brokerage',
type: 'ACCOUNT_TYPE_TINKOFF',
name: ' Основной ',
openedDate: { seconds: '1723507200' },
},
{
id: 'debit',
type: 'ACCOUNT_TYPE_DEBIT',
name: 'Дебетовый',
},
{
id: 'iis',
type: 'ACCOUNT_TYPE_TINKOFF_IIS',
name: ' ',
},
]);
await expect(service.list('user-1')).resolves.toEqual([
{
id: 'brokerage',
type: 'BROKERAGE',
name: 'Основной',
openedOn: '2024-08-13',
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
{
id: 'iis',
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
openedOn: null,
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
]);
});
it('keeps upstream order and adds exact summaries without loading metadata', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'first', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
{ id: 'second', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'Второй' },
]);
tbank.getPortfolio
.mockResolvedValueOnce({
positions: [
{
quantity: { units: 2 },
currentPrice: { currency: 'rub', units: 3 },
},
],
})
.mockResolvedValueOnce({ positions: [] });
tbank.getPositions
.mockResolvedValueOnce({ money: [{ currency: 'RUB', nano: 500000000 }] })
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 4 }] });
await expect(service.list('user-1')).resolves.toEqual([
{
id: 'first',
type: 'BROKERAGE',
name: 'Первый',
openedOn: null,
portfolioSummary: {
totalsByCurrency: [{ currency: 'RUB', value: '6.5' }],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
{
id: 'second',
type: 'INDIVIDUAL_INVESTMENT',
name: 'Второй',
openedOn: null,
portfolioSummary: {
totalsByCurrency: [{ currency: 'USD', value: '4' }],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
]);
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'first');
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second');
});
it('counts non-zero stock and bond positions even without a valuation', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
]);
tbank.getPortfolio.mockResolvedValue({
positions: [
{ instrumentType: 'share', quantity: { units: 200 } },
{ instrumentType: 'SHARE', quantity: { units: -5 } },
{ instrumentType: 'share', quantity: { units: 0, nano: 1 } },
{ instrumentType: 'bond', quantity: { units: 80 } },
{ instrumentType: 'bond', quantity: { units: 0 } },
{ instrumentType: 'share' },
{ instrumentType: 'etf', quantity: { units: 10 } },
{ instrumentType: 'currency', quantity: { units: 100 } },
{ quantity: { units: 1 } },
],
virtualPositions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
});
await expect(service.list('user-1')).resolves.toMatchObject([
{
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 3,
bondPositionCount: 1,
},
},
]);
});
it('localizes an unavailable summary without suppressing another account', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'unavailable', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
{ id: 'available', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Второй' },
]);
tbank.getPortfolio
.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
)
.mockResolvedValueOnce({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
await expect(service.list('user-1')).resolves.toMatchObject([
{ id: 'unavailable', portfolioSummary: null },
{ id: 'available', portfolioSummary: { totalsByCurrency: [] } },
]);
});
it('does not mask an invalid token when the other summary input is unavailable', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
]);
tbank.getPortfolio.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
tbank.getPositions.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
await expect(service.list('user-1')).rejects.toEqual(
expect.any(BadRequestException),
);
});
it('retains the connection when T-Bank rejects its token', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
await expect(service.list('user-1')).rejects.toEqual(
expect.any(BadRequestException),
);
});
it('returns an empty list from T-Bank unchanged', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([]);
await expect(service.list('user-1')).resolves.toEqual([]);
});
it('propagates an unavailable T-Bank response', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.list('user-1')).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
});
@@ -0,0 +1,80 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { isZero } from '../../../common/decimal';
import {
TbankAccessService,
TbankClient,
} from '../../../integrations/tbank/tbank.api';
import { decimal } from '../../../integrations/tbank/decimal';
import { totalsByCurrency } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto';
@Injectable()
export class BrokerageAccountsService {
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
) {}
async list(userId: string): Promise<BrokerageAccountDto[]> {
return this.access.withSupportedAccounts(
userId,
async ({ token, accounts }) =>
Promise.all(
accounts.map(async (account) => {
const [portfolioResult, positionsResult] = await Promise.allSettled(
[
this.tbank.getPortfolio(token, account.id),
this.tbank.getPositions(token, account.id),
],
);
if (
portfolioResult.status === 'rejected' &&
!isTbankUnavailable(portfolioResult.reason)
)
throw portfolioResult.reason;
if (
positionsResult.status === 'rejected' &&
!isTbankUnavailable(positionsResult.reason)
)
throw positionsResult.reason;
if (
portfolioResult.status === 'rejected' ||
positionsResult.status === 'rejected'
)
return { ...account, portfolioSummary: null };
const positions = portfolioResult.value.positions ?? [];
let stockPositionCount = 0;
let bondPositionCount = 0;
for (const position of positions) {
if (isZero(decimal(position.quantity ?? {}))) continue;
const type = position.instrumentType?.toLowerCase();
if (type === 'share') stockPositionCount += 1;
if (type === 'bond') bondPositionCount += 1;
}
return {
...account,
portfolioSummary: {
totalsByCurrency: totalsByCurrency(
positions,
positionsResult.value.money ?? [],
),
stockPositionCount,
bondPositionCount,
},
};
}),
),
);
}
}
function isTbankUnavailable(error: unknown): boolean {
if (!(error instanceof ServiceUnavailableException)) return false;
const response = error.getResponse();
return (
typeof response === 'object' &&
response !== null &&
'code' in response &&
response.code === 'TBANK_UNAVAILABLE'
);
}
@@ -0,0 +1,59 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
IncomeAnalyticsDto,
IncomeAnalyticsQueryDto,
IncomeSummaryDto,
} from './income-analytics.dto';
import { IncomeAnalyticsService } from './income-analytics.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class IncomeAnalyticsController {
constructor(private readonly income: IncomeAnalyticsService) {}
@Get('all/income-summary')
@ApiOkResponse({ type: IncomeSummaryDto })
allSummary(@Req() request: Request & { user: AccessPayload }) {
return this.income.summary(request.user.sub, 'all');
}
@Get('all/income-analytics')
@ApiOkResponse({ type: IncomeAnalyticsDto })
allAnalytics(
@Req() request: Request & { user: AccessPayload },
@Query() query: IncomeAnalyticsQueryDto,
) {
return this.income.analytics(request.user.sub, 'all', query.from, query.to);
}
@Get(':accountId/income-summary')
@ApiOkResponse({ type: IncomeSummaryDto })
summary(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
) {
return this.income.summary(request.user.sub, accountId);
}
@Get(':accountId/income-analytics')
@ApiOkResponse({ type: IncomeAnalyticsDto })
analytics(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: IncomeAnalyticsQueryDto,
) {
return this.income.analytics(
request.user.sub,
accountId,
query.from,
query.to,
);
}
}
@@ -0,0 +1,141 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
export class IncomeAnalyticsQueryDto {
@ApiPropertyOptional({ format: 'date' })
@IsOptional()
@Matches(dateOnly)
from?: string;
@ApiPropertyOptional({ format: 'date' })
@IsOptional()
@Matches(dateOnly)
to?: string;
}
export class IncomePeriodDto {
@ApiProperty({ format: 'date' })
declare from: string;
@ApiProperty({ format: 'date' })
declare to: string;
}
export class IncomeCurrencySummaryDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: MoneyDto })
declare coupon: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare dividend: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare total: MoneyDto;
}
export class IncomeSummaryDto {
@ApiProperty()
declare accountId: string;
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare totalsByCurrency: IncomeCurrencySummaryDto[];
@ApiProperty({ format: 'date-time' })
declare calculatedAt: string;
}
export class IncomeComparisonCurrencyDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare selected: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare compared: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare difference: IncomeCurrencySummaryDto;
}
export class IncomeComparisonDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true })
declare totalsByCurrency: IncomeComparisonCurrencyDto[];
}
export class IncomeBucketDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare totalsByCurrency: IncomeCurrencySummaryDto[];
}
export class IncomeInstrumentDto {
@ApiProperty()
declare key: string;
@ApiProperty({ type: String, nullable: true })
declare figi: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null;
@ApiProperty({ type: String, nullable: true })
declare ticker: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentType: string | null;
@ApiProperty()
declare displayName: string;
@ApiProperty({ type: MoneyDto })
declare coupon: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare dividend: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare total: MoneyDto;
@ApiProperty()
declare receiptCount: number;
}
export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
@ApiProperty()
declare instrumentKey: string;
}
export class IncomeAnalyticsDto extends IncomeSummaryDto {
@ApiProperty({ type: IncomeComparisonDto })
declare previous: IncomeComparisonDto;
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] })
declare bucketGranularity: 'day' | 'week' | 'month';
@ApiProperty({ type: IncomeBucketDto, isArray: true })
declare buckets: IncomeBucketDto[];
@ApiProperty({ type: IncomeInstrumentDto, isArray: true })
declare instruments: IncomeInstrumentDto[];
@ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true })
declare entries: IncomeAnalyticsEntryDto[];
}
@@ -0,0 +1,224 @@
jest.mock('../operations/application/brokerage-operations.service', () => ({
BrokerageOperationsService: class BrokerageOperationsService {},
}));
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { IncomeAnalyticsService } from './income-analytics.service';
describe('IncomeAnalyticsService', () => {
const operations = { collectRanges: jest.fn() };
const service = new IncomeAnalyticsService(operations as never);
beforeEach(() => jest.clearAllMocks());
function seed(items: BrokerageOperationDto[]) {
operations.collectRanges.mockImplementation(
(
_user: string,
_account: string,
build: (opened?: Date) => unknown[],
) => {
build(undefined);
return { pages: [[{ items }], [{ items: [] }], [{ items: [] }]] };
},
);
}
it.each([
{
identity: {
instrumentUid: 'UID',
figi: 'FIGI',
ticker: 'T',
instrumentType: 'bond',
},
key: 'uid:UID:RUB',
},
{
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
key: 'figi:FIGI:RUB',
},
{
identity: { ticker: 'T', instrumentType: 'bond' },
key: 'ticker:T:bond:RUB',
},
{ identity: { ticker: 'T' }, key: 'ticker:T::RUB' },
{
identity: { ticker: null, instrumentType: 'bond' },
key: 'ticker::bond:RUB',
},
{ identity: { ticker: null }, key: 'operation:receipt:RUB' },
])(
'uses one membership for entry and aggregate: $key',
async ({ identity, key }) => {
const payment = {
...entry('receipt', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
...identity,
};
seed([payment]);
const result = await service.analytics(
'user',
'account',
'2026-01-01',
'2026-01-31',
);
expect(result.entries).toEqual([{ ...payment, instrumentKey: key }]);
expect(result.instruments).toEqual([
expect.objectContaining({
key,
receiptCount: 1,
total: { currency: 'RUB', value: '10.25' },
}),
]);
},
);
it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => {
const selected = [
{
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
instrumentUid: 'UID',
},
{
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
instrumentUid: 'UID',
},
{
...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'),
instrumentUid: 'UID',
},
];
seed(selected);
const result = await service.analytics(
'user',
'account',
'2026-01-01',
'2026-01-31',
);
expect(result.entries).toEqual(
selected.map((item) => ({
...item,
instrumentKey: `uid:UID:${item.payment?.currency}`,
})),
);
expect(
result.instruments.map(
({ key, receiptCount, coupon, dividend, total }) => ({
key,
receiptCount,
coupon,
dividend,
total,
}),
),
).toEqual([
{
key: 'uid:UID:USD',
receiptCount: 1,
coupon: { currency: 'USD', value: '20' },
dividend: { currency: 'USD', value: '0' },
total: { currency: 'USD', value: '20' },
},
{
key: 'uid:UID:RUB',
receiptCount: 2,
coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-0.25' },
total: { currency: 'RUB', value: '10' },
},
]);
expect(
result.entries
.filter((item) => item.instrumentKey === 'uid:UID:RUB')
.map((item) => item.payment?.value),
).toEqual(['10.25', '-0.25']);
});
it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation(
(
_user: string,
_account: string,
build: (opened?: Date) => unknown[],
) => {
build(undefined);
return {
pages: [
[
{
items: [
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
entry(
'transfer',
'OPERATION_TYPE_DIVIDEND_TRANSFER',
'99',
'RUB',
),
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
],
},
],
[{ items: [] }],
[{ items: [] }],
],
};
},
);
const result = await service.analytics(
'user',
'account',
'2026-01-01',
'2026-01-31',
);
expect(result.totalsByCurrency).toEqual([
expect.objectContaining({
currency: 'RUB',
coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-2.5' },
total: { currency: 'RUB', value: '7.75' },
}),
expect.objectContaining({
currency: 'USD',
total: { currency: 'USD', value: '1' },
}),
]);
expect(result.entries.map((entry) => entry.id)).toEqual([
'coupon',
'dividend',
'usd',
]);
expect(result.buckets).toHaveLength(31);
});
});
function entry(
id: string,
type: string,
value: string,
currency: string,
): BrokerageOperationDto {
return {
id,
occurredAt: '2026-01-15T12:00:00.000Z',
type,
category: 'income',
description: id,
figi: null,
instrumentUid: null,
ticker: id,
instrumentType: null,
logoName: null,
payment: { currency, value },
quantity: null,
price: null,
accruedInterest: null,
commission: null,
};
}
@@ -0,0 +1,267 @@
import { Injectable } from '@nestjs/common';
import {
add,
compare,
decimalFromString,
formatDecimal,
subtract,
} from '../../../common/decimal';
import { DateOnlyRange, moscowDate } from '../investment-calendar';
import {
BrokerageOperationDto,
BrokerageOperationsService,
} from '../operations/operations.api';
import {
IncomeAnalyticsDto,
IncomeAnalyticsEntryDto,
IncomeComparisonDto,
IncomeCurrencySummaryDto,
IncomeInstrumentDto,
IncomeSummaryDto,
} from './income-analytics.dto';
import {
IncomeBucketGranularity,
planIncomePeriod,
rangeBuckets,
} from './income-period';
type Total = {
coupon: ReturnType<typeof decimalFromString>;
dividend: ReturnType<typeof decimalFromString>;
};
@Injectable()
export class IncomeAnalyticsService {
constructor(private readonly operations: BrokerageOperationsService) {}
async summary(userId: string, accountId: string): Promise<IncomeSummaryDto> {
let plan: ReturnType<typeof planIncomePeriod> | undefined;
const collected = await this.operations.collectRanges(
userId,
accountId,
(openedAt) => {
const planned = planIncomePeriod(undefined, undefined, openedAt);
plan = planned;
return [planned.selected];
},
);
if (!plan) throw new Error('Income period was not planned');
return summaryResponse(
accountId,
plan.selected,
qualifying(collected.pages[0].flatMap((page) => page.items)),
new Date().toISOString(),
);
}
async analytics(
userId: string,
accountId: string,
from: string | undefined,
to: string | undefined,
): Promise<IncomeAnalyticsDto> {
let plan: ReturnType<typeof planIncomePeriod> | undefined;
const collected = await this.operations.collectRanges(
userId,
accountId,
(openedAt) => {
const planned = planIncomePeriod(from, to, openedAt);
plan = planned;
return [
planned.selected,
planned.previous,
...(planned.yearAgo ? [planned.yearAgo] : []),
];
},
);
if (!plan) throw new Error('Income period was not planned');
const selected = qualifying(
collected.pages[0].flatMap((page) => page.items),
).map((entry) => ({
...entry,
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
}));
const previous = qualifying(
collected.pages[1].flatMap((page) => page.items),
);
const yearAgo = plan.yearAgo
? qualifying(collected.pages[2].flatMap((page) => page.items))
: null;
const calculatedAt = new Date().toISOString();
const base = summaryResponse(
accountId,
plan.selected,
selected,
calculatedAt,
);
return {
...base,
previous: comparison(selected, plan.previous, previous),
yearComparison:
plan.yearAgo && yearAgo
? comparison(selected, plan.yearAgo, yearAgo)
: null,
bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected),
entries: selected,
};
}
}
function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
return entries.filter(
(entry) =>
Boolean(entry.payment) &&
['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type),
);
}
function summaryResponse(
accountId: string,
period: DateOnlyRange,
entries: BrokerageOperationDto[],
calculatedAt: string,
): IncomeSummaryDto {
return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
}
function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
const values = new Map<string, Total>();
for (const entry of entries) {
if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value);
if (entry.type === 'OPERATION_TYPE_COUPON')
total.coupon = add(total.coupon, value);
else total.dividend = add(total.dividend, value);
values.set(entry.payment.currency, total);
}
return [...values]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, total]) => presentTotal(currency, total));
}
function comparison(
selectedEntries: BrokerageOperationDto[],
comparedPeriod: DateOnlyRange,
comparedEntries: BrokerageOperationDto[],
): IncomeComparisonDto {
const selected = new Map(
totals(selectedEntries).map((total) => [total.currency, total]),
);
const compared = new Map(
totals(comparedEntries).map((total) => [total.currency, total]),
);
const currencies = [
...new Set([...selected.keys(), ...compared.keys()]),
].sort();
return {
period: comparedPeriod,
totalsByCurrency: currencies.map((currency) => {
const current =
selected.get(currency) ?? presentTotal(currency, zeroTotal());
const prior =
compared.get(currency) ?? presentTotal(currency, zeroTotal());
return {
currency,
selected: current,
compared: prior,
difference: difference(current, prior),
};
}),
};
}
function buckets(
range: DateOnlyRange,
granularity: IncomeBucketGranularity,
entries: BrokerageOperationDto[],
) {
return rangeBuckets(range, granularity).map((period) => ({
period,
totalsByCurrency: totals(
entries.filter((entry) => {
const date = moscowDate(new Date(entry.occurredAt));
return date >= period.from && date <= period.to;
}),
),
}));
}
function instruments(
entries: IncomeAnalyticsEntryDto[],
): IncomeInstrumentDto[] {
const groups = new Map<string, IncomeAnalyticsEntryDto[]>();
for (const entry of entries) {
const group = groups.get(entry.instrumentKey);
if (group) group.push(entry);
else groups.set(entry.instrumentKey, [entry]);
}
return [...groups]
.map(([key, group]) => {
const first = group[0];
const [total] = totals(group);
return {
key,
figi: first.figi,
instrumentUid: first.instrumentUid,
ticker: first.ticker,
instrumentType: first.instrumentType,
displayName: first.ticker ?? (first.description || first.type),
coupon: total.coupon,
dividend: total.dividend,
total: total.total,
receiptCount: group.length,
};
})
.sort(
(left, right) =>
compare(
decimalFromString(right.total.value),
decimalFromString(left.total.value),
) || left.key.localeCompare(right.key),
);
}
function instrumentKey(entry: BrokerageOperationDto): string {
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
if (entry.figi) return `figi:${entry.figi}`;
if (entry.ticker || entry.instrumentType)
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
}
function difference(
left: IncomeCurrencySummaryDto,
right: IncomeCurrencySummaryDto,
): IncomeCurrencySummaryDto {
return presentTotal(left.currency, {
coupon: subtract(
decimalFromString(left.coupon.value),
decimalFromString(right.coupon.value),
),
dividend: subtract(
decimalFromString(left.dividend.value),
decimalFromString(right.dividend.value),
),
});
}
function zeroTotal(): Total {
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
}
function presentTotal(
currency: string,
value: Total,
): IncomeCurrencySummaryDto {
const total = add(value.coupon, value.dividend);
return {
currency,
coupon: { currency, value: formatDecimal(value.coupon) },
dividend: { currency, value: formatDecimal(value.dividend) },
total: { currency, value: formatDecimal(total) },
};
}
@@ -0,0 +1,56 @@
import { BadRequestException } from '@nestjs/common';
import { dateOnlyBounds } from '../investment-calendar';
import { planIncomePeriod, rangeBuckets } from './income-period';
describe('income period planning', () => {
afterEach(() => jest.useRealTimers());
it('uses the Moscow current month and validates strict inclusive ranges', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-31T22:30:00.000Z'));
expect(planIncomePeriod(undefined, undefined)).toMatchObject({
selected: { from: '2026-02-01', to: '2026-02-01' },
});
for (const [from, to] of [
['2026-02-30', '2026-03-01'],
['2026-03-02', '2026-03-01'],
['2026-01-01', '2027-01-02'],
['2026-01-01', '2026-04-01'],
])
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
});
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
const fullMonth = planIncomePeriod('2026-03-01', '2026-03-31');
expect(fullMonth.previous).toEqual({
from: '2026-02-01',
to: '2026-02-28',
});
expect(planIncomePeriod('2024-02-29', '2024-02-29').yearAgo).toBeNull();
expect(
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
).toHaveLength(7);
});
it('accepts multi-year periods and creates monthly buckets for the entire range', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-09-09T12:00:00Z'));
const plan = planIncomePeriod('2020-01-01', '2025-12-31');
expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' });
expect(plan.granularity).toBe('month');
expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72);
expect(plan.yearAgo).toEqual({ from: '2019-01-01', to: '2024-12-31' });
});
it('does not send the unfinished Moscow day as a future instant', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
expect(dateOnlyBounds({ from: '2026-08-01', to: '2026-08-28' })).toEqual({
from: new Date('2026-07-31T21:00:00.000Z'),
to: new Date('2026-08-27T21:44:18.510Z'),
});
});
});
@@ -0,0 +1,141 @@
import { BadRequestException } from '@nestjs/common';
import {
DateOnlyRange,
dateParts,
moscowDate,
utcDate,
} from '../investment-calendar';
export type IncomeBucketGranularity = 'day' | 'week' | 'month';
export function planIncomePeriod(
from: string | undefined,
to: string | undefined,
openedDate?: Date,
): {
selected: DateOnlyRange;
previous: DateOnlyRange;
yearAgo: DateOnlyRange | null;
granularity: IncomeBucketGranularity;
} {
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
const today = moscowDate(new Date());
const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today);
const previous = isFullMonth(selected)
? previousMonth(selected.from)
: shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected);
const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
? candidate
: null;
return {
selected,
previous,
yearAgo: yearAgoRange,
granularity:
rangeDays(selected) <= 45
? 'day'
: rangeDays(selected) <= 180
? 'week'
: 'month',
};
}
export function rangeBuckets(
range: DateOnlyRange,
granularity: IncomeBucketGranularity,
): DateOnlyRange[] {
if (granularity === 'day')
return dates(range).map((date) => ({ from: date, to: date }));
const buckets: DateOnlyRange[] = [];
let cursor =
granularity === 'week'
? addDays(range.from, -((utcDate(range.from).getUTCDay() + 6) % 7))
: `${range.from.slice(0, 7)}-01`;
while (cursor <= range.to) {
const end =
granularity === 'week' ? addDays(cursor, 6) : endOfMonth(cursor);
buckets.push({
from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end,
});
cursor = addDays(end, 1);
}
return buckets;
}
export function rangeDays(range: DateOnlyRange): number {
return (
Math.round(
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
86_400_000,
) + 1
);
}
function assertRange(range: DateOnlyRange, today: string): void {
try {
dateParts(range.from);
dateParts(range.to);
} catch {
throw invalidPeriod();
}
if (range.from > range.to || range.to > today) throw invalidPeriod();
}
function addDays(value: string, days: number): string {
const date = utcDate(value);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function dates(range: DateOnlyRange): string[] {
const result: string[] = [];
for (let date = range.from; date <= range.to; date = addDays(date, 1))
result.push(date);
return result;
}
function firstOfMonth(value: string): string {
return `${value.slice(0, 7)}-01`;
}
function endOfMonth(value: string): string {
const [year, month] = dateParts(firstOfMonth(value));
return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10);
}
function isFullMonth(range: DateOnlyRange): boolean {
return (
range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to)
);
}
function previousMonth(from: string): DateOnlyRange {
const last = addDays(firstOfMonth(from), -1);
return { from: firstOfMonth(last), to: last };
}
function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
return { from: addDays(range.from, days), to: addDays(range.to, days) };
}
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => {
const [year, month, day] = dateParts(value);
const result = `${String(year - 1).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return utcDate(result).toISOString().slice(0, 10) === result
? result
: null;
};
const from = shift(range.from);
const to = shift(range.to);
return from && to ? { from, to } : null;
}
function invalidPeriod(): BadRequestException {
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' });
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { OperationsModule } from '../operations/operations.module';
import { IncomeAnalyticsController } from './income-analytics.controller';
import { IncomeAnalyticsService } from './income-analytics.service';
@Module({
imports: [AuthModule, OperationsModule],
controllers: [IncomeAnalyticsController],
providers: [IncomeAnalyticsService],
})
export class IncomeModule {}
@@ -0,0 +1,46 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type {
TbankCandle,
TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api';
export function timestamp(value?: TbankTimestamp): string | null {
const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0
? new Date(seconds * 1000).toISOString()
: null;
}
export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10);
}
export function previousClose(
candles: TbankCandle[],
priceAt: string,
): TbankCandle | null {
return (
candles
.filter((candle) => {
const time = timestamp(candle.time);
return (
candle.isComplete &&
candle.close &&
time &&
time.slice(0, 10) < moscowDay(priceAt)
);
})
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
null
);
}
export async function optional<T>(
operation: () => Promise<T>,
name: string,
unavailable: string[],
): Promise<T | null> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ServiceUnavailableException)) throw error;
unavailable.push(name);
return null;
}
}
@@ -0,0 +1,109 @@
import {
add,
decimalFromString,
divide,
formatDecimal,
multiply,
} from '../../../../common/decimal';
import { MoneyDto } from '../../../../common/money.dto';
import {
PortfolioDto,
PortfolioPositionDto,
} from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto';
export function instrumentPosition(
portfolio: PortfolioDto,
uid: string,
type: 'STOCK' | 'BOND',
): StockPositionDto {
const positions = portfolio.positions.filter(
(p) => p.type === type && p.instrumentUid === uid,
);
const zero = decimalFromString('0');
const quantity = positions
.map((p) => decimalFromString(p.quantity))
.reduce(add, zero);
const costs = positions.map(cost);
const totalCost = sumComplete(costs);
const currentValue = sumComplete(positions.map((p) => p.currentValue));
const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn));
const positive =
positions.length > 0 &&
positions.every((p) => decimalFromString(p.quantity).value > 0n);
return {
quantity: formatDecimal(quantity),
currentValue,
absoluteReturn,
averagePurchasePrice:
positive && totalCost
? {
currency: totalCost.currency,
value: formatDecimal(
divide(decimalFromString(totalCost.value), quantity, 9),
),
}
: null,
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
accounts: positions.map((p) => ({
...p,
returnPercent:
decimalFromString(p.quantity).value > 0n
? percent(p.absoluteReturn, cost(p))
: null,
})),
isStale: portfolio.isStale,
observedAt: portfolio.lastSuccessfulAt,
};
}
function cost(position: PortfolioPositionDto): MoneyDto | null {
const price = position.averagePurchasePrice;
return price
? {
currency: price.currency,
value: formatDecimal(
multiply(
decimalFromString(price.value),
decimalFromString(position.quantity),
),
),
}
: null;
}
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency;
if (
!currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
)
return null;
return {
currency,
value: formatDecimal(
values.reduce(
(sum, value) => add(sum, decimalFromString(value.value)),
decimalFromString('0'),
),
),
};
}
function percent(
result: MoneyDto | null,
base: MoneyDto | null,
): string | null {
if (
!base ||
!result ||
base.currency !== result.currency ||
decimalFromString(base.value).value <= 0n
)
return null;
return formatDecimal(
divide(
multiply(decimalFromString(result.value), decimalFromString('100')),
decimalFromString(base.value),
6,
),
);
}
@@ -0,0 +1,231 @@
import {
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import {
TbankAccessService,
TbankClient,
type TbankConnectionContext,
} from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { optional, timestamp } from '../../application/instrument-market';
import { BondDto, BondSearchResultDto } from '../presentation/bond.dto';
type IdentifiedBond = TbankBond & { uid: string };
import { money } from '../infrastructure/bond-money.mapper';
@Injectable()
export class BondCatalogService {
private readonly cache = new LRUCache<string, Promise<object>>({
max: 300,
ttl: 30_000,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
) {}
search(userId: string, query: string): Promise<BondSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (connection) => {
const { token } = connection;
const found = await this.tbank.findBonds(token, query);
const bonds = selectBonds(found, query).slice(0, 30);
if (!bonds.length) return [];
// Batch prices and share metadata with detail sections. Resolve readable
// aliases only when an issue is opened, not for every search result.
const [details, prices] = await Promise.all([
Promise.all(
bonds.map(
async (bond) =>
(await optional(
() =>
this.bond(userId, connection, bond.uid)
.then(toBond)
.catch((error: unknown) => {
if (error instanceof NotFoundException)
return toBond(bond);
throw error;
}),
'details',
[],
)) ?? toBond(bond),
),
),
optional(
() =>
this.tbank.getLastPrices(
token,
bonds.map((bond) => bond.uid),
),
'prices',
[],
),
]);
return details.map((detail, index) => {
const short = toBond(bonds[index]);
const last = prices?.find(
(price) => price.instrumentUid === detail.instrumentUid,
);
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null,
priceAt: timestamp(last?.time),
};
});
});
}
get(userId: string, uid: string): Promise<BondDto> {
return this.cached(userId, `bond:${uid}`, async (connection) => {
const result = toBond(await this.bond(userId, connection, uid));
if (
!result.ticker ||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
)
return result;
try {
const match = await this.tickerMatch(userId, result.ticker);
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
} catch (error) {
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
if (
!(error instanceof NotFoundException) &&
!(error instanceof ConflictException) &&
!(error instanceof ServiceUnavailableException)
)
throw error;
}
return result;
});
}
async getByTicker(userId: string, ticker: string): Promise<BondDto> {
const match = await this.tickerMatch(userId, ticker);
return {
...(await this.get(userId, match.uid)),
slug: ticker.trim().toUpperCase(),
};
}
private tickerMatch(userId: string, ticker: string): Promise<IdentifiedBond> {
const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (connection) => {
const { token } = connection;
const found = await this.tbank.findBonds(token, normalized);
const matches = selectBonds(
found.filter(
(item) => item.ticker?.trim().toUpperCase() === normalized,
),
normalized,
);
if (!matches.length) throw new NotFoundException();
if (matches.length > 1)
throw new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' });
return matches[0];
});
}
bond(
userId: string,
connection: TbankConnectionContext,
uid: string,
): Promise<IdentifiedBond> {
return this.cachedForConnection(
userId,
connection,
`metadata:${uid}`,
async () => {
const bond = await this.tbank.getBond(connection.token, uid);
if (!bond || bond.uid !== uid) throw new NotFoundException();
return { ...bond, uid };
},
);
}
cached<T extends object>(
userId: string,
key: string,
operation: (connection: TbankConnectionContext) => Promise<T>,
): Promise<T> {
return this.access.withConnection(userId, (connection) =>
this.cachedForConnection(userId, connection, key, () =>
operation(connection),
),
);
}
cachedForConnection<T extends object>(
userId: string,
{ generation }: TbankConnectionContext,
key: string,
operation: () => Promise<T>,
): Promise<T> {
const cacheKey = JSON.stringify([userId, generation, key]);
const cached = this.cache.get(cacheKey);
if (cached) return cached as Promise<T>;
const pending = operation();
this.cache.set(cacheKey, pending);
return pending.catch((error) => {
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
throw error;
});
}
}
function selectBonds(found: TbankBond[], query: string): IdentifiedBond[] {
const seen = new Set<string>();
return found
.filter(
(bond): bond is IdentifiedBond =>
Boolean(bond.uid) && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND',
)
.sort(
(a, b) =>
Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') ||
a.uid.localeCompare(b.uid),
)
.filter((bond) => {
// A missing ISIN must not collapse unrelated catalog instruments.
const key = bond.isin?.trim()
? `isin:${bond.isin.trim().toUpperCase()}`
: `uid:${bond.uid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function toBond(bond: IdentifiedBond): BondDto {
return {
instrumentUid: bond.uid,
slug: bond.uid,
name: bond.name?.trim() || bond.ticker || bond.uid,
ticker: bond.ticker?.trim() || null,
isin: bond.isin?.trim() || null,
classCode: bond.classCode?.trim() || null,
realExchange: bond.realExchange || null,
apiTradeAvailable: bond.apiTradeAvailableFlag ?? null,
logoName: bond.brand?.logoName?.trim() || null,
currency: bond.currency?.toUpperCase() || null,
bondType: bond.bondType || null,
sector: bond.sector || null,
lot: bond.lot && bond.lot > 0 ? bond.lot : null,
historyFrom: timestamp(bond.first_1dayCandleDate),
nominal: money(bond.nominal),
initialNominal: money(bond.initialNominal),
aci: money(bond.aciValue),
maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null,
callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null,
couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
floatingCoupon: bond.floatingCouponFlag ?? null,
perpetual: bond.perpetualFlag ?? null,
amortization: bond.amortizationFlag ?? null,
subordinated: bond.subordinatedFlag ?? null,
riskLevel: bond.riskLevel || null,
};
}
@@ -0,0 +1,204 @@
import { Injectable } from '@nestjs/common';
import {
decimalFromString,
divide,
formatDecimal,
multiply,
} from '../../../../../common/decimal';
import { MoexClient } from '../../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr.api';
import {
TbankClient,
type TbankConnectionContext,
} from '../../../../../integrations/tbank/tbank.api';
import { PortfolioService } from '../../../portfolio/portfolio.api';
import {
moscowDay,
optional,
timestamp,
} from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position';
import {
BondCouponsDto,
BondMetricsDto,
BondPositionDto,
} from '../presentation/bond.dto';
import { BondMarketService } from './bond-market.service';
import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service';
@Injectable()
export class BondIncomeService {
constructor(
private readonly catalog: BondCatalogService,
private readonly tbank: TbankClient,
private readonly portfolios: PortfolioService,
private readonly market: BondMarketService,
private readonly moex: MoexClient,
private readonly ratings: NcrRatingsClient,
) {}
metrics(userId: string, uid: string): Promise<BondMetricsDto> {
return this.catalog.cached(userId, `metrics:${uid}`, async (connection) => {
const instrument = await this.catalog.bond(userId, connection, uid);
const { ticker, isin, classCode } = instrument;
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, quote, effectiveYield, creditRating] = await Promise.all([
optional(
() => this.couponEvents(userId, connection, uid, from, to),
'coupons',
unavailable,
),
optional(() => this.market.quote(userId, uid), 'quote', unavailable),
ticker && isin && classCode
? optional(
() => this.moex.bondYield(ticker, isin, classCode),
'yield',
unavailable,
)
: null,
isin
? optional(() => this.ratings.rating(isin), 'rating', unavailable)
: null,
]);
const next = events
?.filter((event) => {
const date = timestamp(event.couponDate)?.slice(0, 10);
return date && date >= today;
})
.sort(
(a, b) =>
Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds),
)[0];
const coupon = money(next?.payOneBond);
const period = next?.couponPeriod;
const couponPeriodDays =
period && Number.isInteger(period) && period > 0 ? period : null;
const clean = quote?.cleanPrice;
const couponYieldPercent =
coupon &&
couponPeriodDays &&
clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= 0n &&
decimalFromString(clean.value).value > 0n
? formatDecimal(
divide(
multiply(
decimalFromString(coupon.value),
decimalFromString('36500'),
),
multiply(
decimalFromString(String(couponPeriodDays)),
decimalFromString(clean.value),
),
4,
),
)
: null;
const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
? next.couponType
: instrument.floatingCouponFlag === true
? 'COUPON_TYPE_FLOATING'
: null;
return {
creditRating,
coupon,
couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null,
couponType,
couponPeriodDays,
couponYieldPercent,
couponYieldPriceAt:
couponYieldPercent !== null ? (quote?.priceAt ?? null) : null,
effectiveYield,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
private couponEvents(
userId: string,
connection: TbankConnectionContext,
uid: string,
from: string,
to: string,
) {
return this.catalog.cachedForConnection(
userId,
connection,
`coupon-events:${uid}:${from}:${to}`,
() => this.tbank.getBondCoupons(connection.token, uid, from, to),
);
}
coupons(userId: string, uid: string): Promise<BondCouponsDto> {
return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
await this.catalog.bond(userId, connection, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.couponEvents(userId, connection, uid, from, to),
optional(() => this.position(userId, uid), 'position', unavailable),
]);
const quantity =
position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
const coupons = events
.map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
const perBond = money(event.payOneBond);
const timing = !paymentDate
? ('undated' as const)
: paymentDate >= today
? ('upcoming' as const)
: ('past' as const);
return {
number: event.couponNumber ?? null,
paymentDate,
recordDate,
perBond,
couponType: event.couponType ?? null,
periodDays: event.couponPeriod ?? null,
timing,
estimatedAmount:
quantity &&
perBond &&
timing === 'upcoming' &&
recordDate &&
recordDate >= today
? {
currency: perBond.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)),
),
}
: null,
};
})
.sort((a, b) =>
(a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''),
);
return {
coupons,
from,
to,
observedAt: new Date().toISOString(),
positionAt: quantity ? (position?.observedAt ?? null) : null,
estimateUnavailable: unavailable.length > 0,
positionIsStale: position?.isStale ?? false,
};
});
}
async position(userId: string, uid: string): Promise<BondPositionDto> {
return this.catalog.cached(userId, `position:${uid}`, async () =>
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
);
}
}
@@ -0,0 +1,183 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import {
add,
decimalFromString,
divide,
formatDecimal,
multiply,
subtract,
} from '../../../../../common/decimal';
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import {
optional,
previousClose,
timestamp,
} from '../../application/instrument-market';
import {
BondCandlesDto,
BondCandlesQueryDto,
BondQuoteDto,
} from '../presentation/bond.dto';
const dayMs = 86_400_000;
import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service';
@Injectable()
export class BondMarketService {
constructor(
private readonly catalog: BondCatalogService,
private readonly tbank: TbankClient,
) {}
quote(userId: string, uid: string): Promise<BondQuoteDto> {
return this.catalog.cached(userId, `quote:${uid}`, async (connection) => {
const { token } = connection;
const instrument = await this.catalog.bond(userId, connection, uid);
const unavailable: string[] = [];
const [last, tradingStatus] = await Promise.all([
optional(
() => this.tbank.getLastPrice(token, uid),
'price',
unavailable,
),
optional(
() => this.tbank.getTradingStatus(token, uid),
'status',
unavailable,
),
]);
const priceAt = timestamp(last?.time);
const pricePercent = last?.price
? formatDecimal(decimal(last.price))
: null;
const nominal = money(instrument.nominal);
const aci = money(instrument.aciValue);
const cleanPrice =
pricePercent !== null &&
nominal &&
decimalFromString(nominal.value).value > 0n
? {
currency: nominal.currency,
value: formatDecimal(
divide(
multiply(
decimalFromString(pricePercent),
decimalFromString(nominal.value),
),
decimalFromString('100'),
9,
),
),
}
: null;
const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency
? {
currency: cleanPrice.currency,
value: formatDecimal(
add(
decimalFromString(cleanPrice.value),
decimalFromString(aci.value),
),
),
}
: null;
const previous = priceAt
? await optional(
async () =>
previousClose(
await this.tbank.getCandles(
token,
uid,
new Date(Date.parse(priceAt) - 20 * dayMs),
new Date(priceAt),
),
priceAt,
),
'comparison',
unavailable,
)
: null;
return {
pricePercent,
cleanPrice,
aci,
dirtyPrice,
priceAt,
tradingStatus,
changePercentagePoints:
last?.price && previous?.close
? formatDecimal(
subtract(decimal(last.price), decimal(previous.close)),
)
: null,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
candles(
userId: string,
uid: string,
period: BondCandlesQueryDto['period'] = 'month',
): Promise<BondCandlesDto> {
return this.catalog.cached(
userId,
`candles:${uid}:${period}`,
async (connection) => {
const { token } = connection;
const bond = await this.catalog.bond(userId, connection, uid);
const historyFrom = timestamp(bond.first_1dayCandleDate);
const to = new Date();
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
);
if (period === 'all') {
if (!historyFrom)
throw new ServiceUnavailableException({
code: 'BOND_HISTORY_START_UNAVAILABLE',
});
from.setTime(Date.parse(historyFrom));
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
from.setTime(Date.parse(historyFrom));
const collected = new Map<string, BondCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf());
for (const candle of await this.tbank.getCandles(
token,
uid,
new Date(start),
new Date(end),
)) {
const time = timestamp(candle.time);
if (
time &&
candle.close &&
Date.parse(time) >= from.valueOf() - dayMs &&
Date.parse(time) <= to.valueOf()
)
collected.set(time, {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
});
}
start = end;
}
return {
candles: [...collected.values()].sort((a, b) =>
a.time.localeCompare(b.time),
),
currency: null,
historyFrom,
observedAt: new Date().toISOString(),
};
},
);
}
}
@@ -0,0 +1,498 @@
import {
ConflictException,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import assert from 'node:assert/strict';
import { MoexClient } from '../../../../../integrations/moex/moex.client';
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr-ratings.client';
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
import { BondIncomeService } from './bond-income.service';
import { BondMarketService } from './bond-market.service';
import { BondCatalogService } from './bond-catalog.service';
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
const money = (units: string, currency = 'rub') => ({ units, currency });
const cacheClock = performance;
const bond = {
uid: 'uid',
ticker: 'BOND',
isin: 'ISIN',
instrumentKind: 'INSTRUMENT_TYPE_BOND',
nominal: money('500'),
initialNominal: money('1000'),
aciValue: money('12'),
amortizationFlag: true,
first_1dayCandleDate: time('2024-01-01'),
};
function setup() {
let generation = 'a';
const tbank = {
findBonds: jest.fn().mockResolvedValue([bond]),
getBond: jest.fn().mockResolvedValue(bond),
getLastPrice: jest.fn().mockResolvedValue({
instrumentUid: 'uid',
price: { units: '98', nano: 125000000 },
time: time('2026-09-10T10:00:00Z'),
}),
getLastPrices: jest.fn().mockResolvedValue([]),
getTradingStatus: jest
.fn()
.mockResolvedValue('SECURITY_TRADING_STATUS_NORMAL_TRADING'),
getCandles: jest.fn().mockResolvedValue([
{ time: time('2026-09-09'), close: { units: '99' }, isComplete: true },
{ time: time('2026-09-10'), close: { units: '100' }, isComplete: true },
]),
getBondCoupons: jest.fn().mockResolvedValue([]),
};
const access = {
withConnection: jest.fn(
(user: string, handler: (context: unknown) => unknown) =>
handler({ token: user, generation }),
),
};
const portfolios = {
getAll: jest.fn().mockResolvedValue({
positions: [],
lastSuccessfulAt: '2026-09-10T00:00:00Z',
isStale: false,
}),
};
const ratings = { rating: jest.fn().mockResolvedValue(null) };
const moex = { bondYield: jest.fn().mockResolvedValue(null) };
const service = new BondCatalogService(
access as unknown as TbankAccessService,
tbank as unknown as TbankClient,
);
const market = new BondMarketService(
service,
tbank as unknown as TbankClient,
);
const income = new BondIncomeService(
service,
tbank as unknown as TbankClient,
portfolios as unknown as PortfolioService,
market,
moex as unknown as MoexClient,
ratings as unknown as NcrRatingsClient,
);
return {
market,
income,
service,
ratings,
moex,
tbank,
access,
portfolios,
changeGeneration: () => {
generation = 'b';
},
};
}
describe('bond market use case', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z'));
jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now());
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => {
const { tbank, moex, market, income } = setup();
tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' });
tbank.getBondCoupons.mockResolvedValue([
{
couponDate: time('2026-12-10'),
payOneBond: money('40'),
couponPeriod: 91,
couponType: 'COUPON_TYPE_VARIABLE',
},
{
couponDate: time('2026-10-10'),
payOneBond: money('30'),
couponPeriod: 90,
couponType: 'COUPON_TYPE_FIX',
},
{
couponDate: time('2026-09-01'),
payOneBond: money('90'),
couponPeriod: 90,
},
]);
const effectiveYield = {
value: '15.6262',
pricePercent: '53.384',
date: '2041-05-15',
dateType: 'MATDATE',
tradeAt: '2026-09-10T18:49:39Z',
};
moex.bondYield.mockResolvedValue(effectiveYield);
const [metrics] = await Promise.all([
income.metrics('user', 'uid'),
income.coupons('user', 'uid'),
market.quote('user', 'uid'),
]);
expect(metrics).toMatchObject({
coupon: { value: '30', currency: 'RUB' },
couponDate: '2026-10-10',
couponType: 'COUPON_TYPE_FIX',
couponPeriodDays: 90,
couponYieldPercent: '24.7983',
effectiveYield,
});
expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'ISIN', 'TQOB');
expect(tbank.getBond).toHaveBeenCalledTimes(1);
expect(tbank.getBondCoupons).toHaveBeenCalledTimes(1);
expect(tbank.getLastPrice).toHaveBeenCalledTimes(1);
});
it.each(['unknown', 'currency', 'period', 'zero-price'])(
'does not invent coupon yield with %s inputs',
async (scenario) => {
const { tbank, income } = setup();
tbank.getBondCoupons.mockResolvedValue([
{
couponDate: time('2026-10-10'),
payOneBond:
scenario === 'unknown'
? undefined
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
couponPeriod: scenario === 'period' ? 0 : 90,
},
{
couponDate: time('2026-12-10'),
payOneBond: money('50'),
couponPeriod: 90,
},
]);
if (scenario === 'zero-price')
tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } });
expect(await income.metrics('user', 'uid')).toMatchObject({
couponDate: '2026-10-10',
couponYieldPercent: null,
});
},
);
it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => {
const { tbank, moex, income } = setup();
tbank.getBond.mockResolvedValue({
...bond,
classCode: 'TQOB',
floatingCouponFlag: true,
riskLevel: 'RISK_LEVEL_LOW',
});
moex.bondYield.mockRejectedValue(new ServiceUnavailableException());
expect(await income.metrics('user', 'uid')).toMatchObject({
effectiveYield: null,
coupon: null,
couponType: 'COUPON_TYPE_FLOATING',
couponYieldPercent: null,
unavailable: ['yield'],
});
tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException());
await expect(income.metrics('other', 'uid')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('returns only the rating of the exact issue and isolates a registry outage', async () => {
const { ratings, income } = setup();
const creditRating = {
agency: 'NCR',
value: 'AAA.ru',
status: 'active',
date: '2026-04-17',
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
observedAt: '2026-09-10T10:00:00Z',
};
ratings.rating.mockResolvedValue(creditRating);
expect(await income.metrics('user', 'uid')).toMatchObject({
creditRating,
});
expect(ratings.rating).toHaveBeenCalledWith('ISIN');
ratings.rating.mockRejectedValue(new ServiceUnavailableException());
expect(await income.metrics('other', 'uid')).toMatchObject({
creditRating: null,
unavailable: ['rating'],
});
});
it('opens concurrent sections within one upstream metadata request budget', async () => {
const { service, tbank, market, income } = setup();
tbank.getBond
.mockReset()
.mockResolvedValueOnce(bond)
.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(
Promise.all([
service.getByTicker('user', 'BOND'),
market.quote('user', 'uid'),
market.candles('user', 'uid'),
income.coupons('user', 'uid'),
income.metrics('user', 'uid'),
]),
).resolves.toHaveLength(5);
expect(tbank.getBond).toHaveBeenCalledTimes(1);
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
});
it('searches thirty issues without a separate ticker lookup for each result', async () => {
const { service, tbank, market, income } = setup();
const issues = Array.from({ length: 30 }, (_, index) => ({
...bond,
uid: `uid-${index}`,
isin: `ISIN-${index}`,
ticker: `BOND-${index}`,
}));
tbank.findBonds.mockResolvedValue(issues);
tbank.getBond.mockImplementation((_token: string, uid: string) =>
Promise.resolve(issues.find((item) => item.uid === uid)),
);
expect(await service.search('user', 'BOND')).toHaveLength(30);
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
expect(tbank.getBond).toHaveBeenCalledTimes(30);
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
await service.getByTicker('user', 'BOND-0');
await Promise.all([
market.quote('user', 'uid-0'),
market.candles('user', 'uid-0'),
income.coupons('user', 'uid-0'),
]);
expect(tbank.getBond).toHaveBeenCalledTimes(30);
expect(tbank.findBonds).toHaveBeenCalledTimes(2);
});
it('refreshes current nominal and accrued interest after the metadata TTL', async () => {
const { service, tbank, market } = setup();
await service.get('user', 'uid');
await jest.advanceTimersByTimeAsync(30_001);
tbank.getBond.mockResolvedValue({
...bond,
nominal: money('400'),
aciValue: money('3'),
});
expect(await market.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
dirtyPrice: { value: '395.5' },
});
expect(tbank.getBond).toHaveBeenCalledTimes(2);
});
it('keeps late metadata responses within their original connection generation', async () => {
const { tbank, changeGeneration, market } = setup();
let resolveOld: ((value: typeof bond) => void) | undefined;
tbank.getBond.mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
);
const oldQuote = market.quote('user', 'uid');
changeGeneration();
tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') });
expect(await market.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
});
assert.ok(resolveOld);
resolveOld(bond);
await oldQuote;
expect(await market.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
});
expect(tbank.getBond).toHaveBeenCalledTimes(2);
});
it('keeps percent quotes separate from money and uses current amortized nominal', async () => {
const { market } = setup();
expect(await market.quote('user', 'uid')).toMatchObject({
pricePercent: '98.125',
cleanPrice: { value: '490.625', currency: 'RUB' },
aci: { value: '12', currency: 'RUB' },
dirtyPrice: { value: '502.625', currency: 'RUB' },
changePercentagePoints: '-0.875',
previousCloseDate: '2026-09-09',
});
});
it('does not invent dirty prices or mix currencies when accrued interest is absent or mismatched', async () => {
const { tbank, market } = setup();
tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined });
expect(await market.quote('user', 'uid')).toMatchObject({
aci: null,
dirtyPrice: null,
});
tbank.getBond.mockResolvedValueOnce({
...bond,
aciValue: money('12', 'usd'),
});
expect(await market.quote('other', 'uid')).toMatchObject({
dirtyPrice: null,
});
tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined });
expect(await market.quote('third', 'uid')).toMatchObject({
pricePercent: '98.125',
cleanPrice: null,
dirtyPrice: null,
});
});
it('preserves absent coupon flags and dates without reporting false or zero', async () => {
const { service } = setup();
expect(await service.get('user', 'uid')).toMatchObject({
floatingCoupon: null,
perpetual: null,
couponQuantityPerYear: null,
maturityDate: null,
nominal: { value: '500', currency: 'RUB' },
amortization: true,
});
});
it('keeps distinct issues, deduplicates ISIN listings, and prices the selected exact UID', async () => {
const { service, tbank } = setup();
const listings = [
{ ...bond, uid: 'off', apiTradeAvailableFlag: false },
{ ...bond, uid: 'uid', apiTradeAvailableFlag: true },
{ ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' },
{ ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
{ ...bond, uid: undefined },
{ ...bond, uid: '' },
];
tbank.findBonds.mockResolvedValue(listings);
tbank.getBond.mockImplementation((_token: string, uid: string) =>
Promise.resolve(listings.find((b) => b.uid === uid)),
);
tbank.getLastPrices.mockResolvedValue([
{ instrumentUid: 'uid', price: { units: '99' } },
{ instrumentUid: 'off', price: { units: '1' } },
]);
const results = await service.search('user', 'BOND');
expect(results.map((x) => x.instrumentUid)).toEqual(['uid', 'second']);
expect(results[0]).toMatchObject({ pricePercent: '99', slug: 'uid' });
expect((await service.get('user', 'off')).slug).toBe('off');
});
it('rejects ambiguous tickers and wrong instrument identities', async () => {
const { service, tbank } = setup();
tbank.findBonds.mockResolvedValue([
bond,
{ ...bond, uid: 'other', isin: 'OTHER' },
]);
await expect(service.getByTicker('user', 'bond')).rejects.toBeInstanceOf(
ConflictException,
);
tbank.getBond.mockResolvedValue({ ...bond, uid: 'other' });
await expect(service.get('user', 'uid')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('preserves catalog results on enrichment outage but never suppresses auth failures', async () => {
const { service, tbank } = setup();
tbank.getBond.mockRejectedValue(new ServiceUnavailableException());
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
expect(await service.search('user', 'BOND')).toMatchObject([
{ instrumentUid: 'uid', pricePercent: null, nominal: { value: '500' } },
]);
tbank.getBond.mockRejectedValue(new UnauthorizedException());
await expect(service.search('other', 'BOND')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => {
const { tbank, market } = setup();
const history = await market.candles('user', 'uid', 'all');
expect(history.currency).toBeNull();
expect(history.candles.map((c) => c.close)).toEqual(['99', '100']);
for (const [, , from, to] of tbank.getCandles.mock.calls as [
string,
string,
Date,
Date,
][])
expect(to.valueOf() - from.valueOf()).toBeLessThanOrEqual(365 * 86400000);
tbank.getBond.mockResolvedValue({
...bond,
first_1dayCandleDate: undefined,
});
await expect(market.candles('other', 'uid', 'all')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('isolates caches by user and connection generation and retries failures', async () => {
const { service, tbank, changeGeneration } = setup();
await Promise.all([service.get('a', 'uid'), service.get('a', 'uid')]);
expect(tbank.getBond).toHaveBeenCalledTimes(1);
await service.get('b', 'uid');
changeGeneration();
await service.get('a', 'uid');
expect(tbank.getBond).toHaveBeenCalledTimes(3);
tbank.getBond.mockRejectedValueOnce(new ServiceUnavailableException());
await expect(service.get('c', 'uid')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
await expect(service.get('c', 'uid')).resolves.toMatchObject({
instrumentUid: 'uid',
});
});
it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => {
const { tbank, portfolios, income } = setup();
const holding = {
instrumentUid: 'uid',
type: 'BOND',
quantity: '2',
averagePurchasePrice: { value: '490', currency: 'RUB' },
currentValue: { value: '981.25', currency: 'RUB' },
absoluteReturn: { value: '1.25', currency: 'RUB' },
};
portfolios.getAll.mockResolvedValue({
positions: [
holding,
{ ...holding, instrumentUid: 'other', quantity: '99' },
{ ...holding, type: 'STOCK', quantity: '999' },
],
lastSuccessfulAt: '2026-09-10T00:00:00Z',
isStale: true,
});
tbank.getBondCoupons.mockResolvedValue([
{
couponNumber: '1',
couponDate: time('2026-10-01'),
fixDate: time('2026-09-29'),
payOneBond: money('10'),
},
{
couponNumber: '2',
couponDate: time('2026-10-01'),
fixDate: time('2026-09-09'),
payOneBond: money('10'),
},
{
couponNumber: '3',
couponDate: time('2026-10-01'),
payOneBond: money('10'),
},
{
couponNumber: '4',
couponDate: time('2026-10-01'),
fixDate: time('2026-09-29'),
},
{
couponNumber: '5',
couponDate: time('2025-10-01'),
payOneBond: money('10'),
},
]);
const result = await income.coupons('user', 'uid');
expect(
result.coupons.find((c) => c.number === '1')?.estimatedAmount,
).toEqual({ value: '20', currency: 'RUB' });
expect(
result.coupons
.filter((c) => c.number !== '1')
.every((c) => c.estimatedAmount === null),
).toBe(true);
expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull();
expect(result.positionIsStale).toBe(true);
expect(await income.position('user', 'uid')).toMatchObject({
quantity: '2',
currentValue: { value: '981.25' },
});
});
});
@@ -0,0 +1,10 @@
import { formatDecimal } from '../../../../../common/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase();
return value && currency
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
@@ -0,0 +1,106 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service';
import {
BondCandlesDto,
BondCandlesQueryDto,
BondCouponsDto,
BondDto,
BondMetricsDto,
BondPositionDto,
BondQuoteDto,
BondSearchQueryDto,
BondSearchResultDto,
BondTickerParamsDto,
} from './bond.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
@ApiTags('bonds')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('bonds')
export class BondController {
constructor(
private readonly bonds: BondCatalogService,
private readonly market: BondMarketService,
private readonly income: BondIncomeService,
) {}
@Get(':uid/metrics')
@ApiOkResponse({ type: BondMetricsDto })
metrics(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.income.metrics(request.user.sub, uid);
}
@Get()
@ApiOkResponse({ type: [BondSearchResultDto] })
search(
@Req() request: Request & { user: AccessPayload },
@Query() query: BondSearchQueryDto,
) {
return this.bonds.search(request.user.sub, query.query);
}
@Get('by-ticker/:ticker')
@ApiOkResponse({ type: BondDto })
getByTicker(
@Req() request: Request & { user: AccessPayload },
@Param() params: BondTickerParamsDto,
) {
return this.bonds.getByTicker(request.user.sub, params.ticker);
}
@Get(':uid')
@ApiOkResponse({ type: BondDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.get(request.user.sub, uid);
}
@Get(':uid/quote')
@ApiOkResponse({ type: BondQuoteDto })
quote(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.market.quote(request.user.sub, uid);
}
@Get(':uid/candles')
@ApiOkResponse({ type: BondCandlesDto })
candles(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: BondCandlesQueryDto,
) {
return this.market.candles(request.user.sub, uid, query.period);
}
@Get(':uid/position')
@ApiOkResponse({ type: BondPositionDto })
position(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.income.position(request.user.sub, uid);
}
@Get(':uid/coupons')
@ApiOkResponse({ type: BondCouponsDto })
coupons(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.income.coupons(request.user.sub, uid);
}
}
@@ -0,0 +1,177 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../../../common/money.dto';
import {
StockCandlesDto,
StockCandlesQueryDto,
StockPositionDto,
StockSearchQueryDto,
StockTickerParamsDto,
} from '../../stocks/presentation/stock.dto';
export class BondSearchQueryDto extends StockSearchQueryDto {}
export class BondTickerParamsDto extends StockTickerParamsDto {}
export class BondCandlesQueryDto extends StockCandlesQueryDto {}
export class BondCandlesDto extends StockCandlesDto {}
export class BondPositionDto extends StockPositionDto {}
export class BondDto {
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
@ApiProperty({
description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
})
declare slug: string;
@ApiProperty() declare name: string;
@ApiProperty({ type: String, nullable: true }) declare isin: string | null;
@ApiProperty({ type: String, nullable: true }) declare classCode:
string | null;
@ApiProperty({ type: String, nullable: true }) declare realExchange:
string | null;
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
boolean | null;
@ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) declare logoName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
@ApiProperty({ type: String, nullable: true }) declare sector: string | null;
@ApiProperty({ type: Number, nullable: true }) declare lot: number | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare historyFrom: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare nominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare initialNominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) declare maturityDate:
string | null;
@ApiProperty({ type: String, nullable: true }) declare callDate:
string | null;
@ApiProperty({ type: String, nullable: true }) declare bondType:
string | null;
@ApiProperty({ type: String, nullable: true }) declare riskLevel:
string | null;
@ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear:
number | null;
@ApiProperty({ type: Boolean, nullable: true }) 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;
}
export class BondSearchResultDto extends BondDto {
@ApiProperty({
type: String,
nullable: true,
description: 'Clean price in percent of nominal, not money.',
})
declare pricePercent: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
}
export class BondQuoteDto {
@ApiProperty({ type: String, nullable: true }) declare pricePercent:
string | null;
@ApiProperty({
type: MoneyDto,
nullable: true,
description:
'Indicative clean value at the current nominal; no FX conversion.',
})
declare cleanPrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
@ApiProperty({
type: MoneyDto,
nullable: true,
description:
'Indicative clean value plus current accrued interest, excluding fees.',
})
declare dirtyPrice: MoneyDto | null;
@ApiProperty({ type: String, nullable: true })
declare changePercentagePoints: string | null;
@ApiProperty({ type: String, nullable: true }) declare previousCloseDate:
string | null;
@ApiProperty({ type: String, nullable: true }) declare tradingStatus:
string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
@ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class BondCouponDto {
@ApiProperty({ type: String, nullable: true }) declare number: string | null;
@ApiProperty({ type: String, nullable: true }) declare paymentDate:
string | null;
@ApiProperty({ type: String, nullable: true }) declare recordDate:
string | null;
@ApiProperty({ type: String, nullable: true }) declare couponType:
string | null;
@ApiProperty({ type: Number, nullable: true }) declare periodDays:
number | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare perBond: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare estimatedAmount: MoneyDto | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
'upcoming' | 'past' | 'undated';
}
export class BondCouponsDto {
@ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[];
@ApiProperty() declare from: string;
@ApiProperty() declare to: string;
@ApiProperty() declare observedAt: string;
@ApiProperty({ type: String, nullable: true }) declare positionAt:
string | null;
@ApiProperty() declare estimateUnavailable: boolean;
@ApiProperty() declare positionIsStale: boolean;
}
export class BondYieldDto {
@ApiProperty({ description: 'MOEX effective annual yield in percent.' })
declare value: string;
@ApiProperty() declare pricePercent: string;
@ApiProperty({ format: 'date' }) declare date: string;
@ApiProperty({
type: String,
nullable: true,
description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.',
})
declare dateType: string | null;
@ApiProperty({ format: 'date-time' }) declare tradeAt: string;
}
export class BondCreditRatingDto {
@ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR';
@ApiProperty({ type: String, nullable: true }) declare value: string | null;
@ApiProperty({ enum: ['active', 'withdrawn'] }) declare status:
'active' | 'withdrawn';
@ApiProperty({ format: 'date' }) declare date: string;
@ApiProperty({ format: 'uri' }) declare sourceUrl: string;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class BondMetricsDto {
@ApiProperty({ type: BondCreditRatingDto, nullable: true })
declare creditRating: BondCreditRatingDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare coupon: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare couponDate: string | null;
@ApiProperty({ type: String, nullable: true }) declare couponType:
string | null;
@ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays:
number | null;
@ApiProperty({
type: String,
nullable: true,
description:
'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.',
})
declare couponYieldPercent: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare couponYieldPriceAt: string | null;
@ApiProperty({ type: BondYieldDto, nullable: true })
declare effectiveYield: BondYieldDto | null;
@ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { MoexModule } from '../../../integrations/moex/moex.module';
import { NcrModule } from '../../../integrations/ncr/ncr.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { BondIncomeService } from './bonds/application/bond-income.service';
import { BondMarketService } from './bonds/application/bond-market.service';
import { BondController } from './bonds/presentation/bond.controller';
import { BondCatalogService } from './bonds/application/bond-catalog.service';
import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case';
import { StockIncomeService } from './stocks/application/stock-income.service';
import { StockMarketService } from './stocks/application/stock-market.service';
import { StockController } from './stocks/presentation/stock.controller';
import { StockCatalogService } from './stocks/application/stock-catalog.service';
@Module({
imports: [AuthModule, TbankModule, MoexModule, NcrModule, PortfolioModule],
controllers: [StockController, BondController],
providers: [
BondIncomeService,
BondMarketService,
GetStockCompanyUseCase,
StockIncomeService,
StockMarketService,
StockCatalogService,
BondCatalogService,
],
})
export class InstrumentsModule {}
@@ -0,0 +1,94 @@
import { Injectable } from '@nestjs/common';
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
import { optional } from '../../application/instrument-market';
import { StockCompanyDto, StockMetricDto } from '../presentation/stock.dto';
import { StockCatalogService } from './stock-catalog.service';
@Injectable()
export class GetStockCompanyUseCase {
constructor(
private readonly catalog: StockCatalogService,
private readonly tbank: TbankClient,
) {}
execute(userId: string, uid: string): Promise<StockCompanyDto> {
return this.catalog.cached(userId, `company:${uid}`, async (token) => {
const share = await this.catalog.share(token, uid);
const unavailable: string[] = [];
const assetUid = share.assetUid;
if (!assetUid)
return {
description: null,
metrics: [],
unavailable,
observedAt: new Date().toISOString(),
};
const [description, fundamentals] = await Promise.all([
optional(
() => this.tbank.getAssetDescription(token, assetUid),
'description',
unavailable,
),
optional(
() => this.tbank.getFundamentals(token, assetUid),
'fundamentals',
unavailable,
),
]);
return {
description,
metrics: metrics(fundamentals, share.sector),
unavailable,
observedAt: new Date().toISOString(),
};
});
}
}
function metrics(
values: TbankFundamentals | null,
sector?: string,
): StockMetricDto[] {
if (!values) return [];
const bank = sector?.toLowerCase() === 'financial';
const selection: [
keyof TbankFundamentals,
StockMetricDto['unit'],
StockMetricDto['period'],
][] = [
['marketCapitalization', 'money', null],
['peRatioTtm', 'ratio', 'TTM'],
...(bank
? ([
['priceToBookTtm', 'ratio', 'TTM'],
['roe', 'percent', null],
] as typeof selection)
: ([
['revenueTtm', 'money', 'TTM'],
['ebitdaTtm', 'money', 'TTM'],
] as typeof selection)),
['netIncomeTtm', 'money', 'TTM'],
...(!bank
? ([
['totalDebtMrq', 'money', 'MRQ'],
['netDebtToEbitda', 'ratio', null],
] as typeof selection)
: []),
];
return selection.flatMap(([key, unit, period]) => {
const value = values[key];
const currency = values.currency?.toUpperCase() || null;
return typeof value === 'number' &&
Number.isFinite(value) &&
(unit !== 'money' || currency)
? [
{
key,
value,
unit,
period,
currency: unit === 'money' ? currency : null,
},
]
: [];
});
}
@@ -0,0 +1,200 @@
import {
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import {
TbankAccessService,
TbankClient,
} from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { optional, timestamp } from '../../application/instrument-market';
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
type IdentifiedShare = TbankShare & { uid: string };
@Injectable()
export class StockCatalogService {
private readonly cache = new LRUCache<string, Promise<object>>({
max: 300,
ttl: 30_000,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
) {}
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (token) => {
const found = await this.tbank.findShares(token, query);
const shares = selectShares(found, query).slice(0, 30);
if (!shares.length) return [];
// One market-data request for the list; failed enrichment must not hide a catalog result.
const [details, prices] = await Promise.all([
Promise.all(
shares.map(
async (share) =>
(await optional(
() =>
this.get(userId, share.uid).catch((error: unknown) => {
if (error instanceof NotFoundException) return stock(share);
throw error;
}),
'details',
[],
)) ?? stock(share),
),
),
optional(
() =>
this.tbank.getLastPrices(
token,
shares.map((share) => share.uid),
),
'prices',
[],
),
]);
return details.map((detail, index) => {
const short = stock(shares[index]);
const last = prices?.find(
(price) => price.instrumentUid === detail.instrumentUid,
);
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
price:
last?.price && detail.currency
? {
currency: detail.currency,
value: formatDecimal(decimal(last.price)),
}
: null,
priceAt: timestamp(last?.time),
};
});
});
}
get(userId: string, uid: string): Promise<StockDto> {
return this.cached(userId, `share:${uid}`, async (token) => {
const result = stock(await this.share(token, uid));
if (
!result.ticker ||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
)
return result;
try {
const match = await this.tickerMatch(userId, result.ticker);
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
} catch (error) {
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
if (
!(error instanceof NotFoundException) &&
!(error instanceof ConflictException) &&
!(error instanceof ServiceUnavailableException)
)
throw error;
}
return result;
});
}
async getByTicker(userId: string, ticker: string): Promise<StockDto> {
const match = await this.tickerMatch(userId, ticker);
return {
...(await this.get(userId, match.uid)),
slug: ticker.trim().toUpperCase(),
};
}
private tickerMatch(
userId: string,
ticker: string,
): Promise<IdentifiedShare> {
const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (token) => {
const found = await this.tbank.findShares(token, normalized);
const matches = selectShares(
found.filter(
(item) => item.ticker?.trim().toUpperCase() === normalized,
),
normalized,
);
if (!matches.length) throw new NotFoundException();
if (matches.length > 1)
throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' });
return matches[0];
});
}
async share(token: string, uid: string): Promise<IdentifiedShare> {
const share = await this.tbank.getShare(token, uid);
if (!share || share.uid !== uid) throw new NotFoundException();
return { ...share, uid };
}
cached<T extends object>(
userId: string,
key: string,
operation: (token: string) => Promise<T>,
): Promise<T> {
return this.access.withConnection(userId, ({ token, generation }) => {
const cacheKey = JSON.stringify([userId, generation, key]);
const cached = this.cache.get(cacheKey);
if (cached) return cached as Promise<T>;
const pending = operation(token);
this.cache.set(cacheKey, pending);
return pending.catch((error) => {
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
throw error;
});
});
}
}
function selectShares(found: TbankShare[], query: string): IdentifiedShare[] {
const seen = new Set<string>();
return found
.filter(
(share): share is IdentifiedShare =>
Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE',
)
.sort(
(a, b) =>
Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') ||
a.uid.localeCompare(b.uid),
)
.filter((share) => {
// A missing ISIN must not collapse unrelated catalog instruments.
const key = share.isin?.trim()
? `isin:${share.isin.trim().toUpperCase()}`
: `uid:${share.uid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function stock(share: IdentifiedShare): StockDto {
return {
instrumentUid: share.uid,
slug: share.uid,
name: share.name?.trim() || share.ticker || share.uid,
ticker: share.ticker?.trim() || null,
isin: share.isin?.trim() || null,
classCode: share.classCode?.trim() || null,
realExchange: share.realExchange || null,
apiTradeAvailable: share.apiTradeAvailableFlag ?? null,
logoName: share.brand?.logoName?.trim() || null,
currency: share.currency?.toUpperCase() || null,
shareType: share.shareType || null,
sector: share.sector || null,
lot: share.lot && share.lot > 0 ? share.lot : null,
historyFrom: timestamp(share.first_1dayCandleDate),
};
}
@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import {
decimalFromString,
formatDecimal,
multiply,
} from '../../../../../common/decimal';
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { PortfolioService } from '../../../portfolio/portfolio.api';
import {
moscowDay,
optional,
timestamp,
} from '../../application/instrument-market';
import { stockPosition } from './stock-position';
import {
StockDividendDto,
StockDividendsDto,
StockPositionDto,
} from '../presentation/stock.dto';
import { StockCatalogService } from './stock-catalog.service';
@Injectable()
export class StockIncomeService {
constructor(
private readonly catalog: StockCatalogService,
private readonly tbank: TbankClient,
private readonly portfolios: PortfolioService,
) {}
async position(userId: string, uid: string): Promise<StockPositionDto> {
return this.catalog.cached(userId, `position:${uid}`, async () =>
stockPosition(await this.portfolios.getAll(userId), uid),
);
}
dividends(userId: string, uid: string): Promise<StockDividendsDto> {
return this.catalog.cached(userId, `dividends:${uid}`, async (token) => {
await this.catalog.share(token, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.tbank.getDividends(token, uid, from, to),
optional(() => this.position(userId, uid), 'position', unavailable),
]);
const quantity =
position &&
position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => {
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
const paymentDate =
timestamp(event.paymentDate)?.slice(0, 10) ?? null;
const lastBuyDate =
timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
const currency = event.dividendNet?.currency?.toUpperCase();
const perShare =
currency && event.dividendNet
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
: null;
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
const eventDate = paymentDate ?? recordDate;
const timing: StockDividendDto['timing'] = eventDate
? eventDate >= today
? 'upcoming'
: 'past'
: 'undated';
const estimatedAmount =
quantity &&
perShare &&
recordDate &&
recordDate >= today &&
timing === 'upcoming'
? {
currency: perShare.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)),
),
}
: null;
return {
perShare,
recordDate,
lastBuyDate,
paymentDate,
timing,
estimatedAmount,
};
})
.sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? ''));
return {
dividends,
from,
to,
observedAt: new Date().toISOString(),
positionAt: quantity ? (position?.observedAt ?? null) : null,
estimateUnavailable: unavailable.length > 0,
positionIsStale: position?.isStale ?? false,
};
});
}
}
@@ -0,0 +1,170 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import {
decimalFromString,
divide,
formatDecimal,
multiply,
subtract,
} from '../../../../../common/decimal';
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import {
optional,
previousClose,
timestamp,
} from '../../application/instrument-market';
import {
StockCandlesDto,
StockCandlesQueryDto,
StockQuoteDto,
} from '../presentation/stock.dto';
const dayMs = 86_400_000;
import { StockCatalogService } from './stock-catalog.service';
@Injectable()
export class StockMarketService {
constructor(
private readonly catalog: StockCatalogService,
private readonly tbank: TbankClient,
) {}
quote(userId: string, uid: string): Promise<StockQuoteDto> {
return this.catalog.cached(userId, `quote:${uid}`, async (token) => {
const share = await this.catalog.share(token, uid);
const unavailable: string[] = [];
const [last, tradingStatus] = await Promise.all([
optional(
() => this.tbank.getLastPrice(token, uid),
'price',
unavailable,
),
optional(
() => this.tbank.getTradingStatus(token, uid),
'status',
unavailable,
),
]);
const priceAt = timestamp(last?.time);
const currency = share.currency?.toUpperCase();
const price =
last?.price && currency
? { currency, value: formatDecimal(decimal(last.price)) }
: null;
const previous = priceAt
? await optional(
async () => {
const end = new Date(priceAt);
return previousClose(
await this.tbank.getCandles(
token,
uid,
new Date(end.valueOf() - 20 * dayMs),
end,
),
priceAt,
);
},
'comparison',
unavailable,
)
: null;
const previousPrice =
previous?.close && currency
? { currency, value: formatDecimal(decimal(previous.close)) }
: null;
const difference =
price && previousPrice
? subtract(
decimalFromString(price.value),
decimalFromString(previousPrice.value),
)
: null;
return {
price,
priceAt,
tradingStatus,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
previousClose: previousPrice,
change:
difference && currency
? { currency, value: formatDecimal(difference) }
: null,
changePercent:
difference &&
previousPrice &&
decimalFromString(previousPrice.value).value > 0n
? formatDecimal(
divide(
multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value),
6,
),
)
: null,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
candles(
userId: string,
uid: string,
period: StockCandlesQueryDto['period'] = 'month',
): Promise<StockCandlesDto> {
return this.catalog.cached(
userId,
`candles:${uid}:${period}`,
async (token) => {
const share = await this.catalog.share(token, uid);
const historyFrom = timestamp(share.first_1dayCandleDate);
const to = new Date();
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
);
if (period === 'all') {
if (!historyFrom)
throw new ServiceUnavailableException({
code: 'STOCK_HISTORY_START_UNAVAILABLE',
});
from.setTime(Date.parse(historyFrom));
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
from.setTime(Date.parse(historyFrom));
const collected = new Map<string, StockCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf());
for (const candle of await this.tbank.getCandles(
token,
uid,
new Date(start),
new Date(end),
)) {
const time = timestamp(candle.time);
if (
time &&
candle.close &&
Date.parse(time) >= from.valueOf() - dayMs &&
Date.parse(time) <= to.valueOf()
)
collected.set(time, {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
});
}
start = end;
}
return {
candles: [...collected.values()].sort((a, b) =>
a.time.localeCompare(b.time),
),
currency: share.currency?.toUpperCase() || null,
historyFrom,
observedAt: new Date().toISOString(),
};
},
);
}
}
@@ -0,0 +1,86 @@
import {
PortfolioDto,
PortfolioPositionDto,
} from '../../../portfolio/presentation/portfolio.dto';
import { stockPosition } from './stock-position';
const money = (value: string, currency = 'RUB') => ({ value, currency });
const row = (
quantity = '2',
average = '10',
value = '30',
uid = 'common',
): PortfolioPositionDto => ({
instrumentUid: uid,
figi: uid,
displayName: uid,
type: 'STOCK',
quantity,
averagePurchasePrice: money(average),
currentValue: money(value),
absoluteReturn: money('10'),
isin: null,
ticker: null,
logoName: null,
});
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
positions,
cashBalances: [],
totalsByCurrency: [],
isStale: false,
lastSuccessfulAt: '2026-09-10T00:00:00Z',
structure: { currencies: [], unvaluedCategories: [] },
});
describe('stock position', () => {
it('weights costs exactly and keeps preferred shares separate', () => {
const result = stockPosition(
portfolio([
row(),
row('3', '20', '70'),
row('100', '1', '100', 'preferred'),
]),
'common',
);
expect(result.quantity).toBe('5');
expect(result.averagePurchasePrice).toEqual(money('16'));
expect(result.currentValue).toEqual(money('100'));
expect(result.absoluteReturn).toEqual(money('20'));
expect(result.returnPercent).toBe('25');
expect(result.accounts[0].returnPercent).toBe('50');
});
it('does not round large amounts through floating point', () => {
const result = stockPosition(
portfolio([row('9007199254740993', '0.1', '900719925474099.3')]),
'common',
);
expect(result.quantity).toBe('9007199254740993');
expect(result.averagePurchasePrice?.value).toBe('0.1');
});
it('does not sum currencies or treat unknown values as zero', () => {
const second = {
...row(),
currentValue: money('2', 'USD'),
averagePurchasePrice: null,
absoluteReturn: null,
};
const result = stockPosition(portfolio([row(), second]), 'common');
expect(result.quantity).toBe('4');
expect(result.currentValue).toBeNull();
expect(result.averagePurchasePrice).toBeNull();
expect(result.absoluteReturn).toBeNull();
expect(result.returnPercent).toBeNull();
});
it('keeps no-position and offsetting long/short holdings honest', () => {
expect(stockPosition(portfolio([]), 'common')).toMatchObject({
quantity: '0',
accounts: [],
currentValue: null,
returnPercent: null,
});
expect(
stockPosition(portfolio([row(), row('-2')]), 'common')
.averagePurchasePrice,
).toBeNull();
});
});
@@ -0,0 +1,5 @@
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position';
export function stockPosition(portfolio: PortfolioDto, uid: string) {
return instrumentPosition(portfolio, uid, 'STOCK');
}
@@ -0,0 +1,375 @@
import {
ConflictException,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
import { GetStockCompanyUseCase } from './get-stock-company.use-case';
import { StockIncomeService } from './stock-income.service';
import { StockMarketService } from './stock-market.service';
import { StockCatalogService } from './stock-catalog.service';
import { previousClose } from '../../application/instrument-market';
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) });
const share = {
uid: 'uid',
assetUid: 'asset',
currency: 'rub',
sector: 'financial',
first_1dayCandleDate: time('2025-11-20'),
};
function setup() {
let generation = 'a';
const tbank = {
findShares: jest.fn().mockResolvedValue([]),
getLastPrices: jest.fn().mockResolvedValue([]),
getShare: jest.fn().mockResolvedValue(share),
getCandles: jest.fn().mockResolvedValue([]),
getAssetDescription: jest.fn().mockResolvedValue('Company'),
getFundamentals: jest.fn().mockResolvedValue({
assetUid: 'asset',
currency: 'rub',
peRatioTtm: 6,
netIncomeTtm: 10,
}),
getDividends: jest.fn().mockResolvedValue([]),
};
const access = {
withConnection: jest.fn(
(user: string, handler: (context: unknown) => unknown) =>
handler({ token: user, generation }),
),
};
const portfolios = {
getAll: jest.fn().mockResolvedValue({
positions: [],
lastSuccessfulAt: '2026-09-10T00:00:00Z',
isStale: false,
}),
};
const service = new StockCatalogService(
access as unknown as TbankAccessService,
tbank as unknown as TbankClient,
);
const market = new StockMarketService(
service,
tbank as unknown as TbankClient,
);
const income = new StockIncomeService(
service,
tbank as unknown as TbankClient,
portfolios as unknown as PortfolioService,
);
const companyInfo = new GetStockCompanyUseCase(
service,
tbank as unknown as TbankClient,
);
return {
market,
income,
companyInfo,
service,
tbank,
portfolios,
changeGeneration: () => {
generation = 'b';
},
};
}
describe('stock market use case', () => {
beforeEach(() =>
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
);
afterEach(() => jest.useRealTimers());
it('returns unique ISINs, keeps preferred shares separate and matches prices by selected UID', async () => {
const { service, tbank } = setup();
const listings = [
{
uid: 'special',
ticker: 'SBER',
classCode: 'SPEQ',
apiTradeAvailableFlag: false,
},
{
uid: 'preferred',
ticker: 'SBERP',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
{
uid: 'common',
ticker: 'SBER',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
].map((item) => ({
...item,
name: 'Сбербанк',
isin: item.uid === 'preferred' ? 'ISINP' : 'ISIN',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
}));
tbank.findShares.mockResolvedValue([
...listings,
{ uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' },
{ instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
{ uid: '', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
]);
tbank.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve({
...listings.find((item) => item.uid === uid),
currency: 'rub',
brand: { logoName: 'sber.png' },
shareType:
uid === 'preferred' ? 'SHARE_TYPE_PREFERRED' : 'SHARE_TYPE_COMMON',
}),
);
tbank.getLastPrices.mockResolvedValue([
{
instrumentUid: 'preferred',
price: { units: '305' },
time: time('2026-09-09'),
},
{
instrumentUid: 'common',
price: { units: '306', nano: 120000000 },
time: time('2026-09-10'),
},
]);
const result = await service.search('user', 'SBER');
expect(result.map((item) => item.instrumentUid)).toEqual([
'common',
'preferred',
]);
expect(result[0]).toMatchObject({
classCode: 'TQBR',
isin: 'ISIN',
logoName: 'sber.png',
price: { currency: 'RUB', value: '306.12' },
priceAt: '2026-09-10T00:00:00.000Z',
});
expect(result[1]).toMatchObject({
shareType: 'SHARE_TYPE_PREFERRED',
price: { value: '305' },
});
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
});
it('prefers TQBR among equally available listings, deduplicates before the limit and keeps missing ISINs separate', async () => {
const { service, tbank } = setup();
const hit = {
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
ticker: 'SBER',
apiTradeAvailableFlag: true,
};
tbank.findShares.mockResolvedValue([
...Array.from({ length: 35 }, (_, i) => ({
...hit,
uid: `alternate-${i}`,
isin: ' isin ',
classCode: 'SPEQ',
})),
{ ...hit, uid: 'common', isin: 'ISIN', classCode: 'TQBR' },
{
...hit,
uid: 'preferred',
ticker: 'SBERP',
isin: 'ISINP',
classCode: 'TQBR',
},
{ ...hit, uid: 'unknown-a', isin: '' },
{ ...hit, uid: 'unknown-b' },
]);
tbank.getShare.mockResolvedValue(null);
const result = await service.search('user', 'SBER');
expect(result.map((item) => item.instrumentUid)).toEqual([
'common',
'unknown-a',
'unknown-b',
'preferred',
]);
expect(tbank.getShare).toHaveBeenCalledTimes(4);
});
it('retains catalog identity when enrichment is unavailable, but propagates auth errors', async () => {
const { service, tbank } = setup();
tbank.findShares.mockResolvedValue([
{
uid: 'uid',
ticker: 'SBER',
classCode: 'TQBR',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
},
]);
tbank.getShare.mockRejectedValue(new ServiceUnavailableException());
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
expect(await service.search('user', 'SBER')).toEqual([
expect.objectContaining({
instrumentUid: 'uid',
classCode: 'TQBR',
price: null,
logoName: null,
}),
]);
tbank.getShare.mockResolvedValue(null);
expect(await service.search('user', 'sber')).toEqual([
expect.objectContaining({ instrumentUid: 'uid' }),
]);
tbank.getShare.mockRejectedValue(new UnauthorizedException());
await expect(service.search('another-user', 'SBER')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('skips enrichment for an empty catalog result', async () => {
const { service, tbank } = setup();
expect(await service.search('user', 'none')).toEqual([]);
expect(tbank.getShare).not.toHaveBeenCalled();
expect(tbank.getLastPrices).not.toHaveBeenCalled();
});
it('resolves an exact ticker, preserves other listing UIDs and does not confuse SBER with SBERP', async () => {
const { service, tbank } = setup();
const listings = [
{
uid: 'common',
ticker: 'SBER',
isin: 'COMMON',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
{
uid: 'special',
ticker: 'SBER',
isin: 'COMMON',
classCode: 'SPEQ',
apiTradeAvailableFlag: false,
},
{
uid: 'preferred',
ticker: 'SBERP',
isin: 'PREFERRED',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
].map((item) => ({ ...item, instrumentKind: 'INSTRUMENT_TYPE_SHARE' }));
tbank.findShares.mockResolvedValue(listings);
tbank.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve(listings.find((item) => item.uid === uid)),
);
expect(await service.getByTicker('user', 'sberp')).toMatchObject({
instrumentUid: 'preferred',
slug: 'SBERP',
});
expect(await service.get('user', 'common')).toMatchObject({
instrumentUid: 'common',
slug: 'SBER',
});
expect(await service.get('user', 'special')).toMatchObject({
instrumentUid: 'special',
slug: 'special',
});
await expect(service.getByTicker('user', 'SB')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('refuses ambiguous tickers while keeping exact UID pages accessible', async () => {
const { service, tbank } = setup();
const listings = ['one', 'two'].map((uid) => ({
uid,
ticker: 'SAME',
isin: uid,
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
}));
tbank.findShares.mockResolvedValue(listings);
tbank.getShare.mockResolvedValue(listings[0]);
await expect(service.getByTicker('user', 'SAME')).rejects.toBeInstanceOf(
ConflictException,
);
expect(await service.get('user', 'one')).toMatchObject({
instrumentUid: 'one',
slug: 'one',
});
});
it('keeps exact pages if alias lookup fails and partitions ticker resolution by connection', async () => {
const { service, tbank, changeGeneration } = setup();
tbank.getShare.mockResolvedValue({ ...share, ticker: 'F' });
tbank.findShares.mockRejectedValueOnce(new ServiceUnavailableException());
expect(await service.get('user', 'uid')).toMatchObject({ slug: 'uid' });
tbank.findShares.mockResolvedValue([
{ ...share, ticker: 'F', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
]);
expect(await service.getByTicker('user', 'F')).toMatchObject({
instrumentUid: 'uid',
slug: 'F',
});
changeGeneration();
expect(await service.getByTicker('user', 'F')).toMatchObject({ slug: 'F' });
expect(tbank.findShares).toHaveBeenCalledTimes(3);
});
it('uses a complete previous trading day, never today’s main close', () => {
const candles = [
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: true },
{ time: time('2026-09-10'), close: { units: '11' }, isComplete: true },
{ time: time('2026-09-08'), close: { units: '9' }, isComplete: false },
];
expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]);
expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]);
});
it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => {
const { tbank, market } = setup();
tbank.getCandles.mockResolvedValue([
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: false },
]);
const result = await market.candles('user', 'uid', 'all');
expect(tbank.getCandles).toHaveBeenCalledWith(
'user',
'uid',
new Date('2025-11-20'),
expect.any(Date),
);
expect(result.candles).toHaveLength(1);
expect(result.candles[0].isComplete).toBe(false);
});
it('does not manufacture zero fundamentals and isolates optional failures', async () => {
const { tbank, companyInfo } = setup();
tbank.getAssetDescription.mockRejectedValue(
new ServiceUnavailableException(),
);
const result = await companyInfo.execute('user', 'uid');
expect(result.description).toBeNull();
expect(result.unavailable).toEqual(['description']);
expect(result.metrics.map((item) => item.key)).toEqual([
'peRatioTtm',
'netIncomeTtm',
]);
});
it('isolates cache by user and connection generation', async () => {
const { service, tbank, changeGeneration } = setup();
await service.get('a', 'uid');
await service.get('a', 'uid');
expect(tbank.getShare).toHaveBeenCalledTimes(1);
await service.get('b', 'uid');
changeGeneration();
await service.get('a', 'uid');
expect(tbank.getShare).toHaveBeenCalledTimes(3);
});
it('keeps public dividends when there is no holding or position retrieval fails', async () => {
const { tbank, portfolios, income } = setup();
portfolios.getAll.mockRejectedValue(new ServiceUnavailableException());
tbank.getDividends.mockResolvedValue([
{
dividendNet: { currency: 'rub', units: '32', nano: 880000000 },
recordDate: time('2026-10-13'),
paymentDate: time('2026-10-27'),
},
]);
const result = await income.dividends('user', 'uid');
expect(result.estimateUnavailable).toBe(true);
expect(result.dividends[0]).toMatchObject({
perShare: { currency: 'RUB', value: '32.88' },
estimatedAmount: null,
timing: 'upcoming',
});
});
});
@@ -0,0 +1,108 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
import { StockIncomeService } from '../application/stock-income.service';
import { StockMarketService } from '../application/stock-market.service';
import {
StockCandlesDto,
StockCandlesQueryDto,
StockCompanyDto,
StockDividendsDto,
StockDto,
StockPositionDto,
StockQuoteDto,
StockSearchQueryDto,
StockSearchResultDto,
StockTickerParamsDto,
} from './stock.dto';
import { StockCatalogService } from '../application/stock-catalog.service';
@ApiTags('stocks')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('stocks')
export class StockController {
constructor(
private readonly stocks: StockCatalogService,
private readonly market: StockMarketService,
private readonly income: StockIncomeService,
private readonly companyInfo: GetStockCompanyUseCase,
) {}
@Get()
@ApiOkResponse({ type: [StockSearchResultDto] })
search(
@Req() request: Request & { user: AccessPayload },
@Query() query: StockSearchQueryDto,
) {
return this.stocks.search(request.user.sub, query.query);
}
@Get('by-ticker/:ticker')
@ApiOkResponse({ type: StockDto })
getByTicker(
@Req() request: Request & { user: AccessPayload },
@Param() params: StockTickerParamsDto,
) {
return this.stocks.getByTicker(request.user.sub, params.ticker);
}
@Get(':uid')
@ApiOkResponse({ type: StockDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.stocks.get(request.user.sub, uid);
}
@Get(':uid/quote')
@ApiOkResponse({ type: StockQuoteDto })
quote(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.market.quote(request.user.sub, uid);
}
@Get(':uid/candles')
@ApiOkResponse({ type: StockCandlesDto })
candles(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: StockCandlesQueryDto,
) {
return this.market.candles(request.user.sub, uid, query.period);
}
@Get(':uid/position')
@ApiOkResponse({ type: StockPositionDto })
position(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.income.position(request.user.sub, uid);
}
@Get(':uid/company')
@ApiOkResponse({ type: StockCompanyDto })
company(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.companyInfo.execute(request.user.sub, uid);
}
@Get(':uid/dividends')
@ApiOkResponse({ type: StockDividendsDto })
dividends(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.income.dividends(request.user.sub, uid);
}
}
@@ -0,0 +1,168 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { MoneyDto } from '../../../../../common/money.dto';
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
export class StockSearchQueryDto {
@ApiProperty({ minLength: 2, maxLength: 80 })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MinLength(2)
@MaxLength(80)
declare query: string;
}
export class StockTickerParamsDto {
@ApiProperty({
minLength: 1,
maxLength: 80,
pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$',
})
@IsString()
@MinLength(1)
@MaxLength(80)
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
declare ticker: string;
}
export class StockCandlesQueryDto {
@ApiPropertyOptional({
enum: ['month', 'halfYear', 'year', 'all'],
default: 'month',
})
@IsOptional()
@IsIn(['month', 'halfYear', 'year', 'all'])
period?: 'month' | 'halfYear' | 'year' | 'all';
}
export class StockDto {
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
@ApiProperty({
description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
})
declare slug: string;
@ApiProperty() declare name: string;
@ApiProperty({ type: String, nullable: true }) declare isin: string | null;
@ApiProperty({ type: String, nullable: true }) declare classCode:
string | null;
@ApiProperty({ type: String, nullable: true }) declare realExchange:
string | null;
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
boolean | null;
@ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) declare logoName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
@ApiProperty({ type: String, nullable: true }) declare shareType:
string | null;
@ApiProperty({ type: String, nullable: true }) declare sector: string | null;
@ApiProperty({ type: Number, nullable: true }) declare lot: number | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare historyFrom: string | null;
}
export class StockSearchResultDto extends StockDto {
@ApiProperty({ type: MoneyDto, nullable: true })
declare price: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
}
export class StockQuoteDto {
@ApiProperty({ type: MoneyDto, nullable: true })
declare price: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null;
@ApiProperty({ type: String, nullable: true }) declare tradingStatus:
string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare change: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) declare changePercent:
string | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare previousCloseDate: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare previousClose: MoneyDto | null;
@ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class StockCandleDto {
@ApiProperty({ format: 'date-time' }) declare time: string;
@ApiProperty() declare close: string;
@ApiProperty() declare isComplete: boolean;
}
export class StockCandlesDto {
@ApiProperty({ type: [StockCandleDto] }) declare candles: StockCandleDto[];
@ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare historyFrom: string | null;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class StockPositionRowDto extends PortfolioPositionDto {
@ApiProperty({ type: String, nullable: true }) declare returnPercent:
string | null;
}
export class StockPositionDto {
@ApiProperty() declare quantity: string;
@ApiProperty({ type: MoneyDto, nullable: true })
declare currentValue: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare averagePurchasePrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare absoluteReturn: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) declare returnPercent:
string | null;
@ApiProperty({ type: [StockPositionRowDto] })
declare accounts: StockPositionRowDto[];
@ApiProperty() declare isStale: boolean;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class StockMetricDto {
@ApiProperty() declare key: string;
@ApiProperty() declare value: number;
@ApiProperty({ enum: ['money', 'ratio', 'percent'] }) declare unit:
'money' | 'ratio' | 'percent';
@ApiProperty({ type: String, nullable: true }) declare currency:
string | null;
@ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] })
declare period: 'TTM' | 'MRQ' | null;
}
export class StockCompanyDto {
@ApiProperty({ type: String, nullable: true }) declare description:
string | null;
@ApiProperty({ type: [StockMetricDto] }) declare metrics: StockMetricDto[];
@ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
}
export class StockDividendDto {
@ApiProperty({ type: MoneyDto, nullable: true })
declare perShare: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare recordDate: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare lastBuyDate: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare paymentDate: string | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
'upcoming' | 'past' | 'undated';
@ApiProperty({ type: MoneyDto, nullable: true })
declare estimatedAmount: MoneyDto | null;
}
export class StockDividendsDto {
@ApiProperty({ type: [StockDividendDto] })
declare dividends: StockDividendDto[];
@ApiProperty({ format: 'date' }) declare from: string;
@ApiProperty({ format: 'date' }) declare to: string;
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare positionAt: string | null;
@ApiProperty() declare estimateUnavailable: boolean;
@ApiProperty() declare positionIsStale: boolean;
}
@@ -0,0 +1,43 @@
export type DateOnlyRange = { from: string; to: string };
const moscow = 'Europe/Moscow';
export function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: moscow,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(value);
const get = (type: string) => parts.find((part) => part.type === type)?.value;
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } {
const from = dateParts(range.from);
const end = dateParts(range.to);
const to = new Date(Date.UTC(end[0], end[1] - 1, end[2], 20, 59, 59, 999));
return {
from: new Date(Date.UTC(from[0], from[1] - 1, from[2], -3)),
to: to > new Date() ? new Date() : to,
};
}
export function dateParts(value: string): [number, number, number] {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) throw new Error('Invalid date');
const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as [
number,
number,
number,
];
if (utcDate(value).toISOString().slice(0, 10) !== value)
throw new Error('Invalid date');
return parts;
}
export function utcDate(value: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return new Date(NaN);
return new Date(
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])),
);
}
@@ -0,0 +1,373 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import type { TbankAccountContext } from '../../../../integrations/tbank/tbank-access.service';
import { BrokerageOperationsService } from './brokerage-operations.service';
describe('BrokerageOperationsService', () => {
const access = { withAccount: jest.fn() };
const tbank = {
getOperationsByCursor: jest.fn(),
};
const requestContext = { set: jest.fn() };
const metadata = { resolve: jest.fn() };
const service = new BrokerageOperationsService(
access as never,
tbank as never,
requestContext as never,
metadata as never,
);
let operationsRequest: unknown;
let generation: string;
beforeEach(() => {
jest.clearAllMocks();
access.withAccount.mockReset();
tbank.getOperationsByCursor.mockReset();
metadata.resolve.mockReset();
generation = 'generation-1';
access.withAccount.mockImplementation(
(
_userId: string,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
token: 'token',
generation,
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getOperationsByCursor.mockImplementation(
(_token: string, request: unknown) => {
operationsRequest = request;
return Promise.resolve({ items: [] });
},
);
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('uses the current UTC year by default and asks only for executed operations', async () => {
await service.get('user', 'account', {});
const request = operationsRequest as {
state: string;
from: { seconds: string };
};
expect(request.state).toBe('OPERATION_STATE_EXECUTED');
expect(request.from.seconds).toBe(
String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)),
);
});
it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
hasNext: true,
nextCursor: 'next',
items: [
{ id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' },
{
id: 'fee',
date: { seconds: '2' },
type: 'OPERATION_TYPE_BROKER_FEE',
},
],
});
await expect(
service.get('user', 'account', {
from: '2025-01-01',
to: '2025-01-31',
categories: ['income'],
cursor: 'previous',
limit: 10,
}),
).resolves.toMatchObject({
items: [{ id: 'income' }],
nextCursor: 'next',
hasNext: true,
});
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
from: { seconds: '1735689600' },
to: { seconds: '1738367999', nanos: 999_999_999 },
cursor: 'previous',
}),
);
});
it('includes resolved instrument logo metadata in a ledger entry', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'coupon',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
],
});
metadata.resolve.mockResolvedValue({
brand: { logoName: 'instrument-logo.png' },
});
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
items: [{ id: 'coupon', logoName: 'instrument-logo.png' }],
});
});
it('resolves repeated instrument metadata only once per ledger page', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'first',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
{
id: 'second',
date: { seconds: '2' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
],
});
metadata.resolve.mockResolvedValue({
brand: { logoName: 'instrument-logo.png' },
});
await service.get('user', 'account', {});
expect(metadata.resolve).toHaveBeenCalledTimes(1);
});
it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'first',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
{
id: 'second',
date: { seconds: '2' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
figi: 'BBG000000001',
},
],
});
metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({
brand: { logoName: 'fallback-logo.png' },
});
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
items: [
{ id: 'first', logoName: null },
{ id: 'second', logoName: 'fallback-logo.png' },
],
});
expect(metadata.resolve).toHaveBeenNthCalledWith(
1,
'token',
'instrument-uid',
null,
);
expect(metadata.resolve).toHaveBeenNthCalledWith(
2,
'token',
'instrument-uid',
'BBG000000001',
);
});
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
await expect(
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
).resolves.toMatchObject({
accountId: 'account',
items: [],
nextCursor: null,
hasNext: false,
});
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
});
it('rejects inaccessible accounts before reading operations', async () => {
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
});
it('retains existing T-Bank error semantics', async () => {
access.withAccount.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(BadRequestException),
);
tbank.getOperationsByCursor.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z'));
await service.get('user', 'account', {
categories: ['tax', 'income', 'income'],
});
jest.advanceTimersByTime(500);
await service.get('user', 'account', { categories: ['income', 'tax'] });
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('does not let a cached page bypass account ownership checks', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query);
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account', query)).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('does not reuse a cached ledger page across connection generations', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query);
await service.get('user', 'account', query);
generation = 'generation-2';
await service.get('user', 'account', query);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('does not reuse a cached ledger page after the connection is deleted', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query);
access.withAccount.mockRejectedValueOnce(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
await expect(service.get('user', 'account', query)).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('keeps a late previous-generation page unavailable to ledger and income collection', async () => {
let finishPrevious: ((value: unknown) => void) | undefined;
const previous = new Promise((resolve) => {
finishPrevious = resolve;
});
tbank.getOperationsByCursor
.mockReturnValueOnce(previous)
.mockResolvedValueOnce({
items: [operation('current')],
});
const range = { from: '2025-01-01', to: '2025-01-31' };
const previousRequest = service.collectRanges('user', 'account', [range]);
generation = 'generation-2';
const current = await service.collectRanges('user', 'account', [range]);
finishPrevious?.({ items: [operation('previous')] });
await previousRequest;
const reused = await service.collectRanges('user', 'account', [range]);
expect(current.pages[0][0].items[0].id).toBe('current');
expect(reused.pages[0][0].items[0].id).toBe('current');
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('collects every cursor page and rejects a malformed continuation cursor', async () => {
tbank.getOperationsByCursor
.mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] })
.mockResolvedValueOnce({ hasNext: false, items: [] });
await expect(
service.collectRanges('user', 'account', [
{ from: '2026-01-01', to: '2026-01-31' },
]),
).resolves.toMatchObject({
pages: [[{ hasNext: true }, { hasNext: false }]],
});
tbank.getOperationsByCursor.mockReset();
tbank.getOperationsByCursor.mockResolvedValue({
hasNext: true,
nextCursor: 'same',
items: [],
});
await expect(
service.collectRanges('user', 'account', [
{ from: '2026-02-01', to: '2026-02-28' },
]),
).rejects.toMatchObject({ response: { code: 'TBANK_UNAVAILABLE' } });
});
it('collects an in-progress Moscow day only through the current instant', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
await service.collectRanges('user', 'account', [
{ from: '2026-08-01', to: '2026-08-28' },
]);
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
from: { seconds: '1785531600' },
to: { seconds: '1787867058' },
limit: 1000,
}),
);
});
it('evicts the least recently used ledger page after one hundred entries', async () => {
for (let index = 0; index < 100; index += 1)
await service.get('user', 'account', { cursor: String(index) });
await service.get('user', 'account', { cursor: '0' });
await service.get('user', 'account', { cursor: '100' });
await service.get('user', 'account', { cursor: '1' });
const pages = (service as unknown as { pages: { size: number } }).pages;
expect(pages.size).toBe(100);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
});
});
function operation(id: string) {
return {
id,
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
};
}
@@ -0,0 +1,509 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { randomUUID } from 'node:crypto';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import {
InstrumentMetadataService,
TbankAccessService,
TbankClient,
} from '../../../../integrations/tbank/tbank.api';
import { AccountReferenceDto } from '../../account-reference.dto';
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
import {
BrokerageOperationDto,
BrokerageOperationsPageDto,
BrokerageOperationsQueryDto,
} from '../presentation/brokerage-operation.dto';
import {
mapBrokerageOperation,
operationCategory,
} from '../infrastructure/brokerage-operation.mapper';
type OperationStream = {
account: AccountReferenceDto;
pending: BrokerageOperationDto[];
cursor?: string;
finished: boolean;
seen: string[];
lastOccurredAt?: number;
};
type AllOperationsContinuation = {
signature: string;
range: { from: Date; to: Date };
streams: OperationStream[];
page?: Promise<BrokerageOperationsPageDto>;
};
const freshForMs = 30_000;
const maxCachedPages = 100;
@Injectable()
export class BrokerageOperationsService {
private readonly pages = new LRUCache<string, BrokerageOperationsPageDto>({
max: maxCachedPages,
ttl: freshForMs,
ttlAutopurge: true,
});
private readonly continuations = new LRUCache<
string,
AllOperationsContinuation
>({
max: 100,
ttl: 5 * 60_000,
ttlAutopurge: true,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService,
) {}
async get(
userId: string,
accountId: string,
query: BrokerageOperationsQueryDto,
): Promise<BrokerageOperationsPageDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const range = this.range(query);
return this.access.withAccount(
userId,
accountId,
({ token, generation, account }) =>
this.page(
token,
generation,
userId,
accountId,
query,
query.instrumentUid && !query.from
? {
...range,
from: new Date(Number(account.openedDate?.seconds ?? 0) * 1000),
}
: range,
),
);
}
async getAll(
userId: string,
query: BrokerageOperationsQueryDto,
): Promise<BrokerageOperationsPageDto> {
return this.access.withSupportedAccounts(
userId,
async ({ token, generation, accounts }) => {
const signature = JSON.stringify({
userId,
generation,
accounts: accounts.map((account) => account.id).sort(),
instrumentUid: query.instrumentUid ?? null,
from: query.from ?? null,
to: query.to ?? null,
categories: [...new Set(query.categories ?? [])].sort(),
limit: query.limit ?? 100,
});
const continuation = query.cursor
? this.continuations.get(query.cursor)
: undefined;
if (
query.cursor &&
(!continuation || continuation.signature !== signature)
)
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
if (continuation?.page) return continuation.page;
const oldest = accounts
.map((account) => account.openedOn)
.filter((date): date is string => Boolean(date))
.sort()[0];
const range =
continuation?.range ??
this.range({
...query,
from:
query.from ??
(query.instrumentUid
? ((accounts.every((account) => account.openedOn)
? oldest
: undefined) ?? '1970-01-01')
: undefined),
});
const streams: OperationStream[] = continuation
? continuation.streams.map((stream) => ({
...stream,
pending: [...stream.pending],
seen: [...stream.seen],
}))
: accounts.map(({ id, name }) => ({
account: { id, name },
pending: [],
finished: false,
seen: [],
}));
const result = this.mergePage(
token,
generation,
userId,
query,
signature,
range,
streams,
);
if (continuation) continuation.page = result;
try {
return await result;
} catch (error) {
if (continuation) continuation.page = undefined;
throw error;
}
},
);
}
private async mergePage(
token: string,
generation: string,
userId: string,
query: BrokerageOperationsQueryDto,
signature: string,
range: { from: Date; to: Date },
streams: OperationStream[],
): Promise<BrokerageOperationsPageDto> {
const items: BrokerageOperationDto[] = [];
// Each stream retains unconsumed rows. A page boundary never discards them.
while (items.length < (query.limit ?? 100)) {
for (const stream of streams) {
while (!stream.pending.length && !stream.finished) {
const page = await this.page(
token,
generation,
userId,
stream.account.id,
{ ...query, cursor: stream.cursor },
range,
);
for (const item of page.items) {
const time = Date.parse(item.occurredAt);
if (
!Number.isFinite(time) ||
(stream.lastOccurredAt !== undefined &&
time > stream.lastOccurredAt)
)
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
stream.lastOccurredAt = time;
}
if (page.hasNext) {
if (!page.nextCursor || stream.seen.includes(page.nextCursor))
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
stream.seen.push(page.nextCursor);
stream.cursor = page.nextCursor;
} else stream.finished = true;
stream.pending = page.items.map((item) => ({
...item,
account: stream.account,
}));
}
}
const next = streams
.filter((stream) => stream.pending.length)
.sort(
(left, right) =>
Date.parse(right.pending[0].occurredAt) -
Date.parse(left.pending[0].occurredAt) ||
left.account.id.localeCompare(right.account.id),
)[0];
if (!next) break;
items.push(...next.pending.splice(0, 1));
}
const hasNext = streams.some(
(stream) => stream.pending.length || !stream.finished,
);
const nextCursor = hasNext ? randomUUID() : null;
if (nextCursor)
this.continuations.set(nextCursor, { signature, range, streams });
return {
accountId: 'all',
items,
hasNext,
nextCursor,
observedAt: new Date().toISOString(),
};
}
private async collectAllRanges(
userId: string,
ranges:
| DateOnlyRange[]
| ((accountOpenedAt: Date | undefined) => DateOnlyRange[]),
): Promise<{
accountOpenedAt: Date | undefined;
pages: BrokerageOperationsPageDto[][];
}> {
return this.access.withSupportedAccounts(
userId,
async ({ token, generation, accounts }) => {
// The current cohort includes newly opened accounts with no earlier receipts.
const oldest = accounts
.map((account) => account.openedOn)
.filter((date): date is string => Boolean(date))
.sort()[0];
const accountOpenedAt =
oldest && accounts.every((account) => account.openedOn)
? new Date(`${oldest}T00:00:00+03:00`)
: undefined;
const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
const pages: {
items: (BrokerageOperationDto & { account: AccountReferenceDto })[];
}[][] = selected.map(() => []);
for (const account of accounts) {
for (const [index, range] of selected.entries()) {
const collected = await this.collectRange(
token,
generation,
userId,
account.id,
range,
);
pages[index].push(
...collected.map((page) => ({
...page,
items: page.items.map((item) => ({
...item,
account: { id: account.id, name: account.name },
})),
})),
);
}
}
return {
accountOpenedAt,
pages: pages.map((group) => [
{
...emptyPage('all'),
items: group
.flatMap((page) => page.items)
.sort(
(left, right) =>
Date.parse(right.occurredAt) -
Date.parse(left.occurredAt) ||
left.account.id.localeCompare(right.account.id),
),
},
]),
};
},
);
}
async collectRanges(
userId: string,
accountId: string,
ranges:
| DateOnlyRange[]
| ((accountOpenedAt: Date | undefined) => DateOnlyRange[]),
): Promise<{
accountOpenedAt: Date | undefined;
pages: BrokerageOperationsPageDto[][];
}> {
if (accountId === 'all') return this.collectAllRanges(userId, ranges);
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withAccount(
userId,
accountId,
async ({ token, generation, account }) => {
const accountOpenedAt = account.openedDate?.seconds
? new Date(Number(account.openedDate.seconds) * 1000)
: undefined;
const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
return {
accountOpenedAt,
pages: await Promise.all(
selected.map((range) =>
this.collectRange(token, generation, userId, accountId, range),
),
),
};
},
);
}
private async collectRange(
token: string,
generation: string,
userId: string,
accountId: string,
range: DateOnlyRange,
): Promise<BrokerageOperationsPageDto[]> {
const pages: BrokerageOperationsPageDto[] = [];
const seen = new Set<string>();
let cursor: string | undefined;
do {
const page = await this.page(
token,
generation,
userId,
accountId,
{
from: range.from,
cursor,
limit: 1000,
categories: ['income'],
},
dateOnlyBounds(range),
);
pages.push(page);
if (!page.hasNext) return pages;
if (!page.nextCursor || seen.has(page.nextCursor))
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
seen.add(page.nextCursor);
cursor = page.nextCursor;
} while (cursor);
return pages;
}
private async page(
token: string,
generation: string,
userId: string,
accountId: string,
query: BrokerageOperationsQueryDto,
range: { from: Date; to: Date },
): Promise<BrokerageOperationsPageDto> {
const key = cacheKey(generation, userId, accountId, query, range);
const cached = this.pages.get(key);
if (cached) return cached;
if (range.from > range.to) {
const page = emptyPage(accountId);
this.pages.set(key, page);
return page;
}
const response = await this.tbank.getOperationsByCursor(token, {
accountId,
from: seconds(range.from),
to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')),
cursor: query.cursor,
limit: query.limit ?? 100,
state: 'OPERATION_STATE_EXECUTED',
...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}),
});
const items = (response.items ?? []).filter(
(item) =>
(!query.instrumentUid || item.instrumentUid === query.instrumentUid) &&
(!query.categories?.length ||
query.categories.includes(operationCategory(item.type))),
);
const logoNames = new Map<string, Promise<string | null>>();
const page = {
accountId,
items: await Promise.all(
items.map(async (item) => ({
...mapBrokerageOperation(item),
logoName: await this.logoName(
token,
item.instrumentUid,
item.figi,
logoNames,
),
})),
),
nextCursor: response.nextCursor || null,
hasNext: Boolean(response.hasNext),
observedAt: new Date().toISOString(),
};
this.pages.set(key, page);
return page;
}
private range(query: BrokerageOperationsQueryDto) {
const now = new Date();
return {
from:
dateBoundary(query.from, false) ??
new Date(Date.UTC(now.getUTCFullYear(), 0, 1)),
to: dateBoundary(query.to, true) ?? now,
};
}
private async logoName(
token: string,
instrumentUid: string | undefined,
figi: string | undefined,
logoNames: Map<string, Promise<string | null>>,
): Promise<string | null> {
const uid = instrumentUid?.trim() || null;
const instrumentFigi = figi?.trim() || null;
const key =
uid || instrumentFigi
? JSON.stringify({ uid, figi: instrumentFigi })
: null;
if (!key) return Promise.resolve(null);
const existing = logoNames.get(key);
if (existing) return existing;
const logoName = this.metadata
.resolve(token, uid, instrumentFigi)
.then((instrument) => instrument?.brand?.logoName?.trim() || null);
logoNames.set(key, logoName);
return logoName;
}
}
function cacheKey(
generation: string,
userId: string,
accountId: string,
query: BrokerageOperationsQueryDto,
range: { from: Date; to: Date },
): string {
const categories = [...new Set(query.categories ?? [])].sort();
const end = query.to
? range.to.toISOString()
: Math.floor(range.to.valueOf() / freshForMs);
return JSON.stringify({
generation,
userId,
accountId,
from: range.from.toISOString(),
to: end,
categories,
instrumentUid: query.instrumentUid ?? null,
cursor: query.cursor ?? null,
limit: query.limit ?? 100,
});
}
function dateBoundary(value: string | undefined, end: boolean): Date | null {
if (!value) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(value))
return new Date(`${value}T${end ? '23:59:59.999' : '00:00:00.000'}Z`);
return new Date(value);
}
function seconds(value: Date, endOfSecond = false) {
return {
seconds: Math.floor(value.valueOf() / 1000).toString(),
...(endOfSecond ? { nanos: 999_999_999 } : {}),
};
}
function emptyPage(accountId: string): BrokerageOperationsPageDto {
return {
accountId,
items: [],
nextCursor: null,
hasNext: false,
observedAt: new Date().toISOString(),
};
}
@@ -0,0 +1,64 @@
import {
mapBrokerageOperation,
operationCategory,
} from './brokerage-operation.mapper';
describe('mapBrokerageOperation', () => {
it.each([
['OPERATION_TYPE_COUPON', 'income'],
['OPERATION_TYPE_DIVIDEND', 'income'],
['OPERATION_TYPE_TAX', 'tax'],
['OPERATION_TYPE_BROKER_FEE', 'fee'],
['NEW_TYPE', 'other'],
])('maps %s to %s', (type, category) => {
expect(operationCategory(type)).toBe(category);
});
it('keeps a trade and its commission in one exact-money entry', () => {
expect(
mapBrokerageOperation({
id: 'trade',
date: { seconds: '1' },
type: 'OPERATION_TYPE_BUY',
payment: { currency: 'rub', units: '-1', nano: '-500000000' },
commission: { currency: 'RUB', nano: '-5000000' },
}),
).toMatchObject({
id: 'trade',
category: 'trade',
payment: { currency: 'RUB', value: '-1.5' },
commission: { currency: 'RUB', value: '-0.005' },
});
});
it('does not invent payment for an instrument-only movement', () => {
expect(
mapBrokerageOperation({
id: 'transfer',
date: { seconds: '1' },
type: 'OPERATION_TYPE_INPUT_SECURITIES',
quantity: '3',
}),
).toMatchObject({
category: 'transfer',
quantity: '3',
payment: null,
});
});
});
describe('executed trade quantity', () => {
it('uses the executed part, and does not invent execution quantity when absent', () => {
expect(
mapBrokerageOperation({
type: 'OPERATION_TYPE_BUY',
quantity: '100',
quantityDone: '7',
}).quantity,
).toBe('7');
expect(
mapBrokerageOperation({ type: 'OPERATION_TYPE_BUY', quantity: '100' })
.quantity,
).toBeNull();
});
});
@@ -0,0 +1,88 @@
import { formatDecimal } from '../../../../common/decimal';
import {
TbankMoney,
TbankOperationItem,
} from '../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
BrokerageOperationDto,
OperationCategory,
} from '../presentation/brokerage-operation.dto';
const types: Record<string, OperationCategory> = {
OPERATION_TYPE_BUY: 'trade',
OPERATION_TYPE_BUY_CARD: 'trade',
OPERATION_TYPE_SELL: 'trade',
OPERATION_TYPE_SELL_CARD: 'trade',
OPERATION_TYPE_BUY_MARGIN: 'trade',
OPERATION_TYPE_SELL_MARGIN: 'trade',
OPERATION_TYPE_COUPON: 'income',
OPERATION_TYPE_DIVIDEND: 'income',
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
OPERATION_TYPE_DIV_EXT: 'income',
OPERATION_TYPE_TRANS_IIS_BS: 'transfer',
OPERATION_TYPE_TRANS_BS_BS: 'transfer',
OPERATION_TYPE_BOND_TAX: 'tax',
OPERATION_TYPE_TAX: 'tax',
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
OPERATION_TYPE_SERVICE_FEE: 'fee',
OPERATION_TYPE_MARGIN_FEE: 'fee',
OPERATION_TYPE_BROKER_FEE: 'fee',
OPERATION_TYPE_SUCCESS_FEE: 'fee',
OPERATION_TYPE_INPUT: 'transfer',
OPERATION_TYPE_OUTPUT: 'transfer',
OPERATION_TYPE_INPUT_SECURITIES: 'transfer',
OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer',
};
export function operationCategory(type: string | undefined): OperationCategory {
return types[type ?? ''] ?? 'other';
}
export function mapBrokerageOperation(
operation: TbankOperationItem,
): BrokerageOperationDto {
return {
id: operation.id ?? operation.cursor ?? '',
occurredAt: timestamp(operation.date),
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
category: operationCategory(operation.type),
description: operation.description?.trim() || operation.name?.trim() || '',
figi: text(operation.figi),
instrumentUid: text(operation.instrumentUid),
ticker: text(operation.ticker),
instrumentType: text(operation.instrumentType),
logoName: null,
payment: money(operation.payment),
quantity:
(operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity) === undefined
? null
: String(
operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity,
),
price: money(operation.price),
accruedInterest: money(operation.accruedInt),
commission: money(operation.commission),
};
}
function text(value: string | undefined): string | null {
return value?.trim() || null;
}
function money(value: TbankMoney | undefined) {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
function timestamp(
value: { seconds?: string | number; nanos?: number } | undefined,
) {
return new Date(Number(value?.seconds ?? 0) * 1000).toISOString();
}
@@ -0,0 +1,3 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
export { BrokerageOperationsService } from './application/brokerage-operations.service';
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
import { BrokerageOperationsService } from './application/brokerage-operations.service';
@Module({
imports: [AuthModule, TbankModule],
controllers: [BrokerageOperationsController],
providers: [BrokerageOperationsService],
exports: [BrokerageOperationsService],
})
export class OperationsModule {}
@@ -0,0 +1,138 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto';
export const operationCategories = [
'trade',
'income',
'tax',
'fee',
'transfer',
'other',
] as const;
export type OperationCategory = (typeof operationCategories)[number];
export class BrokerageOperationsQueryDto {
@ApiPropertyOptional({
format: 'uuid',
description:
'Instrument UID. Without from, includes history since the earliest available account opening.',
})
@IsOptional()
@IsUUID()
instrumentUid?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional({ enum: operationCategories, isArray: true })
@IsOptional()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.split(',').filter(Boolean) : value,
)
@IsArray()
@IsEnum(operationCategories, { each: true })
categories?: OperationCategory[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(4096)
cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
}
export class BrokerageOperationDto {
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
@ApiProperty()
declare id: string;
@ApiProperty({ format: 'date-time' })
declare occurredAt: string;
@ApiProperty()
declare type: string;
@ApiProperty({ enum: operationCategories })
declare category: OperationCategory;
@ApiProperty()
declare description: string;
@ApiProperty({ type: String, nullable: true })
declare figi: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null;
@ApiProperty({ type: String, nullable: true })
declare ticker: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentType: string | null;
@ApiProperty({ type: String, nullable: true })
declare logoName: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare payment: MoneyDto | null;
@ApiProperty({ type: String, nullable: true })
declare quantity: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare price: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare accruedInterest: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare commission: MoneyDto | null;
}
export class BrokerageOperationsPageDto {
@ApiProperty()
declare accountId: string;
@ApiProperty({ type: BrokerageOperationDto, isArray: true })
declare items: BrokerageOperationDto[];
@ApiProperty({ type: String, nullable: true })
declare nextCursor: string | null;
@ApiProperty()
declare hasNext: boolean;
@ApiProperty({ format: 'date-time' })
declare observedAt: string;
}
@@ -0,0 +1,18 @@
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { BrokerageOperationsController } from './brokerage-operations.controller';
describe('BrokerageOperationsController', () => {
it('passes the authenticated user, account, and validated query to the service', async () => {
const operations = { get: jest.fn() };
const controller = new BrokerageOperationsController(operations as never);
const query = { limit: 10 };
const request = { user: { sub: 'user' } };
await controller.get(request as never, 'account', query);
expect(operations.get).toHaveBeenCalledWith('user', 'account', query);
});
});
@@ -0,0 +1,38 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import {
BrokerageOperationsPageDto,
BrokerageOperationsQueryDto,
} from './brokerage-operation.dto';
import { BrokerageOperationsService } from '../application/brokerage-operations.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class BrokerageOperationsController {
constructor(private readonly operations: BrokerageOperationsService) {}
@Get('all/operations')
@ApiOkResponse({ type: BrokerageOperationsPageDto })
all(
@Req() request: Request & { user: AccessPayload },
@Query() query: BrokerageOperationsQueryDto,
) {
return this.operations.getAll(request.user.sub, query);
}
@Get(':accountId/operations')
@ApiOkResponse({ type: BrokerageOperationsPageDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: BrokerageOperationsQueryDto,
) {
return this.operations.get(request.user.sub, accountId, query);
}
}
@@ -0,0 +1,373 @@
import {
BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
import assert from 'node:assert/strict';
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service';
import type {
TbankAccountContext,
TbankConnectionContext,
} from '../../../../integrations/tbank/tbank-access.service';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
const access = {
withConnection: jest.fn(),
withAccountForConnection: jest.fn(),
};
const tbank = {
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getInstrumentByFigi: jest.fn(),
getInstrumentByUid: jest.fn(),
};
const requestContext = { set: jest.fn() };
const logger = { event: jest.fn() };
const metadata = new InstrumentMetadataService(
tbank as never,
logger as never,
);
const service = new PortfolioService(
access as never,
tbank as never,
requestContext as never,
metadata,
);
let generation: string;
beforeEach(() => {
jest.clearAllMocks();
service.invalidate('user');
generation = 'generation-1';
access.withConnection.mockImplementation(
(
_userId: string,
handler: (context: TbankConnectionContext) => Promise<unknown>,
) => handler({ token: 'token', generation }),
);
access.withAccountForConnection.mockImplementation(
(
connection: TbankConnectionContext,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
...connection,
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
});
it('resolves account-scoped access before loading upstream portfolio', async () => {
await service.get('user', 'account');
await service.get('user', 'account');
expect(access.withConnection).toHaveBeenCalledTimes(2);
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
expect(tbank.getPositions).toHaveBeenCalledTimes(1);
});
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'ONE',
instrumentType: 'share',
quantity: { units: 2 },
currentPrice: { currency: 'rub', units: 3 },
averagePositionPrice: { currency: 'RUB', units: 2 },
},
{
figi: 'TWO',
instrumentType: 'new-kind',
quantity: { nano: -500000000 },
currentPrice: { currency: 'USD', units: 4 },
},
{ figi: 'ZERO', quantity: {} },
],
});
tbank.getPositions.mockResolvedValue({
money: [
{ currency: 'RUB', units: 1 },
{ currency: 'rub', nano: 500000000 },
{ currency: 'USD' },
],
});
tbank.getInstrumentByFigi
.mockResolvedValueOnce({
ticker: 'ONE',
name: 'Первая позиция',
isin: 'RU000A0JX0J2',
brand: { logoName: 'RU000A0JX0J2x160.png' },
})
.mockRejectedValueOnce(new Error('unavailable'));
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [
{
figi: 'ONE',
displayName: 'Первая позиция',
type: 'STOCK',
quantity: '2',
currentValue: { currency: 'RUB', value: '6' },
absoluteReturn: { currency: 'RUB', value: '2' },
isin: 'RU000A0JX0J2',
ticker: 'ONE',
logoName: 'RU000A0JX0J2x160.png',
},
{
figi: 'TWO',
displayName: 'TWO',
type: 'OTHER',
quantity: '-0.5',
currentValue: { currency: 'USD', value: '-2' },
isin: null,
ticker: null,
logoName: null,
},
],
cashBalances: [{ currency: 'RUB', value: '1.5' }],
totalsByCurrency: [
{ currency: 'RUB', value: '7.5' },
{ currency: 'USD', value: '-2' },
],
});
});
it('excludes zero and currency-less values while keeping an empty portfolio available', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
quantity: { units: 1 },
currentPrice: { currency: 'RUB' },
},
{
quantity: { units: 1 },
currentPrice: { units: 10 },
},
],
});
tbank.getPositions.mockResolvedValue({
money: [{ currency: 'USD' }, { units: 2 }],
});
await expect(service.get('user', 'account')).resolves.toMatchObject({
totalsByCurrency: [],
cashBalances: [],
});
});
it('uses instrument UID before FIGI when resolving a name', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'FIGI',
instrumentUid: 'UID',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [{ displayName: 'Имя по UID', logoName: null }],
});
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => {
const positions = Array.from({ length: 60 }, (_, index) => ({
figi: `BATCH-${index}`,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 10 },
}));
tbank.getPortfolio.mockResolvedValue({ positions });
let active = 0;
tbank.getInstrumentByFigi.mockImplementation(
async (_token: string, figi: string) => {
active += 1;
try {
if (active > 50)
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
await Promise.resolve();
return {
name: `Название ${figi}`,
ticker: figi,
isin: `ISIN-${figi}`,
brand: { logoName: `${figi}.png` },
};
} finally {
active -= 1;
}
},
);
const result = await service.get('user', 'account');
expect(result.positions).toMatchObject(
positions.map((position, index) => ({
displayName: `Название BATCH-${index}`,
type: position.instrumentType === 'share' ? 'STOCK' : 'BOND',
ticker: `BATCH-${index}`,
isin: `ISIN-BATCH-${index}`,
logoName: `BATCH-${index}.png`,
quantity: '2',
currentValue: { currency: 'RUB', value: '20' },
})),
);
});
it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => {
jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') });
try {
tbank.getPortfolio.mockResolvedValue({
positions: Array.from({ length: 60 }, (_, index) => ({
figi: `BUDGET-${index}`,
instrumentUid: `budget-uid-${index}`,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 10 },
})),
});
tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {}));
const pending = service.get('user', 'account');
await jest.advanceTimersByTimeAsync(0);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
await jest.advanceTimersByTimeAsync(30_000);
const result = await pending;
expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z');
expect(result.positions).toHaveLength(60);
expect(result.positions[59]).toMatchObject({
displayName: 'BUDGET-59',
currentValue: { currency: 'RUB', value: '20' },
});
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
await service.get('user', 'account');
await service.get('user', 'account');
expect(access.withConnection).toHaveBeenCalledTimes(2);
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
service.invalidate('user');
await service.get('user', 'account');
expect(tbank.getPortfolio).toHaveBeenCalledTimes(2);
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
isStale: true,
});
});
it('does not mask an invalid token with a snapshot', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
access.withAccountForConnection.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),
);
});
it('does not reuse a snapshot or fallback across connection generations', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
generation = 'generation-2';
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
const snapshot = snapshots.get('user:account');
assert.ok(snapshot);
snapshot.storedAt = Date.now() - 30_001;
access.withAccountForConnection.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
isStale: true,
});
});
it('does not return a snapshot when the current connection is absent', async () => {
await service.get('user', 'account');
access.withConnection.mockRejectedValueOnce(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ConflictException),
);
});
it('does not expose a late previous-generation portfolio snapshot', async () => {
let finishPrevious: ((value: { positions: never[] }) => void) | undefined;
const previous = new Promise<{ positions: never[] }>((resolve) => {
finishPrevious = resolve;
});
tbank.getPortfolio
.mockReturnValueOnce(previous)
.mockResolvedValueOnce({ positions: [] })
.mockResolvedValueOnce({ positions: [] });
tbank.getPositions
.mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] })
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] })
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] });
const previousRequest = service.get('user', 'account');
generation = 'generation-2';
const current = await service.get('user', 'account');
finishPrevious?.({ positions: [] });
await previousRequest;
const afterLateCompletion = await service.get('user', 'account');
expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]);
expect(afterLateCompletion.cashBalances).toEqual([
{ currency: 'USD', value: '3' },
]);
});
});
@@ -0,0 +1,288 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import {
add,
formatDecimal,
isZero,
multiply,
subtract,
} from '../../../../common/decimal';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import { MoneyDto } from '../../../../common/money.dto';
import {
InstrumentMetadataService,
TbankAccessService,
TbankClient,
TbankConnectionContext,
TbankMoney,
} from '../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../integrations/tbank/decimal';
import { portfolioStructure } from '../domain/portfolio-structure';
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
import {
PortfolioDto,
PortfolioPositionDto,
} from '../presentation/portfolio.dto';
const freshForMs = 30_000;
const fallbackForMs = 300_000;
const positionsBatchSize = 5;
const metadataTimeoutMs = 30_000;
const types: Record<string, string> = {
share: 'STOCK',
bond: 'BOND',
etf: 'FUND',
currency: 'CURRENCY',
futures: 'FUTURE',
option: 'OPTION',
sp: 'STRUCTURED_NOTE',
dfa: 'DIGITAL_FINANCIAL_ASSET',
};
type CachedSnapshot = {
generation: string;
snapshot: PortfolioDto;
storedAt: number;
};
@Injectable()
export class PortfolioService {
private readonly snapshots = new Map<string, CachedSnapshot>();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService,
) {}
async get(userId: string, accountId: string): Promise<PortfolioDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withConnection(userId, (connection) =>
this.snapshot(userId, accountId, connection),
);
}
async getAll(userId: string): Promise<PortfolioDto> {
return this.access.withSupportedAccounts(userId, async (context) => {
const portfolios: PortfolioDto[] = [];
for (const account of context.accounts) {
const portfolio = await this.snapshot(userId, account.id, context);
portfolios.push({
...portfolio,
positions: portfolio.positions.map((position) => ({
...position,
account: { id: account.id, name: account.name },
})),
});
}
return {
lastSuccessfulAt:
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
new Date().toISOString(),
isStale: portfolios.some((portfolio) => portfolio.isStale),
totalsByCurrency: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
),
cashBalances: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.cashBalances),
),
positions: portfolios.flatMap((portfolio) => portfolio.positions),
structure: portfolioStructure(
portfolios.flatMap((portfolio) => portfolio.positions),
portfolios.flatMap((portfolio) => portfolio.cashBalances),
),
};
});
}
private async snapshot(
userId: string,
accountId: string,
{ token, generation }: TbankConnectionContext,
): Promise<PortfolioDto> {
const key = `${userId}:${accountId}`;
const cached = this.snapshots.get(key);
if (
cached?.generation === generation &&
Date.now() - cached.storedAt <= freshForMs
)
return cached.snapshot;
try {
const snapshot = await this.access.withAccountForConnection(
{ token, generation },
accountId,
async () => {
const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
return this.normalize(
token,
portfolio.positions ?? [],
positions.money ?? [],
);
},
);
this.snapshots.set(key, {
generation,
snapshot,
storedAt: Date.parse(snapshot.lastSuccessfulAt),
});
return snapshot;
} catch (error) {
if (
error instanceof ServiceUnavailableException &&
cached?.generation === generation &&
Date.now() - cached.storedAt <= fallbackForMs
) {
return { ...cached.snapshot, isStale: true };
}
throw error;
}
}
invalidate(userId: string): void {
for (const key of this.snapshots.keys())
if (key.startsWith(`${userId}:`)) this.snapshots.delete(key);
}
private async normalize(
token: string,
upstream: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
quantity?: { units?: string | number; nano?: string | number };
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney;
}[],
money: TbankMoney[],
): Promise<PortfolioDto> {
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), metadataTimeoutMs);
const positions: (PortfolioPositionDto | null)[] = [];
try {
for (
let offset = 0;
offset < upstream.length;
offset += positionsBatchSize
) {
positions.push(
...(await Promise.all(
upstream
.slice(offset, offset + positionsBatchSize)
.map((position) =>
this.position(token, position, controller.signal),
),
)),
);
}
} finally {
clearTimeout(timeout);
}
const cash = new Map<string, ReturnType<typeof decimal>>();
for (const value of money) {
const currency = value.currency?.trim().toUpperCase();
const amount = decimal(value);
if (!currency || isZero(amount)) continue;
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
}
const cashBalances = [...cash].map(([currency, value]) => ({
currency,
value: formatDecimal(value),
}));
const normalizedPositions = positions.filter(
(position): position is PortfolioPositionDto => position !== null,
);
return {
lastSuccessfulAt,
isStale: false,
totalsByCurrency: totalsByCurrency(upstream, money),
positions: normalizedPositions,
cashBalances,
structure: portfolioStructure(normalizedPositions, cashBalances),
};
}
private async position(
token: string,
position: Parameters<PortfolioService['normalize']>[1][number],
signal: AbortSignal,
): Promise<PortfolioPositionDto | null> {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null;
const instrumentUid = position.instrumentUid?.trim() || null;
const currentPrice = this.money(position.currentPrice);
const averagePurchasePrice = this.money(position.averagePositionPrice);
const currentValue = currentPrice && this.value(quantity, currentPrice);
const absoluteReturn =
currentPrice &&
averagePurchasePrice &&
currentPrice.currency === averagePurchasePrice.currency
? {
currency: currentPrice.currency,
value: formatDecimal(
multiply(
subtract(
decimalFromString(currentPrice.value),
decimalFromString(averagePurchasePrice.value),
),
quantity,
),
),
}
: null;
const metadata = await this.metadata.resolve(
token,
instrumentUid,
figi,
signal,
);
return {
figi,
instrumentUid,
displayName:
metadata?.name?.trim() ||
metadata?.ticker?.trim() ||
figi ||
'Unknown instrument',
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
quantity: formatDecimal(quantity),
currentValue,
averagePurchasePrice,
absoluteReturn,
isin: metadata?.isin?.trim() || null,
ticker: metadata?.ticker?.trim() || null,
logoName: metadata?.brand?.logoName?.trim() || null,
};
}
private value(
quantity: ReturnType<typeof decimal>,
price: MoneyDto,
): MoneyDto {
return {
currency: price.currency,
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
};
}
private money(value: TbankMoney | undefined): MoneyDto | null {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
}
function decimalFromString(value: string) {
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}
@@ -0,0 +1,113 @@
import { portfolioStructure } from './portfolio-structure';
import { PortfolioPositionDto } from '../presentation/portfolio.dto';
const position = (
type: string,
value: string | null,
currency = 'RUB',
): PortfolioPositionDto => ({
type,
currentValue: value === null ? null : { currency, value },
quantity: '1',
displayName: 'Fixture',
figi: null,
isin: null,
ticker: null,
logoName: null,
absoluteReturn: null,
averagePurchasePrice: null,
});
describe('portfolio structure', () => {
it('includes cash once and keeps currency instruments among other assets', () => {
const result = portfolioStructure(
[
position('STOCK', '60'),
position('BOND', '20'),
position('CURRENCY', '10'),
],
[{ currency: 'RUB', value: '10' }],
);
expect(result.currencies[0]).toEqual({
currency: 'RUB',
totalValue: '100',
hasNegativeValues: false,
categories: [
{ type: 'STOCK', value: '60', sharePercent: '60' },
{ type: 'BOND', value: '20', sharePercent: '20' },
{ type: 'OTHER', value: '10', sharePercent: '10' },
{ type: 'CASH', value: '10', sharePercent: '10' },
],
});
});
it('sums beyond Number precision and separates currencies without FX', () => {
const result = portfolioStructure(
[
position('STOCK', '9007199254740993.000000001'),
position('STOCK', '0.000000009'),
position('BOND', '2', 'USD'),
],
[{ currency: 'USD', value: '1' }],
);
expect(result.currencies[0].totalValue).toBe('9007199254740993.00000001');
expect(result.currencies[1].totalValue).toBe('3');
expect(result.currencies[1].categories[1].sharePercent).toBe('66.666667');
expect(result.currencies[1].categories[3].sharePercent).toBe('33.333333');
});
it('represents cash-only, empty and zero-valued portfolios', () => {
expect(portfolioStructure([], []).currencies).toEqual([]);
expect(
portfolioStructure([], [{ currency: 'EUR', value: '10' }]).currencies[0]
.categories[3].sharePercent,
).toBe('100');
expect(
portfolioStructure(
[position('STOCK', '0')],
[],
).currencies[0].categories.every((item) => item.sharePercent === null),
).toBe(true);
});
it('retains unvalued categories without assigning zero or guessing a currency', () => {
const result = portfolioStructure(
[position('BOND', null), position('BOND', null), position('STOCK', '50')],
[],
);
expect(result.unvaluedCategories).toEqual([{ type: 'BOND', count: 2 }]);
expect(result.currencies[0].totalValue).toBe('50');
expect(
result.currencies[0].categories.every(
(item) => item.sharePercent === null,
),
).toBe(true);
expect(
portfolioStructure([position('OTHER', null)], []).currencies,
).toEqual([]);
});
it.each([
[[position('STOCK', '-20'), position('STOCK', '100')], []],
[
[],
[
{ currency: 'RUB', value: '-20' },
{ currency: 'RUB', value: '100' },
],
],
[[position('BOND', '-20')], []],
[[position('BOND', '-20'), position('BOND', '20')], []],
])(
'preserves negative constituents even when their category nets positive or zero',
(positions, cash) => {
const result = portfolioStructure(positions, cash);
expect(result.currencies[0].hasNegativeValues).toBe(true);
expect(
result.currencies[0].categories.every(
(item) => item.sharePercent === null,
),
).toBe(true);
},
);
});
@@ -0,0 +1,77 @@
import {
add,
decimalFromString,
formatDecimal,
} from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types';
const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const;
// Build from positions and cash, never from totals (which already include cash).
export function portfolioStructure(
positions: { type: string; currentValue: Money | null }[],
cash: Money[],
) {
const currencies = new Map<
string,
{
values: ReturnType<typeof decimalFromString>[];
hasNegativeValues: boolean;
}
>();
const unvalued = new Map<string, number>();
const include = (type: string, money: Money) => {
const group = currencies.get(money.currency) ?? {
values: categoryTypes.map(() => decimalFromString('0')),
hasNegativeValues: false,
};
const index = categoryTypes.findIndex((category) => category === type);
const value = decimalFromString(money.value);
group.values[index] = add(group.values[index], value);
group.hasNegativeValues ||= value.value < 0n;
currencies.set(money.currency, group);
};
for (const position of positions) {
const type = ['STOCK', 'BOND'].includes(position.type)
? position.type
: 'OTHER';
if (position.currentValue) include(type, position.currentValue);
else unvalued.set(type, (unvalued.get(type) ?? 0) + 1);
}
for (const amount of cash) include('CASH', amount);
return {
unvaluedCategories: [...unvalued].map(([type, count]) => ({ type, count })),
currencies: [...currencies]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, group]) => {
const total = group.values.reduce(add, decimalFromString('0'));
const canCalculateShares =
!unvalued.size && !group.hasNegativeValues && total.value > 0n;
const categories = group.values.map((value, index) => ({
type: categoryTypes[index],
value: formatDecimal(value),
// Rounded to six decimal places using integer arithmetic. Only the
// resulting dimensionless percentage is passed to chart geometry.
sharePercent: canCalculateShares
? formatDecimal({
value:
(value.value *
10n ** BigInt(total.scale - value.scale) *
100_000_000n +
total.value / 2n) /
total.value,
scale: 6,
})
: null,
}));
return {
currency,
totalValue: formatDecimal(total),
hasNegativeValues: group.hasNegativeValues,
categories,
};
}),
};
}
@@ -0,0 +1,22 @@
import {
add,
decimalFromString,
formatDecimal,
} from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types';
export function sumMoneyByCurrency(amounts: Money[]): Money[] {
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
for (const amount of amounts) {
totals.set(
amount.currency,
add(
totals.get(amount.currency) ?? decimalFromString('0'),
decimalFromString(amount.value),
),
);
}
return [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
}
@@ -0,0 +1,47 @@
import {
add,
formatDecimal,
isZero,
multiply,
} from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
TbankPortfolio,
TbankPositions,
} from '../../../../integrations/tbank/tbank.api';
function totalsByCurrency(
positions: NonNullable<TbankPortfolio['positions']>,
money: NonNullable<TbankPositions['money']>,
): Money[] {
const totals = new Map<string, ReturnType<typeof decimal>>();
const addTotal = (
currency: string | undefined,
amount: ReturnType<typeof decimal>,
) => {
const normalizedCurrency = currency?.trim().toUpperCase();
if (!normalizedCurrency || isZero(amount)) return;
totals.set(
normalizedCurrency,
add(totals.get(normalizedCurrency) ?? decimal({}), amount),
);
};
for (const value of money) addTotal(value.currency, decimal(value));
for (const position of positions) {
const price = position.currentPrice;
if (!price) continue;
addTotal(
price.currency,
multiply(decimal(position.quantity ?? {}), decimal(price)),
);
}
return [...totals]
.filter(([, value]) => !isZero(value))
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
}
export { totalsByCurrency };
@@ -0,0 +1,7 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { totalsByCurrency } from './infrastructure/tbank-portfolio.mapper';
export {
PortfolioDto,
PortfolioPositionDto,
} from './presentation/portfolio.dto';
export { PortfolioService } from './application/portfolio.service';
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioController } from './presentation/portfolio.controller';
import { PortfolioService } from './application/portfolio.service';
@Module({
imports: [AuthModule, TbankModule],
controllers: [PortfolioController],
providers: [PortfolioService],
exports: [PortfolioService],
})
export class PortfolioModule {}
@@ -0,0 +1,31 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import { PortfolioDto } from './portfolio.dto';
import { PortfolioService } from '../application/portfolio.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class PortfolioController {
constructor(private readonly portfolio: PortfolioService) {}
@Get('all/portfolio')
@ApiOkResponse({ type: PortfolioDto })
all(@Req() request: Request & { user: AccessPayload }) {
return this.portfolio.getAll(request.user.sub);
}
@Get(':accountId/portfolio')
@ApiOkResponse({ type: PortfolioDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
) {
return this.portfolio.get(request.user.sub, accountId);
}
}
@@ -0,0 +1,125 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto';
export class PortfolioPositionDto {
@ApiPropertyOptional({ type: String, nullable: true })
instrumentUid?: string | null;
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
@ApiProperty({ type: String, nullable: true })
declare figi: string | null;
@ApiProperty()
declare displayName: string;
@ApiProperty({
enum: [
'STOCK',
'BOND',
'FUND',
'CURRENCY',
'FUTURE',
'OPTION',
'STRUCTURED_NOTE',
'DIGITAL_FINANCIAL_ASSET',
'OTHER',
],
})
declare type: string;
@ApiProperty()
declare quantity: string;
@ApiProperty({ type: MoneyDto, nullable: true })
declare currentValue: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare averagePurchasePrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare absoluteReturn: MoneyDto | null;
@ApiProperty({ type: String, nullable: true })
declare isin: string | null;
@ApiProperty({ type: String, nullable: true })
declare ticker: string | null;
@ApiProperty({ type: String, nullable: true })
declare logoName: string | null;
}
export class PortfolioStructureCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER', 'CASH'] })
declare type: string;
@ApiProperty({ description: 'Exact known value as a decimal string.' })
declare value: string;
@ApiProperty({
type: String,
nullable: true,
description:
'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.',
})
declare sharePercent: string | null;
}
export class PortfolioStructureCurrencyDto {
@ApiProperty()
declare currency: string;
@ApiProperty()
declare totalValue: string;
@ApiProperty({
description: 'Any negative constituent, even if netted positive.',
})
declare hasNegativeValues: boolean;
@ApiProperty({ type: [PortfolioStructureCategoryDto] })
declare categories: PortfolioStructureCategoryDto[];
}
export class PortfolioUnvaluedCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] })
declare type: string;
@ApiProperty({ minimum: 1 })
declare count: number;
}
export class PortfolioStructureDto {
@ApiProperty({ type: [PortfolioStructureCurrencyDto] })
declare currencies: PortfolioStructureCurrencyDto[];
@ApiProperty({
type: [PortfolioUnvaluedCategoryDto],
description:
'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.',
})
declare unvaluedCategories: PortfolioUnvaluedCategoryDto[];
}
export class PortfolioDto {
@ApiProperty({ format: 'date-time' })
declare lastSuccessfulAt: string;
@ApiProperty()
declare isStale: boolean;
@ApiProperty({ type: MoneyDto, isArray: true })
declare totalsByCurrency: MoneyDto[];
@ApiProperty({ type: PortfolioPositionDto, isArray: true })
declare positions: PortfolioPositionDto[];
@ApiProperty({ type: MoneyDto, isArray: true })
declare cashBalances: MoneyDto[];
@ApiProperty({ type: PortfolioStructureDto })
declare structure: PortfolioStructureDto;
}
@@ -0,0 +1,55 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
PutTbankConnectionDto,
TbankConnectionStatusDto,
} from './tbank-connection.dto';
import { TbankConnectionService } from './tbank-connection.service';
@ApiTags('tbank-connection')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('tbank-connection')
export class TbankConnectionController {
constructor(private readonly connection: TbankConnectionService) {}
@Get()
@ApiOkResponse({ type: TbankConnectionStatusDto })
status(@Req() request: Request & { user: AccessPayload }) {
return this.connection.status(request.user.sub);
}
@Put()
@ApiOkResponse({ type: TbankConnectionStatusDto })
put(
@Req() request: Request & { user: AccessPayload },
@Body() body: PutTbankConnectionDto,
) {
return this.connection.put(request.user.sub, body.token);
}
@Delete()
@HttpCode(204)
@ApiNoContentResponse()
delete(@Req() request: Request & { user: AccessPayload }): Promise<void> {
return this.connection.delete(request.user.sub);
}
}
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class PutTbankConnectionDto {
@ApiProperty({ writeOnly: true })
@IsString()
@IsNotEmpty()
@MaxLength(4096)
declare token: string;
}
export class TbankConnectionStatusDto {
@ApiProperty()
declare connected: boolean;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare connectedAt: string | null;
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankConnectionController } from './tbank-connection.controller';
import { TbankConnectionService } from './tbank-connection.service';
@Module({
imports: [AuthModule, TbankModule, PortfolioModule],
controllers: [TbankConnectionController],
providers: [TbankConnectionService],
})
export class TbankConnectionModule {}
@@ -0,0 +1,122 @@
import { BadRequestException } from '@nestjs/common';
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import type { TbankClient } from '../../../integrations/tbank/tbank.client';
import { TbankConnectionService } from './tbank-connection.service';
describe('TbankConnectionService', () => {
const prisma = {
tbankConnection: {
findUnique: jest.fn(),
upsert: jest.fn(),
deleteMany: jest.fn(),
},
};
const tokens = {
encrypt: jest.fn((value: string) => `encrypted:${value}`),
translateInvalidToken: jest.fn(
async (operation: () => Promise<unknown>) => {
try {
return await operation();
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
throw error;
}
},
),
};
const tbank: jest.Mocked<Pick<TbankClient, 'validateToken'>> = {
validateToken: jest.fn(),
};
const portfolio = { invalidate: jest.fn() };
const service = new TbankConnectionService(
prisma as never,
tokens as never,
tbank,
portfolio as never,
);
beforeEach(() => {
jest.clearAllMocks();
tbank.validateToken.mockResolvedValue(undefined);
});
it('does not persist an invalid token', async () => {
tbank.validateToken.mockRejectedValueOnce(new Error('INVALID_TBANK_TOKEN'));
await expect(service.put('user-1', ' token ')).rejects.toEqual(
expect.any(BadRequestException),
);
expect(prisma.tbankConnection.upsert).not.toHaveBeenCalled();
});
it('validates and encrypts a candidate token before persisting it', async () => {
const connectedAt = new Date('2026-08-25T09:00:00.000Z');
prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt });
await expect(service.put('user-1', ' token ')).resolves.toEqual({
connected: true,
connectedAt: '2026-08-25T09:00:00.000Z',
});
expect(tokens.translateInvalidToken).toHaveBeenCalledWith(
expect.any(Function),
);
expect(tbank.validateToken).toHaveBeenCalledWith('token');
expect(tokens.encrypt).toHaveBeenCalledWith('token');
expect(prisma.tbankConnection.upsert).toHaveBeenCalledWith({
where: { userId: 'user-1' },
create: {
userId: 'user-1',
encryptedToken: 'encrypted:token',
connectedAt: expect.any(Date) as Date,
},
update: {
encryptedToken: 'encrypted:token',
connectedAt: expect.any(Date) as Date,
},
});
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
});
it('re-encrypts the same plaintext token for every successful replacement', async () => {
const connectedAt = new Date('2026-08-25T09:00:00.000Z');
prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt });
tokens.encrypt
.mockReturnValueOnce('encrypted:first-nonce')
.mockReturnValueOnce('encrypted:second-nonce');
await service.put('user-1', 'same-token');
await service.put('user-1', 'same-token');
expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
update: expect.objectContaining({
encryptedToken: 'encrypted:first-nonce',
}) as object,
}),
);
expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
update: expect.objectContaining({
encryptedToken: 'encrypted:second-nonce',
}) as object,
}),
);
});
it('deletes a connection idempotently', async () => {
await service.delete('user-1');
expect(prisma.tbankConnection.deleteMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
});
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
});
});
@@ -0,0 +1,60 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import {
TbankClient,
TbankTokenService,
} from '../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../prisma/prisma.service';
import { PortfolioService } from '../portfolio/portfolio.api';
type TbankTokenValidator = Pick<TbankClient, 'validateToken'>;
@Injectable()
export class TbankConnectionService {
constructor(
private readonly prisma: PrismaService,
private readonly tokens: TbankTokenService,
@Inject(TbankClient) private readonly tbank: TbankTokenValidator,
private readonly portfolio: PortfolioService,
) {}
async status(userId: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
return {
connected: Boolean(connection),
connectedAt: connection?.connectedAt.toISOString() ?? null,
};
}
async put(userId: string, value: string) {
const token = value.trim();
if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' });
await this.tokens.translateInvalidToken(() =>
this.tbank.validateToken(token),
);
const encryptedToken = this.tokens.encrypt(token);
const connection = await this.prisma.tbankConnection.upsert({
where: { userId },
create: {
userId,
encryptedToken,
connectedAt: new Date(),
},
update: {
encryptedToken,
connectedAt: new Date(),
},
});
this.portfolio.invalidate(userId);
return {
connected: true,
connectedAt: connection.connectedAt.toISOString(),
};
}
async delete(userId: string): Promise<void> {
await this.prisma.tbankConnection.deleteMany({ where: { userId } });
this.portfolio.invalidate(userId);
}
}
@@ -0,0 +1,20 @@
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { UpcomingEventsController } from './upcoming-events.controller';
describe('UpcomingEventsController', () => {
it('passes the authenticated user and account to the service', async () => {
const events = { get: jest.fn() };
const controller = new UpcomingEventsController(events as never);
await controller.get({ user: { sub: 'user' } } as never, 'account', {
from: '2026-09-01',
to: '2026-09-30',
});
expect(events.get).toHaveBeenCalledWith('user', 'account', {
from: '2026-09-01',
to: '2026-09-30',
});
});
});
@@ -0,0 +1,38 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
import { UpcomingEventsService } from './upcoming-events.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class UpcomingEventsController {
constructor(private readonly events: UpcomingEventsService) {}
@Get('all/events')
@ApiOkResponse({ type: UpcomingEventsDto })
all(
@Req() request: Request & { user: AccessPayload },
@Query() query: UpcomingEventsQueryDto,
) {
return this.events.getAll(request.user.sub, query);
}
@Get(':accountId/events')
@ApiOkResponse({ type: UpcomingEventsDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: UpcomingEventsQueryDto,
) {
return this.events.get(request.user.sub, accountId, query);
}
}
@@ -0,0 +1,74 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto';
import { AccountReferenceDto } from '../account-reference.dto';
export class UpcomingEventsQueryDto {
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsDateString({ strict: true })
from?: string;
@ApiPropertyOptional({
format: 'date',
description:
'Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsDateString({ strict: true })
to?: string;
}
export class UpcomingEventDto {
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
@ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] })
declare type: 'coupon' | 'dividend' | 'maturity' | 'offer';
@ApiProperty({ format: 'date' })
declare date: string;
@ApiProperty({ enum: ['event', 'registry'] })
declare dateKind: 'event' | 'registry';
@ApiProperty({ type: String, nullable: true })
declare figi: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null;
@ApiProperty()
declare ticker: string;
@ApiProperty()
declare displayName: string;
@ApiProperty({ type: String, nullable: true })
declare isin: string | null;
@ApiProperty({ type: String, nullable: true })
declare logoName: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare projectedAmount: MoneyDto | null;
}
export class UpcomingEventsDto {
@ApiProperty()
declare accountId: string;
@ApiProperty({ format: 'date-time' })
declare calculatedAt: string;
@ApiProperty({ type: UpcomingEventDto, isArray: true })
declare events: UpcomingEventDto[];
@ApiProperty({ type: MoneyDto, isArray: true })
declare incomeTotalsByCurrency: MoneyDto[];
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { MoexModule } from '../../../integrations/moex/moex.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { UpcomingEventsController } from './upcoming-events.controller';
import { UpcomingEventsService } from './upcoming-events.service';
@Module({
imports: [AuthModule, TbankModule, MoexModule],
controllers: [UpcomingEventsController],
providers: [UpcomingEventsService],
})
export class UpcomingEventsModule {}
@@ -0,0 +1,434 @@
import {
BadRequestException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import type { TbankAccountContext } from '../../../integrations/tbank/tbank-access.service';
import { UpcomingEventsService } from './upcoming-events.service';
describe('UpcomingEventsService', () => {
const access = { withAccount: jest.fn() };
const tbank = {
getPortfolio: jest.fn(),
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
getDividends: jest.fn(),
};
const moex = { bondEvents: jest.fn() };
const requestContext = { set: jest.fn() };
const logger = { info: jest.fn(), event: jest.fn() };
let service: UpcomingEventsService;
beforeEach(() => {
service = new UpcomingEventsService(
access as never,
tbank as never,
moex as never,
requestContext as never,
logger as never,
);
jest.clearAllMocks();
access.withAccount.mockImplementation(
(
_userId: string,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
token: 'token',
generation: 'generation-1',
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' });
moex.bondEvents.mockResolvedValue([]);
tbank.getDividends.mockResolvedValue([]);
});
it('includes both selected boundaries beyond the default window and isolates dividend caches by period', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SHARE',
instrumentType: 'share',
quantity: { units: 2 },
},
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
{ type: 'maturity', date: '2099-03-01', value: '1000', currency: 'RUB' },
{ type: 'offer', date: '2099-01-31' },
]);
tbank.getDividends.mockImplementation(
(_token: string, _id: string, from: string) =>
Promise.resolve([
{
dividendNet: { units: 5, currency: 'RUB' },
recordDate: {
seconds: String(Date.parse(`${from}T00:00:00Z`) / 1000),
},
},
]),
);
const february = { from: '2099-02-01', to: '2099-02-28' };
const first = await service.get('user', 'account', february);
expect(first.events.map(({ type, date }) => ({ type, date }))).toEqual([
{ type: 'coupon', date: february.from },
{ type: 'dividend', date: february.from },
{ type: 'offer', date: february.to },
]);
expect(first.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '16' },
]);
const march = await service.get('user', 'account', {
from: '2099-03-01',
to: '2099-03-01',
});
expect(march.events.map((event) => event.type)).toEqual([
'dividend',
'maturity',
]);
expect(march.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '10' },
]);
await service.get('user', 'account', february);
expect(tbank.getDividends).toHaveBeenCalledTimes(2);
expect(tbank.getDividends).toHaveBeenNthCalledWith(
1,
'token',
'S',
february.from,
february.to,
);
expect(tbank.getDividends).toHaveBeenNthCalledWith(
2,
'token',
'S',
'2099-03-01',
'2099-03-01',
);
});
it('defaults the end to 30 days after the selected start', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'offer', date: '2099-01-01' },
{ type: 'offer', date: '2099-01-31' },
{ type: 'offer', date: '2099-02-01' },
]);
const result = await service.get('user', 'account', { from: '2099-01-01' });
expect(result.events.map((event) => event.date)).toEqual([
'2099-01-01',
'2099-01-31',
]);
});
it('projects exact amounts and preserves registry dates and offers', async () => {
const now = new Date();
const day = (offset: number) =>
new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() + offset,
),
)
.toISOString()
.slice(0, 10);
tbank.getInstrumentByFigi.mockResolvedValue({
name: 'Бумага',
isin: ' RU000A0JX0J2 ',
brand: { logoName: ' instrument-logo.png ' },
});
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 2 },
},
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 250000000, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${day(1)}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
moex.bondEvents.mockResolvedValue([
{ type: 'offer', date: day(2) },
{ type: 'coupon', date: day(1), value: '2.5', currency: 'RUB' },
{ type: 'maturity', date: day(31), value: '1000', currency: 'RUB' },
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
type: 'coupon',
projectedAmount: { currency: 'RUB', value: '7.5' },
dateKind: 'event',
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
{
type: 'dividend',
projectedAmount: { currency: 'RUB', value: '2.5' },
dateKind: 'registry',
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
{
type: 'offer',
projectedAmount: null,
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
],
});
});
it.each([undefined, ' '])(
'keeps events without optional metadata when it is %s',
async (metadata) => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
instrumentUid: 'bond-uid',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByUid.mockResolvedValue({
name: 'Bond',
isin: metadata,
brand: { logoName: metadata },
});
moex.bondEvents.mockResolvedValue([{ type: 'offer', date: today }]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ type: 'offer', isin: null, logoName: null }],
});
},
);
it('does not read upstream data for inaccessible accounts', async () => {
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
it('uses the portfolio ticker without an unnecessary instrument lookup', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByFigi.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
ticker: 'SBER',
projectedAmount: { currency: 'RUB', value: '1' },
isin: null,
logoName: null,
},
],
});
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
});
it('skips cancelled dividends', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Cancelled',
},
{
dividendNet: { units: 2, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
type: 'dividend',
projectedAmount: { currency: 'RUB', value: '2' },
},
],
});
});
it('does not invent projected money when the source omits currency', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0 },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ type: 'dividend', projectedAmount: null }],
});
});
it('adds exact source-currency income totals without changing the events list', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: today, value: '1.25', currency: 'RUB' },
{ type: 'maturity', date: today, value: '100', currency: 'RUB' },
{ type: 'offer', date: today },
]);
const result = await service.get('user', 'account');
expect(result.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '1.25' },
]);
expect(result.events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'maturity' }),
expect.objectContaining({ type: 'offer' }),
]),
);
});
it('reuses upstream results within the cache TTL across requests', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await service.get('user', 'account');
await service.get('user', 'account');
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
expect(tbank.getDividends).toHaveBeenCalledTimes(1);
});
it('preserves invalid token and source failures without a partial result', async () => {
access.withAccount.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),
);
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
});
@@ -0,0 +1,327 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
} from '../../../common/decimal';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { StructuredLogger } from '../../../common/logging/structured-logger.service';
import { MoneyDto } from '../../../common/money.dto';
import { MoexBondEvent, MoexClient } from '../../../integrations/moex/moex.api';
import { decimal } from '../../../integrations/tbank/decimal';
import {
TbankAccessService,
TbankClient,
TbankDividend,
TbankInstrument,
TbankPortfolio,
} from '../../../integrations/tbank/tbank.api';
import {
UpcomingEventDto,
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
const positionsBatchSize = 5;
const dividendCacheTtlMs = 15 * 60_000;
const instrumentCacheTtlMs = 24 * 60 * 60_000;
@Injectable()
export class UpcomingEventsService {
private readonly dividendCache = new Map<
string,
CachedEntry<TbankDividend[]>
>();
private readonly instrumentCache = new Map<
string,
CachedEntry<TbankInstrument | null>
>();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly moex: MoexClient,
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {}
async get(
userId: string,
accountId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withAccount(userId, accountId, ({ token }) =>
this.forAccount(token, accountId, window),
);
}
async getAll(
userId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
return this.access.withSupportedAccounts(
userId,
async ({ token, accounts }) => {
const events: (UpcomingEventDto & {
account: { id: string; name: string };
})[] = [];
for (const account of accounts) {
const result = await this.forAccount(token, account.id, window);
events.push(
...result.events.map((event) => ({
...event,
account: { id: account.id, name: account.name },
})),
);
}
events.sort(
(left, right) =>
left.date.localeCompare(right.date) ||
left.account.id.localeCompare(right.account.id),
);
return {
accountId: 'all',
calculatedAt: new Date().toISOString(),
events,
incomeTotalsByCurrency: incomeTotals(events),
};
},
);
}
private async forAccount(
token: string,
accountId: string,
window: { from: string; to: string },
): Promise<UpcomingEventsDto> {
const portfolio = await this.tbank.getPortfolio(token, accountId);
const supportedPositions = (portfolio.positions ?? []).filter(supported);
const events: UpcomingEventDto[][] = [];
for (
let offset = 0;
offset < supportedPositions.length;
offset += positionsBatchSize
) {
const batch = supportedPositions.slice(
offset,
offset + positionsBatchSize,
);
events.push(
...(await Promise.all(
batch.map((position) => this.positionEvents(token, position, window)),
)),
);
}
const projected = events
.flat()
.filter((event) => event.date >= window.from && event.date <= window.to);
this.logger.info('upcoming-events.calculated', {
count: projected.length,
});
return {
accountId,
calculatedAt: new Date().toISOString(),
events: projected.sort(
(left, right) =>
left.date.localeCompare(right.date) ||
left.type.localeCompare(right.type) ||
left.ticker.localeCompare(right.ticker),
),
incomeTotalsByCurrency: incomeTotals(projected),
};
}
private async positionEvents(
token: string,
position: Position,
window: { from: string; to: string },
): Promise<UpcomingEventDto[]> {
const ticker = position.ticker?.trim();
const instrument = ticker
? await this.instrument(token, position).catch(() => {
this.logger.event('warn', 'upcoming-events.metadata-unavailable');
return null;
})
: await this.instrument(token, position);
const resolvedTicker = ticker || instrument?.ticker?.trim();
if (!resolvedTicker) return [];
const metadata = {
figi: position.figi?.trim() || null,
instrumentUid: position.instrumentUid?.trim() || null,
ticker: resolvedTicker,
displayName: instrument?.name?.trim() || resolvedTicker,
isin: instrument?.isin?.trim() || null,
logoName: instrument?.brand?.logoName?.trim() || null,
};
const quantity = decimal(position.quantity ?? {});
if (position.instrumentType?.toLowerCase() === 'share') {
const dividends = await this.dividends(token, position, window);
this.logger.event('debug', 'upcoming-events.source-result', {
count: dividends.length,
});
return dividends.map((event) => this.dividend(metadata, quantity, event));
}
const events = await this.moex.bondEvents(resolvedTicker);
this.logger.event('debug', 'upcoming-events.source-result', {
count: events.length,
});
return events.map((event) => this.bond(metadata, quantity, event));
}
private async instrument(token: string, position: Position) {
const instrumentUid = position.instrumentUid?.trim();
if (instrumentUid)
return this.cached(
this.instrumentCache,
instrumentUid,
instrumentCacheTtlMs,
() => this.tbank.getInstrumentByUid(token, instrumentUid),
);
const figi = position.figi?.trim();
if (figi)
return this.cached(this.instrumentCache, figi, instrumentCacheTtlMs, () =>
this.tbank.getInstrumentByFigi(token, figi),
);
return null;
}
private async dividends(
token: string,
position: Position,
window: { from: string; to: string },
): Promise<TbankDividend[]> {
const instrumentId = position.instrumentUid ?? position.figi;
if (!instrumentId) return [];
const dividends = await this.cached(
this.dividendCache,
JSON.stringify([instrumentId, window.from, window.to]),
dividendCacheTtlMs,
() =>
this.tbank.getDividends(token, instrumentId, window.from, window.to),
);
return dividends.filter(
(event) =>
event.dividendType !== 'Cancelled' && Boolean(event.recordDate),
);
}
private async cached<T>(
cache: Map<string, CachedEntry<T>>,
key: string,
ttlMs: number,
load: () => Promise<T>,
): Promise<T> {
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) return hit.promise;
const promise = load().catch((error: unknown) => {
cache.delete(key);
throw error;
});
cache.set(key, { expiresAt: now + ttlMs, promise });
return promise;
}
private dividend(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
>,
quantity: ReturnType<typeof decimal>,
event: TbankDividend,
): UpcomingEventDto {
return {
...metadata,
type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * 1000)
.toISOString()
.slice(0, 10),
dateKind: 'registry',
projectedAmount: event.dividendNet
? projectedMoney(
quantity,
formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency,
)
: null,
};
}
private bond(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
>,
quantity: ReturnType<typeof decimal>,
event: MoexBondEvent,
): UpcomingEventDto {
return {
...metadata,
type: event.type,
date: event.date,
dateKind: 'event',
projectedAmount:
'value' in event
? projectedMoney(quantity, event.value, event.currency)
: null,
};
}
}
function supported(position: Position): boolean {
return (
!isZero(decimal(position.quantity ?? {})) &&
['share', 'bond'].includes(position.instrumentType?.toLowerCase() ?? '')
);
}
function dateWindow(query: UpcomingEventsQueryDto) {
const from = query.from ?? new Date().toISOString().slice(0, 10);
const end = new Date(`${from}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + 30);
const to = query.to ?? end.toISOString().slice(0, 10);
if (from > to)
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
return { from, to };
}
function projectedMoney(
quantity: ReturnType<typeof decimal>,
value: string,
currency: string | undefined,
): MoneyDto | null {
const normalizedCurrency = currency?.trim().toUpperCase();
if (!normalizedCurrency) return null;
return {
currency: normalizedCurrency,
value: formatDecimal(multiply(quantity, decimalFromString(value))),
};
}
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
const totals = new Map<string, ReturnType<typeof decimal>>();
for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount)
continue;
const current = totals.get(event.projectedAmount.currency) ?? {
value: 0n,
scale: 0,
};
totals.set(
event.projectedAmount.currency,
add(current, decimalFromString(event.projectedAmount.value)),
);
}
return [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
}