# 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 under `broker`. - 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`: `GetInstrumentBy` cache wrapper. - Create `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`: `GetPortfolio` + `GetPositions` aggregation. - Create `apps/backend/src/modules/tbank/services/broker-operations.service.ts`: `GetOperationsByCursor` query and categorization. - Create `apps/backend/src/modules/tbank/mappers/money.mapper.ts`: `MoneyValue` and `Quotation` conversion. - 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`: import `TBankModule`. - Modify `apps/backend/src/config/configuration.ts`: add `tbank` and T-Bank cache TTL config. - Modify `apps/backend/package.json` and root lockfile through `npm install`. ### Durable Sync - Modify `apps/backend/prisma/schema.prisma`: add `BrokerOperation` and `BrokerOperationSyncState`. - 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: ```bash 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: ```bash 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: ```bash 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** ```bash 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`: ```typescript 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: ```bash 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: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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: ```bash npx vitest run src/modules/tbank/tbank.config.spec.ts -w apps/backend ``` Expected: PASS. Commit: ```bash 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`: ```typescript 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: ```bash 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`: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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: ```bash npx vitest run "src/modules/tbank/mappers/*.spec.ts" -w apps/backend ``` Expected: PASS. Commit: ```bash 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`: ```typescript 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 = { '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: '', '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: ```bash 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`: ```typescript 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 = ( 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; private readonly clientCache = new Map(); constructor(private readonly configService: ConfigService) { this.requestTimeoutMs = this.configService.get('app.tbank.requestTimeoutMs', 10000); this.queue = new PQueue({ interval: 1000, intervalCap: this.configService.get('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('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('app.tbank.appName', ''); if (appName) metadata.set('x-app-name', appName); return metadata; } redactMetadata(metadata: Metadata): Record { const result: Record = {}; for (const key of Object.keys(metadata.getMap())) { result[key] = key.toLowerCase() === 'authorization' ? '' : 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; const namespace = TBANK_PROTO_PACKAGE.split('.').reduce>( (current, part) => current[part] as Record, pkg, ); const ServiceCtor = namespace[serviceName] as new (address: string, creds: ChannelCredentials) => Client; const client = new ServiceCtor( this.configService.get('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'), ChannelCredentials.createSsl(), ); this.clientCache.set(serviceName, client); return client; } async callUnary( label: string, method: GrpcUnary, request: TRequest, ): Promise { const metadata = this.createMetadata(); const deadline = new Date(Date.now() + this.requestTimeoutMs); return this.queue.add( () => new Promise((resolve, reject) => { method(request, metadata, { deadline }, (error, response) => { if (error) { reject(this.mapGrpcError(label, error)); return; } resolve(response as TResponse); }); }), ) as Promise; } 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`: ```typescript 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: ```bash npx vitest run src/modules/tbank/services/tbank-client.service.spec.ts -w apps/backend ``` Expected: PASS. Commit: ```bash 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`: ```typescript 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) => ({ 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: ```bash 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`: ```typescript 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 { const accounts = await this.findAll(); return accounts.data.find((account) => account.id === accountId) ?? null; } private async fetchAccounts(): Promise { const usersClient = this.tbankClient.getServiceClient('UsersService') as any; const response = await this.tbankClient.callUnary, 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`: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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`: ```typescript 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: ```bash 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** ```bash 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`: ```typescript 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`: ```typescript 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>; }; export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio { const mappedPositions = (input.portfolio.positions ?? []).map((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`: ```typescript 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) => ({ 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`: ```typescript 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 { const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.instrument, [instrumentUid], () => this.fetchByUid(instrumentUid), 'tbankInstrumentTtl', ); return result.data; } private async fetchByUid(instrumentUid: string): Promise { 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`: ```typescript 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>> { 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`: ```typescript 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`: ```typescript 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: ```typescript 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`: ```typescript 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: ```typescript @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: ```bash 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: ```bash 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`: ```typescript 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) => ({ 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`: ```typescript 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`: ```typescript 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 { 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, ): Promise { const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; const response = await this.tbankClient.callUnary< Record, 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`: ```typescript 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`: ```typescript 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`: ```typescript 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: ```typescript @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: ```bash 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: ```bash 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: ```bash PORT=3001 npm run dev:backend ``` Then run: ```bash 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`: ```typescript 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`: ```typescript 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('/api/v1/broker/accounts'); } export function getBrokerPortfolio(accountId: string): Promise<{ data: BrokerPortfolio; meta: ApiResponseMeta; }> { return request(`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`); } export function getBrokerOperations( accountId: string, query: BrokerOperationQuery = {}, ): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> { return request( `/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`: ```typescript 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`: ```typescript import { useQuery } from '@tanstack/react-query'; import { getBrokerAccounts } from '../api/broker'; import type { BrokerAccount } from '../api/responses'; export function useBrokerAccounts() { return useQuery({ queryKey: ['broker', 'accounts'], queryFn: async () => (await getBrokerAccounts()).data, staleTime: 3_600_000, retry: 2, refetchOnWindowFocus: false, }); } ``` Create `apps/frontend/src/hooks/useBrokerPortfolio.ts`: ```typescript 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({ 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`: ```typescript 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({ 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`: ```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 {children}; } 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: ```bash 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: ```bash 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`: ```typescript 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( {ui} , ); } 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(); 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( } /> , ['/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`: ```tsx import { Link } from 'react-router-dom'; import { useBrokerAccounts } from '../../hooks/useBrokerAccounts'; export function BrokerAccountsPage() { const { data: accounts, isLoading, error } = useBrokerAccounts(); if (isLoading) return

