refactor: organize backend by business responsibility
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+18
@@ -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) }));
|
||||
}
|
||||
Reference in New Issue
Block a user