diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 95418e7..aa4eafc 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -32,11 +32,6 @@ "count": 1 } }, - "src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": { - "sonarjs/no-nested-conditional": { - "count": 1 - } - }, "src/modules/investments/income/income-analytics.service.ts": { "sonarjs/no-nested-template-literals": { "count": 1 @@ -99,11 +94,6 @@ "count": 1 } }, - "src/modules/investments/investment-resource.mapper.ts": { - "sonarjs/no-nested-conditional": { - "count": 3 - } - }, "src/modules/investments/investment-sync-plan.ts": { "sonarjs/cognitive-complexity": { "count": 1 @@ -146,11 +136,6 @@ "count": 1 } }, - "src/modules/investments/portfolio/application/portfolio.service.ts": { - "sonarjs/no-nested-conditional": { - "count": 1 - } - }, "src/modules/investments/tbank-sync/tbank-screen.ts": { "sonarjs/cognitive-complexity": { "count": 1 diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts index 98a4315..6d1c436 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts @@ -3,12 +3,12 @@ import { Module } from '@nestjs/common'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; -import { TbankSyncModule } from '../tbank-sync/tbank-sync.module'; +import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module'; import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; @Module({ - imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncModule], + imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule], controllers: [BrokerageAccountsController], providers: [BrokerageAccountsService], exports: [BrokerageAccountsService], diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index 5e38b9d..bfb01ba 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -24,7 +24,7 @@ describe('saved brokerage account projection', () => { it('distinguishes unknown membership from a confirmed empty list', async () => { portfolio.saved.mockResolvedValueOnce({ - membership: { ...meta, availability: 'missing' }, + membership: { ...meta, completeness: 'incomplete' }, accounts: [], }); expect((await service.list('owner')).data).toBeNull(); @@ -66,6 +66,39 @@ describe('saved brokerage account projection', () => { bondPositionCount: 1, }); expect(response.data?.[1].portfolioSummary).toBeNull(); - expect(response.meta.availability).toBe('partial'); + expect(response.meta.availability).toBe('available'); + expect(response.meta.completeness).toBe('complete'); + }); + + it('observes the latest hourly walk without starting another one', async () => { + portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] }); + sync.hourly.mockResolvedValue({ + startedAt: new Date('2026-09-25T12:00:00Z'), + finishedAt: new Date('2026-09-25T12:01:00Z'), + state: 'partial', + sections: [ + { + section: 'portfolio', + accountId: 'a', + errorCode: 'TBANK_UNAVAILABLE', + }, + ], + }); + + const response = await service.list('owner'); + + expect(response.sync).toEqual({ + startedAt: '2026-09-25T12:00:00.000Z', + finishedAt: '2026-09-25T12:01:00.000Z', + state: 'partial', + problems: [ + { + section: 'portfolio', + accountId: 'a', + errorCode: 'TBANK_UNAVAILABLE', + }, + ], + }); + expect(sync.hourly).toHaveBeenCalledWith('owner'); }); }); diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index 1c03544..9a003ca 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -16,7 +16,7 @@ export class BrokerageAccountsService { async list(userId: string): Promise { const saved = await this.portfolio.saved(userId, 'all'); const data: BrokerageAccountDto[] | null = - saved.membership.availability === 'missing' + saved.membership.completeness === 'incomplete' ? null : saved.accounts.map((account) => ({ id: account.id, @@ -55,16 +55,12 @@ export class BrokerageAccountsService { availability: data === null ? 'missing' - : saved.accounts.some((account) => !account.snapshot) - ? 'partial' - : 'complete', + : 'complete', fetchedAt: saved.membership.lastSuccessfulAt ? new Date(saved.membership.lastSuccessfulAt) : null, freshForMs: 3_600_000, - work: saved.membership.sync.id - ? { ...saved.membership.sync, id: saved.membership.sync.id } - : null, + work: null, dependencies: saved.accounts.map((account) => account.meta), }), sync: hourly @@ -72,7 +68,10 @@ export class BrokerageAccountsService { state: hourly.state, startedAt: hourly.startedAt.toISOString(), finishedAt: hourly.finishedAt?.toISOString() ?? null, - sections: hourly.sections, + problems: hourly.sections.map((section) => ({ + ...section, + accountId: section.accountId ?? null, + })), } : null, }; diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index 03a76dc..c136846 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -48,7 +48,7 @@ describe('IncomeAnalyticsService', () => { expect.objectContaining({ role: 'previous' }), expect.objectContaining({ role: 'year', - availability: 'missing', + availability: 'temporarily-unavailable', unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', }), ]); diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index fad1401..dffd297 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -119,7 +119,10 @@ export class IncomeAnalyticsService { ...investmentResourceMeta({ resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, dataRevision: current.meta.dataRevision, - availability: current.meta.availability, + availability: + current.items === null + ? 'temporarily-unavailable' + : 'available', fetchedAt: current.meta.lastSuccessfulAt ? new Date(current.meta.lastSuccessfulAt) : null, diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 5b7194c..489c4e4 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -29,6 +29,7 @@ import { timestamp } from './instrument-market'; const REPLACED_WINDOW_COUNT = 1; const NEXT_WINDOW_OFFSET = 1; +const NO_CANDLE_GAPS = 0; type CandleInput = { instrumentType: 'bond' | 'share'; @@ -106,7 +107,7 @@ export class CandleService { true, ['detail'], ); - if (detail.meta.availability === 'complete' && !detail.instrument) { + if (detail.meta.completeness === 'complete' && !detail.instrument) { throw new NotFoundException(); } const now = new Date(); @@ -114,8 +115,10 @@ export class CandleService { ? rangeFor(input, detail.instrument, now) : null; const saved = range ? await this.repository.read(input.uid, range) : null; - const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; - if (!observe && (!saved || gaps.length)) { + const hasCompleteRange = Boolean( + range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS, + ); + if (!observe && (!saved || !hasCompleteRange)) { await this.request(userId, revision, input, now); } const work = await this.repository.work(userId, revision, scope(input)); diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 609e91e..fb3c389 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -121,7 +121,7 @@ export class CatalogDiscoveryService { availability: discovery.data === null ? 'missing' - : sources.every((source) => source.meta.availability === 'complete') + : sources.every((source) => source.meta.completeness === 'complete') ? 'complete' : 'partial', fetchedAt: discovery.meta.lastSuccessfulAt @@ -173,7 +173,7 @@ export class CatalogDiscoveryService { ['detail'], ); const source = sources[0]; - if (source.meta.availability === 'complete' && !source.instrument) { + if (source.meta.completeness === 'complete' && !source.instrument) { throw new NotFoundException(); } const instrument = source.instrument; diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index f97404e..87b5f2f 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -288,7 +288,7 @@ export class CatalogEnrichmentService { kinds, ); const first = sources[0]; - if (first.meta.availability === 'complete' && !first.instrument) { + if (first.meta.completeness === 'complete' && !first.instrument) { throw new NotFoundException(); } const values = sources.reduce( @@ -297,7 +297,7 @@ export class CatalogEnrichmentService { ); const dependencies = sources.map((source) => source.meta); const complete = dependencies.every( - (dependency) => dependency.availability === 'complete', + (dependency) => dependency.completeness === 'complete', ); const meta = investmentResourceMeta({ resourceKey: `${instrumentType}/${section}/${uid}`, @@ -322,7 +322,7 @@ export class CatalogEnrichmentService { unavailable: sources .filter( (source) => - source.meta.availability !== 'complete' || + source.meta.completeness !== 'complete' || source.meta.sync.state === 'failed', ) .map((source) => source.input.kind), diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index 5189ed2..675513a 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -97,7 +97,7 @@ export class BondIncomeService { ['detail', 'coupons'], { from, to }, ); - if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { + if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) { throw new NotFoundException(); } const events = sources[1].payload?.coupons; @@ -137,7 +137,7 @@ export class BondIncomeService { projected.observedAt, true, ), - meta: { ...result.meta, availability: 'partial' }, + meta: { ...result.meta, completeness: 'incomplete' }, }; } } diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index f98557c..18463be 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -66,7 +66,7 @@ export class StockIncomeService { ['detail', 'dividends'], { from, to }, ); - if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { + if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) { throw new NotFoundException(); } const events = sources[1].payload?.dividends; @@ -106,7 +106,7 @@ export class StockIncomeService { projected.observedAt, true, ), - meta: { ...result.meta, availability: 'partial' }, + meta: { ...result.meta, completeness: 'incomplete' }, }; } } diff --git a/src/modules/investments/investment-resource.dto.ts b/src/modules/investments/investment-resource.dto.ts index 5d3eca3..ec666f6 100644 --- a/src/modules/investments/investment-resource.dto.ts +++ b/src/modules/investments/investment-resource.dto.ts @@ -1,9 +1,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsOptional } from 'class-validator'; -export const availabilityValues = ['missing', 'partial', 'complete'] as const; +export const availabilityValues = [ + 'available', + 'initial-loading', + 'temporarily-unavailable', + 'connection-unavailable', +] as const; +export const completenessValues = ['complete', 'incomplete'] as const; export const freshnessValues = ['unknown', 'fresh', 'stale'] as const; -export const syncStateValues = ['idle', 'running', 'failed'] as const; +export const attemptStateValues = [ + 'none', + 'running', + 'succeeded', + 'partial', + 'failed', +] as const; export class InvestmentReadQueryDto { @ApiPropertyOptional({ enum: ['observe'] }) @@ -12,17 +24,31 @@ export class InvestmentReadQueryDto { declare sync?: 'observe'; } -export class InvestmentSyncDto { - @ApiProperty({ type: String, nullable: true }) - declare id: null | string; +export class InvestmentAttemptProblemDto { + @ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] }) + declare section: 'events' | 'membership' | 'operations' | 'portfolio'; - @ApiProperty({ enum: syncStateValues }) - declare state: (typeof syncStateValues)[number]; + @ApiProperty({ type: String, nullable: true }) + declare accountId: null | string; @ApiProperty({ type: String, nullable: true }) declare errorCode: null | string; } +export class InvestmentAttemptDto { + @ApiProperty({ enum: attemptStateValues }) + declare state: (typeof attemptStateValues)[number]; + + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + declare startedAt: null | string; + + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + declare finishedAt: null | string; + + @ApiProperty({ type: InvestmentAttemptProblemDto, isArray: true }) + declare problems: InvestmentAttemptProblemDto[]; +} + export class InvestmentRangeDto { @ApiPropertyOptional() accountId?: string; @@ -73,14 +99,21 @@ export class InvestmentDependencyDto { @ApiProperty({ enum: availabilityValues }) declare availability: (typeof availabilityValues)[number]; + @ApiProperty({ enum: completenessValues }) + declare completeness: (typeof completenessValues)[number]; + @ApiProperty({ enum: freshnessValues }) declare freshness: (typeof freshnessValues)[number]; @ApiProperty({ type: String, format: 'date-time', nullable: true }) declare lastSuccessfulAt: null | string; - @ApiProperty({ type: InvestmentSyncDto }) - declare sync: InvestmentSyncDto; + @ApiProperty({ type: InvestmentAttemptDto }) + declare lastAttempt: InvestmentAttemptDto; + + // Internal resource-source work remains available to existing source owners. + // It is deliberately undecorated and therefore not part of the HTTP schema. + declare sync: { errorCode: null | string; id: null | string; state: 'failed' | 'idle' | 'running' }; } export class InvestmentResourceMetaDto extends InvestmentDependencyDto { diff --git a/src/modules/investments/investment-resource.mapper.spec.ts b/src/modules/investments/investment-resource.mapper.spec.ts index 6ef5727..995be0e 100644 --- a/src/modules/investments/investment-resource.mapper.spec.ts +++ b/src/modules/investments/investment-resource.mapper.spec.ts @@ -1,4 +1,9 @@ +jest.mock('../../prisma/prisma.service', () => ({ + PrismaService: class {}, +})); + import { investmentResourceMeta } from './investment-resource.mapper'; +import { hourlyAttempt } from './tbank-sync/tbank-sync.repository'; describe('investment resource source freshness', () => { afterEach(() => jest.useRealTimers()); @@ -36,6 +41,105 @@ describe('investment resource source freshness', () => { }).freshness, ).toBe('unknown'); }); + + it('keeps confirmed data state independent from a failed hourly attempt', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-25T11:30:00Z')); + const meta = investmentResourceMeta({ + resourceKey: 'portfolio/a', + dataRevision: '1', + availability: 'complete', + fetchedAt: new Date('2026-09-25T11:00:00Z'), + freshForMs: 3_600_000, + work: null, + lastAttempt: { + startedAt: '2026-09-25T12:00:00.000Z', + finishedAt: '2026-09-25T12:01:00.000Z', + state: 'partial', + problems: [ + { + section: 'portfolio', + accountId: 'a', + errorCode: 'TBANK_UNAVAILABLE', + }, + ], + }, + }); + + expect(meta).toMatchObject({ + availability: 'available', + completeness: 'complete', + freshness: 'fresh', + lastSuccessfulAt: '2026-09-25T11:00:00.000Z', + lastAttempt: { + state: 'partial', + startedAt: '2026-09-25T12:00:00.000Z', + finishedAt: '2026-09-25T12:01:00.000Z', + }, + }); + }); + + it('projects only the matching hourly section into a resource attempt', () => { + const attempt = hourlyAttempt( + { + startedAt: new Date('2026-09-25T12:00:00Z'), + finishedAt: new Date('2026-09-25T12:01:00Z'), + state: 'partial', + sections: [ + { section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE' }, + { section: 'operations', accountId: 'a', errorCode: null }, + { section: 'events', errorCode: 'CALENDAR_UNAVAILABLE' }, + ], + }, + 'portfolio', + 'a', + ); + + const meta = investmentResourceMeta({ + resourceKey: 'portfolio/a', + dataRevision: '1', + availability: 'complete', + fetchedAt: new Date('2026-09-25T11:00:00Z'), + freshForMs: 3_600_000, + work: { id: 'legacy-work', state: 'failed', errorCode: 'LEGACY_FAILURE' }, + lastAttempt: attempt, + }); + + expect(meta).toMatchObject({ + availability: 'available', + completeness: 'complete', + lastAttempt: { + state: 'partial', + startedAt: '2026-09-25T12:00:00.000Z', + finishedAt: '2026-09-25T12:01:00.000Z', + problems: [{ section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE' }], + }, + }); + expect(JSON.stringify(meta)).not.toContain('legacy-work'); + }); + + it('projects no confirmed result as incomplete without inventing a successful attempt', () => { + expect( + investmentResourceMeta({ + resourceKey: 'accounts', + dataRevision: '0', + availability: 'missing', + fetchedAt: null, + freshForMs: 3_600_000, + work: { id: 'work', state: 'running', errorCode: null }, + }), + ).toMatchObject({ + availability: 'initial-loading', + completeness: 'incomplete', + freshness: 'unknown', + lastSuccessfulAt: null, + lastAttempt: { + state: 'running', + startedAt: null, + finishedAt: null, + problems: [], + }, + }); + }); }); describe('dependent resource lifecycle', () => { @@ -60,8 +164,8 @@ describe('dependent resource lifecycle', () => { ...input, dependencies: [failed, running], }); - expect(parent.sync).toEqual(running.sync); - expect(parent.availability).toBe('complete'); + expect(parent.lastAttempt.state).toBe('none'); + expect(parent.availability).toBe('available'); const terminal = investmentResourceMeta({ ...input, dependencies: [failed], diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts index ab2e8eb..8cc265b 100644 --- a/src/modules/investments/investment-resource.mapper.ts +++ b/src/modules/investments/investment-resource.mapper.ts @@ -1,41 +1,34 @@ import { InvestmentResourceMetaDto } from './investment-resource.dto'; +type LegacyAvailability = 'available' | 'complete' | 'connection-unavailable' | 'initial-loading' | 'missing' | 'partial' | 'temporarily-unavailable'; +type Work = null | { errorCode: null | string; id: string; state: string }; +type Attempt = NonNullable; + export function investmentResourceMeta(input: { - availability: InvestmentResourceMetaDto['availability']; + availability: LegacyAvailability; coverage?: NonNullable; dataRevision: string; dependencies?: InvestmentResourceMetaDto[]; fetchedAt: Date | null; freshForMs: number; + lastAttempt?: Attempt; resourceKey: string; unavailableReason?: string; - work: null | { errorCode: null | string; id: string; state: string }; + work: Work; }): InvestmentResourceMetaDto { - const ownSync = { - id: input.work?.id ?? null, - state: - input.work?.state === 'running' - ? ('running' as const) - : input.work?.state === 'failed' - ? ('failed' as const) - : ('idle' as const), - errorCode: input.work?.errorCode ?? null, - }; - const states = [ + const ownSync = sourceSync(input.work); + const syncStates = [ ownSync, ...(input.dependencies ?? []) .filter((dependency) => !dependency.unavailableReason) - .map((dependency) => dependency.sync), + .map((dependency) => dependency.sync) + .filter((state): state is typeof ownSync => Boolean(state)), ]; - const sync = - states.find((state) => state.state === 'running') ?? - states.find((state) => state.state === 'failed') ?? + const sourceWork = + syncStates.find((state) => state.state === 'running') ?? + syncStates.find((state) => state.state === 'failed') ?? ownSync; - const sourceFreshness = input.fetchedAt - ? Date.now() - input.fetchedAt.valueOf() < input.freshForMs - ? ('fresh' as const) - : ('stale' as const) - : ('unknown' as const); + const sourceFreshness = freshnessOf(input.fetchedAt, input.freshForMs); const freshness = [ sourceFreshness, ...(input.dependencies ?? []) @@ -57,16 +50,75 @@ export function investmentResourceMeta(input: { ? { unavailableReason: input.unavailableReason } : {}), dataRevision: input.dataRevision, - availability: input.availability, - freshness: freshness.includes('unknown') - ? ('unknown' as const) - : freshness.includes('stale') - ? ('stale' as const) - : ('fresh' as const), + availability: availability(input.availability, input.work), + completeness: + availability(input.availability, input.work) === 'available' + ? ('complete' as const) + : ('incomplete' as const), + freshness: combinedFreshness(freshness), lastSuccessfulAt: timestamps[0] ?? null, - sync, + lastAttempt: input.lastAttempt ?? + attempt(input.work), ...(input.coverage ? { coverage: input.coverage } : {}), ...(input.dependencies ? { dependencies: input.dependencies } : {}), }; - return meta; + return Object.defineProperty(meta, 'sync', { + enumerable: false, + value: sourceWork, + }) as InvestmentResourceMetaDto; +} + +function sourceSync(work: Work) { + let state: 'failed' | 'idle' | 'running' = 'idle'; + if (work?.state === 'running') state = 'running'; + else if (work?.state === 'failed') state = 'failed'; + return { id: work?.id ?? null, state, errorCode: work?.errorCode ?? null }; +} + +function freshnessOf(fetchedAt: Date | null, freshForMs: number) { + if (!fetchedAt) return 'unknown' as const; + return Date.now() - fetchedAt.valueOf() < freshForMs + ? ('fresh' as const) + : ('stale' as const); +} + +function combinedFreshness(values: InvestmentResourceMetaDto['freshness'][]) { + if (values.includes('unknown')) return 'unknown' as const; + if (values.includes('stale')) return 'stale' as const; + return 'fresh' as const; +} + +function attempt(work: Work): Attempt { + const base = { startedAt: null, finishedAt: null }; + if (work?.state === 'running') return { ...base, state: 'running', problems: [] }; + if (work?.state === 'failed') { + return { + ...base, + state: 'failed', + problems: work.errorCode + ? [{ section: 'membership', accountId: null, errorCode: work.errorCode }] + : [], + }; + } + return { ...base, state: 'none', problems: [] }; +} + +function availability(value: LegacyAvailability, work: Work) { + if ( + value === 'available' || + value === 'connection-unavailable' || + value === 'initial-loading' || + value === 'temporarily-unavailable' + ) { + return value; + } + if (value === 'complete') return 'available' as const; + if (value === 'partial') { + return work?.state === 'running' + ? ('initial-loading' as const) + : ('temporarily-unavailable' as const); + } + return work?.state === 'running' + ? ('initial-loading' as const) + : ('temporarily-unavailable' as const); } diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index e4e3001..5b63076 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -17,6 +17,10 @@ import { ledgerRange, planInvestmentScreen, } from '../../investment-sync-plan'; +import { + hourlyAttempt, + TbankSyncRepository, +} from '../../tbank-sync/tbank-sync.repository'; import { OperationErrorCode } from '../domain/operation-error-code'; import { confirmedOperationRanges, @@ -59,6 +63,7 @@ export class BrokerageOperationsService { private readonly sync: BrokerageOperationsSyncService, private readonly membership: TbankMembershipService, private readonly config: ConfigService, + private readonly hourlySync: TbankSyncRepository, ) {} async read( @@ -145,6 +150,7 @@ export class BrokerageOperationsService { }); } const saved = await this.repository.readPage(readInput); + const hourly = await this.hourlySync.hourly(userId); const accounts = saved?.connection.accounts ?? []; const targets = accounts.flatMap((account) => from < to @@ -243,6 +249,7 @@ export class BrokerageOperationsService { ? STALE_FRESHNESS_MS : Number.POSITIVE_INFINITY, work: work ?? saved?.membershipWork ?? null, + lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId), dependencies: [membershipMeta, ...metadataDependencies], coverage: { requested: targets.map(serialize), @@ -294,7 +301,7 @@ export class BrokerageOperationsService { const hasNext = Boolean(saved && saved.rows.length > limit); return { data: - meta.availability === 'missing' + meta.completeness === 'incomplete' ? null : { accountId, diff --git a/src/modules/investments/operations/operations.module.ts b/src/modules/investments/operations/operations.module.ts index a91bce8..d62f1ad 100644 --- a/src/modules/investments/operations/operations.module.ts +++ b/src/modules/investments/operations/operations.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; +import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module'; import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; import { BrokerageOperationsService } from './application/brokerage-operations.service'; import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository'; import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; @Module({ - imports: [AuthModule, TbankModule], + imports: [AuthModule, TbankModule, TbankSyncRepositoryModule], controllers: [BrokerageOperationsController], providers: [ BrokerageOperationsService, diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index d88d689..fd0811b 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -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(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(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>(); + 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; + } +} diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 8324ca7..2b12f43 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -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,8 @@ 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'); + expect(partial.meta.completeness).toBe('incomplete'); const saved = await service.saved('user', 'all'); expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual( snapshot.totalsByCurrency, diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index eba34dc..61852ff 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -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 { @@ -28,6 +32,7 @@ import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper'; import { PortfolioDto } from '../presentation/portfolio.dto'; const MISSING_DATA_REVISION = 0; +const NO_ACCOUNTS = 0; const membershipFreshForMs = tbankSourceTtlMs.membership; @@ -44,12 +49,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 +71,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,16 +104,21 @@ 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.accounts.some((account) => !account.snapshot); + saved.membership.completeness === 'incomplete' || + (saved.accounts.length > NO_ACCOUNTS && saved.accounts.some((account) => !account.snapshot)); + const available = + !missing && + (saved.membership.availability === 'available' || + saved.accounts.some((account) => account.snapshot)); const metadata = await this.metadata.savedMany( saved.accounts.flatMap((account) => account.snapshot?.positions ?? []), ); @@ -153,6 +167,11 @@ export class PortfolioService { ] : [], ); + let availability: 'available' | 'complete' | 'missing' | 'partial'; + if (available) availability = 'available'; + else if (!missing) availability = 'complete'; + else if (portfolios.length > NO_ACCOUNTS) availability = 'partial'; + else availability = 'missing'; const lastSuccessfulAt = // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? @@ -196,16 +215,13 @@ export class PortfolioService { ]), metadataDependencies.map((dependency) => dependency.dataRevision), ]), - availability: missing - ? portfolios.length - ? 'partial' - : 'missing' - : 'complete', + availability, fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, freshForMs: portfolios.length ? 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), diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index e888736..ca6f52d 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -51,11 +51,32 @@ export class ForecastRepository { const previous = await tx.tbankForecast.findUnique({ where: { userId_revision_scopeKey: key }, }); + const requestedWindow = eventsWindow(input.scopeKey); + const saved = previous ?? (requestedWindow + ? ( + await tx.tbankForecast.findMany({ + where: { + ...owner, + scopeKey: { startsWith: 'events/' }, + membershipRevision: input.membershipRevision, + }, + }) + ).find((forecast) => { + const window = eventsWindow(forecast.scopeKey); + return ( + window && + window.accountId === requestedWindow.accountId && + window.from <= requestedWindow.to && + requestedWindow.from <= window.to + ); + }) + : null); const fallback = - previous?.membershipRevision === input.membershipRevision - ? previous + saved?.membershipRevision === input.membershipRevision + ? saved : null; if (!input.candidate) return fallback; + if (!previous && fallback) return fallback; const portfolios = await tx.tbankPortfolio.findMany({ where: { ...owner, @@ -154,3 +175,13 @@ export class ForecastRepository { }); } } + +function eventsWindow(scopeKey: string) { + if (!scopeKey.startsWith('events/')) return null; + try { + const [accountId, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string]; + return { accountId, from, to }; + } catch { + return null; + } +} diff --git a/src/modules/investments/portfolio/portfolio.module.ts b/src/modules/investments/portfolio/portfolio.module.ts index 307456c..5b91496 100644 --- a/src/modules/investments/portfolio/portfolio.module.ts +++ b/src/modules/investments/portfolio/portfolio.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; +import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module'; import { ForecastService } from './application/forecast.service'; import { PortfolioService } from './application/portfolio.service'; import { ForecastRepository } from './infrastructure/forecast.repository'; @@ -9,7 +10,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository'; import { PortfolioController } from './presentation/portfolio.controller'; @Module({ - imports: [AuthModule, TbankModule], + imports: [AuthModule, TbankModule, TbankSyncRepositoryModule], controllers: [PortfolioController], providers: [ PortfolioService, diff --git a/src/modules/investments/tbank-sync/tbank-sync-repository.module.ts b/src/modules/investments/tbank-sync/tbank-sync-repository.module.ts new file mode 100644 index 0000000..fabf453 --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-sync-repository.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { TbankSyncRepository } from './tbank-sync.repository'; + +@Module({ + providers: [TbankSyncRepository], + exports: [TbankSyncRepository], +}) +export class TbankSyncRepositoryModule {} diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index cbe6bf8..2eb0741 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -22,6 +22,7 @@ import { } from 'class-validator'; import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; +import { InvestmentAttemptProblemDto } from '../investment-resource.dto'; import { operationCategories } from '../operations/operations.api'; const MIN_ACCOUNT_ID_LENGTH = 1; @@ -267,6 +268,6 @@ export class HourlySyncStatusDto { @ApiProperty({ type: String, nullable: true, format: 'date-time' }) declare finishedAt: null | string; - @ApiProperty({ type: HourlySyncSectionDto, isArray: true }) - declare sections: HourlySyncSectionDto[]; + @ApiProperty({ type: InvestmentAttemptProblemDto, isArray: true }) + declare problems: InvestmentAttemptProblemDto[]; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.module.ts b/src/modules/investments/tbank-sync/tbank-sync.module.ts index e2df875..44756fb 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.module.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.module.ts @@ -7,8 +7,8 @@ import { OperationsModule } from '../operations/operations.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module'; import { TbankHourlyScheduler } from './tbank-hourly.scheduler'; +import { TbankSyncRepositoryModule } from './tbank-sync-repository.module'; import { TbankSyncController } from './tbank-sync.controller'; -import { TbankSyncRepository } from './tbank-sync.repository'; import { TbankSyncService } from './tbank-sync.service'; @Module({ imports: [ @@ -18,9 +18,10 @@ import { TbankSyncService } from './tbank-sync.service'; OperationsModule, InstrumentSourcesModule, UpcomingEventsModule, + TbankSyncRepositoryModule, ], controllers: [TbankSyncController], - providers: [TbankSyncService, TbankSyncRepository, TbankHourlyScheduler], - exports: [TbankHourlyScheduler, TbankSyncRepository], + providers: [TbankSyncService, TbankHourlyScheduler], + exports: [TbankHourlyScheduler], }) export class TbankSyncModule {} diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index 0768c95..ae82586 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import type { Prisma } from '../../../../generated/prisma/client'; import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; +import type { InvestmentAttemptDto } from '../investment-resource.dto'; import type { InvestmentScreenDto } from './tbank-sync.dto'; import { PrismaService } from '../../../prisma/prisma.service'; @@ -21,6 +22,40 @@ export type HourlyProgress = { result?: 'failed' | 'partial' | 'succeeded'; sections: HourlySection[]; }; +type HourlyAttempt = { + finishedAt: Date | null; + sections: HourlySection[]; + startedAt: Date; + state: 'cancelled' | 'failed' | 'partial' | 'running' | 'succeeded'; +}; + +export function hourlyAttempt( + hourly: HourlyAttempt | null, + section: HourlySection['section'], + accountId?: string, +): InvestmentAttemptDto { + if (!hourly) { + return { state: 'none', startedAt: null, finishedAt: null, problems: [] }; + } + const problems = hourly.sections + .filter( + (entry) => + entry.section === section && + (accountId === undefined || entry.accountId === accountId), + ) + .map((entry) => ({ + section: entry.section, + accountId: entry.accountId ?? null, + errorCode: entry.errorCode, + })); + return { + state: hourly.state === 'cancelled' ? 'failed' : hourly.state, + startedAt: hourly.startedAt.toISOString(), + finishedAt: hourly.finishedAt?.toISOString() ?? null, + problems, + }; +} + @Injectable() export class TbankSyncRepository { constructor(private readonly prisma: PrismaService) {} diff --git a/src/modules/investments/upcoming-events/upcoming-events.module.ts b/src/modules/investments/upcoming-events/upcoming-events.module.ts index ed66431..ab7ae36 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.module.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.module.ts @@ -4,11 +4,12 @@ import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; +import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module'; import { UpcomingEventsController } from './upcoming-events.controller'; import { UpcomingEventsService } from './upcoming-events.service'; @Module({ - imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule], + imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule, TbankSyncRepositoryModule], controllers: [UpcomingEventsController], providers: [UpcomingEventsService], exports: [UpcomingEventsService], diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 937c231..5393afc 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -20,6 +20,10 @@ import { type PortfolioPositionDto, PortfolioService, } from '../portfolio/portfolio.api'; +import { + hourlyAttempt, + TbankSyncRepository, +} from '../tbank-sync/tbank-sync.repository'; import { bondEvent, dividendEvent } from './upcoming-event.mapper'; import { UpcomingEventDto, @@ -43,6 +47,7 @@ export class UpcomingEventsService { private readonly sources: CatalogEnrichmentService, private readonly forecasts: ForecastService, private readonly metadata: InstrumentMetadataService, + private readonly sync: TbankSyncRepository, ) {} // Read endpoints stay local; callers without a saved portfolio receive unknown. @@ -54,6 +59,7 @@ export class UpcomingEventsService { const window = dateWindow(query); const connection = await this.access.local(userId, accountId); const portfolio = await this.portfolios.read(userId, accountId); + const hourly = await this.sync.hourly(userId); const events: UpcomingEventDto[] = []; let ready = portfolio.data !== null; const positions = portfolio.data?.positions.filter(supported) ?? []; @@ -61,7 +67,7 @@ export class UpcomingEventsService { userId, positions, window, - query.sync === 'observe', + true, ); for (const position of positions) { const generic = metadata.get(metadataKey(position))?.data; @@ -98,7 +104,7 @@ export class UpcomingEventsService { source.input.uid === uid && source.input.kind === 'detail', )?.instrument || generic; const ticker = position.ticker || instrument?.ticker; - if (!calendar || calendar.meta.availability !== 'complete' || !ticker) { + if (!calendar || calendar.meta.completeness !== 'complete' || !ticker) { ready = false; continue; } @@ -157,6 +163,11 @@ export class UpcomingEventsService { membershipRevision: connection.membershipRevision, scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`, portfolio: portfolio.meta, + lastAttempt: hourlyAttempt( + hourly, + 'events', + accountId === 'all' ? undefined : accountId, + ), sources, candidate, }); @@ -168,8 +179,12 @@ export class UpcomingEventsService { // Triggers online refreshes of the account's event sources and returns the // launched work ids for the hourly walk to observe. - async refresh(userId: string, accountId: string): Promise { - const window = dateWindow({}); + async refresh( + userId: string, + accountId: string, + query: UpcomingEventsQueryDto = {}, + ): Promise { + const window = dateWindow(query); await this.access.local(userId, accountId); const portfolio = await this.portfolios.read(userId, accountId); const positions = portfolio.data?.positions.filter(supported) ?? []; diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index 9bb7cd0..a238662 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -81,7 +81,7 @@ describe('All accounts HTTP boundary', () => { expect(response.body).not.toHaveProperty('token'); expect(response.body).not.toHaveProperty('generation'); expect(response.body).toMatchObject({ - meta: { availability: 'complete' }, + meta: { availability: 'available', completeness: 'complete' }, ...(path === 'portfolio' ? { data: { positions: [] } } : {}), }); }, diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index c4fc5c7..eb041e8 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -240,7 +240,8 @@ describe('atomic shared candle reconciliation (SQLite)', () => { data: { fetchedAt: new Date(0) }, }); const stale = await reopened.read('owner', input, true); - expect(stale.meta.availability).toBe('complete'); + expect(stale.meta.availability).toBe('available'); + expect(stale.meta.completeness).toBe('complete'); expect(stale.meta.freshness).toBe('stale'); expect(stale.meta.coverage?.missingRanges).toEqual([]); expect(stale.meta.coverage?.completeRanges.length).toBeGreaterThan(0); @@ -302,7 +303,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before); expect(await prisma.tbankCandle.count()).toBe(0); const read = await service.read('owner', input, true); - expect(read.meta.sync.state).toBe('failed'); + expect(read.meta.lastAttempt.state).toBe('failed'); expect(read.data?.candles).toEqual([]); }); it.each([ diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts index 2658aa3..cab7432 100644 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -85,14 +85,15 @@ describe('shared catalog discovery (SQLite)', () => { it('persists normalized query identity and confirmed empty separately from unknown, across users and reopen', async () => { const cold = await service.read('owner', { ...query, query: ' sber ' }); expect(cold.data).toBeNull(); - expect(cold.meta.availability).toBe('missing'); + expect(cold.meta.availability).toBe('initial-loading'); await settled(); await prisma.$disconnect(); service = createService(); const decrypt = jest.spyOn(tokens, 'decrypt'); const warm = await service.read('other', query); expect(warm.data).toEqual([instrument]); - expect(warm.meta.availability).toBe('complete'); + expect(warm.meta.availability).toBe('available'); + expect(warm.meta.completeness).toBe('complete'); expect(warm.meta.sync.id).toBeNull(); expect(decrypt).not.toHaveBeenCalled(); expect(upstream.findShares).toHaveBeenCalledTimes(1); diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 9c08e50..cbba85d 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -128,7 +128,8 @@ describe('local income completeness and exact money (SQLite)', () => { for (let index = 0; index < 2; index++) { const result = await read(); assert(result.data); - expect(result.meta.availability).toBe('complete'); + expect(result.meta.availability).toBe('available'); + expect(result.meta.completeness).toBe('complete'); expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([ { currency: 'RUB', value: '15.000000002' }, { currency: 'USD', value: '6' }, @@ -146,10 +147,9 @@ describe('local income completeness and exact money (SQLite)', () => { 'previous', 'year', ]); - expect( - result.meta.dependencies.find(({ role }) => role === 'previous') - ?.availability, - ).toBe('missing'); + const previous = result.meta.dependencies.find(({ role }) => role === 'previous'); + expect(previous?.availability).toBe('temporarily-unavailable'); + expect(previous?.completeness).toBe('incomplete'); expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); await prisma.$disconnect(); } @@ -161,7 +161,9 @@ describe('local income completeness and exact money (SQLite)', () => { where: { accountId: 'b' }, }); expect((await read()).data).toBeNull(); - expect((await read()).meta.availability).toBe('partial'); + const incomplete = await read(); + expect(incomplete.meta.availability).toBe('temporarily-unavailable'); + expect(incomplete.meta.completeness).toBe('incomplete'); expect((await read('a')).data).not.toBeNull(); expect((await read('a', 'from=2025-03-01&to=2025-03-31')).data).toBeNull(); await coverage( @@ -241,7 +243,7 @@ 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.sync.state).toBe('failed'); + expect(after.meta.lastAttempt.state).toBe('failed'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('retains current-month observed completeness and does not renew source timestamps', async () => { @@ -261,7 +263,8 @@ describe('local income completeness and exact money (SQLite)', () => { from: `${today.slice(0, 7)}-01`, to: today, }); - expect(result.meta.availability).toBe('complete'); + expect(result.meta.availability).toBe('available'); + expect(result.meta.completeness).toBe('complete'); expect(result.meta.coverage?.asOf).toBe(asOf.toISOString()); expect(result.data?.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index cce6abc..58725dc 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -118,7 +118,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { `limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, ).expect(200); const result = response.body as BrokerageOperationsResourceDto; - expect(result.meta.availability).toBe('complete'); + expect(result.meta.availability).toBe('available'); + expect(result.meta.completeness).toBe('complete'); assert(result.data); ids.push( ...result.data.items.map((item) => `${item.account?.id}/${item.id}`), @@ -251,15 +252,18 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { .set('Authorization', auth()); const first = (await page().expect(200)) .body as BrokerageOperationsResourceDto; - expect(first.meta.availability).toBe('complete'); + expect(first.meta.availability).toBe('available'); + expect(first.meta.completeness).toBe('complete'); assert(first.data?.nextCursor); const second = (await page(first.data.nextCursor).expect(200)) .body as BrokerageOperationsResourceDto; - expect(second.meta.availability).toBe('complete'); + expect(second.meta.availability).toBe('available'); + expect(second.meta.completeness).toBe('complete'); expect(second.meta.coverage?.missingRanges).toEqual([]); const observed = (await page(first.data.nextCursor, true).expect(200)) .body as BrokerageOperationsResourceDto; - expect(observed.meta.availability).toBe('complete'); + expect(observed.meta.availability).toBe('available'); + expect(observed.meta.completeness).toBe('complete'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }, ); @@ -384,12 +388,14 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { 'a', ).expect(200) ).body as BrokerageOperationsResourceDto; - expect(partial.meta.availability).toBe('partial'); - expect(partial.data?.hasNext).toBe(false); + expect(partial.meta.availability).toBe('initial-loading'); + expect(partial.meta.completeness).toBe('incomplete'); + expect(partial.data).toBeNull(); expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0); const whole = (await get('sync=observe', 'a').expect(200)) .body as BrokerageOperationsResourceDto; - expect(whole.meta.availability).toBe('complete'); + expect(whole.meta.availability).toBe('available'); + expect(whole.meta.completeness).toBe('complete'); expect(whole.data?.items).toEqual([]); expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThan(0); }); @@ -419,7 +425,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const cold = (await get('', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(cold.data).toBeNull(); - expect(cold.meta.availability).toBe('missing'); + expect(cold.meta.availability).toBe('initial-loading'); + expect(cold.meta.completeness).toBe('incomplete'); await get('sync=observe', 'a').expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); // eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation. @@ -434,7 +441,8 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const result = (await get('sync=observe', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(result.data?.items).toEqual([]); - expect(result.meta.availability).toBe('complete'); + expect(result.meta.availability).toBe('available'); + expect(result.meta.completeness).toBe('complete'); await get('sync=observe', 'a').expect(200); expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count); }); diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index 907867f..a90401a 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -151,7 +151,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200); expect((cold.body as PortfolioResourceDto).data).toBeNull(); expect((cold.body as PortfolioResourceDto).meta.availability).toBe( - 'missing', + 'temporarily-unavailable', ); await get('brokerage-accounts?sync=observe').expect(200); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); @@ -321,7 +321,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { 'brokerage-accounts/account/portfolio?sync=observe', ).expect(200); expect((failed.body as PortfolioResourceDto).data).toEqual(previous); - expect((failed.body as PortfolioResourceDto).meta.sync.state).toBe('idle'); + expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none'); expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); }); @@ -375,7 +375,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { where: { id: 'abandoned-membership' }, data: { state: 'complete' }, }); - expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'missing' } }); + expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect(await prisma.tbankPortfolio.count()).toBe(0); expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({ data: [{ id: 'account', portfolioSummary: null }], @@ -423,13 +423,14 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { { currency: 'RUB', value: '21.1' }, ]); expect(summaries.data?.[1].portfolioSummary).toBeNull(); - expect(summaries.meta.availability).toBe('partial'); + expect(summaries.meta.availability).toBe('available'); + expect(summaries.meta.completeness).toBe('complete'); expect( (await get('brokerage-accounts/all/portfolio?sync=observe')).body, - ).toMatchObject({ data: null, meta: { availability: 'partial' } }); + ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect( (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, - ).toMatchObject({ data: null, meta: { availability: 'partial' } }); + ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } }); await prisma.tbankSyncWork.updateMany({ @@ -444,7 +445,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ); expect(position.body).toMatchObject({ data: { quantity: '2', accounts: [{ account: { id: 'account' } }] }, - meta: { availability: 'complete' }, + meta: { availability: 'available', completeness: 'complete' }, }); await prisma.$disconnect(); expect( @@ -495,7 +496,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { const empty = await get('brokerage-accounts?sync=observe').expect(200); expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]); expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe( - 'complete', + 'available', ); await put().expect(200); await seedMembership(); diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 05bea97..e338fee 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -21,6 +21,7 @@ import { configureApp } from '../src/main'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository'; import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler'; +import { UpcomingEventsService } from '../src/modules/investments/upcoming-events/upcoming-events.service'; import { PrismaService } from '../src/prisma/prisma.service'; const shareUid = '00000000-0000-4000-8000-000000000001'; @@ -81,6 +82,10 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { `brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`, userId, ); + const refresh = async (range = window, accountId = 'account') => { + await app.get(UpcomingEventsService).refresh('owner', accountId, range); + await settled(); + }; async function settled() { for (let i = 0; i < 500; i++) { await delay(5); @@ -235,39 +240,29 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { }); it('returns unknown immediately, keeps observe passive and shares confirmed empty calendars across users', async () => { - let release: (value: unknown[]) => void = () => {}; - upstream.getDividends.mockReturnValueOnce( - new Promise((resolve) => { - release = resolve; - }), - ); expect( ((await events().expect(200)).body as UpcomingEventsResourceDto).data, ).toBeNull(); expect(upstream.getDividends).not.toHaveBeenCalled(); - const cold = (await events(false).expect(200)) + const cold = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(cold.data).toBeNull(); - for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { - await delay(5); - } + expect(await prisma.tbankForecast.count()).toBe(0); + }); + + it('keeps an ordinary GET local and does not start catalog work', async () => { + await portfolio([position()]); + jest.clearAllMocks(); + await events().expect(200); - release([]); - await settled(); - const warm = (await events().expect(200)).body as UpcomingEventsResourceDto; - expect(warm.meta.availability).toBe('complete'); - expect(warm.data?.events).toEqual([]); - const other = (await events(false, 'account', 'other').expect(200)) - .body as UpcomingEventsResourceDto; - expect(other.data?.events).toEqual([]); - expect(upstream.getDividends).toHaveBeenCalledTimes(1); - expect(await prisma.tbankForecast.count()).toBe(2); - expect(JSON.stringify(warm)).not.toContain('owner-revision'); + + expect(upstream.getDividends).not.toHaveBeenCalled(); + expect(upstream.getInstrumentByUid).not.toHaveBeenCalled(); }); it('preserves source identity, inclusive dates, exact currencies, cancellation and offers in account/all scopes', async () => { await portfolio([position(), position(bondUid, '3', 'BOND')]); - await events(false).expect(200); + await app.get(UpcomingEventsService).refresh('owner', 'account', window); await settled(); const single = (await events().expect(200)) .body as UpcomingEventsResourceDto; @@ -297,7 +292,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { }); it('keeps the previous full forecast and timestamps after a new holding calendar fails, including reopen', async () => { - await events(false).expect(200); + await refresh(); await settled(); const before = (await events().expect(200)) .body as UpcomingEventsResourceDto; @@ -307,7 +302,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { ? Promise.reject(new Error('new calendar unavailable')) : Promise.resolve([]), ); - const pending = (await events(false).expect(200)) + await refresh(); + const pending = (await events().expect(200)) .body as UpcomingEventsResourceDto; expect(pending.data).toEqual(before.data); await settled(); @@ -316,14 +312,14 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .body as UpcomingEventsResourceDto; expect(after.data).toEqual(before.data); expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); - expect(after.meta.sync.state).toBe('failed'); + expect(after.meta.dependencies?.some((dependency) => dependency.lastAttempt.state === 'failed')).toBe(true); const count = upstream.getDividends.mock.calls.length; await events().expect(200); expect(upstream.getDividends).toHaveBeenCalledTimes(count); }); it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => { - await events(false).expect(200); + await refresh(); await settled(); await events().expect(200); const newRange = ( @@ -346,7 +342,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { }); it('refuses a calculated snapshot if its portfolio changes before atomic publication', async () => { - await events(false).expect(200); + await refresh(); await settled(); const before = (await events().expect(200)) .body as UpcomingEventsResourceDto; @@ -531,7 +527,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => { upstream.getBond.mockRejectedValue(new Error('metadata unavailable')); await portfolio([position(bondUid, '3', 'BOND')]); - await events(false).expect(200); + await refresh(); await settled(); const result = (await events().expect(200)) .body as UpcomingEventsResourceDto; @@ -566,7 +562,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { new MoexClient().bondEvents(ticker), ); try { - await events(false).expect(200); + await refresh(); await settled(); const before = (await events().expect(200)) .body as UpcomingEventsResourceDto; @@ -590,7 +586,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .body as UpcomingEventsResourceDto; expect(after.data).toEqual(before.data); expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); - expect(after.meta.sync.state).toBe('failed'); + expect(after.meta.dependencies?.some((dependency) => dependency.lastAttempt.state === 'failed')).toBe(true); expect( await prisma.tbankInstrumentSource.findFirstOrThrow({ where: { kind: 'bond-events' }, @@ -605,9 +601,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { it('defaults to a 30-day window', async () => { await portfolio([position(bondUid, '3', 'BOND')]); - await events(false).expect(200); - await settled(); - await get('brokerage-accounts/account/events?from=2099-02-01').expect(200); + await refresh(); await settled(); const result = ( await get( @@ -622,6 +616,30 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { ]); }); + it('filters a saved source window without changing its calculated timestamp', async () => { + await portfolio([position(bondUid, '3', 'BOND')]); + await app.get(UpcomingEventsService).refresh('owner', 'account', { + from: '2099-02-01', + to: '2099-02-28', + }); + await settled(); + const full = (await events().expect(200)).body as UpcomingEventsResourceDto; + const calculatedAt = full.data?.calculatedAt; + jest.clearAllMocks(); + + const narrow = (await events(false, 'account', 'owner', { + from: '2099-02-01', + to: '2099-02-10', + }).expect(200)).body as UpcomingEventsResourceDto; + + expect(narrow.data?.calculatedAt).toBe(calculatedAt); + expect(narrow.data?.events.map((event) => event.date)).toEqual([ + '2099-02-01', + ]); + expect(upstream.getDividends).not.toHaveBeenCalled(); + expect(moex.bondEvents).not.toHaveBeenCalled(); + }); + it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => { let release: (value: unknown[]) => void = () => {}; upstream.getDividends.mockReturnValueOnce( @@ -629,7 +647,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { release = resolve; }), ); - await events(false).expect(200); + await app.get(UpcomingEventsService).refresh('owner', 'account', window); for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { await delay(5); } @@ -664,8 +682,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { it('uses UTC today for an end-only range and includes the entire leap day', async () => { const today = new Date().toISOString().slice(0, 10); - await get('brokerage-accounts/account/events?to=2096-02-29').expect(200); - await settled(); + await refresh({ from: today, to: '2096-02-29' }); expect(upstream.getDividends).toHaveBeenCalledWith( 'synthetic-owner', shareUid, @@ -685,8 +702,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { }, ]); const range = { from: '2096-02-29', to: '2096-02-29' }; - await events(false, 'account', 'owner', range).expect(200); - await settled(); + await refresh(range); + jest.clearAllMocks(); const result = (await events(true, 'account', 'owner', range).expect(200)) .body as UpcomingEventsResourceDto; expect(result.data?.events).toHaveLength(1); @@ -694,5 +711,6 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { date: '2096-02-29', projectedAmount: { currency: 'RUB', value: '14' }, }); + expect(upstream.getDividends).not.toHaveBeenCalled(); }); });