feat: reconcile hourly investment resource states

This commit is contained in:
2026-10-09 23:10:50 +03:00
committed by ksv741
parent 5d857de7a5
commit 323b83b582
25 changed files with 445 additions and 86 deletions
@@ -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],
@@ -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');
});
});
@@ -16,7 +16,7 @@ export class BrokerageAccountsService {
async list(userId: string): Promise<BrokerageAccountsResourceDto> {
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,
};