From 130b303ec123f144d3833e5c7afa88d40f16f081 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 25 Aug 2026 12:06:41 +0300 Subject: [PATCH] refactor(tbank): centralize access rules --- AGENTS.md | 11 +- src/tbank/brokerage-accounts.service.spec.ts | 25 ++-- src/tbank/brokerage-accounts.service.ts | 75 +--------- .../brokerage-operations.service.spec.ts | 45 +++--- src/tbank/brokerage-operations.service.ts | 51 +------ src/tbank/portfolio.service.spec.ts | 64 ++++----- src/tbank/portfolio.service.ts | 69 +++------- src/tbank/tbank-access.service.spec.ts | 130 ++++++++++++++++++ src/tbank/tbank-access.service.ts | 59 ++++++++ src/tbank/tbank-account-policy.spec.ts | 70 ++++++++++ src/tbank/tbank-account-policy.ts | 52 +++++++ src/tbank/tbank-connection.module.ts | 4 + src/tbank/tbank-connection.service.spec.ts | 53 ++++++- src/tbank/tbank-connection.service.ts | 29 ++-- src/tbank/tbank-token.service.spec.ts | 41 ++++++ src/tbank/tbank-token.service.ts | 33 +++++ src/tbank/upcoming-events.service.spec.ts | 38 ++--- src/tbank/upcoming-events.service.ts | 51 +------ 18 files changed, 573 insertions(+), 327 deletions(-) create mode 100644 src/tbank/tbank-access.service.spec.ts create mode 100644 src/tbank/tbank-access.service.ts create mode 100644 src/tbank/tbank-account-policy.spec.ts create mode 100644 src/tbank/tbank-account-policy.ts create mode 100644 src/tbank/tbank-token.service.spec.ts create mode 100644 src/tbank/tbank-token.service.ts diff --git a/AGENTS.md b/AGENTS.md index 08ac781..5adb890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,15 @@ ## Назначение и 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 @@ -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. +Актуальные scripts проверять в `package.json`/`npm run`. ## Enforceable boundaries diff --git a/src/tbank/brokerage-accounts.service.spec.ts b/src/tbank/brokerage-accounts.service.spec.ts index 7a28630..651a2ad 100644 --- a/src/tbank/brokerage-accounts.service.spec.ts +++ b/src/tbank/brokerage-accounts.service.spec.ts @@ -1,24 +1,29 @@ -import { BadRequestException, ConflictException } from '@nestjs/common'; -import { ServiceUnavailableException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ServiceUnavailableException, +} from '@nestjs/common'; jest.mock('../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); 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', () => { const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; const prisma = { tbankConnection: { findUnique: jest.fn() } }; const config = { getOrThrow: jest.fn(() => encryptionKey) }; const tbank = { getOpenAccounts: jest.fn() }; - const service = new BrokerageAccountsService( + const tokens = new TbankTokenService(config as never); + const access = new TbankAccessService( prisma as never, - config as never, + tokens, tbank as never, ); - const crypto = TbankCrypto.fromBase64(encryptionKey); + const service = new BrokerageAccountsService(access); beforeEach(() => jest.clearAllMocks()); @@ -33,7 +38,7 @@ describe('BrokerageAccountsService', () => { it('normalizes supported accounts and ignores unsupported ones', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: crypto.encrypt('token'), + encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([ { @@ -72,7 +77,7 @@ describe('BrokerageAccountsService', () => { it('retains the connection when T-Bank rejects its token', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: crypto.encrypt('token'), + encryptedToken: tokens.encrypt('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 () => { prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: crypto.encrypt('token'), + encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([]); @@ -92,7 +97,7 @@ describe('BrokerageAccountsService', () => { it('propagates an unavailable T-Bank response', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: crypto.encrypt('token'), + encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockRejectedValue( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), diff --git a/src/tbank/brokerage-accounts.service.ts b/src/tbank/brokerage-accounts.service.ts index 69b876a..2bb1519 100644 --- a/src/tbank/brokerage-accounts.service.ts +++ b/src/tbank/brokerage-accounts.service.ts @@ -1,79 +1,12 @@ -import { - BadRequestException, - ConflictException, - Injectable, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Environment } from '../config/env.validation'; -import { PrismaService } from '../prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; import { BrokerageAccountDto } from './brokerage-account.dto'; -import { TbankClient } from './tbank.client'; -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; +import { TbankAccessService } from './tbank-access.service'; @Injectable() export class BrokerageAccountsService { - private readonly crypto: TbankCrypto; - - constructor( - private readonly prisma: PrismaService, - config: ConfigService, - private readonly tbank: TbankClient, - ) { - this.crypto = TbankCrypto.fromBase64( - config.getOrThrow('TOKEN_ENCRYPTION_KEY'), - ); - } + constructor(private readonly access: TbankAccessService) {} async list(userId: string): Promise { - const connection = await this.prisma.tbankConnection.findUnique({ - 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); + return this.access.listSupportedAccounts(userId); } } diff --git a/src/tbank/brokerage-operations.service.spec.ts b/src/tbank/brokerage-operations.service.spec.ts index bbabaad..f8f4c89 100644 --- a/src/tbank/brokerage-operations.service.spec.ts +++ b/src/tbank/brokerage-operations.service.spec.ts @@ -4,41 +4,41 @@ import { ServiceUnavailableException, } from '@nestjs/common'; -jest.mock('../prisma/prisma.service', () => ({ - PrismaService: class PrismaService {}, +jest.mock('./tbank-access.service', () => ({ + TbankAccessService: class TbankAccessService {}, })); import { BrokerageOperationsService } from './brokerage-operations.service'; -import { TbankCrypto } from './tbank.crypto'; +import type { TbankAccountContext } from './tbank-access.service'; describe('BrokerageOperationsService', () => { - const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; - const prisma = { tbankConnection: { findUnique: jest.fn() } }; - const config = { getOrThrow: jest.fn(() => key) }; + const access = { withAccount: jest.fn() }; const tbank = { - getOpenAccounts: jest.fn(), getOperationsByCursor: jest.fn(), }; const requestContext = { set: jest.fn() }; const service = new BrokerageOperationsService( - prisma as never, - config as never, + access as never, tbank as never, requestContext as never, ); - const crypto = TbankCrypto.fromBase64(key); let operationsRequest: unknown; beforeEach(() => { jest.clearAllMocks(); - tbank.getOpenAccounts.mockReset(); + access.withAccount.mockReset(); tbank.getOperationsByCursor.mockReset(); - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: crypto.encrypt('token'), - }); - tbank.getOpenAccounts.mockResolvedValue([ - { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, - ]); + access.withAccount.mockImplementation( + ( + _userId: string, + _accountId: string, + handler: (context: TbankAccountContext) => Promise, + ) => + handler({ + token: 'token', + account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' }, + }), + ); tbank.getOperationsByCursor.mockImplementation( (_token: string, request: unknown) => { operationsRequest = request; @@ -116,7 +116,7 @@ describe('BrokerageOperationsService', () => { }); it('rejects inaccessible accounts before reading operations', async () => { - tbank.getOpenAccounts.mockResolvedValue([]); + access.withAccount.mockRejectedValueOnce(new NotFoundException()); await expect(service.get('user', 'account', {})).rejects.toEqual( expect.any(NotFoundException), @@ -125,15 +125,12 @@ describe('BrokerageOperationsService', () => { }); it('retains existing T-Bank error semantics', async () => { - tbank.getOpenAccounts.mockRejectedValueOnce( - new Error('INVALID_TBANK_TOKEN'), + access.withAccount.mockRejectedValueOnce( + new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), ); await expect(service.get('user', 'account', {})).rejects.toEqual( expect.any(BadRequestException), ); - tbank.getOpenAccounts.mockResolvedValueOnce([ - { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, - ]); tbank.getOperationsByCursor.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); @@ -158,7 +155,7 @@ describe('BrokerageOperationsService', () => { it('does not let a cached page bypass account ownership checks', async () => { const query = { from: '2025-01-01', to: '2025-01-31' }; await service.get('user', 'account', query); - tbank.getOpenAccounts.mockResolvedValueOnce([]); + access.withAccount.mockRejectedValueOnce(new NotFoundException()); await expect(service.get('user', 'account', query)).rejects.toEqual( expect.any(NotFoundException), diff --git a/src/tbank/brokerage-operations.service.ts b/src/tbank/brokerage-operations.service.ts index 184f68f..c4abf24 100644 --- a/src/tbank/brokerage-operations.service.ts +++ b/src/tbank/brokerage-operations.service.ts @@ -1,14 +1,6 @@ -import { - BadRequestException, - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable } from '@nestjs/common'; import { LRUCache } from 'lru-cache'; import { RequestContextService } from '../common/logging/request-context.service'; -import { Environment } from '../config/env.validation'; -import { PrismaService } from '../prisma/prisma.service'; import { BrokerageOperationsPageDto, BrokerageOperationsQueryDto, @@ -17,21 +9,14 @@ import { mapBrokerageOperation, operationCategory, } from './brokerage-operation.mapper'; +import { TbankAccessService } from './tbank-access.service'; import { TbankClient } from './tbank.client'; -import { TbankCrypto } from './tbank.crypto'; const freshForMs = 30_000; const maxCachedPages = 100; -const supportedAccounts = new Set([ - 'ACCOUNT_TYPE_TINKOFF', - 'ACCOUNT_TYPE_TINKOFF_IIS', - 'ACCOUNT_TYPE_INVEST_BOX', - 'ACCOUNT_TYPE_INVEST_FUND', -]); @Injectable() export class BrokerageOperationsService { - private readonly crypto: TbankCrypto; private readonly pages = new LRUCache({ max: maxCachedPages, ttl: freshForMs, @@ -39,15 +24,10 @@ export class BrokerageOperationsService { }); constructor( - private readonly prisma: PrismaService, - config: ConfigService, + private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly requestContext: RequestContextService, - ) { - this.crypto = TbankCrypto.fromBase64( - config.getOrThrow('TOKEN_ENCRYPTION_KEY'), - ); - } + ) {} async get( userId: string, @@ -57,22 +37,7 @@ export class BrokerageOperationsService { this.requestContext.set({ userId, brokerageAccountId: accountId }); const range = this.range(query); - try { - 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(); - + return this.access.withAccount(userId, accountId, async ({ token }) => { const key = cacheKey(userId, accountId, query, range); const cached = this.pages.get(key); if (cached) return cached; @@ -106,11 +71,7 @@ export class BrokerageOperationsService { }; this.pages.set(key, 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) { diff --git a/src/tbank/portfolio.service.spec.ts b/src/tbank/portfolio.service.spec.ts index da14570..ea4411e 100644 --- a/src/tbank/portfolio.service.spec.ts +++ b/src/tbank/portfolio.service.spec.ts @@ -1,23 +1,18 @@ import { BadRequestException, - ConflictException, - NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; -jest.mock('../prisma/prisma.service', () => ({ - PrismaService: class PrismaService {}, +jest.mock('./tbank-access.service', () => ({ + TbankAccessService: class TbankAccessService {}, })); import { PortfolioService } from './portfolio.service'; -import { TbankCrypto } from './tbank.crypto'; +import type { TbankAccountContext } from './tbank-access.service'; describe('PortfolioService', () => { - const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; - const prisma = { tbankConnection: { findUnique: jest.fn() } }; - const config = { getOrThrow: jest.fn(() => key) }; + const access = { withAccount: jest.fn() }; const tbank = { - getOpenAccounts: jest.fn(), getPortfolio: jest.fn(), getPositions: jest.fn(), getInstrumentByFigi: jest.fn(), @@ -26,39 +21,40 @@ describe('PortfolioService', () => { const requestContext = { set: jest.fn() }; const logger = { event: jest.fn() }; const service = new PortfolioService( - prisma as never, - config as never, + access as never, tbank as never, requestContext as never, logger as never, ); - const crypto = TbankCrypto.fromBase64(key); beforeEach(() => { jest.clearAllMocks(); service.invalidate('user'); - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: crypto.encrypt('token'), - }); - tbank.getOpenAccounts.mockResolvedValue([ - { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, - ]); + access.withAccount.mockImplementation( + ( + _userId: string, + _accountId: string, + handler: (context: TbankAccountContext) => Promise, + ) => + handler({ + token: 'token', + account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' }, + }), + ); tbank.getPortfolio.mockResolvedValue({ positions: [] }); tbank.getPositions.mockResolvedValue({ money: [] }); }); - it('requires a connection and an accessible brokerage account', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue(null); - await expect(service.get('user', 'account')).rejects.toEqual( - expect.any(ConflictException), - ); - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: crypto.encrypt('token'), - }); - tbank.getOpenAccounts.mockResolvedValue([]); - await expect(service.get('user', 'account')).rejects.toEqual( - expect.any(NotFoundException), + it('resolves account-scoped access before loading upstream portfolio', async () => { + await service.get('user', 'account'); + + expect(access.withAccount).toHaveBeenCalledWith( + 'user', + 'account', + expect.any(Function), ); + expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'account'); + expect(tbank.getPositions).toHaveBeenCalledWith('token', 'account'); }); 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 () => { await service.get('user', 'account'); await service.get('user', 'account'); - expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(1); + expect(access.withAccount).toHaveBeenCalledTimes(1); service.invalidate('user'); await service.get('user', 'account'); - expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(2); + expect(access.withAccount).toHaveBeenCalledTimes(2); const snapshots = ( service as never as { snapshots: Map } ).snapshots; snapshots.get('user:account')!.storedAt = Date.now() - 30_001; - tbank.getOpenAccounts.mockRejectedValueOnce( + access.withAccount.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); await expect(service.get('user', 'account')).resolves.toMatchObject({ @@ -170,8 +166,8 @@ describe('PortfolioService', () => { service as never as { snapshots: Map } ).snapshots; snapshots.get('user:account')!.storedAt = Date.now() - 30_001; - tbank.getOpenAccounts.mockRejectedValueOnce( - new Error('INVALID_TBANK_TOKEN'), + access.withAccount.mockRejectedValueOnce( + new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), ); await expect(service.get('user', 'account')).rejects.toEqual( expect.any(BadRequestException), diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts index f0b3bca..38787b3 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/tbank/portfolio.service.ts @@ -1,15 +1,6 @@ -import { - BadRequestException, - ConflictException, - Injectable, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Environment } from '../config/env.validation'; +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { RequestContextService } from '../common/logging/request-context.service'; import { StructuredLogger } from '../common/logging/structured-logger.service'; -import { PrismaService } from '../prisma/prisma.service'; import { add, decimal, @@ -19,18 +10,12 @@ import { subtract, } from './decimal'; import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; +import { TbankAccessService } from './tbank-access.service'; import { TbankClient, TbankMoney } from './tbank.client'; -import { TbankCrypto } from './tbank.crypto'; const freshForMs = 30_000; const fallbackForMs = 300_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 = { share: 'STOCK', bond: 'BOND', @@ -55,21 +40,15 @@ type CachedMetadata = { @Injectable() export class PortfolioService { - private readonly crypto: TbankCrypto; private readonly snapshots = new Map(); private readonly metadata = new Map(); constructor( - private readonly prisma: PrismaService, - config: ConfigService, + private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly requestContext: RequestContextService, private readonly logger: StructuredLogger, - ) { - this.crypto = TbankCrypto.fromBase64( - config.getOrThrow('TOKEN_ENCRYPTION_KEY'), - ); - } + ) {} async get(userId: string, accountId: string): Promise { this.requestContext.set({ userId, brokerageAccountId: accountId }); @@ -78,36 +57,24 @@ export class PortfolioService { if (cached && Date.now() - cached.storedAt <= freshForMs) return cached.snapshot; try { - 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, positions] = await Promise.all([ - this.tbank.getPortfolio(token, accountId), - this.tbank.getPositions(token, accountId), - ]); - const snapshot = await this.normalize( - token, - portfolio.positions ?? [], - positions.money ?? [], + const snapshot = await this.access.withAccount( + userId, + accountId, + async ({ token }) => { + const [portfolio, positions] = await Promise.all([ + this.tbank.getPortfolio(token, accountId), + this.tbank.getPositions(token, accountId), + ]); + return this.normalize( + token, + portfolio.positions ?? [], + positions.money ?? [], + ); + }, ); this.snapshots.set(key, { snapshot, storedAt: Date.now() }); return snapshot; } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); - } if ( error instanceof ServiceUnavailableException && cached && diff --git a/src/tbank/tbank-access.service.spec.ts b/src/tbank/tbank-access.service.spec.ts new file mode 100644 index 0000000..322a7a6 --- /dev/null +++ b/src/tbank/tbank-access.service.spec.ts @@ -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) => + 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); + }); +}); diff --git a/src/tbank/tbank-access.service.ts b/src/tbank/tbank-access.service.ts new file mode 100644 index 0000000..a849f9f --- /dev/null +++ b/src/tbank/tbank-access.service.ts @@ -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 { + const { token } = await this.connection(userId); + return this.tokens.translateInvalidToken(async () => + supportedTbankAccounts(await this.tbank.getOpenAccounts(token)), + ); + } + + async withAccount( + userId: string, + accountId: string, + handler: (context: TbankAccountContext) => Promise, + ): Promise { + 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) }; + } +} diff --git a/src/tbank/tbank-account-policy.spec.ts b/src/tbank/tbank-account-policy.spec.ts new file mode 100644 index 0000000..607faa6 --- /dev/null +++ b/src/tbank/tbank-account-policy.spec.ts @@ -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', + }); + }); +}); diff --git a/src/tbank/tbank-account-policy.ts b/src/tbank/tbank-account-policy.ts new file mode 100644 index 0000000..2e6351f --- /dev/null +++ b/src/tbank/tbank-account-policy.ts @@ -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); +} diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index be3ee89..56930a3 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -7,8 +7,10 @@ import { MoexClient } from './moex.client'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; import { TbankClient } from './tbank.client'; +import { TbankAccessService } from './tbank-access.service'; import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionService } from './tbank-connection.service'; +import { TbankTokenService } from './tbank-token.service'; import { UpcomingEventsController } from './upcoming-events.controller'; import { UpcomingEventsService } from './upcoming-events.service'; @@ -28,6 +30,8 @@ import { UpcomingEventsService } from './upcoming-events.service'; PortfolioService, MoexClient, UpcomingEventsService, + TbankTokenService, + TbankAccessService, ], }) export class TbankConnectionModule {} diff --git a/src/tbank/tbank-connection.service.spec.ts b/src/tbank/tbank-connection.service.spec.ts index 4a4cb83..74c25ad 100644 --- a/src/tbank/tbank-connection.service.spec.ts +++ b/src/tbank/tbank-connection.service.spec.ts @@ -15,8 +15,19 @@ describe('TbankConnectionService', () => { deleteMany: jest.fn(), }, }; - const config = { - getOrThrow: jest.fn(() => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='), + const tokens = { + encrypt: jest.fn((value: string) => `encrypted:${value}`), + translateInvalidToken: jest.fn( + async (operation: () => Promise) => { + 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> = { validateToken: jest.fn(), @@ -24,15 +35,18 @@ describe('TbankConnectionService', () => { const portfolio = { invalidate: jest.fn() }; const service = new TbankConnectionService( prisma as never, - config as never, + tokens as never, tbank, portfolio as never, ); - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + tbank.validateToken.mockResolvedValue(undefined); + }); 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( expect.any(BadRequestException), @@ -40,6 +54,35 @@ describe('TbankConnectionService', () => { 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 () => { await service.delete('user-1'); diff --git a/src/tbank/tbank-connection.service.ts b/src/tbank/tbank-connection.service.ts index 8c5cfd6..0598cc3 100644 --- a/src/tbank/tbank-connection.service.ts +++ b/src/tbank/tbank-connection.service.ts @@ -1,27 +1,19 @@ 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 { TbankClient } from './tbank.client'; -import { TbankCrypto } from './tbank.crypto'; import { PortfolioService } from './portfolio.service'; +import { TbankTokenService } from './tbank-token.service'; type TbankTokenValidator = Pick; @Injectable() export class TbankConnectionService { - private readonly crypto: TbankCrypto; - constructor( private readonly prisma: PrismaService, - config: ConfigService, + private readonly tokens: TbankTokenService, @Inject(TbankClient) private readonly tbank: TbankTokenValidator, private readonly portfolio: PortfolioService, - ) { - this.crypto = TbankCrypto.fromBase64( - config.getOrThrow('TOKEN_ENCRYPTION_KEY'), - ); - } + ) {} async status(userId: string) { const connection = await this.prisma.tbankConnection.findUnique({ @@ -36,23 +28,18 @@ export class TbankConnectionService { async put(userId: string, value: string) { const token = value.trim(); if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' }); - try { - await this.tbank.validateToken(token); - } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); - } - throw error; - } + await this.tokens.translateInvalidToken(() => + this.tbank.validateToken(token), + ); const connection = await this.prisma.tbankConnection.upsert({ where: { userId }, create: { userId, - encryptedToken: this.crypto.encrypt(token), + encryptedToken: this.tokens.encrypt(token), connectedAt: new Date(), }, update: { - encryptedToken: this.crypto.encrypt(token), + encryptedToken: this.tokens.encrypt(token), connectedAt: new Date(), }, }); diff --git a/src/tbank/tbank-token.service.spec.ts b/src/tbank/tbank-token.service.spec.ts new file mode 100644 index 0000000..17c298d --- /dev/null +++ b/src/tbank/tbank-token.service.spec.ts @@ -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); + }); +}); diff --git a/src/tbank/tbank-token.service.ts b/src/tbank/tbank-token.service.ts new file mode 100644 index 0000000..eace0b9 --- /dev/null +++ b/src/tbank/tbank-token.service.ts @@ -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) { + 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(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + throw error; + } + } +} diff --git a/src/tbank/upcoming-events.service.spec.ts b/src/tbank/upcoming-events.service.spec.ts index 9813d76..e0ef4a1 100644 --- a/src/tbank/upcoming-events.service.spec.ts +++ b/src/tbank/upcoming-events.service.spec.ts @@ -4,19 +4,16 @@ import { ServiceUnavailableException, } from '@nestjs/common'; -jest.mock('../prisma/prisma.service', () => ({ - PrismaService: class PrismaService {}, +jest.mock('./tbank-access.service', () => ({ + TbankAccessService: class TbankAccessService {}, })); import { UpcomingEventsService } from './upcoming-events.service'; -import { TbankCrypto } from './tbank.crypto'; +import type { TbankAccountContext } from './tbank-access.service'; describe('UpcomingEventsService', () => { - const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; - const prisma = { tbankConnection: { findUnique: jest.fn() } }; - const config = { getOrThrow: jest.fn(() => key) }; + const access = { withAccount: jest.fn() }; const tbank = { - getOpenAccounts: jest.fn(), getPortfolio: jest.fn(), getInstrumentByUid: jest.fn(), getInstrumentByFigi: jest.fn(), @@ -25,25 +22,28 @@ describe('UpcomingEventsService', () => { const moex = { bondEvents: jest.fn() }; const requestContext = { set: jest.fn() }; const logger = { info: jest.fn(), event: jest.fn() }; - const crypto = TbankCrypto.fromBase64(key); let service: UpcomingEventsService; beforeEach(() => { service = new UpcomingEventsService( - prisma as never, - config as never, + access as never, tbank as never, moex as never, requestContext as never, logger as never, ); jest.clearAllMocks(); - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: crypto.encrypt('token'), - }); - tbank.getOpenAccounts.mockResolvedValue([ - { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, - ]); + access.withAccount.mockImplementation( + ( + _userId: string, + _accountId: string, + handler: (context: TbankAccountContext) => Promise, + ) => + handler({ + token: 'token', + account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' }, + }), + ); tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' }); moex.bondEvents.mockResolvedValue([]); tbank.getDividends.mockResolvedValue([]); @@ -102,7 +102,7 @@ describe('UpcomingEventsService', () => { }); 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( expect.any(NotFoundException), ); @@ -204,8 +204,8 @@ describe('UpcomingEventsService', () => { }); it('preserves invalid token and source failures without a partial result', async () => { - tbank.getOpenAccounts.mockRejectedValueOnce( - new Error('INVALID_TBANK_TOKEN'), + access.withAccount.mockRejectedValueOnce( + new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), ); await expect(service.get('user', 'account')).rejects.toEqual( expect.any(BadRequestException), diff --git a/src/tbank/upcoming-events.service.ts b/src/tbank/upcoming-events.service.ts index 38d725d..8566a4f 100644 --- a/src/tbank/upcoming-events.service.ts +++ b/src/tbank/upcoming-events.service.ts @@ -1,32 +1,17 @@ -import { - BadRequestException, - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Environment } from '../config/env.validation'; +import { Injectable } from '@nestjs/common'; import { RequestContextService } from '../common/logging/request-context.service'; import { StructuredLogger } from '../common/logging/structured-logger.service'; -import { PrismaService } from '../prisma/prisma.service'; import { decimal, formatDecimal, isZero, multiply } from './decimal'; import { MoexBondEvent, MoexClient } from './moex.client'; +import { TbankAccessService } from './tbank-access.service'; import { TbankClient, TbankDividend, TbankInstrument, TbankPortfolio, } from './tbank.client'; -import { TbankCrypto } from './tbank.crypto'; 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[number]; type CachedEntry = { expiresAt: number; promise: Promise }; @@ -36,7 +21,6 @@ const instrumentCacheTtlMs = 24 * 60 * 60_000; @Injectable() export class UpcomingEventsService { - private readonly crypto: TbankCrypto; private readonly dividendCache = new Map< string, CachedEntry @@ -47,35 +31,16 @@ export class UpcomingEventsService { >(); constructor( - private readonly prisma: PrismaService, - config: ConfigService, + private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly moex: MoexClient, private readonly requestContext: RequestContextService, private readonly logger: StructuredLogger, - ) { - this.crypto = TbankCrypto.fromBase64( - config.getOrThrow('TOKEN_ENCRYPTION_KEY'), - ); - } + ) {} async get(userId: string, accountId: string): Promise { this.requestContext.set({ userId, brokerageAccountId: accountId }); - try { - 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(); + return this.access.withAccount(userId, accountId, async ({ token }) => { const portfolio = await this.tbank.getPortfolio(token, accountId); const window = dateWindow(); const supportedPositions = (portfolio.positions ?? []).filter(supported); @@ -113,11 +78,7 @@ export class UpcomingEventsService { 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(