109 KiB
Raw Permalink Blame History

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:

npm install @grpc/grpc-js @grpc/proto-loader protobufjs long -w apps/backend

Expected: apps/backend/package.json gains the four dependencies and package-lock.json updates.

  • Step 2: Vendor official T-Bank proto contracts

Run:

mkdir -p apps/backend/src/modules/tbank/proto/contracts/google/api
curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/common.proto -o apps/backend/src/modules/tbank/proto/contracts/common.proto
curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/users.proto -o apps/backend/src/modules/tbank/proto/contracts/users.proto
curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/operations.proto -o apps/backend/src/modules/tbank/proto/contracts/operations.proto
curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/instruments.proto -o apps/backend/src/modules/tbank/proto/contracts/instruments.proto
curl -L -s https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/field_behavior.proto -o apps/backend/src/modules/tbank/proto/contracts/google/api/field_behavior.proto

Expected: each file exists and begins with syntax = "proto3";.

  • Step 3: Verify proto service methods are present

Run:

rg -n "rpc GetAccounts|rpc GetPortfolio|rpc GetPositions|rpc GetOperationsByCursor|rpc GetInstrumentBy" apps/backend/src/modules/tbank/proto/contracts

Expected: output includes all five method declarations.

  • Step 4: Commit dependencies and contracts
git add apps/backend/package.json package-lock.json apps/backend/src/modules/tbank/proto/contracts
git commit -m "feat: add tbank invest proto contracts"

Task 2: Add Configuration And Core T-Bank Types

Files:

  • Modify: apps/backend/src/config/configuration.ts

  • Create: apps/backend/src/modules/tbank/tbank.config.ts

  • Create: apps/backend/src/modules/tbank/types/tbank-proto.types.ts

  • Create: apps/backend/src/modules/tbank/types/broker.types.ts

  • Test: apps/backend/src/modules/tbank/tbank.config.spec.ts

  • Step 1: Write failing config test

Create apps/backend/src/modules/tbank/tbank.config.spec.ts:

import configuration from '../../config/configuration';

describe('T-Bank configuration', () => {
  const originalEnv = process.env;

  beforeEach(() => {
    process.env = { ...originalEnv };
  });

  afterAll(() => {
    process.env = originalEnv;
  });

  it('uses conservative defaults for T-Bank integration', () => {
    delete process.env.T_BANK_BASE_URL;
    delete process.env.T_BANK_RATE_LIMIT_PER_SECOND;
    delete process.env.CACHE_TBANK_PORTFOLIO_TTL;

    const config = configuration();

    expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
    expect(config.tbank.rateLimitPerSecond).toBe(5);
    expect(config.cache.tbankPortfolioTtl).toBe(60);
  });

  it('reads T-Bank token and TTL overrides from environment', () => {
    process.env.T_BANK_TOKEN = 'secret-token';
    process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443';
    process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
    process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';

    const config = configuration();

    expect(config.tbank.token).toBe('secret-token');
    expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443');
    expect(config.tbank.rateLimitPerSecond).toBe(2);
    expect(config.cache.tbankAccountsTtl).toBe(120);
  });
});
  • Step 2: Run failing config test

Run:

npx vitest run src/modules/tbank/tbank.config.spec.ts -w apps/backend

Expected: FAIL because config.tbank and T-Bank cache keys do not exist yet.

  • Step 3: Extend backend configuration

Modify apps/backend/src/config/configuration.ts so the returned object includes:

tbank: {
  token: process.env.T_BANK_TOKEN || '',
  baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
  appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
  rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
  requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
},
cache: {
  marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
  historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
  candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
  securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
  searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
  dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
  tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
  tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
  tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
  tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
},

Keep the existing port, database, moex, and auth sections unchanged.

  • Step 4: Add T-Bank constants

Create apps/backend/src/modules/tbank/tbank.config.ts:

export const TBANK_PROTO_PACKAGE = 'tinkoff.public.invest.api.contract.v1';

export const TBANK_PROTO_FILES = {
  users: 'users.proto',
  operations: 'operations.proto',
  instruments: 'instruments.proto',
} as const;

export const TBANK_ACCOUNT_TYPES = {
  brokerage: 'ACCOUNT_TYPE_TINKOFF',
  iis: 'ACCOUNT_TYPE_TINKOFF_IIS',
} as const;

export const TBANK_OPEN_ACCOUNT_STATUS = 'ACCOUNT_STATUS_OPEN';

export const TBANK_CACHE_KEYS = {
  accounts: 'tbank:accounts',
  portfolio: 'tbank:portfolio',
  positions: 'tbank:positions',
  operations: 'tbank:operations',
  instrument: 'tbank:instrument',
} as const;
  • Step 5: Add narrow proto interfaces

Create apps/backend/src/modules/tbank/types/tbank-proto.types.ts:

export type TBankTimestamp = {
  seconds?: number | string;
  nanos?: number;
};

export type TBankMoneyValue = {
  currency?: string;
  units?: number | string;
  nano?: number;
};

export type TBankQuotation = {
  units?: number | string;
  nano?: number;
};

export type TBankAccount = {
  id: string;
  type: string;
  name?: string;
  status: string;
  openedDate?: TBankTimestamp;
  closedDate?: TBankTimestamp;
  accessLevel?: string;
};

export type TBankAccountsResponse = {
  accounts?: TBankAccount[];
};

export type TBankPortfolioPosition = {
  figi?: string;
  instrumentType?: string;
  quantity?: TBankQuotation;
  averagePositionPrice?: TBankMoneyValue;
  expectedYield?: TBankQuotation;
  currentNkd?: TBankMoneyValue;
  currentPrice?: TBankMoneyValue;
  averagePositionPriceFifo?: TBankMoneyValue;
  blocked?: boolean;
  blockedLots?: TBankQuotation;
  positionUid?: string;
  instrumentUid?: string;
  expectedYieldFifo?: TBankQuotation;
  dailyYield?: TBankMoneyValue;
  ticker?: string;
  classCode?: string;
};

export type TBankPortfolioResponse = {
  accountId?: string;
  totalAmountShares?: TBankMoneyValue;
  totalAmountBonds?: TBankMoneyValue;
  totalAmountEtf?: TBankMoneyValue;
  totalAmountCurrencies?: TBankMoneyValue;
  totalAmountFutures?: TBankMoneyValue;
  expectedYield?: TBankQuotation;
  positions?: TBankPortfolioPosition[];
  totalAmountOptions?: TBankMoneyValue;
  totalAmountSp?: TBankMoneyValue;
  totalAmountPortfolio?: TBankMoneyValue;
  dailyYield?: TBankMoneyValue;
  dailyYieldRelative?: TBankQuotation;
  totalAmountDfa?: TBankMoneyValue;
};

export type TBankPositionsSecurity = {
  figi?: string;
  blocked?: string | number;
  balance?: string | number;
  positionUid?: string;
  instrumentUid?: string;
  ticker?: string;
  classCode?: string;
  exchangeBlocked?: boolean;
  instrumentType?: string;
};

export type TBankPositionsResponse = {
  accountId?: string;
  money?: TBankMoneyValue[];
  blocked?: TBankMoneyValue[];
  securities?: TBankPositionsSecurity[];
};

export type TBankOperationTrade = {
  num?: string;
  date?: TBankTimestamp;
  quantity?: string | number;
  price?: TBankMoneyValue;
  yield?: TBankMoneyValue;
  yieldRelative?: TBankQuotation;
};

export type TBankOperationItem = {
  cursor?: string;
  brokerAccountId?: string;
  id?: string;
  parentOperationId?: string;
  name?: string;
  date?: TBankTimestamp;
  type?: string;
  description?: string;
  state?: string;
  instrumentUid?: string;
  figi?: string;
  instrumentType?: string;
  instrumentKind?: string;
  positionUid?: string;
  ticker?: string;
  classCode?: string;
  payment?: TBankMoneyValue;
  price?: TBankMoneyValue;
  commission?: TBankMoneyValue;
  yield?: TBankMoneyValue;
  yieldRelative?: TBankQuotation;
  accruedInt?: TBankMoneyValue;
  quantity?: string | number;
  quantityRest?: string | number;
  quantityDone?: string | number;
  tradesInfo?: { trades?: TBankOperationTrade[] };
};

export type TBankOperationsByCursorResponse = {
  hasNext?: boolean;
  nextCursor?: string;
  items?: TBankOperationItem[];
};

export type TBankInstrument = {
  figi?: string;
  ticker?: string;
  classCode?: string;
  isin?: string;
  lot?: number;
  currency?: string;
  name?: string;
  exchange?: string;
  instrumentType?: string;
  uid?: string;
  positionUid?: string;
  assetUid?: string;
  instrumentKind?: string;
};

export type TBankInstrumentResponse = {
  instrument?: TBankInstrument;
};
  • Step 6: Add normalized broker domain types

Create apps/backend/src/modules/tbank/types/broker.types.ts:

export type BrokerMoney = {
  currency: string;
  units: string;
  nano: number;
  value: number;
};

export type BrokerAccount = {
  id: string;
  type: 'brokerage' | 'iis';
  name: string;
  status: string;
  openedAt: string | null;
  accessLevel: string | null;
};

export type BrokerPosition = {
  figi: string | null;
  instrumentUid: string | null;
  positionUid: string | null;
  ticker: string | null;
  classCode: string | null;
  instrumentType: string | null;
  name: string | null;
  quantity: number | null;
  blockedLots: number | null;
  currentPrice: BrokerMoney | null;
  currentValue: BrokerMoney | null;
  averagePositionPrice: BrokerMoney | null;
  expectedYieldPercent: number | null;
  dailyYield: BrokerMoney | null;
};

export type BrokerPortfolio = {
  account: BrokerAccount;
  totals: {
    shares: BrokerMoney | null;
    bonds: BrokerMoney | null;
    etf: BrokerMoney | null;
    currencies: BrokerMoney | null;
    futures: BrokerMoney | null;
    options: BrokerMoney | null;
    structuredProducts: BrokerMoney | null;
    dfa: BrokerMoney | null;
    portfolio: BrokerMoney | null;
  };
  yields: {
    expectedPercent: number | null;
    daily: BrokerMoney | null;
    dailyPercent: number | null;
  };
  cash: BrokerMoney[];
  blockedCash: BrokerMoney[];
  positions: BrokerPosition[];
  asOf: string;
};

export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';

export type BrokerOperation = {
  cursor: string | null;
  accountId: string;
  id: string | null;
  parentOperationId: string | null;
  date: string | null;
  type: string;
  category: BrokerOperationCategory;
  description: string | null;
  state: string | null;
  instrumentUid: string | null;
  figi: string | null;
  ticker: string | null;
  classCode: string | null;
  instrumentType: string | null;
  payment: BrokerMoney | null;
  price: BrokerMoney | null;
  commission: BrokerMoney | null;
  yield: BrokerMoney | null;
  accruedInt: BrokerMoney | null;
  quantity: number | null;
  quantityDone: number | null;
};

export type BrokerOperationsPage = {
  accountId: string;
  items: BrokerOperation[];
  nextCursor: string | null;
  hasNext: boolean;
  asOf: string;
};
  • Step 7: Run config test and commit

Run:

npx vitest run src/modules/tbank/tbank.config.spec.ts -w apps/backend

Expected: PASS.

Commit:

git add apps/backend/src/config/configuration.ts apps/backend/src/modules/tbank/tbank.config.ts apps/backend/src/modules/tbank/types apps/backend/src/modules/tbank/tbank.config.spec.ts
git commit -m "feat: configure tbank integration"

Task 3: Implement Money, Account, And Operation Mappers

Files:

  • Create: apps/backend/src/modules/tbank/mappers/money.mapper.ts

  • Create: apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts

  • Create: apps/backend/src/modules/tbank/mappers/account.mapper.ts

  • Create: apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts

  • Create: apps/backend/src/modules/tbank/mappers/operation.mapper.ts

  • Create: apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts

  • Step 1: Write money mapper tests

Create apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts:

import { mapMoneyValue, mapQuotationToNumber, mapTimestampToIso } from './money.mapper';

describe('money.mapper', () => {
  it('maps positive MoneyValue with nano precision', () => {
    expect(mapMoneyValue({ currency: 'rub', units: '123', nano: 450000000 })).toEqual({
      currency: 'RUB',
      units: '123',
      nano: 450000000,
      value: 123.45,
    });
  });

  it('maps negative MoneyValue with negative nano', () => {
    expect(mapMoneyValue({ currency: 'rub', units: '-5', nano: -250000000 })).toEqual({
      currency: 'RUB',
      units: '-5',
      nano: -250000000,
      value: -5.25,
    });
  });

  it('returns null for absent MoneyValue', () => {
    expect(mapMoneyValue(undefined)).toBeNull();
  });

  it('maps quotation to number', () => {
    expect(mapQuotationToNumber({ units: '12', nano: 345000000 })).toBe(12.345);
  });

  it('maps unix timestamp seconds to ISO string', () => {
    expect(mapTimestampToIso({ seconds: '1781577000', nanos: 0 })).toBe('2026-06-16T02:30:00.000Z');
  });
});
  • Step 2: Run failing money mapper test

Run:

npx vitest run src/modules/tbank/mappers/money.mapper.spec.ts -w apps/backend

Expected: FAIL because money.mapper.ts does not exist.

  • Step 3: Implement money mapper

Create apps/backend/src/modules/tbank/mappers/money.mapper.ts:

import type { BrokerMoney } from '../types/broker.types';
import type { TBankMoneyValue, TBankQuotation, TBankTimestamp } from '../types/tbank-proto.types';

const NANO_FACTOR = 1_000_000_000;

export function mapMoneyValue(value: TBankMoneyValue | null | undefined): BrokerMoney | null {
  if (!value) return null;
  const units = String(value.units ?? '0');
  const nano = value.nano ?? 0;
  const numericUnits = Number(units);
  const decimal = numericUnits + nano / NANO_FACTOR;

  return {
    currency: (value.currency || '').toUpperCase(),
    units,
    nano,
    value: Number(decimal.toFixed(9)),
  };
}

export function mapQuotationToNumber(value: TBankQuotation | null | undefined): number | null {
  if (!value) return null;
  const units = Number(value.units ?? 0);
  const nano = value.nano ?? 0;
  return Number((units + nano / NANO_FACTOR).toFixed(9));
}

export function mapTimestampToIso(value: TBankTimestamp | null | undefined): string | null {
  if (!value?.seconds) return null;
  const millis = Number(value.seconds) * 1000 + Math.floor((value.nanos ?? 0) / 1_000_000);
  return new Date(millis).toISOString();
}

export function mapInteger(value: string | number | null | undefined): number | null {
  if (value === null || value === undefined || value === '') return null;
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : null;
}
  • Step 4: Write account mapper tests

Create apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts:

import { isSupportedBrokerAccount, mapAccount } from './account.mapper';
import type { TBankAccount } from '../types/tbank-proto.types';

describe('account.mapper', () => {
  const baseAccount: TBankAccount = {
    id: '2000000001',
    type: 'ACCOUNT_TYPE_TINKOFF',
    name: 'Broker',
    status: 'ACCOUNT_STATUS_OPEN',
    openedDate: { seconds: '1781577000' },
    accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
  };

  it('accepts open brokerage and IIS accounts', () => {
    expect(isSupportedBrokerAccount(baseAccount)).toBe(true);
    expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_TINKOFF_IIS' })).toBe(
      true,
    );
  });

  it('rejects invest box, closed, and unspecified accounts', () => {
    expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_INVEST_BOX' })).toBe(
      false,
    );
    expect(isSupportedBrokerAccount({ ...baseAccount, status: 'ACCOUNT_STATUS_CLOSED' })).toBe(
      false,
    );
    expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_UNSPECIFIED' })).toBe(
      false,
    );
  });

  it('maps T-Bank account to broker account DTO', () => {
    expect(mapAccount(baseAccount)).toEqual({
      id: '2000000001',
      type: 'brokerage',
      name: 'Broker',
      status: 'ACCOUNT_STATUS_OPEN',
      openedAt: '2026-06-16T02:30:00.000Z',
      accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
    });
  });
});
  • Step 5: Implement account mapper

Create apps/backend/src/modules/tbank/mappers/account.mapper.ts:

import {
  TBANK_ACCOUNT_TYPES,
  TBANK_OPEN_ACCOUNT_STATUS,
} from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccount } from '../types/tbank-proto.types';
import { mapTimestampToIso } from './money.mapper';

export function isSupportedBrokerAccount(account: TBankAccount): boolean {
  return (
    account.status === TBANK_OPEN_ACCOUNT_STATUS &&
    (account.type === TBANK_ACCOUNT_TYPES.brokerage || account.type === TBANK_ACCOUNT_TYPES.iis)
  );
}

export function mapAccount(account: TBankAccount): BrokerAccount {
  return {
    id: account.id,
    type: account.type === TBANK_ACCOUNT_TYPES.iis ? 'iis' : 'brokerage',
    name: account.name || account.id,
    status: account.status,
    openedAt: mapTimestampToIso(account.openedDate),
    accessLevel: account.accessLevel ?? null,
  };
}
  • Step 6: Write operation mapper tests

Create apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts:

import { categorizeOperationType, mapOperation, mapOperationsPage } from './operation.mapper';

describe('operation.mapper', () => {
  it.each([
    ['OPERATION_TYPE_BUY', 'trade'],
    ['OPERATION_TYPE_SELL', 'trade'],
    ['OPERATION_TYPE_DIVIDEND', 'income'],
    ['OPERATION_TYPE_COUPON', 'income'],
    ['OPERATION_TYPE_TAX', 'tax'],
    ['OPERATION_TYPE_DIVIDEND_TAX', 'tax'],
    ['OPERATION_TYPE_BROKER_FEE', 'fee'],
    ['OPERATION_TYPE_SERVICE_FEE', 'fee'],
    ['OPERATION_TYPE_INPUT', 'transfer'],
    ['OPERATION_TYPE_OUTPUT', 'transfer'],
    ['OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', 'other'],
  ])('maps %s to %s', (type, category) => {
    expect(categorizeOperationType(type)).toBe(category);
  });

  it('maps operation item with money and quantities', () => {
    const result = mapOperation(
      {
        cursor: 'cursor-1',
        brokerAccountId: 'acc-1',
        id: 'op-1',
        date: { seconds: '1781577000' },
        type: 'OPERATION_TYPE_COUPON',
        description: 'Coupon',
        state: 'OPERATION_STATE_EXECUTED',
        ticker: 'SU26238RMFS5',
        classCode: 'TQOB',
        payment: { currency: 'rub', units: '100', nano: 0 },
        commission: { currency: 'rub', units: '0', nano: 0 },
        quantity: '5',
        quantityDone: '5',
      },
      'acc-1',
    );

    expect(result).toMatchObject({
      cursor: 'cursor-1',
      accountId: 'acc-1',
      id: 'op-1',
      category: 'income',
      ticker: 'SU26238RMFS5',
      quantity: 5,
      quantityDone: 5,
      payment: { currency: 'RUB', value: 100 },
    });
  });

  it('maps operations page cursor metadata', () => {
    const page = mapOperationsPage('acc-1', {
      hasNext: true,
      nextCursor: 'next',
      items: [{ cursor: 'cursor-1', type: 'OPERATION_TYPE_BUY' }],
    });

    expect(page.accountId).toBe('acc-1');
    expect(page.hasNext).toBe(true);
    expect(page.nextCursor).toBe('next');
    expect(page.items).toHaveLength(1);
  });
});
  • Step 7: Implement operation mapper

Create apps/backend/src/modules/tbank/mappers/operation.mapper.ts:

import type {
  BrokerOperation,
  BrokerOperationCategory,
  BrokerOperationsPage,
} from '../types/broker.types';
import type {
  TBankOperationItem,
  TBankOperationsByCursorResponse,
} from '../types/tbank-proto.types';
import { mapInteger, mapMoneyValue, mapTimestampToIso } from './money.mapper';

const TRADE_TYPES = new Set([
  'OPERATION_TYPE_BUY',
  'OPERATION_TYPE_BUY_CARD',
  'OPERATION_TYPE_SELL',
  'OPERATION_TYPE_SELL_CARD',
  'OPERATION_TYPE_BUY_MARGIN',
  'OPERATION_TYPE_SELL_MARGIN',
  'OPERATION_TYPE_DELIVERY_BUY',
  'OPERATION_TYPE_DELIVERY_SELL',
]);

const INCOME_TYPES = new Set([
  'OPERATION_TYPE_DIVIDEND',
  'OPERATION_TYPE_COUPON',
  'OPERATION_TYPE_BOND_REPAYMENT',
  'OPERATION_TYPE_BOND_REPAYMENT_FULL',
  'OPERATION_TYPE_OVERNIGHT',
  'OPERATION_TYPE_OVER_INCOME',
  'OPERATION_TYPE_ACCRUING_VARMARGIN',
  'OPERATION_TYPE_TAX_REPO_REFUND',
  'OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE',
  'OPERATION_TYPE_DIV_EXT',
  'OPERATION_TYPE_DFA_REDEMPTION',
]);

const TAX_TYPES = new Set([
  'OPERATION_TYPE_TAX',
  'OPERATION_TYPE_BOND_TAX',
  'OPERATION_TYPE_DIVIDEND_TAX',
  'OPERATION_TYPE_TAX_CORRECTION',
  'OPERATION_TYPE_BENEFIT_TAX',
  'OPERATION_TYPE_TAX_PROGRESSIVE',
  'OPERATION_TYPE_BOND_TAX_PROGRESSIVE',
  'OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE',
  'OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE',
  'OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE',
  'OPERATION_TYPE_TAX_REPO',
  'OPERATION_TYPE_TAX_REPO_PROGRESSIVE',
  'OPERATION_TYPE_TAX_REPO_HOLD',
  'OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE',
  'OPERATION_TYPE_TAX_CORRECTION_COUPON',
]);

const FEE_TYPES = new Set([
  'OPERATION_TYPE_SERVICE_FEE',
  'OPERATION_TYPE_MARGIN_FEE',
  'OPERATION_TYPE_BROKER_FEE',
  'OPERATION_TYPE_SUCCESS_FEE',
  'OPERATION_TYPE_TRACK_MFEE',
  'OPERATION_TYPE_TRACK_PFEE',
  'OPERATION_TYPE_CASH_FEE',
  'OPERATION_TYPE_OUT_FEE',
  'OPERATION_TYPE_OUT_STAMP_DUTY',
  'OPERATION_TYPE_OUTPUT_PENALTY',
  'OPERATION_TYPE_ADVICE_FEE',
  'OPERATION_TYPE_OVER_COM',
  'OPERATION_TYPE_OTHER_FEE',
  'OPERATION_TYPE_FUNDING',
]);