Загрузка брокерских счетов...

; if (error) return

Не удалось загрузить счета

; return (

Брокерские счета

Реальные брокерские счета и ИИС из T-Bank Invest.

{(accounts ?? []).map((account) => (
{account.name}
{account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}
))}
); } ``` - [ ] **Step 3: Implement broker account detail page** Create `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`: ```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

Загрузка портфеля...

; if (portfolio.error || !portfolio.data) { return

Не удалось загрузить портфель

; } return (

{portfolio.data.account.name}

{formatMoney(portfolio.data.totals.portfolio)} Дневная доходность: {formatMoney(portfolio.data.yields.daily)} Ожидаемая доходность: {portfolio.data.yields.expectedPercent ?? '—'}%

Деньги

{portfolio.data.cash.map((money) => ( {formatMoney(money)} ))}

Позиции

{portfolio.data.positions.map((position) => ( ))}
Инструмент Количество Стоимость Доходность
{position.ticker || position.name || position.figi} {position.quantity ?? '—'} {formatMoney(position.currentValue)} {position.expectedYieldPercent ?? '—'}%

Операции

{operations.isLoading ? (

Загрузка операций...

) : (
{(operations.data?.items ?? []).map((operation) => ( ))}
Дата Тип Инструмент Сумма
{operation.date ? new Date(operation.date).toLocaleString('ru-RU') : '—'} {operation.type} {operation.ticker || operation.description || '—'} {formatMoney(operation.payment)}
)}
); } ``` - [ ] **Step 4: Wire routes and navigation** Modify `apps/frontend/src/routes.tsx` imports: ```typescript import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage'; import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage'; ``` Add protected routes: ```tsx } /> } /> ``` Modify `apps/frontend/src/components/Layout.tsx` to add a link after `Портфели`: ```tsx Брокер ``` - [ ] **Step 5: Run frontend tests, build, and browser verification** Run: ```bash 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: ```bash 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** ```bash 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 running `npx 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`: ```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: ```bash 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`: ```typescript 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`: ```typescript 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 { 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: ```bash 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: ```bash 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`: ```markdown # 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 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: ```markdown ## 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`: ```markdown # 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: ```bash npm run build:docs ``` Expected: Docusaurus build exits 0. Commit: ```bash 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: ```bash npm run test:backend ``` Expected: all backend Vitest tests PASS. - [ ] **Step 2: Run frontend tests** Run: ```bash npm run test:frontend ``` Expected: all frontend Vitest tests PASS. - [ ] **Step 3: Run lint** Run: ```bash npm run lint ``` Expected: ESLint exits 0 for backend and frontend. - [ ] **Step 4: Run builds** Run: ```bash 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: ```bash PORT=3001 npm run dev:backend ``` Run: ```bash 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: ```json { "missing": [] } ``` - [ ] **Step 6: Browser-check frontend** With backend and frontend dev servers running, open: ```text 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: ```bash git status --short git diff --check git log --oneline --max-count=8 ``` Expected: - `git diff --check` exits 0; - status contains only intentional uncommitted files, or is clean after the final commit; - recent commits correspond to the tasks above.