diff --git a/prisma/migrations/20261108000000_rub_only_value_points/migration.sql b/prisma/migrations/20261108000000_rub_only_value_points/migration.sql new file mode 100644 index 0000000..0c7d62e --- /dev/null +++ b/prisma/migrations/20261108000000_rub_only_value_points/migration.sql @@ -0,0 +1,32 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_TbankPortfolioValuePoint" ( + "userId" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "value" TEXT NOT NULL, + "capturedAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("userId", "accountId", "attemptId"), + CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT +); +-- RUB-only: keep the RUB row per key; non-RUB rows are dropped. +INSERT INTO "new_TbankPortfolioValuePoint" ("accountId", "attemptId", "capturedAt", "createdAt", "userId", "value") +SELECT "accountId", "attemptId", "capturedAt", "createdAt", "userId", "value" +FROM "TbankPortfolioValuePoint" +WHERE "currency" = 'RUB'; +DROP TABLE "TbankPortfolioValuePoint"; +ALTER TABLE "new_TbankPortfolioValuePoint" RENAME TO "TbankPortfolioValuePoint"; +CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt"); + +-- RUB-only: persisted JSON payloads written in the old multi-currency shape +-- are stale. A missing snapshot/forecast is the app's normal "not synced yet" +-- state: reads report availability 'missing' and the hourly walk rebuilds them +-- on the next pass. Delete the stale rows instead of failing reads with 500. +DELETE FROM "TbankPortfolio"; +DELETE FROM "TbankForecast"; + +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7fc478e..9cd1820 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -101,18 +101,17 @@ model TbankPortfolioValueHistory { @@id([userId, accountId]) } -// One row per currency of a fully valued successful portfolio attempt. +// One row per fully valued successful portfolio attempt (RUB only). model TbankPortfolioValuePoint { userId String accountId String attemptId String - currency String value String capturedAt DateTime createdAt DateTime @default(now()) history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict) - @@id([userId, accountId, attemptId, currency]) + @@id([userId, accountId, attemptId]) @@index([userId, accountId, capturedAt]) } diff --git a/src/integrations/moex/moex.client.spec.ts b/src/integrations/moex/moex.client.spec.ts index e9a6dd1..20ea021 100644 --- a/src/integrations/moex/moex.client.spec.ts +++ b/src/integrations/moex/moex.client.spec.ts @@ -340,4 +340,59 @@ describe('MoexClient', () => { expect(starts).toEqual(['0', '1']); }, ); + + it('filters non-RUB coupons and maturity out of bond events', async () => { + const pages: Record = { + coupons: Response.json({ + 'coupons': { + columns: ['coupondate', 'value', 'faceunit'], + data: [ + ['2099-02-01', '2', 'SUR'], + ['2099-02-02', '3', 'USD'], + ], + }, + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, 2, 100]], + }, + }), + offers: Response.json({ + 'offers': { + columns: ['offerdate'], + data: [['2099-02-28']], + }, + 'offers.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, 1, 100]], + }, + }), + description: Response.json({ + description: { + columns: ['name', 'value'], + data: [ + ['MATDATE', '2099-02-28'], + ['FACEVALUE', '500'], + ['FACEUNIT', 'USD'], + ], + }, + }), + }; + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation((input) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const block = url.searchParams.get('iss.only')?.split(',')[0] ?? 'description'; + // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names of this test's page map. + const page = pages[block] ?? pages.description; + return Promise.resolve(page as Response); + }); + try { + await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([ + { type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' }, + { type: 'offer', date: '2099-02-28' }, + ]); + } finally { + fetchMock.mockRestore(); + } + }); }); diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index 742285d..7ec4cb9 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -157,9 +157,9 @@ export class MoexClient { for (const row of coupons) { const date = text(row.coupondate); const value = numberText(row.value); - const currency = currencyText(row.faceunit); - if (date && value && currency) { - events.push({ type: 'coupon', date, value, currency }); + // RUB-only: coupons in other currencies are filtered out at this ingest boundary. + if (date && value && currencyText(row.faceunit) === 'RUB') { + events.push({ type: 'coupon', date, value, currency: 'RUB' }); } } for (const row of offers) { @@ -171,9 +171,8 @@ export class MoexClient { ); const date = values.get('MATDATE'); const value = numberText(values.get('FACEVALUE')); - const currency = currencyText(values.get('FACEUNIT')); - if (date && value && currency) { - events.push({ type: 'maturity', date, value, currency }); + if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') { + events.push({ type: 'maturity', date, value, currency: 'RUB' }); } return events; } diff --git a/src/integrations/tbank/decimal.ts b/src/integrations/tbank/decimal.ts index fc2faef..5922fd4 100644 --- a/src/integrations/tbank/decimal.ts +++ b/src/integrations/tbank/decimal.ts @@ -1,4 +1,6 @@ -import { Decimal } from '../../common/decimal'; +import type { Money } from '../../common/money.types'; + +import { Decimal, formatDecimal } from '../../common/decimal'; const MISSING_PROTO_COMPONENT = 0; @@ -6,6 +8,12 @@ const NANO = 1_000_000_000n; export type ProtoDecimal = { nano?: number | string; units?: number | string }; +export function rubMoney(value?: ProtoDecimal & { currency?: string }): Money | null { + return value?.currency?.trim().toUpperCase() === 'RUB' + ? { currency: 'RUB', value: formatDecimal(decimal(value)) } + : null; +} + export function decimal(value: ProtoDecimal): Decimal { return { value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT), diff --git a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts index 838fea3..bda65ac 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts @@ -7,9 +7,6 @@ import { PortfolioStructureDto } from '../portfolio/portfolio.api'; import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto'; export class UnrealizedReturnDto { - @ApiProperty() - declare currency: string; - @ApiProperty({ example: '12.345678901' }) declare absolute: string; @@ -24,17 +21,17 @@ export class UnrealizedReturnDto { } export class PortfolioSummaryDto { - @ApiProperty({ type: MoneyDto, isArray: true }) - declare totalsByCurrency: MoneyDto[]; + @ApiProperty({ type: MoneyDto }) + declare totals: MoneyDto; - @ApiProperty({ type: MoneyDto, isArray: true }) - declare cashBalances: MoneyDto[]; + @ApiProperty({ type: MoneyDto }) + declare cash: MoneyDto; @ApiProperty({ type: PortfolioStructureDto }) declare structure: PortfolioStructureDto; - @ApiProperty({ type: UnrealizedReturnDto, isArray: true }) - declare unrealizedReturnByCurrency: UnrealizedReturnDto[]; + @ApiProperty({ type: UnrealizedReturnDto }) + declare unrealizedReturn: UnrealizedReturnDto; @ApiProperty({ type: 'integer', diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index 4605b89..f2ab224 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -33,7 +33,7 @@ describe('saved brokerage account projection', () => { }); it('keeps exact saved totals and independent missing summaries', async () => { - const structure = { currencies: [], unvaluedCategories: [] }; + const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }; portfolio.saved.mockResolvedValue({ membership: meta, accounts: [ @@ -44,11 +44,9 @@ describe('saved brokerage account projection', () => { openedOn: null, meta, snapshot: { - cashBalances: [{ currency: 'RUB', value: '10.000000001' }], + cash: { currency: 'RUB', value: '10.000000001' }, structure, - totalsByCurrency: [ - { currency: 'RUB', value: '100000000.000000001' }, - ], + totals: { currency: 'RUB', value: '100000000.000000001' }, positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }], }, }, @@ -64,14 +62,14 @@ describe('saved brokerage account projection', () => { }); const response = await service.list('owner'); expect(response.data?.[0].portfolioSummary).toEqual({ - cashBalances: [{ currency: 'RUB', value: '10.000000001' }], + cash: { currency: 'RUB', value: '10.000000001' }, structure, - totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], + totals: { currency: 'RUB', value: '100000000.000000001' }, stockPositionCount: 1, bondPositionCount: 1, fundPositionCount: 0, totalPositionCount: 2, - unrealizedReturnByCurrency: [], + unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false }, }); expect(response.data?.[1].portfolioSummary).toBeNull(); expect(response.meta.availability).toBe('available'); @@ -79,74 +77,65 @@ describe('saved brokerage account projection', () => { }); it('returns an empty summary from an empty saved portfolio', async () => { - const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] }; + const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }; portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [{ id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta, - snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] }, + snapshot: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] }, }], }); expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({ - cashBalances: [], structure, totalsByCurrency: [], + cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, stockPositionCount: 0, bondPositionCount: 0, fundPositionCount: 0, totalPositionCount: 0, - unrealizedReturnByCurrency: [], + unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false }, }); }); - it('calculates exact returns by currency, partial data, and zero cost basis', async () => { + it('calculates exact returns, partial data, and zero cost basis', async () => { portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [{ id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta, snapshot: { - cashBalances: [], structure: {}, totalsByCurrency: [ - { currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' }, - ], + cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' }, positions: [ { type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } }, { type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } }, - { type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null }, - { type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } }, - { type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } }, - { type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null }, + { type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'RUB', value: '2.5' }, absoluteReturn: null }, + { type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null }, ], }, }], }); expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({ - stockPositionCount: 2, bondPositionCount: 2, - fundPositionCount: 1, totalPositionCount: 6, - unrealizedReturnByCurrency: [ - { currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true }, - { currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false }, - { currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true }, - ], + stockPositionCount: 1, bondPositionCount: 2, + fundPositionCount: 1, totalPositionCount: 4, + unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true }, }); }); - it('omits return percent for a currency with short positions', async () => { + it('omits return percent for short positions', async () => { portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [{ id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta, snapshot: { - cashBalances: [], structure: {}, totalsByCurrency: [], + cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' }, positions: [ { type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } }, - { type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } }, + { type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } }, ], }, }], }); - expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([ - { currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false }, - { currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false }, - ]); + expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual( + { absolute: '-19', costBasis: '-190', percent: null, partial: false }, + ); }); it('observes the latest hourly walk without starting another one', async () => { diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index 655db21..d82e320 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { createHash } from 'node:crypto'; import { + add, decimalFromString, divide, formatDecimal, @@ -10,7 +11,6 @@ import { } from '#src/common/decimal'; import { investmentResourceMeta } from '../investment-resource.mapper'; -import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals'; import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api'; import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api'; import { @@ -22,61 +22,42 @@ import { const PERCENT_DECIMAL_PLACES = 2; const ZERO_QUANTITY = 0n; -function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] { - // Cash-like CURRENCY positions must not duplicate the separately returned cash balances. - const investments = positions.filter((position) => position.type !== 'CURRENCY'); - const absolute = sumMoneyByCurrency( - investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []), +function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto { + const absoluteReturn = positions.reduce( + (sum, position) => position.absoluteReturn + ? add(sum, decimalFromString(position.absoluteReturn.value)) + : sum, + decimalFromString('0'), ); - const costs = sumMoneyByCurrency( - investments.flatMap((position) => - position.absoluteReturn && position.averagePurchasePrice - ? [{ - currency: position.absoluteReturn.currency, - value: formatDecimal(multiply( - decimalFromString(position.averagePurchasePrice.value), - decimalFromString(position.quantity), - )), - }] - : [], - ), - ); - const partial = new Set( - investments.flatMap((position) => { - const money = position.absoluteReturn - ? null - : position.averagePurchasePrice ?? position.currentValue; - return money ? [money.currency] : []; - }), + const costBasis = positions + .filter((position) => position.absoluteReturn && position.averagePurchasePrice) + .reduce( + (sum, position) => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above. + add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))), + decimalFromString('0'), + ); + const partial = positions.some( + (position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue), ); // A short position has negative cost basis, so a percent would flip sign or net out. - const withShorts = new Set( - investments.flatMap((position) => { - const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue; - return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : []; - }), + const withShorts = positions.some( + (position) => + (position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) && + decimalFromString(position.quantity).value < ZERO_QUANTITY, ); - const currencies = new Set([...absolute, ...costs].map((money) => money.currency)); - return [...new Set([...currencies, ...partial])] - .sort((left, right) => left.localeCompare(right)) - .map((currency) => { - const absoluteReturn = absolute.find((money) => money.currency === currency)?.value ?? '0'; - const costBasis = costs.find((money) => money.currency === currency)?.value ?? '0'; - const cost = decimalFromString(costBasis); - return { - currency, - absolute: absoluteReturn, - costBasis, - percent: isZero(cost) || withShorts.has(currency) - ? null - : formatDecimal(divide( - multiply(decimalFromString(absoluteReturn), decimalFromString('100')), - cost, - PERCENT_DECIMAL_PLACES, - )), - partial: partial.has(currency), - }; - }); + return { + absolute: formatDecimal(absoluteReturn), + costBasis: formatDecimal(costBasis), + percent: isZero(costBasis) || withShorts + ? null + : formatDecimal(divide( + multiply(absoluteReturn, decimalFromString('100')), + costBasis, + PERCENT_DECIMAL_PLACES, + )), + partial, + }; } @Injectable() @@ -98,10 +79,10 @@ export class BrokerageAccountsService { openedOn: account.openedOn, portfolioSummary: account.snapshot ? { - totalsByCurrency: account.snapshot.totalsByCurrency, - cashBalances: account.snapshot.cashBalances, + totals: account.snapshot.totals, + cash: account.snapshot.cash, structure: account.snapshot.structure, - unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions), + unrealizedReturn: unrealizedReturn(account.snapshot.positions), stockPositionCount: account.snapshot.positions.filter( (position) => position.type === 'STOCK', ).length, diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index 9ab09c5..cf9bfe7 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -39,10 +39,7 @@ export class IncomePeriodDto { declare to: string; } -export class IncomeCurrencySummaryDto { - @ApiProperty() - declare currency: string; - +export class IncomeTotalDto { @ApiProperty({ type: MoneyDto }) declare coupon: MoneyDto; @@ -60,8 +57,8 @@ export class IncomeSummaryDto { @ApiProperty({ type: IncomePeriodDto }) declare period: IncomePeriodDto; - @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true }) - declare totalsByCurrency: IncomeCurrencySummaryDto[]; + @ApiProperty({ type: IncomeTotalDto }) + declare total: IncomeTotalDto; @ApiProperty({ format: 'date-time' }) declare calculatedAt: string; @@ -71,8 +68,8 @@ export class IncomeBucketDto { @ApiProperty({ type: IncomePeriodDto }) declare period: IncomePeriodDto; - @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true }) - declare totalsByCurrency: IncomeCurrencySummaryDto[]; + @ApiProperty({ type: IncomeTotalDto }) + declare total: IncomeTotalDto; } export class IncomeInstrumentDto { diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index 72052e4..067e479 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -57,22 +57,22 @@ describe('IncomeAnalyticsService', () => { ticker: 'T', instrumentType: 'bond', }, - key: 'uid:UID:RUB', + key: 'uid:UID', }, { identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' }, - key: 'figi:FIGI:RUB', + key: 'figi:FIGI', }, { identity: { ticker: 'T', instrumentType: 'bond' }, - key: 'ticker:T:bond:RUB', + key: 'ticker:T:bond', }, - { identity: { ticker: 'T' }, key: 'ticker:T::RUB' }, + { identity: { ticker: 'T' }, key: 'ticker:T:' }, { identity: { ticker: null, instrumentType: 'bond' }, - key: 'ticker::bond:RUB', + key: 'ticker::bond', }, - { identity: { ticker: null }, key: 'operation:receipt:RUB' }, + { identity: { ticker: null }, key: 'operation:receipt' }, ])( 'uses one membership for entry and aggregate: $key', async ({ identity, key }) => { @@ -102,7 +102,7 @@ describe('IncomeAnalyticsService', () => { }, ); - it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => { + it('preserves entry order, repeated IDs and signed exact totals', async () => { const selected = [ { ...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), @@ -112,10 +112,6 @@ describe('IncomeAnalyticsService', () => { ...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'), instrumentUid: 'UID', }, - { - ...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'), - instrumentUid: 'UID', - }, ]; seed(selected); @@ -131,7 +127,7 @@ describe('IncomeAnalyticsService', () => { expect(result.entries).toEqual( selected.map((item) => ({ ...item, - instrumentKey: `uid:UID:${item.payment?.currency}`, + instrumentKey: 'uid:UID', })), ); expect( @@ -146,14 +142,7 @@ describe('IncomeAnalyticsService', () => { ), ).toEqual([ { - key: 'uid:UID:USD', - receiptCount: 1, - coupon: { currency: 'USD', value: '20' }, - dividend: { currency: 'USD', value: '0' }, - total: { currency: 'USD', value: '20' }, - }, - { - key: 'uid:UID:RUB', + key: 'uid:UID', receiptCount: 2, coupon: { currency: 'RUB', value: '10.25' }, dividend: { currency: 'RUB', value: '-0.25' }, @@ -162,7 +151,7 @@ describe('IncomeAnalyticsService', () => { ]); expect( result.entries - .filter((item) => item.instrumentKey === 'uid:UID:RUB') + .filter((item) => item.instrumentKey === 'uid:UID') .map((item) => item.payment?.value), ).toEqual(['10.25', '-0.25']); }); @@ -191,26 +180,32 @@ describe('IncomeAnalyticsService', () => { expect(response.data?.buckets).toEqual([ { period: { from: '2026-01-14', to: '2026-01-14' }, - totalsByCurrency: [expect.objectContaining({ + total: { + coupon: { currency: 'RUB', value: '0.1' }, + dividend: { currency: 'RUB', value: '0' }, total: { currency: 'RUB', value: '0.1' }, - })], + }, }, { period: { from: '2026-01-15', to: '2026-01-15' }, - totalsByCurrency: [expect.objectContaining({ + total: { coupon: { currency: 'RUB', value: '0.2' }, dividend: { currency: 'RUB', value: '-0.1' }, total: { currency: 'RUB', value: '0.1' }, - })], + }, }, { period: { from: '2026-01-16', to: '2026-01-16' }, - totalsByCurrency: [], + total: { + coupon: { currency: 'RUB', value: '0' }, + dividend: { currency: 'RUB', value: '0' }, + total: { currency: 'RUB', value: '0' }, + }, }, ]); }); - it('aggregates only exact coupon and dividend payments by source currency', async () => { + it('aggregates only exact coupon and dividend payments', async () => { operations.collectRanges.mockImplementation( ( _user: string, @@ -223,7 +218,6 @@ describe('IncomeAnalyticsService', () => { entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'), entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'), - entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'), ], ].map((items, index) => ({ items, meta: completeMeta(index) })); }, @@ -238,22 +232,14 @@ describe('IncomeAnalyticsService', () => { const result = response.data; assert(result); - expect(result.totalsByCurrency).toEqual([ - expect.objectContaining({ - currency: 'RUB', - coupon: { currency: 'RUB', value: '10.25' }, - dividend: { currency: 'RUB', value: '-2.5' }, - total: { currency: 'RUB', value: '7.75' }, - }), - expect.objectContaining({ - currency: 'USD', - total: { currency: 'USD', value: '1' }, - }), - ]); + expect(result.total).toEqual({ + coupon: { currency: 'RUB', value: '10.25' }, + dividend: { currency: 'RUB', value: '-2.5' }, + total: { currency: 'RUB', value: '7.75' }, + }); expect(result.entries.map((entry) => entry.id)).toEqual([ 'coupon', 'dividend', - 'usd', ]); expect(result.buckets).toHaveLength(31); }); diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 5eb9734..b5adeae 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -18,11 +18,11 @@ import { import { IncomeAnalyticsEntryDto, IncomeAnalyticsResourceDto, - IncomeCurrencySummaryDto, IncomeDependencyDto, IncomeInstrumentDto, IncomeSummaryDto, IncomeSummaryResourceDto, + IncomeTotalDto, } from './income-analytics.dto'; import { IncomeBucketGranularity, @@ -105,7 +105,7 @@ export class IncomeAnalyticsService { assert(current.meta.lastSuccessfulAt); const selected = qualifying(current.items).map((entry) => ({ ...entry, - instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`, + instrumentKey: instrumentKey(entry), })); return { data: { @@ -139,23 +139,19 @@ function summaryResponse( entries: BrokerageOperationDto[], calculatedAt: string, ): IncomeSummaryDto { - return { accountId, period, totalsByCurrency: totals(entries), calculatedAt }; + return { accountId, period, total: totals(entries), calculatedAt }; } -function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] { - const values = new Map(); +function totals(entries: BrokerageOperationDto[]): IncomeTotalDto { + const total = zeroTotal(); for (const entry of entries) { if (!entry.payment) continue; - const total = values.get(entry.payment.currency) ?? zeroTotal(); const value = decimalFromString(entry.payment.value); if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) { total.coupon = add(total.coupon, value); } else total.dividend = add(total.dividend, value); - values.set(entry.payment.currency, total); } - return [...values] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([currency, total]) => presentTotal(currency, total)); + return presentTotal(total); } function buckets( @@ -169,7 +165,7 @@ function buckets( })); return rangeBuckets(range, granularity).map((period) => ({ period, - totalsByCurrency: totals( + total: totals( datedEntries .filter(({ date }) => date >= period.from && date <= period.to) .map(({ entry }) => entry), @@ -189,7 +185,7 @@ function instruments( return [...groups] .map(([key, group]) => { const first = group[0]; - const [total] = totals(group); + const total = totals(group); return { key, figi: first.figi, @@ -225,15 +221,10 @@ function zeroTotal(): Total { return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } }; } -function presentTotal( - currency: string, - value: Total, -): IncomeCurrencySummaryDto { - const total = add(value.coupon, value.dividend); +function presentTotal(value: Total): IncomeTotalDto { return { - currency, - coupon: { currency, value: formatDecimal(value.coupon) }, - dividend: { currency, value: formatDecimal(value.dividend) }, - total: { currency, value: formatDecimal(total) }, + coupon: { currency: 'RUB', value: formatDecimal(value.coupon) }, + dividend: { currency: 'RUB', value: formatDecimal(value.dividend) }, + total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) }, }; } diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.spec.ts b/src/modules/investments/instruments/application/catalog-discovery.service.spec.ts new file mode 100644 index 0000000..753816b --- /dev/null +++ b/src/modules/investments/instruments/application/catalog-discovery.service.spec.ts @@ -0,0 +1,20 @@ +import { CatalogDiscoveryService } from './catalog-discovery.service'; + +jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {} })); + +describe('catalog discovery currency filtering', () => { + it('uses details to filter InstrumentShort results before resolving ticker ambiguity', async () => { + const short = (uid: string) => ({ uid, ticker: 'SBER', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }); + const client = { + findShares: jest.fn().mockResolvedValue([short('a'), short('b')]), + getShare: jest.fn().mockImplementation((_token: string, uid: string) => + Promise.resolve({ uid, ticker: 'SBER', currency: uid === 'a' ? 'USD' : 'RUB' })), + getLastPrices: jest.fn().mockResolvedValue([]), + }; + const access = { online: jest.fn().mockImplementation((_user: string, run: (connection: { token: string }) => unknown) => run({ token: 'fixture' })) }; + const service = new CatalogDiscoveryService(access as never, client as never); + const search = await service.search('user', { instrumentType: 'share', query: 'SBER' }); + expect(search.data.map((item) => item.uid)).toEqual(['b']); + expect((await service.byTicker('user', 'share', 'SBER')).data?.uid).toBe('b'); + }); +}); diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 4ea5e5e..6cf401a 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -49,7 +49,8 @@ export class CatalogDiscoveryService { ? this.tbank.getShare(token, uid) : this.tbank.getBond(token, uid), ); - if (!instrument) throw new NotFoundException(); + // RUB-only: a non-RUB instrument line (e.g. USD line of an SPB-traded stock) is not available. + if (!instrument || instrument.currency?.toUpperCase() !== 'RUB') throw new NotFoundException(); return { data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid }, meta: investmentResourceMeta({ @@ -74,10 +75,18 @@ export class CatalogDiscoveryService { ? this.tbank.findShares(token, query) : this.tbank.findBonds(token, query), ); - const matches = selectCatalogInstruments( + const candidates = selectCatalogInstruments( found.filter((item) => item.ticker?.trim().toUpperCase() === query), query, instrumentType, + ); + const details = await this.access.online(userId, ({ token }) => Promise.all( + candidates.map((item) => instrumentType === 'share' + ? this.tbank.getShare(token, item.uid) + : this.tbank.getBond(token, item.uid)), + )); + const matches = candidates.filter((_item, index) => + details.at(index)?.currency?.trim().toUpperCase() === 'RUB', ).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit); if (!matches.length) throw new NotFoundException(); if (matches.length > UNAMBIGUOUS_MATCH_COUNT) { @@ -104,7 +113,7 @@ export class CatalogDiscoveryService { : found, input.query, input.instrumentType, - ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); + ); const [details, prices] = await Promise.all([ Promise.all( instruments.map((item) => @@ -122,12 +131,13 @@ export class CatalogDiscoveryService { const fetchedAt = new Date(); const data = instruments.flatMap((item, index) => { const detail = details.at(index); + if (detail?.currency?.trim().toUpperCase() !== 'RUB') return []; return [{ ...item, detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null, price: prices.find((price) => price.instrumentUid === item.uid) ?? null, }]; - }); + }).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); return { data, meta: investmentResourceMeta({ diff --git a/src/modules/investments/instruments/application/instrument-position.spec.ts b/src/modules/investments/instruments/application/instrument-position.spec.ts index eaf75dd..9923448 100644 --- a/src/modules/investments/instruments/application/instrument-position.spec.ts +++ b/src/modules/investments/instruments/application/instrument-position.spec.ts @@ -27,11 +27,11 @@ const row = ( }); const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({ positions, - cashBalances: [], - totalsByCurrency: [], + cash: { currency: 'RUB', value: '0' }, + totals: { currency: 'RUB', value: '0' }, isStale: false, lastSuccessfulAt: '2026-09-10T00:00:00Z', - structure: { currencies: [], unvaluedCategories: [] }, + structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, }); describe('stock position', () => { @@ -64,10 +64,10 @@ describe('stock position', () => { expect(result.averagePurchasePrice?.value).toBe('0.1'); }, ); - it('does not sum currencies or treat unknown values as zero', () => { + it('does not treat unknown values as zero', () => { const second = { ...row(), - currentValue: money('2', 'USD'), + currentValue: null, averagePurchasePrice: null, absoluteReturn: null, }; diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index 26b5569..2eba6ac 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -46,7 +46,7 @@ export function instrumentPosition( averagePurchasePrice: positive && totalCost ? { - currency: totalCost.currency, + currency: 'RUB', value: formatDecimal( divide( decimalFromString(totalCost.value), @@ -83,17 +83,12 @@ function cost(position: PortfolioPositionDto): MoneyDto | null { : null; } function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { - const currency = values[0]?.currency; - if ( - !currency || - !values.every((v): v is MoneyDto => v !== null && v.currency === currency) - ) { - return null; - } + if (!values.length || !values.every((value): value is MoneyDto => value !== null)) return null; + const present = values; return { - currency, + currency: 'RUB', value: formatDecimal( - values.reduce( + present.reduce( (sum, value) => add(sum, decimalFromString(value.value)), decimalFromString('0'), ), @@ -107,7 +102,6 @@ function percent( if ( !base || !result || - base.currency !== result.currency || decimalFromString(base.value).value <= ZERO_AMOUNT ) { return null; diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index 5e71d90..5a76683 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -178,7 +178,7 @@ export function bondCoupons( recordDate && recordDate >= today ? { - currency: perBond.currency, + currency: 'RUB', value: formatDecimal( multiply(quantity, decimalFromString(perBond.value)), ), @@ -227,7 +227,6 @@ export function bondMetrics( coupon && couponPeriodDays && clean && - coupon.currency === clean.currency && decimalFromString(coupon.value).value >= ZERO_AMOUNT && decimalFromString(clean.value).value > ZERO_AMOUNT ? formatDecimal( diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index e7919d6..b39c57a 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -82,7 +82,7 @@ export function bondQuote( nominal && decimalFromString(nominal.value).value > ZERO_AMOUNT ? { - currency: nominal.currency, + currency: 'RUB', value: formatDecimal( divide( multiply( @@ -96,9 +96,9 @@ export function bondQuote( } : null; const dirtyPrice = - cleanPrice && aci && aci.currency === cleanPrice.currency + cleanPrice && aci ? { - currency: cleanPrice.currency, + currency: 'RUB', value: formatDecimal( add( decimalFromString(cleanPrice.value), diff --git a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts index 66ca80e..b6d1c79 100644 --- a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts +++ b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts @@ -1,10 +1 @@ -import { formatDecimal } from '#src/common/decimal'; -import { decimal } from '#src/integrations/tbank/decimal'; -import { type TbankMoney } from '#src/integrations/tbank/tbank.api'; - -export function money(value?: TbankMoney) { - const currency = value?.currency?.toUpperCase(); - return value && currency - ? { currency, value: formatDecimal(decimal(value)) } - : null; -} +export { rubMoney as money } from '#src/integrations/tbank/decimal'; diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index f5e5e74..4611847 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -70,16 +70,17 @@ export function metrics( // eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above. const value = values[key]; const currency = values.currency?.toUpperCase() || null; + const rub = currency === 'RUB'; return typeof value === 'number' && Number.isFinite(value) && - (unit !== 'money' || currency) + (unit !== 'money' || rub) ? [ { key, value, unit, period, - currency: unit === 'money' ? currency : null, + currency: unit === 'money' ? 'RUB' : null, }, ] : []; diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index e5d65e2..a9c86b1 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -45,7 +45,7 @@ export class StockCatalogService { price: last?.price && detail.currency ? { - currency: detail.currency, + currency: 'RUB', value: formatDecimal(decimal(last.price)), } : null, diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index 96986bd..86347d3 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -6,7 +6,7 @@ import { multiply, } from '#src/common/decimal'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; -import { decimal } from '#src/integrations/tbank/decimal'; +import { rubMoney } from '#src/integrations/tbank/decimal'; import { TbankAccessService, type TbankDividend, @@ -131,11 +131,7 @@ export function stockDividends( const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; - const currency = event.dividendNet?.currency?.toUpperCase(); - const perShare = - currency && event.dividendNet - ? { currency, value: formatDecimal(decimal(event.dividendNet)) } - : null; + const perShare = rubMoney(event.dividendNet); // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. const eventDate = paymentDate ?? recordDate; const timing: StockDividendDto['timing'] = eventDate @@ -150,7 +146,7 @@ export function stockDividends( recordDate >= today && timing === 'upcoming' ? { - currency: perShare.currency, + currency: 'RUB', value: formatDecimal( multiply(quantity, decimalFromString(perShare.value)), ), diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index 468904d..6351d48 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -8,7 +8,7 @@ import { subtract, } from '#src/common/decimal'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; -import { decimal } from '#src/integrations/tbank/decimal'; +import { rubMoney } from '#src/integrations/tbank/decimal'; import { CandleService } from '../../application/candle.service'; import { @@ -70,15 +70,8 @@ export function stockQuote( const { price: last, candle: previous, tradingStatus = null } = values; const share = values.instrument; const priceAt = timestamp(last?.time); - const currency = share?.currency?.toUpperCase(); - const price = - last?.price && currency - ? { currency, value: formatDecimal(decimal(last.price)) } - : null; - const previousPrice = - previous?.close && currency - ? { currency, value: formatDecimal(decimal(previous.close)) } - : null; + const price = last?.price ? rubMoney({ ...last.price, currency: share?.currency }) : null; + const previousPrice = previous?.close ? rubMoney({ ...previous.close, currency: share?.currency }) : null; const difference = price && previousPrice ? subtract( @@ -93,8 +86,8 @@ export function stockQuote( previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousClose: previousPrice, change: - difference && currency - ? { currency, value: formatDecimal(difference) } + difference + ? { currency: 'RUB', value: formatDecimal(difference) } : null, changePercent: difference && diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts index 9604936..498cb54 100644 --- a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts @@ -87,7 +87,6 @@ describe('StockScreenerService', () => { client.getAllShares.mockResolvedValueOnce([ share(1, { countryOfRisk: undefined, - currency: undefined, isin: undefined, realExchange: undefined, sector: undefined, @@ -96,7 +95,7 @@ describe('StockScreenerService', () => { }), ]); const result = await service.search('user-1', {}); - expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null })); + expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: 'RUB', isin: null, logoName: null, ticker: null })); client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]); await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException); diff --git a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts index f6b5e06..8a1e894 100644 --- a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts +++ b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts @@ -29,16 +29,23 @@ describe('selectStockScreenerInstruments', () => { share({ isin: 'D', sector: 'finance', uid: 'uid-4' }), ], }, - { currency: 'RUB', query: 'star', sector: 'energy' }, + { query: 'star', sector: 'energy' }, ); expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] }); }); + it('filters out non-RUB instruments', () => { + const result = selectStockScreenerInstruments( + { asOf, instruments: [share({ currency: 'USD' }), share({ currency: undefined })] }, + {}, + ); + expect(result.instruments).toEqual([]); + }); + it.each([ ['sector', { sector: 'energy' }, share({ sector: undefined })], ['share type', { shareType: 'common' }, share({ shareType: 'preferred' })], - ['currency', { currency: 'USD' }, share({ currency: undefined })], ['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })], ['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })], ['query', { query: 'star' }, share({ name: undefined, ticker: undefined })], diff --git a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts index 2287785..8f4a954 100644 --- a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts +++ b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts @@ -7,7 +7,6 @@ export type StockScreenerCatalog = { export type StockScreenerConditions = { countryOfRisk?: string; - currency?: string; maxLot?: number; query?: string; realExchange?: string; @@ -76,12 +75,7 @@ function matches( ) { return false; } - if ( - conditions.currency !== undefined && - instrument.currency !== conditions.currency - ) { - return false; - } + if (instrument.currency?.toUpperCase() !== 'RUB') return false; if ( conditions.realExchange !== undefined && instrument.realExchange !== conditions.realExchange diff --git a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts index f5286d5..3717dac 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -89,7 +89,7 @@ export class StockController { criteria: StockScreenerCriteriaDto, ) { // The global whitelist pipe strips unknown keys before this param pipe runs, so only the raw body can reject them. - const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']); + const allowed = new Set(['countryOfRisk', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']); if (Object.keys(request.body as object).some((key) => !allowed.has(key))) { throw new BadRequestException(); } diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index 10b2eef..80a1511 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -52,7 +52,6 @@ export class StockScreenerCriteriaDto { @IsOptional() @IsString() @MinLength(MIN_TICKER_LENGTH) - currency?: string; @ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH }) @IsOptional() diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts index 0c20319..faab264 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts @@ -56,6 +56,24 @@ describe('mapBrokerageOperation', () => { payment: null, }); }); + + it('filters non-RUB money out of every money field at ingest', () => { + expect( + mapBrokerageOperation({ + id: 'usd', + date: { seconds: '1' }, + type: 'OPERATION_TYPE_DIVIDEND', + payment: { currency: 'usd', units: '21' }, + price: { currency: 'USD', units: '10' }, + commission: { currency: 'EUR', units: '1' }, + }), + ).toMatchObject({ + id: 'usd', + payment: null, + price: null, + commission: null, + }); + }); }); describe('executed trade quantity', () => { diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 27fcce2..0e1c41f 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,8 +1,7 @@ -import { formatDecimal } from '#src/common/decimal'; import { MILLISECONDS_PER_SECOND } from '#src/common/time'; -import { decimal } from '#src/integrations/tbank/decimal'; +import { rubMoney } from '#src/integrations/tbank/decimal'; import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol'; -import { TbankMoney, TbankOperationItem } from '#src/integrations/tbank/tbank.api'; +import { TbankOperationItem } from '#src/integrations/tbank/tbank.api'; import { type OperationCategory } from '../../operation-category'; import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto'; @@ -56,7 +55,7 @@ export function mapBrokerageOperation( ticker: text(operation.ticker), instrumentType: text(operation.instrumentType), logoName: null, - payment: money(operation.payment), + payment: rubMoney(operation.payment), quantity: (operationCategory(operation.type) === 'trade' ? operation.quantityDone @@ -67,9 +66,9 @@ export function mapBrokerageOperation( ? operation.quantityDone : operation.quantity, ), - price: money(operation.price), - accruedInterest: money(operation.accruedInt), - commission: money(operation.commission), + price: rubMoney(operation.price), + accruedInterest: rubMoney(operation.accruedInt), + commission: rubMoney(operation.commission), }; } @@ -77,13 +76,6 @@ function text(value: string | undefined): null | string { return value?.trim() || null; } -function money(value: TbankMoney | undefined) { - const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; -} - export function operationTimestamp( value: undefined | { nanos?: number; seconds?: number | string }, ) { diff --git a/src/modules/investments/performance/domain/performance-availability.ts b/src/modules/investments/performance/domain/performance-availability.ts index cde1d66..5261b7b 100644 --- a/src/modules/investments/performance/domain/performance-availability.ts +++ b/src/modules/investments/performance/domain/performance-availability.ts @@ -8,7 +8,6 @@ export enum PerformanceUnavailableReason { AmbiguousReturn = 'AMBIGUOUS_RETURN', HistoryStartUnknown = 'HISTORY_START_UNKNOWN', IndexHistoryUnavailable = 'INDEX_HISTORY_UNAVAILABLE', - NonRubActivity = 'NON_RUB_ACTIVITY', NoSolution = 'NO_SOLUTION', OperationsHistoryIncomplete = 'OPERATIONS_HISTORY_INCOMPLETE', PortfolioValuationIncomplete = 'PORTFOLIO_VALUATION_INCOMPLETE', diff --git a/src/modules/investments/performance/domain/performance-flows.spec.ts b/src/modules/investments/performance/domain/performance-flows.spec.ts index 21207e9..85fbb3e 100644 --- a/src/modules/investments/performance/domain/performance-flows.spec.ts +++ b/src/modules/investments/performance/domain/performance-flows.spec.ts @@ -30,12 +30,20 @@ describe('performance cash flows', () => { expect(result.flows).toHaveLength(2); }); - it('detects non-RUB operations and unvalued securities transfers', () => { + it('detects unvalued securities transfers', () => { const result = readPerformanceOperations([ - op('A', 'OPERATION_TYPE_INPUT', '2025-01-01T15:00:00Z', '10', 'usd'), op('B', 'OPERATION_TYPE_INPUT_SECURITIES', '2025-01-01T15:00:00Z', '0'), ]); - expect([...result.nonRubAccounts]).toEqual(['A']); expect([...result.unvaluedAccounts]).toEqual(['B']); }); + + it('skips non-RUB cash movements read back from persisted payloads', () => { + const result = readPerformanceOperations([ + op('A', 'OPERATION_TYPE_INPUT', '2025-01-01T15:00:00Z', '100'), + op('A', 'OPERATION_TYPE_INPUT', '2025-02-01T15:00:00Z', '21', 'usd'), + ]); + expect(result.flows).toHaveLength(1); + expect(formatDecimal(sumFlows(result.flows))).toBe('100'); + expect([...result.missingAmountAccounts]).toEqual(['A']); + }); }); diff --git a/src/modules/investments/performance/domain/performance-flows.ts b/src/modules/investments/performance/domain/performance-flows.ts index 17ebd22..49e5807 100644 --- a/src/modules/investments/performance/domain/performance-flows.ts +++ b/src/modules/investments/performance/domain/performance-flows.ts @@ -68,7 +68,10 @@ function appendCashFlow( missingAmountAccounts.add(operation.accountId); return; } - if (value.currency !== 'RUB') return; + if (value.currency !== 'RUB') { + missingAmountAccounts.add(operation.accountId); + return; + } const amount = magnitude(value.value); flows.push({ accountId: operation.accountId, @@ -79,7 +82,6 @@ function appendCashFlow( export function readPerformanceOperations(operations: Operation[]) { const flows: PerformanceFlow[] = []; - const nonRubAccounts = new Set(); const unvaluedAccounts = new Set(); const missingAmountAccounts = new Set(); for (const operation of operations) { @@ -87,14 +89,13 @@ export function readPerformanceOperations(operations: Operation[]) { if (typeof payload.type !== 'string') continue; const value = money(payload.payment); if (deposits.has(payload.type) || withdrawals.has(payload.type)) { - if (value && value.currency !== 'RUB') nonRubAccounts.add(operation.accountId); appendCashFlow(operation, payload, value, flows, missingAmountAccounts); } else if (securities.has(payload.type)) { // Securities moved in or out carry no money amount for the flow. unvaluedAccounts.add(operation.accountId); } } - return { flows, missingAmountAccounts, nonRubAccounts, unvaluedAccounts }; + return { flows, missingAmountAccounts, unvaluedAccounts }; } export function candidateTransfers(input: ReturnType): Candidate[] { diff --git a/src/modules/investments/performance/performance.service.spec.ts b/src/modules/investments/performance/performance.service.spec.ts index b9ad434..399db4c 100644 --- a/src/modules/investments/performance/performance.service.spec.ts +++ b/src/modules/investments/performance/performance.service.spec.ts @@ -16,7 +16,7 @@ function account(accountId: string, amount = '121000') { return { accountId, name: accountId, historyFrom: first, historyBoundary: 'known', portfolio: { fetchedAt: end, payload: { - positions: [], totalsByCurrency: [{ currency: 'RUB', value: amount }], + positions: [], totals: { currency: 'RUB', value: amount }, } }, coverage: [{ from: first, to: coverageEnd, asOf: coverageEnd }], operations: [operation(accountId, 'OPERATION_TYPE_INPUT', '100000')], @@ -94,6 +94,13 @@ describe('investment performance read', () => { expect(result.data?.return).toMatchObject({ basis: 'period', value: '0.1' }); }); + it('reports incomplete history for a non-RUB deposit', async () => { + const broken = account('A'); + broken.operations.push({ ...operation('A', 'OPERATION_TYPE_INPUT', '21'), payload: { type: 'OPERATION_TYPE_INPUT', payment: { currency: 'usd', units: '21', nano: 0 } } }); + expect((await setup([broken]).service.read('user', 'A')).meta.unavailable) + .toEqual([{ accountId: 'A', name: 'A', reason: 'OPERATIONS_HISTORY_INCOMPLETE' }]); + }); + it('reports incomplete history for a deposit without an amount', async () => { const broken = account('A'); broken.operations.push({ ...operation('A', 'OPERATION_TYPE_INPUT', '0'), payload: { type: 'OPERATION_TYPE_INPUT' } } as never); diff --git a/src/modules/investments/performance/performance.service.ts b/src/modules/investments/performance/performance.service.ts index 5f07670..580556c 100644 --- a/src/modules/investments/performance/performance.service.ts +++ b/src/modules/investments/performance/performance.service.ts @@ -17,14 +17,13 @@ import { PerformanceReturnDto, PerformanceSeriesPointDto, } from './presentation/performance.dto'; -const RUB = 'RUB'; const DAY_MS = 86_400_000; const DAYS_IN_YEAR = 365; const RATE_SCALE = 6; const MONEY_SCALE = 2; +const RUB = 'RUB'; const ZERO = 0; const ONE = 1; -const ZERO_UNITS = 0n; const ZERO_DECIMAL = decimalFromString('0'); const LAST_FLOW = -1; const ONE_DECIMAL = decimalFromString('1'); @@ -164,7 +163,7 @@ export class PerformanceService { const portfolio = account.portfolio; if (!portfolio) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete); const snapshot = portfolio.payload as { positions: { currentValue: unknown }[]; - totalsByCurrency: { currency: string; value: string }[]; }; + totals: { value: string }; }; if (!isCompleteValuation(snapshot)) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete); if (!hasPerformanceCoverage(account.accountId, account.historyFrom, portfolio.fetchedAt, account.coverage)) { return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete); @@ -172,16 +171,10 @@ export class PerformanceService { const operations = account.operations.filter((op) => op.occurredAt <= portfolio.fetchedAt) .map((op) => ({ ...op, accountId: account.accountId })); const parsed = readPerformanceOperations(operations); - if (parsed.nonRubAccounts.size || snapshot.totalsByCurrency.some((row) => - row.currency !== RUB && decimalFromString(row.value).value !== ZERO_UNITS)) { - return fail(PerformanceUnavailableReason.NonRubActivity); - } if (parsed.missingAmountAccounts.size) return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete); if (parsed.unvaluedAccounts.size) return fail(PerformanceUnavailableReason.UnvaluedSecuritiesTransfer); - const value = snapshot.totalsByCurrency.find((row) => row.currency === RUB); - if (!value) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete); return { - included: { account, flows: parsed.flows, value: decimalFromString(value.value), + included: { account, flows: parsed.flows, value: decimalFromString(snapshot.totals.value), to: moscowDate(portfolio.fetchedAt) }, reason: null, }; diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index b481ae2..21ba09e 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -103,24 +103,21 @@ function filterEvents(payload: T, scopeKey: string, savedScope const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string]; const value = payload as T & { events?: { date: string; projectedAmount?: { currency: string; value: string }; type: string }[]; - incomeTotalsByCurrency?: { currency: string; value: string }[]; + incomeTotal?: { currency: string; value: string }; }; - if (!value.events || !value.incomeTotalsByCurrency) return payload; + if (!value.events || !value.incomeTotal) return payload; const events = value.events.filter((event) => event.date >= from && event.date <= to); - const totals = new Map>(); + let total = { value: 0n, scale: 0 }; for (const event of events) { if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) continue; - const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 }; - totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value))); + total = add(total, decimalFromString(event.projectedAmount.value)); } const [, savedFrom, savedTo] = JSON.parse(savedScopeKey.slice('events/'.length)) as [string, string, string]; return { ...value, window: { from: savedFrom, to: savedTo }, events, - incomeTotalsByCurrency: [...totals] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })), + incomeTotal: { currency: 'RUB', value: formatDecimal(total) }, }; } catch { return payload; diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 8b7b0e8..17e1c2b 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -145,19 +145,15 @@ describe('persistent portfolio projection', () => { ]), ).toEqual([ ['Known', 'STOCK', '2'], - ['B', 'OTHER', '-0.5'], ]); expect(snapshot.positions[0].absoluteReturn).toEqual({ currency: 'RUB', value: '2.000000002', }); - expect(snapshot.totalsByCurrency).toEqual([ - { currency: 'RUB', value: '7.000000002' }, - { currency: 'USD', value: '-2' }, - ]); - expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]); + expect(snapshot.totals).toEqual({ currency: 'RUB', value: '7.000000002' }); + expect(snapshot.cash).toEqual({ currency: 'RUB', value: '1' }); }); - it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => { + it('filters non-RUB cash and positions out of the RUB-only snapshot', async () => { repository.reserve.mockResolvedValueOnce({ launch: true, work: { id: 'work', attemptId: 'attempt' }, @@ -180,17 +176,10 @@ describe('persistent portfolio projection', () => { assert(handle); await handle.done; const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; - expect(snapshot.cashBalances).toEqual([ - { currency: 'RUB', value: '0' }, - { currency: 'EUR', value: '1' }, - ]); - expect(snapshot.totalsByCurrency).toEqual([ - { currency: 'EUR', value: '1' }, - { currency: 'USD', value: '3' }, - ]); + expect(snapshot.cash).toEqual({ currency: 'RUB', value: '0' }); + expect(snapshot.totals).toEqual({ currency: 'RUB', value: '0' }); expect(snapshot.positions[0].currentValue).toBeNull(); - expect(snapshot.positions[1].absoluteReturn).toBeNull(); - expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' }); + expect(snapshot.positions).toHaveLength(1); }); it('counts cash once when T-Bank also returns it as a CURRENCY position', async () => { @@ -213,9 +202,9 @@ describe('persistent portfolio projection', () => { assert(handle); await handle.done; const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; - expect(snapshot.totalsByCurrency).toEqual([{ currency: 'RUB', value: '121.49' }]); - expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND', 'CURRENCY']); - expect(snapshot.structure.currencies[0].totalValue).toBe('121.49'); + expect(snapshot.totals).toEqual({ currency: 'RUB', value: '121.49' }); + expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND']); + expect(snapshot.structure.totalValue).toBe('121.49'); }); // Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response. @@ -235,9 +224,9 @@ describe('persistent portfolio projection', () => { lastSuccessfulAt: membershipAt.toISOString(), isStale: false, positions: [], - cashBalances: [], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }], + cash: { currency: 'RUB', value: '0' }, + structure: { totalValue: '1.000000001', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, + totals: { currency: 'RUB', value: '1.000000001' }, }; const account = { accountId: 'a', @@ -261,8 +250,8 @@ describe('persistent portfolio projection', () => { expect(partial.meta.availability).toBe('temporarily-unavailable'); expect(partial.meta.completeness).toBe('incomplete'); const saved = await service.saved('user', 'all'); - expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual( - snapshot.totalsByCurrency, + expect(saved.accounts[0].snapshot?.totals).toEqual( + snapshot.totals, ); expect(saved.accounts[1].snapshot).toBeNull(); repository.read.mockResolvedValue({ @@ -279,9 +268,9 @@ describe('persistent portfolio projection', () => { work: [], }); const complete = await service.read('user', 'all'); - expect(complete.data?.totalsByCurrency).toEqual([ - { currency: 'RUB', value: '2.000000002' }, - ]); + expect(complete.data?.totals).toEqual({ + currency: 'RUB', value: '2.000000002', + }); expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString()); expect(complete.data?.isStale).toBe(true); expect(tbank.getPortfolio).not.toHaveBeenCalled(); diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index b6d298c..ce42abe 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -25,7 +25,7 @@ import { TbankSyncRepository, } from '../../tbank-sync/tbank-sync.api'; import { portfolioStructure } from '../domain/portfolio-structure'; -import { sumMoneyByCurrency } from '../domain/portfolio-totals'; +import { sumMoney } from '../domain/portfolio-totals'; import { portfolioFreshForMs, PortfolioRepository, @@ -184,16 +184,16 @@ export class PortfolioService { isStale: portfolios.length ? portfolios.some((portfolio) => portfolio.isStale) : saved.membership.freshness !== 'fresh', - totalsByCurrency: sumMoneyByCurrency( - portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), + totals: sumMoney( + portfolios.map((portfolio) => portfolio.totals), ), - cashBalances: sumMoneyByCurrency( - portfolios.flatMap((portfolio) => portfolio.cashBalances), + cash: sumMoney( + portfolios.map((portfolio) => portfolio.cash), ), positions: portfolios.flatMap((portfolio) => portfolio.positions), structure: portfolioStructure( portfolios.flatMap((portfolio) => portfolio.positions), - portfolios.flatMap((portfolio) => portfolio.cashBalances), + portfolios.map((portfolio) => portfolio.cash), ), }; const work = diff --git a/src/modules/investments/portfolio/domain/complete-valuation.spec.ts b/src/modules/investments/portfolio/domain/complete-valuation.spec.ts index 6b9e3c0..fea7004 100644 --- a/src/modules/investments/portfolio/domain/complete-valuation.spec.ts +++ b/src/modules/investments/portfolio/domain/complete-valuation.spec.ts @@ -1,14 +1,13 @@ import { isCompleteValuation } from './complete-valuation'; describe('isCompleteValuation', () => { - it('requires an actual currency total and every position value', () => { - expect(isCompleteValuation({ totalsByCurrency: [], positions: [] })).toBe(false); + it('requires an actual total and every position value', () => { expect(isCompleteValuation({ - totalsByCurrency: [{ currency: 'RUB', value: '100' }], + totals: { value: '100' }, positions: [{ currentValue: null }], })).toBe(false); expect(isCompleteValuation({ - totalsByCurrency: [{ currency: 'RUB', value: '100' }], + totals: { value: '100' }, positions: [{ currentValue: { currency: 'RUB', value: '0' } }], })).toBe(true); }); diff --git a/src/modules/investments/portfolio/domain/complete-valuation.ts b/src/modules/investments/portfolio/domain/complete-valuation.ts index 5ffc610..3a30b83 100644 --- a/src/modules/investments/portfolio/domain/complete-valuation.ts +++ b/src/modules/investments/portfolio/domain/complete-valuation.ts @@ -1,13 +1,11 @@ type Valuation = { positions: { currentValue: unknown }[]; - totalsByCurrency: unknown[]; + totals: { value: string }; }; -const MIN_TOTALS_COUNT = 0; - export function isCompleteValuation( snapshot: Valuation, ): boolean { - return snapshot.totalsByCurrency.length > MIN_TOTALS_COUNT && + return Boolean(snapshot.totals) && snapshot.positions.every((position) => position.currentValue !== null); } diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts index c7ff8b7..cb16906 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts @@ -4,10 +4,9 @@ import { portfolioStructure } from './portfolio-structure'; const position = ( type: string, value: null | string, - currency = 'RUB', ): PortfolioPositionDto => ({ type, - currentValue: value === null ? null : { currency, value }, + currentValue: value === null ? null : { currency: 'RUB', value }, quantity: '1', displayName: 'Fixture', figi: null, @@ -20,18 +19,18 @@ const position = ( averagePurchasePrice: null, }); +const rub = (value: string) => ({ currency: 'RUB', value }); + describe('portfolio structure', () => { - it('counts cash once and skips CURRENCY positions that mirror it', () => { + it('builds a single RUB composition with cash counted once', () => { const result = portfolioStructure( [ position('STOCK', '60'), position('BOND', '20'), - position('CURRENCY', '10'), ], - [{ currency: 'RUB', value: '10' }], + [rub('10')], ); - expect(result.currencies[0]).toEqual({ - currency: 'RUB', + expect(result).toEqual({ totalValue: '90', hasNegativeValues: false, categories: [ @@ -40,75 +39,58 @@ describe('portfolio structure', () => { { type: 'OTHER', value: '0', sharePercent: '0' }, { type: 'CASH', value: '10', sharePercent: '11.111111' }, ], + unvaluedCategories: [], }); }); - it('sums beyond Number precision and separates currencies without FX', () => { + it('sums beyond Number precision', () => { const result = portfolioStructure( [ position('STOCK', '9007199254740993.000000001'), position('STOCK', '0.000000009'), - position('BOND', '2', 'USD'), + position('BOND', '3.000000001'), ], - [{ currency: 'USD', value: '1' }], + [rub('1')], ); - expect(result.currencies[0].totalValue).toBe('9007199254740993.00000001'); - expect(result.currencies[1].totalValue).toBe('3'); - expect(result.currencies[1].categories[1].sharePercent).toBe('66.666667'); - expect(result.currencies[1].categories[3].sharePercent).toBe('33.333333'); + expect(result.totalValue).toBe('9007199254740997.000000011'); + expect(result.categories[0].sharePercent).toBe('100'); + expect(result.categories[1].sharePercent).toBe('0'); + expect(result.categories[3].sharePercent).toBe('0'); }); it('represents cash-only, empty and zero-valued portfolios', () => { - expect(portfolioStructure([], []).currencies).toEqual([]); + expect(portfolioStructure([], [rub('0')]).totalValue).toBe('0'); + expect(portfolioStructure([], [rub('10')]).categories[3].sharePercent).toBe('100'); expect( - portfolioStructure([], [{ currency: 'EUR', value: '10' }]).currencies[0] - .categories[3].sharePercent, - ).toBe('100'); - expect( - portfolioStructure( - [position('STOCK', '0')], - [], - ).currencies[0].categories.every((item) => item.sharePercent === null), - ).toBe(true); - }); - - it('retains unvalued categories without assigning zero or guessing a currency', () => { - const result = portfolioStructure( - [position('BOND', null), position('BOND', null), position('STOCK', '50')], - [], - ); - expect(result.unvaluedCategories).toEqual([{ type: 'BOND', count: 2 }]); - expect(result.currencies[0].totalValue).toBe('50'); - expect( - result.currencies[0].categories.every( + portfolioStructure([position('STOCK', '0')], [rub('0')]).categories.every( (item) => item.sharePercent === null, ), ).toBe(true); + }); + + it('retains unvalued categories without assigning zero', () => { + const result = portfolioStructure( + [position('BOND', null), position('BOND', null), position('STOCK', '50')], + [rub('0')], + ); + expect(result.unvaluedCategories).toEqual([{ type: 'BOND', count: 2 }]); + expect(result.totalValue).toBe('50'); expect( - portfolioStructure([position('OTHER', null)], []).currencies, - ).toEqual([]); + result.categories.every((item) => item.sharePercent === null), + ).toBe(true); }); it.each([ - [[position('STOCK', '-20'), position('STOCK', '100')], []], - [ - [], - [ - { currency: 'RUB', value: '-20' }, - { currency: 'RUB', value: '100' }, - ], - ], - [[position('BOND', '-20')], []], - [[position('BOND', '-20'), position('BOND', '20')], []], + [[position('STOCK', '-20'), position('STOCK', '100')], rub('0')], + [[], rub('-20')], + [[position('BOND', '-20'), position('BOND', '20')], rub('0')], ])( 'preserves negative constituents even when their category nets positive or zero', (positions, cash) => { - const result = portfolioStructure(positions, cash); - expect(result.currencies[0].hasNegativeValues).toBe(true); + const result = portfolioStructure(positions, [cash]); + expect(result.hasNegativeValues).toBe(true); expect( - result.currencies[0].categories.every( - (item) => item.sharePercent === null, - ), + result.categories.every((item) => item.sharePercent === null), ).toBe(true); }, ); diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index 9b92249..7cb9c1d 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -20,71 +20,52 @@ const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const; // Build from positions and cash, never from totals (which already include cash). export function portfolioStructure( positions: { currentValue: Money | null; type: string }[], - cash: Money[], + cashAmounts: Money[], ) { - const currencies = new Map< - string, - { - hasNegativeValues: boolean; - values: ReturnType[]; - } - >(); + const values = categoryTypes.map(() => decimalFromString('0')); + let hasNegativeValues = false; const unvalued = new Map(); const include = (type: string, money: Money) => { - const group = currencies.get(money.currency) ?? { - values: categoryTypes.map(() => decimalFromString('0')), - hasNegativeValues: false, - }; const index = categoryTypes.findIndex((category) => category === type); const value = decimalFromString(money.value); // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. - group.values[index] = add(group.values[index], value); - group.hasNegativeValues ||= value.value < ZERO_AMOUNT; - currencies.set(money.currency, group); + values[index] = add(values[index], value); + hasNegativeValues ||= value.value < ZERO_AMOUNT; }; for (const position of positions) { - // CURRENCY positions mirror cash, which is counted once as CASH below. - if (position.type === 'CURRENCY') continue; const type = ['BOND', 'STOCK'].includes(position.type) ? position.type : 'OTHER'; if (position.currentValue) include(type, position.currentValue); else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT); } - for (const amount of cash) include('CASH', amount); + for (const amount of cashAmounts) include('CASH', amount); + const total = values.reduce(add, decimalFromString('0')); + const canCalculateShares = + !unvalued.size && !hasNegativeValues && total.value > ZERO_AMOUNT; + const categories = values.map((value, index) => ({ + // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. + type: categoryTypes[index], + value: formatDecimal(value), + // Rounded to six decimal places using integer arithmetic. Only the + // resulting dimensionless percentage is passed to chart geometry. + sharePercent: canCalculateShares + ? formatDecimal({ + value: + (value.value * + DECIMAL_RADIX ** BigInt(total.scale - value.scale) * + PERCENT_SCALED_FACTOR + + total.value / ROUNDING_DIVISOR) / + total.value, + scale: SHARE_PERCENT_DECIMAL_PLACES, + }) + : null, + })); return { + totalValue: formatDecimal(total), + hasNegativeValues, + categories, unvaluedCategories: [...unvalued].map(([type, count]) => ({ type, count })), - currencies: [...currencies] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([currency, group]) => { - const total = group.values.reduce(add, decimalFromString('0')); - const canCalculateShares = - !unvalued.size && !group.hasNegativeValues && total.value > ZERO_AMOUNT; - const categories = group.values.map((value, index) => ({ - // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. - type: categoryTypes[index], - value: formatDecimal(value), - // Rounded to six decimal places using integer arithmetic. Only the - // resulting dimensionless percentage is passed to chart geometry. - sharePercent: canCalculateShares - ? formatDecimal({ - value: - (value.value * - DECIMAL_RADIX ** BigInt(total.scale - value.scale) * - PERCENT_SCALED_FACTOR + - total.value / ROUNDING_DIVISOR) / - total.value, - scale: SHARE_PERCENT_DECIMAL_PLACES, - }) - : null, - })); - return { - currency, - totalValue: formatDecimal(total), - hasNegativeValues: group.hasNegativeValues, - categories, - }; - }), }; } diff --git a/src/modules/investments/portfolio/domain/portfolio-totals.ts b/src/modules/investments/portfolio/domain/portfolio-totals.ts index 25cdd2a..6e5a91c 100644 --- a/src/modules/investments/portfolio/domain/portfolio-totals.ts +++ b/src/modules/investments/portfolio/domain/portfolio-totals.ts @@ -1,23 +1,12 @@ import type { Money } from '#src/common/money.types'; -import { - add, - decimalFromString, - formatDecimal, -} from '#src/common/decimal'; +import { add, decimalFromString, formatDecimal } from '#src/common/decimal'; -export function sumMoneyByCurrency(amounts: Money[]): Money[] { - const totals = new Map>(); - for (const amount of amounts) { - totals.set( - amount.currency, - add( - totals.get(amount.currency) ?? decimalFromString('0'), - decimalFromString(amount.value), - ), - ); - } - return [...totals] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); +// RUB-only: all inputs are expected to be RUB Money. +export function sumMoney(amounts: Money[]): Money { + const total = amounts.reduce( + (sum, amount) => add(sum, decimalFromString(amount.value)), + decimalFromString('0'), + ); + return { currency: 'RUB', value: formatDecimal(total) }; } diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 8ceba25..626c835 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -150,27 +150,23 @@ export class PortfolioRepository { create: { userId: attempt.userId, accountId }, update: { deletedAt: null }, }); - for (const { currency, value } of snapshot.totalsByCurrency) { - await tx.tbankPortfolioValuePoint.upsert({ - where: { - userId_accountId_attemptId_currency: { - userId: attempt.userId, - accountId, - attemptId: attempt.attemptId, - currency, - }, - }, - create: { + await tx.tbankPortfolioValuePoint.upsert({ + where: { + userId_accountId_attemptId: { userId: attempt.userId, accountId, attemptId: attempt.attemptId, - currency, - value, - capturedAt: data.fetchedAt, }, - update: {}, - }); - } + }, + create: { + userId: attempt.userId, + accountId, + attemptId: attempt.attemptId, + value: snapshot.totals.value, + capturedAt: data.fetchedAt, + }, + update: {}, + }); } return true; }); @@ -198,8 +194,9 @@ export function portfolioSnapshot(value: Prisma.JsonValue): PortfolioDto { const snapshot = value as null | Partial; if ( !Array.isArray(snapshot?.positions) || - !Array.isArray(snapshot.totalsByCurrency) || - !Array.isArray(snapshot.cashBalances) + !snapshot.totals?.currency || + !snapshot.cash?.currency || + !Array.isArray(snapshot.structure?.categories) ) { throw new Error('Corrupted portfolio snapshot payload'); } diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index 79e171f..382bbd0 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -8,13 +8,12 @@ import { multiply, subtract, } from '#src/common/decimal'; -import { decimal } from '#src/integrations/tbank/decimal'; +import { decimal, rubMoney } from '#src/integrations/tbank/decimal'; import { metadataKey, type TbankInstrument, type TbankMoney, TbankPortfolio, - TbankPositions, } from '#src/integrations/tbank/tbank.api'; import type { Classification } from '../application/position-classification.service'; @@ -22,6 +21,8 @@ import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfol import { portfolioStructure } from '../domain/portfolio-structure'; +const RUB = 'RUB'; + const types: Record = { share: 'STOCK', bond: 'BOND', @@ -35,32 +36,36 @@ const types: Record = { function mapPortfolio( upstream: NonNullable, - cash: TbankMoney[], + cashBalances: TbankMoney[], metadata: ReadonlyMap, lastSuccessfulAt: string, classification: ReadonlyMap = new Map(), ): PortfolioDto { const positions = upstream + // Currency positions mirror cash balances and are non-RUB holdings by definition. + .filter((position) => types[position.instrumentType?.toLowerCase() ?? ''] !== 'CURRENCY' && + (!position.currentPrice?.currency?.trim() || position.currentPrice.currency.trim().toUpperCase() === RUB)) .map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null, classification.get(metadataKey(position)))) .filter((position): position is PortfolioPositionDto => position !== null); - const balances = new Map>(); - for (const value of cash) { - const currency = value.currency?.trim().toUpperCase(); - const amount = decimal(value); - if (!currency || isZero(amount)) continue; - balances.set(currency, add(balances.get(currency) ?? decimal({}), amount)); + let cashTotal = decimal({}); + for (const value of cashBalances) { + if (value.currency?.trim().toUpperCase() !== RUB) continue; + cashTotal = add(cashTotal, decimal(value)); } - const cashBalances = [...balances].map(([currency, value]) => ({ - currency, - value: formatDecimal(value), - })); + const cash: Money = { currency: RUB, value: formatDecimal(cashTotal) }; + let total = cashTotal; + for (const position of positions) { + if (!position.currentValue) continue; + total = add(total, decimalFromString(position.currentValue.value)); + } + const totals: Money = { currency: RUB, value: formatDecimal(total) }; return { lastSuccessfulAt, isStale: false, - totalsByCurrency: totalsByCurrency(upstream, cash), + totals, positions, - cashBalances, - structure: portfolioStructure(positions, cashBalances), + cash, + structure: portfolioStructure(positions, [cash]), }; } @@ -73,16 +78,16 @@ function mapPosition( if (isZero(quantity)) return null; const figi = position.figi?.trim() || null; const instrumentUid = position.instrumentUid?.trim() || null; - const currentPrice = money(position.currentPrice); - const averagePurchasePrice = money(position.averagePositionPrice); + const currentPrice = rubMoney(position.currentPrice); + const averagePurchasePrice = rubMoney(position.averagePositionPrice); const currentValue = currentPrice && { - currency: currentPrice.currency, + currency: RUB, value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))), }; const absoluteReturn = - currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency + currentPrice && averagePurchasePrice ? { - currency: currentPrice.currency, + currency: RUB, value: formatDecimal(multiply( subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)), quantity, @@ -106,44 +111,4 @@ function mapPosition( }; } -function money(value: TbankMoney | undefined): Money | null { - const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; -} - -function totalsByCurrency( - positions: NonNullable, - money: NonNullable, -): Money[] { - const totals = new Map>(); - const addTotal = ( - currency: string | undefined, - amount: ReturnType, - ) => { - const normalizedCurrency = currency?.trim().toUpperCase(); - if (!normalizedCurrency || isZero(amount)) return; - totals.set( - normalizedCurrency, - add(totals.get(normalizedCurrency) ?? decimal({}), amount), - ); - }; - - for (const value of money) addTotal(value.currency, decimal(value)); - for (const position of positions) { - const price = position.currentPrice; - // CURRENCY positions mirror the cash balances already added above. - if (!price || types[position.instrumentType?.toLowerCase() ?? ''] === 'CURRENCY') continue; - addTotal( - price.currency, - multiply(decimal(position.quantity ?? {}), decimal(price)), - ); - } - - return [...totals] - .filter(([, value]) => !isZero(value)) - .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); -} - -export { mapPortfolio, totalsByCurrency }; +export { mapPortfolio }; diff --git a/src/modules/investments/portfolio/presentation/portfolio.dto.ts b/src/modules/investments/portfolio/presentation/portfolio.dto.ts index 7527c7f..ad18f76 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.dto.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.dto.ts @@ -85,22 +85,6 @@ export class PortfolioStructureCategoryDto { declare sharePercent: null | string; } -export class PortfolioStructureCurrencyDto { - @ApiProperty() - declare currency: string; - - @ApiProperty() - declare totalValue: string; - - @ApiProperty({ - description: 'Any negative constituent, even if netted positive.', - }) - declare hasNegativeValues: boolean; - - @ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true }) - declare categories: PortfolioStructureCategoryDto[]; -} - export class PortfolioUnvaluedCategoryDto { @ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] }) declare type: string; @@ -110,13 +94,21 @@ export class PortfolioUnvaluedCategoryDto { } export class PortfolioStructureDto { - @ApiProperty({ type: PortfolioStructureCurrencyDto, isArray: true }) - declare currencies: PortfolioStructureCurrencyDto[]; + @ApiProperty({ description: 'Exact known total as a decimal string.' }) + declare totalValue: string; + + @ApiProperty({ + description: 'Any negative constituent, even if netted positive.', + }) + declare hasNegativeValues: boolean; + + @ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true }) + declare categories: PortfolioStructureCategoryDto[]; @ApiProperty({ type: PortfolioUnvaluedCategoryDto, isArray: true, description: - 'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.', + 'Positions without current value. Shares are unavailable when nonempty.', }) declare unvaluedCategories: PortfolioUnvaluedCategoryDto[]; } @@ -128,14 +120,14 @@ export class PortfolioDto { @ApiProperty() declare isStale: boolean; - @ApiProperty({ type: MoneyDto, isArray: true }) - declare totalsByCurrency: MoneyDto[]; + @ApiProperty({ type: MoneyDto }) + declare totals: MoneyDto; @ApiProperty({ type: PortfolioPositionDto, isArray: true }) declare positions: PortfolioPositionDto[]; - @ApiProperty({ type: MoneyDto, isArray: true }) - declare cashBalances: MoneyDto[]; + @ApiProperty({ type: MoneyDto }) + declare cash: MoneyDto; @ApiProperty({ type: PortfolioStructureDto }) declare structure: PortfolioStructureDto; diff --git a/src/modules/investments/upcoming-events/upcoming-event.mapper.ts b/src/modules/investments/upcoming-events/upcoming-event.mapper.ts index bf2af15..4752700 100644 --- a/src/modules/investments/upcoming-events/upcoming-event.mapper.ts +++ b/src/modules/investments/upcoming-events/upcoming-event.mapper.ts @@ -61,10 +61,10 @@ function projectedMoney( value: string, currency: string | undefined, ): UpcomingEventDto['projectedAmount'] { - const normalizedCurrency = currency?.trim().toUpperCase(); - if (!normalizedCurrency) return null; + // RUB-only: projected income in other currencies is filtered out. + if (currency?.trim().toUpperCase() !== 'RUB') return null; return { - currency: normalizedCurrency, + currency: 'RUB', value: formatDecimal(multiply(quantity, decimalFromString(value))), }; } diff --git a/src/modules/investments/upcoming-events/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts index fa2656b..b38bf71 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -90,8 +90,8 @@ export class UpcomingEventsDto { @ApiProperty({ type: UpcomingEventDto, isArray: true }) declare events: UpcomingEventDto[]; - @ApiProperty({ type: MoneyDto, isArray: true }) - declare incomeTotalsByCurrency: MoneyDto[]; + @ApiProperty({ type: MoneyDto }) + declare incomeTotal: MoneyDto; } export class UpcomingEventsResourceDto { diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts index 98ca93a..d482d51 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts @@ -13,19 +13,19 @@ const metadata = { logoName: null, }; describe('forecast financial projections', () => { - it('preserves registry dates, source currency and exact signed fractional quantities', () => { + it('preserves registry dates and exact signed fractional quantities', () => { expect( dividendEvent(metadata, decimalFromString('-2.5'), { recordDate: { seconds: String(Date.parse('2026-10-13Z') / 1000) }, - dividendNet: { currency: 'usd', units: '10', nano: 1 }, + dividendNet: { currency: 'rub', units: '10', nano: 1 }, }), ).toMatchObject({ date: '2026-10-13', dateKind: 'registry', - projectedAmount: { currency: 'USD', value: '-25.0000000025' }, + projectedAmount: { currency: 'RUB', value: '-25.0000000025' }, }); }); - it('does not invent an amount for offers or missing currency', () => { + it('does not invent an amount for offers, missing or non-RUB currency', () => { expect( bondEvent(metadata, decimalFromString('3'), { type: 'offer', @@ -38,6 +38,12 @@ describe('forecast financial projections', () => { dividendNet: { units: '10' }, }).projectedAmount, ).toBeNull(); + expect( + dividendEvent(metadata, decimalFromString('3'), { + recordDate: { seconds: '1790812800' }, + dividendNet: { currency: 'usd', units: '10' }, + }).projectedAmount, + ).toBeNull(); }); it('preserves redemption as a non-income event with its exact amount', () => { expect( diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 3327e97..476aa56 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -156,7 +156,7 @@ export class UpcomingEventsService { accountId, calculatedAt: new Date().toISOString(), events, - incomeTotalsByCurrency: incomeTotals(events), + incomeTotal: incomeTotal(events), } : undefined; return this.forecasts.result({ @@ -286,22 +286,13 @@ function dateWindow(query: UpcomingEventsQueryDto) { return { from, to }; } -function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { - const totals = new Map(); +function incomeTotal(events: UpcomingEventDto[]): MoneyDto { + let total: Decimal = { value: 0n, scale: 0 }; for (const event of events) { if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) { continue; } - const current = totals.get(event.projectedAmount.currency) ?? { - value: 0n, - scale: 0, - }; - totals.set( - event.projectedAmount.currency, - add(current, decimalFromString(event.projectedAmount.value)), - ); + total = add(total, decimalFromString(event.projectedAmount.value)); } - return [...totals] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); + return { currency: 'RUB', value: formatDecimal(total) }; } diff --git a/test/bank-cards-durability.cjs b/test/bank-cards-durability.cjs index 6be23da..7617343 100644 --- a/test/bank-cards-durability.cjs +++ b/test/bank-cards-durability.cjs @@ -91,7 +91,7 @@ function seedTbank(owner) { db.transaction(() => { db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now, owner); insert('TbankAccount', { ...scope, type: 'BROKERAGE', name: 'Synthetic persisted account', upstreamOrder: 0 }); - insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cashBalances: [], totalsByCurrency: [], structure: { currencies: [], unvaluedCategories: [] } }), fetchedAt: now }); + insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cash: { currency: 'RUB', value: '0' }, totals: { currency: 'RUB', value: '0' }, structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] } }), fetchedAt: now }); insert('TbankOperation', { ...scope, brokerOperationId: 'durable-income', occurredAt: now, category: 'income', payload: JSON.stringify({ id: 'durable-income', payment: exact }) }); insert('TbankOperationCoverage', { ...scope, from: now - 1000, to: now + 1000, fetchedAt: now, asOf: now }); insert('TbankForecast', { userId: owner, revision, scopeKey: 'durable-forecast', membershipRevision: 1, payload: JSON.stringify({ incomeTotalsByCurrency: [exact] }), dependencyRevisions: JSON.stringify({ portfolio: 1 }), sourceTimestamps: JSON.stringify({ portfolio: new Date(now).toISOString() }), calculatedAt: now }); diff --git a/test/online-upstream.ts b/test/online-upstream.ts index 293d3fe..3ba7e14 100644 --- a/test/online-upstream.ts +++ b/test/online-upstream.ts @@ -62,12 +62,12 @@ export function failUpstream(rpc: jest.Mock, kind: 'bond' | 'share', code: numbe uid: 'e6123145-9665-43e0-8413-cd61b8aa9b13', }; const healthy = new Map([ - ['bondBy', { instrument }], + ['bondBy', { instrument: { ...instrument, currency: 'rub' } }], ['findInstrument', { instruments: [instrument] }], ['getAssetBy', { asset: { description: 'description' } }], ['getAssetFundamentals', { fundamentals: [] }], ['getLastPrices', { lastPrices: [] }], - ['shareBy', { instrument }], + ['shareBy', { instrument: { ...instrument, currency: 'rub' } }], ]); const failure = Object.assign(new Error('grpc failure'), { code, diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index 32f91693..0cf0784 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -98,7 +98,7 @@ describe('OpenAPI contract (integration)', () => { expect.arrayContaining([ 'accountId', 'period', - 'totalsByCurrency', + 'total', 'calculatedAt', 'bucketGranularity', 'buckets', diff --git a/test/portfolio-structure.e2e-spec.ts b/test/portfolio-structure.e2e-spec.ts index 30dd3d0..02395ff 100644 --- a/test/portfolio-structure.e2e-spec.ts +++ b/test/portfolio-structure.e2e-spec.ts @@ -111,25 +111,20 @@ describe('Portfolio structure HTTP contract', () => { expect((response.body as { data: unknown }).data).toMatchObject({ structure: { unvaluedCategories: [], - currencies: [ + totalValue: id === 'all' ? '200' : '100', + hasNegativeValues: false, + categories: [ { - currency: 'RUB', - totalValue: id === 'all' ? '200' : '100', - hasNegativeValues: false, - categories: [ - { - type: 'STOCK', - value: id === 'all' ? '120' : '60', - sharePercent: '60', - }, - { type: 'BOND', value: '0', sharePercent: '0' }, - { type: 'OTHER', value: '0', sharePercent: '0' }, - { - type: 'CASH', - value: id === 'all' ? '80' : '40', - sharePercent: '40', - }, - ], + type: 'STOCK', + value: id === 'all' ? '120' : '60', + sharePercent: '60', + }, + { type: 'BOND', value: '0', sharePercent: '0' }, + { type: 'OTHER', value: '0', sharePercent: '0' }, + { + type: 'CASH', + value: id === 'all' ? '80' : '40', + sharePercent: '40', }, ], }, @@ -149,15 +144,11 @@ describe('Portfolio structure HTTP contract', () => { expect((response.body as { data: unknown }).data).toMatchObject({ structure: { unvaluedCategories: [{ type: 'BOND', count: 1 }], - currencies: [ - { - categories: [ - { sharePercent: null }, - { sharePercent: null }, - { sharePercent: null }, - { value: '40', sharePercent: null }, - ], - }, + categories: [ + { sharePercent: null }, + { sharePercent: null }, + { sharePercent: null }, + { value: '40', sharePercent: null }, ], }, }); @@ -174,17 +165,13 @@ describe('Portfolio structure HTTP contract', () => { .expect(200); expect((response.body as { data: unknown }).data).toMatchObject({ structure: { - currencies: [ - { - hasNegativeValues: true, - totalValue: '150', - categories: [ - { value: '120', sharePercent: null }, - { sharePercent: null }, - { sharePercent: null }, - { value: '30', sharePercent: null }, - ], - }, + hasNegativeValues: true, + totalValue: '150', + categories: [ + { value: '120', sharePercent: null }, + { sharePercent: null }, + { sharePercent: null }, + { value: '30', sharePercent: null }, ], }, }); diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 1eddbb2..4ab1d19 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -103,7 +103,6 @@ describe('local income completeness and exact money (SQLite)', () => { for (const [id, units, nano, currency, type] of [ ['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'], ['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'], - ['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'], ] as const) { await prisma.tbankOperation.create({ data: { @@ -130,11 +129,8 @@ describe('local income completeness and exact money (SQLite)', () => { assert(result.data); expect(result.meta.availability).toBe('available'); expect(result.meta.completeness).toBe('complete'); - expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([ - { currency: 'RUB', value: '15.000000002' }, - { currency: 'USD', value: '6' }, - ]); - expect(result.data.entries).toHaveLength(6); + expect(result.data.total.total).toEqual({ currency: 'RUB', value: '15.000000002' }); + expect(result.data.entries).toHaveLength(4); expect( result.data.entries .filter((row) => row.id === 'same') @@ -185,7 +181,7 @@ describe('local income completeness and exact money (SQLite)', () => { assert(after.data); expect(after.data.entries.find((entry) => entry.account?.id === 'a' && entry.id === 'same')?.logoName).toBe('bond.png'); expect(after.data.entries.find((entry) => entry.account?.id === 'b' && entry.id === 'same')?.logoName).toBeNull(); - expect(after.data.totalsByCurrency).toEqual(base.totalsByCurrency); + expect(after.data.total).toEqual(base.total); expect(after.data.calculatedAt).toBe(base.calculatedAt); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); @@ -205,8 +201,8 @@ describe('local income completeness and exact money (SQLite)', () => { new Date('2025-03-31T21:00:00Z'), ); expect( - (await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency, - ).toEqual([]); + (await read('a', 'from=2025-03-01&to=2025-03-31')).data?.total.total, + ).toEqual({ currency: 'RUB', value: '0' }); expect(upstream.getOperationsByCursor).toHaveBeenCalledWith( 'synthetic', expect.objectContaining({ accountId: 'b' }), ); @@ -225,11 +221,8 @@ describe('local income completeness and exact money (SQLite)', () => { }); const result = await read(); assert(result.data); - expect(result.data.entries).toHaveLength(6); - expect( - result.data.totalsByCurrency.find(({ currency }) => currency === 'RUB') - ?.total, - ).toEqual({ currency: 'RUB', value: '25.000000002' }); + expect(result.data.entries).toHaveLength(4); + expect(result.data.total.total).toEqual({ currency: 'RUB', value: '25.000000002' }); }); it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => { const before = await read(); diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index b3c2f6f..367ef72 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -268,7 +268,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect( income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })), ).toEqual(expected); - expect(income.data?.totalsByCurrency[0].total).toEqual({ + expect(income.data?.total.total).toEqual({ currency: 'RUB', value: '2.000000002', }); diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index be0c72d..7218fc1 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -218,32 +218,27 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { bondPositionCount: 0, fundPositionCount: 0, totalPositionCount: 1, - totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }], - cashBalances: [{ currency: 'RUB', value: '1.1' }], + totals: { currency: 'RUB', value: '21.100000002' }, + cash: { currency: 'RUB', value: '1.1' }, structure: { - currencies: [{ - currency: 'RUB', - totalValue: '21.100000002', - hasNegativeValues: false, - categories: [ - { type: 'STOCK', value: '20.000000002', sharePercent: '94.78673' }, - { type: 'BOND', value: '0', sharePercent: '0' }, - { type: 'OTHER', value: '0', sharePercent: '0' }, - { type: 'CASH', value: '1.1', sharePercent: '5.21327' }, - ], - }], + totalValue: '21.100000002', + hasNegativeValues: false, + categories: [ + { type: 'STOCK', value: '20.000000002', sharePercent: '94.78673' }, + { type: 'BOND', value: '0', sharePercent: '0' }, + { type: 'OTHER', value: '0', sharePercent: '0' }, + { type: 'CASH', value: '1.1', sharePercent: '5.21327' }, + ], unvaluedCategories: [], }, - unrealizedReturnByCurrency: [ - { currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true }, - ], + unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true }, }); const portfolio = await get('brokerage-accounts/account/portfolio').expect( 200, ); expect( - (portfolio.body as PortfolioResourceDto).data?.totalsByCurrency, - ).toEqual([{ currency: 'RUB', value: '21.100000002' }]); + (portfolio.body as PortfolioResourceDto).data?.totals, + ).toEqual({ currency: 'RUB', value: '21.100000002' }); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled(); @@ -315,7 +310,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { logoName: 'enriched.png', quantity: '2', }); - expect(after.data?.totalsByCurrency).toEqual(before.data?.totalsByCurrency); + expect(after.data?.totals).toEqual(before.data?.totals); expect(after.data?.lastSuccessfulAt).toBe(before.data?.lastSuccessfulAt); expect(after.meta.dataRevision).not.toBe(before.meta.dataRevision); expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); @@ -513,12 +508,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { const summaries = (await get('brokerage-accounts').expect(200)) .body as BrokerageAccountsResourceDto; expect(summaries.data?.[0].portfolioSummary).toMatchObject({ - totalsByCurrency: [{ currency: 'RUB', value: '21.1' }], - cashBalances: [{ currency: 'RUB', value: '1.1' }], - structure: { currencies: [{ currency: 'RUB' }] }, - unrealizedReturnByCurrency: [ - { currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true }, - ], + totals: { currency: 'RUB', value: '21.1' }, + cash: { currency: 'RUB', value: '1.1' }, + unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true }, stockPositionCount: 1, bondPositionCount: 0, fundPositionCount: 0, diff --git a/test/tbank-portfolio-publish.e2e-spec.ts b/test/tbank-portfolio-publish.e2e-spec.ts index 911be8d..e1d8b70 100644 --- a/test/tbank-portfolio-publish.e2e-spec.ts +++ b/test/tbank-portfolio-publish.e2e-spec.ts @@ -16,18 +16,15 @@ describe('portfolio publication (SQLite)', () => { const snapshot = { lastSuccessfulAt: '2026-01-01T00:00:00.000Z', isStale: false, - totalsByCurrency: [], + totals: { currency: 'RUB', value: '0' }, positions: [], - cashBalances: [], - structure: { currencies: [], unvaluedCategories: [] }, + cash: { currency: 'RUB', value: '0' }, + structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, } satisfies PortfolioDto; const accountId = 'account'; const valuedSnapshot = { ...snapshot, - totalsByCurrency: [ - { currency: 'RUB', value: '200' }, - { currency: 'USD', value: '10' }, - ], + totals: { currency: 'RUB', value: '200' }, } satisfies PortfolioDto; const createRunningAttempt = () => prisma.tbankSyncWork.create({ @@ -95,7 +92,7 @@ describe('portfolio publication (SQLite)', () => { expect(await prisma.tbankPortfolio.count()).toBe(1); }); - it('rolls back the snapshot, first value point and claim when the second point fails', async () => { + it('rolls back the snapshot, value point and claim when the point write fails', async () => { const previousFetchedAt = new Date('2025-12-31T00:00:00.000Z'); const previousCapturedAt = new Date('2025-12-31T01:00:00.000Z'); await prisma.tbankPortfolio.create({ @@ -114,7 +111,6 @@ describe('portfolio publication (SQLite)', () => { points: { create: { attemptId: 'previous-attempt', - currency: 'RUB', value: '100', capturedAt: previousCapturedAt, }, @@ -123,11 +119,9 @@ describe('portfolio publication (SQLite)', () => { }); await createRunningAttempt(); interceptTransaction((tx) => { - const upsert = tx.tbankPortfolioValuePoint.upsert.bind(tx.tbankPortfolioValuePoint); jest .spyOn(tx.tbankPortfolioValuePoint, 'upsert') - .mockImplementationOnce((args) => upsert(args)) - .mockRejectedValueOnce(new Error('injected second point failure')); + .mockRejectedValueOnce(new Error('injected point failure')); }); await expect( @@ -136,7 +130,7 @@ describe('portfolio publication (SQLite)', () => { accountId, valuedSnapshot, ), - ).rejects.toThrow('injected second point failure'); + ).rejects.toThrow('injected point failure'); expect( await prisma.tbankPortfolio.findUnique({ diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index 66df523..1775643 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -114,7 +114,7 @@ describe('T-Bank persisted read budget', () => { (item) => item.instrumentKind === 'INSTRUMENT_TYPE_SHARE' && item.ticker.includes(query), - ), + ).map((item) => ({ uid: item.uid, ticker: item.ticker, name: item.name, isin: item.isin, instrumentKind: item.instrumentKind, classCode: item.classCode, apiTradeAvailableFlag: item.apiTradeAvailableFlag })), ), findBonds: (_token: string, query: string) => counted( @@ -123,7 +123,7 @@ describe('T-Bank persisted read budget', () => { (item) => item.instrumentKind === 'INSTRUMENT_TYPE_BOND' && item.ticker.includes(query), - ), + ).map((item) => ({ uid: item.uid, ticker: item.ticker, name: item.name, isin: item.isin, instrumentKind: item.instrumentKind, classCode: item.classCode, apiTradeAvailableFlag: item.apiTradeAvailableFlag })), ), getShare: (_token: string, uid: string) => counted( @@ -279,9 +279,9 @@ describe('T-Bank persisted read budget', () => { currentPrice: { currency: 'rub', units: '1000' }, averagePositionPrice: { currency: 'rub', units: '900' }, }], - totalsByCurrency: [{ currency: 'RUB', value: '1100' }], - cashBalances: [{ currency: 'RUB', value: '100' }], - structure: { currencies: [], unvaluedCategories: [] }, + totals: { currency: 'RUB', value: '1100' }, + cash: { currency: 'RUB', value: '100' }, + structure: { totalValue: '1100', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, }, fetchedAt: now, }, diff --git a/test/tbank-value-history.e2e-spec.ts b/test/tbank-value-history.e2e-spec.ts index 92dfbd1..14ee912 100644 --- a/test/tbank-value-history.e2e-spec.ts +++ b/test/tbank-value-history.e2e-spec.ts @@ -80,7 +80,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { ], }); upstream.getPositions.mockResolvedValue({ - money: [{ units: '5', currency: 'usd' }], + money: [{ units: '5', currency: 'rub' }], }); }); @@ -123,7 +123,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { } describe('1.1 Point persistence', () => { - it('writes points for fully valued snapshot with two currencies', async () => { + it('writes a single RUB point for a fully valued snapshot', async () => { await createUser('owner'); await putConnection('owner', 'token-a').expect(200); await publishPortfolio('owner', 'a'); @@ -133,12 +133,10 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { include: { points: true }, }); expect(history?.deletedAt).toBeNull(); - expect(history?.points).toHaveLength(2); + expect(history?.points).toHaveLength(1); - const rubPoint = history?.points.find((p) => p.currency === 'RUB'); - const usdPoint = history?.points.find((p) => p.currency === 'USD'); - expect(rubPoint?.value).toBe('21'); - expect(usdPoint?.value).toBe('5'); + const rubPoint = history?.points[0]; + expect(rubPoint?.value).toBe('26'); const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({ where: { @@ -201,7 +199,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { expect(points).toHaveLength(0); }); - it('writes no points for empty account without currency', async () => { + it('writes a zero point for an empty fully valued account', async () => { upstream.getPortfolio.mockResolvedValue({ positions: [] }); upstream.getPositions.mockResolvedValue({ money: [] }); @@ -212,7 +210,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); - expect(points).toHaveLength(0); + expect(points).toHaveLength(1); + expect(points[0].value).toBe('0'); }); it('deduplicates repeated publication of same attempt', async () => { @@ -258,7 +257,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); - expect(points).toHaveLength(2); + expect(points).toHaveLength(1); }); it('late attempt after token replacement adds no points', async () => { @@ -282,10 +281,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const published = { lastSuccessfulAt: new Date().toISOString(), isStale: false, - totalsByCurrency: [ - { currency: 'RUB', value: '100' }, - { currency: 'USD', value: '50' }, - ], + totals: { currency: 'RUB', value: '100' }, positions: [ { figi: 'stock', @@ -302,8 +298,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { issuer: null, }, ], - cashBalances: [{ currency: 'USD', value: '50' }], - structure: { currencies: [], unvaluedCategories: [] }, + cash: { currency: 'RUB', value: '0' }, + structure: { totalValue: '100', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, }; const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published); @@ -323,7 +319,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const pointsBefore = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); - expect(pointsBefore).toHaveLength(2); + expect(pointsBefore).toHaveLength(1); upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed')); await publishPortfolio('owner', 'a'); @@ -331,7 +327,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); - expect(pointsAfter).toHaveLength(2); + expect(pointsAfter).toHaveLength(1); expect(pointsAfter).toEqual(pointsBefore); }); }); @@ -345,7 +341,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const oldPoints = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); - expect(oldPoints).toHaveLength(2); + expect(oldPoints).toHaveLength(1); upstream.validateToken.mockResolvedValue([accounts[0]]); await putConnection('owner', 'token-b').expect(200); @@ -361,8 +357,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { where: { userId: 'owner', accountId: 'a' }, orderBy: { createdAt: 'asc' }, }); - expect(newPoints.length).toBeGreaterThan(2); - expect(newPoints.slice(0, 2)).toEqual(oldPoints); + expect(newPoints.length).toBeGreaterThan(1); + expect(newPoints.slice(0, 1)).toEqual(oldPoints); }); it('hides history when account disappears from membership', async () => { @@ -392,7 +388,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const pointsB = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'b' }, }); - expect(pointsB).toHaveLength(2); + expect(pointsB).toHaveLength(1); }); it('leaves visibility unchanged on membership fetch error', async () => { @@ -424,7 +420,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); - expect(points).toHaveLength(2); + expect(points).toHaveLength(1); await request(app.getHttpServer()) .delete('/api/v1/tbank-connection') @@ -450,7 +446,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); - expect(pointsAfter.length).toBeGreaterThan(2); + expect(pointsAfter.length).toBeGreaterThan(1); }); it('unhides when disappeared account returns in membership', async () => { @@ -530,7 +526,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => { const points = await prisma.tbankPortfolioValuePoint.findMany({ where: { userId: 'owner', accountId: 'a' }, }); - expect(points).toHaveLength(2); + expect(points).toHaveLength(1); }); }); }); diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 1d7481c..c83981d 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -113,9 +113,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { lastSuccessfulAt: new Date().toISOString(), isStale: false, positions, - totalsByCurrency: [], - cashBalances: [], - structure: { currencies: [], unvaluedCategories: [] }, + totals: { currency: 'RUB', value: '0' }, + cash: { currency: 'RUB', value: '0' }, + structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, }; await prisma.tbankPortfolio.upsert({ where: { @@ -273,17 +273,14 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { single.data?.events.find((event) => event.type === 'offer') ?.projectedAmount, ).toBeNull(); - expect(single.data?.incomeTotalsByCurrency).toEqual([ - { currency: 'RUB', value: '6' }, - { currency: 'USD', value: '20.000000002' }, - ]); + expect(single.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' }); const all = (await events('all').expect(200)) .body as UpcomingEventsResourceDto; expect( all.data?.events.every((event) => event.account?.id === 'account'), ).toBe(true); - expect(all.data?.incomeTotalsByCurrency).toEqual( - single.data?.incomeTotalsByCurrency, + expect(all.data?.incomeTotal).toEqual( + single.data?.incomeTotal, ); expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); expect(upstream.getBondCoupons).not.toHaveBeenCalled(); @@ -334,7 +331,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(newRange.data).toEqual({ ...saved.data, events: [], - incomeTotalsByCurrency: [], + incomeTotal: { currency: 'RUB', value: '0' }, }); expect(newRange.data?.window).toEqual(window); await prisma.tbankConnection.update({ @@ -372,8 +369,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { } expect( ((await events().expect(200)).body as UpcomingEventsResourceDto).data - ?.incomeTotalsByCurrency, - ).toEqual([{ currency: 'USD', value: '90.000000009' }]); + ?.incomeTotal, + ).toEqual({ currency: 'RUB', value: '0' }); }); it('serves instrument payouts from shared calendars with private exact holdings and retains successful source timestamps on failure', async () => { @@ -539,9 +536,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await settled(); const result = (await events().expect(200)) .body as UpcomingEventsResourceDto; - expect(result.data?.incomeTotalsByCurrency).toEqual([ - { currency: 'RUB', value: '6' }, - ]); + expect(result.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' }); expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); }); @@ -574,9 +569,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await settled(); const before = (await events().expect(200)) .body as UpcomingEventsResourceDto; - expect(before.data?.incomeTotalsByCurrency).toEqual([ - { currency: 'RUB', value: '6' }, - ]); + expect(before.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' }); const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({ where: { kind: 'bond-events' }, }); @@ -675,7 +668,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const w1 = windowOf(day1, '2099-03-04'); const dividend = (date: string) => ({ recordDate: time(date), - dividendNet: { units: '10', nano: 1, currency: 'usd' }, + dividendNet: { units: '10', nano: 1, currency: 'rub' }, }); const defaultEvents = async () => (await get('brokerage-accounts/account/events').expect(200)) @@ -705,9 +698,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await refresh({}); const first = await defaultEvents(); expect(first.data?.window).toEqual(w0); - expect(first.data?.incomeTotalsByCurrency).toEqual([ - { currency: 'USD', value: '20.000000002' }, - ]); + expect(first.data?.incomeTotal).toEqual({ currency: 'RUB', value: '20.000000002' }); jest.setSystemTime(new Date(`${day1}T12:00:00Z`)); await portfolio([position(shareUid, '5')]); @@ -720,9 +711,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { '2099-02-05', '2099-03-04', ]); - expect(second.data?.incomeTotalsByCurrency).toEqual([ - { currency: 'USD', value: '100.00000001' }, - ]); + expect(second.data?.incomeTotal).toEqual({ currency: 'RUB', value: '100.00000001' }); const stale = (await events('account', 'owner', w0).expect(200)) .body as UpcomingEventsResourceDto; expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt); @@ -736,7 +725,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(disjoint.data).toEqual({ ...second.data, events: [], - incomeTotalsByCurrency: [], + incomeTotal: { currency: 'RUB', value: '0' }, }); expect(await prisma.tbankForecast.count()).toBe(2); }); @@ -779,7 +768,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const dividend = (units: string, dividendType = 'Regular') => ({ recordDate: time(date), dividendType, - dividendNet: { units, currency: 'usd' }, + dividendNet: { units, currency: 'rub' }, }); // One hourly walk: the scheduler stamps it once and passes it to every account. const startWalk = async () => { @@ -803,16 +792,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => { upstream.getDividends.mockResolvedValue([dividend('10')]); await pass(await startWalk()); - expect((await latest()).data?.incomeTotalsByCurrency).toEqual([ - { currency: 'USD', value: '20' }, - ]); + expect((await latest()).data?.incomeTotal).toEqual({ currency: 'RUB', value: '20' }); const first = await dividendRow(); upstream.getDividends.mockResolvedValue([dividend('30')]); await pass(await startWalk()); - expect((await latest()).data?.incomeTotalsByCurrency).toEqual([ - { currency: 'USD', value: '60' }, - ]); + expect((await latest()).data?.incomeTotal).toEqual({ currency: 'RUB', value: '60' }); expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1); upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]);