const TRANSFER_TYPES = new Set([
  'OPERATION_TYPE_INPUT',
  'OPERATION_TYPE_OUTPUT',
  'OPERATION_TYPE_INPUT_SECURITIES',
  'OPERATION_TYPE_OUTPUT_SECURITIES',
  'OPERATION_TYPE_OUTPUT_SWIFT',
  'OPERATION_TYPE_INPUT_SWIFT',
  'OPERATION_TYPE_OUTPUT_ACQUIRING',
  'OPERATION_TYPE_INPUT_ACQUIRING',
  'OPERATION_TYPE_TRANS_IIS_BS',
  'OPERATION_TYPE_TRANS_BS_BS',
  'OPERATION_TYPE_OUT_MULTI',
  'OPERATION_TYPE_INP_MULTI',
  'OPERATION_TYPE_OVER_PLACEMENT',
]);

export function categorizeOperationType(type: string | null | undefined): BrokerOperationCategory {
  if (!type) return 'other';
  if (TRADE_TYPES.has(type)) return 'trade';
  if (INCOME_TYPES.has(type)) return 'income';
  if (TAX_TYPES.has(type)) return 'tax';
  if (FEE_TYPES.has(type)) return 'fee';
  if (TRANSFER_TYPES.has(type)) return 'transfer';
  return 'other';
}

export function mapOperation(item: TBankOperationItem, accountId: string): BrokerOperation {
  const type = item.type || 'OPERATION_TYPE_UNSPECIFIED';

  return {
    cursor: item.cursor ?? null,
    accountId: item.brokerAccountId || accountId,
    id: item.id ?? null,
    parentOperationId: item.parentOperationId ?? null,
    date: mapTimestampToIso(item.date),
    type,
    category: categorizeOperationType(type),
    description: item.description || item.name || null,
    state: item.state ?? null,
    instrumentUid: item.instrumentUid ?? null,
    figi: item.figi ?? null,
    ticker: item.ticker ?? null,
    classCode: item.classCode ?? null,
    instrumentType: item.instrumentType ?? null,
    payment: mapMoneyValue(item.payment),
    price: mapMoneyValue(item.price),
    commission: mapMoneyValue(item.commission),
    yield: mapMoneyValue(item.yield),
    accruedInt: mapMoneyValue(item.accruedInt),
    quantity: mapInteger(item.quantity),
    quantityDone: mapInteger(item.quantityDone),
  };
}

export function mapOperationsPage(
  accountId: string,
  response: TBankOperationsByCursorResponse,
): BrokerOperationsPage {
  return {
    accountId,
    items: (response.items ?? []).map((item) => mapOperation(item, accountId)),
    nextCursor: response.nextCursor || null,
    hasNext: response.hasNext ?? false,
    asOf: new Date().toISOString(),
  };
}
  • Step 8: Run mapper tests and commit

Run:

npx vitest run "src/modules/tbank/mappers/*.spec.ts" -w apps/backend

Expected: PASS.

Commit:

git add apps/backend/src/modules/tbank/mappers
git commit -m "feat: add tbank domain mappers"

Task 4: Implement TBankClientService gRPC Transport

Files:

  • Create: apps/backend/src/modules/tbank/services/tbank-client.service.ts

  • Create: apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts

  • Create: apps/backend/src/modules/tbank/tbank.module.ts

  • Step 1: Write client service tests

Create apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts:

import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Metadata, status } from '@grpc/grpc-js';
import { TBankClientService } from './tbank-client.service';

describe('TBankClientService', () => {
  const config = {
    get: vi.fn((key: string, fallback?: unknown) => {
      const values: Record<string, unknown> = {
        'app.tbank.token': 'token-1',
        'app.tbank.appName': 'ksv741.moex-vibe',
        'app.tbank.rateLimitPerSecond': 5,
        'app.tbank.requestTimeoutMs': 10000,
      };
      return values[key] ?? fallback;
    }),
  } as unknown as ConfigService;

  it('builds redacted authorization metadata', () => {
    const service = new TBankClientService(config);
    const metadata = service.createMetadata();

    expect(metadata.get('Authorization')).toEqual(['Bearer token-1']);
    expect(metadata.get('x-app-name')).toEqual(['ksv741.moex-vibe']);
    expect(service.redactMetadata(metadata)).toEqual({
      Authorization: '<redacted>',
      'x-app-name': 'ksv741.moex-vibe',
    });
  });

  it('throws integration unavailable when token is missing', async () => {
    const missingConfig = {
      get: vi.fn((key: string, fallback?: unknown) =>
        key === 'app.tbank.token' ? '' : (fallback as unknown),
      ),
    } as unknown as ConfigService;
    const service = new TBankClientService(missingConfig);

    await expect(
      service.callUnary('UsersService/GetAccounts', (_request, _metadata, _options, callback) => {
        callback(null, {});
      }, {}),
    ).rejects.toThrow(ServiceUnavailableException);
  });

  it('wraps grpc errors with status code and tracking id', async () => {
    const service = new TBankClientService(config);
    const error = Object.assign(new Error('Too many requests'), {
      code: status.RESOURCE_EXHAUSTED,
      metadata: new Metadata(),
    });
    error.metadata.set('x-tracking-id', 'tracking-1');

    await expect(
      service.callUnary('OperationsService/GetPortfolio', (_request, _metadata, _options, cb) => {
        cb(error, null);
      }, {}),
    ).rejects.toMatchObject({
      response: expect.objectContaining({
        message: expect.stringContaining('T-Bank upstream error'),
      }),
    });
  });
});
  • Step 2: Run failing client service test

Run:

npx vitest run src/modules/tbank/services/tbank-client.service.spec.ts -w apps/backend

Expected: FAIL because TBankClientService does not exist.

  • Step 3: Implement TBankClientService

Create apps/backend/src/modules/tbank/services/tbank-client.service.ts:

import { Injectable, ServiceUnavailableException, BadGatewayException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  CallOptions,
  ChannelCredentials,
  Client,
  ClientUnaryCall,
  loadPackageDefinition,
  Metadata,
  ServiceError,
  status,
} from '@grpc/grpc-js';
import { loadSync } from '@grpc/proto-loader';
import PQueue from 'p-queue';
import { join } from 'path';
import {
  TBANK_PROTO_FILES,
  TBANK_PROTO_PACKAGE,
} from '../tbank.config';

type GrpcUnary<TRequest, TResponse> = (
  request: TRequest,
  metadata: Metadata,
  options: CallOptions,
  callback: (error: ServiceError | null, response: TResponse | null) => void,
) => ClientUnaryCall;

@Injectable()
export class TBankClientService {
  private readonly logger = new Logger(TBankClientService.name);
  private readonly queue: PQueue;
  private readonly requestTimeoutMs: number;
  private readonly packageDefinition: ReturnType<typeof loadPackageDefinition>;
  private readonly clientCache = new Map<string, Client>();

  constructor(private readonly configService: ConfigService) {
    this.requestTimeoutMs = this.configService.get<number>('app.tbank.requestTimeoutMs', 10000);
    this.queue = new PQueue({
      interval: 1000,
      intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
    });

    const protoRoot = join(__dirname, '..', 'proto', 'contracts');
    const definition = loadSync(Object.values(TBANK_PROTO_FILES), {
      includeDirs: [protoRoot],
      keepCase: false,
      longs: String,
      enums: String,
      defaults: true,
      oneofs: true,
    });
    this.packageDefinition = loadPackageDefinition(definition);
  }

  createMetadata(): Metadata {
    const token = this.configService.get<string>('app.tbank.token', '');
    if (!token) {
      throw new ServiceUnavailableException('T-Bank integration is not configured');
    }

    const metadata = new Metadata();
    metadata.set('Authorization', `Bearer ${token}`);
    const appName = this.configService.get<string>('app.tbank.appName', '');
    if (appName) metadata.set('x-app-name', appName);
    return metadata;
  }

  redactMetadata(metadata: Metadata): Record<string, string> {
    const result: Record<string, string> = {};
    for (const key of Object.keys(metadata.getMap())) {
      result[key] = key.toLowerCase() === 'authorization' ? '<redacted>' : String(metadata.get(key)[0]);
    }
    return result;
  }

  getServiceClient(serviceName: 'UsersService' | 'OperationsService' | 'InstrumentsService'): Client {
    const cached = this.clientCache.get(serviceName);
    if (cached) return cached;

    const pkg = this.packageDefinition as Record<string, unknown>;
    const namespace = TBANK_PROTO_PACKAGE.split('.').reduce<Record<string, unknown>>(
      (current, part) => current[part] as Record<string, unknown>,
      pkg,
    );
    const ServiceCtor = namespace[serviceName] as new (address: string, creds: ChannelCredentials) => Client;
    const client = new ServiceCtor(
      this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
      ChannelCredentials.createSsl(),
    );
    this.clientCache.set(serviceName, client);
    return client;
  }

  async callUnary<TRequest, TResponse>(
    label: string,
    method: GrpcUnary<TRequest, TResponse>,
    request: TRequest,
  ): Promise<TResponse> {
    const metadata = this.createMetadata();
    const deadline = new Date(Date.now() + this.requestTimeoutMs);

    return this.queue.add(
      () =>
        new Promise<TResponse>((resolve, reject) => {
          method(request, metadata, { deadline }, (error, response) => {
            if (error) {
              reject(this.mapGrpcError(label, error));
              return;
            }
            resolve(response as TResponse);
          });
        }),
    ) as Promise<TResponse>;
  }

  private mapGrpcError(label: string, error: ServiceError): Error {
    const trackingId = error.metadata?.get('x-tracking-id')?.[0];
    const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];
    const publicMessage =
      error.code === status.RESOURCE_EXHAUSTED
        ? 'T-Bank rate limit exceeded'
        : `T-Bank upstream error while calling ${label}`;

    this.logger.warn(
      JSON.stringify({
        label,
        code: error.code,
        trackingId,
        retryAfter,
        message: error.message,
      }),
    );

    return new BadGatewayException({
      message: publicMessage,
      trackingId: trackingId ? String(trackingId) : null,
      retryAfter: retryAfter ? String(retryAfter) : null,
    });
  }
}
  • Step 4: Create TBankModule

Create apps/backend/src/modules/tbank/tbank.module.ts:

import { Module } from '@nestjs/common';
import { TBankClientService } from './services/tbank-client.service';

@Module({
  providers: [TBankClientService],
  exports: [TBankClientService],
})
export class TBankModule {}
  • Step 5: Run client service tests and commit

Run:

npx vitest run src/modules/tbank/services/tbank-client.service.spec.ts -w apps/backend

Expected: PASS.

Commit:

git add apps/backend/src/modules/tbank/services/tbank-client.service.ts apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts apps/backend/src/modules/tbank/tbank.module.ts
git commit -m "feat: add tbank grpc client service"

Task 5: Implement Broker Accounts Endpoint

Files:

  • Create: apps/backend/src/modules/tbank/services/broker-accounts.service.ts

  • Create: apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts

  • Create: apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts

  • Create: apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts

  • Modify: apps/backend/src/modules/tbank/tbank.controller.ts

  • Modify: apps/backend/src/modules/tbank/tbank.module.ts

  • Modify: apps/backend/src/app.module.ts

  • Step 1: Write accounts service test

Create apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts:

