From a580a8183e7c4d66b19b20c2ad8cb1da92ef9b6c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 14 Oct 2026 16:20:27 +0300 Subject: [PATCH] fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58) --- .../brokerage-accounts.service.ts | 3 +- .../income/income-analytics.service.ts | 1 + .../brokerage-operations.service.ts | 2 + .../tbank-sync/tbank-sync.repository.ts | 5 +- test/tbank-income-local.e2e-spec.ts | 4 +- test/tbank-local-reads.e2e-spec.ts | 69 +++++++++++++++++++ 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index 9a003ca..8ad93bd 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { PortfolioService } from '../portfolio/portfolio.api'; -import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository'; +import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.repository'; import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto'; @Injectable() @@ -61,6 +61,7 @@ export class BrokerageAccountsService { : null, freshForMs: 3_600_000, work: null, + lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']), dependencies: saved.accounts.map((account) => account.meta), }), sync: hourly diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index e654c62..0e4f4bd 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -98,6 +98,7 @@ export class IncomeAnalyticsService { work: work.id ? { id: work.id, state: work.state, errorCode: work.errorCode } : null, + lastAttempt: current.meta.lastAttempt, coverage: current.meta.coverage, dependencies, }), diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 193cfd0..2a1ef46 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -435,6 +435,7 @@ export class BrokerageOperationsService { saved = await this.repository.readPage(input); } const now = new Date(); + const hourly = await this.hourlySync.hourly(userId); const membership = investmentResourceMeta({ resourceKey: 'accounts/membership', dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), @@ -503,6 +504,7 @@ export class BrokerageOperationsService { fetchedAt, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, work, + lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId), dependencies: [membership], coverage: { requested: targets.map(serialize), diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index cebd86f..b10fbcc 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -43,16 +43,17 @@ type HourlyAttempt = { export function hourlyAttempt( hourly: HourlyAttempt | null, - section: HourlySection['section'], + section: HourlySection['section'] | readonly HourlySection['section'][], accountId?: string, ): InvestmentAttemptDto { if (!hourly) { return { state: 'none', startedAt: null, finishedAt: null, problems: [] }; } + const sections = Array.isArray(section) ? section : [section]; const problems = hourly.sections .filter( (entry) => - entry.section === section && + sections.includes(entry.section) && (accountId === undefined || entry.accountId === accountId), ) .map((entry) => ({ diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 2e16feb..121ebab 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -266,7 +266,9 @@ describe('local income completeness and exact money (SQLite)', () => { await repo.fail(fence, false); const after = await read(); expect(after.data).toEqual(before.data); - expect(after.meta.lastAttempt.state).toBe('failed'); + // Without an hourly walk the durable attempt is absent; the failed + // source work stays a technical observation, not the hourly attempt. + expect(after.meta.lastAttempt.state).toBe('none'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('retains current-month observed completeness and does not renew source timestamps', async () => { diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index a90401a..bdea5a7 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -15,6 +15,7 @@ import { } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; +import { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; import { PrismaService } from '../src/prisma/prisma.service'; @@ -325,6 +326,74 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); }); + it('reports the durable hourly attempt in accounts and income meta with section relevance', async () => { + await put().expect(200); + await seedMembership(); + await requestPortfolio('account'); + await settled(); + const connection = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + const startedAt = new Date('2026-10-10T10:00:00.000Z'); + const finishedAt = new Date('2026-10-10T10:05:00.000Z'); + await prisma.tbankSyncWork.create({ + data: { + id: 'hourly-attempt', + userId: 'owner', + revision: connection.revision, + scopeKey: 'hourly', + intent: {}, + state: 'complete', + attemptId: 'hourly-attempt', + processRunId: 'test', + priority: 0, + progress: { + result: 'partial', + sections: [ + { + section: 'portfolio', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + { + section: 'operations', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ], + }, + createdAt: startedAt, + updatedAt: finishedAt, + }, + }); + const accounts = await get('brokerage-accounts').expect(200); + const accountsMeta = (accounts.body as BrokerageAccountsResourceDto).meta; + expect(accountsMeta.lastAttempt.state).toBe('partial'); + expect(accountsMeta.lastAttempt.startedAt).toBe(startedAt.toISOString()); + expect(accountsMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString()); + expect(accountsMeta.lastAttempt.problems).toEqual([ + { + section: 'portfolio', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ]); + const income = await get( + 'brokerage-accounts/account/income-analytics?sync=observe', + ).expect(200); + const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta; + expect(incomeMeta.lastAttempt.state).toBe('partial'); + expect(incomeMeta.lastAttempt.startedAt).toBe(startedAt.toISOString()); + expect(incomeMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString()); + expect(incomeMeta.lastAttempt.problems).toEqual([ + { + section: 'operations', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ]); + }); + it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => { await put().expect(200); const connection = await prisma.tbankConnection.update({