fix(portfolio): publish new complete forecast regardless of request window (WealthLedger/backend#55) (#156)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m11s
Backend verify / verify (push) Failing after 7m6s

This commit was merged in pull request #156.
This commit is contained in:
2026-11-02 22:42:46 +03:00
parent 554410a271
commit 169bee1da5
5 changed files with 182 additions and 29 deletions
@@ -20,6 +20,7 @@ export class ForecastService {
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
membershipRevision: number;
portfolio: InvestmentResourceMetaDto;
replacesLatest?: boolean;
revision: string;
scopeKey: string;
sources: {
@@ -74,7 +75,7 @@ export class ForecastService {
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
return {
data: stored ? filterEvents(stored.payload as T, input.scopeKey) : null,
data: stored ? filterEvents(stored.payload as T, input.scopeKey, stored.scopeKey) : null,
meta: investmentResourceMeta({
resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
@@ -95,7 +96,7 @@ export class ForecastService {
}
}
function filterEvents<T extends object>(payload: T, scopeKey: string): T {
function filterEvents<T extends object>(payload: T, scopeKey: string, savedScopeKey: string): T {
if (!scopeKey.startsWith('events/')) return payload;
try {
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
@@ -111,8 +112,10 @@ function filterEvents<T extends object>(payload: T, scopeKey: string): T {
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
}
const [, savedFrom, savedTo] = JSON.parse(savedScopeKey.slice('events/'.length)) as [string, string, string];
return {
...value,
window: { from: savedFrom, to: savedTo },
events,
incomeTotalsByCurrency: [...totals]
.sort(([left], [right]) => left.localeCompare(right))
@@ -26,6 +26,7 @@ export class ForecastRepository {
membershipRevision: number;
portfolioProjection: string;
portfolios: { accountId: string; dataRevision: number }[];
replacesLatest?: boolean;
revision: string;
scopeKey: string;
sources: ForecastSourceVersion[];
@@ -51,32 +52,30 @@ export class ForecastRepository {
const previous = await tx.tbankForecast.findUnique({
where: { userId_revision_scopeKey: key },
});
// An events read serves the account's latest complete forecast whatever
// the requested window; other scopes keep their exact-key row.
const requestedWindow = eventsWindow(input.scopeKey);
const saved = previous ?? (requestedWindow
? (
await tx.tbankForecast.findMany({
where: {
...owner,
scopeKey: { startsWith: 'events/' },
membershipRevision: input.membershipRevision,
const latest = requestedWindow
? await tx.tbankForecast.findFirst({
where: {
...owner,
// scopeKey is `events/[accountId,from,to]` JSON: pin the account.
scopeKey: {
startsWith: `events/[${JSON.stringify(requestedWindow.accountId)},`,
},
})
).find((forecast) => {
const window = eventsWindow(forecast.scopeKey);
return (
window &&
window.accountId === requestedWindow.accountId &&
window.from <= requestedWindow.to &&
requestedWindow.from <= window.to
);
membershipRevision: input.membershipRevision,
},
orderBy: [{ calculatedAt: 'desc' }, { scopeKey: 'asc' }],
})
: null);
: previous;
const fallback =
saved?.membershipRevision === input.membershipRevision
? saved
: null;
latest?.membershipRevision === input.membershipRevision ? latest : null;
if (!input.candidate) return fallback;
if (!previous && fallback) return fallback;
// Only the default window may replace the latest forecast; an explicit
// range is served from it and publishes only when nothing is saved.
if (!input.replacesLatest && fallback && fallback.scopeKey !== input.scopeKey) {
return fallback;
}
const portfolios = await tx.tbankPortfolio.findMany({
where: {
...owner,
@@ -152,7 +151,7 @@ export class ForecastRepository {
) as Prisma.InputJsonObject;
const timestamps = input.timestamps as Prisma.InputJsonArray;
if (
fallback &&
fallback?.scopeKey === input.scopeKey &&
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
) {
@@ -65,6 +65,14 @@ export class UpcomingEventDto {
declare projectedAmount: MoneyDto | null;
}
export class UpcomingEventsWindowDto {
@ApiProperty({ format: 'date' })
declare from: string;
@ApiProperty({ format: 'date' })
declare to: string;
}
export class UpcomingEventsDto {
@ApiProperty()
declare accountId: string;
@@ -72,6 +80,13 @@ export class UpcomingEventsDto {
@ApiProperty({ format: 'date-time' })
declare calculatedAt: string;
@ApiProperty({
type: UpcomingEventsWindowDto,
description:
'Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter.',
})
declare window: UpcomingEventsWindowDto;
@ApiProperty({ type: UpcomingEventDto, isArray: true })
declare events: UpcomingEventDto[];
@@ -150,7 +150,7 @@ export class UpcomingEventsService {
a.type.localeCompare(b.type) ||
a.ticker.localeCompare(b.ticker),
);
const candidate: undefined | UpcomingEventsDto = ready
const candidate: Omit<UpcomingEventsDto, 'window'> | undefined = ready
? {
accountId,
calculatedAt: new Date().toISOString(),
@@ -163,6 +163,7 @@ export class UpcomingEventsService {
revision: connection.revision,
membershipRevision: connection.membershipRevision,
scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`,
replacesLatest: query.from === undefined && query.to === undefined,
portfolio: portfolio.meta,
lastAttempt: hourlyAttempt(
hourly,