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,373 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import type { TbankAccountContext } from '../../../../integrations/tbank/tbank-access.service';
import { BrokerageOperationsService } from './brokerage-operations.service';
describe('BrokerageOperationsService', () => {
const access = { withAccount: jest.fn() };
const tbank = {
getOperationsByCursor: jest.fn(),
};
const requestContext = { set: jest.fn() };
const metadata = { resolve: jest.fn() };
const service = new BrokerageOperationsService(
access as never,
tbank as never,
requestContext as never,
metadata as never,
);
let operationsRequest: unknown;
let generation: string;
beforeEach(() => {
jest.clearAllMocks();
access.withAccount.mockReset();
tbank.getOperationsByCursor.mockReset();
metadata.resolve.mockReset();
generation = 'generation-1';
access.withAccount.mockImplementation(
(
_userId: string,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
token: 'token',
generation,
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getOperationsByCursor.mockImplementation(
(_token: string, request: unknown) => {
operationsRequest = request;
return Promise.resolve({ items: [] });
},
);
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('uses the current UTC year by default and asks only for executed operations', async () => {
await service.get('user', 'account', {});
const request = operationsRequest as {
state: string;
from: { seconds: string };
};
expect(request.state).toBe('OPERATION_STATE_EXECUTED');
expect(request.from.seconds).toBe(
String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)),
);
});
it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
hasNext: true,
nextCursor: 'next',
items: [
{ id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' },
{
id: 'fee',
date: { seconds: '2' },
type: 'OPERATION_TYPE_BROKER_FEE',
},
],
});
await expect(
service.get('user', 'account', {
from: '2025-01-01',
to: '2025-01-31',
categories: ['income'],
cursor: 'previous',
limit: 10,
}),
).resolves.toMatchObject({
items: [{ id: 'income' }],
nextCursor: 'next',
hasNext: true,
});
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
from: { seconds: '1735689600' },
to: { seconds: '1738367999', nanos: 999_999_999 },
cursor: 'previous',
}),
);
});
it('includes resolved instrument logo metadata in a ledger entry', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'coupon',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
],
});
metadata.resolve.mockResolvedValue({
brand: { logoName: 'instrument-logo.png' },
});
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
items: [{ id: 'coupon', logoName: 'instrument-logo.png' }],
});
});
it('resolves repeated instrument metadata only once per ledger page', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'first',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
{
id: 'second',
date: { seconds: '2' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
],
});
metadata.resolve.mockResolvedValue({
brand: { logoName: 'instrument-logo.png' },
});
await service.get('user', 'account', {});
expect(metadata.resolve).toHaveBeenCalledTimes(1);
});
it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => {
tbank.getOperationsByCursor.mockResolvedValue({
items: [
{
id: 'first',
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
},
{
id: 'second',
date: { seconds: '2' },
type: 'OPERATION_TYPE_COUPON',
instrumentUid: 'instrument-uid',
figi: 'BBG000000001',
},
],
});
metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({
brand: { logoName: 'fallback-logo.png' },
});
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
items: [
{ id: 'first', logoName: null },
{ id: 'second', logoName: 'fallback-logo.png' },
],
});
expect(metadata.resolve).toHaveBeenNthCalledWith(
1,
'token',
'instrument-uid',
null,
);
expect(metadata.resolve).toHaveBeenNthCalledWith(
2,
'token',
'instrument-uid',
'BBG000000001',
);
});
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
await expect(
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
).resolves.toMatchObject({
accountId: 'account',
items: [],
nextCursor: null,
hasNext: false,
});
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
});
it('rejects inaccessible accounts before reading operations', async () => {
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
});
it('retains existing T-Bank error semantics', async () => {
access.withAccount.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(BadRequestException),
);
tbank.getOperationsByCursor.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account', {})).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z'));
await service.get('user', 'account', {
categories: ['tax', 'income', 'income'],
});
jest.advanceTimersByTime(500);
await service.get('user', 'account', { categories: ['income', 'tax'] });
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
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);
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account', query)).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('does not reuse a cached ledger page across connection generations', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query);
await service.get('user', 'account', query);
generation = 'generation-2';
await service.get('user', 'account', query);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('does not reuse a cached ledger page after the connection is deleted', async () => {
const query = { from: '2025-01-01', to: '2025-01-31' };
await service.get('user', 'account', query);
access.withAccount.mockRejectedValueOnce(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
await expect(service.get('user', 'account', query)).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it('keeps a late previous-generation page unavailable to ledger and income collection', async () => {
let finishPrevious: ((value: unknown) => void) | undefined;
const previous = new Promise((resolve) => {
finishPrevious = resolve;
});
tbank.getOperationsByCursor
.mockReturnValueOnce(previous)
.mockResolvedValueOnce({
items: [operation('current')],
});
const range = { from: '2025-01-01', to: '2025-01-31' };
const previousRequest = service.collectRanges('user', 'account', [range]);
generation = 'generation-2';
const current = await service.collectRanges('user', 'account', [range]);
finishPrevious?.({ items: [operation('previous')] });
await previousRequest;
const reused = await service.collectRanges('user', 'account', [range]);
expect(current.pages[0][0].items[0].id).toBe('current');
expect(reused.pages[0][0].items[0].id).toBe('current');
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('collects every cursor page and rejects a malformed continuation cursor', async () => {
tbank.getOperationsByCursor
.mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] })
.mockResolvedValueOnce({ hasNext: false, items: [] });
await expect(
service.collectRanges('user', 'account', [
{ from: '2026-01-01', to: '2026-01-31' },
]),
).resolves.toMatchObject({
pages: [[{ hasNext: true }, { hasNext: false }]],
});
tbank.getOperationsByCursor.mockReset();
tbank.getOperationsByCursor.mockResolvedValue({
hasNext: true,
nextCursor: 'same',
items: [],
});
await expect(
service.collectRanges('user', 'account', [
{ from: '2026-02-01', to: '2026-02-28' },
]),
).rejects.toMatchObject({ response: { code: 'TBANK_UNAVAILABLE' } });
});
it('collects an in-progress Moscow day only through the current instant', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
await service.collectRanges('user', 'account', [
{ from: '2026-08-01', to: '2026-08-28' },
]);
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
from: { seconds: '1785531600' },
to: { seconds: '1787867058' },
limit: 1000,
}),
);
});
it('evicts the least recently used ledger page after one hundred entries', async () => {
for (let index = 0; index < 100; index += 1)
await service.get('user', 'account', { cursor: String(index) });
await service.get('user', 'account', { cursor: '0' });
await service.get('user', 'account', { cursor: '100' });
await service.get('user', 'account', { cursor: '1' });
const pages = (service as unknown as { pages: { size: number } }).pages;
expect(pages.size).toBe(100);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
});
});
function operation(id: string) {
return {
id,
date: { seconds: '1' },
type: 'OPERATION_TYPE_COUPON',
};
}
@@ -0,0 +1,509 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { randomUUID } from 'node:crypto';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import {
InstrumentMetadataService,
TbankAccessService,
TbankClient,
} from '../../../../integrations/tbank/tbank.api';
import { AccountReferenceDto } from '../../account-reference.dto';
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
import {
BrokerageOperationDto,
BrokerageOperationsPageDto,
BrokerageOperationsQueryDto,
} from '../presentation/brokerage-operation.dto';
import {
mapBrokerageOperation,
operationCategory,
} from '../infrastructure/brokerage-operation.mapper';
type OperationStream = {
account: AccountReferenceDto;
pending: BrokerageOperationDto[];
cursor?: string;
finished: boolean;
seen: string[];
lastOccurredAt?: number;
};
type AllOperationsContinuation = {
signature: string;
range: { from: Date; to: Date };
streams: OperationStream[];
page?: Promise<BrokerageOperationsPageDto>;
};
const freshForMs = 30_000;
const maxCachedPages = 100;
@Injectable()
export class BrokerageOperationsService {
private readonly pages = new LRUCache<string, BrokerageOperationsPageDto>({
max: maxCachedPages,
ttl: freshForMs,
ttlAutopurge: true,
});
private readonly continuations = new LRUCache<
string,
AllOperationsContinuation
>({
max: 100,
ttl: 5 * 60_000,
ttlAutopurge: true,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService,
) {}
async get(
userId: string,
accountId: string,
query: BrokerageOperationsQueryDto,
): Promise<BrokerageOperationsPageDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const range = this.range(query);
return this.access.withAccount(
userId,
accountId,
({ token, generation, account }) =>
this.page(
token,
generation,
userId,
accountId,
query,
query.instrumentUid && !query.from
? {
...range,
from: new Date(Number(account.openedDate?.seconds ?? 0) * 1000),
}
: range,
),
);
}
async getAll(
userId: string,
query: BrokerageOperationsQueryDto,
): Promise<BrokerageOperationsPageDto> {
return this.access.withSupportedAccounts(
userId,
async ({ token, generation, accounts }) => {
const signature = JSON.stringify({
userId,
generation,
accounts: accounts.map((account) => account.id).sort(),
instrumentUid: query.instrumentUid ?? null,
from: query.from ?? null,
to: query.to ?? null,
categories: [...new Set(query.categories ?? [])].sort(),
limit: query.limit ?? 100,
});
const continuation = query.cursor
? this.continuations.get(query.cursor)
: undefined;
if (
query.cursor &&
(!continuation || continuation.signature !== signature)
)
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
if (continuation?.page) return continuation.page;
const oldest = accounts
.map((account) => account.openedOn)
.filter((date): date is string => Boolean(date))
.sort()[0];
const range =
continuation?.range ??
this.range({
...query,
from:
query.from ??
(query.instrumentUid
? ((accounts.every((account) => account.openedOn)
? oldest
: undefined) ?? '1970-01-01')
: undefined),
});
const streams: OperationStream[] = continuation
? continuation.streams.map((stream) => ({
...stream,
pending: [...stream.pending],
seen: [...stream.seen],
}))
: accounts.map(({ id, name }) => ({
account: { id, name },
pending: [],
finished: false,
seen: [],
}));
const result = this.mergePage(
token,
generation,
userId,
query,
signature,
range,
streams,
);
if (continuation) continuation.page = result;
try {
return await result;
} catch (error) {
if (continuation) continuation.page = undefined;
throw error;
}
},
);
}
private async mergePage(
token: string,
generation: string,
userId: string,
query: BrokerageOperationsQueryDto,
signature: string,
range: { from: Date; to: Date },
streams: OperationStream[],
): Promise<BrokerageOperationsPageDto> {
const items: BrokerageOperationDto[] = [];
// Each stream retains unconsumed rows. A page boundary never discards them.
while (items.length < (query.limit ?? 100)) {
for (const stream of streams) {
while (!stream.pending.length && !stream.finished) {
const page = await this.page(
token,
generation,
userId,
stream.account.id,
{ ...query, cursor: stream.cursor },
range,
);
for (const item of page.items) {
const time = Date.parse(item.occurredAt);
if (
!Number.isFinite(time) ||
(stream.lastOccurredAt !== undefined &&
time > stream.lastOccurredAt)
)
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
stream.lastOccurredAt = time;
}
if (page.hasNext) {
if (!page.nextCursor || stream.seen.includes(page.nextCursor))
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
stream.seen.push(page.nextCursor);
stream.cursor = page.nextCursor;
} else stream.finished = true;
stream.pending = page.items.map((item) => ({
...item,
account: stream.account,
}));
}
}
const next = streams
.filter((stream) => stream.pending.length)
.sort(
(left, right) =>
Date.parse(right.pending[0].occurredAt) -
Date.parse(left.pending[0].occurredAt) ||
left.account.id.localeCompare(right.account.id),
)[0];
if (!next) break;
items.push(...next.pending.splice(0, 1));
}
const hasNext = streams.some(
(stream) => stream.pending.length || !stream.finished,
);
const nextCursor = hasNext ? randomUUID() : null;
if (nextCursor)
this.continuations.set(nextCursor, { signature, range, streams });
return {
accountId: 'all',
items,
hasNext,
nextCursor,
observedAt: new Date().toISOString(),
};
}
private async collectAllRanges(
userId: string,
ranges:
| DateOnlyRange[]
| ((accountOpenedAt: Date | undefined) => DateOnlyRange[]),
): Promise<{
accountOpenedAt: Date | undefined;
pages: BrokerageOperationsPageDto[][];
}> {
return this.access.withSupportedAccounts(
userId,
async ({ token, generation, accounts }) => {
// The current cohort includes newly opened accounts with no earlier receipts.
const oldest = accounts
.map((account) => account.openedOn)
.filter((date): date is string => Boolean(date))
.sort()[0];
const accountOpenedAt =
oldest && accounts.every((account) => account.openedOn)
? new Date(`${oldest}T00:00:00+03:00`)
: undefined;
const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
const pages: {
items: (BrokerageOperationDto & { account: AccountReferenceDto })[];
}[][] = selected.map(() => []);
for (const account of accounts) {
for (const [index, range] of selected.entries()) {
const collected = await this.collectRange(
token,
generation,
userId,
account.id,
range,
);
pages[index].push(
...collected.map((page) => ({
...page,
items: page.items.map((item) => ({
...item,
account: { id: account.id, name: account.name },
})),
})),
);
}
}
return {
accountOpenedAt,
pages: pages.map((group) => [
{
...emptyPage('all'),
items: group
.flatMap((page) => page.items)
.sort(
(left, right) =>
Date.parse(right.occurredAt) -
Date.parse(left.occurredAt) ||
left.account.id.localeCompare(right.account.id),
),
},
]),
};
},
);
}
async collectRanges(
userId: string,
accountId: string,
ranges:
| DateOnlyRange[]
| ((accountOpenedAt: Date | undefined) => DateOnlyRange[]),
): Promise<{
accountOpenedAt: Date | undefined;
pages: BrokerageOperationsPageDto[][];
}> {
if (accountId === 'all') return this.collectAllRanges(userId, ranges);
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withAccount(
userId,
accountId,
async ({ token, generation, account }) => {
const accountOpenedAt = account.openedDate?.seconds
? new Date(Number(account.openedDate.seconds) * 1000)
: undefined;
const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
return {
accountOpenedAt,
pages: await Promise.all(
selected.map((range) =>
this.collectRange(token, generation, userId, accountId, range),
),
),
};
},
);
}
private async collectRange(
token: string,
generation: string,
userId: string,
accountId: string,
range: DateOnlyRange,
): Promise<BrokerageOperationsPageDto[]> {
const pages: BrokerageOperationsPageDto[] = [];
const seen = new Set<string>();
let cursor: string | undefined;
do {
const page = await this.page(
token,
generation,
userId,
accountId,
{
from: range.from,
cursor,
limit: 1000,
categories: ['income'],
},
dateOnlyBounds(range),
);
pages.push(page);
if (!page.hasNext) return pages;
if (!page.nextCursor || seen.has(page.nextCursor))
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
seen.add(page.nextCursor);
cursor = page.nextCursor;
} while (cursor);
return pages;
}
private async page(
token: string,
generation: string,
userId: string,
accountId: string,
query: BrokerageOperationsQueryDto,
range: { from: Date; to: Date },
): Promise<BrokerageOperationsPageDto> {
const key = cacheKey(generation, userId, accountId, query, range);
const cached = this.pages.get(key);
if (cached) return cached;
if (range.from > range.to) {
const page = emptyPage(accountId);
this.pages.set(key, page);
return page;
}
const response = await this.tbank.getOperationsByCursor(token, {
accountId,
from: seconds(range.from),
to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')),
cursor: query.cursor,
limit: query.limit ?? 100,
state: 'OPERATION_STATE_EXECUTED',
...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}),
});
const items = (response.items ?? []).filter(
(item) =>
(!query.instrumentUid || item.instrumentUid === query.instrumentUid) &&
(!query.categories?.length ||
query.categories.includes(operationCategory(item.type))),
);
const logoNames = new Map<string, Promise<string | null>>();
const page = {
accountId,
items: await Promise.all(
items.map(async (item) => ({
...mapBrokerageOperation(item),
logoName: await this.logoName(
token,
item.instrumentUid,
item.figi,
logoNames,
),
})),
),
nextCursor: response.nextCursor || null,
hasNext: Boolean(response.hasNext),
observedAt: new Date().toISOString(),
};
this.pages.set(key, page);
return page;
}
private range(query: BrokerageOperationsQueryDto) {
const now = new Date();
return {
from:
dateBoundary(query.from, false) ??
new Date(Date.UTC(now.getUTCFullYear(), 0, 1)),
to: dateBoundary(query.to, true) ?? now,
};
}
private async logoName(
token: string,
instrumentUid: string | undefined,
figi: string | undefined,
logoNames: Map<string, Promise<string | null>>,
): Promise<string | null> {
const uid = instrumentUid?.trim() || null;
const instrumentFigi = figi?.trim() || null;
const key =
uid || instrumentFigi
? JSON.stringify({ uid, figi: instrumentFigi })
: null;
if (!key) return Promise.resolve(null);
const existing = logoNames.get(key);
if (existing) return existing;
const logoName = this.metadata
.resolve(token, uid, instrumentFigi)
.then((instrument) => instrument?.brand?.logoName?.trim() || null);
logoNames.set(key, logoName);
return logoName;
}
}
function cacheKey(
generation: string,
userId: string,
accountId: string,
query: BrokerageOperationsQueryDto,
range: { from: Date; to: Date },
): string {
const categories = [...new Set(query.categories ?? [])].sort();
const end = query.to
? range.to.toISOString()
: Math.floor(range.to.valueOf() / freshForMs);
return JSON.stringify({
generation,
userId,
accountId,
from: range.from.toISOString(),
to: end,
categories,
instrumentUid: query.instrumentUid ?? null,
cursor: query.cursor ?? null,
limit: query.limit ?? 100,
});
}
function dateBoundary(value: string | undefined, end: boolean): Date | null {
if (!value) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(value))
return new Date(`${value}T${end ? '23:59:59.999' : '00:00:00.000'}Z`);
return new Date(value);
}
function seconds(value: Date, endOfSecond = false) {
return {
seconds: Math.floor(value.valueOf() / 1000).toString(),
...(endOfSecond ? { nanos: 999_999_999 } : {}),
};
}
function emptyPage(accountId: string): BrokerageOperationsPageDto {
return {
accountId,
items: [],
nextCursor: null,
hasNext: false,
observedAt: new Date().toISOString(),
};
}