codex/frontend-overview-redesign #50
@ -19,6 +19,12 @@ export class BrokerAnalyticsDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
totalReceived!: number;
|
totalReceived!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalFees!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalTaxesPaid!: number;
|
||||||
|
|
||||||
@ApiProperty({ type: Number, nullable: true })
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
totalReturnPercent!: number | null;
|
totalReturnPercent!: number | null;
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto
|
|||||||
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
|
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
|
||||||
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||||
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
||||||
|
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
|
||||||
|
|
||||||
export class BrokerAccountsEnvelopeDto {
|
export class BrokerAccountsEnvelopeDto {
|
||||||
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
||||||
@ -63,3 +64,11 @@ export class BrokerEventsEnvelopeDto {
|
|||||||
@ApiProperty({ type: ApiResponseMeta })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: ApiResponseMeta;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BrokerPortfolioHistoryEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
|
||||||
|
data!: BrokerPortfolioHistoryDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
|
meta!: ApiResponseMeta;
|
||||||
|
}
|
||||||
|
|||||||
@ -40,4 +40,9 @@ export class BrokerOperationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
state?: string;
|
state?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categories?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -59,6 +59,8 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
totalCoupons: 0,
|
totalCoupons: 0,
|
||||||
totalReceived: 0,
|
totalReceived: 0,
|
||||||
totalReturnPercent: null,
|
totalReturnPercent: null,
|
||||||
|
totalFees: 0,
|
||||||
|
totalTaxesPaid: 0,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -177,17 +179,62 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({
|
expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
type: { in: expect.any(Array) },
|
|
||||||
payment: { not: null },
|
payment: { not: null },
|
||||||
OR: [
|
OR: [
|
||||||
{ state: 'OPERATION_STATE_EXECUTED' },
|
{ state: 'OPERATION_STATE_EXECUTED' },
|
||||||
{ state: null },
|
{ 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 () => {
|
it('rounds all monetary values to 2 decimal places', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
@ -216,6 +263,8 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
totalCoupons: 0,
|
totalCoupons: 0,
|
||||||
totalReceived: 0,
|
totalReceived: 0,
|
||||||
totalReturnPercent: null,
|
totalReturnPercent: null,
|
||||||
|
totalFees: 0,
|
||||||
|
totalTaxesPaid: 0,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
},
|
},
|
||||||
fromCache: true,
|
fromCache: true,
|
||||||
|
|||||||
@ -61,20 +61,29 @@ export class BrokerAnalyticsService {
|
|||||||
const operations = await this.prisma.brokerOperation.findMany({
|
const operations = await this.prisma.brokerOperation.findMany({
|
||||||
where: {
|
where: {
|
||||||
accountId,
|
accountId,
|
||||||
type: { in: Array.from(ANALYTICS_TYPES) },
|
|
||||||
payment: { not: null },
|
payment: { not: null },
|
||||||
OR: [
|
OR: [
|
||||||
{ state: 'OPERATION_STATE_EXECUTED' },
|
{ state: 'OPERATION_STATE_EXECUTED' },
|
||||||
{ state: null },
|
{ 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 totalDeposits = 0;
|
||||||
let totalWithdrawn = 0;
|
let totalWithdrawn = 0;
|
||||||
let totalDividends = 0;
|
let totalDividends = 0;
|
||||||
let totalCoupons = 0;
|
let totalCoupons = 0;
|
||||||
|
let totalFees = 0;
|
||||||
|
let totalTaxesPaid = 0;
|
||||||
|
|
||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
let value = 0;
|
let value = 0;
|
||||||
@ -85,7 +94,11 @@ export class BrokerAnalyticsService {
|
|||||||
continue;
|
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;
|
totalDeposits += value;
|
||||||
} else if (WITHDRAWAL_TYPES.has(op.type)) {
|
} else if (WITHDRAWAL_TYPES.has(op.type)) {
|
||||||
totalWithdrawn += Math.abs(value);
|
totalWithdrawn += Math.abs(value);
|
||||||
@ -108,6 +121,8 @@ export class BrokerAnalyticsService {
|
|||||||
totalDividends: Math.round(totalDividends * 100) / 100,
|
totalDividends: Math.round(totalDividends * 100) / 100,
|
||||||
totalCoupons: Math.round(totalCoupons * 100) / 100,
|
totalCoupons: Math.round(totalCoupons * 100) / 100,
|
||||||
totalReceived: Math.round(totalReceived * 100) / 100,
|
totalReceived: Math.round(totalReceived * 100) / 100,
|
||||||
|
totalFees: Math.round(totalFees * 100) / 100,
|
||||||
|
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
|
||||||
totalReturnPercent,
|
totalReturnPercent,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'
|
|||||||
import { mapOperationsPage } from '../mappers/operation.mapper';
|
import { mapOperationsPage } from '../mappers/operation.mapper';
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
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 type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
import { TBankClientService } from './tbank-client.service';
|
import { TBankClientService } from './tbank-client.service';
|
||||||
@ -33,6 +33,19 @@ export class BrokerOperationsService {
|
|||||||
'tbankOperationsTtl',
|
'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);
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
|||||||
import { BrokerEventsService } from './services/broker-events.service';
|
import { BrokerEventsService } from './services/broker-events.service';
|
||||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
|
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
|
||||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
|
|
||||||
describe('TBankController', () => {
|
describe('TBankController', () => {
|
||||||
@ -15,6 +16,7 @@ describe('TBankController', () => {
|
|||||||
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
|
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
|
||||||
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
||||||
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
|
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
|
||||||
|
const portfolioHistory = { getHistory: vi.fn() } as unknown as BrokerPortfolioHistoryService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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();
|
const response = await controller.getAccounts();
|
||||||
|
|
||||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
@ -54,7 +56,7 @@ describe('TBankController', () => {
|
|||||||
it('exposes a sync trigger for durable operation history', async () => {
|
it('exposes a sync trigger for durable operation history', async () => {
|
||||||
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
|
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', {
|
const response = await controller.syncOperations('acc-1', {
|
||||||
from: '2026-06-01T00:00:00.000Z',
|
from: '2026-06-01T00:00:00.000Z',
|
||||||
to: '2026-06-17T00:00:00.000Z',
|
to: '2026-06-17T00:00:00.000Z',
|
||||||
@ -76,13 +78,15 @@ describe('TBankController', () => {
|
|||||||
totalCoupons: 50,
|
totalCoupons: 50,
|
||||||
totalReceived: 200,
|
totalReceived: 200,
|
||||||
totalReturnPercent: 25,
|
totalReturnPercent: 25,
|
||||||
|
totalFees: 0,
|
||||||
|
totalTaxesPaid: 0,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
};
|
};
|
||||||
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
|
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
|
||||||
new ApiEnvelopePayload(analyticsData, false, null),
|
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');
|
const response = await controller.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
||||||
@ -92,6 +96,67 @@ describe('TBankController', () => {
|
|||||||
expect(response.cachedAt).toBeNull();
|
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 () => {
|
it('forwards events query and wraps response', async () => {
|
||||||
const eventsData = {
|
const eventsData = {
|
||||||
items: [],
|
items: [],
|
||||||
@ -114,7 +179,7 @@ describe('TBankController', () => {
|
|||||||
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
|
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 query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
||||||
const response = await controller.getEvents('acc-1', query);
|
const response = await controller.getEvents('acc-1', query);
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
BrokerOperationSyncEnvelopeDto,
|
BrokerOperationSyncEnvelopeDto,
|
||||||
BrokerOperationsEnvelopeDto,
|
BrokerOperationsEnvelopeDto,
|
||||||
BrokerPortfolioEnvelopeDto,
|
BrokerPortfolioEnvelopeDto,
|
||||||
|
BrokerPortfolioHistoryEnvelopeDto,
|
||||||
BrokerPositionsEnvelopeDto,
|
BrokerPositionsEnvelopeDto,
|
||||||
} from './dto/broker-envelope.dto';
|
} from './dto/broker-envelope.dto';
|
||||||
import { BrokerEventsQueryDto } from './dto/broker-events-query.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 { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
||||||
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
|
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||||
import { BrokerEventsService } from './services/broker-events.service';
|
import { BrokerEventsService } from './services/broker-events.service';
|
||||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
import { BrokerOperationsService } from './services/broker-operations.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';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
|
|
||||||
@ApiTags('Broker')
|
@ApiTags('Broker')
|
||||||
@ -33,6 +35,7 @@ export class TBankController {
|
|||||||
private readonly brokerOperationsService: BrokerOperationsService,
|
private readonly brokerOperationsService: BrokerOperationsService,
|
||||||
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
||||||
private readonly brokerAnalyticsService: BrokerAnalyticsService,
|
private readonly brokerAnalyticsService: BrokerAnalyticsService,
|
||||||
|
private readonly brokerPortfolioHistoryService: BrokerPortfolioHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('accounts')
|
@Get('accounts')
|
||||||
@ -81,6 +84,16 @@ export class TBankController {
|
|||||||
return this.brokerEventsService.getEvents(accountId, query);
|
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')
|
@Get('accounts/:accountId/analytics')
|
||||||
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
||||||
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { BrokerInstrumentsService } from './services/broker-instruments.service'
|
|||||||
import { BrokerEventsService } from './services/broker-events.service';
|
import { BrokerEventsService } from './services/broker-events.service';
|
||||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
|
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
|
||||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
import { TBankClientService } from './services/tbank-client.service';
|
import { TBankClientService } from './services/tbank-client.service';
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ import { TBankClientService } from './services/tbank-client.service';
|
|||||||
BrokerOperationsService,
|
BrokerOperationsService,
|
||||||
BrokerOperationSyncService,
|
BrokerOperationSyncService,
|
||||||
BrokerAnalyticsService,
|
BrokerAnalyticsService,
|
||||||
|
BrokerPortfolioHistoryService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TBankClientService,
|
TBankClientService,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user