diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index 27db677..a490415 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -38,6 +38,7 @@ export function ApiProblemResponses(): MethodDecorator & ClassDecorator { problemResponse(400, 'Invalid request'), problemResponse(401, 'Authentication required'), problemResponse(404, 'Resource not found'), + problemResponse(503, 'Upstream service unavailable'), problemResponse(500, 'Internal server error'), ); } diff --git a/src/tbank/moex.client.spec.ts b/src/tbank/moex.client.spec.ts new file mode 100644 index 0000000..a3563bc --- /dev/null +++ b/src/tbank/moex.client.spec.ts @@ -0,0 +1,31 @@ +import { MoexClient } from './moex.client'; + +describe('MoexClient', () => { + it('accepts normal ISS blocks when the response omits cursor blocks', async () => { + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + dividends: { + columns: ['registryclosedate', 'value', 'currencyid'], + data: [['2026-08-20', '2.5', 'SUR']], + }, + }), + } as Response); + + await expect(new MoexClient().dividends('SBER')).resolves.toEqual([ + { date: '2026-08-20', value: '2.5', currency: 'RUB' }, + ]); + fetchMock.mockRestore(); + }); + + it('treats a security absent from MOEX as having no known events', async () => { + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + await expect(new MoexClient().dividends('UNKNOWN')).resolves.toEqual([]); + fetchMock.mockRestore(); + }); +}); diff --git a/src/tbank/moex.client.ts b/src/tbank/moex.client.ts new file mode 100644 index 0000000..5904ed5 --- /dev/null +++ b/src/tbank/moex.client.ts @@ -0,0 +1,156 @@ +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; + +const baseUrl = 'https://iss.moex.com/iss'; +const timeoutMs = 10_000; + +export type MoexDividend = { + date: string; + value: string; + currency: string; +}; +export type MoexBondEvent = + | { type: 'coupon'; date: string; value: string; currency: string } + | { type: 'maturity'; date: string; value: string; currency: string } + | { type: 'offer'; date: string }; + +type IssBlock = { columns?: unknown; data?: unknown }; +type IssResponse = Record; + +@Injectable() +export class MoexClient { + async dividends(secid: string): Promise { + const rows = await this.rows( + `securities/${encodeURIComponent(secid)}/dividends.json`, + 'dividends', + ); + return rows.flatMap((row) => { + const date = text(row.registryclosedate); + const value = numberText(row.value); + const currency = currencyText(row.currencyid); + return date && value && currency ? [{ date, value, currency }] : []; + }); + } + + async bondEvents(secid: string): Promise { + const path = `securities/${encodeURIComponent(secid)}/bondization.json`; + const [coupons, offers, description] = await Promise.all([ + this.rows(path, 'coupons'), + this.rows(path, 'offers'), + this.rows( + `securities/${encodeURIComponent(secid)}.json`, + 'description', + false, + ), + ]); + const events: MoexBondEvent[] = []; + for (const row of coupons) { + const date = text(row.coupondate); + const value = numberText(row.value); + const currency = currencyText(row.faceunit); + if (date && value && currency) + events.push({ type: 'coupon', date, value, currency }); + } + for (const row of offers) { + const date = text(row.offerdate); + if (date) events.push({ type: 'offer', date }); + } + const values = new Map( + description.map((row) => [text(row.name), text(row.value)]), + ); + const date = values.get('MATDATE'); + const value = numberText(values.get('FACEVALUE')); + const currency = currencyText(values.get('FACEUNIT')); + if (date && value && currency) + events.push({ type: 'maturity', date, value, currency }); + return events; + } + + private async rows( + path: string, + block: string, + paginated = true, + ): Promise[]> { + const rows: Record[] = []; + let start = 0; + for (;;) { + const query = new URLSearchParams({ + 'iss.meta': 'off', + 'iss.only': `${block},${block}.cursor`, + start: String(start), + }); + const response = await this.request(`${baseUrl}/${path}?${query}`); + if (!response) return rows; + const current = parseRows(response[block]); + rows.push(...current); + if (!paginated) return rows; + const cursorBlock = response[`${block}.cursor`]; + if (!cursorBlock) { + if (current.length < 100) return rows; + start += 100; + continue; + } + const cursor = parseRows(cursorBlock)[0]; + const index = integer(cursor?.index); + const total = integer(cursor?.total); + const pageSize = integer(cursor?.pagesize); + if ( + index === null || + total === null || + pageSize === null || + index + pageSize >= total + ) + return rows; + start = index + pageSize; + } + } + + private async request(url: string): Promise { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); + const body: unknown = await response.json(); + if (!body || typeof body !== 'object') + throw new Error('MOEX malformed response'); + return body as IssResponse; + } catch { + throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } + } +} + +function parseRows(block: IssBlock | undefined): Record[] { + if (!Array.isArray(block?.columns) || !Array.isArray(block.data)) + throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + const columns = block.columns; + if (!columns.every((column) => typeof column === 'string')) + throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + return block.data.map((values) => { + if (!Array.isArray(values)) + throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + return Object.fromEntries( + columns.map((column, index) => [column.toLowerCase(), values[index]]), + ); + }); +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function numberText(value: unknown): string | null { + const result = typeof value === 'number' ? String(value) : text(value); + return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null; +} + +function currencyText(value: unknown): string | null { + const result = text(value)?.toUpperCase(); + return result === 'SUR' ? 'RUB' : (result ?? null); +} + +function integer(value: unknown): number | null { + const result = Number(value); + return Number.isSafeInteger(result) && result >= 0 ? result : null; +} diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index 3ed4382..be3ee89 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -3,11 +3,14 @@ import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageOperationsController } from './brokerage-operations.controller'; import { BrokerageOperationsService } from './brokerage-operations.service'; +import { MoexClient } from './moex.client'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; import { TbankClient } from './tbank.client'; import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionService } from './tbank-connection.service'; +import { UpcomingEventsController } from './upcoming-events.controller'; +import { UpcomingEventsService } from './upcoming-events.service'; @Module({ controllers: [ @@ -15,6 +18,7 @@ import { TbankConnectionService } from './tbank-connection.service'; BrokerageAccountsController, BrokerageOperationsController, PortfolioController, + UpcomingEventsController, ], providers: [ TbankClient, @@ -22,6 +26,8 @@ import { TbankConnectionService } from './tbank-connection.service'; BrokerageAccountsService, BrokerageOperationsService, PortfolioService, + MoexClient, + UpcomingEventsService, ], }) export class TbankConnectionModule {} diff --git a/src/tbank/tbank.client.ts b/src/tbank/tbank.client.ts index cb3bd38..6ed62a6 100644 --- a/src/tbank/tbank.client.ts +++ b/src/tbank/tbank.client.ts @@ -40,6 +40,7 @@ export type TbankPortfolio = { figi?: string; instrumentUid?: string; instrumentType?: string; + ticker?: string; quantity?: TbankDecimal; currentPrice?: TbankMoney; averagePositionPrice?: TbankMoney; diff --git a/src/tbank/upcoming-events.controller.spec.ts b/src/tbank/upcoming-events.controller.spec.ts new file mode 100644 index 0000000..ba0498a --- /dev/null +++ b/src/tbank/upcoming-events.controller.spec.ts @@ -0,0 +1,14 @@ +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'); + expect(events.get).toHaveBeenCalledWith('user', 'account'); + }); +}); diff --git a/src/tbank/upcoming-events.controller.ts b/src/tbank/upcoming-events.controller.ts new file mode 100644 index 0000000..6c3a95b --- /dev/null +++ b/src/tbank/upcoming-events.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; +import { AccessPayload } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { UpcomingEventsDto } 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(':accountId/events') + @ApiOkResponse({ type: UpcomingEventsDto }) + get( + @Req() request: Request & { user: AccessPayload }, + @Param('accountId') accountId: string, + ) { + return this.events.get(request.user.sub, accountId); + } +} diff --git a/src/tbank/upcoming-events.dto.ts b/src/tbank/upcoming-events.dto.ts new file mode 100644 index 0000000..7bfe46b --- /dev/null +++ b/src/tbank/upcoming-events.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UpcomingEventDto { + @ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] }) + type: 'coupon' | 'dividend' | 'maturity' | 'offer'; + + @ApiProperty({ format: 'date' }) + date: string; + + @ApiProperty({ enum: ['event', 'registry'] }) + dateKind: 'event' | 'registry'; + + @ApiProperty({ type: String, nullable: true }) + figi: string | null; + + @ApiProperty({ type: String, nullable: true }) + instrumentUid: string | null; + + @ApiProperty() + ticker: string; + + @ApiProperty() + displayName: string; + + @ApiProperty({ type: String, nullable: true }) + currency: string | null; + + @ApiProperty({ type: String, nullable: true }) + amount: string | null; +} + +export class UpcomingEventsDto { + @ApiProperty() + accountId: string; + + @ApiProperty({ format: 'date-time' }) + calculatedAt: string; + + @ApiProperty({ type: UpcomingEventDto, isArray: true }) + events: UpcomingEventDto[]; +} diff --git a/src/tbank/upcoming-events.service.spec.ts b/src/tbank/upcoming-events.service.spec.ts new file mode 100644 index 0000000..25fa130 --- /dev/null +++ b/src/tbank/upcoming-events.service.spec.ts @@ -0,0 +1,139 @@ +import { + BadRequestException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; + +jest.mock('../prisma/prisma.service', () => ({ + PrismaService: class PrismaService {}, +})); + +import { UpcomingEventsService } from './upcoming-events.service'; +import { TbankCrypto } from './tbank.crypto'; + +describe('UpcomingEventsService', () => { + const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; + const prisma = { tbankConnection: { findUnique: jest.fn() } }; + const config = { getOrThrow: jest.fn(() => key) }; + const tbank = { + getOpenAccounts: jest.fn(), + getPortfolio: jest.fn(), + getInstrumentByUid: jest.fn(), + getInstrumentByFigi: jest.fn(), + }; + const moex = { dividends: jest.fn(), bondEvents: jest.fn() }; + const service = new UpcomingEventsService( + prisma as never, + config as never, + tbank as never, + moex as never, + ); + const crypto = TbankCrypto.fromBase64(key); + + beforeEach(() => { + jest.clearAllMocks(); + prisma.tbankConnection.findUnique.mockResolvedValue({ + encryptedToken: crypto.encrypt('token'), + }); + tbank.getOpenAccounts.mockResolvedValue([ + { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, + ]); + tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' }); + moex.dividends.mockResolvedValue([]); + moex.bondEvents.mockResolvedValue([]); + }); + + 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.getPortfolio.mockResolvedValue({ + positions: [ + { + figi: 'S', + ticker: 'SBER', + instrumentType: 'share', + quantity: { units: 2 }, + }, + { + figi: 'B', + ticker: 'BOND', + instrumentType: 'bond', + quantity: { units: 3 }, + }, + ], + }); + moex.dividends.mockResolvedValue([ + { date: day(1), value: '1.25', currency: 'RUB' }, + ]); + 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', amount: '7.5', dateKind: 'event' }, + { type: 'dividend', amount: '2.5', dateKind: 'registry' }, + { type: 'offer', amount: null }, + ], + }); + }); + + it('does not read upstream data for inaccessible accounts', async () => { + tbank.getOpenAccounts.mockResolvedValue([]); + 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' }), + ); + moex.dividends.mockResolvedValue([ + { date: today, value: '1', currency: 'RUB' }, + ]); + + await expect(service.get('user', 'account')).resolves.toMatchObject({ + events: [{ ticker: 'SBER', amount: '1' }], + }); + expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S'); + }); + + it('preserves invalid token and source failures without a partial result', async () => { + tbank.getOpenAccounts.mockRejectedValueOnce( + new Error('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), + ); + }); +}); diff --git a/src/tbank/upcoming-events.service.ts b/src/tbank/upcoming-events.service.ts new file mode 100644 index 0000000..f2b35bd --- /dev/null +++ b/src/tbank/upcoming-events.service.ts @@ -0,0 +1,220 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Environment } from '../config/env.validation'; +import { PrismaService } from '../prisma/prisma.service'; +import { decimal, formatDecimal, isZero, multiply } from './decimal'; +import { MoexBondEvent, MoexClient, MoexDividend } from './moex.client'; +import { TbankClient, TbankPortfolio } from './tbank.client'; +import { TbankCrypto } from './tbank.crypto'; +import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto'; + +const supportedAccounts = new Set([ + 'ACCOUNT_TYPE_TINKOFF', + 'ACCOUNT_TYPE_TINKOFF_IIS', + 'ACCOUNT_TYPE_INVEST_BOX', + 'ACCOUNT_TYPE_INVEST_FUND', +]); + +type Position = NonNullable[number]; + +@Injectable() +export class UpcomingEventsService { + private readonly crypto: TbankCrypto; + private readonly logger = new Logger(UpcomingEventsService.name); + + constructor( + private readonly prisma: PrismaService, + config: ConfigService, + private readonly tbank: TbankClient, + private readonly moex: MoexClient, + ) { + this.crypto = TbankCrypto.fromBase64( + config.getOrThrow('TOKEN_ENCRYPTION_KEY'), + ); + } + + async get(userId: string, accountId: string): Promise { + try { + const connection = await this.prisma.tbankConnection.findUnique({ + where: { userId }, + }); + if (!connection) + throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); + const token = this.crypto.decrypt(connection.encryptedToken); + const accounts = await this.tbank.getOpenAccounts(token); + if ( + !accounts.some( + (account) => + account.id === accountId && supportedAccounts.has(account.type), + ) + ) + throw new NotFoundException(); + const portfolio = await this.tbank.getPortfolio(token, accountId); + const window = dateWindow(); + const events = await Promise.all( + (portfolio.positions ?? []) + .filter((position) => supported(position)) + .map((position) => this.positionEvents(token, position)), + ); + const projected = events + .flat() + .filter( + (event) => event.date >= window.from && event.date <= window.to, + ); + this.logger.log({ + event: 'upcoming_events_calculated', + accountId, + from: window.from, + to: window.to, + positionCount: (portfolio.positions ?? []).length, + eventCount: 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), + ), + }; + } catch (error) { + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + throw error; + } + } + + private async positionEvents( + token: string, + position: Position, + ): Promise { + const ticker = position.ticker?.trim(); + const instrument = ticker + ? await this.instrument(token, position).catch((error: unknown) => { + this.logger.warn({ + event: 'upcoming_event_metadata_unavailable', + figi: position.figi?.trim() || null, + ticker, + error: error instanceof Error ? error.message : 'UNKNOWN', + }); + 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, + }; + const quantity = decimal(position.quantity ?? {}); + this.logger.debug({ + event: 'upcoming_event_position_resolved', + figi: metadata.figi, + portfolioTicker: ticker || null, + resolvedTicker, + displayName: metadata.displayName, + instrumentType: position.instrumentType?.toLowerCase() || null, + }); + if (position.instrumentType?.toLowerCase() === 'share') { + const dividends = await this.moex.dividends(resolvedTicker); + this.logger.debug({ + event: 'upcoming_event_source_result', + ticker: resolvedTicker, + source: 'dividends', + eventCount: dividends.length, + }); + return dividends.map((event) => this.dividend(metadata, quantity, event)); + } + const events = await this.moex.bondEvents(resolvedTicker); + this.logger.debug({ + event: 'upcoming_event_source_result', + ticker: resolvedTicker, + source: 'bondization', + eventCount: events.length, + }); + return events.map((event) => this.bond(metadata, quantity, event)); + } + + private async instrument(token: string, position: Position) { + if (position.instrumentUid) + return this.tbank.getInstrumentByUid(token, position.instrumentUid); + if (position.figi) + return this.tbank.getInstrumentByFigi(token, position.figi); + return null; + } + + private dividend( + metadata: Pick< + UpcomingEventDto, + 'figi' | 'instrumentUid' | 'ticker' | 'displayName' + >, + quantity: ReturnType, + event: MoexDividend, + ): UpcomingEventDto { + return { + ...metadata, + type: 'dividend', + date: event.date, + dateKind: 'registry', + currency: event.currency, + amount: amount(quantity, event.value), + }; + } + + private bond( + metadata: Pick< + UpcomingEventDto, + 'figi' | 'instrumentUid' | 'ticker' | 'displayName' + >, + quantity: ReturnType, + event: MoexBondEvent, + ): UpcomingEventDto { + return { + ...metadata, + type: event.type, + date: event.date, + dateKind: 'event', + currency: 'currency' in event ? event.currency : null, + amount: 'value' in event ? amount(quantity, event.value) : null, + }; + } +} + +function supported(position: Position): boolean { + return ( + !isZero(decimal(position.quantity ?? {})) && + ['share', 'bond'].includes(position.instrumentType?.toLowerCase() ?? '') + ); +} + +function dateWindow() { + const now = new Date(); + const from = now.toISOString().slice(0, 10); + const end = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 30), + ); + return { from, to: end.toISOString().slice(0, 10) }; +} + +function amount(quantity: ReturnType, value: string): string { + return formatDecimal(multiply(quantity, decimalFromString(value))); +} + +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, + }; +}