refactor(tbank): centralize access rules

This commit is contained in:
2026-08-25 12:06:41 +03:00
parent 5375e5598a
commit 130b303ec1
18 changed files with 573 additions and 327 deletions
+9 -2
View File
@@ -2,9 +2,15 @@
## Назначение и authority ## Назначение и authority
NestJS API WealthLedger владеет доменной логикой, данными, конфигурацией и server-side интеграциями. Canonical OpenSpec store — `wealth-ledger-specs` в `../specs/openspec`; OpenSpec commands, читающие или изменяющие change, всегда выполняются с `--store wealth-ledger-specs`. Versioned planning artifacts вне canonical store запрещены, включая `superpowers/plans`; `../references/**` read-only. NestJS API WealthLedger владеет доменной логикой, данными, конфигурацией и
server-side интеграциями. Shared SDD policy, common reading pointers,
audit routing и `../references/**` policy задаёт root `AGENTS.md`.
Перед изменением поведения прочитать [`../docs/architecture/system-overview.md`](../docs/architecture/system-overview.md), для терминов — [`../docs/architecture/domain-glossary.md`](../docs/architecture/domain-glossary.md), для решений — [`../docs/adr/README.md`](../docs/adr/README.md). Testing и обязательная Solution Assessment для auth/security/storage/integrations/tooling определены в [`../docs/development/engineering-governance.md`](../docs/development/engineering-governance.md). Audit-derived работа начинается с [`../docs/development/audit-remediation.md`](../docs/development/audit-remediation.md). Canonical SDD authority находится в `wealth-ledger-specs`, физически в
`../specs/openspec` от этого репозитория. Любая OpenSpec command, читающая или
изменяющая change, должна использовать `--store wealth-ledger-specs`.
Для устойчивых backend decisions читать [`../docs/adr/README.md`](../docs/adr/README.md).
## Canonical gate ## Canonical gate
@@ -16,6 +22,7 @@ npm run verify
``` ```
Gate non-mutating и последовательно проверяет runtime, hygiene, ESLint, application/test TypeScript, risk-based Jest suite и build. `npm run lint:fix` и `npm run format` изменяют files и выполняются только явно; `npm run test:cov` — диагностика без общего threshold. `npm run test:e2e` не входит в passing gate до WL-P2-05 trigger из remediation registry. Gate non-mutating и последовательно проверяет runtime, hygiene, ESLint, application/test TypeScript, risk-based Jest suite и build. `npm run lint:fix` и `npm run format` изменяют files и выполняются только явно; `npm run test:cov` — диагностика без общего threshold. `npm run test:e2e` не входит в passing gate до WL-P2-05 trigger из remediation registry.
Актуальные scripts проверять в `package.json`/`npm run`.
## Enforceable boundaries ## Enforceable boundaries
+15 -10
View File
@@ -1,24 +1,29 @@
import { BadRequestException, ConflictException } from '@nestjs/common'; import {
import { ServiceUnavailableException } from '@nestjs/common'; BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({ jest.mock('../prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
import { TbankCrypto } from './tbank.crypto'; import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
describe('BrokerageAccountsService', () => { describe('BrokerageAccountsService', () => {
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
const prisma = { tbankConnection: { findUnique: jest.fn() } }; const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => encryptionKey) }; const config = { getOrThrow: jest.fn(() => encryptionKey) };
const tbank = { getOpenAccounts: jest.fn() }; const tbank = { getOpenAccounts: jest.fn() };
const service = new BrokerageAccountsService( const tokens = new TbankTokenService(config as never);
const access = new TbankAccessService(
prisma as never, prisma as never,
config as never, tokens,
tbank as never, tbank as never,
); );
const crypto = TbankCrypto.fromBase64(encryptionKey); const service = new BrokerageAccountsService(access);
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
@@ -33,7 +38,7 @@ describe('BrokerageAccountsService', () => {
it('normalizes supported accounts and ignores unsupported ones', async () => { it('normalizes supported accounts and ignores unsupported ones', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({ prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'), encryptedToken: tokens.encrypt('token'),
}); });
tbank.getOpenAccounts.mockResolvedValue([ tbank.getOpenAccounts.mockResolvedValue([
{ {
@@ -72,7 +77,7 @@ describe('BrokerageAccountsService', () => {
it('retains the connection when T-Bank rejects its token', async () => { it('retains the connection when T-Bank rejects its token', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({ prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'), encryptedToken: tokens.encrypt('token'),
}); });
tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN')); tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
@@ -83,7 +88,7 @@ describe('BrokerageAccountsService', () => {
it('returns an empty list from T-Bank unchanged', async () => { it('returns an empty list from T-Bank unchanged', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({ prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'), encryptedToken: tokens.encrypt('token'),
}); });
tbank.getOpenAccounts.mockResolvedValue([]); tbank.getOpenAccounts.mockResolvedValue([]);
@@ -92,7 +97,7 @@ describe('BrokerageAccountsService', () => {
it('propagates an unavailable T-Bank response', async () => { it('propagates an unavailable T-Bank response', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({ prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'), encryptedToken: tokens.encrypt('token'),
}); });
tbank.getOpenAccounts.mockRejectedValue( tbank.getOpenAccounts.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
+4 -71
View File
@@ -1,79 +1,12 @@
import { import { Injectable } from '@nestjs/common';
BadRequestException,
ConflictException,
Injectable,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { PrismaService } from '../prisma/prisma.service';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { BrokerageAccountDto } from './brokerage-account.dto';
import { TbankClient } from './tbank.client'; import { TbankAccessService } from './tbank-access.service';
import { TbankCrypto } from './tbank.crypto';
const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: {
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
},
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: {
type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка',
},
} as const;
type AccountType = keyof typeof accountTypes;
@Injectable() @Injectable()
export class BrokerageAccountsService { export class BrokerageAccountsService {
private readonly crypto: TbankCrypto; constructor(private readonly access: TbankAccessService) {}
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
async list(userId: string): Promise<BrokerageAccountDto[]> { async list(userId: string): Promise<BrokerageAccountDto[]> {
const connection = await this.prisma.tbankConnection.findUnique({ return this.access.listSupportedAccounts(userId);
where: { userId },
});
if (!connection) {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
}
try {
const accounts = await this.tbank.getOpenAccounts(
this.crypto.decrypt(connection.encryptedToken),
);
return accounts.flatMap((account) => {
const details = accountTypes[account.type as AccountType];
if (!details) return [];
return [
{
id: account.id,
name: account.name.trim() || details.name,
type: details.type,
openedOn: this.openedOn(account.openedDate?.seconds),
},
];
});
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
}
throw error;
}
}
private openedOn(seconds: string | number | undefined): string | null {
if (seconds === undefined) return null;
const date = new Date(Number(seconds) * 1000);
if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10);
} }
} }
+21 -24
View File
@@ -4,41 +4,41 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({ jest.mock('./tbank-access.service', () => ({
PrismaService: class PrismaService {}, TbankAccessService: class TbankAccessService {},
})); }));
import { BrokerageOperationsService } from './brokerage-operations.service'; import { BrokerageOperationsService } from './brokerage-operations.service';
import { TbankCrypto } from './tbank.crypto'; import type { TbankAccountContext } from './tbank-access.service';
describe('BrokerageOperationsService', () => { describe('BrokerageOperationsService', () => {
const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; const access = { withAccount: jest.fn() };
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => key) };
const tbank = { const tbank = {
getOpenAccounts: jest.fn(),
getOperationsByCursor: jest.fn(), getOperationsByCursor: jest.fn(),
}; };
const requestContext = { set: jest.fn() }; const requestContext = { set: jest.fn() };
const service = new BrokerageOperationsService( const service = new BrokerageOperationsService(
prisma as never, access as never,
config as never,
tbank as never, tbank as never,
requestContext as never, requestContext as never,
); );
const crypto = TbankCrypto.fromBase64(key);
let operationsRequest: unknown; let operationsRequest: unknown;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
tbank.getOpenAccounts.mockReset(); access.withAccount.mockReset();
tbank.getOperationsByCursor.mockReset(); tbank.getOperationsByCursor.mockReset();
prisma.tbankConnection.findUnique.mockResolvedValue({ access.withAccount.mockImplementation(
encryptedToken: crypto.encrypt('token'), (
}); _userId: string,
tbank.getOpenAccounts.mockResolvedValue([ _accountId: string,
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, handler: (context: TbankAccountContext) => Promise<unknown>,
]); ) =>
handler({
token: 'token',
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getOperationsByCursor.mockImplementation( tbank.getOperationsByCursor.mockImplementation(
(_token: string, request: unknown) => { (_token: string, request: unknown) => {
operationsRequest = request; operationsRequest = request;
@@ -116,7 +116,7 @@ describe('BrokerageOperationsService', () => {
}); });
it('rejects inaccessible accounts before reading operations', async () => { it('rejects inaccessible accounts before reading operations', async () => {
tbank.getOpenAccounts.mockResolvedValue([]); access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account', {})).rejects.toEqual( await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(NotFoundException), expect.any(NotFoundException),
@@ -125,15 +125,12 @@ describe('BrokerageOperationsService', () => {
}); });
it('retains existing T-Bank error semantics', async () => { it('retains existing T-Bank error semantics', async () => {
tbank.getOpenAccounts.mockRejectedValueOnce( access.withAccount.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'), new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
); );
await expect(service.get('user', 'account', {})).rejects.toEqual( await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(BadRequestException), expect.any(BadRequestException),
); );
tbank.getOpenAccounts.mockResolvedValueOnce([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
]);
tbank.getOperationsByCursor.mockRejectedValueOnce( tbank.getOperationsByCursor.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
); );
@@ -158,7 +155,7 @@ describe('BrokerageOperationsService', () => {
it('does not let a cached page bypass account ownership checks', async () => { it('does not let a cached page bypass account ownership checks', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' }; const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query); await service.get('user', 'account', query);
tbank.getOpenAccounts.mockResolvedValueOnce([]); access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account', query)).rejects.toEqual( await expect(service.get('user', 'account', query)).rejects.toEqual(
expect.any(NotFoundException), expect.any(NotFoundException),
+6 -45
View File
@@ -1,14 +1,6 @@
import { import { Injectable } from '@nestjs/common';
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { RequestContextService } from '../common/logging/request-context.service'; import { RequestContextService } from '../common/logging/request-context.service';
import { Environment } from '../config/env.validation';
import { PrismaService } from '../prisma/prisma.service';
import { import {
BrokerageOperationsPageDto, BrokerageOperationsPageDto,
BrokerageOperationsQueryDto, BrokerageOperationsQueryDto,
@@ -17,21 +9,14 @@ import {
mapBrokerageOperation, mapBrokerageOperation,
operationCategory, operationCategory,
} from './brokerage-operation.mapper'; } from './brokerage-operation.mapper';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
import { TbankCrypto } from './tbank.crypto';
const freshForMs = 30_000; const freshForMs = 30_000;
const maxCachedPages = 100; const maxCachedPages = 100;
const supportedAccounts = new Set([
'ACCOUNT_TYPE_TINKOFF',
'ACCOUNT_TYPE_TINKOFF_IIS',
'ACCOUNT_TYPE_INVEST_BOX',
'ACCOUNT_TYPE_INVEST_FUND',
]);
@Injectable() @Injectable()
export class BrokerageOperationsService { export class BrokerageOperationsService {
private readonly crypto: TbankCrypto;
private readonly pages = new LRUCache<string, BrokerageOperationsPageDto>({ private readonly pages = new LRUCache<string, BrokerageOperationsPageDto>({
max: maxCachedPages, max: maxCachedPages,
ttl: freshForMs, ttl: freshForMs,
@@ -39,15 +24,10 @@ export class BrokerageOperationsService {
}); });
constructor( constructor(
private readonly prisma: PrismaService, private readonly access: TbankAccessService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService, private readonly requestContext: RequestContextService,
) { ) {}
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
async get( async get(
userId: string, userId: string,
@@ -57,22 +37,7 @@ export class BrokerageOperationsService {
this.requestContext.set({ userId, brokerageAccountId: accountId }); this.requestContext.set({ userId, brokerageAccountId: accountId });
const range = this.range(query); const range = this.range(query);
try { return this.access.withAccount(userId, accountId, async ({ token }) => {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
const token = this.crypto.decrypt(connection.encryptedToken);
const accounts = await this.tbank.getOpenAccounts(token);
if (
!accounts.some(
(account) =>
account.id === accountId && supportedAccounts.has(account.type),
)
)
throw new NotFoundException();
const key = cacheKey(userId, accountId, query, range); const key = cacheKey(userId, accountId, query, range);
const cached = this.pages.get(key); const cached = this.pages.get(key);
if (cached) return cached; if (cached) return cached;
@@ -106,11 +71,7 @@ export class BrokerageOperationsService {
}; };
this.pages.set(key, page); this.pages.set(key, page);
return page; return page;
} catch (error) { });
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
throw error;
}
} }
private range(query: BrokerageOperationsQueryDto) { private range(query: BrokerageOperationsQueryDto) {
+30 -34
View File
@@ -1,23 +1,18 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({ jest.mock('./tbank-access.service', () => ({
PrismaService: class PrismaService {}, TbankAccessService: class TbankAccessService {},
})); }));
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { TbankCrypto } from './tbank.crypto'; import type { TbankAccountContext } from './tbank-access.service';
describe('PortfolioService', () => { describe('PortfolioService', () => {
const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; const access = { withAccount: jest.fn() };
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => key) };
const tbank = { const tbank = {
getOpenAccounts: jest.fn(),
getPortfolio: jest.fn(), getPortfolio: jest.fn(),
getPositions: jest.fn(), getPositions: jest.fn(),
getInstrumentByFigi: jest.fn(), getInstrumentByFigi: jest.fn(),
@@ -26,39 +21,40 @@ describe('PortfolioService', () => {
const requestContext = { set: jest.fn() }; const requestContext = { set: jest.fn() };
const logger = { event: jest.fn() }; const logger = { event: jest.fn() };
const service = new PortfolioService( const service = new PortfolioService(
prisma as never, access as never,
config as never,
tbank as never, tbank as never,
requestContext as never, requestContext as never,
logger as never, logger as never,
); );
const crypto = TbankCrypto.fromBase64(key);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
service.invalidate('user'); service.invalidate('user');
prisma.tbankConnection.findUnique.mockResolvedValue({ access.withAccount.mockImplementation(
encryptedToken: crypto.encrypt('token'), (
}); _userId: string,
tbank.getOpenAccounts.mockResolvedValue([ _accountId: string,
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, handler: (context: TbankAccountContext) => Promise<unknown>,
]); ) =>
handler({
token: 'token',
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getPortfolio.mockResolvedValue({ positions: [] }); tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] }); tbank.getPositions.mockResolvedValue({ money: [] });
}); });
it('requires a connection and an accessible brokerage account', async () => { it('resolves account-scoped access before loading upstream portfolio', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null); await service.get('user', 'account');
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ConflictException), expect(access.withAccount).toHaveBeenCalledWith(
); 'user',
prisma.tbankConnection.findUnique.mockResolvedValue({ 'account',
encryptedToken: crypto.encrypt('token'), expect.any(Function),
});
tbank.getOpenAccounts.mockResolvedValue([]);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(NotFoundException),
); );
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'account');
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'account');
}); });
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => { it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
@@ -148,15 +144,15 @@ describe('PortfolioService', () => {
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => { it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
await service.get('user', 'account'); await service.get('user', 'account');
await service.get('user', 'account'); await service.get('user', 'account');
expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(1); expect(access.withAccount).toHaveBeenCalledTimes(1);
service.invalidate('user'); service.invalidate('user');
await service.get('user', 'account'); await service.get('user', 'account');
expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(2); expect(access.withAccount).toHaveBeenCalledTimes(2);
const snapshots = ( const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> } service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots; ).snapshots;
snapshots.get('user:account')!.storedAt = Date.now() - 30_001; snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
tbank.getOpenAccounts.mockRejectedValueOnce( access.withAccount.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
); );
await expect(service.get('user', 'account')).resolves.toMatchObject({ await expect(service.get('user', 'account')).resolves.toMatchObject({
@@ -170,8 +166,8 @@ describe('PortfolioService', () => {
service as never as { snapshots: Map<string, { storedAt: number }> } service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots; ).snapshots;
snapshots.get('user:account')!.storedAt = Date.now() - 30_001; snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
tbank.getOpenAccounts.mockRejectedValueOnce( access.withAccount.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'), new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
); );
await expect(service.get('user', 'account')).rejects.toEqual( await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException), expect.any(BadRequestException),
+18 -51
View File
@@ -1,15 +1,6 @@
import { import { Injectable, ServiceUnavailableException } from '@nestjs/common';
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { RequestContextService } from '../common/logging/request-context.service'; import { RequestContextService } from '../common/logging/request-context.service';
import { StructuredLogger } from '../common/logging/structured-logger.service'; import { StructuredLogger } from '../common/logging/structured-logger.service';
import { PrismaService } from '../prisma/prisma.service';
import { import {
add, add,
decimal, decimal,
@@ -19,18 +10,12 @@ import {
subtract, subtract,
} from './decimal'; } from './decimal';
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient, TbankMoney } from './tbank.client'; import { TbankClient, TbankMoney } from './tbank.client';
import { TbankCrypto } from './tbank.crypto';
const freshForMs = 30_000; const freshForMs = 30_000;
const fallbackForMs = 300_000; const fallbackForMs = 300_000;
const metadataForMs = 86_400_000; const metadataForMs = 86_400_000;
const supportedAccounts = new Set([
'ACCOUNT_TYPE_TINKOFF',
'ACCOUNT_TYPE_TINKOFF_IIS',
'ACCOUNT_TYPE_INVEST_BOX',
'ACCOUNT_TYPE_INVEST_FUND',
]);
const types: Record<string, string> = { const types: Record<string, string> = {
share: 'STOCK', share: 'STOCK',
bond: 'BOND', bond: 'BOND',
@@ -55,21 +40,15 @@ type CachedMetadata = {
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private readonly crypto: TbankCrypto;
private readonly snapshots = new Map<string, CachedSnapshot>(); private readonly snapshots = new Map<string, CachedSnapshot>();
private readonly metadata = new Map<string, CachedMetadata>(); private readonly metadata = new Map<string, CachedMetadata>();
constructor( constructor(
private readonly prisma: PrismaService, private readonly access: TbankAccessService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService, private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
) { ) {}
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
async get(userId: string, accountId: string): Promise<PortfolioDto> { async get(userId: string, accountId: string): Promise<PortfolioDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId }); this.requestContext.set({ userId, brokerageAccountId: accountId });
@@ -78,36 +57,24 @@ export class PortfolioService {
if (cached && Date.now() - cached.storedAt <= freshForMs) if (cached && Date.now() - cached.storedAt <= freshForMs)
return cached.snapshot; return cached.snapshot;
try { try {
const connection = await this.prisma.tbankConnection.findUnique({ const snapshot = await this.access.withAccount(
where: { userId }, userId,
}); accountId,
if (!connection) async ({ token }) => {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); const [portfolio, positions] = await Promise.all([
const token = this.crypto.decrypt(connection.encryptedToken); this.tbank.getPortfolio(token, accountId),
const accounts = await this.tbank.getOpenAccounts(token); this.tbank.getPositions(token, accountId),
if ( ]);
!accounts.some( return this.normalize(
(account) => token,
account.id === accountId && supportedAccounts.has(account.type), portfolio.positions ?? [],
) positions.money ?? [],
) { );
throw new NotFoundException(); },
}
const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
const snapshot = await this.normalize(
token,
portfolio.positions ?? [],
positions.money ?? [],
); );
this.snapshots.set(key, { snapshot, storedAt: Date.now() }); this.snapshots.set(key, { snapshot, storedAt: Date.now() });
return snapshot; return snapshot;
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
}
if ( if (
error instanceof ServiceUnavailableException && error instanceof ServiceUnavailableException &&
cached && cached &&
+130
View File
@@ -0,0 +1,130 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { TbankAccessService } from './tbank-access.service';
import type { TbankAccountContext } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
describe('TbankAccessService', () => {
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const token = {
decrypt: jest.fn(),
translateInvalidToken: jest.fn((operation: () => Promise<unknown>) =>
new TbankTokenService({
getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
} as never).translateInvalidToken(operation),
),
};
const tbank = { getOpenAccounts: jest.fn() };
const service = new TbankAccessService(
prisma as never,
token as never,
tbank as never,
);
beforeEach(() => {
jest.clearAllMocks();
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: 'encrypted',
});
token.decrypt.mockReturnValue('plain-token');
tbank.getOpenAccounts.mockResolvedValue([
{
id: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: ' Основной ',
openedDate: { seconds: '1723507200' },
},
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
]);
});
it('lists supported accounts through the saved decrypted token', async () => {
await expect(service.listSupportedAccounts('user')).resolves.toEqual([
{
id: 'account',
type: 'BROKERAGE',
name: 'Основной',
openedOn: '2024-08-13',
},
]);
expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({
where: { userId: 'user' },
});
expect(token.decrypt).toHaveBeenCalledWith('encrypted');
expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token');
});
it('rejects missing connections before calling T-Bank', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null);
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
tbank.getOpenAccounts.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'),
);
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
expect.any(BadRequestException),
);
const unavailable = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
tbank.getOpenAccounts.mockRejectedValueOnce(unavailable);
await expect(service.listSupportedAccounts('user')).rejects.toBe(
unavailable,
);
});
it('wraps account-scoped handlers with resolved account access', async () => {
const handler = jest.fn((context: TbankAccountContext) =>
Promise.resolve({
resolvedToken: context.token,
accountId: context.account.id,
}),
);
await expect(
service.withAccount('user', 'account', handler),
).resolves.toEqual({
resolvedToken: 'plain-token',
accountId: 'account',
});
});
it('rejects inaccessible accounts before running the handler', async () => {
const handler = jest.fn();
await expect(
service.withAccount('user', 'missing', handler),
).rejects.toEqual(expect.any(NotFoundException));
expect(handler).not.toHaveBeenCalled();
});
it('translates invalid-token handler failures and preserves unrelated handler failures', async () => {
await expect(
service.withAccount('user', 'account', () =>
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
),
).rejects.toEqual(expect.any(BadRequestException));
const failure = new Error('source failed');
await expect(
service.withAccount('user', 'account', () => Promise.reject(failure)),
).rejects.toBe(failure);
});
});
+59
View File
@@ -0,0 +1,59 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { BrokerageAccountDto } from './brokerage-account.dto';
import {
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account-policy';
import { TbankClient, TbankAccount } from './tbank.client';
import { TbankTokenService } from './tbank-token.service';
export type TbankAccountContext = {
token: string;
account: TbankAccount;
};
@Injectable()
export class TbankAccessService {
constructor(
private readonly prisma: PrismaService,
private readonly tokens: TbankTokenService,
private readonly tbank: TbankClient,
) {}
async listSupportedAccounts(userId: string): Promise<BrokerageAccountDto[]> {
const { token } = await this.connection(userId);
return this.tokens.translateInvalidToken(async () =>
supportedTbankAccounts(await this.tbank.getOpenAccounts(token)),
);
}
async withAccount<T>(
userId: string,
accountId: string,
handler: (context: TbankAccountContext) => Promise<T>,
): Promise<T> {
const { token } = await this.connection(userId);
return this.tokens.translateInvalidToken(async () => {
const account = accessibleTbankAccount(
await this.tbank.getOpenAccounts(token),
accountId,
);
if (!account) throw new NotFoundException();
return handler({ token, account });
});
}
private async connection(userId: string): Promise<{ token: string }> {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
return { token: this.tokens.decrypt(connection.encryptedToken) };
}
}
+70
View File
@@ -0,0 +1,70 @@
import {
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account-policy';
describe('tbank-account-policy', () => {
it('normalizes supported account types and fallback names', () => {
expect(
supportedTbankAccounts([
{
id: 'brokerage',
type: 'ACCOUNT_TYPE_TINKOFF',
name: ' Основной ',
openedDate: { seconds: '1723507200' },
},
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Дебетовый' },
{ id: 'iis', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: ' ' },
{ id: 'box', type: 'ACCOUNT_TYPE_INVEST_BOX', name: ' ' },
{ id: 'fund', type: 'ACCOUNT_TYPE_INVEST_FUND', name: ' ' },
]),
).toEqual([
{
id: 'brokerage',
type: 'BROKERAGE',
name: 'Основной',
openedOn: '2024-08-13',
},
{
id: 'iis',
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
openedOn: null,
},
{
id: 'box',
type: 'INVEST_BOX',
name: 'Инвесткопилка',
openedOn: null,
},
{
id: 'fund',
type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка',
openedOn: null,
},
]);
});
it('matches account-scoped access only for supported account types', () => {
expect(
accessibleTbankAccount(
[
{ id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
{ id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' },
],
'account',
),
).toBeNull();
expect(
accessibleTbankAccount(
[{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }],
'account',
),
).toEqual({
id: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Brokerage',
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { BrokerageAccountDto } from './brokerage-account.dto';
import { TbankAccount } from './tbank.client';
const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: {
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
},
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: {
type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка',
},
} as const;
type AccountType = keyof typeof accountTypes;
export function supportedTbankAccounts(
accounts: TbankAccount[],
): BrokerageAccountDto[] {
return accounts.flatMap((account) => {
const details = accountTypes[account.type as AccountType];
if (!details) return [];
return [
{
id: account.id,
name: account.name.trim() || details.name,
type: details.type,
openedOn: openedOn(account.openedDate?.seconds),
},
];
});
}
export function accessibleTbankAccount(
accounts: TbankAccount[],
accountId: string,
): TbankAccount | null {
return (
accounts.find(
(account) => account.id === accountId && account.type in accountTypes,
) ?? null
);
}
function openedOn(seconds: string | number | undefined): string | null {
if (seconds === undefined) return null;
const date = new Date(Number(seconds) * 1000);
if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10);
}
+4
View File
@@ -7,8 +7,10 @@ import { MoexClient } from './moex.client';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
import { TbankAccessService } from './tbank-access.service';
import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionController } from './tbank-connection.controller';
import { TbankConnectionService } from './tbank-connection.service'; import { TbankConnectionService } from './tbank-connection.service';
import { TbankTokenService } from './tbank-token.service';
import { UpcomingEventsController } from './upcoming-events.controller'; import { UpcomingEventsController } from './upcoming-events.controller';
import { UpcomingEventsService } from './upcoming-events.service'; import { UpcomingEventsService } from './upcoming-events.service';
@@ -28,6 +30,8 @@ import { UpcomingEventsService } from './upcoming-events.service';
PortfolioService, PortfolioService,
MoexClient, MoexClient,
UpcomingEventsService, UpcomingEventsService,
TbankTokenService,
TbankAccessService,
], ],
}) })
export class TbankConnectionModule {} export class TbankConnectionModule {}
+48 -5
View File
@@ -15,8 +15,19 @@ describe('TbankConnectionService', () => {
deleteMany: jest.fn(), deleteMany: jest.fn(),
}, },
}; };
const config = { const tokens = {
getOrThrow: jest.fn(() => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='), encrypt: jest.fn((value: string) => `encrypted:${value}`),
translateInvalidToken: jest.fn(
async (operation: () => Promise<unknown>) => {
try {
return await operation();
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
throw error;
}
},
),
}; };
const tbank: jest.Mocked<Pick<TbankClient, 'validateToken'>> = { const tbank: jest.Mocked<Pick<TbankClient, 'validateToken'>> = {
validateToken: jest.fn(), validateToken: jest.fn(),
@@ -24,15 +35,18 @@ describe('TbankConnectionService', () => {
const portfolio = { invalidate: jest.fn() }; const portfolio = { invalidate: jest.fn() };
const service = new TbankConnectionService( const service = new TbankConnectionService(
prisma as never, prisma as never,
config as never, tokens as never,
tbank, tbank,
portfolio as never, portfolio as never,
); );
beforeEach(() => jest.clearAllMocks()); beforeEach(() => {
jest.clearAllMocks();
tbank.validateToken.mockResolvedValue(undefined);
});
it('does not persist an invalid token', async () => { it('does not persist an invalid token', async () => {
tbank.validateToken.mockRejectedValue(new Error('INVALID_TBANK_TOKEN')); tbank.validateToken.mockRejectedValueOnce(new Error('INVALID_TBANK_TOKEN'));
await expect(service.put('user-1', ' token ')).rejects.toEqual( await expect(service.put('user-1', ' token ')).rejects.toEqual(
expect.any(BadRequestException), expect.any(BadRequestException),
@@ -40,6 +54,35 @@ describe('TbankConnectionService', () => {
expect(prisma.tbankConnection.upsert).not.toHaveBeenCalled(); expect(prisma.tbankConnection.upsert).not.toHaveBeenCalled();
}); });
it('validates and encrypts a candidate token before persisting it', async () => {
const connectedAt = new Date('2026-08-25T09:00:00.000Z');
prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt });
await expect(service.put('user-1', ' token ')).resolves.toEqual({
connected: true,
connectedAt: '2026-08-25T09:00:00.000Z',
});
expect(tokens.translateInvalidToken).toHaveBeenCalledWith(
expect.any(Function),
);
expect(tbank.validateToken).toHaveBeenCalledWith('token');
expect(tokens.encrypt).toHaveBeenCalledWith('token');
expect(prisma.tbankConnection.upsert).toHaveBeenCalledWith({
where: { userId: 'user-1' },
create: {
userId: 'user-1',
encryptedToken: 'encrypted:token',
connectedAt: expect.any(Date) as Date,
},
update: {
encryptedToken: 'encrypted:token',
connectedAt: expect.any(Date) as Date,
},
});
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
});
it('deletes a connection idempotently', async () => { it('deletes a connection idempotently', async () => {
await service.delete('user-1'); await service.delete('user-1');
+8 -21
View File
@@ -1,27 +1,19 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
import { TbankCrypto } from './tbank.crypto';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { TbankTokenService } from './tbank-token.service';
type TbankTokenValidator = Pick<TbankClient, 'validateToken'>; type TbankTokenValidator = Pick<TbankClient, 'validateToken'>;
@Injectable() @Injectable()
export class TbankConnectionService { export class TbankConnectionService {
private readonly crypto: TbankCrypto;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
config: ConfigService<Environment, true>, private readonly tokens: TbankTokenService,
@Inject(TbankClient) private readonly tbank: TbankTokenValidator, @Inject(TbankClient) private readonly tbank: TbankTokenValidator,
private readonly portfolio: PortfolioService, private readonly portfolio: PortfolioService,
) { ) {}
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
async status(userId: string) { async status(userId: string) {
const connection = await this.prisma.tbankConnection.findUnique({ const connection = await this.prisma.tbankConnection.findUnique({
@@ -36,23 +28,18 @@ export class TbankConnectionService {
async put(userId: string, value: string) { async put(userId: string, value: string) {
const token = value.trim(); const token = value.trim();
if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' }); if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' });
try { await this.tokens.translateInvalidToken(() =>
await this.tbank.validateToken(token); this.tbank.validateToken(token),
} catch (error) { );
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
}
throw error;
}
const connection = await this.prisma.tbankConnection.upsert({ const connection = await this.prisma.tbankConnection.upsert({
where: { userId }, where: { userId },
create: { create: {
userId, userId,
encryptedToken: this.crypto.encrypt(token), encryptedToken: this.tokens.encrypt(token),
connectedAt: new Date(), connectedAt: new Date(),
}, },
update: { update: {
encryptedToken: this.crypto.encrypt(token), encryptedToken: this.tokens.encrypt(token),
connectedAt: new Date(), connectedAt: new Date(),
}, },
}); });
+41
View File
@@ -0,0 +1,41 @@
import {
BadRequestException,
ServiceUnavailableException,
} from '@nestjs/common';
import { TbankTokenService } from './tbank-token.service';
describe('TbankTokenService', () => {
const config = {
getOrThrow: jest.fn(() => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='),
};
let service: TbankTokenService;
beforeEach(() => {
jest.clearAllMocks();
service = new TbankTokenService(config as never);
});
it('encrypts and decrypts a token through the configured crypto', () => {
const encrypted = service.encrypt('secret-token');
expect(encrypted).not.toContain('secret-token');
expect(service.decrypt(encrypted)).toBe('secret-token');
expect(config.getOrThrow).toHaveBeenCalledWith('TOKEN_ENCRYPTION_KEY');
});
it('translates invalid T-Bank token errors and preserves unrelated failures', async () => {
await expect(
service.translateInvalidToken(() =>
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
),
).rejects.toEqual(expect.any(BadRequestException));
const unavailable = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
await expect(
service.translateInvalidToken(() => Promise.reject(unavailable)),
).rejects.toBe(unavailable);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { TbankCrypto } from './tbank.crypto';
@Injectable()
export class TbankTokenService {
private readonly crypto: TbankCrypto;
constructor(config: ConfigService<Environment, true>) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
encrypt(value: string): string {
return this.crypto.encrypt(value);
}
decrypt(payload: string): string {
return this.crypto.decrypt(payload);
}
async translateInvalidToken<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
throw error;
}
}
}
+19 -19
View File
@@ -4,19 +4,16 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({ jest.mock('./tbank-access.service', () => ({
PrismaService: class PrismaService {}, TbankAccessService: class TbankAccessService {},
})); }));
import { UpcomingEventsService } from './upcoming-events.service'; import { UpcomingEventsService } from './upcoming-events.service';
import { TbankCrypto } from './tbank.crypto'; import type { TbankAccountContext } from './tbank-access.service';
describe('UpcomingEventsService', () => { describe('UpcomingEventsService', () => {
const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; const access = { withAccount: jest.fn() };
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => key) };
const tbank = { const tbank = {
getOpenAccounts: jest.fn(),
getPortfolio: jest.fn(), getPortfolio: jest.fn(),
getInstrumentByUid: jest.fn(), getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(), getInstrumentByFigi: jest.fn(),
@@ -25,25 +22,28 @@ describe('UpcomingEventsService', () => {
const moex = { bondEvents: jest.fn() }; const moex = { bondEvents: jest.fn() };
const requestContext = { set: jest.fn() }; const requestContext = { set: jest.fn() };
const logger = { info: jest.fn(), event: jest.fn() }; const logger = { info: jest.fn(), event: jest.fn() };
const crypto = TbankCrypto.fromBase64(key);
let service: UpcomingEventsService; let service: UpcomingEventsService;
beforeEach(() => { beforeEach(() => {
service = new UpcomingEventsService( service = new UpcomingEventsService(
prisma as never, access as never,
config as never,
tbank as never, tbank as never,
moex as never, moex as never,
requestContext as never, requestContext as never,
logger as never, logger as never,
); );
jest.clearAllMocks(); jest.clearAllMocks();
prisma.tbankConnection.findUnique.mockResolvedValue({ access.withAccount.mockImplementation(
encryptedToken: crypto.encrypt('token'), (
}); _userId: string,
tbank.getOpenAccounts.mockResolvedValue([ _accountId: string,
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, handler: (context: TbankAccountContext) => Promise<unknown>,
]); ) =>
handler({
token: 'token',
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' }); tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' });
moex.bondEvents.mockResolvedValue([]); moex.bondEvents.mockResolvedValue([]);
tbank.getDividends.mockResolvedValue([]); tbank.getDividends.mockResolvedValue([]);
@@ -102,7 +102,7 @@ describe('UpcomingEventsService', () => {
}); });
it('does not read upstream data for inaccessible accounts', async () => { it('does not read upstream data for inaccessible accounts', async () => {
tbank.getOpenAccounts.mockResolvedValue([]); access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account')).rejects.toEqual( await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(NotFoundException), expect.any(NotFoundException),
); );
@@ -204,8 +204,8 @@ describe('UpcomingEventsService', () => {
}); });
it('preserves invalid token and source failures without a partial result', async () => { it('preserves invalid token and source failures without a partial result', async () => {
tbank.getOpenAccounts.mockRejectedValueOnce( access.withAccount.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'), new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
); );
await expect(service.get('user', 'account')).rejects.toEqual( await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException), expect.any(BadRequestException),
+6 -45
View File
@@ -1,32 +1,17 @@
import { import { Injectable } from '@nestjs/common';
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { RequestContextService } from '../common/logging/request-context.service'; import { RequestContextService } from '../common/logging/request-context.service';
import { StructuredLogger } from '../common/logging/structured-logger.service'; import { StructuredLogger } from '../common/logging/structured-logger.service';
import { PrismaService } from '../prisma/prisma.service';
import { decimal, formatDecimal, isZero, multiply } from './decimal'; import { decimal, formatDecimal, isZero, multiply } from './decimal';
import { MoexBondEvent, MoexClient } from './moex.client'; import { MoexBondEvent, MoexClient } from './moex.client';
import { TbankAccessService } from './tbank-access.service';
import { import {
TbankClient, TbankClient,
TbankDividend, TbankDividend,
TbankInstrument, TbankInstrument,
TbankPortfolio, TbankPortfolio,
} from './tbank.client'; } from './tbank.client';
import { TbankCrypto } from './tbank.crypto';
import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto'; import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto';
const supportedAccounts = new Set([
'ACCOUNT_TYPE_TINKOFF',
'ACCOUNT_TYPE_TINKOFF_IIS',
'ACCOUNT_TYPE_INVEST_BOX',
'ACCOUNT_TYPE_INVEST_FUND',
]);
type Position = NonNullable<TbankPortfolio['positions']>[number]; type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> }; type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
@@ -36,7 +21,6 @@ const instrumentCacheTtlMs = 24 * 60 * 60_000;
@Injectable() @Injectable()
export class UpcomingEventsService { export class UpcomingEventsService {
private readonly crypto: TbankCrypto;
private readonly dividendCache = new Map< private readonly dividendCache = new Map<
string, string,
CachedEntry<TbankDividend[]> CachedEntry<TbankDividend[]>
@@ -47,35 +31,16 @@ export class UpcomingEventsService {
>(); >();
constructor( constructor(
private readonly prisma: PrismaService, private readonly access: TbankAccessService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly moex: MoexClient, private readonly moex: MoexClient,
private readonly requestContext: RequestContextService, private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
) { ) {}
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
async get(userId: string, accountId: string): Promise<UpcomingEventsDto> { async get(userId: string, accountId: string): Promise<UpcomingEventsDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId }); this.requestContext.set({ userId, brokerageAccountId: accountId });
try { return this.access.withAccount(userId, accountId, async ({ token }) => {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
const token = this.crypto.decrypt(connection.encryptedToken);
const accounts = await this.tbank.getOpenAccounts(token);
if (
!accounts.some(
(account) =>
account.id === accountId && supportedAccounts.has(account.type),
)
)
throw new NotFoundException();
const portfolio = await this.tbank.getPortfolio(token, accountId); const portfolio = await this.tbank.getPortfolio(token, accountId);
const window = dateWindow(); const window = dateWindow();
const supportedPositions = (portfolio.positions ?? []).filter(supported); const supportedPositions = (portfolio.positions ?? []).filter(supported);
@@ -113,11 +78,7 @@ export class UpcomingEventsService {
left.ticker.localeCompare(right.ticker), left.ticker.localeCompare(right.ticker),
), ),
}; };
} catch (error) { });
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
throw error;
}
} }
private async positionEvents( private async positionEvents(