feat: reconcile hourly investment resource states

This commit is contained in:
2026-10-09 19:56:14 +03:00
parent 2facd8675c
commit aee330c6c2
25 changed files with 445 additions and 86 deletions
@@ -2,6 +2,7 @@ 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,
@@ -16,6 +17,7 @@ export class ForecastService {
async result<T extends object>(input: {
candidate?: T;
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
membershipRevision: number;
portfolio: InvestmentResourceMetaDto;
revision: string;
@@ -72,22 +74,51 @@ export class ForecastService {
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
return {
data: stored ? (stored.payload as T) : null,
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.availability !== 'missing',
(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;
}
}
@@ -26,6 +26,7 @@ describe('persistent portfolio projection', () => {
repository as never,
{ request: jest.fn().mockResolvedValue(null) } as never,
{ event: jest.fn() } as never,
{ hourly: jest.fn().mockResolvedValue(null) } as never,
);
access.local.mockResolvedValue({
revision: 'private',
@@ -192,7 +193,7 @@ describe('persistent portfolio projection', () => {
});
const partial = await service.read('user', 'all');
expect(partial.data).toBeNull();
expect(partial.meta.availability).toBe('partial');
expect(partial.meta.availability).toBe('temporarily-unavailable');
const saved = await service.saved('user', 'all');
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
snapshot.totalsByCurrency,
@@ -17,6 +17,10 @@ import {
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
hourlyAttempt,
TbankSyncRepository,
} from '../../tbank-sync/tbank-sync.repository';
import { portfolioStructure } from '../domain/portfolio-structure';
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
import {
@@ -44,12 +48,14 @@ export class PortfolioService {
private readonly repository: PortfolioRepository,
private readonly membership: TbankMembershipService,
private readonly logger: StructuredLogger,
private readonly sync: TbankSyncRepository,
) {}
// Local reads never start upstream work; the hourly walk owns refreshes.
async saved(userId: string, accountId: string) {
const connection = await this.access.local(userId, accountId);
const saved = await this.repository.read(userId, connection.revision);
const hourly = await this.sync.hourly(userId);
if (
saved?.membershipAt &&
accountId !== 'all' &&
@@ -64,8 +70,10 @@ export class PortfolioService {
fetchedAt: saved?.membershipAt ?? null,
freshForMs: membershipFreshForMs,
work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null,
lastAttempt: hourlyAttempt(hourly, 'membership'),
});
return {
hourly,
membership: membershipMeta,
accounts: (saved?.accounts ?? [])
.filter(
@@ -95,15 +103,16 @@ export class PortfolioService {
saved?.work.find(
(work) => work.scopeKey === portfolioWorkKey(account.accountId),
) ?? null,
lastAttempt: hourlyAttempt(hourly, 'portfolio', account.accountId),
}),
})),
};
}
async read(userId: string, accountId: string) {
const saved = await this.saved(userId, accountId);
const { hourly, ...saved } = await this.saved(userId, accountId);
const missing =
saved.membership.availability === 'missing' ||
saved.membership.completeness === 'incomplete' ||
saved.accounts.some((account) => !account.snapshot);
const metadata = await this.metadata.savedMany(
saved.accounts.flatMap((account) => account.snapshot?.positions ?? []),
@@ -206,6 +215,7 @@ export class PortfolioService {
? portfolioFreshForMs
: membershipFreshForMs,
work: work.id ? { ...work, id: work.id } : null,
lastAttempt: hourlyAttempt(hourly, 'portfolio', accountId === 'all' ? undefined : accountId),
dependencies: [
saved.membership,
...saved.accounts.map((account) => account.meta),