109 KiB
T-Bank Broker Portfolios Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a read-only T-Bank Invest broker portfolios area that lists brokerage/IIS accounts, current positions, cash balances, and operation history.
Architecture: Add a dedicated backend TBankModule that owns gRPC transport, auth metadata, rate limiting, cache use, and DTO mapping. Expose MoexVibe REST endpoints under /api/v1/broker/*, then add frontend API/hooks/pages for a broker accounts list and account detail tabs. Persist operation history only after the direct-read MVP is working, using Prisma models that do not touch the existing manual Portfolio domain.
Tech Stack: NestJS 10, @grpc/grpc-js, @grpc/proto-loader, p-queue, @nestjs/cache-manager, Prisma SQLite, React 18, TanStack Query v5, Vitest, Docusaurus.
File Structure
Backend
- Create
apps/backend/src/modules/tbank/tbank.module.ts: Nest feature module. - Create
apps/backend/src/modules/tbank/tbank.controller.ts: REST endpoints underbroker. - Create
apps/backend/src/modules/tbank/tbank.config.ts: constants and config helpers for service names and TTL keys. - Create
apps/backend/src/modules/tbank/types/tbank-proto.types.ts: narrow TypeScript interfaces for the proto payloads used by MoexVibe. - Create
apps/backend/src/modules/tbank/types/broker.types.ts: normalized domain types returned by services. - Create
apps/backend/src/modules/tbank/services/tbank-client.service.ts: gRPC channel/client factory, metadata, limiter, unary wrapper. - Create
apps/backend/src/modules/tbank/services/broker-accounts.service.ts:GetAccounts+ account filtering. - Create
apps/backend/src/modules/tbank/services/broker-instruments.service.ts:GetInstrumentBycache wrapper. - Create
apps/backend/src/modules/tbank/services/broker-portfolio.service.ts:GetPortfolio+GetPositionsaggregation. - Create
apps/backend/src/modules/tbank/services/broker-operations.service.ts:GetOperationsByCursorquery and categorization. - Create
apps/backend/src/modules/tbank/mappers/money.mapper.ts:MoneyValueandQuotationconversion. - Create
apps/backend/src/modules/tbank/mappers/account.mapper.ts: account normalization and filter predicates. - Create
apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts: portfolio/positions normalization. - Create
apps/backend/src/modules/tbank/mappers/operation.mapper.ts: operation normalization and categories. - Create DTO files under
apps/backend/src/modules/tbank/dto/: Swagger and validation classes. - Create tests next to each service/mapper:
*.spec.ts. - Create vendored proto files under
apps/backend/src/modules/tbank/proto/contracts/. - Modify
apps/backend/src/app.module.ts: importTBankModule. - Modify
apps/backend/src/config/configuration.ts: addtbankand T-Bank cache TTL config. - Modify
apps/backend/package.jsonand root lockfile throughnpm install.
Durable Sync
- Modify
apps/backend/prisma/schema.prisma: addBrokerOperationandBrokerOperationSyncState. - Create
apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts: local upsert/backfill logic. - Create tests for sync windowing and upsert mapping.
Frontend
- Create
apps/frontend/src/api/broker.ts: broker API client functions. - Modify
apps/frontend/src/api/responses.ts: add broker response interfaces. - Create
apps/frontend/src/hooks/useBrokerAccounts.ts. - Create
apps/frontend/src/hooks/useBrokerPortfolio.ts. - Create
apps/frontend/src/hooks/useBrokerOperations.ts. - Create
apps/frontend/src/pages/broker/BrokerAccountsPage.tsx. - Create
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx. - Create
apps/frontend/src/pages/broker/BrokerPages.test.tsx. - Modify
apps/frontend/src/routes.tsx: add protected broker routes. - Modify
apps/frontend/src/components/Layout.tsx: add navigation link. - Modify
apps/frontend/src/styles.css: no changes are expected for the first UI pass; keep this file untouched unless tests or browser verification reveal text overlap.
Published Docs
- Create
apps/docs/docs/backend/tbank-invest.md. - Modify
apps/docs/docs/backend/modules.md. - Modify
apps/docs/docs/backend/configuration.md. - Modify
apps/docs/docs/backend/caching.md. - Modify
apps/docs/docs/backend/portfolio.md. - Create
apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md. - Modify
apps/docs/docs/adr/index.md. - Modify
apps/docs/sidebars.ts.
Task 1: Add gRPC Dependencies And Official Proto Contracts
Files:
-
Modify:
apps/backend/package.json -
Modify:
package-lock.json -
Create:
apps/backend/src/modules/tbank/proto/contracts/common.proto -
Create:
apps/backend/src/modules/tbank/proto/contracts/users.proto -
Create:
apps/backend/src/modules/tbank/proto/contracts/operations.proto -
Create:
apps/backend/src/modules/tbank/proto/contracts/instruments.proto -
Create:
apps/backend/src/modules/tbank/proto/contracts/google/api/field_behavior.proto -
Step 1: Install backend gRPC runtime dependencies
Run:
npm install @grpc/grpc-js @grpc/proto-loader protobufjs long -w apps/backend
Expected: apps/backend/package.json gains the four dependencies and package-lock.json updates.
- Step 2: Vendor official T-Bank proto contracts
Run:
mkdir -p apps/backend/src/modules/tbank/proto/contracts/google/api
curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/common.proto -o apps/backend/src/modules/tbank/proto/contracts/common.proto
curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/users.proto -o apps/backend/src/modules/tbank/proto/contracts/users.proto
curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/operations.proto -o apps/backend/src/modules/tbank/proto/contracts/operations.proto
curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/instruments.proto -o apps/backend/src/modules/tbank/proto/contracts/instruments.proto
curl -L -s https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/field_behavior.proto -o apps/backend/src/modules/tbank/proto/contracts/google/api/field_behavior.proto
Expected: each file exists and begins with syntax = "proto3";.
- Step 3: Verify proto service methods are present
Run:
rg -n "rpc GetAccounts|rpc GetPortfolio|rpc GetPositions|rpc GetOperationsByCursor|rpc GetInstrumentBy" apps/backend/src/modules/tbank/proto/contracts
Expected: output includes all five method declarations.
- Step 4: Commit dependencies and contracts
git add apps/backend/package.json package-lock.json apps/backend/src/modules/tbank/proto/contracts
git commit -m "feat: add tbank invest proto contracts"
Task 2: Add Configuration And Core T-Bank Types
Files:
-
Modify:
apps/backend/src/config/configuration.ts -
Create:
apps/backend/src/modules/tbank/tbank.config.ts -
Create:
apps/backend/src/modules/tbank/types/tbank-proto.types.ts -
Create:
apps/backend/src/modules/tbank/types/broker.types.ts -
Test:
apps/backend/src/modules/tbank/tbank.config.spec.ts -
Step 1: Write failing config test
Create apps/backend/src/modules/tbank/tbank.config.spec.ts:
import configuration from '../../config/configuration';
describe('T-Bank configuration', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('uses conservative defaults for T-Bank integration', () => {
delete process.env.T_BANK_BASE_URL;
delete process.env.T_BANK_RATE_LIMIT_PER_SECOND;
delete process.env.CACHE_TBANK_PORTFOLIO_TTL;
const config = configuration();
expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
expect(config.tbank.rateLimitPerSecond).toBe(5);
expect(config.cache.tbankPortfolioTtl).toBe(60);
});
it('reads T-Bank token and TTL overrides from environment', () => {
process.env.T_BANK_TOKEN = 'secret-token';
process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443';
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
const config = configuration();
expect(config.tbank.token).toBe('secret-token');
expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443');
expect(config.tbank.rateLimitPerSecond).toBe(2);
expect(config.cache.tbankAccountsTtl).toBe(120);
});
});
- Step 2: Run failing config test
Run:
npx vitest run src/modules/tbank/tbank.config.spec.ts -w apps/backend
Expected: FAIL because config.tbank and T-Bank cache keys do not exist yet.
- Step 3: Extend backend configuration
Modify apps/backend/src/config/configuration.ts so the returned object includes:
tbank: {
token: process.env.T_BANK_TOKEN || '',
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
},
cache: {
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
},
Keep the existing port, database, moex, and auth sections unchanged.
- Step 4: Add T-Bank constants
Create apps/backend/src/modules/tbank/tbank.config.ts:
export const TBANK_PROTO_PACKAGE = 'tinkoff.public.invest.api.contract.v1';
export const TBANK_PROTO_FILES = {
users: 'users.proto',
operations: 'operations.proto',
instruments: 'instruments.proto',
} as const;
export const TBANK_ACCOUNT_TYPES = {
brokerage: 'ACCOUNT_TYPE_TINKOFF',
iis: 'ACCOUNT_TYPE_TINKOFF_IIS',
} as const;
export const TBANK_OPEN_ACCOUNT_STATUS = 'ACCOUNT_STATUS_OPEN';
export const TBANK_CACHE_KEYS = {
accounts: 'tbank:accounts',
portfolio: 'tbank:portfolio',
positions: 'tbank:positions',
operations: 'tbank:operations',
instrument: 'tbank:instrument',
} as const;
- Step 5: Add narrow proto interfaces
Create apps/backend/src/modules/tbank/types/tbank-proto.types.ts:
export type TBankTimestamp = {
seconds?: number | string;
nanos?: number;
};
export type TBankMoneyValue = {
currency?: string;
units?: number | string;
nano?: number;
};
export type TBankQuotation = {
units?: number | string;
nano?: number;
};
export type TBankAccount = {
id: string;
type: string;
name?: string;
status: string;
openedDate?: TBankTimestamp;
closedDate?: TBankTimestamp;
accessLevel?: string;
};
export type TBankAccountsResponse = {
accounts?: TBankAccount[];
};
export type TBankPortfolioPosition = {
figi?: string;
instrumentType?: string;
quantity?: TBankQuotation;
averagePositionPrice?: TBankMoneyValue;
expectedYield?: TBankQuotation;
currentNkd?: TBankMoneyValue;
currentPrice?: TBankMoneyValue;
averagePositionPriceFifo?: TBankMoneyValue;
blocked?: boolean;
blockedLots?: TBankQuotation;
positionUid?: string;
instrumentUid?: string;
expectedYieldFifo?: TBankQuotation;
dailyYield?: TBankMoneyValue;
ticker?: string;
classCode?: string;
};
export type TBankPortfolioResponse = {
accountId?: string;
totalAmountShares?: TBankMoneyValue;
totalAmountBonds?: TBankMoneyValue;
totalAmountEtf?: TBankMoneyValue;
totalAmountCurrencies?: TBankMoneyValue;
totalAmountFutures?: TBankMoneyValue;
expectedYield?: TBankQuotation;
positions?: TBankPortfolioPosition[];
totalAmountOptions?: TBankMoneyValue;
totalAmountSp?: TBankMoneyValue;
totalAmountPortfolio?: TBankMoneyValue;
dailyYield?: TBankMoneyValue;
dailyYieldRelative?: TBankQuotation;
totalAmountDfa?: TBankMoneyValue;
};
export type TBankPositionsSecurity = {
figi?: string;
blocked?: string | number;
balance?: string | number;
positionUid?: string;
instrumentUid?: string;
ticker?: string;
classCode?: string;
exchangeBlocked?: boolean;
instrumentType?: string;
};
export type TBankPositionsResponse = {
accountId?: string;
money?: TBankMoneyValue[];
blocked?: TBankMoneyValue[];
securities?: TBankPositionsSecurity[];
};
export type TBankOperationTrade = {
num?: string;
date?: TBankTimestamp;
quantity?: string | number;
price?: TBankMoneyValue;
yield?: TBankMoneyValue;
yieldRelative?: TBankQuotation;
};
export type TBankOperationItem = {
cursor?: string;
brokerAccountId?: string;
id?: string;
parentOperationId?: string;
name?: string;
date?: TBankTimestamp;
type?: string;
description?: string;
state?: string;
instrumentUid?: string;
figi?: string;
instrumentType?: string;
instrumentKind?: string;
positionUid?: string;
ticker?: string;
classCode?: string;
payment?: TBankMoneyValue;
price?: TBankMoneyValue;
commission?: TBankMoneyValue;
yield?: TBankMoneyValue;
yieldRelative?: TBankQuotation;
accruedInt?: TBankMoneyValue;
quantity?: string | number;
quantityRest?: string | number;
quantityDone?: string | number;
tradesInfo?: { trades?: TBankOperationTrade[] };
};
export type TBankOperationsByCursorResponse = {
hasNext?: boolean;
nextCursor?: string;
items?: TBankOperationItem[];
};
export type TBankInstrument = {
figi?: string;
ticker?: string;
classCode?: string;
isin?: string;
lot?: number;
currency?: string;
name?: string;
exchange?: string;
instrumentType?: string;
uid?: string;
positionUid?: string;
assetUid?: string;
instrumentKind?: string;
};
export type TBankInstrumentResponse = {
instrument?: TBankInstrument;
};
- Step 6: Add normalized broker domain types
Create apps/backend/src/modules/tbank/types/broker.types.ts:
export type BrokerMoney = {
currency: string;
units: string;
nano: number;
value: number;
};
export type BrokerAccount = {
id: string;
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: string | null;
accessLevel: string | null;
};
export type BrokerPosition = {
figi: string | null;
instrumentUid: string | null;
positionUid: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
name: string | null;
quantity: number | null;
blockedLots: number | null;
currentPrice: BrokerMoney | null;
currentValue: BrokerMoney | null;
averagePositionPrice: BrokerMoney | null;
expectedYieldPercent: number | null;
dailyYield: BrokerMoney | null;
};
export type BrokerPortfolio = {
account: BrokerAccount;
totals: {
shares: BrokerMoney | null;
bonds: BrokerMoney | null;
etf: BrokerMoney | null;
currencies: BrokerMoney | null;
futures: BrokerMoney | null;
options: BrokerMoney | null;
structuredProducts: BrokerMoney | null;
dfa: BrokerMoney | null;
portfolio: BrokerMoney | null;
};
yields: {
expectedPercent: number | null;
daily: BrokerMoney | null;
dailyPercent: number | null;
};
cash: BrokerMoney[];
blockedCash: BrokerMoney[];
positions: BrokerPosition[];
asOf: string;
};
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
export type BrokerOperation = {
cursor: string | null;
accountId: string;
id: string | null;
parentOperationId: string | null;
date: string | null;
type: string;
category: BrokerOperationCategory;
description: string | null;
state: string | null;
instrumentUid: string | null;
figi: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
payment: BrokerMoney | null;
price: BrokerMoney | null;
commission: BrokerMoney | null;
yield: BrokerMoney | null;
accruedInt: BrokerMoney | null;
quantity: number | null;
quantityDone: number | null;
};
export type BrokerOperationsPage = {
accountId: string;
items: BrokerOperation[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
};
- Step 7: Run config test and commit
Run:
npx vitest run src/modules/tbank/tbank.config.spec.ts -w apps/backend
Expected: PASS.
Commit:
git add apps/backend/src/config/configuration.ts apps/backend/src/modules/tbank/tbank.config.ts apps/backend/src/modules/tbank/types apps/backend/src/modules/tbank/tbank.config.spec.ts
git commit -m "feat: configure tbank integration"
Task 3: Implement Money, Account, And Operation Mappers
Files:
-
Create:
apps/backend/src/modules/tbank/mappers/money.mapper.ts -
Create:
apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts -
Create:
apps/backend/src/modules/tbank/mappers/account.mapper.ts -
Create:
apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts -
Create:
apps/backend/src/modules/tbank/mappers/operation.mapper.ts -
Create:
apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts -
Step 1: Write money mapper tests
Create apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts:
import { mapMoneyValue, mapQuotationToNumber, mapTimestampToIso } from './money.mapper';
describe('money.mapper', () => {
it('maps positive MoneyValue with nano precision', () => {
expect(mapMoneyValue({ currency: 'rub', units: '123', nano: 450000000 })).toEqual({
currency: 'RUB',
units: '123',
nano: 450000000,
value: 123.45,
});
});
it('maps negative MoneyValue with negative nano', () => {
expect(mapMoneyValue({ currency: 'rub', units: '-5', nano: -250000000 })).toEqual({
currency: 'RUB',
units: '-5',
nano: -250000000,
value: -5.25,
});
});
it('returns null for absent MoneyValue', () => {
expect(mapMoneyValue(undefined)).toBeNull();
});
it('maps quotation to number', () => {
expect(mapQuotationToNumber({ units: '12', nano: 345000000 })).toBe(12.345);
});
it('maps unix timestamp seconds to ISO string', () => {
expect(mapTimestampToIso({ seconds: '1781577000', nanos: 0 })).toBe('2026-06-16T02:30:00.000Z');
});
});
- Step 2: Run failing money mapper test
Run:
npx vitest run src/modules/tbank/mappers/money.mapper.spec.ts -w apps/backend
Expected: FAIL because money.mapper.ts does not exist.
- Step 3: Implement money mapper
Create apps/backend/src/modules/tbank/mappers/money.mapper.ts:
import type { BrokerMoney } from '../types/broker.types';
import type { TBankMoneyValue, TBankQuotation, TBankTimestamp } from '../types/tbank-proto.types';
const NANO_FACTOR = 1_000_000_000;
export function mapMoneyValue(value: TBankMoneyValue | null | undefined): BrokerMoney | null {
if (!value) return null;
const units = String(value.units ?? '0');
const nano = value.nano ?? 0;
const numericUnits = Number(units);
const decimal = numericUnits + nano / NANO_FACTOR;
return {
currency: (value.currency || '').toUpperCase(),
units,
nano,
value: Number(decimal.toFixed(9)),
};
}
export function mapQuotationToNumber(value: TBankQuotation | null | undefined): number | null {
if (!value) return null;
const units = Number(value.units ?? 0);
const nano = value.nano ?? 0;
return Number((units + nano / NANO_FACTOR).toFixed(9));
}
export function mapTimestampToIso(value: TBankTimestamp | null | undefined): string | null {
if (!value?.seconds) return null;
const millis = Number(value.seconds) * 1000 + Math.floor((value.nanos ?? 0) / 1_000_000);
return new Date(millis).toISOString();
}
export function mapInteger(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
- Step 4: Write account mapper tests
Create apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts:
import { isSupportedBrokerAccount, mapAccount } from './account.mapper';
import type { TBankAccount } from '../types/tbank-proto.types';
describe('account.mapper', () => {
const baseAccount: TBankAccount = {
id: '2000000001',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedDate: { seconds: '1781577000' },
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
it('accepts open brokerage and IIS accounts', () => {
expect(isSupportedBrokerAccount(baseAccount)).toBe(true);
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_TINKOFF_IIS' })).toBe(
true,
);
});
it('rejects invest box, closed, and unspecified accounts', () => {
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_INVEST_BOX' })).toBe(
false,
);
expect(isSupportedBrokerAccount({ ...baseAccount, status: 'ACCOUNT_STATUS_CLOSED' })).toBe(
false,
);
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_UNSPECIFIED' })).toBe(
false,
);
});
it('maps T-Bank account to broker account DTO', () => {
expect(mapAccount(baseAccount)).toEqual({
id: '2000000001',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: '2026-06-16T02:30:00.000Z',
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
});
});
});
- Step 5: Implement account mapper
Create apps/backend/src/modules/tbank/mappers/account.mapper.ts:
import {
TBANK_ACCOUNT_TYPES,
TBANK_OPEN_ACCOUNT_STATUS,
} from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccount } from '../types/tbank-proto.types';
import { mapTimestampToIso } from './money.mapper';
export function isSupportedBrokerAccount(account: TBankAccount): boolean {
return (
account.status === TBANK_OPEN_ACCOUNT_STATUS &&
(account.type === TBANK_ACCOUNT_TYPES.brokerage || account.type === TBANK_ACCOUNT_TYPES.iis)
);
}
export function mapAccount(account: TBankAccount): BrokerAccount {
return {
id: account.id,
type: account.type === TBANK_ACCOUNT_TYPES.iis ? 'iis' : 'brokerage',
name: account.name || account.id,
status: account.status,
openedAt: mapTimestampToIso(account.openedDate),
accessLevel: account.accessLevel ?? null,
};
}
- Step 6: Write operation mapper tests
Create apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts:
import { categorizeOperationType, mapOperation, mapOperationsPage } from './operation.mapper';
describe('operation.mapper', () => {
it.each([
['OPERATION_TYPE_BUY', 'trade'],
['OPERATION_TYPE_SELL', 'trade'],
['OPERATION_TYPE_DIVIDEND', 'income'],
['OPERATION_TYPE_COUPON', 'income'],
['OPERATION_TYPE_TAX', 'tax'],
['OPERATION_TYPE_DIVIDEND_TAX', 'tax'],
['OPERATION_TYPE_BROKER_FEE', 'fee'],
['OPERATION_TYPE_SERVICE_FEE', 'fee'],
['OPERATION_TYPE_INPUT', 'transfer'],
['OPERATION_TYPE_OUTPUT', 'transfer'],
['OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', 'other'],
])('maps %s to %s', (type, category) => {
expect(categorizeOperationType(type)).toBe(category);
});
it('maps operation item with money and quantities', () => {
const result = mapOperation(
{
cursor: 'cursor-1',
brokerAccountId: 'acc-1',
id: 'op-1',
date: { seconds: '1781577000' },
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
state: 'OPERATION_STATE_EXECUTED',
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
payment: { currency: 'rub', units: '100', nano: 0 },
commission: { currency: 'rub', units: '0', nano: 0 },
quantity: '5',
quantityDone: '5',
},
'acc-1',
);
expect(result).toMatchObject({
cursor: 'cursor-1',
accountId: 'acc-1',
id: 'op-1',
category: 'income',
ticker: 'SU26238RMFS5',
quantity: 5,
quantityDone: 5,
payment: { currency: 'RUB', value: 100 },
});
});
it('maps operations page cursor metadata', () => {
const page = mapOperationsPage('acc-1', {
hasNext: true,
nextCursor: 'next',
items: [{ cursor: 'cursor-1', type: 'OPERATION_TYPE_BUY' }],
});
expect(page.accountId).toBe('acc-1');
expect(page.hasNext).toBe(true);
expect(page.nextCursor).toBe('next');
expect(page.items).toHaveLength(1);
});
});
- Step 7: Implement operation mapper
Create apps/backend/src/modules/tbank/mappers/operation.mapper.ts:
import type {
BrokerOperation,
BrokerOperationCategory,
BrokerOperationsPage,
} from '../types/broker.types';
import type {
TBankOperationItem,
TBankOperationsByCursorResponse,
} from '../types/tbank-proto.types';
import { mapInteger, mapMoneyValue, mapTimestampToIso } from './money.mapper';
const TRADE_TYPES = new Set([
'OPERATION_TYPE_BUY',
'OPERATION_TYPE_BUY_CARD',
'OPERATION_TYPE_SELL',
'OPERATION_TYPE_SELL_CARD',
'OPERATION_TYPE_BUY_MARGIN',
'OPERATION_TYPE_SELL_MARGIN',
'OPERATION_TYPE_DELIVERY_BUY',
'OPERATION_TYPE_DELIVERY_SELL',
]);
const INCOME_TYPES = new Set([
'OPERATION_TYPE_DIVIDEND',
'OPERATION_TYPE_COUPON',
'OPERATION_TYPE_BOND_REPAYMENT',
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
'OPERATION_TYPE_OVERNIGHT',
'OPERATION_TYPE_OVER_INCOME',
'OPERATION_TYPE_ACCRUING_VARMARGIN',
'OPERATION_TYPE_TAX_REPO_REFUND',
'OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE',
'OPERATION_TYPE_DIV_EXT',
'OPERATION_TYPE_DFA_REDEMPTION',
]);
const TAX_TYPES = new Set([
'OPERATION_TYPE_TAX',
'OPERATION_TYPE_BOND_TAX',
'OPERATION_TYPE_DIVIDEND_TAX',
'OPERATION_TYPE_TAX_CORRECTION',
'OPERATION_TYPE_BENEFIT_TAX',
'OPERATION_TYPE_TAX_PROGRESSIVE',
'OPERATION_TYPE_BOND_TAX_PROGRESSIVE',
'OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE',
'OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE',
'OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE',
'OPERATION_TYPE_TAX_REPO',
'OPERATION_TYPE_TAX_REPO_PROGRESSIVE',
'OPERATION_TYPE_TAX_REPO_HOLD',
'OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE',
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
]);
const FEE_TYPES = new Set([
'OPERATION_TYPE_SERVICE_FEE',
'OPERATION_TYPE_MARGIN_FEE',
'OPERATION_TYPE_BROKER_FEE',
'OPERATION_TYPE_SUCCESS_FEE',
'OPERATION_TYPE_TRACK_MFEE',
'OPERATION_TYPE_TRACK_PFEE',
'OPERATION_TYPE_CASH_FEE',
'OPERATION_TYPE_OUT_FEE',
'OPERATION_TYPE_OUT_STAMP_DUTY',
'OPERATION_TYPE_OUTPUT_PENALTY',
'OPERATION_TYPE_ADVICE_FEE',
'OPERATION_TYPE_OVER_COM',
'OPERATION_TYPE_OTHER_FEE',
'OPERATION_TYPE_FUNDING',
]);
const TRANSFER_TYPES = new Set([
'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_INPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS',
'OPERATION_TYPE_OUT_MULTI',
'OPERATION_TYPE_INP_MULTI',
'OPERATION_TYPE_OVER_PLACEMENT',
]);
export function categorizeOperationType(type: string | null | undefined): BrokerOperationCategory {
if (!type) return 'other';
if (TRADE_TYPES.has(type)) return 'trade';
if (INCOME_TYPES.has(type)) return 'income';
if (TAX_TYPES.has(type)) return 'tax';
if (FEE_TYPES.has(type)) return 'fee';
if (TRANSFER_TYPES.has(type)) return 'transfer';
return 'other';
}
export function mapOperation(item: TBankOperationItem, accountId: string): BrokerOperation {
const type = item.type || 'OPERATION_TYPE_UNSPECIFIED';
return {
cursor: item.cursor ?? null,
accountId: item.brokerAccountId || accountId,
id: item.id ?? null,
parentOperationId: item.parentOperationId ?? null,
date: mapTimestampToIso(item.date),
type,
category: categorizeOperationType(type),
description: item.description || item.name || null,
state: item.state ?? null,
instrumentUid: item.instrumentUid ?? null,
figi: item.figi ?? null,
ticker: item.ticker ?? null,
classCode: item.classCode ?? null,
instrumentType: item.instrumentType ?? null,
payment: mapMoneyValue(item.payment),
price: mapMoneyValue(item.price),
commission: mapMoneyValue(item.commission),
yield: mapMoneyValue(item.yield),
accruedInt: mapMoneyValue(item.accruedInt),
quantity: mapInteger(item.quantity),
quantityDone: mapInteger(item.quantityDone),
};
}
export function mapOperationsPage(
accountId: string,
response: TBankOperationsByCursorResponse,
): BrokerOperationsPage {
return {
accountId,
items: (response.items ?? []).map((item) => mapOperation(item, accountId)),
nextCursor: response.nextCursor || null,
hasNext: response.hasNext ?? false,
asOf: new Date().toISOString(),
};
}
- Step 8: Run mapper tests and commit
Run:
npx vitest run "src/modules/tbank/mappers/*.spec.ts" -w apps/backend
Expected: PASS.
Commit:
git add apps/backend/src/modules/tbank/mappers
git commit -m "feat: add tbank domain mappers"
Task 4: Implement TBankClientService gRPC Transport
Files:
-
Create:
apps/backend/src/modules/tbank/services/tbank-client.service.ts -
Create:
apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts -
Create:
apps/backend/src/modules/tbank/tbank.module.ts -
Step 1: Write client service tests
Create apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts:
import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Metadata, status } from '@grpc/grpc-js';
import { TBankClientService } from './tbank-client.service';
describe('TBankClientService', () => {
const config = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.tbank.token': 'token-1',
'app.tbank.appName': 'ksv741.moex-vibe',
'app.tbank.rateLimitPerSecond': 5,
'app.tbank.requestTimeoutMs': 10000,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
it('builds redacted authorization metadata', () => {
const service = new TBankClientService(config);
const metadata = service.createMetadata();
expect(metadata.get('Authorization')).toEqual(['Bearer token-1']);
expect(metadata.get('x-app-name')).toEqual(['ksv741.moex-vibe']);
expect(service.redactMetadata(metadata)).toEqual({
Authorization: '<redacted>',
'x-app-name': 'ksv741.moex-vibe',
});
});
it('throws integration unavailable when token is missing', async () => {
const missingConfig = {
get: vi.fn((key: string, fallback?: unknown) =>
key === 'app.tbank.token' ? '' : (fallback as unknown),
),
} as unknown as ConfigService;
const service = new TBankClientService(missingConfig);
await expect(
service.callUnary('UsersService/GetAccounts', (_request, _metadata, _options, callback) => {
callback(null, {});
}, {}),
).rejects.toThrow(ServiceUnavailableException);
});
it('wraps grpc errors with status code and tracking id', async () => {
const service = new TBankClientService(config);
const error = Object.assign(new Error('Too many requests'), {
code: status.RESOURCE_EXHAUSTED,
metadata: new Metadata(),
});
error.metadata.set('x-tracking-id', 'tracking-1');
await expect(
service.callUnary('OperationsService/GetPortfolio', (_request, _metadata, _options, cb) => {
cb(error, null);
}, {}),
).rejects.toMatchObject({
response: expect.objectContaining({
message: expect.stringContaining('T-Bank upstream error'),
}),
});
});
});
- Step 2: Run failing client service test
Run:
npx vitest run src/modules/tbank/services/tbank-client.service.spec.ts -w apps/backend
Expected: FAIL because TBankClientService does not exist.
- Step 3: Implement TBankClientService
Create apps/backend/src/modules/tbank/services/tbank-client.service.ts:
import { Injectable, ServiceUnavailableException, BadGatewayException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
CallOptions,
ChannelCredentials,
Client,
ClientUnaryCall,
loadPackageDefinition,
Metadata,
ServiceError,
status,
} from '@grpc/grpc-js';
import { loadSync } from '@grpc/proto-loader';
import PQueue from 'p-queue';
import { join } from 'path';
import {
TBANK_PROTO_FILES,
TBANK_PROTO_PACKAGE,
} from '../tbank.config';
type GrpcUnary<TRequest, TResponse> = (
request: TRequest,
metadata: Metadata,
options: CallOptions,
callback: (error: ServiceError | null, response: TResponse | null) => void,
) => ClientUnaryCall;
@Injectable()
export class TBankClientService {
private readonly logger = new Logger(TBankClientService.name);
private readonly queue: PQueue;
private readonly requestTimeoutMs: number;
private readonly packageDefinition: ReturnType<typeof loadPackageDefinition>;
private readonly clientCache = new Map<string, Client>();
constructor(private readonly configService: ConfigService) {
this.requestTimeoutMs = this.configService.get<number>('app.tbank.requestTimeoutMs', 10000);
this.queue = new PQueue({
interval: 1000,
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
});
const protoRoot = join(__dirname, '..', 'proto', 'contracts');
const definition = loadSync(Object.values(TBANK_PROTO_FILES), {
includeDirs: [protoRoot],
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
this.packageDefinition = loadPackageDefinition(definition);
}
createMetadata(): Metadata {
const token = this.configService.get<string>('app.tbank.token', '');
if (!token) {
throw new ServiceUnavailableException('T-Bank integration is not configured');
}
const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`);
const appName = this.configService.get<string>('app.tbank.appName', '');
if (appName) metadata.set('x-app-name', appName);
return metadata;
}
redactMetadata(metadata: Metadata): Record<string, string> {
const result: Record<string, string> = {};
for (const key of Object.keys(metadata.getMap())) {
result[key] = key.toLowerCase() === 'authorization' ? '<redacted>' : String(metadata.get(key)[0]);
}
return result;
}
getServiceClient(serviceName: 'UsersService' | 'OperationsService' | 'InstrumentsService'): Client {
const cached = this.clientCache.get(serviceName);
if (cached) return cached;
const pkg = this.packageDefinition as Record<string, unknown>;
const namespace = TBANK_PROTO_PACKAGE.split('.').reduce<Record<string, unknown>>(
(current, part) => current[part] as Record<string, unknown>,
pkg,
);
const ServiceCtor = namespace[serviceName] as new (address: string, creds: ChannelCredentials) => Client;
const client = new ServiceCtor(
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
ChannelCredentials.createSsl(),
);
this.clientCache.set(serviceName, client);
return client;
}
async callUnary<TRequest, TResponse>(
label: string,
method: GrpcUnary<TRequest, TResponse>,
request: TRequest,
): Promise<TResponse> {
const metadata = this.createMetadata();
const deadline = new Date(Date.now() + this.requestTimeoutMs);
return this.queue.add(
() =>
new Promise<TResponse>((resolve, reject) => {
method(request, metadata, { deadline }, (error, response) => {
if (error) {
reject(this.mapGrpcError(label, error));
return;
}
resolve(response as TResponse);
});
}),
) as Promise<TResponse>;
}
private mapGrpcError(label: string, error: ServiceError): Error {
const trackingId = error.metadata?.get('x-tracking-id')?.[0];
const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];
const publicMessage =
error.code === status.RESOURCE_EXHAUSTED
? 'T-Bank rate limit exceeded'
: `T-Bank upstream error while calling ${label}`;
this.logger.warn(
JSON.stringify({
label,
code: error.code,
trackingId,
retryAfter,
message: error.message,
}),
);
return new BadGatewayException({
message: publicMessage,
trackingId: trackingId ? String(trackingId) : null,
retryAfter: retryAfter ? String(retryAfter) : null,
});
}
}
- Step 4: Create TBankModule
Create apps/backend/src/modules/tbank/tbank.module.ts:
import { Module } from '@nestjs/common';
import { TBankClientService } from './services/tbank-client.service';
@Module({
providers: [TBankClientService],
exports: [TBankClientService],
})
export class TBankModule {}
- Step 5: Run client service tests and commit
Run:
npx vitest run src/modules/tbank/services/tbank-client.service.spec.ts -w apps/backend
Expected: PASS.
Commit:
git add apps/backend/src/modules/tbank/services/tbank-client.service.ts apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts apps/backend/src/modules/tbank/tbank.module.ts
git commit -m "feat: add tbank grpc client service"
Task 5: Implement Broker Accounts Endpoint
Files:
-
Create:
apps/backend/src/modules/tbank/services/broker-accounts.service.ts -
Create:
apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts -
Create:
apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts -
Create:
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts -
Modify:
apps/backend/src/modules/tbank/tbank.controller.ts -
Modify:
apps/backend/src/modules/tbank/tbank.module.ts -
Modify:
apps/backend/src/app.module.ts -
Step 1: Write accounts service test
Create apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts:
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
import { CacheService } from '../../cache/cache.service';
describe('BrokerAccountsService', () => {
const client = {
getServiceClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = {
getOrFetch: vi.fn(),
} as unknown as CacheService;
beforeEach(() => vi.clearAllMocks());
it('returns only open brokerage and IIS accounts from cache wrapper', async () => {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-16T02:30:00.000Z',
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
accounts: [
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '2', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '3', type: 'ACCOUNT_TYPE_INVEST_BOX', name: 'Box', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '4', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Closed', status: 'ACCOUNT_STATUS_CLOSED' },
],
});
const service = new BrokerAccountsService(client, cache);
const result = await service.findAll();
expect(result.data).toHaveLength(2);
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
expect(result.meta.fromCache).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:accounts',
['open-brokerage-iis'],
expect.any(Function),
'tbankAccountsTtl',
);
});
});
- Step 2: Run failing accounts service test
Run:
npx vitest run src/modules/tbank/services/broker-accounts.service.spec.ts -w apps/backend
Expected: FAIL because BrokerAccountsService does not exist.
- Step 3: Implement accounts service
Create apps/backend/src/modules/tbank/services/broker-accounts.service.ts:
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { isSupportedBrokerAccount, mapAccount } from '../mappers/account.mapper';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerAccountsService {
constructor(
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async findAll(): Promise<{
data: BrokerAccount[];
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.accounts,
['open-brokerage-iis'],
() => this.fetchAccounts(),
'tbankAccountsTtl',
);
return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } };
}
async findById(accountId: string): Promise<BrokerAccount | null> {
const accounts = await this.findAll();
return accounts.data.find((account) => account.id === accountId) ?? null;
}
private async fetchAccounts(): Promise<BrokerAccount[]> {
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
const response = await this.tbankClient.callUnary<Record<string, string>, TBankAccountsResponse>(
'UsersService/GetAccounts',
usersClient.getAccounts.bind(usersClient),
{ status: 'ACCOUNT_STATUS_OPEN' },
);
return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount);
}
}
- Step 4: Add account DTOs and envelope DTO
Create apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
export class BrokerAccountResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enum: ['brokerage', 'iis'] })
type!: 'brokerage' | 'iis';
@ApiProperty()
name!: string;
@ApiProperty()
status!: string;
@ApiProperty({ nullable: true })
openedAt!: string | null;
@ApiProperty({ nullable: true })
accessLevel!: string | null;
}
Create apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] })
data!: BrokerAccountResponseDto[];
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
- Step 5: Add controller and module wiring
Create apps/backend/src/modules/tbank/tbank.controller.ts:
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAccountsEnvelopeDto } from './dto/broker-envelope.dto';
@ApiTags('Broker')
@ApiBearerAuth()
@Controller('broker')
export class TBankController {
constructor(private readonly brokerAccountsService: BrokerAccountsService) {}
@Get('accounts')
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() {
return this.brokerAccountsService.findAll();
}
}
Modify apps/backend/src/modules/tbank/tbank.module.ts:
import { Module } from '@nestjs/common';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { TBankClientService } from './services/tbank-client.service';
@Module({
controllers: [TBankController],
providers: [TBankClientService, BrokerAccountsService],
exports: [TBankClientService, BrokerAccountsService],
})
export class TBankModule {}
Modify apps/backend/src/app.module.ts:
import { TBankModule } from './modules/tbank/tbank.module';
and add TBankModule after PortfolioModule in the imports array.
- Step 6: Run accounts tests and backend build
Run:
npx vitest run src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/mappers/account.mapper.spec.ts -w apps/backend
npm run build:backend
Expected: tests PASS and backend build exits 0.
- Step 7: Commit accounts endpoint
git add apps/backend/src/app.module.ts apps/backend/src/modules/tbank
git commit -m "feat: expose tbank broker accounts"
Task 6: Implement Broker Portfolio Endpoint
Files:
-
Create:
apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts -
Create:
apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts -
Create:
apps/backend/src/modules/tbank/services/broker-instruments.service.ts -
Create:
apps/backend/src/modules/tbank/services/broker-portfolio.service.ts -
Create:
apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts -
Create:
apps/backend/src/modules/tbank/dto/broker-money.dto.ts -
Create:
apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts -
Modify:
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts -
Modify:
apps/backend/src/modules/tbank/tbank.controller.ts -
Modify:
apps/backend/src/modules/tbank/tbank.module.ts -
Step 1: Write portfolio mapper tests
Create apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts:
import { mapBrokerPortfolio } from './portfolio.mapper';
import type { BrokerAccount } from '../types/broker.types';
describe('portfolio.mapper', () => {
const account: BrokerAccount = {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
it('combines portfolio totals, cash, and enriched positions', () => {
const result = mapBrokerPortfolio({
account,
portfolio: {
accountId: 'acc-1',
totalAmountShares: { currency: 'rub', units: '1000', nano: 0 },
totalAmountPortfolio: { currency: 'rub', units: '1500', nano: 0 },
expectedYield: { units: '10', nano: 500000000 },
positions: [
{
figi: 'BBG004730N88',
instrumentUid: 'uid-1',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
currentPrice: { currency: 'rub', units: '250', nano: 0 },
averagePositionPrice: { currency: 'rub', units: '200', nano: 0 },
},
],
},
positions: {
money: [{ currency: 'rub', units: '500', nano: 0 }],
blocked: [{ currency: 'rub', units: '10', nano: 0 }],
securities: [],
},
instruments: new Map([['uid-1', { name: 'Sberbank', ticker: 'SBER' }]]),
});
expect(result.account.id).toBe('acc-1');
expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10);
expect(result.positions[0]).toMatchObject({
ticker: 'SBER',
name: 'Sberbank',
quantity: 10,
currentValue: { value: 2500 },
});
});
});
- Step 2: Implement portfolio mapper
Create apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts:
import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { mapMoneyValue, mapQuotationToNumber } from './money.mapper';
type MapBrokerPortfolioInput = {
account: BrokerAccount;
portfolio: TBankPortfolioResponse;
positions: TBankPositionsResponse;
instruments: Map<string, Partial<TBankInstrument>>;
};
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
const mappedPositions = (input.portfolio.positions ?? []).map<BrokerPosition>((position) => {
const quantity = mapQuotationToNumber(position.quantity);
const currentPrice = mapMoneyValue(position.currentPrice);
const currentValue =
currentPrice && quantity !== null
? {
...currentPrice,
units: String(Math.trunc(currentPrice.value * quantity)),
nano: 0,
value: Number((currentPrice.value * quantity).toFixed(9)),
}
: null;
const instrument =
(position.instrumentUid && input.instruments.get(position.instrumentUid)) ||
(position.positionUid && input.instruments.get(position.positionUid)) ||
undefined;
return {
figi: position.figi ?? null,
instrumentUid: position.instrumentUid ?? null,
positionUid: position.positionUid ?? null,
ticker: position.ticker || instrument?.ticker || null,
classCode: position.classCode || instrument?.classCode || null,
instrumentType: position.instrumentType || instrument?.instrumentType || null,
name: instrument?.name ?? null,
quantity,
blockedLots: mapQuotationToNumber(position.blockedLots),
currentPrice,
currentValue,
averagePositionPrice: mapMoneyValue(position.averagePositionPrice),
expectedYieldPercent: mapQuotationToNumber(position.expectedYield),
dailyYield: mapMoneyValue(position.dailyYield),
};
});
return {
account: input.account,
totals: {
shares: mapMoneyValue(input.portfolio.totalAmountShares),
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
etf: mapMoneyValue(input.portfolio.totalAmountEtf),
currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies),
futures: mapMoneyValue(input.portfolio.totalAmountFutures),
options: mapMoneyValue(input.portfolio.totalAmountOptions),
structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp),
dfa: mapMoneyValue(input.portfolio.totalAmountDfa),
portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio),
},
yields: {
expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield),
daily: mapMoneyValue(input.portfolio.dailyYield),
dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative),
},
cash: (input.positions.money ?? []).map(mapMoneyValue).filter((value) => value !== null),
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter((value) => value !== null),
positions: mappedPositions,
asOf: new Date().toISOString(),
};
}
- Step 3: Write portfolio service test
Create apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts:
import { NotFoundException } from '@nestjs/common';
import { BrokerPortfolioService } from './broker-portfolio.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';
import { CacheService } from '../../cache/cache.service';
describe('BrokerPortfolioService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => vi.clearAllMocks());
it('throws 404 for excluded or missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
});
it('fetches portfolio and positions through cache', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
vi.mocked(client.callUnary)
.mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
money: [{ currency: 'rub', units: '1000', nano: 0 }],
blocked: [],
securities: [],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPortfolio('acc-1');
expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio',
['acc-1'],
expect.any(Function),
'tbankPortfolioTtl',
);
});
});
- Step 4: Implement instrument and portfolio services
Create apps/backend/src/modules/tbank/services/broker-instruments.service.ts:
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { TBankInstrument, TBankInstrumentResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerInstrumentsService {
constructor(
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async findByInstrumentUid(instrumentUid: string): Promise<TBankInstrument | null> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.instrument,
[instrumentUid],
() => this.fetchByUid(instrumentUid),
'tbankInstrumentTtl',
);
return result.data;
}
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
const response = await this.tbankClient.callUnary<
{ idType: string; id: string },
TBankInstrumentResponse
>(
'InstrumentsService/GetInstrumentBy',
instrumentsClient.getInstrumentBy.bind(instrumentsClient),
{ idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid },
);
return response.instrument ?? null;
}
}
Create apps/backend/src/modules/tbank/services/broker-portfolio.service.ts:
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapBrokerPortfolio } from '../mappers/portfolio.mapper';
import type { BrokerPortfolio } from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerPortfolioService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly instrumentsService: BrokerInstrumentsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio,
[accountId],
async () => {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const [portfolio, positions] = await Promise.all([
this.tbankClient.callUnary<{ accountId: string; currency: string }, TBankPortfolioResponse>(
'OperationsService/GetPortfolio',
operationsClient.getPortfolio.bind(operationsClient),
{ accountId, currency: 'RUB' },
),
this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient),
{ accountId },
),
]);
const instrumentMap = await this.buildInstrumentMap(portfolio);
return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap });
},
'tbankPortfolioTtl',
);
return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } };
}
private async buildInstrumentMap(
portfolio: TBankPortfolioResponse,
): Promise<Map<string, Partial<TBankInstrument>>> {
const ids = Array.from(
new Set((portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean)),
) as string[];
const entries = await Promise.all(
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
);
return new Map(entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null));
}
}
- Step 5: Add portfolio DTOs and controller route
Create apps/backend/src/modules/tbank/dto/broker-money.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
export class BrokerMoneyDto {
@ApiProperty()
currency!: string;
@ApiProperty()
units!: string;
@ApiProperty()
nano!: number;
@ApiProperty()
value!: number;
}
Create apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPositionResponseDto {
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
positionUid!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ nullable: true })
name!: string | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
blockedLots!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentPrice!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentValue!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
averagePositionPrice!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
expectedYieldPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dailyYield!: BrokerMoneyDto | null;
}
export class BrokerPortfolioTotalsDto {
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
shares!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
bonds!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
etf!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currencies!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
futures!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
options!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
structuredProducts!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dfa!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
portfolio!: BrokerMoneyDto | null;
}
export class BrokerPortfolioYieldsDto {
@ApiProperty({ nullable: true })
expectedPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
daily!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
dailyPercent!: number | null;
}
export class BrokerPortfolioResponseDto {
@ApiProperty({ type: BrokerAccountResponseDto })
account!: BrokerAccountResponseDto;
@ApiProperty({ type: BrokerPortfolioTotalsDto })
totals!: BrokerPortfolioTotalsDto;
@ApiProperty({ type: BrokerPortfolioYieldsDto })
yields!: BrokerPortfolioYieldsDto;
@ApiProperty({ type: [BrokerMoneyDto] })
cash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerMoneyDto] })
blockedCash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerPositionResponseDto] })
positions!: BrokerPositionResponseDto[];
@ApiProperty()
asOf!: string;
}
Modify apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts to add:
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
Modify apps/backend/src/modules/tbank/tbank.controller.ts:
import { Param } from '@nestjs/common';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { BrokerPortfolioEnvelopeDto } from './dto/broker-envelope.dto';
Inject BrokerPortfolioService in the constructor and add:
@Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) {
return this.brokerPortfolioService.getPortfolio(accountId);
}
Update TBankModule providers and exports to include BrokerInstrumentsService and
BrokerPortfolioService.
- Step 6: Run portfolio tests and commit
Run:
npx vitest run src/modules/tbank/mappers/portfolio.mapper.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts -w apps/backend
npm run build:backend
Expected: tests PASS and backend build exits 0.
Commit:
git add apps/backend/src/modules/tbank
git commit -m "feat: expose tbank broker portfolio"
Task 7: Implement Broker Operations Endpoint
Files:
-
Create:
apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts -
Create:
apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts -
Create:
apps/backend/src/modules/tbank/services/broker-operations.service.ts -
Create:
apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts -
Modify:
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts -
Modify:
apps/backend/src/modules/tbank/tbank.controller.ts -
Modify:
apps/backend/src/modules/tbank/tbank.module.ts -
Step 1: Write operations service tests
Create apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts:
import { NotFoundException } from '@nestjs/common';
import { BrokerOperationsService } from './broker-operations.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
import { CacheService } from '../../cache/cache.service';
describe('BrokerOperationsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => vi.clearAllMocks());
it('throws 404 for excluded or missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerOperationsService(accounts, client, cache);
await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException);
});
it('builds cursor request and maps operation page', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
hasNext: false,
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
});
const service = new BrokerOperationsService(accounts, client, cache);
const result = await service.getOperations('acc-1', {
from: '2026-01-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
expect(result.data.items[0].category).toBe('trade');
expect(client.callUnary).toHaveBeenCalledWith(
'OperationsService/GetOperationsByCursor',
expect.any(Function),
expect.objectContaining({
accountId: 'acc-1',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
}),
);
});
});
- Step 2: Add query DTO
Create apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts:
import { Transform } from 'class-transformer';
import { IsDateString, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class BrokerOperationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : Number(value)))
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
instrumentId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
operationTypes?: string;
@ApiPropertyOptional({ default: 'OPERATION_STATE_EXECUTED' })
@IsOptional()
@IsString()
state?: string;
}
- Step 3: Implement operations service
Create apps/backend/src/modules/tbank/services/broker-operations.service.ts:
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapOperationsPage } from '../mappers/operation.mapper';
import type { BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerOperationsService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getOperations(
accountId: string,
query: BrokerOperationQueryDto,
): Promise<{ data: BrokerOperationsPage; meta: { fromCache: boolean; cachedAt: string | null } }> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
const request = this.buildRequest(accountId, query);
const cacheParts = [accountId, JSON.stringify(request)];
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.operations,
cacheParts,
() => this.fetchOperations(accountId, request),
'tbankOperationsTtl',
);
return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } };
}
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
const now = new Date();
const startOfYear = new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
const operationTypes = query.operationTypes
? query.operationTypes.split(',').map((value) => value.trim()).filter(Boolean)
: undefined;
return {
accountId,
instrumentId: query.instrumentId,
from: { seconds: Math.floor(new Date(query.from ?? startOfYear.toISOString()).getTime() / 1000) },
to: { seconds: Math.floor(new Date(query.to ?? now.toISOString()).getTime() / 1000) },
cursor: query.cursor,
limit: query.limit ?? 100,
operationTypes,
state: query.state ?? 'OPERATION_STATE_EXECUTED',
withoutCommissions: false,
withoutTrades: false,
withoutOvernights: false,
};
}
private async fetchOperations(
accountId: string,
request: Record<string, unknown>,
): Promise<BrokerOperationsPage> {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse
>(
'OperationsService/GetOperationsByCursor',
operationsClient.getOperationsByCursor.bind(operationsClient),
request,
);
return mapOperationsPage(accountId, response);
}
}
- Step 4: Add operation DTOs and controller route
Create apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';
const operationCategories = ['trade', 'income', 'tax', 'fee', 'transfer', 'other'] as const;
export class BrokerOperationResponseDto {
@ApiProperty({ nullable: true })
cursor!: string | null;
@ApiProperty()
accountId!: string;
@ApiProperty({ nullable: true })
id!: string | null;
@ApiProperty({ nullable: true })
parentOperationId!: string | null;
@ApiProperty({ nullable: true })
date!: string | null;
@ApiProperty()
type!: string;
@ApiProperty({ enum: operationCategories })
category!: (typeof operationCategories)[number];
@ApiProperty({ nullable: true })
description!: string | null;
@ApiProperty({ nullable: true })
state!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
payment!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
price!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
commission!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
yield!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
accruedInt!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
quantityDone!: number | null;
}
export class BrokerOperationsPageResponseDto {
@ApiProperty()
accountId!: string;
@ApiProperty({ type: [BrokerOperationResponseDto] })
items!: BrokerOperationResponseDto[];
@ApiProperty({ nullable: true })
nextCursor!: string | null;
@ApiProperty()
hasNext!: boolean;
@ApiProperty()
asOf!: string;
}
Modify apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts:
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerOperationsPageResponseDto })
data!: BrokerOperationsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
Modify apps/backend/src/modules/tbank/tbank.controller.ts:
import { Query } from '@nestjs/common';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerOperationsEnvelopeDto } from './dto/broker-envelope.dto';
Inject BrokerOperationsService and add:
@Get('accounts/:accountId/operations')
@ApiOperation({ summary: 'Get paginated T-Bank broker account operations' })
@ApiOkResponse({ type: BrokerOperationsEnvelopeDto })
async getOperations(
@Param('accountId') accountId: string,
@Query() query: BrokerOperationQueryDto,
) {
return this.brokerOperationsService.getOperations(accountId, query);
}
Update TBankModule providers and exports to include BrokerOperationsService.
- Step 5: Run operations tests and commit
Run:
npx vitest run src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/mappers/operation.mapper.spec.ts -w apps/backend
npm run build:backend
Expected: tests PASS and backend build exits 0.
Commit:
git add apps/backend/src/modules/tbank
git commit -m "feat: expose tbank broker operations"
Task 8: Generate Frontend Types And Add Broker API Hooks
Files:
-
Modify:
apps/frontend/src/api/responses.ts -
Create:
apps/frontend/src/api/broker.ts -
Create:
apps/frontend/src/api/broker.test.ts -
Create:
apps/frontend/src/hooks/useBrokerAccounts.ts -
Create:
apps/frontend/src/hooks/useBrokerPortfolio.ts -
Create:
apps/frontend/src/hooks/useBrokerOperations.ts -
Create:
apps/frontend/src/hooks/useBrokerAccounts.test.tsx -
Step 1: Regenerate OpenAPI types after backend endpoints exist
Start backend in a separate shell when it is not already running:
PORT=3001 npm run dev:backend
Then run:
npm run codegen -w apps/frontend
Expected: apps/frontend/src/api/types.ts includes /api/v1/broker/accounts,
/api/v1/broker/accounts/{accountId}/portfolio, and
/api/v1/broker/accounts/{accountId}/operations.
- Step 2: Add frontend response interfaces
Append to apps/frontend/src/api/responses.ts:
export interface BrokerMoney {
currency: string;
units: string;
nano: number;
value: number;
}
export interface BrokerAccount {
id: string;
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: string | null;
accessLevel: string | null;
}
export interface BrokerPosition {
figi: string | null;
instrumentUid: string | null;
positionUid: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
name: string | null;
quantity: number | null;
blockedLots: number | null;
currentPrice: BrokerMoney | null;
currentValue: BrokerMoney | null;
averagePositionPrice: BrokerMoney | null;
expectedYieldPercent: number | null;
dailyYield: BrokerMoney | null;
}
export interface BrokerPortfolio {
account: BrokerAccount;
totals: {
shares: BrokerMoney | null;
bonds: BrokerMoney | null;
etf: BrokerMoney | null;
currencies: BrokerMoney | null;
futures: BrokerMoney | null;
options: BrokerMoney | null;
structuredProducts: BrokerMoney | null;
dfa: BrokerMoney | null;
portfolio: BrokerMoney | null;
};
yields: {
expectedPercent: number | null;
daily: BrokerMoney | null;
dailyPercent: number | null;
};
cash: BrokerMoney[];
blockedCash: BrokerMoney[];
positions: BrokerPosition[];
asOf: string;
}
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
export interface BrokerOperation {
cursor: string | null;
accountId: string;
id: string | null;
parentOperationId: string | null;
date: string | null;
type: string;
category: BrokerOperationCategory;
description: string | null;
state: string | null;
instrumentUid: string | null;
figi: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
payment: BrokerMoney | null;
price: BrokerMoney | null;
commission: BrokerMoney | null;
yield: BrokerMoney | null;
accruedInt: BrokerMoney | null;
quantity: number | null;
quantityDone: number | null;
}
export interface BrokerOperationsPage {
accountId: string;
items: BrokerOperation[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}
- Step 3: Add broker API client
Create apps/frontend/src/api/broker.ts:
import { request } from './client';
import type {
ApiResponseMeta,
BrokerAccount,
BrokerOperation,
BrokerOperationsPage,
BrokerPortfolio,
} from './responses';
export type BrokerOperationQuery = {
from?: string;
to?: string;
cursor?: string;
limit?: number;
instrumentId?: string;
operationTypes?: string;
state?: string;
};
export function getBrokerAccounts(): Promise<{
data: BrokerAccount[];
meta: ApiResponseMeta;
}> {
return request<BrokerAccount[]>('/api/v1/broker/accounts');
}
export function getBrokerPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: ApiResponseMeta;
}> {
return request<BrokerPortfolio>(`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`);
}
export function getBrokerOperations(
accountId: string,
query: BrokerOperationQuery = {},
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
return request<BrokerOperationsPage>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
{
from: query.from,
to: query.to,
cursor: query.cursor,
limit: query.limit ? String(query.limit) : undefined,
instrumentId: query.instrumentId,
operationTypes: query.operationTypes,
state: query.state,
},
);
}
- Step 4: Add API client test
Create apps/frontend/src/api/broker.test.ts:
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getBrokerOperations } from './broker';
describe('broker api', () => {
afterEach(() => vi.restoreAllMocks());
it('serializes operations query parameters', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
data: {
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
meta: { fromCache: false, cachedAt: null },
},
}),
} as Response);
await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
expect.any(Object),
);
});
});
- Step 5: Add broker hooks
Create apps/frontend/src/hooks/useBrokerAccounts.ts:
import { useQuery } from '@tanstack/react-query';
import { getBrokerAccounts } from '../api/broker';
import type { BrokerAccount } from '../api/responses';
export function useBrokerAccounts() {
return useQuery<BrokerAccount[]>({
queryKey: ['broker', 'accounts'],
queryFn: async () => (await getBrokerAccounts()).data,
staleTime: 3_600_000,
retry: 2,
refetchOnWindowFocus: false,
});
}
Create apps/frontend/src/hooks/useBrokerPortfolio.ts:
import { useQuery } from '@tanstack/react-query';
import { getBrokerPortfolio } from '../api/broker';
import type { BrokerPortfolio } from '../api/responses';
export function useBrokerPortfolio(accountId: string | undefined) {
return useQuery<BrokerPortfolio>({
queryKey: ['broker', 'portfolio', accountId],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
});
}
Create apps/frontend/src/hooks/useBrokerOperations.ts:
import { useQuery } from '@tanstack/react-query';
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
import type { BrokerOperationsPage } from '../api/responses';
export function useBrokerOperations(accountId: string | undefined, query: BrokerOperationQuery = {}) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,
refetchOnWindowFocus: false,
});
}
- Step 6: Add hook smoke test
Create apps/frontend/src/hooks/useBrokerAccounts.test.tsx:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { getBrokerAccounts } from '../api/broker';
import { useBrokerAccounts } from './useBrokerAccounts';
vi.mock('../api/broker', () => ({
getBrokerAccounts: vi.fn(),
}));
function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
describe('useBrokerAccounts', () => {
it('returns broker accounts from API', async () => {
vi.mocked(getBrokerAccounts).mockResolvedValue({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
meta: { fromCache: false, cachedAt: null },
});
const { result } = renderHook(() => useBrokerAccounts(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].name).toBe('Broker');
});
});
- Step 7: Run frontend API/hook tests and commit
Run:
npx vitest run src/api/broker.test.ts src/hooks/useBrokerAccounts.test.tsx -w apps/frontend
npm run build:frontend
Expected: tests PASS and frontend build exits 0.
Commit:
git add apps/frontend/src/api apps/frontend/src/hooks
git commit -m "feat: add broker frontend api hooks"
Task 9: Build Broker Portfolio UI
Files:
-
Create:
apps/frontend/src/pages/broker/BrokerAccountsPage.tsx -
Create:
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx -
Create:
apps/frontend/src/pages/broker/BrokerPages.test.tsx -
Modify:
apps/frontend/src/routes.tsx -
Modify:
apps/frontend/src/components/Layout.tsx -
Modify:
apps/frontend/src/styles.css: expected to remain unchanged unless browser verification shows a concrete layout defect. -
Step 1: Add UI tests for broker pages
Create apps/frontend/src/pages/broker/BrokerPages.test.tsx:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import * as accountHook from '../../hooks/useBrokerAccounts';
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import * as operationsHook from '../../hooks/useBrokerOperations';
import { BrokerAccountsPage } from './BrokerAccountsPage';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
function renderWithClient(ui: React.ReactElement, initialEntries = ['/broker']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe('Broker pages', () => {
it('renders broker and IIS accounts', () => {
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
data: [
{ id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null },
{ id: 'acc-2', type: 'iis', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null },
],
isLoading: false,
error: null,
} as any);
renderWithClient(<BrokerAccountsPage />);
expect(screen.getByText('Broker')).toBeInTheDocument();
expect(screen.getByText('IIS')).toBeInTheDocument();
});
it('renders positions and operations for account detail', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: {
account: { id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null },
totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
blockedCash: [],
positions: [{ ticker: 'SBER', name: 'Sberbank', quantity: 10, currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } }],
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [{ id: 'op-1', date: '2026-06-16T00:00:00.000Z', category: 'trade', type: 'OPERATION_TYPE_BUY', description: 'Buy', ticker: 'SBER', payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 } }],
nextCursor: null,
hasNext: false,
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any);
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/broker/acc-1'],
);
expect(screen.getByText('SBER')).toBeInTheDocument();
expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument();
});
});
- Step 2: Implement broker accounts page
Create apps/frontend/src/pages/broker/BrokerAccountsPage.tsx:
import { Link } from 'react-router-dom';
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
export function BrokerAccountsPage() {
const { data: accounts, isLoading, error } = useBrokerAccounts();
if (isLoading) return <p>Загрузка брокерских счетов...</p>;
if (error) return <p style={{ color: 'var(--color-danger)' }}>Не удалось загрузить счета</p>;
return (
<div>
<h1 style={{ marginBottom: 8 }}>Брокерские счета</h1>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 24 }}>
Реальные брокерские счета и ИИС из T-Bank Invest.
</p>
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
{(accounts ?? []).map((account) => (
<Link
key={account.id}
to={`/broker/${encodeURIComponent(account.id)}`}
style={{
display: 'block',
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
color: 'var(--color-text)',
textDecoration: 'none',
}}
>
<div style={{ fontSize: 18, fontWeight: 700 }}>{account.name}</div>
<div style={{ marginTop: 8, color: 'var(--color-text-secondary)' }}>
{account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}
</div>
</Link>
))}
</div>
</div>
);
}
- Step 3: Implement broker account detail page
Create apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx:
import { useParams } from 'react-router-dom';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import type { BrokerMoney } from '../../api/responses';
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '—';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
export function BrokerAccountDetailPage() {
const { accountId } = useParams();
const portfolio = useBrokerPortfolio(accountId);
const operations = useBrokerOperations(accountId, { limit: 100 });
if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
if (portfolio.error || !portfolio.data) {
return <p style={{ color: 'var(--color-danger)' }}>Не удалось загрузить портфель</p>;
}
return (
<div>
<h1 style={{ marginBottom: 8 }}>{portfolio.data.account.name}</h1>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 24 }}>
<strong>{formatMoney(portfolio.data.totals.portfolio)}</strong>
<span>Дневная доходность: {formatMoney(portfolio.data.yields.daily)}</span>
<span>Ожидаемая доходность: {portfolio.data.yields.expectedPercent ?? '—'}%</span>
</div>
<section style={{ marginBottom: 32 }}>
<h2>Деньги</h2>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{portfolio.data.cash.map((money) => (
<span key={money.currency}>{formatMoney(money)}</span>
))}
</div>
</section>
<section style={{ marginBottom: 32 }}>
<h2>Позиции</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th align="left">Инструмент</th>
<th align="right">Количество</th>
<th align="right">Стоимость</th>
<th align="right">Доходность</th>
</tr>
</thead>
<tbody>
{portfolio.data.positions.map((position) => (
<tr key={position.positionUid || position.instrumentUid || position.ticker}>
<td>{position.ticker || position.name || position.figi}</td>
<td align="right">{position.quantity ?? '—'}</td>
<td align="right">{formatMoney(position.currentValue)}</td>
<td align="right">{position.expectedYieldPercent ?? '—'}%</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section>
<h2>Операции</h2>
{operations.isLoading ? (
<p>Загрузка операций...</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th align="left">Дата</th>
<th align="left">Тип</th>
<th align="left">Инструмент</th>
<th align="right">Сумма</th>
</tr>
</thead>
<tbody>
{(operations.data?.items ?? []).map((operation) => (
<tr key={operation.cursor || operation.id}>
<td>{operation.date ? new Date(operation.date).toLocaleString('ru-RU') : '—'}</td>
<td>{operation.type}</td>
<td>{operation.ticker || operation.description || '—'}</td>
<td align="right">{formatMoney(operation.payment)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
- Step 4: Wire routes and navigation
Modify apps/frontend/src/routes.tsx imports:
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
Add protected routes:
<Route
path="/broker"
element={
<ProtectedRoute>
<BrokerAccountsPage />
</ProtectedRoute>
}
/>
<Route
path="/broker/:accountId"
element={
<ProtectedRoute>
<BrokerAccountDetailPage />
</ProtectedRoute>
}
/>
Modify apps/frontend/src/components/Layout.tsx to add a link after Портфели:
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
- Step 5: Run frontend tests, build, and browser verification
Run:
npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend
npm run build:frontend
Expected: tests PASS and frontend build exits 0.
Then start dev servers:
PORT=3001 npm run dev:backend
npm run dev:frontend
Open http://localhost:5173/broker with Browser. Verify:
-
page renders without a blank screen;
-
protected route redirects unauthenticated users consistently with existing portfolio pages;
-
layout does not overlap at desktop and mobile widths.
-
Step 6: Commit frontend broker UI
git add apps/frontend/src/api apps/frontend/src/hooks apps/frontend/src/pages/broker apps/frontend/src/routes.tsx apps/frontend/src/components/Layout.tsx apps/frontend/src/styles.css
git commit -m "feat: add broker portfolio UI"
Task 10: Add Durable Operation Sync Models And Service
Files:
-
Modify:
apps/backend/prisma/schema.prisma -
Create: migration SQL generated by Prisma under
apps/backend/prisma/migrations/when runningnpx prisma migrate dev --name add_broker_operations -w apps/backend -
Create:
apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts -
Create:
apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts -
Modify:
apps/backend/src/modules/tbank/tbank.module.ts -
Step 1: Add Prisma models
Append to apps/backend/prisma/schema.prisma:
model BrokerOperation {
id Int @id @default(autoincrement())
accountId String
cursor String?
operationId String?
parentOperationId String?
date DateTime?
type String
category String
state String?
instrumentUid String?
figi String?
ticker String?
classCode String?
payment String?
price String?
commission String?
yield String?
accruedInt String?
quantity Int?
quantityDone Int?
raw String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([accountId, cursor])
@@index([accountId, date])
@@index([accountId, type])
}
model BrokerOperationSyncState {
id Int @id @default(autoincrement())
accountId String @unique
lastCursor String?
lastSyncedFrom DateTime?
lastSyncedTo DateTime?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
- Step 2: Generate migration and Prisma client
Run:
npx prisma migrate dev --name add_broker_operations -w apps/backend
Expected: migration SQL file created and Prisma client generated.
- Step 3: Write sync service test
Create apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts:
import { BrokerOperationSyncService } from './broker-operation-sync.service';
import { BrokerOperationsService } from './broker-operations.service';
import { PrismaService } from '../../prisma/prisma.service';
describe('BrokerOperationSyncService', () => {
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const prisma = {
brokerOperation: { upsert: vi.fn() },
brokerOperationSyncState: { upsert: vi.fn() },
} as unknown as PrismaService;
beforeEach(() => vi.clearAllMocks());
it('syncs operation pages and stores raw payload', async () => {
vi.mocked(operations.getOperations)
.mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: true,
nextCursor: 'next',
asOf: '2026-06-16T00:00:00.000Z',
items: [
{
cursor: 'c1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-16T00:00:00.000Z',
type: 'OPERATION_TYPE_BUY',
category: 'trade',
description: null,
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 10,
quantityDone: 10,
},
],
},
meta: { fromCache: false, cachedAt: null },
})
.mockResolvedValueOnce({
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
const result = await service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
});
expect(result.upserted).toBe(1);
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
}),
);
expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalled();
});
});
- Step 4: Implement sync service
Create apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { BrokerOperation } from '../types/broker.types';
import { BrokerOperationsService } from './broker-operations.service';
type SyncRange = {
from: string;
to: string;
};
@Injectable()
export class BrokerOperationSyncService {
constructor(
private readonly operationsService: BrokerOperationsService,
private readonly prisma: PrismaService,
) {}
async syncAccount(accountId: string, range: SyncRange): Promise<{ upserted: number }> {
let cursor: string | undefined;
let upserted = 0;
do {
const page = await this.operationsService.getOperations(accountId, {
from: range.from,
to: range.to,
cursor,
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
for (const operation of page.data.items) {
await this.upsertOperation(operation);
upserted++;
}
cursor = page.data.nextCursor ?? undefined;
if (!page.data.hasNext) break;
} while (cursor);
await this.prisma.brokerOperationSyncState.upsert({
where: { accountId },
create: {
accountId,
lastCursor: cursor ?? null,
lastSyncedFrom: new Date(range.from),
lastSyncedTo: new Date(range.to),
},
update: {
lastCursor: cursor ?? null,
lastSyncedFrom: new Date(range.from),
lastSyncedTo: new Date(range.to),
syncedAt: new Date(),
},
});
return { upserted };
}
private async upsertOperation(operation: BrokerOperation): Promise<void> {
const cursor = operation.cursor || `${operation.id || 'operation'}:${operation.date || 'no-date'}`;
const data = {
accountId: operation.accountId,
cursor,
operationId: operation.id,
parentOperationId: operation.parentOperationId,
date: operation.date ? new Date(operation.date) : null,
type: operation.type,
category: operation.category,
state: operation.state,
instrumentUid: operation.instrumentUid,
figi: operation.figi,
ticker: operation.ticker,
classCode: operation.classCode,
payment: operation.payment ? JSON.stringify(operation.payment) : null,
price: operation.price ? JSON.stringify(operation.price) : null,
commission: operation.commission ? JSON.stringify(operation.commission) : null,
yield: operation.yield ? JSON.stringify(operation.yield) : null,
accruedInt: operation.accruedInt ? JSON.stringify(operation.accruedInt) : null,
quantity: operation.quantity,
quantityDone: operation.quantityDone,
raw: JSON.stringify(operation),
};
await this.prisma.brokerOperation.upsert({
where: { accountId_cursor: { accountId: operation.accountId, cursor } },
create: data,
update: data,
});
}
}
Add BrokerOperationSyncService to TBankModule providers and exports.
- Step 5: Run sync tests and commit
Run:
npx vitest run src/modules/tbank/services/broker-operation-sync.service.spec.ts -w apps/backend
npm run build:backend
Expected: tests PASS and backend build exits 0.
Commit:
git add apps/backend/prisma apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts apps/backend/src/modules/tbank/tbank.module.ts
git commit -m "feat: persist tbank broker operations"
Task 11: Publish T-Bank Integration Documentation
Files:
-
Create:
apps/docs/docs/backend/tbank-invest.md -
Modify:
apps/docs/docs/backend/modules.md -
Modify:
apps/docs/docs/backend/configuration.md -
Modify:
apps/docs/docs/backend/caching.md -
Modify:
apps/docs/docs/backend/portfolio.md -
Create:
apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md -
Modify:
apps/docs/docs/adr/index.md -
Modify:
apps/docs/sidebars.ts -
Step 1: Add backend T-Bank integration page
Create apps/docs/docs/backend/tbank-invest.md:
# T-Bank Invest Integration
`TBankModule` is a read-only backend integration with T-Bank Invest API. The backend is the only
client that talks to T-Bank; the frontend calls MoexVibe endpoints under `/api/v1/broker`.
## Scope
The first version supports only open brokerage accounts and IIS accounts:
- `ACCOUNT_TYPE_TINKOFF`
- `ACCOUNT_TYPE_TINKOFF_IIS`
Invest Box, DFA smart accounts, debit accounts, savings accounts, and money market fund accounts
are ignored.
## Protocol
MoexVibe uses gRPC against `invest-public-api.tbank.ru:443`. REST is treated as a debugging proxy,
not as the application integration protocol.
The backend sends:
```text
Authorization: Bearer <T_BANK_TOKEN>
x-app-name: ksv741.moex-vibe
The token is read from backend environment variables and is never returned to the frontend.
Backend Endpoints
| Endpoint | Description |
|---|---|
GET /api/v1/broker/accounts |
Open brokerage and IIS accounts. |
GET /api/v1/broker/accounts/:accountId/portfolio |
Portfolio totals, positions, cash, and blocked cash. |
GET /api/v1/broker/accounts/:accountId/operations |
Cursor-paginated operation history. |
T-Bank Methods
| Need | T-Bank method |
|---|---|
| Accounts | UsersService/GetAccounts |
| Portfolio totals | OperationsService/GetPortfolio |
| Cash and settled positions | OperationsService/GetPositions |
| Operation history | OperationsService/GetOperationsByCursor |
| Instrument metadata | InstrumentsService/GetInstrumentBy |
Security
This version is single-user/admin-oriented because it uses one server-side T_BANK_TOKEN. Before
opening MoexVibe to multiple users, replace this with encrypted per-user token storage and bind each
broker account to its owner.
- [ ] **Step 2: Update backend module docs**
Modify `apps/docs/docs/backend/modules.md`:
- Add `TBankModule` to feature modules in the Mermaid diagram.
- Add row `TBankModule | Нет | modules/tbank/ | Read-only T-Bank Invest broker portfolios`.
- Add a section describing `TBankModule`, its gRPC client, cache usage, and read-only scope.
- [ ] **Step 3: Update configuration docs**
Modify `apps/docs/docs/backend/configuration.md` and add rows:
```markdown
| `T_BANK_TOKEN` | empty | Server-side T-Bank Invest token |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | T-Bank gRPC endpoint |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Optional T-Bank app metadata |
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Local limiter for T-Bank calls |
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | gRPC request deadline |
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | Broker accounts cache TTL |
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | Broker portfolio cache TTL |
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | Broker operations page cache TTL |
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | T-Bank instrument metadata TTL |
- Step 4: Update caching and portfolio docs
Modify apps/docs/docs/backend/caching.md to add T-Bank cache rows matching the spec.
Modify apps/docs/docs/backend/portfolio.md to add a short section:
## Manual portfolios vs broker portfolios
`PortfolioModule` remains the manual virtual portfolio domain. T-Bank broker accounts are exposed by
`TBankModule` under `/api/v1/broker/*` and are not stored as `Portfolio` records.
- Step 5: Add ADR-011
Create apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md:
# ADR-011: T-Bank Invest integration uses gRPC
**Статус:** Accepted
**Дата:** 2026-06-16
## Контекст
MoexVibe needs a read-only integration with T-Bank Invest for brokerage and IIS accounts, current
positions, cash balances, and operation history. T-Bank provides gRPC, REST proxy, WebSocket, and an
official JS SDK.
## Решение
Use a thin backend gRPC integration based on official proto contracts. Keep REST as a manual
debugging tool and do not depend directly on the JS SDK in the first implementation.
## Обоснование
- gRPC is the primary T-Bank Invest protocol.
- Unary methods cover accounts, portfolio, positions, operations, and instruments.
- Stream methods can be added later without changing the public MoexVibe API.
- Owning the transport layer lets MoexVibe control rate limiting, metadata redaction, tracking IDs,
test doubles, and the future transition from one server token to per-user tokens.
## Последствия
- The backend vendors official proto contracts.
- The backend owns T-Bank-specific rate limits and cache TTLs.
- Integration remains read-only until a separate trading/order ADR is accepted.
Update apps/docs/docs/adr/index.md and apps/docs/sidebars.ts to include ADR-011 and
backend/tbank-invest.
- Step 6: Build docs and commit
Run:
npm run build:docs
Expected: Docusaurus build exits 0.
Commit:
git add apps/docs
git commit -m "docs: document tbank invest integration"
Task 12: Final Verification And OpenAPI Contract Check
Files:
-
Verify all changed files.
-
Step 1: Run backend tests
Run:
npm run test:backend
Expected: all backend Vitest tests PASS.
- Step 2: Run frontend tests
Run:
npm run test:frontend
Expected: all frontend Vitest tests PASS.
- Step 3: Run lint
Run:
npm run lint
Expected: ESLint exits 0 for backend and frontend.
- Step 4: Run builds
Run:
npm run build:backend
npm run build:frontend
npm run build:docs
Expected: all builds exit 0.
- Step 5: Verify Swagger exposes broker endpoints
Start backend:
PORT=3001 npm run dev:backend
Run:
node -e "fetch('http://localhost:3001/api/docs-json').then(r => r.json()).then(j => { const required = ['/api/v1/broker/accounts','/api/v1/broker/accounts/{accountId}/portfolio','/api/v1/broker/accounts/{accountId}/operations']; const missing = required.filter(p => !j.paths || !j.paths[p]); console.log(JSON.stringify({ missing }, null, 2)); if (missing.length) process.exit(1); })"
Expected:
{
"missing": []
}
- Step 6: Browser-check frontend
With backend and frontend dev servers running, open:
http://localhost:5173/broker
Verify:
-
unauthenticated users are redirected by
ProtectedRoute; -
authenticated view renders account loading/error states;
-
account detail page renders positions and operations without overlapping text at desktop and mobile widths;
-
no token value appears in the browser UI or console logs.
-
Step 7: Inspect git diff
Run:
git status --short
git diff --check
git log --oneline --max-count=8
Expected:
git diff --checkexits 0;- status contains only intentional uncommitted files, or is clean after the final commit;
- recent commits correspond to the tasks above.