125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
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<T extends object>(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<T extends object>(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<string, ReturnType<typeof decimalFromString>>();
|
|
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;
|
|
}
|
|
}
|