refactor(tbank): centralize access rules
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user