diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 448e2e6..70166da 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -218,6 +218,18 @@ describe('persistent portfolio projection', () => { expect(snapshot.structure.currencies[0].totalValue).toBe('121.49'); }); + // Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response. + it('rejects a stored snapshot payload without the persisted shape', async () => { + repository.read.mockResolvedValue({ + membershipAt, + membershipRevision: 1, + accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, + portfolio: { payload: { positions: 'broken' }, fetchedAt: membershipAt, dataRevision: 1 } }], + work: [], + }); + await expect(service.read('user', 'all')).rejects.toThrow('Corrupted portfolio snapshot payload'); + }); + it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { const snapshot: PortfolioDto = { lastSuccessfulAt: membershipAt.toISOString(), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index b001098..43d5229 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -4,6 +4,8 @@ import { } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; +import type { Prisma } from '../../../../../generated/prisma/client'; + import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { @@ -26,6 +28,7 @@ import { sumMoneyByCurrency } from '../domain/portfolio-totals'; import { portfolioFreshForMs, PortfolioRepository, + portfolioSnapshot, portfolioWorkKey, } from '../infrastructure/portfolio.repository'; import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper'; @@ -88,18 +91,7 @@ export class PortfolioService { type: account.type, openedOn: account.openedOn, snapshot: account.portfolio - ? { - ...(account.portfolio.payload as unknown as PortfolioDto), - positions: (account.portfolio.payload as unknown as PortfolioDto).positions.map((position) => ({ - ...position, - sector: position.sector ?? null, - issuer: position.issuer ?? null, - })), - lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), - isStale: - Date.now() - account.portfolio.fetchedAt.valueOf() >= - portfolioFreshForMs, - } + ? savedSnapshot(account.portfolio) : null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, @@ -326,3 +318,17 @@ export class PortfolioService { return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt, classifications); } } + +function savedSnapshot(saved: { fetchedAt: Date; payload: Prisma.JsonValue }): PortfolioDto { + const snapshot = portfolioSnapshot(saved.payload); + return { + ...snapshot, + positions: snapshot.positions.map((position) => ({ + ...position, + sector: position.sector ?? null, + issuer: position.issuer ?? null, + })), + lastSuccessfulAt: saved.fetchedAt.toISOString(), + isStale: Date.now() - saved.fetchedAt.valueOf() >= portfolioFreshForMs, + }; +} diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 2d5cfd3..3be02a0 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -200,3 +200,16 @@ export class PortfolioRepository { export function portfolioWorkKey(accountId: string): string { return `portfolio:${JSON.stringify(accountId)}`; } + +// Json payload is written only by publish(); a shape check turns corruption into an explicit 500. +export function portfolioSnapshot(value: Prisma.JsonValue): PortfolioDto { + const snapshot = value as null | Partial; + if ( + !Array.isArray(snapshot?.positions) || + !Array.isArray(snapshot.totalsByCurrency) || + !Array.isArray(snapshot.cashBalances) + ) { + throw new Error('Corrupted portfolio snapshot payload'); + } + return snapshot as PortfolioDto; +} diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts new file mode 100644 index 0000000..bb938a4 --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts @@ -0,0 +1,14 @@ +jest.mock('../../../prisma/prisma.service', () => ({ + PrismaService: class {}, +})); + +import { screenIntent } from './tbank-sync.repository'; + +describe('screen sync intent', () => { + it('restores a persisted intent and rejects a corrupted one', () => { + const intent = { now: '2026-11-02T00:00:00.000Z', scope: { screen: 'accounts' } }; + expect(screenIntent(intent)).toEqual(intent); + expect(() => screenIntent({ source: 'hourly' })).toThrow('Corrupted screen sync intent'); + expect(() => screenIntent(null)).toThrow('Corrupted screen sync intent'); + }); +}); diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index dbeab81..8ccb815 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -8,6 +8,15 @@ import type { InvestmentScreenDto } from './tbank-sync.dto'; import { PrismaService } from '../../../prisma/prisma.service'; export type ScreenIntent = { now: string; scope: InvestmentScreenDto }; + +// Json columns are written only by this repository; a shape check turns corruption into an explicit 500. +export function screenIntent(value: Prisma.JsonValue): ScreenIntent { + const intent = value as null | Partial; + if (typeof intent?.now !== 'string' || typeof intent.scope?.screen !== 'string') { + throw new Error('Corrupted screen sync intent'); + } + return intent as ScreenIntent; +} export type ScreenProgress = { attempts?: Record; children: string[]; @@ -226,10 +235,8 @@ export class TbankSyncRepository { processRunId: runId, state: 'running', priority: 0, - intent: JSON.parse( - JSON.stringify({ source: 'hourly' }), - ) as Prisma.InputJsonObject, - progress: { sections: [] } as unknown as Prisma.InputJsonValue, + intent: { source: 'hourly' }, + progress: { sections: [] }, errorCode: null, }; const work = await tx.tbankSyncWork.upsert({ diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index bdc37a8..42c12a1 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -8,10 +8,7 @@ import { randomUUID } from 'node:crypto'; import { TbankAccessService } from '../../../integrations/tbank/tbank.api'; import { BrokerageOperationsSyncService } from '../operations/operations.api'; import { TbankSyncStatusDto } from './tbank-sync.dto'; -import { - type ScreenIntent, - TbankSyncRepository, -} from './tbank-sync.repository'; +import { screenIntent, TbankSyncRepository } from './tbank-sync.repository'; @Injectable() export class TbankSyncService { @@ -35,7 +32,7 @@ export class TbankSyncService { if (!saved.work.scopeKey.startsWith('screen:')) { return this.sourceStatus(userId, saved.work); } - const intent = saved.work.intent as unknown as ScreenIntent; + const intent = screenIntent(saved.work.intent); await this.access.local(userId, intent.scope.accountId); const children = await Promise.all( saved.children.map((child) =>