import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; import { planIncomePeriod } from './income/income.api'; import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { type OperationInterval, operationIntervals, } from './operations/domain/operations-sync-plan'; const FORECAST_YEARS = 1; const JANUARY_INDEX = 0; const FIRST_DAY_OF_MONTH = 1; const EXCLUSIVE_END_OFFSET_MS = 1; const NO_END_OFFSET_MS = 0; export type InvestmentScreen = { accountId?: string; candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; candleRange?: { from: Date; to: Date }; from?: string; instrumentUid?: string; query?: string; screen: | 'account' | 'accounts' | 'bond' | 'bonds' | 'events' | 'income' | 'operations' | 'stock' | 'stocks'; to?: string; }; const defaultEventDays = 30; const incomeHistoryYears = 5; const candleMonths = { month: 1, halfYear: 6, year: 12 }; type Account = { accountId: string; openedOn: null | string }; type Instrument = { historyFrom: Date | null; type: 'BOND' | 'STOCK'; uid: string; }; type Source = { accountId?: string; from?: Date; instrumentType?: 'BOND' | 'STOCK'; instrumentUid?: string; kind: | 'calendar' | 'candles' | 'catalog' | 'company' | 'membership' | 'metadata' | 'metrics' | 'operations' | 'portfolio' | 'quote'; priority: 'discovery' | 'history' | 'screen'; query?: string; to?: Date; }; // Expands screen scope, never credentials or arbitrary caller-supplied jobs. // Owners execute their own sources and expand instruments after discovery. export function planInvestmentScreen( screen: InvestmentScreen, context: { accounts: Account[]; instruments: Instrument[]; membershipKnown: boolean; }, now: Date, force = false, ) { const sources: Source[] = [{ kind: 'membership', priority: 'discovery' }]; const accounts = context.accounts.filter( (account) => !screen.accountId || screen.accountId === 'all' || account.accountId === screen.accountId, ); const unavailable: { reason: string; resourceKey: string }[] = []; const operations = (from: Date, to: Date, instrumentUid?: string) => { const targets = accounts.flatMap((account) => from < to ? [ { accountId: account.accountId, from, to, ...(instrumentUid ? { instrumentUid } : {}), }, ] : [], ); sources.push( ...operationIntervals(targets).map((range) => ({ ...range, kind: 'operations' as const, priority: 'screen' as const, })), ); }; const portfolio = () => sources.push( ...accounts.map(({ accountId }) => ({ kind: 'portfolio' as const, accountId, priority: 'screen' as const, })), ); const knownOpening = accounts.length && accounts.every((account) => account.openedOn) ? new Date( // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, ) : undefined; if (!context.membershipKnown) return { sources, unavailable, now }; if ( ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) ) { portfolio(); } if ( screen.screen === 'operations' || screen.screen === 'stock' || screen.screen === 'bond' ) { const range = ledgerRange( screen.from, screen.to, screen.instrumentUid, accounts, now, ); operations(range.from, range.to, force ? screen.instrumentUid : undefined); } if (screen.screen === 'account' || screen.screen === 'income') { const period = planIncomePeriod( screen.screen === 'income' ? screen.from : undefined, screen.screen === 'income' ? screen.to : undefined, knownOpening, ); for (const range of [ period.selected, ...(screen.screen === 'income' ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] : []), ]) { const bounds = dateOnlyBounds(range, now); operations(bounds.from, bounds.to); } if (screen.screen === 'income' && !period.yearAgo) { unavailable.push({ resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier reason: 'calendar_or_account_boundary', }); } if (screen.screen === 'account') { const range = ledgerRange( screen.from, screen.to, undefined, accounts, now, ); operations(range.from, range.to); } } if (screen.screen === 'stocks' || screen.screen === 'bonds') { sources.push({ kind: 'catalog', instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND', query: screen.query?.trim().toUpperCase() ?? '', priority: 'screen', }); } const instruments = context.instruments.filter( (instrument) => !screen.instrumentUid || instrument.uid === screen.instrumentUid, ); if ( screen.instrumentUid && !instruments.length && (screen.screen === 'stock' || screen.screen === 'bond') ) { instruments.push({ uid: screen.instrumentUid, type: screen.screen === 'stock' ? 'STOCK' : 'BOND', historyFrom: null, }); } for (const instrument of instruments) { const identity = { instrumentUid: instrument.uid, instrumentType: instrument.type, }; sources.push({ ...identity, kind: 'metadata', priority: 'screen' }); if (['bond', 'bonds', 'stock', 'stocks'].includes(screen.screen)) { sources.push({ ...identity, kind: 'quote', priority: 'screen' }); } if (screen.screen === 'stock' || screen.screen === 'bond') { sources.push({ ...identity, kind: screen.screen === 'stock' ? 'company' : 'metrics', priority: 'screen', }); const from = new Date(now); const period = screen.candlePeriod ?? 'month'; if (period !== 'all') { // eslint-disable-next-line security/detect-object-injection -- The period belongs to the validated candle-period union. from.setUTCMonth(from.getUTCMonth() - candleMonths[period]); } const range = screen.candleRange ?? (period === 'all' ? instrument.historyFrom ? { from: instrument.historyFrom, to: now } : null : { from, to: now }); if (range) { sources.push({ ...identity, kind: 'candles', ...range, priority: 'screen', }); } else { unavailable.push({ resourceKey: `candles/${instrument.uid}/all`, reason: 'history_start_unavailable', }); } } if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) { const day = screen.screen === 'stock' || screen.screen === 'bond' ? moscowDate(now) : now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); const from = screen.screen === 'events' && screen.from ? new Date(`${screen.from}T00:00:00Z`) : new Date(`${day}T00:00:00Z`); const to = screen.screen === 'events' && screen.to ? new Date(`${screen.to}T00:00:00Z`) : new Date(from); if ( !(screen.screen === 'events' && screen.to) && screen.screen !== 'stock' && screen.screen !== 'bond' ) { to.setUTCDate(to.getUTCDate() + defaultEventDays); } if (screen.screen === 'stock' || screen.screen === 'bond') { from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears); to.setUTCFullYear(to.getUTCFullYear() + FORECAST_YEARS); } sources.push({ ...identity, kind: 'calendar', from, to, priority: 'screen', }); } } if (!force && !['bonds', 'stocks'].includes(screen.screen)) { sources.push( ...accountHistory(accounts, now).map((range) => ({ ...range, kind: 'operations' as const, priority: 'history' as const, })), ); } const priorities = { discovery: 0, screen: 1, history: 2 }; sources.sort( (left, right) => priorities[left.priority] - priorities[right.priority], ); return { sources, unavailable, now }; } export function accountHistory( accounts: Account[], now: Date, ): OperationInterval[] { return accounts.flatMap((account) => { const from = new Date( account.openedOn ? `${account.openedOn}T00:00:00Z` : '1970-01-01T00:00:00Z', ); return from < now ? [{ accountId: account.accountId, from, to: now }] : []; }); } export function ledgerRange( fromValue: string | undefined, toValue: string | undefined, instrumentUid: string | undefined, accounts: Account[], now: Date, ) { const opening = accounts.every((account) => account.openedOn) // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. ? accounts.map((account) => account.openedOn ?? '').sort()[0] : undefined; const from = fromValue ? new Date(fromValue) : instrumentUid ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH)); const end = toValue ? new Date( /^\d{4}-\d{2}-\d{2}$/.test(toValue) ? `${toValue}T23:59:59.999Z` : toValue, ) : now; return { from, to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())), }; }