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