fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58)
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m45s

This commit is contained in:
2026-10-14 16:20:27 +03:00
parent da2e1d0781
commit d47dfee208
6 changed files with 80 additions and 4 deletions
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioService } from '../portfolio/portfolio.api'; 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'; import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
@Injectable() @Injectable()
@@ -61,6 +61,7 @@ export class BrokerageAccountsService {
: null, : null,
freshForMs: 3_600_000, freshForMs: 3_600_000,
work: null, work: null,
lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
dependencies: saved.accounts.map((account) => account.meta), dependencies: saved.accounts.map((account) => account.meta),
}), }),
sync: hourly sync: hourly
@@ -98,6 +98,7 @@ export class IncomeAnalyticsService {
work: work.id work: work.id
? { id: work.id, state: work.state, errorCode: work.errorCode } ? { id: work.id, state: work.state, errorCode: work.errorCode }
: null, : null,
lastAttempt: current.meta.lastAttempt,
coverage: current.meta.coverage, coverage: current.meta.coverage,
dependencies, dependencies,
}), }),
@@ -435,6 +435,7 @@ export class BrokerageOperationsService {
saved = await this.repository.readPage(input); saved = await this.repository.readPage(input);
} }
const now = new Date(); const now = new Date();
const hourly = await this.hourlySync.hourly(userId);
const membership = investmentResourceMeta({ const membership = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
@@ -503,6 +504,7 @@ export class BrokerageOperationsService {
fetchedAt, fetchedAt,
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
work, work,
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
dependencies: [membership], dependencies: [membership],
coverage: { coverage: {
requested: targets.map(serialize), requested: targets.map(serialize),
@@ -43,16 +43,17 @@ type HourlyAttempt = {
export function hourlyAttempt( export function hourlyAttempt(
hourly: HourlyAttempt | null, hourly: HourlyAttempt | null,
section: HourlySection['section'], section: HourlySection['section'] | readonly HourlySection['section'][],
accountId?: string, accountId?: string,
): InvestmentAttemptDto { ): InvestmentAttemptDto {
if (!hourly) { if (!hourly) {
return { state: 'none', startedAt: null, finishedAt: null, problems: [] }; return { state: 'none', startedAt: null, finishedAt: null, problems: [] };
} }
const sections = Array.isArray(section) ? section : [section];
const problems = hourly.sections const problems = hourly.sections
.filter( .filter(
(entry) => (entry) =>
entry.section === section && sections.includes(entry.section) &&
(accountId === undefined || entry.accountId === accountId), (accountId === undefined || entry.accountId === accountId),
) )
.map((entry) => ({ .map((entry) => ({
+3 -1
View File
@@ -266,7 +266,9 @@ describe('local income completeness and exact money (SQLite)', () => {
await repo.fail(fence, false); await repo.fail(fence, false);
const after = await read(); const after = await read();
expect(after.data).toEqual(before.data); 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(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
}); });
it('retains current-month observed completeness and does not renew source timestamps', async () => { it('retains current-month observed completeness and does not renew source timestamps', async () => {
+69
View File
@@ -15,6 +15,7 @@ import {
} from '../src/integrations/tbank/tbank.client'; } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main'; import { configureApp } from '../src/main';
import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; 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 { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
@@ -325,6 +326,74 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); 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 () => { it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => {
await put().expect(200); await put().expect(200);
const connection = await prisma.tbankConnection.update({ const connection = await prisma.tbankConnection.update({