import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
import { CacheService } from '../../cache/cache.service';

describe('BrokerAccountsService', () => {
  const client = {
    getServiceClient: vi.fn(),
    callUnary: vi.fn(),
  } as unknown as TBankClientService;
  const cache = {
    getOrFetch: vi.fn(),
  } as unknown as CacheService;

  beforeEach(() => vi.clearAllMocks());

  it('returns only open brokerage and IIS accounts from cache wrapper', async () => {
    vi.mocked(cache.getOrFetch).mockImplementation(
      async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
        data: await fetchFn(),
        fromCache: false,
        cachedAt: '2026-06-16T02:30:00.000Z',
      }),
    );
    vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
    vi.mocked(client.callUnary).mockResolvedValue({
      accounts: [
        { id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
        { id: '2', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN' },
        { id: '3', type: 'ACCOUNT_TYPE_INVEST_BOX', name: 'Box', status: 'ACCOUNT_STATUS_OPEN' },
        { id: '4', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Closed', status: 'ACCOUNT_STATUS_CLOSED' },
      ],
    });

    const service = new BrokerAccountsService(client, cache);
    const result = await service.findAll();

    expect(result.data).toHaveLength(2);
    expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
    expect(result.meta.fromCache).toBe(false);
    expect(cache.getOrFetch).toHaveBeenCalledWith(
      'tbank:accounts',
      ['open-brokerage-iis'],
      expect.any(Function),
      'tbankAccountsTtl',
    );
  });
});
  • Step 2: Run failing accounts service test

Run:

npx vitest run src/modules/tbank/services/broker-accounts.service.spec.ts -w apps/backend

Expected: FAIL because BrokerAccountsService does not exist.

  • Step 3: Implement accounts service

Create apps/backend/src/modules/tbank/services/broker-accounts.service.ts:

import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { isSupportedBrokerAccount, mapAccount } from '../mappers/account.mapper';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';

@Injectable()
export class BrokerAccountsService {
  constructor(
    private readonly tbankClient: TBankClientService,
    private readonly cacheService: CacheService,
  ) {}

  async findAll(): Promise<{
    data: BrokerAccount[];
    meta: { fromCache: boolean; cachedAt: string | null };
  }> {
    const result = await this.cacheService.getOrFetch(
      TBANK_CACHE_KEYS.accounts,
      ['open-brokerage-iis'],
      () => this.fetchAccounts(),
      'tbankAccountsTtl',
    );

    return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } };
  }

  async findById(accountId: string): Promise<BrokerAccount | null> {
    const accounts = await this.findAll();
    return accounts.data.find((account) => account.id === accountId) ?? null;
  }

  private async fetchAccounts(): Promise<BrokerAccount[]> {
    const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
    const response = await this.tbankClient.callUnary<Record<string, string>, TBankAccountsResponse>(
      'UsersService/GetAccounts',
      usersClient.getAccounts.bind(usersClient),
      { status: 'ACCOUNT_STATUS_OPEN' },
    );

    return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount);
  }
}
  • Step 4: Add account DTOs and envelope DTO

Create apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts:

import { ApiProperty } from '@nestjs/swagger';

export class BrokerAccountResponseDto {
  @ApiProperty()
  id!: string;

  @ApiProperty({ enum: ['brokerage', 'iis'] })
  type!: 'brokerage' | 'iis';

  @ApiProperty()
  name!: string;

  @ApiProperty()
  status!: string;

  @ApiProperty({ nullable: true })
  openedAt!: string | null;

  @ApiProperty({ nullable: true })
  accessLevel!: string | null;
}

Create apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts:

import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';

export class BrokerResponseMetaDto {
  @ApiProperty({ nullable: true })
  cachedAt!: string | null;

  @ApiProperty()
  fromCache!: boolean;
}

export class BrokerAccountsEnvelopeDto {
  @ApiProperty({ type: [BrokerAccountResponseDto] })
  data!: BrokerAccountResponseDto[];

  @ApiProperty({ type: BrokerResponseMetaDto })
  meta!: BrokerResponseMetaDto;
}
  • Step 5: Add controller and module wiring

Create apps/backend/src/modules/tbank/tbank.controller.ts:

import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAccountsEnvelopeDto } from './dto/broker-envelope.dto';

@ApiTags('Broker')
@ApiBearerAuth()
@Controller('broker')
export class TBankController {
  constructor(private readonly brokerAccountsService: BrokerAccountsService) {}

  @Get('accounts')
  @ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
  @ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
  async getAccounts() {
    return this.brokerAccountsService.findAll();
  }
}

Modify apps/backend/src/modules/tbank/tbank.module.ts:

import { Module } from '@nestjs/common';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { TBankClientService } from './services/tbank-client.service';

@Module({
  controllers: [TBankController],
  providers: [TBankClientService, BrokerAccountsService],
  exports: [TBankClientService, BrokerAccountsService],
})
export class TBankModule {}

Modify apps/backend/src/app.module.ts:

import { TBankModule } from './modules/tbank/tbank.module';

and add TBankModule after PortfolioModule in the imports array.

  • Step 6: Run accounts tests and backend build

Run:

npx vitest run src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/mappers/account.mapper.spec.ts -w apps/backend
npm run build:backend

Expected: tests PASS and backend build exits 0.

  • Step 7: Commit accounts endpoint
git add apps/backend/src/app.module.ts apps/backend/src/modules/tbank
git commit -m "feat: expose tbank broker accounts"

Task 6: Implement Broker Portfolio Endpoint

Files:

  • Create: apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts

  • Create: apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts

  • Create: apps/backend/src/modules/tbank/services/broker-instruments.service.ts

  • Create: apps/backend/src/modules/tbank/services/broker-portfolio.service.ts

  • Create: apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts

  • Create: apps/backend/src/modules/tbank/dto/broker-money.dto.ts

  • Create: apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts

  • Modify: apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts

  • Modify: apps/backend/src/modules/tbank/tbank.controller.ts

  • Modify: apps/backend/src/modules/tbank/tbank.module.ts

  • Step 1: Write portfolio mapper tests

Create apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts:

import { mapBrokerPortfolio } from './portfolio.mapper';
import type { BrokerAccount } from '../types/broker.types';

describe('portfolio.mapper', () => {
  const account: BrokerAccount = {
    id: 'acc-1',
    type: 'brokerage',
    name: 'Broker',
    status: 'ACCOUNT_STATUS_OPEN',
    openedAt: null,
    accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
  };

  it('combines portfolio totals, cash, and enriched positions', () => {
    const result = mapBrokerPortfolio({
      account,
      portfolio: {
        accountId: 'acc-1',
        totalAmountShares: { currency: 'rub', units: '1000', nano: 0 },
        totalAmountPortfolio: { currency: 'rub', units: '1500', nano: 0 },
        expectedYield: { units: '10', nano: 500000000 },
        positions: [
          {
            figi: 'BBG004730N88',
            instrumentUid: 'uid-1',
            ticker: 'SBER',
            classCode: 'TQBR',
            instrumentType: 'share',
            quantity: { units: '10', nano: 0 },
            currentPrice: { currency: 'rub', units: '250', nano: 0 },
            averagePositionPrice: { currency: 'rub', units: '200', nano: 0 },
          },
        ],
      },
      positions: {
        money: [{ currency: 'rub', units: '500', nano: 0 }],
        blocked: [{ currency: 'rub', units: '10', nano: 0 }],
        securities: [],
      },
      instruments: new Map([['uid-1', { name: 'Sberbank', ticker: 'SBER' }]]),
    });

    expect(result.account.id).toBe('acc-1');
    expect(result.totals.shares?.value).toBe(1000);
    expect(result.cash[0].value).toBe(500);
    expect(result.blockedCash[0].value).toBe(10);
    expect(result.positions[0]).toMatchObject({
      ticker: 'SBER',
      name: 'Sberbank',
      quantity: 10,
      currentValue: { value: 2500 },
    });
  });
});
  • Step 2: Implement portfolio mapper

Create apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts:

import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../types/broker.types';
import type {
  TBankInstrument,
  TBankPortfolioResponse,
  TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { mapMoneyValue, mapQuotationToNumber } from './money.mapper';

type MapBrokerPortfolioInput = {
  account: BrokerAccount;
  portfolio: TBankPortfolioResponse;
  positions: TBankPositionsResponse;
  instruments: Map<string, Partial<TBankInstrument>>;
};

export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
  const mappedPositions = (input.portfolio.positions ?? []).map<BrokerPosition>((position) => {
    const quantity = mapQuotationToNumber(position.quantity);
    const currentPrice = mapMoneyValue(position.currentPrice);
    const currentValue =
      currentPrice && quantity !== null
        ? {
            ...currentPrice,
            units: String(Math.trunc(currentPrice.value * quantity)),
            nano: 0,
            value: Number((currentPrice.value * quantity).toFixed(9)),
          }
        : null;
    const instrument =
      (position.instrumentUid && input.instruments.get(position.instrumentUid)) ||
      (position.positionUid && input.instruments.get(position.positionUid)) ||
      undefined;

    return {
      figi: position.figi ?? null,
      instrumentUid: position.instrumentUid ?? null,
      positionUid: position.positionUid ?? null,
      ticker: position.ticker || instrument?.ticker || null,
      classCode: position.classCode || instrument?.classCode || null,
      instrumentType: position.instrumentType || instrument?.instrumentType || null,
      name: instrument?.name ?? null,
      quantity,
      blockedLots: mapQuotationToNumber(position.blockedLots),
      currentPrice,
      currentValue,
      averagePositionPrice: mapMoneyValue(position.averagePositionPrice),
      expectedYieldPercent: mapQuotationToNumber(position.expectedYield),
      dailyYield: mapMoneyValue(position.dailyYield),
    };
  });

  return {
    account: input.account,
    totals: {
      shares: mapMoneyValue(input.portfolio.totalAmountShares),
      bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
      etf: mapMoneyValue(input.portfolio.totalAmountEtf),
      currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies),
      futures: mapMoneyValue(input.portfolio.totalAmountFutures),
      options: mapMoneyValue(input.portfolio.totalAmountOptions),
      structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp),
      dfa: mapMoneyValue(input.portfolio.totalAmountDfa),
      portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio),
    },
    yields: {
      expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield),
      daily: mapMoneyValue(input.portfolio.dailyYield),
      dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative),
    },
    cash: (input.positions.money ?? []).map(mapMoneyValue).filter((value) => value !== null),
    blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter((value) => value !== null),
    positions: mappedPositions,
    asOf: new Date().toISOString(),
  };
}
  • Step 3: Write portfolio service test

Create apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts:

import { NotFoundException } from '@nestjs/common';
import { BrokerPortfolioService } from './broker-portfolio.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';
import { CacheService } from '../../cache/cache.service';

