fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58)
This commit is contained in:
@@ -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) => ({
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user