import { planIncomePeriod } from './income/income.api'; import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { operationIntervals, type OperationInterval, } from './operations/operations.api'; export type InvestmentScreen = { screen: | 'accounts' | 'account' | 'operations' | 'income' | 'events' | 'stocks' | 'bonds' | 'stock' | 'bond'; accountId?: string; from?: string; to?: string; instrumentUid?: string; query?: string; candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; candleRange?: { from: Date; to: Date }; }; const defaultEventDays = 30; const incomeHistoryYears = 5; const candleMonths = { month: 1, halfYear: 6, year: 12 }; type Account = { accountId: string; openedOn: string | null }; type Instrument = { uid: string; type: 'STOCK' | 'BOND'; historyFrom: Date | null; }; type Source = { kind: | 'membership' | 'portfolio' | 'operations' | 'metadata' | 'catalog' | 'quote' | 'company' | 'metrics' | 'calendar' | 'candles'; accountId?: string; instrumentUid?: string; instrumentType?: 'STOCK' | 'BOND'; from?: Date; to?: Date; query?: string; priority: 'discovery' | 'screen' | 'history'; }; // 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: { resourceKey: string; reason: 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( `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, ) : undefined; if (!context.membershipKnown) return { sources, unavailable, now }; if ( ['accounts', 'account', 'events', 'stock', 'bond'].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); operations( bounds.from, new Date(Math.min(bounds.to.valueOf() + 1, now.valueOf())), ); } 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 (['stocks', 'bonds', 'stock', 'bond'].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') 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', 'events', 'stock', 'bond'].includes(screen.screen)) { const day = screen.screen === 'stock' || screen.screen === 'bond' ? moscowDate(now) : now.toISOString().slice(0, 10); 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() + 1); } sources.push({ ...identity, kind: 'calendar', from, to, priority: 'screen', }); } if ( !force && ['account', 'operations', 'income', 'events'].includes(screen.screen) ) { if (instrument.historyFrom) sources.push({ ...identity, kind: 'candles', from: instrument.historyFrom, to: now, priority: 'history', }); else unavailable.push({ resourceKey: `candles/${instrument.uid}/all`, reason: 'history_start_unavailable', }); } } if (!force && !['stocks', 'bonds'].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) ? 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(), 0, 1)); 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 ? 1 : 0), now.valueOf())), }; }