describe('BrokerPortfolioService', () => {
  const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
  const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
  const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
  const cache = { getOrFetch: vi.fn() } as unknown as CacheService;

  beforeEach(() => vi.clearAllMocks());

  it('throws 404 for excluded or missing account', async () => {
    vi.mocked(accounts.findById).mockResolvedValue(null);
    const service = new BrokerPortfolioService(accounts, instruments, client, cache);

    await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
  });

  it('fetches portfolio and positions through cache', async () => {
    vi.mocked(accounts.findById).mockResolvedValue({
      id: 'acc-1',
      type: 'brokerage',
      name: 'Broker',
      status: 'ACCOUNT_STATUS_OPEN',
      openedAt: null,
      accessLevel: null,
    });
    vi.mocked(cache.getOrFetch).mockImplementation(
      async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
        data: await fetchFn(),
        fromCache: false,
        cachedAt: null,
      }),
    );
    vi.mocked(client.getServiceClient).mockReturnValue({
      getPortfolio: vi.fn(),
      getPositions: vi.fn(),
    } as any);
    vi.mocked(client.callUnary)
      .mockResolvedValueOnce({
        accountId: 'acc-1',
        totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
        positions: [],
      })
      .mockResolvedValueOnce({
        accountId: 'acc-1',
        money: [{ currency: 'rub', units: '1000', nano: 0 }],
        blocked: [],
        securities: [],
      });

    const service = new BrokerPortfolioService(accounts, instruments, client, cache);
    const result = await service.getPortfolio('acc-1');

    expect(result.data.account.id).toBe('acc-1');
    expect(result.data.cash[0].value).toBe(1000);
    expect(cache.getOrFetch).toHaveBeenCalledWith(
      'tbank:portfolio',
      ['acc-1'],
      expect.any(Function),
      'tbankPortfolioTtl',
    );
  });
});
  • Step 4: Implement instrument and portfolio services

Create apps/backend/src/modules/tbank/services/broker-instruments.service.ts:

import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { TBankInstrument, TBankInstrumentResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';

@Injectable()
export class BrokerInstrumentsService {
  constructor(
    private readonly tbankClient: TBankClientService,
    private readonly cacheService: CacheService,
  ) {}

  async findByInstrumentUid(instrumentUid: string): Promise<TBankInstrument | null> {
    const result = await this.cacheService.getOrFetch(
      TBANK_CACHE_KEYS.instrument,
      [instrumentUid],
      () => this.fetchByUid(instrumentUid),
      'tbankInstrumentTtl',
    );
    return result.data;
  }

  private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
    const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
    const response = await this.tbankClient.callUnary<
      { idType: string; id: string },
      TBankInstrumentResponse
    >(
      'InstrumentsService/GetInstrumentBy',
      instrumentsClient.getInstrumentBy.bind(instrumentsClient),
      { idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid },
    );
    return response.instrument ?? null;
  }
}

Create apps/backend/src/modules/tbank/services/broker-portfolio.service.ts:

import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapBrokerPortfolio } from '../mappers/portfolio.mapper';
import type { BrokerPortfolio } from '../types/broker.types';
import type {
  TBankInstrument,
  TBankPortfolioResponse,
  TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';

@Injectable()
export class BrokerPortfolioService {
  constructor(
    private readonly accountsService: BrokerAccountsService,
    private readonly instrumentsService: BrokerInstrumentsService,
    private readonly tbankClient: TBankClientService,
    private readonly cacheService: CacheService,
  ) {}

  async getPortfolio(accountId: string): Promise<{
    data: BrokerPortfolio;
    meta: { fromCache: boolean; cachedAt: string | null };
  }> {
    const account = await this.accountsService.findById(accountId);
    if (!account) throw new NotFoundException('Broker account not found');

    const result = await this.cacheService.getOrFetch(
      TBANK_CACHE_KEYS.portfolio,
      [accountId],
      async () => {
        const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
        const [portfolio, positions] = await Promise.all([
          this.tbankClient.callUnary<{ accountId: string; currency: string }, TBankPortfolioResponse>(
            'OperationsService/GetPortfolio',
            operationsClient.getPortfolio.bind(operationsClient),
            { accountId, currency: 'RUB' },
          ),
          this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
            'OperationsService/GetPositions',
            operationsClient.getPositions.bind(operationsClient),
            { accountId },
          ),
        ]);

        const instrumentMap = await this.buildInstrumentMap(portfolio);
        return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap });
      },
      'tbankPortfolioTtl',
    );

    return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } };
  }

  private async buildInstrumentMap(
    portfolio: TBankPortfolioResponse,
  ): Promise<Map<string, Partial<TBankInstrument>>> {
    const ids = Array.from(
      new Set((portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean)),
    ) as string[];
    const entries = await Promise.all(
      ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
    );
    return new Map(entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null));
  }
}
  • Step 5: Add portfolio DTOs and controller route

Create apps/backend/src/modules/tbank/dto/broker-money.dto.ts:

import { ApiProperty } from '@nestjs/swagger';

export class BrokerMoneyDto {
  @ApiProperty()
  currency!: string;

  @ApiProperty()
  units!: string;

  @ApiProperty()
  nano!: number;

  @ApiProperty()
  value!: number;
}

Create apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts:

import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerMoneyDto } from './broker-money.dto';

export class BrokerPositionResponseDto {
  @ApiProperty({ nullable: true })
  figi!: string | null;

  @ApiProperty({ nullable: true })
  instrumentUid!: string | null;

  @ApiProperty({ nullable: true })
  positionUid!: string | null;

  @ApiProperty({ nullable: true })
  ticker!: string | null;

  @ApiProperty({ nullable: true })
  classCode!: string | null;

  @ApiProperty({ nullable: true })
  instrumentType!: string | null;

  @ApiProperty({ nullable: true })
  name!: string | null;

  @ApiProperty({ nullable: true })
  quantity!: number | null;

  @ApiProperty({ nullable: true })
  blockedLots!: number | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  currentPrice!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  currentValue!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  averagePositionPrice!: BrokerMoneyDto | null;

  @ApiProperty({ nullable: true })
  expectedYieldPercent!: number | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  dailyYield!: BrokerMoneyDto | null;
}

export class BrokerPortfolioTotalsDto {
  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  shares!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  bonds!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  etf!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  currencies!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  futures!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  options!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  structuredProducts!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  dfa!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  portfolio!: BrokerMoneyDto | null;
}

export class BrokerPortfolioYieldsDto {
  @ApiProperty({ nullable: true })
  expectedPercent!: number | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  daily!: BrokerMoneyDto | null;

  @ApiProperty({ nullable: true })
  dailyPercent!: number | null;
}

export class BrokerPortfolioResponseDto {
  @ApiProperty({ type: BrokerAccountResponseDto })
  account!: BrokerAccountResponseDto;

  @ApiProperty({ type: BrokerPortfolioTotalsDto })
  totals!: BrokerPortfolioTotalsDto;

  @ApiProperty({ type: BrokerPortfolioYieldsDto })
  yields!: BrokerPortfolioYieldsDto;

  @ApiProperty({ type: [BrokerMoneyDto] })
  cash!: BrokerMoneyDto[];

  @ApiProperty({ type: [BrokerMoneyDto] })
  blockedCash!: BrokerMoneyDto[];

  @ApiProperty({ type: [BrokerPositionResponseDto] })
  positions!: BrokerPositionResponseDto[];

  @ApiProperty()
  asOf!: string;
}

Modify apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts to add:

import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';

export class BrokerPortfolioEnvelopeDto {
  @ApiProperty({ type: BrokerPortfolioResponseDto })
  data!: BrokerPortfolioResponseDto;

  @ApiProperty({ type: BrokerResponseMetaDto })
  meta!: BrokerResponseMetaDto;
}

Modify apps/backend/src/modules/tbank/tbank.controller.ts:

import { Param } from '@nestjs/common';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { BrokerPortfolioEnvelopeDto } from './dto/broker-envelope.dto';

Inject BrokerPortfolioService in the constructor and add:

@Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) {
  return this.brokerPortfolioService.getPortfolio(accountId);
}

Update TBankModule providers and exports to include BrokerInstrumentsService and BrokerPortfolioService.

  • Step 6: Run portfolio tests and commit

Run:

npx vitest run src/modules/tbank/mappers/portfolio.mapper.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts -w apps/backend
npm run build:backend

Expected: tests PASS and backend build exits 0.

Commit:

git add apps/backend/src/modules/tbank
git commit -m "feat: expose tbank broker portfolio"

Task 7: Implement Broker Operations Endpoint

Files:

  • Create: apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts

  • Create: apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts

  • Create: apps/backend/src/modules/tbank/services/broker-operations.service.ts

  • Create: apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts

  • Modify: apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts

  • Modify: apps/backend/src/modules/tbank/tbank.controller.ts

  • Modify: apps/backend/src/modules/tbank/tbank.module.ts

  • Step 1: Write operations service tests

Create apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts:

import { NotFoundException } from '@nestjs/common';
import { BrokerOperationsService } from './broker-operations.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
import { CacheService } from '../../cache/cache.service';

describe('BrokerOperationsService', () => {
  const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
  const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
  const cache = { getOrFetch: vi.fn() } as unknown as CacheService;

  beforeEach(() => vi.clearAllMocks());

  it('throws 404 for excluded or missing account', async () => {
    vi.mocked(accounts.findById).mockResolvedValue(null);
    const service = new BrokerOperationsService(accounts, client, cache);

    await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException);
  });

  it('builds cursor request and maps operation page', async () => {
    vi.mocked(accounts.findById).mockResolvedValue({
      id: 'acc-1',
      type: 'brokerage',
      name: 'Broker',
      status: 'ACCOUNT_STATUS_OPEN',
      openedAt: null,
      accessLevel: null,
    });
    vi.mocked(cache.getOrFetch).mockImplementation(
      async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
        data: await fetchFn(),
        fromCache: false,
        cachedAt: null,
      }),
    );
    vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
    vi.mocked(client.callUnary).mockResolvedValue({
      hasNext: false,
      items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
    });

    const service = new BrokerOperationsService(accounts, client, cache);
    const result = await service.getOperations('acc-1', {
      from: '2026-01-01T00:00:00.000Z',
      to: '2026-06-16T00:00:00.000Z',
      limit: 1000,
      state: 'OPERATION_STATE_EXECUTED',
    });

    expect(result.data.items[0].category).toBe('trade');
    expect(client.callUnary).toHaveBeenCalledWith(
      'OperationsService/GetOperationsByCursor',
      expect.any(Function),
      expect.objectContaining({
        accountId: 'acc-1',
        limit: 1000,
        state: 'OPERATION_STATE_EXECUTED',
      }),
    );
  });
});
  • Step 2: Add query DTO

Create apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts:

