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