import { Injectable } from '@nestjs/common'; import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { add, decimalFromString, formatDecimal } from '../../../../common/decimal'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { ForecastRepository, type ForecastSourceVersion, } from '../infrastructure/forecast.repository'; const MISSING_DATA_REVISION = 0; @Injectable() export class ForecastService { constructor(private readonly repository: ForecastRepository) {} async result(input: { candidate?: T; lastAttempt?: InvestmentResourceMetaDto['lastAttempt']; membershipRevision: number; portfolio: InvestmentResourceMetaDto; revision: string; scopeKey: string; sources: { meta: InvestmentResourceMetaDto; version: ForecastSourceVersion | null; }[]; userId: string; }) { const dependencies = [ input.portfolio, ...input.sources.map((source) => source.meta), ]; const stored = await this.repository.resolve({ ...input, portfolioProjection: input.portfolio.dataRevision, portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) => dependency.resourceKey.startsWith('portfolio/') ? [ { accountId: decodeURIComponent( dependency.resourceKey.slice('portfolio/'.length), ), dataRevision: Number( (dependency as InvestmentResourceMetaDto).dataRevision, ), }, ] : [], ), sources: input.sources.flatMap((source) => source.version ? [source.version] : [], ), timestamps: dependencies.flatMap((dependency) => dependency.lastSuccessfulAt ? [ { resourceKey: dependency.resourceKey, at: dependency.lastSuccessfulAt, }, ] : [], ), }); const timestamps = stored?.sourceTimestamps as undefined | { at: string; resourceKey: string }[]; const first = timestamps?.map((entry) => entry.at).sort()[0]; const work = dependencies .map((dependency) => dependency.sync) .find((sync) => sync.state === 'running') ?? dependencies .map((dependency) => dependency.sync) .find((sync) => sync.state === 'failed'); return { data: stored ? filterEvents(stored.payload as T, input.scopeKey) : null, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), availability: stored ? 'complete' : dependencies.some( (dependency) => dependency.completeness === 'complete', ) ? 'partial' : 'missing', fetchedAt: first ? new Date(first) : null, freshForMs: Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, lastAttempt: input.lastAttempt, dependencies, }), }; } } function filterEvents(payload: T, scopeKey: string): T { if (!scopeKey.startsWith('events/')) return payload; try { 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 }[]; }; if (!value.events || !value.incomeTotalsByCurrency) return payload; const events = value.events.filter((event) => event.date >= from && event.date <= to); const totals = new Map>(); 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))); } return { ...value, events, incomeTotalsByCurrency: [...totals] .sort(([left], [right]) => left.localeCompare(right)) .map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })), }; } catch { return payload; } }