import { Transform } from 'class-transformer';
import { IsDateString, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';

export class BrokerOperationQueryDto {
  @ApiPropertyOptional()
  @IsOptional()
  @IsDateString()
  from?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsDateString()
  to?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  cursor?: string;

  @ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
  @IsOptional()
  @Transform(({ value }) => (value === undefined ? undefined : Number(value)))
  @IsInt()
  @Min(1)
  @Max(1000)
  limit?: number;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  instrumentId?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  operationTypes?: string;

  @ApiPropertyOptional({ default: 'OPERATION_STATE_EXECUTED' })
  @IsOptional()
  @IsString()
  state?: string;
}
  • Step 3: Implement operations service

Create apps/backend/src/modules/tbank/services/broker-operations.service.ts:

import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapOperationsPage } from '../mappers/operation.mapper';
import type { BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';

@Injectable()
export class BrokerOperationsService {
  constructor(
    private readonly accountsService: BrokerAccountsService,
    private readonly tbankClient: TBankClientService,
    private readonly cacheService: CacheService,
  ) {}

  async getOperations(
    accountId: string,
    query: BrokerOperationQueryDto,
  ): Promise<{ data: BrokerOperationsPage; meta: { fromCache: boolean; cachedAt: string | null } }> {
    const account = await this.accountsService.findById(accountId);
    if (!account) throw new NotFoundException('Broker account not found');

    const request = this.buildRequest(accountId, query);
    const cacheParts = [accountId, JSON.stringify(request)];
    const result = await this.cacheService.getOrFetch(
      TBANK_CACHE_KEYS.operations,
      cacheParts,
      () => this.fetchOperations(accountId, request),
      'tbankOperationsTtl',
    );

    return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } };
  }

  private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
    const now = new Date();
    const startOfYear = new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
    const operationTypes = query.operationTypes
      ? query.operationTypes.split(',').map((value) => value.trim()).filter(Boolean)
      : undefined;

    return {
      accountId,
      instrumentId: query.instrumentId,
      from: { seconds: Math.floor(new Date(query.from ?? startOfYear.toISOString()).getTime() / 1000) },
      to: { seconds: Math.floor(new Date(query.to ?? now.toISOString()).getTime() / 1000) },
      cursor: query.cursor,
      limit: query.limit ?? 100,
      operationTypes,
      state: query.state ?? 'OPERATION_STATE_EXECUTED',
      withoutCommissions: false,
      withoutTrades: false,
      withoutOvernights: false,
    };
  }

  private async fetchOperations(
    accountId: string,
    request: Record<string, unknown>,
  ): Promise<BrokerOperationsPage> {
    const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
    const response = await this.tbankClient.callUnary<
      Record<string, unknown>,
      TBankOperationsByCursorResponse
    >(
      'OperationsService/GetOperationsByCursor',
      operationsClient.getOperationsByCursor.bind(operationsClient),
      request,
    );
    return mapOperationsPage(accountId, response);
  }
}
  • Step 4: Add operation DTOs and controller route

Create apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts:

import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';

const operationCategories = ['trade', 'income', 'tax', 'fee', 'transfer', 'other'] as const;

export class BrokerOperationResponseDto {
  @ApiProperty({ nullable: true })
  cursor!: string | null;

  @ApiProperty()
  accountId!: string;

  @ApiProperty({ nullable: true })
  id!: string | null;

  @ApiProperty({ nullable: true })
  parentOperationId!: string | null;

  @ApiProperty({ nullable: true })
  date!: string | null;

  @ApiProperty()
  type!: string;

  @ApiProperty({ enum: operationCategories })
  category!: (typeof operationCategories)[number];

  @ApiProperty({ nullable: true })
  description!: string | null;

  @ApiProperty({ nullable: true })
  state!: string | null;

  @ApiProperty({ nullable: true })
  instrumentUid!: string | null;

  @ApiProperty({ nullable: true })
  figi!: string | null;

  @ApiProperty({ nullable: true })
  ticker!: string | null;

  @ApiProperty({ nullable: true })
  classCode!: string | null;

  @ApiProperty({ nullable: true })
  instrumentType!: string | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  payment!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  price!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  commission!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  yield!: BrokerMoneyDto | null;

  @ApiProperty({ type: BrokerMoneyDto, nullable: true })
  accruedInt!: BrokerMoneyDto | null;

  @ApiProperty({ nullable: true })
  quantity!: number | null;

  @ApiProperty({ nullable: true })
  quantityDone!: number | null;
}

export class BrokerOperationsPageResponseDto {
  @ApiProperty()
  accountId!: string;

  @ApiProperty({ type: [BrokerOperationResponseDto] })
  items!: BrokerOperationResponseDto[];

  @ApiProperty({ nullable: true })
  nextCursor!: string | null;

  @ApiProperty()
  hasNext!: boolean;

  @ApiProperty()
  asOf!: string;
}

Modify apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts:

import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';

export class BrokerOperationsEnvelopeDto {
  @ApiProperty({ type: BrokerOperationsPageResponseDto })
  data!: BrokerOperationsPageResponseDto;

  @ApiProperty({ type: BrokerResponseMetaDto })
  meta!: BrokerResponseMetaDto;
}

Modify apps/backend/src/modules/tbank/tbank.controller.ts:

import { Query } from '@nestjs/common';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerOperationsEnvelopeDto } from './dto/broker-envelope.dto';

Inject BrokerOperationsService and add:

@Get('accounts/:accountId/operations')
@ApiOperation({ summary: 'Get paginated T-Bank broker account operations' })
@ApiOkResponse({ type: BrokerOperationsEnvelopeDto })
async getOperations(
  @Param('accountId') accountId: string,
  @Query() query: BrokerOperationQueryDto,
) {
  return this.brokerOperationsService.getOperations(accountId, query);
}

Update TBankModule providers and exports to include BrokerOperationsService.

  • Step 5: Run operations tests and commit

Run:

npx vitest run src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/mappers/operation.mapper.spec.ts -w apps/backend
npm run build:backend

Expected: tests PASS and backend build exits 0.

Commit:

git add apps/backend/src/modules/tbank
git commit -m "feat: expose tbank broker operations"

Task 8: Generate Frontend Types And Add Broker API Hooks

Files:

  • Modify: apps/frontend/src/api/responses.ts

  • Create: apps/frontend/src/api/broker.ts

  • Create: apps/frontend/src/api/broker.test.ts

  • Create: apps/frontend/src/hooks/useBrokerAccounts.ts

  • Create: apps/frontend/src/hooks/useBrokerPortfolio.ts

  • Create: apps/frontend/src/hooks/useBrokerOperations.ts

  • Create: apps/frontend/src/hooks/useBrokerAccounts.test.tsx

  • Step 1: Regenerate OpenAPI types after backend endpoints exist

Start backend in a separate shell when it is not already running:

PORT=3001 npm run dev:backend

Then run:

npm run codegen -w apps/frontend

Expected: apps/frontend/src/api/types.ts includes /api/v1/broker/accounts, /api/v1/broker/accounts/{accountId}/portfolio, and /api/v1/broker/accounts/{accountId}/operations.

  • Step 2: Add frontend response interfaces

Append to apps/frontend/src/api/responses.ts:

export interface BrokerMoney {
  currency: string;
  units: string;
  nano: number;
  value: number;
}

export interface BrokerAccount {
  id: string;
  type: 'brokerage' | 'iis';
  name: string;
  status: string;
  openedAt: string | null;
  accessLevel: string | null;
}

export interface BrokerPosition {
  figi: string | null;
  instrumentUid: string | null;
  positionUid: string | null;
  ticker: string | null;
  classCode: string | null;
  instrumentType: string | null;
  name: string | null;
  quantity: number | null;
  blockedLots: number | null;
  currentPrice: BrokerMoney | null;
  currentValue: BrokerMoney | null;
  averagePositionPrice: BrokerMoney | null;
  expectedYieldPercent: number | null;
  dailyYield: BrokerMoney | null;
}

export interface BrokerPortfolio {
  account: BrokerAccount;
  totals: {
    shares: BrokerMoney | null;
    bonds: BrokerMoney | null;
    etf: BrokerMoney | null;
    currencies: BrokerMoney | null;
    futures: BrokerMoney | null;
    options: BrokerMoney | null;
    structuredProducts: BrokerMoney | null;
    dfa: BrokerMoney | null;
    portfolio: BrokerMoney | null;
  };
  yields: {
    expectedPercent: number | null;
    daily: BrokerMoney | null;
    dailyPercent: number | null;
  };
  cash: BrokerMoney[];
  blockedCash: BrokerMoney[];
  positions: BrokerPosition[];
  asOf: string;
}

export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';

export interface BrokerOperation {
  cursor: string | null;
  accountId: string;
  id: string | null;
  parentOperationId: string | null;
  date: string | null;
  type: string;
  category: BrokerOperationCategory;
  description: string | null;
  state: string | null;
  instrumentUid: string | null;
  figi: string | null;
  ticker: string | null;
  classCode: string | null;
  instrumentType: string | null;
  payment: BrokerMoney | null;
  price: BrokerMoney | null;
  commission: BrokerMoney | null;
  yield: BrokerMoney | null;
  accruedInt: BrokerMoney | null;
  quantity: number | null;
  quantityDone: number | null;
}

export interface BrokerOperationsPage {
  accountId: string;
  items: BrokerOperation[];
  nextCursor: string | null;
  hasNext: boolean;
  asOf: string;
}
  • Step 3: Add broker API client

Create apps/frontend/src/api/broker.ts:

import { request } from './client';
import type {
  ApiResponseMeta,
  BrokerAccount,
  BrokerOperation,
  BrokerOperationsPage,
  BrokerPortfolio,
} from './responses';

export type BrokerOperationQuery = {
  from?: string;
  to?: string;
  cursor?: string;
  limit?: number;
  instrumentId?: string;
  operationTypes?: string;
  state?: string;
};

export function getBrokerAccounts(): Promise<{
  data: BrokerAccount[];
  meta: ApiResponseMeta;
}> {
  return request<BrokerAccount[]>('/api/v1/broker/accounts');
}

export function getBrokerPortfolio(accountId: string): Promise<{
  data: BrokerPortfolio;
  meta: ApiResponseMeta;
}> {
  return request<BrokerPortfolio>(`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`);
}

