refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,46 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto';
export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true })
declare totalsByCurrency: MoneyDto[];
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero stock positions, not the number of units.',
})
declare stockPositionCount: number;
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero bond positions, not the number of units.',
})
declare bondPositionCount: number;
}
export class BrokerageAccountDto {
@ApiProperty()
declare id: string;
@ApiProperty()
declare name: string;
@ApiProperty({
enum: [
'BROKERAGE',
'INDIVIDUAL_INVESTMENT',
'INVEST_BOX',
'MONEY_MARKET_FUND',
],
})
declare type:
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare openedOn: string | null;
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
portfolioSummary?: PortfolioSummaryDto | null;
}
@@ -0,0 +1,22 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class BrokerageAccountsController {
constructor(private readonly accounts: BrokerageAccountsService) {}
@Get()
@ApiOkResponse({ type: BrokerageAccountDto, isArray: true })
list(@Req() request: Request & { user: AccessPayload }) {
return this.accounts.list(request.user.sub);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({
imports: [AuthModule, TbankModule],
controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService],
})
export class BrokerageAccountsModule {}
@@ -0,0 +1,249 @@
import {
BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service';
import { BrokerageAccountsService } from './brokerage-accounts.service';
describe('BrokerageAccountsService', () => {
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => encryptionKey) };
const tbank = {
getOpenAccounts: jest.fn(),
getPortfolio: jest.fn(),
getPositions: jest.fn(),
};
const tokens = new TbankTokenService(config as never);
const access = new TbankAccessService(
prisma as never,
tokens,
tbank as never,
);
const service = new BrokerageAccountsService(access, tbank as never);
beforeEach(() => {
jest.clearAllMocks();
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
});
it('does not call T-Bank without a connection', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null);
await expect(service.list('user-1')).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('normalizes supported accounts and ignores unsupported ones', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{
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: ' ',
},
]);
await expect(service.list('user-1')).resolves.toEqual([
{
id: 'brokerage',
type: 'BROKERAGE',
name: 'Основной',
openedOn: '2024-08-13',
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
{
id: 'iis',
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
openedOn: null,
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
]);
});
it('keeps upstream order and adds exact summaries without loading metadata', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'first', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
{ id: 'second', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'Второй' },
]);
tbank.getPortfolio
.mockResolvedValueOnce({
positions: [
{
quantity: { units: 2 },
currentPrice: { currency: 'rub', units: 3 },
},
],
})
.mockResolvedValueOnce({ positions: [] });
tbank.getPositions
.mockResolvedValueOnce({ money: [{ currency: 'RUB', nano: 500000000 }] })
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 4 }] });
await expect(service.list('user-1')).resolves.toEqual([
{
id: 'first',
type: 'BROKERAGE',
name: 'Первый',
openedOn: null,
portfolioSummary: {
totalsByCurrency: [{ currency: 'RUB', value: '6.5' }],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
{
id: 'second',
type: 'INDIVIDUAL_INVESTMENT',
name: 'Второй',
openedOn: null,
portfolioSummary: {
totalsByCurrency: [{ currency: 'USD', value: '4' }],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
]);
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'first');
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second');
});
it('counts non-zero stock and bond positions even without a valuation', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
]);
tbank.getPortfolio.mockResolvedValue({
positions: [
{ instrumentType: 'share', quantity: { units: 200 } },
{ instrumentType: 'SHARE', quantity: { units: -5 } },
{ instrumentType: 'share', quantity: { units: 0, nano: 1 } },
{ instrumentType: 'bond', quantity: { units: 80 } },
{ instrumentType: 'bond', quantity: { units: 0 } },
{ instrumentType: 'share' },
{ instrumentType: 'etf', quantity: { units: 10 } },
{ instrumentType: 'currency', quantity: { units: 100 } },
{ quantity: { units: 1 } },
],
virtualPositions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
});
await expect(service.list('user-1')).resolves.toMatchObject([
{
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 3,
bondPositionCount: 1,
},
},
]);
});
it('localizes an unavailable summary without suppressing another account', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'unavailable', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
{ id: 'available', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Второй' },
]);
tbank.getPortfolio
.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
)
.mockResolvedValueOnce({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
await expect(service.list('user-1')).resolves.toMatchObject([
{ id: 'unavailable', portfolioSummary: null },
{ id: 'available', portfolioSummary: { totalsByCurrency: [] } },
]);
});
it('does not mask an invalid token when the other summary input is unavailable', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
]);
tbank.getPortfolio.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
tbank.getPositions.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
await expect(service.list('user-1')).rejects.toEqual(
expect.any(BadRequestException),
);
});
it('retains the connection when T-Bank rejects its token', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
await expect(service.list('user-1')).rejects.toEqual(
expect.any(BadRequestException),
);
});
it('returns an empty list from T-Bank unchanged', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([]);
await expect(service.list('user-1')).resolves.toEqual([]);
});
it('propagates an unavailable T-Bank response', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.list('user-1')).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
});
@@ -0,0 +1,80 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { isZero } from '../../../common/decimal';
import {
TbankAccessService,
TbankClient,
} from '../../../integrations/tbank/tbank.api';
import { decimal } from '../../../integrations/tbank/decimal';
import { totalsByCurrency } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto';
@Injectable()
export class BrokerageAccountsService {
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
) {}
async list(userId: string): Promise<BrokerageAccountDto[]> {
return this.access.withSupportedAccounts(
userId,
async ({ token, accounts }) =>
Promise.all(
accounts.map(async (account) => {
const [portfolioResult, positionsResult] = await Promise.allSettled(
[
this.tbank.getPortfolio(token, account.id),
this.tbank.getPositions(token, account.id),
],
);
if (
portfolioResult.status === 'rejected' &&
!isTbankUnavailable(portfolioResult.reason)
)
throw portfolioResult.reason;
if (
positionsResult.status === 'rejected' &&
!isTbankUnavailable(positionsResult.reason)
)
throw positionsResult.reason;
if (
portfolioResult.status === 'rejected' ||
positionsResult.status === 'rejected'
)
return { ...account, portfolioSummary: null };
const positions = portfolioResult.value.positions ?? [];
let stockPositionCount = 0;
let bondPositionCount = 0;
for (const position of positions) {
if (isZero(decimal(position.quantity ?? {}))) continue;
const type = position.instrumentType?.toLowerCase();
if (type === 'share') stockPositionCount += 1;
if (type === 'bond') bondPositionCount += 1;
}
return {
...account,
portfolioSummary: {
totalsByCurrency: totalsByCurrency(
positions,
positionsResult.value.money ?? [],
),
stockPositionCount,
bondPositionCount,
},
};
}),
),
);
}
}
function isTbankUnavailable(error: unknown): boolean {
if (!(error instanceof ServiceUnavailableException)) return false;
const response = error.getResponse();
return (
typeof response === 'object' &&
response !== null &&
'code' in response &&
response.code === 'TBANK_UNAVAILABLE'
);
}