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
+15 -10
View File
@@ -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' }),
+4 -71
View File
@@ -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<Environment, true>,
private readonly tbank: TbankClient,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
constructor(private readonly access: TbankAccessService) {}
async list(userId: string): Promise<BrokerageAccountDto[]> {
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);
}
}
+21 -24
View File
@@ -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<unknown>,
) =>
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),
+6 -45
View File
@@ -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<string, BrokerageOperationsPageDto>({
max: maxCachedPages,
ttl: freshForMs,
@@ -39,15 +24,10 @@ export class BrokerageOperationsService {
});
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
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) {
+30 -34
View File
@@ -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<unknown>,
) =>
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<string, { storedAt: number }> }
).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<string, { storedAt: number }> }
).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),
+18 -51
View File
@@ -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<string, string> = {
share: 'STOCK',
bond: 'BOND',
@@ -55,21 +40,15 @@ type CachedMetadata = {
@Injectable()
export class PortfolioService {
private readonly crypto: TbankCrypto;
private readonly snapshots = new Map<string, CachedSnapshot>();
private readonly metadata = new Map<string, CachedMetadata>();
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
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<PortfolioDto> {
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 &&
+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 { 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 {}
+48 -5
View File
@@ -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<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'>> = {
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');
+8 -21
View File
@@ -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<TbankClient, 'validateToken'>;
@Injectable()
export class TbankConnectionService {
private readonly crypto: TbankCrypto;
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
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(),
},
});
+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,
} 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<unknown>,
) =>
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),
+6 -45
View File
@@ -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<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
@@ -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<TbankDividend[]>
@@ -47,35 +31,16 @@ export class UpcomingEventsService {
>();
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
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<UpcomingEventsDto> {
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(