export function getBrokerOperations(
  accountId: string,
  query: BrokerOperationQuery = {},
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
  return request<BrokerOperationsPage>(
    `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
    {
      from: query.from,
      to: query.to,
      cursor: query.cursor,
      limit: query.limit ? String(query.limit) : undefined,
      instrumentId: query.instrumentId,
      operationTypes: query.operationTypes,
      state: query.state,
    },
  );
}
  • Step 4: Add API client test

Create apps/frontend/src/api/broker.test.ts:

import { afterEach, describe, expect, it, vi } from 'vitest';
import { getBrokerOperations } from './broker';

describe('broker api', () => {
  afterEach(() => vi.restoreAllMocks());

  it('serializes operations query parameters', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue({
      ok: true,
      json: async () => ({
        data: {
          data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
          meta: { fromCache: false, cachedAt: null },
        },
      }),
    } as Response);

    await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });

    expect(fetch).toHaveBeenCalledWith(
      expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
      expect.any(Object),
    );
  });
});
  • Step 5: Add broker hooks

Create apps/frontend/src/hooks/useBrokerAccounts.ts:

import { useQuery } from '@tanstack/react-query';
import { getBrokerAccounts } from '../api/broker';
import type { BrokerAccount } from '../api/responses';

export function useBrokerAccounts() {
  return useQuery<BrokerAccount[]>({
    queryKey: ['broker', 'accounts'],
    queryFn: async () => (await getBrokerAccounts()).data,
    staleTime: 3_600_000,
    retry: 2,
    refetchOnWindowFocus: false,
  });
}

Create apps/frontend/src/hooks/useBrokerPortfolio.ts:

import { useQuery } from '@tanstack/react-query';
import { getBrokerPortfolio } from '../api/broker';
import type { BrokerPortfolio } from '../api/responses';

export function useBrokerPortfolio(accountId: string | undefined) {
  return useQuery<BrokerPortfolio>({
    queryKey: ['broker', 'portfolio', accountId],
    enabled: Boolean(accountId),
    queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
    staleTime: 60_000,
    retry: 2,
    refetchOnWindowFocus: false,
  });
}

Create apps/frontend/src/hooks/useBrokerOperations.ts:

import { useQuery } from '@tanstack/react-query';
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
import type { BrokerOperationsPage } from '../api/responses';

export function useBrokerOperations(accountId: string | undefined, query: BrokerOperationQuery = {}) {
  return useQuery<BrokerOperationsPage>({
    queryKey: ['broker', 'operations', accountId, query],
    enabled: Boolean(accountId),
    queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
    staleTime: 300_000,
    retry: 2,
    refetchOnWindowFocus: false,
  });
}
  • Step 6: Add hook smoke test

Create apps/frontend/src/hooks/useBrokerAccounts.test.tsx:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { getBrokerAccounts } from '../api/broker';
import { useBrokerAccounts } from './useBrokerAccounts';

vi.mock('../api/broker', () => ({
  getBrokerAccounts: vi.fn(),
}));

function wrapper({ children }: { children: React.ReactNode }) {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

describe('useBrokerAccounts', () => {
  it('returns broker accounts from API', async () => {
    vi.mocked(getBrokerAccounts).mockResolvedValue({
      data: [
        {
          id: 'acc-1',
          type: 'brokerage',
          name: 'Broker',
          status: 'ACCOUNT_STATUS_OPEN',
          openedAt: null,
          accessLevel: null,
        },
      ],
      meta: { fromCache: false, cachedAt: null },
    });

    const { result } = renderHook(() => useBrokerAccounts(), { wrapper });

    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data?.[0].name).toBe('Broker');
  });
});
  • Step 7: Run frontend API/hook tests and commit

Run:

npx vitest run src/api/broker.test.ts src/hooks/useBrokerAccounts.test.tsx -w apps/frontend
npm run build:frontend

Expected: tests PASS and frontend build exits 0.

Commit:

git add apps/frontend/src/api apps/frontend/src/hooks
git commit -m "feat: add broker frontend api hooks"

Task 9: Build Broker Portfolio UI

Files:

  • Create: apps/frontend/src/pages/broker/BrokerAccountsPage.tsx

  • Create: apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx

  • Create: apps/frontend/src/pages/broker/BrokerPages.test.tsx

  • Modify: apps/frontend/src/routes.tsx

  • Modify: apps/frontend/src/components/Layout.tsx

  • Modify: apps/frontend/src/styles.css: expected to remain unchanged unless browser verification shows a concrete layout defect.

  • Step 1: Add UI tests for broker pages

Create apps/frontend/src/pages/broker/BrokerPages.test.tsx:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import * as accountHook from '../../hooks/useBrokerAccounts';
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import * as operationsHook from '../../hooks/useBrokerOperations';
import { BrokerAccountsPage } from './BrokerAccountsPage';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';

function renderWithClient(ui: React.ReactElement, initialEntries = ['/broker']) {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <QueryClientProvider client={client}>
      <MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
    </QueryClientProvider>,
  );
}

describe('Broker pages', () => {
  it('renders broker and IIS accounts', () => {
    vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
      data: [
        { id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null },
        { id: 'acc-2', type: 'iis', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null },
      ],
      isLoading: false,
      error: null,
    } as any);

    renderWithClient(<BrokerAccountsPage />);

    expect(screen.getByText('Broker')).toBeInTheDocument();
    expect(screen.getByText('IIS')).toBeInTheDocument();
  });

  it('renders positions and operations for account detail', () => {
    vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
      data: {
        account: { id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null },
        totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
        yields: { expectedPercent: 5, daily: null, dailyPercent: null },
        cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
        blockedCash: [],
        positions: [{ ticker: 'SBER', name: 'Sberbank', quantity: 10, currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } }],
        asOf: '2026-06-16T00:00:00.000Z',
      },
      isLoading: false,
      error: null,
    } as any);
    vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
      data: {
        accountId: 'acc-1',
        items: [{ id: 'op-1', date: '2026-06-16T00:00:00.000Z', category: 'trade', type: 'OPERATION_TYPE_BUY', description: 'Buy', ticker: 'SBER', payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 } }],
        nextCursor: null,
        hasNext: false,
        asOf: '2026-06-16T00:00:00.000Z',
      },
      isLoading: false,
      error: null,
    } as any);

    renderWithClient(
      <Routes>
        <Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
      </Routes>,
      ['/broker/acc-1'],
    );

    expect(screen.getByText('SBER')).toBeInTheDocument();
    expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument();
  });
});
  • Step 2: Implement broker accounts page

Create apps/frontend/src/pages/broker/BrokerAccountsPage.tsx:

import { Link } from 'react-router-dom';
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';

export function BrokerAccountsPage() {
  const { data: accounts, isLoading, error } = useBrokerAccounts();

  if (isLoading) return <p>Загрузка брокерских счетов...</p>;
  if (error) return <p style={{ color: 'var(--color-danger)' }}>Не удалось загрузить счета</p>;

  return (
    <div>
      <h1 style={{ marginBottom: 8 }}>Брокерские счета</h1>
      <p style={{ color: 'var(--color-text-secondary)', marginBottom: 24 }}>
        Реальные брокерские счета и ИИС из T-Bank Invest.
      </p>
      <div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
        {(accounts ?? []).map((account) => (
          <Link
            key={account.id}
            to={`/broker/${encodeURIComponent(account.id)}`}
            style={{
              display: 'block',
              padding: 20,
              background: 'var(--color-surface)',
              border: '1px solid #e0e0e0',
              borderRadius: 8,
              color: 'var(--color-text)',
              textDecoration: 'none',
            }}
          >
            <div style={{ fontSize: 18, fontWeight: 700 }}>{account.name}</div>
            <div style={{ marginTop: 8, color: 'var(--color-text-secondary)' }}>
              {account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}
  • Step 3: Implement broker account detail page

Create apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx:

import { useParams } from 'react-router-dom';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import type { BrokerMoney } from '../../api/responses';

function formatMoney(value: BrokerMoney | null | undefined) {
  if (!value) return '—';
  return new Intl.NumberFormat('ru-RU', {
    style: 'currency',
    currency: value.currency || 'RUB',
    maximumFractionDigits: 2,
  }).format(value.value);
}

export function BrokerAccountDetailPage() {
  const { accountId } = useParams();
  const portfolio = useBrokerPortfolio(accountId);
  const operations = useBrokerOperations(accountId, { limit: 100 });

  if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
  if (portfolio.error || !portfolio.data) {
    return <p style={{ color: 'var(--color-danger)' }}>Не удалось загрузить портфель</p>;
  }

  return (
    <div>
      <h1 style={{ marginBottom: 8 }}>{portfolio.data.account.name}</h1>
      <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 24 }}>
        <strong>{formatMoney(portfolio.data.totals.portfolio)}</strong>
        <span>Дневная доходность: {formatMoney(portfolio.data.yields.daily)}</span>
        <span>Ожидаемая доходность: {portfolio.data.yields.expectedPercent ?? '—'}%</span>
      </div>

      <section style={{ marginBottom: 32 }}>
        <h2>Деньги</h2>
        <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
          {portfolio.data.cash.map((money) => (
            <span key={money.currency}>{formatMoney(money)}</span>
          ))}
        </div>
      </section>

      <section style={{ marginBottom: 32 }}>
        <h2>Позиции</h2>
        <div style={{ overflowX: 'auto' }}>
          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
            <thead>
              <tr>
                <th align="left">Инструмент</th>
                <th align="right">Количество</th>
                <th align="right">Стоимость</th>
                <th align="right">Доходность</th>
              </tr>
            </thead>
            <tbody>
              {portfolio.data.positions.map((position) => (
                <tr key={position.positionUid || position.instrumentUid || position.ticker}>
                  <td>{position.ticker || position.name || position.figi}</td>
                  <td align="right">{position.quantity ?? '—'}</td>
                  <td align="right">{formatMoney(position.currentValue)}</td>
                  <td align="right">{position.expectedYieldPercent ?? '—'}%</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </section>

      <section>
        <h2>Операции</h2>
        {operations.isLoading ? (
          <p>Загрузка операций...</p>
        ) : (
          <div style={{ overflowX: 'auto' }}>
            <table style={{ width: '100%', borderCollapse: 'collapse' }}>
              <thead>
                <tr>
                  <th align="left">Дата</th>
                  <th align="left">Тип</th>
                  <th align="left">Инструмент</th>
                  <th align="right">Сумма</th>
                </tr>
              </thead>
              <tbody>
                {(operations.data?.items ?? []).map((operation) => (
                  <tr key={operation.cursor || operation.id}>
                    <td>{operation.date ? new Date(operation.date).toLocaleString('ru-RU') : '—'}</td>
                    <td>{operation.type}</td>
                    <td>{operation.ticker || operation.description || '—'}</td>
                    <td align="right">{formatMoney(operation.payment)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </section>
    </div>
  );
}
  • Step 4: Wire routes and navigation

Modify apps/frontend/src/routes.tsx imports:

import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';

Add protected routes:

<Route
  path="/broker"
  element={
    <ProtectedRoute>
      <BrokerAccountsPage />
    </ProtectedRoute>
  }
/>
<Route
  path="/broker/:accountId"
  element={
    <ProtectedRoute>
      <BrokerAccountDetailPage />
    </ProtectedRoute>
  }
/>

Modify apps/frontend/src/components/Layout.tsx to add a link after Портфели:

<Link
  to="/broker"
  style={{
    fontSize: 14,
    color: 'var(--color-text)',
    textDecoration: 'none',
    fontWeight: 500,
  }}
>
  Брокер
</Link>
  • Step 5: Run frontend tests, build, and browser verification

Run:

npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend
npm run build:frontend

Expected: tests PASS and frontend build exits 0.

Then start dev servers:

PORT=3001 npm run dev:backend
npm run dev:frontend

Open http://localhost:5173/broker with Browser. Verify:

  • page renders without a blank screen;

  • protected route redirects unauthenticated users consistently with existing portfolio pages;

  • layout does not overlap at desktop and mobile widths.

  • Step 6: Commit frontend broker UI

git add apps/frontend/src/api apps/frontend/src/hooks apps/frontend/src/pages/broker apps/frontend/src/routes.tsx apps/frontend/src/components/Layout.tsx apps/frontend/src/styles.css
git commit -m "feat: add broker portfolio UI"

Task 10: Add Durable Operation Sync Models And Service

Files:

  • Modify: apps/backend/prisma/schema.prisma

  • Create: migration SQL generated by Prisma under apps/backend/prisma/migrations/ when 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:

model BrokerOperation {
  id                Int      @id @default(autoincrement())
  accountId         String
  cursor            String?
  operationId       String?
  parentOperationId String?
  date              DateTime?
  type              String
  category          String
  state             String?
  instrumentUid     String?
  figi              String?
  ticker            String?
  classCode         String?
  payment           String?
  price             String?
  commission        String?
  yield             String?
  accruedInt        String?
  quantity          Int?
  quantityDone      Int?
  raw               String
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt

  @@unique([accountId, cursor])
  @@index([accountId, date])
  @@index([accountId, type])
}

model BrokerOperationSyncState {
  id             Int      @id @default(autoincrement())
  accountId      String   @unique
  lastCursor     String?
  lastSyncedFrom DateTime?
  lastSyncedTo   DateTime?
  syncedAt       DateTime @default(now())
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
}
  • Step 2: Generate migration and Prisma client

Run:

npx prisma migrate dev --name add_broker_operations -w apps/backend

Expected: migration SQL file created and Prisma client generated.

  • Step 3: Write sync service test

Create apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts:

import { BrokerOperationSyncService } from './broker-operation-sync.service';
import { BrokerOperationsService } from './broker-operations.service';
import { PrismaService } from '../../prisma/prisma.service';

describe('BrokerOperationSyncService', () => {
  const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
  const prisma = {
    brokerOperation: { upsert: vi.fn() },
    brokerOperationSyncState: { upsert: vi.fn() },
  } as unknown as PrismaService;

  beforeEach(() => vi.clearAllMocks());

  it('syncs operation pages and stores raw payload', async () => {
    vi.mocked(operations.getOperations)
      .mockResolvedValueOnce({
        data: {
          accountId: 'acc-1',
          hasNext: true,
          nextCursor: 'next',
          asOf: '2026-06-16T00:00:00.000Z',
          items: [
            {
              cursor: 'c1',
              accountId: 'acc-1',
              id: 'op-1',
              parentOperationId: null,
              date: '2026-06-16T00:00:00.000Z',
              type: 'OPERATION_TYPE_BUY',
              category: 'trade',
              description: null,
              state: 'OPERATION_STATE_EXECUTED',
              instrumentUid: 'uid-1',
              figi: null,
              ticker: 'SBER',
              classCode: 'TQBR',
              instrumentType: 'share',
              payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
              price: null,
              commission: null,
              yield: null,
              accruedInt: null,
              quantity: 10,
              quantityDone: 10,
            },
          ],
        },
        meta: { fromCache: false, cachedAt: null },
      })
      .mockResolvedValueOnce({
        data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
        meta: { fromCache: false, cachedAt: null },
      });

    const service = new BrokerOperationSyncService(operations, prisma);
    const result = await service.syncAccount('acc-1', {
      from: '2026-06-01T00:00:00.000Z',
      to: '2026-06-16T00:00:00.000Z',
    });

    expect(result.upserted).toBe(1);
    expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
      }),
    );
    expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalled();
  });
});
  • Step 4: Implement sync service

Create apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { BrokerOperation } from '../types/broker.types';
import { BrokerOperationsService } from './broker-operations.service';

type SyncRange = {
  from: string;
  to: string;
};

@Injectable()
export class BrokerOperationSyncService {
  constructor(
    private readonly operationsService: BrokerOperationsService,
    private readonly prisma: PrismaService,
  ) {}

  async syncAccount(accountId: string, range: SyncRange): Promise<{ upserted: number }> {
    let cursor: string | undefined;
    let upserted = 0;

    do {
      const page = await this.operationsService.getOperations(accountId, {
        from: range.from,
        to: range.to,
        cursor,
        limit: 1000,
        state: 'OPERATION_STATE_EXECUTED',
      });

      for (const operation of page.data.items) {
        await this.upsertOperation(operation);
        upserted++;
      }

      cursor = page.data.nextCursor ?? undefined;
      if (!page.data.hasNext) break;
    } while (cursor);

    await this.prisma.brokerOperationSyncState.upsert({
      where: { accountId },
      create: {
        accountId,
        lastCursor: cursor ?? null,
        lastSyncedFrom: new Date(range.from),
        lastSyncedTo: new Date(range.to),
      },
      update: {
        lastCursor: cursor ?? null,
        lastSyncedFrom: new Date(range.from),
        lastSyncedTo: new Date(range.to),
        syncedAt: new Date(),
      },
    });

    return { upserted };
  }

  private async upsertOperation(operation: BrokerOperation): Promise<void> {
    const cursor = operation.cursor || `${operation.id || 'operation'}:${operation.date || 'no-date'}`;
    const data = {
      accountId: operation.accountId,
      cursor,
      operationId: operation.id,
      parentOperationId: operation.parentOperationId,
      date: operation.date ? new Date(operation.date) : null,
      type: operation.type,
      category: operation.category,
      state: operation.state,
      instrumentUid: operation.instrumentUid,
      figi: operation.figi,
      ticker: operation.ticker,
      classCode: operation.classCode,
      payment: operation.payment ? JSON.stringify(operation.payment) : null,
      price: operation.price ? JSON.stringify(operation.price) : null,
      commission: operation.commission ? JSON.stringify(operation.commission) : null,
      yield: operation.yield ? JSON.stringify(operation.yield) : null,
      accruedInt: operation.accruedInt ? JSON.stringify(operation.accruedInt) : null,
      quantity: operation.quantity,
      quantityDone: operation.quantityDone,
      raw: JSON.stringify(operation),
    };

    await this.prisma.brokerOperation.upsert({
      where: { accountId_cursor: { accountId: operation.accountId, cursor } },
      create: data,
      update: data,
    });
  }
}

Add BrokerOperationSyncService to TBankModule providers and exports.

  • Step 5: Run sync tests and commit

Run:

npx vitest run src/modules/tbank/services/broker-operation-sync.service.spec.ts -w apps/backend
npm run build:backend

Expected: tests PASS and backend build exits 0.

Commit:

git add apps/backend/prisma apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts apps/backend/src/modules/tbank/tbank.module.ts
git commit -m "feat: persist tbank broker operations"

Task 11: Publish T-Bank Integration Documentation

Files:

  • Create: apps/docs/docs/backend/tbank-invest.md

  • Modify: apps/docs/docs/backend/modules.md

  • Modify: apps/docs/docs/backend/configuration.md

  • Modify: apps/docs/docs/backend/caching.md

  • Modify: apps/docs/docs/backend/portfolio.md

  • Create: apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md

  • Modify: apps/docs/docs/adr/index.md

  • Modify: apps/docs/sidebars.ts

  • Step 1: Add backend T-Bank integration page

Create apps/docs/docs/backend/tbank-invest.md:

# T-Bank Invest Integration

`TBankModule` is a read-only backend integration with T-Bank Invest API. The backend is the only
client that talks to T-Bank; the frontend calls MoexVibe endpoints under `/api/v1/broker`.

## Scope

The first version supports only open brokerage accounts and IIS accounts:

- `ACCOUNT_TYPE_TINKOFF`
- `ACCOUNT_TYPE_TINKOFF_IIS`

Invest Box, DFA smart accounts, debit accounts, savings accounts, and money market fund accounts
are ignored.

## Protocol

MoexVibe uses gRPC against `invest-public-api.tbank.ru:443`. REST is treated as a debugging proxy,
not as the application integration protocol.

The backend sends:

```text
Authorization: Bearer <T_BANK_TOKEN>
x-app-name: ksv741.moex-vibe

The token is read from backend environment variables and is never returned to the frontend.

Backend Endpoints

Endpoint Description
GET /api/v1/broker/accounts Open brokerage and IIS accounts.
GET /api/v1/broker/accounts/:accountId/portfolio Portfolio totals, positions, cash, and blocked cash.
GET /api/v1/broker/accounts/:accountId/operations Cursor-paginated operation history.

T-Bank Methods

Need T-Bank method
Accounts UsersService/GetAccounts
Portfolio totals OperationsService/GetPortfolio
Cash and settled positions OperationsService/GetPositions
Operation history OperationsService/GetOperationsByCursor
Instrument metadata InstrumentsService/GetInstrumentBy

Security

This version is single-user/admin-oriented because it uses one server-side T_BANK_TOKEN. Before opening MoexVibe to multiple users, replace this with encrypted per-user token storage and bind each broker account to its owner.


- [ ] **Step 2: Update backend module docs**

Modify `apps/docs/docs/backend/modules.md`:

- Add `TBankModule` to feature modules in the Mermaid diagram.
- Add row `TBankModule | Нет | modules/tbank/ | Read-only T-Bank Invest broker portfolios`.
- Add a section describing `TBankModule`, its gRPC client, cache usage, and read-only scope.

- [ ] **Step 3: Update configuration docs**

Modify `apps/docs/docs/backend/configuration.md` and add rows:

```markdown
| `T_BANK_TOKEN` | empty | Server-side T-Bank Invest token |
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | T-Bank gRPC endpoint |
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Optional T-Bank app metadata |
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Local limiter for T-Bank calls |
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | gRPC request deadline |
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | Broker accounts cache TTL |
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | Broker portfolio cache TTL |
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | Broker operations page cache TTL |
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | T-Bank instrument metadata TTL |
  • Step 4: Update caching and portfolio docs

Modify apps/docs/docs/backend/caching.md to add T-Bank cache rows matching the spec.

Modify apps/docs/docs/backend/portfolio.md to add a short section:

## Manual portfolios vs broker portfolios

`PortfolioModule` remains the manual virtual portfolio domain. T-Bank broker accounts are exposed by
`TBankModule` under `/api/v1/broker/*` and are not stored as `Portfolio` records.
  • Step 5: Add ADR-011

Create apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md:

# ADR-011: T-Bank Invest integration uses gRPC

**Статус:** Accepted

**Дата:** 2026-06-16

## Контекст

MoexVibe needs a read-only integration with T-Bank Invest for brokerage and IIS accounts, current
positions, cash balances, and operation history. T-Bank provides gRPC, REST proxy, WebSocket, and an
official JS SDK.

## Решение

Use a thin backend gRPC integration based on official proto contracts. Keep REST as a manual
debugging tool and do not depend directly on the JS SDK in the first implementation.

## Обоснование

- gRPC is the primary T-Bank Invest protocol.
- Unary methods cover accounts, portfolio, positions, operations, and instruments.
- Stream methods can be added later without changing the public MoexVibe API.
- Owning the transport layer lets MoexVibe control rate limiting, metadata redaction, tracking IDs,
  test doubles, and the future transition from one server token to per-user tokens.

## Последствия

- The backend vendors official proto contracts.
- The backend owns T-Bank-specific rate limits and cache TTLs.
- Integration remains read-only until a separate trading/order ADR is accepted.

Update apps/docs/docs/adr/index.md and apps/docs/sidebars.ts to include ADR-011 and backend/tbank-invest.

  • Step 6: Build docs and commit

Run:

npm run build:docs

Expected: Docusaurus build exits 0.

Commit:

git add apps/docs
git commit -m "docs: document tbank invest integration"

Task 12: Final Verification And OpenAPI Contract Check

Files:

  • Verify all changed files.

  • Step 1: Run backend tests

Run:

npm run test:backend

Expected: all backend Vitest tests PASS.

  • Step 2: Run frontend tests

Run:

npm run test:frontend

Expected: all frontend Vitest tests PASS.

  • Step 3: Run lint

Run:

npm run lint

Expected: ESLint exits 0 for backend and frontend.

  • Step 4: Run builds

Run:

npm run build:backend
npm run build:frontend
npm run build:docs

Expected: all builds exit 0.

  • Step 5: Verify Swagger exposes broker endpoints

Start backend:

PORT=3001 npm run dev:backend

Run:

node -e "fetch('http://localhost:3001/api/docs-json').then(r => r.json()).then(j => { const required = ['/api/v1/broker/accounts','/api/v1/broker/accounts/{accountId}/portfolio','/api/v1/broker/accounts/{accountId}/operations']; const missing = required.filter(p => !j.paths || !j.paths[p]); console.log(JSON.stringify({ missing }, null, 2)); if (missing.length) process.exit(1); })"

Expected:

{
  "missing": []
}
  • Step 6: Browser-check frontend

With backend and frontend dev servers running, open:

http://localhost:5173/broker

Verify:

  • unauthenticated users are redirected by ProtectedRoute;

  • authenticated view renders account loading/error states;

  • account detail page renders positions and operations without overlapping text at desktop and mobile widths;

  • no token value appears in the browser UI or console logs.

  • Step 7: Inspect git diff

Run:

git status --short
git diff --check
git log --oneline --max-count=8

Expected:

  • git diff --check exits 0;
  • status contains only intentional uncommitted files, or is clean after the final commit;
  • recent commits correspond to the tasks above.