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(),
};
}
@@ -0,0 +1,64 @@
import {
mapBrokerageOperation,
operationCategory,
} from './brokerage-operation.mapper';
describe('mapBrokerageOperation', () => {
it.each([
['OPERATION_TYPE_COUPON', 'income'],
['OPERATION_TYPE_DIVIDEND', 'income'],
['OPERATION_TYPE_TAX', 'tax'],
['OPERATION_TYPE_BROKER_FEE', 'fee'],
['NEW_TYPE', 'other'],
])('maps %s to %s', (type, category) => {
expect(operationCategory(type)).toBe(category);
});
it('keeps a trade and its commission in one exact-money entry', () => {
expect(
mapBrokerageOperation({
id: 'trade',
date: { seconds: '1' },
type: 'OPERATION_TYPE_BUY',
payment: { currency: 'rub', units: '-1', nano: '-500000000' },
commission: { currency: 'RUB', nano: '-5000000' },
}),
).toMatchObject({
id: 'trade',
category: 'trade',
payment: { currency: 'RUB', value: '-1.5' },
commission: { currency: 'RUB', value: '-0.005' },
});
});
it('does not invent payment for an instrument-only movement', () => {
expect(
mapBrokerageOperation({
id: 'transfer',
date: { seconds: '1' },
type: 'OPERATION_TYPE_INPUT_SECURITIES',
quantity: '3',
}),
).toMatchObject({
category: 'transfer',
quantity: '3',
payment: null,
});
});
});
describe('executed trade quantity', () => {
it('uses the executed part, and does not invent execution quantity when absent', () => {
expect(
mapBrokerageOperation({
type: 'OPERATION_TYPE_BUY',
quantity: '100',
quantityDone: '7',
}).quantity,
).toBe('7');
expect(
mapBrokerageOperation({ type: 'OPERATION_TYPE_BUY', quantity: '100' })
.quantity,
).toBeNull();
});
});
@@ -0,0 +1,88 @@
import { formatDecimal } from '../../../../common/decimal';
import {
TbankMoney,
TbankOperationItem,
} from '../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
BrokerageOperationDto,
OperationCategory,
} from '../presentation/brokerage-operation.dto';
const types: Record<string, OperationCategory> = {
OPERATION_TYPE_BUY: 'trade',
OPERATION_TYPE_BUY_CARD: 'trade',
OPERATION_TYPE_SELL: 'trade',
OPERATION_TYPE_SELL_CARD: 'trade',
OPERATION_TYPE_BUY_MARGIN: 'trade',
OPERATION_TYPE_SELL_MARGIN: 'trade',
OPERATION_TYPE_COUPON: 'income',
OPERATION_TYPE_DIVIDEND: 'income',
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
OPERATION_TYPE_DIV_EXT: 'income',
OPERATION_TYPE_TRANS_IIS_BS: 'transfer',
OPERATION_TYPE_TRANS_BS_BS: 'transfer',
OPERATION_TYPE_BOND_TAX: 'tax',
OPERATION_TYPE_TAX: 'tax',
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
OPERATION_TYPE_SERVICE_FEE: 'fee',
OPERATION_TYPE_MARGIN_FEE: 'fee',
OPERATION_TYPE_BROKER_FEE: 'fee',
OPERATION_TYPE_SUCCESS_FEE: 'fee',
OPERATION_TYPE_INPUT: 'transfer',
OPERATION_TYPE_OUTPUT: 'transfer',
OPERATION_TYPE_INPUT_SECURITIES: 'transfer',
OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer',
};
export function operationCategory(type: string | undefined): OperationCategory {
return types[type ?? ''] ?? 'other';
}
export function mapBrokerageOperation(
operation: TbankOperationItem,
): BrokerageOperationDto {
return {
id: operation.id ?? operation.cursor ?? '',
occurredAt: timestamp(operation.date),
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
category: operationCategory(operation.type),
description: operation.description?.trim() || operation.name?.trim() || '',
figi: text(operation.figi),
instrumentUid: text(operation.instrumentUid),
ticker: text(operation.ticker),
instrumentType: text(operation.instrumentType),
logoName: null,
payment: money(operation.payment),
quantity:
(operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity) === undefined
? null
: String(
operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity,
),
price: money(operation.price),
accruedInterest: money(operation.accruedInt),
commission: money(operation.commission),
};
}
function text(value: string | undefined): string | null {
return value?.trim() || null;
}
function money(value: TbankMoney | undefined) {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
function timestamp(
value: { seconds?: string | number; nanos?: number } | undefined,
) {
return new Date(Number(value?.seconds ?? 0) * 1000).toISOString();
}
@@ -0,0 +1,3 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
export { BrokerageOperationsService } from './application/brokerage-operations.service';
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
import { BrokerageOperationsService } from './application/brokerage-operations.service';
@Module({
imports: [AuthModule, TbankModule],
controllers: [BrokerageOperationsController],
providers: [BrokerageOperationsService],
exports: [BrokerageOperationsService],
})
export class OperationsModule {}
@@ -0,0 +1,138 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto';
export const operationCategories = [
'trade',
'income',
'tax',
'fee',
'transfer',
'other',
] as const;
export type OperationCategory = (typeof operationCategories)[number];
export class BrokerageOperationsQueryDto {
@ApiPropertyOptional({
format: 'uuid',
description:
'Instrument UID. Without from, includes history since the earliest available account opening.',
})
@IsOptional()
@IsUUID()
instrumentUid?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional({ enum: operationCategories, isArray: true })
@IsOptional()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.split(',').filter(Boolean) : value,
)
@IsArray()
@IsEnum(operationCategories, { each: true })
categories?: OperationCategory[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(4096)
cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
}
export class BrokerageOperationDto {
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
@ApiProperty()
declare id: string;
@ApiProperty({ format: 'date-time' })
declare occurredAt: string;
@ApiProperty()
declare type: string;
@ApiProperty({ enum: operationCategories })
declare category: OperationCategory;
@ApiProperty()
declare description: string;
@ApiProperty({ type: String, nullable: true })
declare figi: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null;
@ApiProperty({ type: String, nullable: true })
declare ticker: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentType: string | null;
@ApiProperty({ type: String, nullable: true })
declare logoName: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare payment: MoneyDto | null;
@ApiProperty({ type: String, nullable: true })
declare quantity: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare price: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare accruedInterest: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare commission: MoneyDto | null;
}
export class BrokerageOperationsPageDto {
@ApiProperty()
declare accountId: string;
@ApiProperty({ type: BrokerageOperationDto, isArray: true })
declare items: BrokerageOperationDto[];
@ApiProperty({ type: String, nullable: true })
declare nextCursor: string | null;
@ApiProperty()
declare hasNext: boolean;
@ApiProperty({ format: 'date-time' })
declare observedAt: string;
}
@@ -0,0 +1,18 @@
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { BrokerageOperationsController } from './brokerage-operations.controller';
describe('BrokerageOperationsController', () => {
it('passes the authenticated user, account, and validated query to the service', async () => {
const operations = { get: jest.fn() };
const controller = new BrokerageOperationsController(operations as never);
const query = { limit: 10 };
const request = { user: { sub: 'user' } };
await controller.get(request as never, 'account', query);
expect(operations.get).toHaveBeenCalledWith('user', 'account', query);
});
});
@@ -0,0 +1,38 @@
import { Controller, Get, Param, Query, 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 {
BrokerageOperationsPageDto,
BrokerageOperationsQueryDto,
} from './brokerage-operation.dto';
import { BrokerageOperationsService } from '../application/brokerage-operations.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class BrokerageOperationsController {
constructor(private readonly operations: BrokerageOperationsService) {}
@Get('all/operations')
@ApiOkResponse({ type: BrokerageOperationsPageDto })
all(
@Req() request: Request & { user: AccessPayload },
@Query() query: BrokerageOperationsQueryDto,
) {
return this.operations.getAll(request.user.sub, query);
}
@Get(':accountId/operations')
@ApiOkResponse({ type: BrokerageOperationsPageDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: BrokerageOperationsQueryDto,
) {
return this.operations.get(request.user.sub, accountId, query);
}
}