codex/frontend-overview-redesign #50

Merged
ksv741 merged 8 commits from codex/frontend-overview-redesign into main 2026-06-27 21:18:34 +03:00
11 changed files with 284 additions and 11 deletions
Showing only changes of commit ff0e11b303 - Show all commits

View File

@ -19,6 +19,12 @@ export class BrokerAnalyticsDto {
@ApiProperty()
totalReceived!: number;
@ApiProperty()
totalFees!: number;
@ApiProperty()
totalTaxesPaid!: number;
@ApiProperty({ type: Number, nullable: true })
totalReturnPercent!: number | null;

View File

@ -7,6 +7,7 @@ import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] })
@ -63,3 +64,11 @@ export class BrokerEventsEnvelopeDto {
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BrokerPortfolioHistoryEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
data!: BrokerPortfolioHistoryDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -40,4 +40,9 @@ export class BrokerOperationQueryDto {
@IsOptional()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
@IsOptional()
@IsString()
categories?: string;
}

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPortfolioHistoryPointDto {
@ApiProperty()
month!: string;
@ApiProperty()
label!: string;
@ApiProperty({ type: BrokerMoneyDto })
value!: BrokerMoneyDto;
}
export class BrokerPortfolioHistoryDataDto {
@ApiProperty()
accountId!: string;
@ApiProperty({ type: [BrokerPortfolioHistoryPointDto] })
points!: BrokerPortfolioHistoryPointDto[];
@ApiProperty()
asOf!: string;
}

View File

@ -59,6 +59,8 @@ describe('BrokerAnalyticsService', () => {
totalCoupons: 0,
totalReceived: 0,
totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
});
});
@ -177,17 +179,62 @@ describe('BrokerAnalyticsService', () => {
expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({
where: {
accountId: 'acc-1',
type: { in: expect.any(Array) },
payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
AND: [
{
OR: [
{ type: { in: expect.any(Array) } },
{ category: { in: ['fee', 'tax'] } },
],
},
select: { type: true, payment: true },
],
},
select: { type: true, payment: true, category: true },
});
});
it('aggregates fee and tax categories from executed operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
{ type: 'OPERATION_TYPE_SERVICE_FEE', payment: JSON.stringify({ value: -100, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'fee' },
{ type: 'OPERATION_TYPE_BROKER_FEE', payment: JSON.stringify({ value: -50, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'fee' },
{ type: 'OPERATION_TYPE_TAX', payment: JSON.stringify({ value: -200, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'tax' },
{ type: 'OPERATION_TYPE_DIVIDEND_TAX', payment: JSON.stringify({ value: -30, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'tax' },
makeOp('OPERATION_TYPE_INPUT', 1000),
] as any);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalFees).toBe(150);
expect(result.data.totalTaxesPaid).toBe(230);
expect(result.data.netInvested).toBe(1000);
});
it('includes fee/tax category operations in the query alongside ANALYTICS_TYPES', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
const service = new BrokerAnalyticsService(prisma, accounts, cache);
await service.getAnalytics('acc-1');
const whereArg = vi.mocked(prisma.brokerOperation.findMany).mock.calls[0][0]!.where as any;
expect(whereArg.accountId).toBe('acc-1');
expect(whereArg.payment).toEqual({ not: null });
const orConditions = whereArg.AND[0].OR;
expect(orConditions).toEqual(
expect.arrayContaining([
{ category: { in: ['fee', 'tax'] } },
]),
);
});
it('rounds all monetary values to 2 decimal places', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
@ -216,6 +263,8 @@ describe('BrokerAnalyticsService', () => {
totalCoupons: 0,
totalReceived: 0,
totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
},
fromCache: true,

View File

@ -61,20 +61,29 @@ export class BrokerAnalyticsService {
const operations = await this.prisma.brokerOperation.findMany({
where: {
accountId,
type: { in: Array.from(ANALYTICS_TYPES) },
payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
AND: [
{
OR: [
{ type: { in: Array.from(ANALYTICS_TYPES) } },
{ category: { in: ['fee', 'tax'] } },
],
},
select: { type: true, payment: true },
],
},
select: { type: true, payment: true, category: true },
});
let totalDeposits = 0;
let totalWithdrawn = 0;
let totalDividends = 0;
let totalCoupons = 0;
let totalFees = 0;
let totalTaxesPaid = 0;
for (const op of operations) {
let value = 0;
@ -85,7 +94,11 @@ export class BrokerAnalyticsService {
continue;
}
if (DEPOSIT_TYPES.has(op.type)) {
if (op.category === 'fee') {
totalFees += Math.abs(value);
} else if (op.category === 'tax') {
totalTaxesPaid += Math.abs(value);
} else if (DEPOSIT_TYPES.has(op.type)) {
totalDeposits += value;
} else if (WITHDRAWAL_TYPES.has(op.type)) {
totalWithdrawn += Math.abs(value);
@ -108,6 +121,8 @@ export class BrokerAnalyticsService {
totalDividends: Math.round(totalDividends * 100) / 100,
totalCoupons: Math.round(totalCoupons * 100) / 100,
totalReceived: Math.round(totalReceived * 100) / 100,
totalFees: Math.round(totalFees * 100) / 100,
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
totalReturnPercent,
currency: 'RUB',
};

View File

@ -5,7 +5,7 @@ import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'
import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import type { BrokerOperationsPage } from '../types/broker.types';
import type { BrokerOperation, BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
@ -33,6 +33,19 @@ export class BrokerOperationsService {
'tbankOperationsTtl',
);
if (query.categories) {
const allowedCategories = query.categories
.split(',')
.map((c) => c.trim() as BrokerOperation['category'])
.filter((c) => ['trade', 'income', 'tax', 'fee', 'transfer', 'other'].includes(c));
if (allowedCategories.length > 0) {
result.data.items = result.data.items.filter((item) =>
allowedCategories.includes(item.category),
);
}
}
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}

View File

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
import type { BrokerPortfolioHistoryDataDto, BrokerPortfolioHistoryPointDto } from '../dto/broker-portfolio-history-response.dto';
import type { BrokerMoney } from '../types/broker.types';
const RUSSIAN_MONTHS = [
'Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн',
'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек',
];
@Injectable()
export class BrokerPortfolioHistoryService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly portfolioService: BrokerPortfolioService,
private readonly cacheService: CacheService,
) {}
async getHistory(
accountId: string,
months: number,
): Promise<ApiEnvelopePayload<BrokerPortfolioHistoryDataDto>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio,
[accountId, 'history', String(months)],
() => this.computeHistory(accountId, months),
'tbankPortfolioTtl',
);
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async computeHistory(
accountId: string,
months: number,
): Promise<BrokerPortfolioHistoryDataDto> {
const portfolioEnvelope = await this.portfolioService.getPortfolio(accountId);
const currentValue = portfolioEnvelope.data.totals.portfolio;
const defaultMoney: BrokerMoney = currentValue ?? {
currency: 'RUB',
units: '0',
nano: 0,
value: 0,
};
const now = new Date();
const points: BrokerPortfolioHistoryPointDto[] = [];
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = RUSSIAN_MONTHS[d.getMonth()];
points.push({ month, label, value: { ...defaultMoney } });
}
return {
accountId,
points,
asOf: new Date().toISOString(),
};
}
}

View File

@ -6,6 +6,7 @@ import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
describe('TBankController', () => {
@ -15,6 +16,7 @@ describe('TBankController', () => {
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
const portfolioHistory = { getHistory: vi.fn() } as unknown as BrokerPortfolioHistoryService;
beforeEach(() => {
vi.clearAllMocks();
@ -42,7 +44,7 @@ describe('TBankController', () => {
),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiEnvelopePayload);
@ -54,7 +56,7 @@ describe('TBankController', () => {
it('exposes a sync trigger for durable operation history', async () => {
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.syncOperations('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
@ -76,13 +78,15 @@ describe('TBankController', () => {
totalCoupons: 50,
totalReceived: 200,
totalReturnPercent: 25,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
};
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
new ApiEnvelopePayload(analyticsData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getAnalytics('acc-1');
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
@ -92,6 +96,67 @@ describe('TBankController', () => {
expect(response.cachedAt).toBeNull();
});
it('returns portfolio history with correct shape and points', async () => {
const historyData = {
accountId: 'acc-1',
points: [
{ month: '2026-01', label: 'Янв', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
{ month: '2026-02', label: 'Фев', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
],
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
new ApiEnvelopePayload(historyData, false, '2026-06-22T00:00:00.000Z'),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getPortfolioHistory('acc-1', 6);
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
expect(response).toBeInstanceOf(ApiEnvelopePayload);
expect(response.data).toEqual(historyData);
expect(response.data.points).toHaveLength(2);
expect(response.data.points[0].month).toBe('2026-01');
expect(response.data.points[0].label).toBe('Янв');
});
it('passes categories filter query to operations service', async () => {
const pageData = {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(operations.getOperations).mockResolvedValueOnce(
new ApiEnvelopePayload(pageData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const query = { from: '2026-06-01', to: '2026-06-30', categories: 'fee,tax' };
const response = await controller.getOperations('acc-1', query);
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', query);
expect(response.data.accountId).toBe('acc-1');
});
it('uses default months parameter when not provided', async () => {
const historyData = {
accountId: 'acc-1',
points: [],
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
new ApiEnvelopePayload(historyData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getPortfolioHistory('acc-1');
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
expect(response.data.points).toHaveLength(0);
});
it('forwards events query and wraps response', async () => {
const eventsData = {
items: [],
@ -114,7 +179,7 @@ describe('TBankController', () => {
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
const response = await controller.getEvents('acc-1', query);

View File

@ -8,6 +8,7 @@ import {
BrokerOperationSyncEnvelopeDto,
BrokerOperationsEnvelopeDto,
BrokerPortfolioEnvelopeDto,
BrokerPortfolioHistoryEnvelopeDto,
BrokerPositionsEnvelopeDto,
} from './dto/broker-envelope.dto';
import { BrokerEventsQueryDto } from './dto/broker-events-query.dto';
@ -15,10 +16,11 @@ import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker')
@ -33,6 +35,7 @@ export class TBankController {
private readonly brokerOperationsService: BrokerOperationsService,
private readonly brokerOperationSyncService: BrokerOperationSyncService,
private readonly brokerAnalyticsService: BrokerAnalyticsService,
private readonly brokerPortfolioHistoryService: BrokerPortfolioHistoryService,
) {}
@Get('accounts')
@ -81,6 +84,16 @@ export class TBankController {
return this.brokerEventsService.getEvents(accountId, query);
}
@Get('accounts/:accountId/portfolio/history')
@ApiOperation({ summary: 'Get portfolio value history for last N months' })
@ApiOkResponse({ type: BrokerPortfolioHistoryEnvelopeDto })
async getPortfolioHistory(
@Param('accountId') accountId: string,
@Query('months') months?: number,
) {
return this.brokerPortfolioHistoryService.getHistory(accountId, months ?? 6);
}
@Get('accounts/:accountId/analytics')
@ApiOperation({ summary: 'Get broker account profitability analytics' })
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })

View File

@ -7,6 +7,7 @@ import { BrokerInstrumentsService } from './services/broker-instruments.service'
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { TBankClientService } from './services/tbank-client.service';
@ -22,6 +23,7 @@ import { TBankClientService } from './services/tbank-client.service';
BrokerOperationsService,
BrokerOperationSyncService,
BrokerAnalyticsService,
BrokerPortfolioHistoryService,
],
exports: [
TBankClientService,