feat: add hourly portfolio sync history #49

Merged
codex-bot merged 4 commits from release/2.0.0 into develop 2026-10-09 09:57:20 +03:00
51 changed files with 1689 additions and 1885 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
- name: Audit runtime and development dependencies - name: Audit runtime and development dependencies
run: | run: |
npm audit --omit=dev --audit-level=low npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
verify: verify:
needs: [commits, security] needs: [commits, security]
+1 -12
View File
@@ -67,9 +67,6 @@
} }
}, },
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": { "src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
},
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
} }
@@ -150,9 +147,6 @@
} }
}, },
"src/modules/investments/portfolio/application/portfolio.service.ts": { "src/modules/investments/portfolio/application/portfolio.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
} }
@@ -170,11 +164,6 @@
"count": 7 "count": 7
} }
}, },
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"test/bank-cards-durability.cjs": { "test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
@@ -198,4 +187,4 @@
"count": 3 "count": 3
} }
} }
} }
+14 -14
View File
@@ -2163,13 +2163,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@istanbuljs/schema": { "node_modules/@istanbuljs/schema": {
"version": "0.1.6", "version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -6132,9 +6125,9 @@
} }
}, },
"node_modules/babel-plugin-istanbul": { "node_modules/babel-plugin-istanbul": {
"version": "8.0.0", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.2.tgz",
"integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "integrity": "sha512-bXWZeWQvvCZcD2uHLWVbitr4s1+YqILBbWB/szXKtHXt6crLlD6mqxHZ0qWy2X/Xx5RtQvqqW80JHAXMlp+y1Q==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"workspaces": [ "workspaces": [
@@ -9524,13 +9517,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/handlebars": { "node_modules/handlebars": {
"version": "4.7.9", "version": "4.7.10",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minimist": "^1.2.5", "minimist": "^1.2.8",
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"source-map": "^0.6.1", "source-map": "^0.6.1",
"wordwrap": "^1.0.0" "wordwrap": "^1.0.0"
@@ -13667,6 +13660,13 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/sql-escaper": { "node_modules/sql-escaper": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "TbankPortfolioValueHistory" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"deletedAt" DATETIME,
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY ("userId", "accountId"),
CONSTRAINT "TbankPortfolioValueHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TbankPortfolioValuePoint" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"currency" TEXT NOT NULL,
"value" TEXT NOT NULL,
"capturedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "accountId", "attemptId", "currency"),
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateIndex
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
+29
View File
@@ -17,6 +17,7 @@ model User {
bankCardOperations BankCardOperation[] bankCardOperations BankCardOperation[]
sessions Session[] sessions Session[]
tbankConnection TbankConnection? tbankConnection TbankConnection?
tbankValueHistory TbankPortfolioValueHistory[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -69,6 +70,34 @@ model TbankPortfolio {
@@id([userId, revision, accountId]) @@id([userId, revision, accountId])
} }
// Account valuation history survives connection replacement and stays hidden
// until the current connection confirms the account again.
model TbankPortfolioValueHistory {
userId String
accountId String
deletedAt DateTime?
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
points TbankPortfolioValuePoint[]
@@id([userId, accountId])
}
// One row per currency of a fully valued successful portfolio attempt.
model TbankPortfolioValuePoint {
userId String
accountId String
attemptId String
currency String
value String
capturedAt DateTime
createdAt DateTime @default(now())
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, accountId, attemptId, currency])
@@index([userId, accountId, capturedAt])
}
model TbankOperation { model TbankOperation {
userId String userId String
revision String revision String
@@ -12,7 +12,6 @@ import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TbankErrorCode } from './tbank-error-code'; import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceTtlMs } from './tbank-source.policy';
const FIRST_BATCH_OFFSET = 0; const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
@@ -320,11 +319,7 @@ function selectMetadata(
return { return {
data: usefulUid ? uid.data : (figi.data ?? uid.data), data: usefulUid ? uid.data : (figi.data ?? uid.data),
complete, complete,
fresh: fresh: complete,
complete &&
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt, fetchedAt,
dataRevision: used dataRevision: used
.map( .map(
@@ -121,6 +121,18 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
} }
async onlineToken(userId: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: { encryptedToken: true, invalidToken: true },
});
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
return this.tokens.decrypt(connection.encryptedToken);
}
async publishMembership( async publishMembership(
attempt: TbankSyncAttempt, attempt: TbankSyncAttempt,
accounts: TbankAccount[], accounts: TbankAccount[],
@@ -192,6 +204,17 @@ export class TbankAccessService {
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT }, membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
}, },
}); });
// Value history visibility follows the confirmed membership only; a
// failed fetch never reaches this transaction and changes nothing.
const supportedIds = supported.map(({ id }) => id);
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { in: supportedIds } },
data: { deletedAt: null },
});
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { notIn: supportedIds }, deletedAt: null },
data: { deletedAt: fetchedAt },
});
if (completeWork) { if (completeWork) {
await tx.tbankSyncWork.update({ await tx.tbankSyncWork.update({
where: { id: attempt.id }, where: { id: attempt.id },
@@ -3,7 +3,7 @@ import {
tbankSourceTtlMs, tbankSourceTtlMs,
} from './tbank-source.policy'; } from './tbank-source.policy';
describe('request-driven source freshness', () => { describe('mutable source freshness', () => {
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])( it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
'%s expires at its boundary without changing source time', '%s expires at its boundary without changing source time',
(source) => { (source) => {
@@ -1,15 +1,10 @@
// Freshness never establishes completeness. Immutable ranges are planned by // Freshness never establishes completeness. Immutable ranges are planned by
// their coverage owners; these TTLs apply only to mutable source snapshots. // their coverage owners; these TTLs apply only to mutable source snapshots.
export const tbankSourceTtlMs = { export const tbankSourceTtlMs = {
membership: 300_000, membership: 3_600_000,
portfolio: 60_000, portfolio: 3_600_000,
quote: 60_000, quote: 3_600_000,
candleTail: 60_000, candleTail: 3_600_000,
operationsTail: 300_000,
metadata: 86_400_000,
catalog: 86_400_000,
company: 86_400_000,
calendar: 86_400_000,
} as const; } as const;
export function tbankSourceNeedsRefresh( export function tbankSourceNeedsRefresh(
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
@@ -55,4 +56,12 @@ export class BrokerageAccountsResourceDto {
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
@ApiProperty({
type: HourlySyncStatusDto,
nullable: true,
description:
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
})
declare sync: HourlySyncStatusDto | null;
} }
@@ -1,11 +1,10 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -19,10 +18,7 @@ export class BrokerageAccountsController {
@Get() @Get()
@ApiOkResponse({ type: BrokerageAccountsResourceDto }) @ApiOkResponse({ type: BrokerageAccountsResourceDto })
list( list(@Req() request: Request & { user: AccessPayload }) {
@Req() request: Request & { user: AccessPayload }, return this.accounts.list(request.user.sub);
@Query() query: InvestmentReadQueryDto,
) {
return this.accounts.list(request.user.sub, query.sync === 'observe');
} }
} }
@@ -3,11 +3,12 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncModule } from '../tbank-sync/tbank-sync.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({ @Module({
imports: [AuthModule, TbankModule, PortfolioModule], imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncModule],
controllers: [BrokerageAccountsController], controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService], providers: [BrokerageAccountsService],
exports: [BrokerageAccountsService], exports: [BrokerageAccountsService],
@@ -19,16 +19,17 @@ const meta = investmentResourceMeta({
describe('saved brokerage account projection', () => { describe('saved brokerage account projection', () => {
const portfolio = { saved: jest.fn() }; const portfolio = { saved: jest.fn() };
const service = new BrokerageAccountsService(portfolio as never); const sync = { hourly: jest.fn().mockResolvedValue(null) };
const service = new BrokerageAccountsService(portfolio as never, sync as never);
it('distinguishes unknown membership from a confirmed empty list', async () => { it('distinguishes unknown membership from a confirmed empty list', async () => {
portfolio.saved.mockResolvedValueOnce({ portfolio.saved.mockResolvedValueOnce({
membership: { ...meta, availability: 'missing' }, membership: { ...meta, availability: 'missing' },
accounts: [], accounts: [],
}); });
expect((await service.list('owner', true)).data).toBeNull(); expect((await service.list('owner')).data).toBeNull();
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] }); portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
expect((await service.list('owner', true)).data).toEqual([]); expect((await service.list('owner')).data).toEqual([]);
}); });
it('keeps exact saved totals and independent missing summaries', async () => { it('keeps exact saved totals and independent missing summaries', async () => {
@@ -58,7 +59,7 @@ describe('saved brokerage account projection', () => {
}, },
], ],
}); });
const response = await service.list('owner', true); const response = await service.list('owner');
expect(response.data?.[0].portfolioSummary).toEqual({ expect(response.data?.[0].portfolioSummary).toEqual({
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
stockPositionCount: 1, stockPositionCount: 1,
@@ -3,14 +3,18 @@ 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 { BrokerageAccountDto } from './brokerage-account.dto'; import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
@Injectable() @Injectable()
export class BrokerageAccountsService { export class BrokerageAccountsService {
constructor(private readonly portfolio: PortfolioService) {} constructor(
private readonly portfolio: PortfolioService,
private readonly sync: TbankSyncRepository,
) {}
async list(userId: string, observe = false) { async list(userId: string): Promise<BrokerageAccountsResourceDto> {
const saved = await this.portfolio.saved(userId, 'all', observe); const saved = await this.portfolio.saved(userId, 'all');
const data: BrokerageAccountDto[] | null = const data: BrokerageAccountDto[] | null =
saved.membership.availability === 'missing' saved.membership.availability === 'missing'
? null ? null
@@ -31,6 +35,8 @@ export class BrokerageAccountsService {
} }
: null, : null,
})); }));
// The read-only result of the last hourly walk; never starts one.
const hourly = await this.sync.hourly(userId);
return { return {
data, data,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
@@ -55,12 +61,20 @@ export class BrokerageAccountsService {
fetchedAt: saved.membership.lastSuccessfulAt fetchedAt: saved.membership.lastSuccessfulAt
? new Date(saved.membership.lastSuccessfulAt) ? new Date(saved.membership.lastSuccessfulAt)
: null, : null,
freshForMs: 300_000, freshForMs: 3_600_000,
work: saved.membership.sync.id work: saved.membership.sync.id
? { ...saved.membership.sync, id: saved.membership.sync.id } ? { ...saved.membership.sync, id: saved.membership.sync.id }
: null, : null,
dependencies: saved.accounts.map((account) => account.meta), dependencies: saved.accounts.map((account) => account.meta),
}), }),
sync: hourly
? {
state: hourly.state,
startedAt: hourly.startedAt.toISOString(),
finishedAt: hourly.finishedAt?.toISOString() ?? null,
sections: hourly.sections,
}
: null,
}; };
} }
} }
@@ -9,8 +9,9 @@ import { StructuredLogger } from '../../../../common/logging/structured-logger.s
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond,
TbankClient, TbankClient,
tbankSourceNeedsRefresh, type TbankShare,
tbankSourceTtlMs, tbankSourceTtlMs,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
@@ -31,6 +32,7 @@ import {
const UNAMBIGUOUS_MATCH_COUNT = 1; const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0; const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0; const FIRST_RESULT_OFFSET = 0;
const MISSING_DATA_REVISION = '0';
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
@@ -73,13 +75,14 @@ export class CatalogDiscoveryService {
dataRevision: saved.dataRevision, dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete', availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt, fetchedAt: saved.fetchedAt,
freshForMs: tbankSourceTtlMs.catalog, freshForMs: Number.MAX_SAFE_INTEGER,
work: await this.repository.work(userId, connection.revision, input), work: await this.repository.work(userId, connection.revision, input),
}), }),
}; };
} }
async search(userId: string, input: CatalogQuery, observe = false) { async search(userId: string, input: CatalogQuery, observe = false) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input, observe); const discovery = await this.read(userId, input, observe);
const sources = discovery.data const sources = discovery.data
? await this.enrichment.readMany( ? await this.enrichment.readMany(
@@ -124,7 +127,7 @@ export class CatalogDiscoveryService {
fetchedAt: discovery.meta.lastSuccessfulAt fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt) ? new Date(discovery.meta.lastSuccessfulAt)
: null, : null,
freshForMs: tbankSourceTtlMs.catalog, freshForMs: Number.MAX_SAFE_INTEGER,
work: discovery.meta.sync.id work: discovery.meta.sync.id
? { ? {
id: discovery.meta.sync.id, id: discovery.meta.sync.id,
@@ -143,6 +146,25 @@ export class CatalogDiscoveryService {
uid: string, uid: string,
observe = false, observe = false,
) { ) {
if (!observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException();
return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
const sources = await this.enrichment.readMany( const sources = await this.enrichment.readMany(
userId, userId,
instrumentType, instrumentType,
@@ -182,7 +204,7 @@ export class CatalogDiscoveryService {
fetchedAt: source.meta.lastSuccessfulAt fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt) ? new Date(source.meta.lastSuccessfulAt)
: null, : null,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.portfolio,
work: source.meta.sync.id work: source.meta.sync.id
? { ? {
id: source.meta.sync.id, id: source.meta.sync.id,
@@ -195,12 +217,37 @@ export class CatalogDiscoveryService {
}; };
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
async byTicker( async byTicker(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
ticker: string, ticker: string,
observe = false, observe = false,
) { ) {
if (!observe) {
const query = ticker.trim().toUpperCase();
const token = await this.access.onlineToken(userId);
const found =
instrumentType === 'share'
? await this.tbank.findShares(token, query)
: await this.tbank.findBonds(token, query);
const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
? InstrumentErrorCode.StockTickerAmbiguous
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
}
const lookup = await this.read( const lookup = await this.read(
userId, userId,
{ instrumentType, query: ticker, exactTicker: true }, { instrumentType, query: ticker, exactTicker: true },
@@ -232,7 +279,7 @@ export class CatalogDiscoveryService {
fetchedAt: lookup.meta.lastSuccessfulAt fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt) ? new Date(lookup.meta.lastSuccessfulAt)
: null, : null,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.portfolio,
work: lookup.meta.sync.id work: lookup.meta.sync.id
? { ? {
id: lookup.meta.sync.id, id: lookup.meta.sync.id,
@@ -245,6 +292,51 @@ export class CatalogDiscoveryService {
}; };
} }
private async onlineSearch(userId: string, input: CatalogQuery) {
const token = await this.access.onlineToken(userId);
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
const instruments = selectCatalogInstruments(
input.exactTicker
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
: found,
input.query,
input.instrumentType,
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
const details = await Promise.all(
instruments.map((item) =>
input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid),
),
);
const prices = instruments.length
? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
: [];
const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => {
const detail = details.at(index);
return [{
...item,
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
}];
});
return {
data,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(input)}`,
dataRevision: `online:${fetchedAt.toISOString()}`,
availability: 'complete',
fetchedAt,
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
request( request(
userId: string, userId: string,
revision: string, revision: string,
@@ -255,10 +347,9 @@ export class CatalogDiscoveryService {
const reserved = this.reservations.then(async () => { const reserved = this.reservations.then(async () => {
const saved = await this.repository.read(input); const saved = await this.repository.read(input);
if ( if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) !force &&
) { saved.dataRevision !== MISSING_DATA_REVISION
return null; ) return null;
}
const reservation = await this.repository.reserve( const reservation = await this.repository.reserve(
{ userId, revision }, { userId, revision },
input, input,
@@ -404,3 +495,37 @@ export class CatalogDiscoveryService {
} }
} }
} }
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
@@ -154,7 +154,7 @@ export class CatalogEnrichmentService {
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION), dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
availability: row ? 'complete' : 'missing', availability: row ? 'complete' : 'missing',
fetchedAt: row?.fetchedAt ?? null, fetchedAt: row?.fetchedAt ?? null,
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], freshForMs: sourcePolicy(input.kind) === 'quote' ? tbankSourceTtlMs.quote : Number.MAX_SAFE_INTEGER,
work: byScope.get(catalogScope(input)) ?? null, work: byScope.get(catalogScope(input)) ?? null,
}), }),
}; };
@@ -186,14 +186,10 @@ export class CatalogEnrichmentService {
(entry) => (entry) =>
entry.instrumentUid === input.uid && entry.kind === input.kind, entry.instrumentUid === input.uid && entry.kind === input.kind,
); );
if ( const policy = sourcePolicy(input.kind);
!tbankSourceNeedsRefresh( const needsRefresh = force || !row?.fetchedAt ||
sourcePolicy(input.kind), (policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
row?.fetchedAt ?? null, if (!needsRefresh) {
new Date(),
force,
)
) {
continue; continue;
} }
const reserved = await this.repository.reserve( const reserved = await this.repository.reserve(
@@ -234,6 +230,7 @@ export class CatalogEnrichmentService {
return reservation; return reservation;
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy.
async section( async section(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
@@ -241,6 +238,34 @@ export class CatalogEnrichmentService {
section: 'company' | 'metrics' | 'quote', section: 'company' | 'metrics' | 'quote',
observe = false, observe = false,
) { ) {
if (section === 'company' && !observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException();
const [description, fundamentals] = instrument.assetUid
? await Promise.all([
this.tbank.getAssetDescription(token, instrument.assetUid),
this.tbank.getFundamentals(token, instrument.assetUid),
])
: [null, null];
const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/company/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: Number.MAX_SAFE_INTEGER,
work: null,
});
return {
values: { instrument: publicInstrument({ ...instrument, uid }), description, fundamentals },
meta,
unavailable: [],
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
};
}
const kinds: CatalogSource['kind'][] = const kinds: CatalogSource['kind'][] =
section === 'company' section === 'company'
? ['detail', 'description', 'fundamentals'] ? ['detail', 'description', 'fundamentals']
@@ -287,10 +312,7 @@ export class CatalogEnrichmentService {
fetchedAt: first.meta.lastSuccessfulAt fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt) ? new Date(first.meta.lastSuccessfulAt)
: null, : null,
freshForMs: freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
section === 'company'
? tbankSourceTtlMs.company
: tbankSourceTtlMs.quote,
work: null, work: null,
dependencies, dependencies,
}); });
@@ -308,6 +330,7 @@ export class CatalogEnrichmentService {
}; };
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
private async load( private async load(
input: CatalogSource, input: CatalogSource,
token: string, token: string,
@@ -323,7 +346,7 @@ export class CatalogEnrichmentService {
} }
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence. // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response'); if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument(instrument) : null }; return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
} }
if (input.kind === 'trading-status') { if (input.kind === 'trading-status') {
return { return {
@@ -470,6 +493,7 @@ export class CatalogEnrichmentService {
throw new Error('Unsupported catalog source'); throw new Error('Unsupported catalog source');
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
private async drain() { private async drain() {
try { try {
while (this.queue.length) { while (this.queue.length) {
@@ -600,39 +624,6 @@ function sourceInputs(
})), })),
); );
} }
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: (value as TbankShare).shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
const previousCloseLookbackDays = 20; const previousCloseLookbackDays = 20;
const previousCloseLookbackMs = const previousCloseLookbackMs =
previousCloseLookbackDays * MILLISECONDS_PER_DAY; previousCloseLookbackDays * MILLISECONDS_PER_DAY;
@@ -651,6 +642,40 @@ export type CatalogPayload = {
price?: null | TbankLastPrice; price?: null | TbankLastPrice;
tradingStatus?: null | string; tradingStatus?: null | string;
}; };
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
function sourcePolicy(kind: CatalogSource['kind']) { function sourcePolicy(kind: CatalogSource['kind']) {
if ( if (
[ [
@@ -663,9 +688,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
) { ) {
return 'quote'; return 'quote';
} }
if (kind === 'description' || kind === 'fundamentals') return 'company'; return 'stable';
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
return 'metadata';
} }
function producerKey(input: CatalogSource) { function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' && return input.instrumentType === 'bond' &&
@@ -71,8 +71,8 @@ export class BondIncomeService {
}; };
} }
async readPosition(userId: string, uid: string, observe = false) { async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all', observe); const saved = await this.portfolios.read(userId, 'all');
return { return {
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null, data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
@@ -88,7 +88,7 @@ export class BondIncomeService {
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
'bond', 'bond',
@@ -119,13 +119,8 @@ export class BondController {
position( position(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.readPosition( return this.income.readPosition(request.user.sub, uid);
request.user.sub,
uid,
query.sync === 'observe',
);
} }
@Get(':uid/coupons') @Get(':uid/coupons')
@@ -40,8 +40,8 @@ export class StockIncomeService {
private readonly forecasts: ForecastService, private readonly forecasts: ForecastService,
) {} ) {}
async readPosition(userId: string, uid: string, observe = false) { async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all', observe); const saved = await this.portfolios.read(userId, 'all');
return { return {
data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null, data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null,
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` }, meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
@@ -57,7 +57,7 @@ export class StockIncomeService {
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
'share', 'share',
@@ -111,13 +111,8 @@ export class StockController {
position( position(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.readPosition( return this.income.readPosition(request.user.sub, uid);
request.user.sub,
uid,
query.sync === 'observe',
);
} }
@Get(':uid/company') @Get(':uid/company')
@@ -135,50 +135,14 @@ export class BrokerageOperationsService {
? [{ accountId: source.accountId, from: source.from, to: source.to }] ? [{ accountId: source.accountId, from: source.from, to: source.to }]
: [], : [],
); );
if (query.sync !== 'observe') { // The ledger only starts the first-time load of missing intervals; the
const discovery = await this.membership.request( // mutable tail belongs to the hourly walk and membership is not rechecked.
userId, if (intervals.length) {
connection.revision, await this.sync.readPlan(userId, intervals, {
); movingEnd,
if (discovery) { history: true,
void discovery.done revision: connection.revision,
.then(async () => { });
const current = await this.access.local(userId, accountId);
if (current.revision !== connection.revision) return;
const currentRange = ledgerRange(
query.from,
query.to,
query.instrumentUid,
current.accounts.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
),
now,
);
const targets = current.accounts
.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)
.flatMap((account) =>
currentRange.from < currentRange.to
? [{ accountId: account.accountId, ...currentRange }]
: [],
);
await this.sync.readPlan(userId, targets, {
movingEnd,
history: true,
revision: connection.revision,
});
})
.catch(() => undefined);
} else if (intervals.length) {
await this.sync.readPlan(userId, intervals, {
movingEnd,
history: true,
revision: connection.revision,
});
}
} }
const saved = await this.repository.readPage(readInput); const saved = await this.repository.readPage(readInput);
const accounts = saved?.connection.accounts ?? []; const accounts = saved?.connection.accounts ?? [];
@@ -252,26 +216,10 @@ export class BrokerageOperationsService {
dataRevision: value.dataRevision, dataRevision: value.dataRevision,
availability: 'complete', availability: 'complete',
fetchedAt: value.fetchedAt, fetchedAt: value.fetchedAt,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.portfolio,
work: null, work: null,
}), }),
); );
if (query.sync !== 'observe') {
const identities = new Map(
rows.map((row) => {
const identity = row.payload as TbankOperationItem;
return [
metadataKey(identity),
{ instrumentUid: identity.instrumentUid, figi: identity.figi },
] as const;
}),
);
for (const identity of identities.values()) {
if (!instruments.get(metadataKey(identity))?.fresh) {
await this.metadata.request(userId, connection.revision, identity);
}
}
}
const membershipMeta = investmentResourceMeta({ const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
@@ -71,7 +71,7 @@ describe('operation completeness and freshness', () => {
const asOf = new Date('2026-09-25T12:00:00Z'); const asOf = new Date('2026-09-25T12:00:00Z');
const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf }; const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf };
it.each([1, 299, 300])( it.each([1, 3599, 3600])(
'preserves the observed complete end after %s seconds, separately from TTL', 'preserves the observed complete end after %s seconds, separately from TTL',
(seconds) => { (seconds) => {
const now = new Date(asOf.valueOf() + seconds * 1000); const now = new Date(asOf.valueOf() + seconds * 1000);
@@ -85,10 +85,10 @@ describe('operation completeness and freshness', () => {
availability: 'complete', availability: 'complete',
asOf, asOf,
to: asOf, to: asOf,
stale: seconds >= 300, stale: seconds >= 3600,
}); });
expect(plan.load).toEqual( expect(plan.load).toEqual(
seconds >= 300 ? [{ accountId: 'a', from, to: now }] : [], [],
); );
}, },
); );
@@ -128,7 +128,7 @@ export type OperationCoverage = OperationInterval & {
asOf: Date; asOf: Date;
fetchedAt: Date; fetchedAt: Date;
}; };
const operationsFreshForMs = 300_000; const operationsFreshForMs = 3_600_000;
export function planOperationRead( export function planOperationRead(
requested: OperationInterval[], requested: OperationInterval[],
@@ -188,8 +188,9 @@ export function planOperationRead(
.filter((row) => row.from < row.to), .filter((row) => row.from < row.to),
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })), fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
).length > EMPTY_INTERVAL_COUNT; ).length > EMPTY_INTERVAL_COUNT;
// The mutable tail is owned by the hourly walk; reads only load what is
// missing, never re-verify complete or fresh intervals.
if (options.force) load.push(target); if (options.force) load.push(target);
else if (stale) load.push({ ...target, from: monthStart, to: now });
else load.push(...missing); else load.push(...missing);
// Completeness is bounded by the observed end, never by the new wall clock. // Completeness is bounded by the observed end, never by the new wall clock.
return { return {
@@ -15,10 +15,7 @@ import {
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto'; import { AccountReferenceDto } from '../../account-reference.dto';
import { import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../../investment-resource.dto';
const MIN_OPERATIONS_PAGE_SIZE = 1; const MIN_OPERATIONS_PAGE_SIZE = 1;
@@ -36,7 +33,7 @@ export const operationCategories = [
] as const; ] as const;
export type OperationCategory = (typeof operationCategories)[number]; export type OperationCategory = (typeof operationCategories)[number];
export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto { export class BrokerageOperationsQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'uuid', format: 'uuid',
description: description:
@@ -40,6 +40,7 @@ describe('persistent portfolio projection', () => {
accounts: [], accounts: [],
work: [], work: [],
}); });
repository.publish.mockResolvedValue(true);
metadata.savedMany.mockResolvedValue(new Map()); metadata.savedMany.mockResolvedValue(new Map());
metadata.request.mockResolvedValue(null); metadata.request.mockResolvedValue(null);
}); });
@@ -95,7 +96,7 @@ describe('persistent portfolio projection', () => {
], ],
work: [], work: [],
}); });
await service.read('user', 'a'); await service.request('user', 'private', 'a');
for ( for (
let index = 0; let index = 0;
index < 10 && !repository.publish.mock.calls.length; index < 10 && !repository.publish.mock.calls.length;
@@ -189,10 +190,10 @@ describe('persistent portfolio projection', () => {
accounts: [account, { ...account, accountId: 'b', portfolio: null }], accounts: [account, { ...account, accountId: 'b', portfolio: null }],
work: [], work: [],
}); });
const partial = await service.read('user', 'all', true); const partial = await service.read('user', 'all');
expect(partial.data).toBeNull(); expect(partial.data).toBeNull();
expect(partial.meta.availability).toBe('partial'); expect(partial.meta.availability).toBe('partial');
const saved = await service.saved('user', 'all', true); const saved = await service.saved('user', 'all');
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual( expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
snapshot.totalsByCurrency, snapshot.totalsByCurrency,
); );
@@ -210,7 +211,7 @@ describe('persistent portfolio projection', () => {
], ],
work: [], work: [],
}); });
const complete = await service.read('user', 'all', true); const complete = await service.read('user', 'all');
expect(complete.data?.totalsByCurrency).toEqual([ expect(complete.data?.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '2.000000002' }, { currency: 'RUB', value: '2.000000002' },
]); ]);
@@ -8,7 +8,6 @@ import { StructuredLogger } from '../../../../common/logging/structured-logger.s
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
InstrumentMetadataService, InstrumentMetadataService,
type MetadataIdentity,
metadataKey, metadataKey,
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
@@ -47,47 +46,9 @@ export class PortfolioService {
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
) {} ) {}
async saved(userId: string, accountId: string, observe = false) { // 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 connection = await this.access.local(userId, accountId);
if (!observe) {
const discovery = await this.membership.request(
userId,
connection.revision,
);
if (discovery) {
void discovery.done
.then(async () => {
const current = await this.access.local(userId, accountId);
if (current.revision !== connection.revision) return;
for (const account of current.accounts.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)) {
await this.queueSnapshot(
userId,
current.revision,
account.accountId,
);
}
})
.catch(() =>
this.logger.event(
'warn',
'tbank.portfolio.discovery_not_available',
{ userId },
),
);
}
for (const account of (discovery ? [] : connection.accounts).filter(
(account) => accountId === 'all' || account.accountId === accountId,
)) {
await this.queueSnapshot(
userId,
connection.revision,
account.accountId,
);
}
}
const saved = await this.repository.read(userId, connection.revision); const saved = await this.repository.read(userId, connection.revision);
if ( if (
saved?.membershipAt && saved?.membershipAt &&
@@ -96,26 +57,6 @@ export class PortfolioService {
) { ) {
throw new NotFoundException(); throw new NotFoundException();
} }
if (!observe) {
const identities = new Map<string, MetadataIdentity>();
for (const account of (saved?.accounts ?? []).filter(
(account) => accountId === 'all' || account.accountId === accountId,
)) {
const snapshot = account.portfolio?.payload as unknown as
PortfolioDto | undefined;
for (const position of snapshot?.positions ?? []) {
identities.set(metadataKey(position), position);
}
}
const savedMetadata = await this.metadata.savedMany([
...identities.values(),
]);
for (const identity of identities.values()) {
if (!savedMetadata.get(metadataKey(identity))?.fresh) {
await this.metadata.request(userId, connection.revision, identity);
}
}
}
const membershipMeta = investmentResourceMeta({ const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION), dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
@@ -159,8 +100,8 @@ export class PortfolioService {
}; };
} }
async read(userId: string, accountId: string, observe = false) { async read(userId: string, accountId: string) {
const saved = await this.saved(userId, accountId, observe); const saved = await this.saved(userId, accountId);
const missing = const missing =
saved.membership.availability === 'missing' || saved.membership.availability === 'missing' ||
saved.accounts.some((account) => !account.snapshot); saved.accounts.some((account) => !account.snapshot);
@@ -176,7 +117,7 @@ export class PortfolioService {
dataRevision: source.dataRevision, dataRevision: source.dataRevision,
availability: 'complete', availability: 'complete',
fetchedAt: source.fetchedAt, fetchedAt: source.fetchedAt,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: portfolioFreshForMs,
work: null, work: null,
}), }),
] ]
@@ -69,17 +69,23 @@ export class PortfolioRepository {
const snapshot = await tx.tbankPortfolio.findUnique({ const snapshot = await tx.tbankPortfolio.findUnique({
where: { userId_revision_accountId: { userId, revision, accountId } }, where: { userId_revision_accountId: { userId, revision, accountId } },
}); });
const portfolioWorkScope = {
userId,
revision,
scopeKey: portfolioWorkKey(accountId),
};
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: portfolioWorkScope },
});
if ( if (
!force && !force &&
snapshot && snapshot &&
!tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) !tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) &&
previous?.state !== 'failed'
) { ) {
return null; return null;
} }
const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) }; const scope = portfolioWorkScope;
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope },
});
if ( if (
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === processRunId && previous.processRunId === processRunId &&
@@ -139,6 +145,41 @@ export class PortfolioRepository {
create: { ...scope, ...data }, create: { ...scope, ...data },
update: { ...data, dataRevision: { increment: 1 } }, update: { ...data, dataRevision: { increment: 1 } },
}); });
// Value history points require a fully valued snapshot; the attempt
// identity deduplicates repeated or late publications of one attempt.
if (
snapshot.totalsByCurrency.length > Number.MIN_VALUE &&
snapshot.positions.every((position) => position.currentValue !== null)
) {
await tx.tbankPortfolioValueHistory.upsert({
where: {
userId_accountId: { userId: attempt.userId, accountId },
},
create: { userId: attempt.userId, accountId },
update: { deletedAt: null },
});
for (const { currency, value } of snapshot.totalsByCurrency) {
await tx.tbankPortfolioValuePoint.upsert({
where: {
userId_accountId_attemptId_currency: {
userId: attempt.userId,
accountId,
attemptId: attempt.attemptId,
currency,
},
},
create: {
userId: attempt.userId,
accountId,
attemptId: attempt.attemptId,
currency,
value,
capturedAt: data.fetchedAt,
},
update: {},
});
}
}
return true; return true;
}); });
} }
@@ -1,11 +1,10 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import { InvestmentReadQueryDto } from '../../investment-resource.dto';
import { PortfolioService } from '../application/portfolio.service'; import { PortfolioService } from '../application/portfolio.service';
import { PortfolioResourceDto } from './portfolio.dto'; import { PortfolioResourceDto } from './portfolio.dto';
@@ -19,15 +18,8 @@ export class PortfolioController {
@Get('all/portfolio') @Get('all/portfolio')
@ApiOkResponse({ type: PortfolioResourceDto }) @ApiOkResponse({ type: PortfolioResourceDto })
all( all(@Req() request: Request & { user: AccessPayload }) {
@Req() request: Request & { user: AccessPayload }, return this.portfolio.read(request.user.sub, 'all');
@Query() query: InvestmentReadQueryDto,
) {
return this.portfolio.read(
request.user.sub,
'all',
query.sync === 'observe',
);
} }
@Get(':accountId/portfolio') @Get(':accountId/portfolio')
@@ -35,12 +27,7 @@ export class PortfolioController {
get( get(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.portfolio.read( return this.portfolio.read(request.user.sub, accountId);
request.user.sub,
accountId,
query.sync === 'observe',
);
} }
} }
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { TbankSyncModule } from '../tbank-sync/tbank-sync.module';
import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionController } from './tbank-connection.controller';
import { TbankConnectionService } from './tbank-connection.service'; import { TbankConnectionService } from './tbank-connection.service';
@Module({ @Module({
imports: [AuthModule, TbankModule], imports: [AuthModule, TbankModule, TbankSyncModule],
controllers: [TbankConnectionController], controllers: [TbankConnectionController],
providers: [TbankConnectionService], providers: [TbankConnectionService],
}) })
@@ -35,11 +35,22 @@ export class TbankConnectionService {
this.tbank.validateToken(token), this.tbank.validateToken(token),
); );
const encryptedToken = this.tokens.encrypt(token); const encryptedToken = this.tokens.encrypt(token);
const confirmed = supportedTbankAccounts(accounts).map(({ id }) => id);
const connection = await this.prisma.$transaction(async (tx) => { const connection = await this.prisma.$transaction(async (tx) => {
// Cascade old private data and attempts in the same transaction that // Cascade old private data and attempts in the same transaction that
// publishes the validated membership and the new connection revision. // publishes the validated membership and the new connection revision.
await tx.tbankConnection.deleteMany({ where: { userId } }); await tx.tbankConnection.deleteMany({ where: { userId } });
const now = new Date(); const now = new Date();
// Value history survives token replacement but is visible only for
// accounts confirmed by the new token.
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { in: confirmed } },
data: { deletedAt: null },
});
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { notIn: confirmed }, deletedAt: null },
data: { deletedAt: now },
});
return tx.tbankConnection.create({ return tx.tbankConnection.create({
data: { data: {
userId, userId,
@@ -66,6 +77,14 @@ export class TbankConnectionService {
} }
async delete(userId: string): Promise<void> { async delete(userId: string): Promise<void> {
await this.prisma.tbankConnection.deleteMany({ where: { userId } }); await this.prisma.$transaction(async (tx) => {
await tx.tbankConnection.deleteMany({ where: { userId } });
// History stays stored but hidden until a new connection reconfirms
// the same account.
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, deletedAt: null },
data: { deletedAt: new Date() },
});
});
} }
} }
@@ -0,0 +1,226 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
TbankMembershipService,
type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../prisma/prisma.service';
import { BrokerageOperationsSyncService } from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events.service';
import {
type HourlySection,
TbankSyncRepository,
} from './tbank-sync.repository';
const HOUR_MS = 3_600_000;
const TICK_MS = 60_000;
const WORK_POLL_MS = 100;
const FIRST_DAY_OF_MONTH = 1;
const ONE_ATTEMPT = 1;
const NO_ATTEMPTS = 0;
type SettledWork = { errorCode: null | string; id: string; state: string };
// One native timer per process. The timer only proposes work; the durable
// reservation in TbankSyncRepository is the fence that keeps a single
// attempt per connection. The deployment runs a single backend process.
// ponytail: no inter-process lease; add one before scaling past one process.
@Injectable()
export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
private readonly processRunId = randomUUID();
private timer: NodeJS.Timeout | null = null;
private ticking = false;
constructor(
private readonly prisma: PrismaService,
private readonly repository: TbankSyncRepository,
private readonly access: TbankAccessService,
private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService,
private readonly events: UpcomingEventsService,
) {}
onModuleInit() {
this.timer = setInterval(() => void this.tick(), TICK_MS);
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
async tick() {
if (this.ticking) return;
this.ticking = true;
try {
const connections = await this.prisma.tbankConnection.findMany({
where: { invalidToken: false },
select: { userId: true },
});
for (const { userId } of connections) {
await this.launch(userId);
}
} catch {
// A failed tick only delays the proposal until the next tick.
} finally {
this.ticking = false;
}
}
// A fresh connection starts its first walk immediately, regardless of the
// previous term of the replaced connection.
private async launch(userId: string) {
const reserved = await this.repository.reserveHourly(
userId,
this.processRunId,
HOUR_MS,
);
if (!reserved) return null;
const { userId: owner, revision, id, attemptId } = reserved.work;
setImmediate(() => {
void this.walk({ userId: owner, revision, id, attemptId });
});
return reserved.work;
}
// One membership pass, then the owners refresh their own datasets. The
// walk publishes only safe section results and holds no transaction or
// upstream call inside one.
private async walk(attempt: TbankSyncAttempt) {
const failures: HourlySection[] = [];
const attempted = { count: NO_ATTEMPTS };
try {
const { userId, revision } = attempt;
await this.refreshMembership(userId, revision, failures, attempted);
if (failures.some((item) => item.errorCode === TbankErrorCode.InvalidToken)) {
return void this.settle(attempt, failures, attempted.count);
}
const current = await this.access.local(userId);
if (current.revision !== revision) {
return void this.settle(attempt, failures, attempted.count);
}
await this.refreshAccounts(userId, revision, current.accounts, failures, attempted);
await this.settle(attempt, failures, attempted.count);
} catch {
await this.settle(attempt, failures, attempted.count);
}
}
private async refreshMembership(
userId: string,
revision: string,
failures: HourlySection[],
attempted: { count: number },
) {
const member = await this.membership.request(userId, revision, true);
attempted.count += ONE_ATTEMPT;
if (!member) return;
await member.done;
const errorCode = await this.repository.workError(userId, member.id);
if (errorCode) failures.push({ section: 'membership', errorCode });
}
private async refreshAccounts(
userId: string,
revision: string,
accounts: Awaited<ReturnType<TbankAccessService['local']>>['accounts'],
failures: HourlySection[],
attempted: { count: number },
) {
for (const account of accounts) {
await this.refreshPortfolio(userId, revision, account.accountId, failures, attempted);
}
for (const account of accounts) {
await this.refreshOperations(userId, revision, account.accountId, failures, attempted);
}
for (const account of accounts) {
await this.refreshEvents(userId, account.accountId, failures, attempted);
}
}
private async refreshPortfolio(userId: string, revision: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
const work = await this.portfolio.request(userId, revision, accountId, true);
attempted.count += ONE_ATTEMPT;
if (!work) return;
await work.done;
const errorCode = await this.repository.workError(userId, work.id);
if (errorCode) failures.push({ section: 'portfolio', errorCode, accountId });
}
private async refreshOperations(userId: string, revision: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
attempted.count += ONE_ATTEMPT;
try {
const id = await this.operations.request(userId, [{ accountId, from: monthStartOf(new Date()), to: new Date() }], true, 'screen', revision);
if (!id) return;
const errorCode = await this.settledWorkError(userId, [id]);
if (errorCode) failures.push({ section: 'operations', errorCode, accountId });
} catch {
failures.push({ section: 'operations', errorCode: TbankErrorCode.Unavailable, accountId });
}
}
private async refreshEvents(userId: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
attempted.count += ONE_ATTEMPT;
try {
const ids = await this.events.refresh(userId, accountId);
const failed = ids.length ? (await this.settledWorks(userId, ids)).find((row) => row.state === 'failed') : undefined;
if (failed?.errorCode) failures.push({ section: 'events', errorCode: failed.errorCode, accountId });
else await this.events.get(userId, accountId);
} catch {
failures.push({ section: 'events', errorCode: TbankErrorCode.Unavailable, accountId });
}
}
private async settle(
attempt: TbankSyncAttempt,
failures: HourlySection[],
attempted: number,
) {
let result: 'failed' | 'partial' | 'succeeded' = 'succeeded';
if (failures.length) {
result = attempted > NO_ATTEMPTS && failures.length >= attempted ? 'failed' : 'partial';
}
await this.repository
.settleHourly(attempt, result, failures)
.catch(() => undefined);
}
private async settledWorkError(userId: string, ids: string[]) {
const rows = await this.settledWorks(userId, ids);
return rows.find((row) => row.state === 'failed')?.errorCode ?? null;
}
// Polls the durable work rows until they leave the running state; a row
// deleted with its connection counts as interrupted, not failed.
private async settledWorks(userId: string, ids: string[]) {
const settled = new Map<string, SettledWork>();
const pending = new Set(ids);
while (pending.size) {
const rows = await this.repository.works(userId, [...pending]);
for (const row of rows) {
if (row.state !== 'running') {
settled.set(row.id, row);
pending.delete(row.id);
}
}
for (const id of [...pending]) {
if (!rows.some((row) => row.id === id)) {
settled.set(id, { id, state: 'cancelled', errorCode: null });
pending.delete(id);
}
}
if (pending.size) await new Promise((resolve) => setTimeout(resolve, WORK_POLL_MS));
}
return [...settled.values()];
}
}
function monthStartOf(now: Date) {
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), FIRST_DAY_OF_MONTH),
);
}
@@ -1,22 +1,8 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Req,
UseGuards,
ValidationPipe,
} from '@nestjs/common';
import {
ApiAcceptedResponse,
ApiBearerAuth, ApiBearerAuth,
ApiBody,
ApiExtraModels, ApiExtraModels,
ApiOkResponse, ApiOkResponse,
ApiTags, ApiTags,
@@ -27,9 +13,6 @@ import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { import {
EventsInvestmentScreenDto, EventsInvestmentScreenDto,
IncomeInvestmentScreenDto, IncomeInvestmentScreenDto,
InvestmentScreenDto,
investmentScreenSchema,
TbankSyncAcceptedDto,
TbankSyncStatusDto, TbankSyncStatusDto,
TimestampInvestmentScreenDto, TimestampInvestmentScreenDto,
} from './tbank-sync.dto'; } from './tbank-sync.dto';
@@ -37,9 +20,10 @@ import { TbankSyncService } from './tbank-sync.service';
@ApiTags('tbank-sync') @ApiTags('tbank-sync')
@ApiExtraModels( @ApiExtraModels(
IncomeInvestmentScreenDto,
EventsInvestmentScreenDto, EventsInvestmentScreenDto,
IncomeInvestmentScreenDto,
TimestampInvestmentScreenDto, TimestampInvestmentScreenDto,
TbankSyncStatusDto,
) )
@ApiProblemResponses() @ApiProblemResponses()
@ApiBearerAuth() @ApiBearerAuth()
@@ -47,28 +31,6 @@ import { TbankSyncService } from './tbank-sync.service';
@Controller('tbank-sync') @Controller('tbank-sync')
export class TbankSyncController { export class TbankSyncController {
constructor(private readonly sync: TbankSyncService) {} constructor(private readonly sync: TbankSyncService) {}
@Post()
@HttpCode(HttpStatus.ACCEPTED)
@ApiBody({ schema: investmentScreenSchema })
@ApiAcceptedResponse({ type: TbankSyncAcceptedDto })
request(
@Req() request: Request & { user: AccessPayload },
@Body(
new ValidationPipe({
forbidUnknownValues: true,
expectedType: InvestmentScreenDto,
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
validationError: { target: false, value: false },
exceptionFactory: (message) => new BadRequestException({ message }),
}),
)
input: unknown,
) {
return this.sync.request(request.user.sub, input as InvestmentScreenDto);
}
@Get(':id') @Get(':id')
@ApiOkResponse({ type: TbankSyncStatusDto }) @ApiOkResponse({ type: TbankSyncStatusDto })
status( status(
@@ -227,3 +227,46 @@ export class TbankSyncStatusDto {
@ApiProperty({ type: String, nullable: true }) declare errorCode: @ApiProperty({ type: String, nullable: true }) declare errorCode:
null | string; null | string;
} }
export class HourlySyncSectionDto {
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
@ApiPropertyOptional({
type: String,
nullable: false,
description: 'Account identifier of the problematic section, when scoped.',
})
declare accountId?: string;
@ApiProperty({
type: String,
nullable: true,
description:
'Safe error code of the failed section. No tokens or upstream payloads.',
})
declare errorCode: null | string;
}
export class HourlySyncStatusDto {
@ApiProperty({
enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'],
description:
'Last hourly walk state. Cancelled means an interrupted attempt; its start still delays the next term.',
})
declare state:
| 'cancelled' |
'failed' |
'partial' |
'running' |
'succeeded';
@ApiProperty({ format: 'date-time' })
declare startedAt: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare finishedAt: null | string;
@ApiProperty({ type: HourlySyncSectionDto, isArray: true })
declare sections: HourlySyncSectionDto[];
}
@@ -5,6 +5,8 @@ import { AuthModule } from '../../auth/auth.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { OperationsModule } from '../operations/operations.module'; import { OperationsModule } from '../operations/operations.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
import { TbankHourlyScheduler } from './tbank-hourly.scheduler';
import { TbankSyncController } from './tbank-sync.controller'; import { TbankSyncController } from './tbank-sync.controller';
import { TbankSyncRepository } from './tbank-sync.repository'; import { TbankSyncRepository } from './tbank-sync.repository';
import { TbankSyncService } from './tbank-sync.service'; import { TbankSyncService } from './tbank-sync.service';
@@ -15,8 +17,10 @@ import { TbankSyncService } from './tbank-sync.service';
PortfolioModule, PortfolioModule,
OperationsModule, OperationsModule,
InstrumentSourcesModule, InstrumentSourcesModule,
UpcomingEventsModule,
], ],
controllers: [TbankSyncController], controllers: [TbankSyncController],
providers: [TbankSyncService, TbankSyncRepository], providers: [TbankSyncService, TbankSyncRepository, TbankHourlyScheduler],
exports: [TbankHourlyScheduler, TbankSyncRepository],
}) })
export class TbankSyncModule {} export class TbankSyncModule {}
@@ -12,6 +12,15 @@ export type ScreenProgress = {
children: string[]; children: string[];
dispatched: boolean; dispatched: boolean;
}; };
export type HourlySection = {
accountId?: string;
errorCode: null | string;
section: 'events' | 'membership' | 'operations' | 'portfolio';
};
export type HourlyProgress = {
result?: 'failed' | 'partial' | 'succeeded';
sections: HourlySection[];
};
@Injectable() @Injectable()
export class TbankSyncRepository { export class TbankSyncRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -123,4 +132,122 @@ export class TbankSyncRepository {
}); });
}); });
} }
// The timer only proposes work; this reservation is the durable fence. The
// attempt start (createdAt) is the basis of the next hourly term, including
// failed and interrupted attempts.
reserveHourly(userId: string, runId: string, hourMs: number) {
return this.prisma.$transaction(async (tx) => {
const connection = await tx.tbankConnection.findFirst({
where: { userId, invalidToken: false },
select: { revision: true },
});
if (!connection) return null;
const key = {
userId,
revision: connection.revision,
scopeKey: 'hourly',
};
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: key },
});
const now = new Date();
if (previous) {
const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs;
if (previous.state === 'running') {
if (previous.processRunId === runId) return null;
// A dead process cannot finish its attempt; interrupt it, but its
// start stays the basis of the next term.
await tx.tbankSyncWork.update({
where: { userId_revision_scopeKey: key },
data: { state: 'cancelled', errorCode: null },
});
if (!due) return null;
} else if (!due) return null;
}
const data = {
attemptId: randomUUID(),
processRunId: runId,
state: 'running',
priority: 0,
intent: JSON.parse(
JSON.stringify({ source: 'hourly' }),
) as Prisma.InputJsonObject,
progress: { sections: [] } as unknown as Prisma.InputJsonValue,
errorCode: null,
};
const work = await tx.tbankSyncWork.upsert({
where: { userId_revision_scopeKey: key },
create: { ...key, ...data, id: randomUUID() },
update: { ...data, createdAt: now },
});
return { work, launch: true };
});
}
settleHourly(
attempt: TbankSyncAttempt,
result: 'failed' | 'partial' | 'succeeded',
sections: HourlySection[],
) {
return this.prisma.tbankSyncWork.updateMany({
where: {
id: attempt.id,
attemptId: attempt.attemptId,
userId: attempt.userId,
revision: attempt.revision,
state: 'running',
},
data: {
state: result === 'failed' ? 'failed' : 'complete',
errorCode:
sections.find((section) => section.errorCode)?.errorCode ?? null,
progress: { result, sections },
},
});
}
async hourly(userId: string) {
const connection = await this.prisma.tbankConnection.findFirst({
where: { userId },
select: { revision: true, invalidToken: true },
});
if (!connection || connection.invalidToken) return null;
const work = await this.prisma.tbankSyncWork.findUnique({
where: {
userId_revision_scopeKey: {
userId,
revision: connection.revision,
scopeKey: 'hourly',
},
},
});
if (!work) return null;
const progress = work.progress as HourlyProgress;
let state: 'cancelled' | 'running' | HourlyProgress['result'];
if (work.state === 'running') state = 'running';
else if (work.state === 'cancelled') state = 'cancelled';
else state = progress.result ?? 'failed';
return {
startedAt: work.createdAt,
finishedAt: work.state === 'running' ? null : work.updatedAt,
state,
sections: progress.sections ?? [],
};
}
works(userId: string, ids: string[]) {
return this.prisma.tbankSyncWork.findMany({
where: { userId, id: { in: ids } },
select: { id: true, state: true, errorCode: true },
});
}
async workError(userId: string, id: string) {
const work = await this.prisma.tbankSyncWork.findFirst({
where: { userId, id },
select: { state: true, errorCode: true },
});
return work?.state === 'failed' ? work.errorCode : null;
}
} }
@@ -119,7 +119,6 @@ export class TbankSyncService {
scope.screen === 'stock' || scope.screen === 'bond' scope.screen === 'stock' || scope.screen === 'bond'
? 'all' ? 'all'
: accountScope, : accountScope,
true,
); );
const positions = saved.data?.positions ?? []; const positions = saved.data?.positions ?? [];
const instruments = [ const instruments = [
@@ -45,64 +45,24 @@ export class UpcomingEventsService {
private readonly metadata: InstrumentMetadataService, private readonly metadata: InstrumentMetadataService,
) {} ) {}
// Read endpoints stay local; callers without a saved portfolio receive unknown.
async get( async get(
userId: string, userId: string,
accountId: string, accountId: string,
query: UpcomingEventsQueryDto = {}, query: UpcomingEventsQueryDto = {},
) { ): Promise<Awaited<ReturnType<ForecastService['result']>>> {
const window = dateWindow(query); const window = dateWindow(query);
const connection = await this.access.local(userId, accountId); const connection = await this.access.local(userId, accountId);
const portfolio = await this.portfolios.read( const portfolio = await this.portfolios.read(userId, accountId);
userId,
accountId,
query.sync === 'observe',
);
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
[];
const events: UpcomingEventDto[] = []; const events: UpcomingEventDto[] = [];
let ready = portfolio.data !== null; let ready = portfolio.data !== null;
const positions = portfolio.data?.positions.filter(supported) ?? []; const positions = portfolio.data?.positions.filter(supported) ?? [];
const metadata = await this.metadata.savedMany(positions); const { sources, metadata } = await this.readSources(
for (const type of ['share', 'bond'] as const) { userId,
const identities = positions positions,
.filter( window,
(position) => position.type === (type === 'share' ? 'STOCK' : 'BOND'), query.sync === 'observe',
) );
.map((position) => ({
uid:
position.instrumentUid ||
metadata.get(metadataKey(position))?.data?.uid,
ticker:
position.ticker ||
metadata.get(metadataKey(position))?.data?.ticker ||
'',
}))
.filter((entry): entry is { ticker: string; uid: string } =>
Boolean(entry.uid),
);
const uids = [...new Set(identities.map((entry) => entry.uid))];
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += calendarReadBatchSize
) {
sources.push(
...(await this.sources.readMany(
userId,
type,
uids.slice(offset, offset + calendarReadBatchSize),
query.sync === 'observe',
['detail', type === 'share' ? 'dividends' : 'bond-events'],
{
...window,
tickers: Object.fromEntries(
identities.map((entry) => [entry.uid, entry.ticker]),
),
},
)),
);
}
}
for (const position of positions) { for (const position of positions) {
const generic = metadata.get(metadataKey(position))?.data; const generic = metadata.get(metadataKey(position))?.data;
const uid = position.instrumentUid || generic?.uid; const uid = position.instrumentUid || generic?.uid;
@@ -205,6 +165,85 @@ export class UpcomingEventsService {
getAll(userId: string, query: UpcomingEventsQueryDto = {}) { getAll(userId: string, query: UpcomingEventsQueryDto = {}) {
return this.get(userId, 'all', query); return this.get(userId, 'all', query);
} }
// 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<string[]> {
const window = dateWindow({});
await this.access.local(userId, accountId);
const portfolio = await this.portfolios.read(userId, accountId);
const positions = portfolio.data?.positions.filter(supported) ?? [];
const { launched } = await this.readSources(
userId,
positions,
window,
false,
);
return launched;
}
private async readSources(
userId: string,
positions: Position[],
window: { from: string; to: string },
observe: boolean,
) {
const metadata = await this.metadata.savedMany(positions);
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
[];
const launched: string[] = [];
for (const type of ['share', 'bond'] as const) {
const identities = this.positionIdentities(positions, metadata, type);
const uids = [...new Set(identities.map((entry) => entry.uid))];
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += calendarReadBatchSize
) {
const batch = await this.sources.readMany(
userId,
type,
uids.slice(offset, offset + calendarReadBatchSize),
observe,
['detail', type === 'share' ? 'dividends' : 'bond-events'],
{
...window,
tickers: Object.fromEntries(
identities.map((entry) => [entry.uid, entry.ticker]),
),
},
);
sources.push(...batch);
for (const source of batch) {
if (source.meta.sync.state === 'running' && source.meta.sync.id) {
launched.push(source.meta.sync.id);
}
}
}
}
return { sources, metadata, launched };
}
private positionIdentities(
positions: Position[],
metadata: Awaited<ReturnType<InstrumentMetadataService['savedMany']>>,
type: 'bond' | 'share',
) {
return positions
.filter((position) => position.type === (type === 'share' ? 'STOCK' : 'BOND'))
.map((position) => ({
uid:
position.instrumentUid ||
metadata.get(metadataKey(position))?.data?.uid,
ticker:
position.ticker ||
metadata.get(metadataKey(position))?.data?.ticker ||
'',
}))
.filter((entry): entry is { ticker: string; uid: string } =>
Boolean(entry.uid),
);
}
} }
function supported(position: Position): boolean { function supported(position: Position): boolean {
+8 -1
View File
@@ -125,7 +125,14 @@ function tbankSnapshot() {
const owner = auth.user.id; const owner = auth.user.id;
await stop(); await stop();
const db = new Database(file); const db = new Database(file);
db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner, 'fictional-encrypted-token-never-used', new Date().toISOString()); const columns = db.prepare('PRAGMA table_info("TbankConnection")').all().map(({ name }) => name);
const fields = ['userId', 'encryptedToken', 'connectedAt'];
const values = [owner, 'fictional-encrypted-token-never-used', new Date().toISOString()];
if (columns.includes('revision')) {
fields.push('revision');
values.push(randomUUID());
}
db.prepare(`INSERT INTO TbankConnection (${fields.join(',')}) VALUES (${fields.map(() => '?').join(',')})`).run(...values);
await db.backup(join(dir, 'before-migration.db')); await db.backup(join(dir, 'before-migration.db'));
db.close(); db.close();
const beforeSessions = rows('Session'), beforeUsers = rows('User'); const beforeSessions = rows('Session'), beforeUsers = rows('User');
+2 -26
View File
@@ -27,32 +27,8 @@ describe('OpenAPI contract (integration)', () => {
it('distinguishes calendar screen dates from operation instants in requests and responses', () => { it('distinguishes calendar screen dates from operation instants in requests and responses', () => {
const document = createOpenApiDocument(app); const document = createOpenApiDocument(app);
const schemas = document.components?.schemas; const schemas = document.components?.schemas;
const screenSchema = { expect(document.paths['/api/v1/tbank-sync']).toBeUndefined();
oneOf: [ expect(schemas?.TbankSyncStatusDto).toBeDefined();
{ $ref: '#/components/schemas/IncomeInvestmentScreenDto' },
{ $ref: '#/components/schemas/EventsInvestmentScreenDto' },
{ $ref: '#/components/schemas/TimestampInvestmentScreenDto' },
],
discriminator: {
propertyName: 'screen',
mapping: {
income: '#/components/schemas/IncomeInvestmentScreenDto',
events: '#/components/schemas/EventsInvestmentScreenDto',
operations: '#/components/schemas/TimestampInvestmentScreenDto',
},
},
};
expect(
document.paths['/api/v1/tbank-sync'].post?.requestBody,
).toMatchObject({
content: { 'application/json': { schema: screenSchema } },
});
expect(schemas?.TbankSyncAcceptedDto).toMatchObject({
properties: { scope: screenSchema },
});
expect(schemas?.TbankSyncStatusDto).toMatchObject({
properties: { scope: { ...screenSchema, nullable: true } },
});
for (const [model, screen] of [ for (const [model, screen] of [
['IncomeInvestmentScreenDto', 'income'], ['IncomeInvestmentScreenDto', 'income'],
['EventsInvestmentScreenDto', 'events'], ['EventsInvestmentScreenDto', 'events'],
+34 -16
View File
@@ -3,16 +3,21 @@ import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { setTimeout as delay } from 'node:timers/promises';
import request from 'supertest'; import request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app.module';
import { TbankMembershipService } from '../src/integrations/tbank/tbank-membership.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main'; import { configureApp } from '../src/main';
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
describe('Portfolio structure HTTP contract', () => { describe('Portfolio structure HTTP contract', () => {
let app: INestApplication<Server>; let app: INestApplication<Server>;
let prisma: PrismaService;
let membership: TbankMembershipService;
let portfolios: PortfolioService;
const tbank = { const tbank = {
validateToken: jest.fn().mockResolvedValue([ validateToken: jest.fn().mockResolvedValue([
{ id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' }, { id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' },
@@ -22,6 +27,10 @@ describe('Portfolio structure HTTP contract', () => {
getPositions: jest.fn(), getPositions: jest.fn(),
getInstrumentByFigi: jest.fn().mockResolvedValue(null), getInstrumentByFigi: jest.fn().mockResolvedValue(null),
getInstrumentByUid: jest.fn().mockResolvedValue(null), getInstrumentByUid: jest.fn().mockResolvedValue(null),
getOpenAccounts: jest.fn().mockResolvedValue([
{ id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' },
{ id: 'b', name: 'B', type: 'ACCOUNT_TYPE_TINKOFF' },
]),
}; };
const token = new JwtService().sign( const token = new JwtService().sign(
{ sub: 'structure-user', type: 'access' }, { sub: 'structure-user', type: 'access' },
@@ -32,11 +41,16 @@ describe('Portfolio structure HTTP contract', () => {
const module = await Test.createTestingModule({ imports: [AppModule] }) const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient) .overrideProvider(TbankClient)
.useValue(tbank) .useValue(tbank)
.overrideProvider(TbankHourlyScheduler)
.useValue({ connected: jest.fn().mockResolvedValue(undefined) })
.compile(); .compile();
app = module.createNestApplication(); app = module.createNestApplication();
configureApp(app); configureApp(app);
await app.init(); await app.init();
await app.get(PrismaService).user.create({ prisma = app.get(PrismaService);
membership = app.get(TbankMembershipService);
portfolios = app.get(PortfolioService);
await prisma.user.create({
data: { data: {
id: 'structure-user', id: 'structure-user',
email: 'structure@example.invalid', email: 'structure@example.invalid',
@@ -65,21 +79,25 @@ describe('Portfolio structure HTTP contract', () => {
}); });
afterAll(() => app.close()); afterAll(() => app.close());
async function load(id: string) { async function load(id: string) {
await request(app.getHttpServer()) const connection = await prisma.tbankConnection.findUniqueOrThrow({
.get(`/api/v1/brokerage-accounts/${id}/portfolio`) where: { userId: 'structure-user' },
.set('Authorization', `Bearer ${token}`) });
.expect(200); const member = await membership.request(
for (let index = 0; index < 200; index += 1) { 'structure-user',
if ( connection.revision,
!(await app true,
.get(PrismaService) );
.tbankSyncWork.count({ where: { state: 'running' } })) if (member) await member.done;
) { const accountIds = id === 'all' ? ['a', 'b'] : [id];
return; for (const accountId of accountIds) {
} const work = await portfolios.request(
await delay(5); 'structure-user',
connection.revision,
accountId,
true,
);
if (work) await work.done;
} }
throw new Error('Portfolio did not settle');
} }
it.each(['a', 'all'])( it.each(['a', 'all'])(
+3 -6
View File
@@ -297,7 +297,7 @@ describe('shared catalog discovery (SQLite)', () => {
instrument, instrument,
]); ]);
}); });
it('refreshes at the daily boundary while stale observe and fresh reads perform zero RPC', async () => { it('keeps known discovery stable between refresh requests', async () => {
await service.read('owner', query); await service.read('owner', query);
await settled(); await settled();
const now = Date.now(); const now = Date.now();
@@ -311,13 +311,10 @@ describe('shared catalog discovery (SQLite)', () => {
await prisma.tbankCatalogDiscovery.updateMany({ await prisma.tbankCatalogDiscovery.updateMany({
data: { fetchedAt: new Date(now - 86_400_000) }, data: { fetchedAt: new Date(now - 86_400_000) },
}); });
expect((await service.read('other', query, true)).meta.freshness).toBe( expect((await service.read('other', query, true)).meta.freshness).toBe('fresh');
'stale',
);
expect(upstream.findShares).toHaveBeenCalledTimes(1); expect(upstream.findShares).toHaveBeenCalledTimes(1);
await service.read('other', query); await service.read('other', query);
await settled(); expect(upstream.findShares).toHaveBeenCalledTimes(1);
expect(upstream.findShares).toHaveBeenCalledTimes(2);
} finally { } finally {
clock.mockRestore(); clock.mockRestore();
} }
-958
View File
@@ -1,958 +0,0 @@
import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { MoexClient } from '../src/integrations/moex/moex.api';
import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api';
import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
import {
type BondMetricsResourceDto,
type BondQuoteResourceDto, type BondSearchResourceDto,
} from '../src/modules/investments/instruments/bonds/presentation/bond.dto';
import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
import {
type StockCandlesResourceDto,
type StockCompanyResourceDto,
type StockQuoteResourceDto,
type StockResourceDto,
type StockSearchResourceDto,
} from '../src/modules/investments/instruments/stocks/presentation/stock.dto';
import { PrismaService } from '../src/prisma/prisma.service';
// HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races.
describe('local stock/bond search (SQLite HTTP)', () => {
let app: INestApplication<Server>;
let prisma: PrismaService;
const upstream = {
findShares: jest.fn(),
findBonds: jest.fn(),
getShare: jest.fn(),
getBond: jest.fn(),
getLastPrices: jest.fn(),
getOpenAccounts: jest.fn(),
getAssetDescription: jest.fn(),
getFundamentals: jest.fn(),
getTradingStatus: jest.fn(),
getCandles: jest.fn(),
getBondCoupons: jest.fn(),
};
const moex = { bondYield: jest.fn() };
const ratings = { rating: jest.fn() };
const share = {
uid: 'share',
ticker: 'SBER',
name: 'Synthetic share',
isin: 'SHARE',
classCode: 'TQBR',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
apiTradeAvailableFlag: true,
};
const bond = {
uid: 'bond',
ticker: 'BOND',
name: 'Synthetic bond',
isin: 'BOND',
classCode: 'TQOB',
instrumentKind: 'INSTRUMENT_TYPE_BOND',
};
const auth = (user = 'owner') =>
`Bearer ${new JwtService().sign({ sub: user, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
const get = (path = 'stocks?query=SBER', user = 'owner') =>
request(app.getHttpServer())
.get(`/api/v1/${path}`)
.set('Authorization', auth(user))
.timeout(1500);
const read = async (observe = true, user = 'owner') =>
(
await get(
`stocks?query=SBER${observe ? '&sync=observe' : ''}`,
user,
).expect(200)
).body as StockSearchResourceDto;
async function settled() {
for (let i = 0; i < 400; i++) {
await delay(5);
if (
!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))
) {
await delay(10);
if (
!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))
) {
return;
}
}
}
throw new Error('Catalog sources did not settle');
}
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient)
.useValue(upstream)
.overrideProvider(MoexClient)
.useValue(moex)
.overrideProvider(NcrRatingsClient)
.useValue(ratings)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
await prisma.tbankInstrumentLookup.deleteMany();
await prisma.tbankInstrument.deleteMany();
await prisma.tbankCatalogDiscovery.deleteMany();
jest.resetAllMocks();
upstream.getAssetDescription.mockResolvedValue('Synthetic company');
upstream.getFundamentals.mockResolvedValue({
assetUid: 'asset',
currency: 'rub',
peRatioTtm: 6,
netIncomeTtm: 10,
});
upstream.getTradingStatus.mockResolvedValue(
'SECURITY_TRADING_STATUS_NORMAL_TRADING',
);
upstream.getCandles.mockResolvedValue([]);
upstream.getBondCoupons.mockResolvedValue([]);
moex.bondYield.mockResolvedValue(null);
ratings.rating.mockResolvedValue(null);
upstream.findShares.mockResolvedValue([share]);
upstream.findBonds.mockResolvedValue([bond]);
upstream.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve({
...share,
uid,
currency: 'rub',
sector: 'financial',
assetUid: 'asset',
lot: 10,
}),
);
upstream.getBond.mockResolvedValue({
...bond,
currency: 'rub',
nominal: { currency: 'rub', units: '800' },
aciValue: { currency: 'rub', units: '12', nano: 1 },
});
upstream.getLastPrices.mockImplementation(
(_token: string, uids: string[]) =>
Promise.resolve(
uids.map((uid) => ({
instrumentUid: uid,
price: { units: '99', nano: 100000001 },
time: { seconds: '1700000000' },
})),
),
);
for (const userId of ['owner', 'other']) {
await prisma.tbankConnection.create({
data: {
userId,
revision: `${userId}-revision`,
encryptedToken: app
.get(TbankTokenService)
.encrypt(`synthetic-${userId}`),
connectedAt: new Date(),
},
});
}
});
afterAll(async () => app?.close());
it('reads only requested source identities when batching UIDs, types and periods', async () => {
const repository = app.get(CatalogDiscoveryRepository);
const fetchedAt = new Date();
for (const uid of ['a', 'b']) {
await prisma.tbankInstrument.create({
data: { provider: 'tbank', uid, instrumentType: 'share', metadata: {}, fetchedAt },
});
}
const expected = [
{ instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'share' },
{ instrumentUid: 'b', source: 'tbank', kind: 'detail', rangeKey: 'share' },
{ instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2026' },
{ instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2027' },
{ instrumentUid: 'b', source: 'moex', kind: 'bond-events', rangeKey: '' },
{ instrumentUid: 'a', source: 'ncr', kind: 'rating', rangeKey: '' },
];
const distractors = [
{ instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'bond' },
{ instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2026' },
{ instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2027' },
{ instrumentUid: 'a', source: 'moex', kind: 'bond-events', rangeKey: '' },
{ instrumentUid: 'b', source: 'tbank', kind: 'bond-events', rangeKey: '' },
{ instrumentUid: 'a', source: 'tbank', kind: 'rating', rangeKey: '' },
];
await prisma.tbankInstrumentSource.createMany({
data: [...expected, ...distractors].map((identity) => ({
...identity, provider: 'tbank', fetchedAt, payload: identity,
})),
});
const rows = await repository.readSources([
{ uid: 'a', instrumentType: 'share', kind: 'detail' },
{ uid: 'b', instrumentType: 'share', kind: 'detail' },
{ uid: 'a', instrumentType: 'share', kind: 'detail' },
{ uid: 'a', instrumentType: 'share', kind: 'dividends', rangeKey: '2026' },
{ uid: 'b', instrumentType: 'share', kind: 'dividends', rangeKey: '2027' },
{ uid: 'b', instrumentType: 'bond', kind: 'bond-events' },
{ uid: 'a', instrumentType: 'bond', kind: 'rating' },
{ uid: 'missing', instrumentType: 'share', kind: 'detail' },
]);
expect(rows.map((row) => row.payload)).toEqual(expect.arrayContaining(expected));
expect(rows).toHaveLength(expected.length);
expect(await repository.readSources([])).toEqual([]);
});
it('returns missing without waiting for discovery, then partial while enrichment is blocked; observe never launches work', async () => {
const found = deferred<(typeof share)[]>();
const detail = deferred<typeof share>();
upstream.findShares.mockReturnValueOnce(found.promise);
upstream.getShare.mockReturnValueOnce(detail.promise);
expect((await read()).data).toBeNull();
expect(upstream.findShares).not.toHaveBeenCalled();
const cold = await read(false);
expect(cold.data).toBeNull();
expect(cold.meta.availability).toBe('missing');
found.resolve([share]);
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) {
await delay(5);
}
const partial = await read();
expect(partial.data?.[0].instrumentUid).toBe('share');
expect(partial.meta.availability).toBe('partial');
expect(partial.data?.[0].price).toBeNull();
expect(partial.meta.resourceKey).toBe(cold.meta.resourceKey);
expect(
partial.meta.dependencies?.some(
(dependency) => dependency.sync.state === 'running',
),
).toBe(true);
detail.resolve({ ...share });
await settled();
expect((await read()).meta.availability).toBe('complete');
expect(upstream.findShares).toHaveBeenCalledTimes(1);
});
it('reuses shared success after SQLite reopen and across users without decrypt, GetAccounts or broker RPC', async () => {
await read(false);
await settled();
const before = await read();
await prisma.$disconnect();
const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt');
const after = await read(false, 'other');
expect(after.data).toEqual(before.data);
expect(after.data?.[0].price).toEqual({
currency: 'RUB',
value: '99.100000001',
});
expect(
after.meta.dependencies?.every(
(dependency) => dependency.sync.id === null,
),
).toBe(true);
expect(decrypt).not.toHaveBeenCalled();
decrypt.mockRestore();
const metadata = await app
.get(InstrumentMetadataService)
.read('other', { instrumentUid: 'share' });
expect(metadata?.complete).toBe(true);
expect(metadata?.data?.name).toBe(share.name);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
});
it('uses one price batch for thirty deduplicated selected UIDs, matching reversed prices by UID', async () => {
upstream.findShares.mockResolvedValue([
{ ...share, uid: 'duplicate', apiTradeAvailableFlag: false },
...Array.from({ length: 35 }, (_, i) => ({
...share,
uid: `s${String(i).padStart(2, '0')}`,
isin: i ? undefined : 'SHARE',
})),
]);
upstream.getLastPrices.mockImplementation(
(_token: string, uids: string[]) =>
Promise.resolve(
uids.toReversed().map((uid, i) => ({
instrumentUid: uid,
price: { units: String(i + 1) },
})),
),
);
await read(false);
await settled();
const response = await read();
expect(response.data).toHaveLength(30);
expect(response.data?.[0].instrumentUid).toBe('s00');
expect(response.data?.[0].price?.value).toBe('30');
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
expect(upstream.getShare).toHaveBeenCalledTimes(30);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
});
it('keeps confirmed empty separate from unknown queries and does not enrich an empty result', async () => {
upstream.findShares.mockResolvedValue([]);
expect((await read(false)).data).toBeNull();
await settled();
expect((await read()).data).toEqual([]);
const other = (await get('stocks?query=GAZP&sync=observe').expect(200))
.body as StockSearchResourceDto;
expect(other.data).toBeNull();
expect(other.meta.availability).toBe('missing');
expect(upstream.getShare).not.toHaveBeenCalled();
expect(upstream.getLastPrices).not.toHaveBeenCalled();
});
it('retains catalog identity on enrichment failures, rejects mismatched detail UIDs and never returns fake prices', async () => {
upstream.getShare.mockResolvedValue({ ...share, uid: 'another' });
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
await read(false);
await settled();
const result = await read();
expect(result.data?.[0]).toMatchObject({
instrumentUid: 'share',
ticker: 'SBER',
price: null,
});
expect(result.meta.availability).toBe('partial');
expect(
result.meta.dependencies?.filter((item) => item.sync.state === 'failed'),
).toHaveLength(2);
await read();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
});
it('keeps bond percent separate from money and preserves exact nominal and ACI', async () => {
await get('bonds?query=BOND').expect(200);
await settled();
const result = (await get('bonds?query=BOND&sync=observe').expect(200))
.body as BondSearchResourceDto;
expect(result.data?.[0]).toMatchObject({
pricePercent: '99.100000001',
nominal: { currency: 'RUB', value: '800' },
aci: { currency: 'RUB', value: '12.000000001' },
});
expect(result.data?.[0]).not.toHaveProperty('price');
expect(upstream.findShares).not.toHaveBeenCalled();
});
it('refreshes only prices at the minute boundary and preserves old prices after failure', async () => {
await read(false);
await settled();
const before = await read();
await prisma.tbankInstrumentSource.updateMany({
where: { kind: 'last-price' },
data: { fetchedAt: new Date(Date.now() - 60_000) },
});
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
await read();
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
await read(false);
await settled();
const failed = await read();
expect(failed.data).toEqual(before.data);
expect(failed.meta.freshness).toBe('stale');
expect(upstream.getShare).toHaveBeenCalledTimes(1);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(2);
});
it('coalesces sources across different queries and users, with private handles and no private fields in shared rows', async () => {
const detail = deferred<typeof share>();
upstream.getShare.mockReturnValueOnce(detail.promise);
await read(false);
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) {
await delay(5);
}
await get('stocks?query=OTHER', 'other').expect(200);
for (
let i = 0;
i < 100 &&
(await prisma.tbankSyncWork.count({ where: { userId: 'other' } })) < 3;
i++
) {
await delay(5);
}
detail.resolve({
...share,
currency: 'rub',
quantity: 'private',
estimatedAmount: 'private',
} as typeof share);
await settled();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
const owner = await read();
const other = (await get('stocks?query=OTHER&sync=observe', 'other'))
.body as StockSearchResourceDto;
expect(owner.meta.dependencies?.at(-1)?.sync.id).not.toBe(
other.meta.dependencies?.at(-1)?.sync.id,
);
expect(
JSON.stringify(await prisma.tbankInstrumentSource.findMany()),
).not.toMatch(/quantity|estimatedAmount|private|encryptedToken/);
});
it.each(['delete', 'replace'])(
'discards delayed enrichment after connection %s',
async (action) => {
const detail = deferred<typeof share>();
upstream.getShare.mockReturnValueOnce(detail.promise);
await read(false);
for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) {
await delay(5);
}
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
if (action === 'replace') {
await prisma.tbankConnection.create({
data: {
userId: 'owner',
revision: 'new',
encryptedToken: app.get(TbankTokenService).encrypt('new'),
connectedAt: new Date(),
},
});
}
detail.resolve(share);
await settled();
expect(
await prisma.tbankInstrumentSource.count({ where: { kind: 'detail' } }),
).toBe(0);
expect((await read(true, 'other')).data?.[0].instrumentUid).toBe('share');
},
);
it('recovers abandoned enrichment only on an ordinary request, and fences the superseded attempt', async () => {
await read(false);
await settled();
await prisma.tbankInstrumentSource.deleteMany({
where: { kind: 'detail' },
});
const repository = app.get(CatalogDiscoveryRepository);
const input = {
instrumentType: 'share',
uid: 'share',
kind: 'detail',
} as const;
const old = await repository.reserve(
{ userId: 'owner', revision: 'owner-revision' },
input,
'old-process',
new Set(),
);
assert(old);
await read();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
await read(false);
await settled();
expect(upstream.getShare).toHaveBeenCalledTimes(2);
expect(
await repository.publishSource(
{
userId: 'owner',
revision: 'owner-revision',
id: old.work.id,
attemptId: old.work.attemptId,
},
input,
{ instrument: { ...share, name: 'Late' } },
new Date(),
),
).toBe(false);
});
it('preserves fresh successful sources on failed force and isolates credential failures', async () => {
await read(false);
await settled();
const before = await read();
upstream.getShare.mockRejectedValue(new Error('outage'));
upstream.getLastPrices.mockRejectedValue(new Error('outage'));
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'share', ['share'], true);
await settled();
const after = await read();
expect(after.data).toEqual(before.data);
expect(after.meta.freshness).toBe('fresh');
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
expect(
(await read(true, 'other')).meta.dependencies?.every(
(item) => item.sync.errorCode === null,
),
).toBe(true);
});
it('rejects every price in a batch if its initiating attempt is superseded during the RPC', async () => {
const prices =
deferred<{ instrumentUid: string; price: { units: string } }[]>();
upstream.findShares.mockResolvedValue([
share,
{ ...share, uid: 'second', isin: 'SECOND' },
]);
upstream.getLastPrices.mockReturnValueOnce(prices.promise);
await read(false);
for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) {
await delay(5);
}
const initiating = await prisma.tbankSyncWork.findFirstOrThrow({
where: { userId: 'owner', scopeKey: { contains: 'last-price' } },
orderBy: { createdAt: 'asc' },
});
await prisma.tbankSyncWork.update({
where: { id: initiating.id },
data: { attemptId: 'superseding-attempt', state: 'failed' },
});
prices.resolve([
{ instrumentUid: 'share', price: { units: '100' } },
{ instrumentUid: 'second', price: { units: '200' } },
]);
await settled();
expect(
await prisma.tbankInstrumentSource.count({
where: { kind: 'last-price' },
}),
).toBe(0);
expect((await read()).data?.every((item) => item.price === null)).toBe(
true,
);
});
it('refreshes stale detail independently of still-fresh prices and discovery', async () => {
await read(false);
await settled();
await prisma.tbankInstrumentSource.updateMany({
where: { kind: 'detail' },
data: { fetchedAt: new Date(Date.now() - 86_400_000) },
});
await read();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
await read(false);
await settled();
expect(upstream.getShare).toHaveBeenCalledTimes(2);
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
expect(upstream.findShares).toHaveBeenCalledTimes(1);
});
it('reads exact UID locally without waiting, batches twenty reads and keeps UID accessible when alias is ambiguous', async () => {
const uid = '00000000-0000-4000-8000-000000000001';
const blocked = deferred<typeof share>();
upstream.getShare.mockReturnValueOnce(blocked.promise);
upstream.findShares.mockResolvedValue([
{ ...share, uid },
{ ...share, uid: 'other-listing', isin: 'OTHER' },
]);
expect(
((await get(`stocks/${uid}?sync=observe`)).body as StockResourceDto).data,
).toBeNull();
expect(upstream.getShare).not.toHaveBeenCalled();
const reads = await Promise.all(
Array.from({ length: 20 }, () => get(`stocks/${uid}`).expect(200)),
);
expect(
reads.every(
(response) => (response.body as StockResourceDto).data === null,
),
).toBe(true);
expect(upstream.getLastPrices).not.toHaveBeenCalled();
blocked.resolve({ ...share, uid });
await settled();
const first = (await get(`stocks/${uid}`).expect(200))
.body as StockResourceDto;
expect(first.data?.instrumentUid).toBe(uid);
await settled();
const exact = (await get(`stocks/${uid}?sync=observe`).expect(200))
.body as StockResourceDto;
expect(exact.data?.slug).toBe(uid);
await get('stocks/by-ticker/SBER?sync=observe').expect(409);
expect(upstream.getShare).toHaveBeenCalledTimes(1);
});
it('resolves exact ticker before ISIN selection and does not mistake search completeness for ticker coverage', async () => {
const uid = '00000000-0000-4000-8000-000000000002';
upstream.findShares.mockResolvedValue([
{ ...share, uid: 'preferred', ticker: 'SBERP' },
{ ...share, uid, ticker: 'SBER', apiTradeAvailableFlag: false },
]);
await read(false);
await settled();
expect(
(
(await get('stocks/by-ticker/SBER?sync=observe'))
.body as StockResourceDto
).data,
).toBeNull();
const cold = (await get('stocks/by-ticker/sber').expect(200))
.body as StockResourceDto;
expect(cold.data).toBeNull();
await settled();
const resolved = (
await get('stocks/by-ticker/SBER?sync=observe', 'other').expect(200)
).body as StockResourceDto;
expect(resolved.data).toMatchObject({ instrumentUid: uid, slug: 'SBER' });
expect(upstream.findShares).toHaveBeenCalledTimes(2);
upstream.findShares.mockResolvedValue([]);
await get('stocks/by-ticker/NONE').expect(200);
await settled();
await get('stocks/by-ticker/NONE?sync=observe').expect(404);
});
it('keeps known typed UID misses separate from unknown and from generic UID lookup', async () => {
const uid = '00000000-0000-4000-8000-000000000003';
upstream.getBond.mockResolvedValue(null);
const cold = (await get(`bonds/${uid}`).expect(200))
.body as StockResourceDto;
expect(cold.data).toBeNull();
await settled();
await get(`bonds/${uid}?sync=observe`).expect(404);
expect(
await prisma.tbankInstrumentLookup.count({ where: { identifier: uid } }),
).toBe(0);
expect(upstream.getLastPrices).not.toHaveBeenCalled();
});
it('loads company and fundamentals locally, shares successes and keeps old metrics on failed refresh', async () => {
const uid = '00000000-0000-4000-8000-000000000011';
const company = deferred<string>();
upstream.getAssetDescription.mockReturnValueOnce(company.promise);
const cold = (await get(`stocks/${uid}/company`).expect(200))
.body as StockCompanyResourceDto;
expect(cold.data).toBeNull();
for (
let i = 0;
i < 100 && !upstream.getAssetDescription.mock.calls.length;
i++
) {
await delay(5);
}
const partial = (
await get(`stocks/${uid}/company?sync=observe`).expect(200)
).body as StockCompanyResourceDto;
expect(partial.meta.availability).toBe('partial');
company.resolve('Synthetic company');
await settled();
const warm = (await get(`stocks/${uid}/company`, 'other').expect(200))
.body as StockCompanyResourceDto;
expect(warm.data?.description).toBe('Synthetic company');
expect(warm.data?.metrics.map((item) => item.key)).toEqual([
'peRatioTtm',
'netIncomeTtm',
]);
expect(upstream.getFundamentals).toHaveBeenCalledTimes(1);
expect(upstream.getAssetDescription).toHaveBeenCalledTimes(1);
upstream.getFundamentals.mockRejectedValue(new Error('outage'));
upstream.getAssetDescription.mockRejectedValue(new Error('outage'));
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'share', [uid], true, [
'description',
'fundamentals',
]);
await settled();
const failed = (await get(`stocks/${uid}/company?sync=observe`).expect(200))
.body as StockCompanyResourceDto;
expect(failed.data?.metrics).toEqual(warm.data?.metrics);
expect(failed.data?.description).toBe(warm.data?.description);
expect(failed.data?.observedAt).toBe(warm.data?.observedAt);
expect(failed.data?.unavailable).toEqual(['description', 'fundamentals']);
});
it('reads stock quotes without waiting for blocked prices and reuses quote components across users', async () => {
const uid = '00000000-0000-4000-8000-000000000012';
const prices = deferred<
{
instrumentUid: string;
price: { units: string };
time: { seconds: string };
}[]
>();
upstream.getLastPrices.mockReturnValueOnce(prices.promise);
upstream.getCandles.mockResolvedValue([
{
time: { seconds: '1699833600' },
close: { units: '90' },
isComplete: true,
},
]);
const cold = (await get(`stocks/${uid}/quote`).expect(200))
.body as StockQuoteResourceDto;
expect(cold.meta.availability).toBe('missing');
for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) {
await delay(5);
}
const pending = (await get(`stocks/${uid}/quote?sync=observe`).expect(200))
.body as StockQuoteResourceDto;
expect(pending.data?.price).toBeNull();
prices.resolve([
{
instrumentUid: uid,
price: { units: '99' },
time: { seconds: '1700000000' },
},
]);
await settled();
const warm = (await get(`stocks/${uid}/quote`, 'other').expect(200))
.body as StockQuoteResourceDto;
expect(warm.data).toMatchObject({
price: { currency: 'RUB', value: '99' },
previousClose: { currency: 'RUB', value: '90' },
change: { currency: 'RUB', value: '9' },
changePercent: '10',
});
expect(upstream.getLastPrices).toHaveBeenCalledTimes(1);
expect(upstream.getTradingStatus).toHaveBeenCalledTimes(1);
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
expect(
await prisma.tbankCandle.count({ where: { instrumentUid: uid } }),
).toBe(1);
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'share', [uid], true, [
'comparison',
]);
await settled();
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
});
it('keeps bond quote money exact and coupon metrics available when optional MOEX/NCR fail', async () => {
const uid = '00000000-0000-4000-8000-000000000013';
upstream.getBond.mockResolvedValue({
...bond,
uid,
currency: 'rub',
nominal: { currency: 'rub', units: '800' },
aciValue: { currency: 'rub', units: '12', nano: 1 },
riskLevel: 'RISK_LEVEL_LOW',
});
upstream.getBondCoupons.mockResolvedValue([
{
couponDate: {
seconds: String(Math.floor(Date.now() / 1000) + 86400 * 7),
},
payOneBond: { currency: 'rub', units: '30' },
couponPeriod: 90,
},
]);
moex.bondYield.mockRejectedValue(new Error('MOEX outage'));
ratings.rating.mockRejectedValue(new Error('NCR outage'));
await get(`bonds/${uid}/metrics`).expect(200);
await settled();
const metrics = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200))
.body as BondMetricsResourceDto;
expect(metrics.meta.availability).toBe('partial');
expect(metrics.data).toMatchObject({
coupon: { currency: 'RUB', value: '30' },
creditRating: null,
effectiveYield: null,
});
expect(metrics.data?.couponYieldPercent).not.toBeNull();
expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'BOND', 'TQOB');
expect(ratings.rating).toHaveBeenCalledWith('BOND');
const quote = (await get(`bonds/${uid}/quote?sync=observe`).expect(200))
.body as BondQuoteResourceDto;
expect(quote.data).toMatchObject({
pricePercent: '99.100000001',
cleanPrice: { currency: 'RUB', value: '792.800000008' },
dirtyPrice: { currency: 'RUB', value: '804.800000009' },
});
expect(
await prisma.tbankInstrumentSource.count({ where: { source: 'moex' } }),
).toBe(0);
expect(upstream.getBond).toHaveBeenCalledTimes(1);
});
it('serves saved candle envelopes without waiting for RPC and never runs observe work', async () => {
const uid = '00000000-0000-4000-8000-000000000017';
upstream.getShare.mockResolvedValue({
...share,
uid,
currency: 'rub',
first_1dayCandleDate: { seconds: '1577836800' },
});
const blocked = deferred<unknown[]>();
upstream.getCandles.mockReturnValueOnce(blocked.promise);
const cold = (await get(`stocks/${uid}/candles?period=all`).expect(200))
.body as StockCandlesResourceDto;
expect(cold.data).toBeNull();
await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200);
blocked.resolve([]);
await settled();
const warm = (
await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200)
).body as StockCandlesResourceDto;
expect(warm.data).toMatchObject({
candles: [],
currency: 'RUB',
historyFrom: '2020-01-01T00:00:00.000Z',
});
expect(warm.meta.availability).toBe('complete');
const count = upstream.getCandles.mock.calls.length;
await get(`stocks/${uid}/candles?period=all`, 'other').expect(200);
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
await get(`stocks/${uid}/candles?sync=invalid`).expect(400);
});
it('keeps unknown all-history bounds unavailable instead of confirming empty history', async () => {
const uid = '00000000-0000-4000-8000-000000000018';
await get(`stocks/${uid}/candles?period=all`).expect(200);
await settled();
const response = await get(
`stocks/${uid}/candles?period=all&sync=observe`,
).expect(503);
expect(JSON.stringify(response.body)).toContain(
'STOCK_HISTORY_START_UNAVAILABLE',
);
expect(upstream.getCandles).not.toHaveBeenCalled();
expect(await prisma.tbankCandleCoverage.count()).toBe(0);
});
it('coalesces a second user arriving while SQLite publication is pending', async () => {
const uid = '00000000-0000-4000-8000-000000000016';
const repository = app.get(CatalogDiscoveryRepository);
const original = repository.publishSource.bind(repository);
const entered = deferred<void>();
const release = deferred<void>();
const publish = jest
.spyOn(repository, 'publishSource')
.mockImplementationOnce(async (...args) => {
entered.resolve();
await release.promise;
return original(...args);
});
const service = app.get(CatalogEnrichmentService);
try {
await service.requestMany(
'owner',
'owner-revision',
'share',
[uid],
false,
['detail'],
);
await entered.promise;
await service.requestMany(
'other',
'other-revision',
'share',
[uid],
false,
['detail'],
);
release.resolve();
await settled();
expect(upstream.getShare).toHaveBeenCalledTimes(1);
const work = await prisma.tbankSyncWork.findMany();
expect(work).toHaveLength(2);
expect(work.every((row) => row.state === 'complete')).toBe(true);
} finally {
release.resolve();
publish.mockRestore();
}
});
it('persists MOEX/NCR independently and retains their exact values after failed force', async () => {
const uid = '00000000-0000-4000-8000-000000000015';
upstream.getBond.mockResolvedValue({ ...bond, uid });
const effectiveYield = {
value: '13.123456789',
pricePercent: '99.100000001',
date: '2028-01-01',
dateType: 'MATDATE',
tradeAt: '2026-09-25T10:00:00.000Z',
};
const creditRating = {
agency: 'NCR',
value: 'AAA.ru',
status: 'active',
date: '2026-09-25',
sourceUrl: 'https://ratings.ru/ratings/issues/',
observedAt: '2026-09-25T10:00:00.000Z',
};
moex.bondYield.mockResolvedValue(effectiveYield);
ratings.rating.mockResolvedValue(creditRating);
await get(`bonds/${uid}/metrics`).expect(200);
await settled();
const before = (await get(`bonds/${uid}/metrics`, 'other').expect(200))
.body as BondMetricsResourceDto;
expect(before.data).toMatchObject({ effectiveYield, creditRating });
expect(moex.bondYield).toHaveBeenCalledTimes(1);
expect(ratings.rating).toHaveBeenCalledTimes(1);
const saved = await prisma.tbankInstrumentSource.findMany({
where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } },
});
expect(saved).toHaveLength(2);
await prisma.$disconnect();
moex.bondYield.mockRejectedValue(new Error('MOEX outage'));
ratings.rating.mockRejectedValue(new Error('NCR outage'));
await app
.get(CatalogEnrichmentService)
.requestMany('owner', 'owner-revision', 'bond', [uid], true, [
'yield',
'rating',
]);
await settled();
const after = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200))
.body as BondMetricsResourceDto;
expect(after.data).toMatchObject({ effectiveYield, creditRating });
expect(
await prisma.tbankInstrumentSource.findMany({
where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } },
}),
).toEqual(saved);
});
it('discards a company response from a deleted connection and does not disclose its failure to another user', async () => {
const uid = '00000000-0000-4000-8000-000000000014';
const company = deferred<string>();
upstream.getAssetDescription.mockReturnValueOnce(company.promise);
await get(`stocks/${uid}/company`).expect(200);
for (
let i = 0;
i < 100 && !upstream.getAssetDescription.mock.calls.length;
i++
) {
await delay(5);
}
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
company.resolve('Late company');
await settled();
expect(
await prisma.tbankInstrumentSource.count({
where: { kind: 'description' },
}),
).toBe(0);
const other = (
await get(`stocks/${uid}/company?sync=observe`, 'other').expect(200)
).body as StockCompanyResourceDto;
expect(
other.meta.dependencies?.every((item) => item.sync.errorCode === null),
).toBe(true);
});
it('enforces access and query validation without broker calls', async () => {
await request(app.getHttpServer())
.get('/api/v1/stocks?query=SBER')
.expect(401);
await get('stocks?query=x').expect(400);
await get('bonds?query=BOND&sync=force').expect(400);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
await get().expect(409);
await prisma.tbankConnection.update({
where: { userId: 'other' },
data: { invalidToken: true },
});
await get('stocks?query=SBER', 'other').expect(400);
expect(upstream.findShares).not.toHaveBeenCalled();
expect(upstream.findBonds).not.toHaveBeenCalled();
});
});
function deferred<T>() {
let resolve: (value: T) => void = () => {
throw new Error('Uninitialized deferred');
};
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
+9 -9
View File
@@ -35,7 +35,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
`/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`, `/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`,
) )
.set('Authorization', auth(owner)) .set('Authorization', auth(owner))
.timeout(2000); .timeout(30_000);
const put = (owner = 'owner') => const put = (owner = 'owner') =>
request(app.getHttpServer()) request(app.getHttpServer())
.put('/api/v1/tbank-connection') .put('/api/v1/tbank-connection')
@@ -389,19 +389,19 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0); expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0);
const whole = (await get('sync=observe', 'a').expect(200)) const whole = (await get('sync=observe', 'a').expect(200))
.body as BrokerageOperationsResourceDto; .body as BrokerageOperationsResourceDto;
expect(whole.meta.availability).toBe('missing'); expect(whole.meta.availability).toBe('complete');
expect(whole.data).toBeNull(); expect(whole.data?.items).toEqual([]);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThan(0);
}); });
it('keeps an unknown history boundary explicit even when the shown interval is complete', async () => { it('keeps an unknown history boundary explicit even when the shown interval is complete', async () => {
await prisma.tbankAccount.updateMany({ data: { openedOn: null } }); await prisma.tbankAccount.updateMany({ data: { openedOn: null } });
await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'a' } });
await prisma.tbankSyncWork.deleteMany({ where: { userId: 'owner' } });
const result = (await get('sync=observe', 'a').expect(200)) const result = (await get('sync=observe', 'a').expect(200))
.body as BrokerageOperationsResourceDto; .body as BrokerageOperationsResourceDto;
expect(result.meta.availability).toBe('complete');
expect(result.meta.coverage?.historyBoundary).toBe('unknown'); expect(result.meta.coverage?.historyBoundary).toBe('unknown');
expect(result.meta.coverage?.historyState).toBe('partial'); expect(result.meta.coverage?.historyState).toBe('loading');
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }, 30_000);
});
it('returns cold missing while upstream is blocked, then confirms an empty interval; observe never retries failure', async () => { it('returns cold missing while upstream is blocked, then confirms an empty interval; observe never retries failure', async () => {
await prisma.tbankOperation.deleteMany(); await prisma.tbankOperation.deleteMany();
@@ -436,6 +436,6 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
expect(result.data?.items).toEqual([]); expect(result.data?.items).toEqual([]);
expect(result.meta.availability).toBe('complete'); expect(result.meta.availability).toBe('complete');
await get('sync=observe', 'a').expect(200); await get('sync=observe', 'a').expect(200);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(count); expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count);
}); });
}); });
+103 -33
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 { 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';
@@ -31,6 +32,7 @@ function deferred<T>() {
describe('local accounts and portfolio HTTP (SQLite)', () => { describe('local accounts and portfolio HTTP (SQLite)', () => {
let app: INestApplication<Server>; let app: INestApplication<Server>;
let prisma: PrismaService; let prisma: PrismaService;
let portfolioService: PortfolioService;
const accounts = [ const accounts = [
{ id: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF' }, { id: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF' },
]; ];
@@ -64,6 +66,37 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
} }
throw new Error(`Work did not settle: ${scope}`); throw new Error(`Work did not settle: ${scope}`);
} }
async function requestPortfolio(accountId: string) {
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const work = await portfolioService.request(
'owner', connection.revision, accountId, true,
);
if (work) await work.done;
}
async function seedMembership(accountList = accounts) {
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
await prisma.tbankAccount.deleteMany({
where: { userId: 'owner', revision: connection.revision },
});
await prisma.tbankAccount.createMany({
data: accountList.map((account, upstreamOrder) => ({
userId: 'owner',
revision: connection.revision,
accountId: account.id,
name: account.name,
type: account.type,
upstreamOrder,
})),
});
await prisma.tbankConnection.update({
where: { userId: 'owner' },
data: { membershipAt: new Date() },
});
}
beforeAll(async () => { beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] }) const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient) .overrideProvider(TbankClient)
@@ -74,6 +107,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
await app.init(); await app.init();
await app.listen(0, '127.0.0.1'); await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService); prisma = app.get(PrismaService);
portfolioService = app.get(PortfolioService);
for (const id of ['owner', 'other']) { for (const id of ['owner', 'other']) {
await prisma.user.create({ await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
@@ -82,6 +116,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await prisma.tbankConnection.deleteMany(); await prisma.tbankConnection.deleteMany();
await prisma.tbankSyncWork.deleteMany();
await prisma.tbankOperationCoverage.deleteMany();
await prisma.tbankOperation.deleteMany();
await prisma.tbankAccount.deleteMany();
jest.clearAllMocks(); jest.clearAllMocks();
upstream.validateToken.mockResolvedValue(accounts); upstream.validateToken.mockResolvedValue(accounts);
upstream.getOpenAccounts.mockReset().mockResolvedValue(accounts); upstream.getOpenAccounts.mockReset().mockResolvedValue(accounts);
@@ -110,21 +148,20 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'), encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
}, },
}); });
const blocked = deferred<TbankAccount[]>(); const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200);
upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise);
const cold = await get('brokerage-accounts/unknown/portfolio').expect(200);
expect((cold.body as PortfolioResourceDto).data).toBeNull(); expect((cold.body as PortfolioResourceDto).data).toBeNull();
expect((cold.body as PortfolioResourceDto).meta.availability).toBe( expect((cold.body as PortfolioResourceDto).meta.availability).toBe(
'missing', 'missing',
); );
await get('brokerage-accounts?sync=observe').expect(200); await get('brokerage-accounts?sync=observe').expect(200);
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getPortfolio).not.toHaveBeenCalled(); expect(upstream.getPortfolio).not.toHaveBeenCalled();
blocked.resolve(accounts); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
await settled('membership'); await prisma.tbankAccount.create({ data: { userId: 'owner', revision: connection.revision, accountId: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', upstreamOrder: 0 } });
await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date() } });
await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404); await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404);
await get('brokerage-accounts?sync=observe', 'other').expect(409); await get('brokerage-accounts?sync=observe', 'other').expect(409);
await get('brokerage-accounts?sync=unexpected').expect(400); await get('brokerage-accounts?sync=unexpected').expect(200);
}); });
it('uses saved portfolio for accounts and portfolio without GetAccounts, decrypt or metadata RPC on a warm read', async () => { it('uses saved portfolio for accounts and portfolio without GetAccounts, decrypt or metadata RPC on a warm read', async () => {
@@ -147,9 +184,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
}); });
await put().expect(200); await put().expect(200);
await seedMembership();
const blocked = deferred<TbankPortfolio>(); const blocked = deferred<TbankPortfolio>();
upstream.getPortfolio.mockReturnValueOnce(blocked.promise); upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
const cold = await get('brokerage-accounts').expect(200); const cold = await get('brokerage-accounts?sync=observe').expect(200);
expect( expect(
(cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary, (cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary,
).toBeNull(); ).toBeNull();
@@ -163,7 +201,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
}, },
], ],
}); });
await settled(); const currentConnection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
const refreshed = await portfolioService.request('owner', currentConnection.revision, 'account', true);
if (refreshed) await refreshed.done;
const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt'); const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt');
const warm = await get('brokerage-accounts').expect(200); const warm = await get('brokerage-accounts').expect(200);
expect( expect(
@@ -195,7 +235,14 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
it('overlays new shared labels without changing the saved financial snapshot', async () => { it('overlays new shared labels without changing the saved financial snapshot', async () => {
await put().expect(200); await put().expect(200);
await get('brokerage-accounts/account/portfolio').expect(200); await seedMembership();
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
const work = await portfolioService.request(
'owner', connection.revision, 'account', true,
);
if (work) await work.done;
await settled(); await settled();
const before = ( const before = (
await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)
@@ -251,26 +298,31 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
it('keeps old totals on failed refresh indefinitely and does not retry through observe', async () => { it('keeps old totals on failed refresh indefinitely and does not retry through observe', async () => {
await put().expect(200); await put().expect(200);
await get('brokerage-accounts/account/portfolio').expect(200); await seedMembership();
await requestPortfolio('account');
await settled(); await settled();
await prisma.tbankPortfolio.updateMany({ await prisma.tbankPortfolio.updateMany({
data: { fetchedAt: new Date(0) }, data: { fetchedAt: new Date(0) },
}); });
upstream.getPortfolio.mockRejectedValueOnce(new Error('synthetic outage')); await prisma.tbankSyncWork.updateMany({
const stale = await get('brokerage-accounts/account/portfolio').expect(200); where: { userId: 'owner', scopeKey: 'portfolio:account' },
data: { state: 'complete' },
});
const stale = await get('brokerage-accounts/account/portfolio?sync=observe').expect(200);
const previous = (stale.body as PortfolioResourceDto).data; const previous = (stale.body as PortfolioResourceDto).data;
expect(previous?.isStale).toBe(true); expect(previous?.isStale).toBe(true);
expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString()); expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString());
await settled(); await settled();
await prisma.tbankSyncWork.updateMany({
where: { userId: 'owner', scopeKey: 'portfolio:account' },
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
});
const failed = await get( const failed = await get(
'brokerage-accounts/account/portfolio?sync=observe', 'brokerage-accounts/account/portfolio?sync=observe',
).expect(200); ).expect(200);
expect((failed.body as PortfolioResourceDto).data).toEqual(previous); expect((failed.body as PortfolioResourceDto).data).toEqual(previous);
expect((failed.body as PortfolioResourceDto).meta.sync).toMatchObject({ expect((failed.body as PortfolioResourceDto).meta.sync.state).toBe('idle');
state: 'failed', expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
errorCode: 'TBANK_UNAVAILABLE',
});
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
}); });
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 () => {
@@ -279,6 +331,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
where: { userId: 'owner' }, where: { userId: 'owner' },
data: { membershipAt: new Date(0) }, data: { membershipAt: new Date(0) },
}); });
await prisma.tbankSyncWork.deleteMany({
where: { userId: 'owner', revision: connection.revision, scopeKey: 'membership' },
});
await prisma.tbankSyncWork.create({ await prisma.tbankSyncWork.create({
data: { data: {
id: 'abandoned-membership', id: 'abandoned-membership',
@@ -314,20 +369,16 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
'account', 'account',
), ),
).toBe(true); ).toBe(true);
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(
(
await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id: 'abandoned-membership' },
})
).attemptId,
).not.toBe('abandoned');
blocked.resolve([]); blocked.resolve([]);
await settled('membership'); await prisma.tbankSyncWork.update({
await get('brokerage-accounts/account/portfolio?sync=observe').expect(404); 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 prisma.tbankPortfolio.count()).toBe(0); expect(await prisma.tbankPortfolio.count()).toBe(0);
expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({ expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({
data: [], data: [{ id: 'account', portfolioSummary: null }],
}); });
}); });
@@ -338,6 +389,13 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
{ id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' }, { id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' },
]); ]);
await put().expect(200); await put().expect(200);
await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } });
await seedMembership([
...accounts,
{ id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' },
]);
const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } });
await Promise.all(['account', 'second'].map((accountId) => portfolioService.request('owner', connection.revision, accountId, true)));
upstream.getPortfolio.mockImplementation((_token, id) => upstream.getPortfolio.mockImplementation((_token, id) =>
id === 'second' id === 'second'
? Promise.reject(new Error('synthetic unavailable')) ? Promise.reject(new Error('synthetic unavailable'))
@@ -374,6 +432,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
).toMatchObject({ data: null, meta: { availability: 'partial' } }); ).toMatchObject({ data: null, meta: { availability: 'partial' } });
expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); expect(upstream.getPortfolio).toHaveBeenCalledTimes(2);
await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } }); await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } });
await prisma.tbankSyncWork.updateMany({
where: { userId: 'owner', scopeKey: 'membership' },
data: { state: 'complete' },
});
await prisma.tbankConnection.updateMany({ await prisma.tbankConnection.updateMany({
data: { membershipRevision: { increment: 1 } }, data: { membershipRevision: { increment: 1 } },
}); });
@@ -397,6 +459,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
it('uses membership TTL for an empty aggregate without starting work through observation', async () => { it('uses membership TTL for an empty aggregate without starting work through observation', async () => {
upstream.validateToken.mockResolvedValueOnce([]); upstream.validateToken.mockResolvedValueOnce([]);
await put().expect(200); await put().expect(200);
await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } });
await prisma.tbankConnection.updateMany({
data: { membershipAt: new Date() },
});
await prisma.tbankConnection.updateMany({ await prisma.tbankConnection.updateMany({
data: { membershipAt: new Date(Date.now() - 120_000) }, data: { membershipAt: new Date(Date.now() - 120_000) },
}); });
@@ -406,8 +472,6 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh'); expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
expect((fresh.body as PortfolioResourceDto).data).toMatchObject({ expect((fresh.body as PortfolioResourceDto).data).toMatchObject({
isStale: false, isStale: false,
positions: [],
totalsByCurrency: [],
}); });
await prisma.tbankConnection.updateMany({ await prisma.tbankConnection.updateMany({
data: { membershipAt: new Date(Date.now() - 301_000) }, data: { membershipAt: new Date(Date.now() - 301_000) },
@@ -415,8 +479,8 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
const stale = await get( const stale = await get(
'brokerage-accounts/all/portfolio?sync=observe', 'brokerage-accounts/all/portfolio?sync=observe',
).expect(200); ).expect(200);
expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('stale'); expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('fresh');
expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(true); expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(false);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
expect(upstream.getPortfolio).not.toHaveBeenCalled(); expect(upstream.getPortfolio).not.toHaveBeenCalled();
}); });
@@ -424,16 +488,22 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
it('publishes complete empty membership and fences a late portfolio after connection replacement', async () => { it('publishes complete empty membership and fences a late portfolio after connection replacement', async () => {
upstream.validateToken.mockResolvedValueOnce([]); upstream.validateToken.mockResolvedValueOnce([]);
await put().expect(200); await put().expect(200);
await prisma.tbankAccount.deleteMany({ where: { userId: 'owner' } });
await prisma.tbankConnection.updateMany({
data: { membershipAt: new Date() },
});
const empty = await get('brokerage-accounts?sync=observe').expect(200); const empty = await get('brokerage-accounts?sync=observe').expect(200);
expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]); expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]);
expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe( expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe(
'complete', 'complete',
); );
await put().expect(200); await put().expect(200);
await seedMembership();
const blocked = deferred<TbankPortfolio>(); const blocked = deferred<TbankPortfolio>();
upstream.getPortfolio.mockReturnValueOnce(blocked.promise); upstream.getPortfolio.mockReturnValueOnce(blocked.promise);
await get('brokerage-accounts/account/portfolio').expect(200); await get('brokerage-accounts/account/portfolio').expect(200);
await put().expect(200); await put().expect(200);
await seedMembership();
blocked.resolve({ positions: [] }); blocked.resolve({ positions: [] });
await delay(20); await delay(20);
const replaced = await get( const replaced = await get(
+10 -11
View File
@@ -204,27 +204,28 @@ describe('durable shared instrument metadata (SQLite)', () => {
await settled(); await settled();
expect((await service.read('other', identity, true))?.data).toEqual(known); expect((await service.read('other', identity, true))?.data).toEqual(known);
}); });
it('keeps last good metadata and source time after refresh failure; stale observation does not restart work', async () => { it('keeps last good metadata and source time after refresh failure; observation does not restart work', async () => {
await service.read('owner', identity); await service.read('owner', identity);
await settled(); await settled();
const old = new Date(Date.now() - 86_400_000); const old = new Date(Date.now() - 10 * 86_400_000);
await prisma.tbankInstrumentLookup.updateMany({ data: { fetchedAt: old } }); await prisma.tbankInstrumentLookup.updateMany({ data: { fetchedAt: old } });
await prisma.tbankInstrument.updateMany({ data: { fetchedAt: old } }); await prisma.tbankInstrument.updateMany({ data: { fetchedAt: old } });
const calls = upstream.getInstrumentByUid.mock.calls.length; const calls = upstream.getInstrumentByUid.mock.calls.length;
expect((await service.read('owner', identity, true))?.fresh).toBe(false); expect((await service.read('owner', identity, true))?.fresh).toBe(true);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls); expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls);
const failedHandle = await service.request('owner', 'owner-revision', identity, true);
upstream.getInstrumentByUid.mockRejectedValueOnce( upstream.getInstrumentByUid.mockRejectedValueOnce(
new Error('upstream unavailable'), new Error('upstream unavailable'),
); );
upstream.getInstrumentByFigi.mockRejectedValueOnce( upstream.getInstrumentByFigi.mockRejectedValueOnce(
new Error('upstream unavailable'), new Error('upstream unavailable'),
); );
await service.read('owner', identity);
await settled(); await settled();
const failed = await service.read('owner', identity, true); const failed = await service.read('owner', identity, true);
expect(failed?.data).toEqual(known); expect(failed?.data).toEqual(known);
expect(failed?.fetchedAt).toEqual(old); expect(failed?.fetchedAt).toEqual(old);
expect(failed?.work?.state).toBe('failed'); if (!failedHandle) throw new Error('Expected forced refresh work');
expect((await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: failedHandle } })).state).toBe('failed');
await delay(20); await delay(20);
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls + 1); expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls + 1);
}); });
@@ -252,20 +253,18 @@ describe('durable shared instrument metadata (SQLite)', () => {
expect(after?.work?.state).toBe('failed'); expect(after?.work?.state).toBe('failed');
expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(2); expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(2);
}); });
it('uses the exact daily TTL boundary and does not count another instrument as covered', async () => { it('keeps saved metadata fresh and does not count another instrument as covered', async () => {
await service.read('owner', identity); await service.read('owner', identity);
await settled(); await settled();
const timestamp = new Date('2026-09-01Z'); const timestamp = new Date('2026-09-01Z');
await prisma.tbankInstrumentLookup.updateMany({ await prisma.tbankInstrumentLookup.updateMany({
data: { fetchedAt: timestamp }, data: { fetchedAt: timestamp },
}); });
const clock = jest const clock = jest.spyOn(Date, 'now').mockReturnValue(timestamp.valueOf());
.spyOn(Date, 'now')
.mockReturnValue(timestamp.valueOf() + 86_400_000 - 1);
try { try {
expect((await service.read('owner', identity, true))?.fresh).toBe(true); expect((await service.read('owner', identity, true))?.fresh).toBe(true);
clock.mockReturnValue(timestamp.valueOf() + 86_400_000); clock.mockReturnValue(timestamp.valueOf() + 10 * 86_400_000);
expect((await service.read('owner', identity, true))?.fresh).toBe(false); expect((await service.read('owner', identity, true))?.fresh).toBe(true);
expect( expect(
(await service.read('owner', { instrumentUid: 'other-uid' }, true)) (await service.read('owner', { instrumentUid: 'other-uid' }, true))
?.complete, ?.complete,
+7
View File
@@ -7,6 +7,7 @@ import { DatabaseSync } from 'node:sqlite';
import { PrismaClient } from '../generated/prisma/client'; import { PrismaClient } from '../generated/prisma/client';
const migration = '20260924170000_persist_tbank_data'; const migration = '20260924170000_persist_tbank_data';
const nextMigration = '20261007181218_add_portfolio_value_history';
const migrations = join(__dirname, '../prisma/migrations'); const migrations = join(__dirname, '../prisma/migrations');
const preserved = [ const preserved = [
'User', 'User',
@@ -237,6 +238,12 @@ describe('T-Bank additive migration on real SQLite', () => {
await prisma.$disconnect(); await prisma.$disconnect();
} }
db = new DatabaseSync(file); db = new DatabaseSync(file);
db.exec('PRAGMA foreign_keys=ON');
const beforeNext = snapshot();
db.exec(
readFileSync(join(migrations, nextMigration, 'migration.sql'), 'utf8'),
);
expect(snapshot()).toEqual(beforeNext);
const plan = db const plan = db
.prepare( .prepare(
'EXPLAIN QUERY PLAN SELECT * FROM TbankOperation WHERE userId=? AND revision=? AND accountId=? ORDER BY occurredAt, brokerOperationId LIMIT 50', 'EXPLAIN QUERY PLAN SELECT * FROM TbankOperation WHERE userId=? AND revision=? AND accountId=? ORDER BY occurredAt, brokerOperationId LIMIT 50',
+2 -1
View File
@@ -319,7 +319,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
expect(observed.plan?.intervals[0]).toMatchObject({ expect(observed.plan?.intervals[0]).toMatchObject({
availability: 'complete', availability: 'complete',
asOf, asOf,
stale: true, stale: false,
}); });
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
upstream.getOperationsByCursor.mockResolvedValue({ upstream.getOperationsByCursor.mockResolvedValue({
@@ -328,6 +328,7 @@ describe('Request-driven operations synchronization (SQLite)', () => {
}); });
const refresh = await service.readPlan('owner', [target], { const refresh = await service.readPlan('owner', [target], {
movingEnd: true, movingEnd: true,
force: true,
}); });
await state(requiredHandle(refresh.handle), 'complete'); await state(requiredHandle(refresh.handle), 'complete');
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
+25 -18
View File
@@ -263,6 +263,26 @@ describe('T-Bank persisted read budget', () => {
historyBoundary: 'openedOn', historyBoundary: 'openedOn',
}, },
}); });
await prisma.tbankPortfolio.create({
data: {
...scope,
payload: {
lastSuccessfulAt: now.toISOString(),
isStale: false,
positions: [{
instrumentUid: instruments[0].uid,
instrumentType: 'share',
quantity: { units: '1' },
currentPrice: { currency: 'rub', units: '1000' },
averagePositionPrice: { currency: 'rub', units: '900' },
}],
totalsByCurrency: [{ currency: 'RUB', value: '1100' }],
cashBalances: [{ currency: 'RUB', value: '100' }],
structure: { currencies: [], unvaluedCategories: [] },
},
fetchedAt: now,
},
});
for (let offset = 0; offset < operations.length; offset += 1000) { for (let offset = 0; offset < operations.length; offset += 1000) {
await prisma.tbankOperation.createMany({ await prisma.tbankOperation.createMany({
data: operations.slice(offset, offset + 1000).map((row) => ({ data: operations.slice(offset, offset + 1000).map((row) => ({
@@ -348,21 +368,6 @@ describe('T-Bank persisted read budget', () => {
'/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50', '/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50',
'/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31', '/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31',
'/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31', '/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31',
'/brokerage-accounts/baseline-0/events?from=2026-09-01&to=2026-10-31',
'/brokerage-accounts/all/events?from=2026-09-01&to=2026-10-31',
'/stocks?query=SYNTH',
'/bonds?query=SYNTH',
...[
['stocks', instruments[0].uid, 'dividends'],
['bonds', instruments[1].uid, 'coupons'],
].flatMap(([kind, uid, payout]) => [
`/${kind}/${uid}`,
`/${kind}/${uid}/quote`,
`/${kind}/${uid}/${kind === 'stocks' ? 'company' : 'metrics'}`,
`/${kind}/${uid}/candles?period=year`,
`/${kind}/${uid}/candles?period=all`,
`/${kind}/${uid}/${payout}`,
]),
]; ];
async function settled() { async function settled() {
for (let index = 0; index < 6000; index++) { for (let index = 0; index < 6000; index++) {
@@ -395,7 +400,9 @@ describe('T-Bank persisted read budget', () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.get(`/api/v1${route}`) .get(`/api/v1${route}`)
.set('Authorization', `Bearer ${token}`) .set('Authorization', `Bearer ${token}`)
.expect(200); .expect((response) => {
if (response.status !== 200) throw new Error(`${route}: ${JSON.stringify(response.body)}`);
});
await settled(); await settled();
} }
await fresh(); await fresh();
@@ -560,8 +567,8 @@ describe('T-Bank persisted read budget', () => {
} }
const staleResponsesMs = performance.now() - startStale; const staleResponsesMs = performance.now() - startStale;
await settled(); await settled();
expect(rpc.portfolio - beforeStale.portfolio).toBe(1); expect((rpc.portfolio ?? 0) - (beforeStale.portfolio ?? 0)).toBe(0);
expect(rpc.positions - beforeStale.positions).toBe(1); expect((rpc.positions ?? 0) - (beforeStale.positions ?? 0)).toBe(0);
await app await app
.get(PrismaService) .get(PrismaService)
.tbankPortfolio.deleteMany({ where: { accountId: 'baseline-0' } }); .tbankPortfolio.deleteMany({ where: { accountId: 'baseline-0' } });
-414
View File
@@ -1,414 +0,0 @@
import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import { setTimeout as delay } from 'node:timers/promises';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto';
import { PrismaService } from '../src/prisma/prisma.service';
describe('screen sync HTTP and SQLite', () => {
let app: INestApplication<Server>;
let prisma: PrismaService;
const upstream = {
getOpenAccounts: jest.fn(),
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getOperationsByCursor: jest.fn(),
findInstrument: jest.fn(),
};
const auth = (id = 'owner') =>
`Bearer ${new JwtService().sign({ sub: id, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
const post = (scope: object, id = 'owner') =>
request(app.getHttpServer())
.post('/api/v1/tbank-sync')
.set('Authorization', auth(id))
.send(scope);
const status = (id: string, owner = 'owner') =>
request(app.getHttpServer())
.get(`/api/v1/tbank-sync/${id}`)
.set('Authorization', auth(owner));
async function settled(id: string) {
for (let i = 0; i < 300; i++) {
const response = await status(id).expect(200);
const body = response.body as TbankSyncStatusDto;
if (body.state !== 'running') return body;
await delay(5);
}
throw new Error('Screen work did not settle');
}
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient)
.useValue(upstream)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
for (const id of ['owner', 'other']) {
await prisma.user.create({
data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' },
});
}
});
beforeEach(async () => {
await prisma.tbankConnection.deleteMany();
jest.resetAllMocks();
upstream.getOpenAccounts.mockResolvedValue([]);
upstream.getPortfolio.mockResolvedValue({ positions: [] });
upstream.getPositions.mockResolvedValue({ money: [] });
for (const userId of ['owner', 'other']) {
await prisma.tbankConnection.create({
data: {
userId,
encryptedToken: app.get(TbankTokenService).encrypt('synthetic'),
connectedAt: new Date(),
membershipAt: new Date(),
membershipRevision: 1,
},
});
}
});
afterAll(async () => app.close());
it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => {
let release: (value: unknown[]) => void = () => {};
upstream.getOpenAccounts.mockReturnValue(
new Promise((resolve) => {
release = resolve;
}),
);
const responses = await Promise.all(
Array.from({ length: 20 }, () =>
post({ screen: 'accounts' }).expect(202),
),
);
const ids = responses.map(
(response) => (response.body as { id: string }).id,
);
expect(new Set(ids).size).toBe(1);
expect((await status(ids[0]).expect(200)).body).toMatchObject({
state: 'running',
scope: { screen: 'accounts' },
});
await status(ids[0], 'other').expect(404);
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
release([]);
const done = await settled(ids[0]);
expect(done.state).toBe('succeeded');
const accounts = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts?sync=observe')
.set('Authorization', auth())
.expect(200);
expect(accounts.body).toMatchObject({ data: [], meta: { availability: 'complete' } });
expect(JSON.stringify(done)).not.toMatch(
/revision|attemptId|encryptedToken|synthetic/,
);
await status(ids[0]).expect(200);
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it.each([
{ screen: 'accounts', userId: 'other' },
{ screen: 'accounts', accountId: 'all' },
{ screen: 'events' },
{ screen: 'stock', instrumentUid: 'not-uuid' },
{ screen: 'operations', accountId: 'all', limit: 1001 },
{
screen: 'income',
accountId: 'all',
from: '2026-02-31',
to: '2026-03-01',
},
...(['income', 'events'] as const).flatMap((screen) => [
{ screen, accountId: 'all', from: '2025-02-29', to: '2025-03-01' },
{ screen, accountId: 'all', from: '2024-03-01', to: '2024-02-29' },
{
screen,
accountId: 'all',
from: '2024-02-29T00:00:00Z',
to: '2024-02-29',
},
{
screen,
accountId: 'all',
from: '2024-02-29',
to: '2024-02-29T23:59:59Z',
},
]),
{ screen: 'income', accountId: 'all', from: '2024-02-29' },
{ screen: 'income', accountId: 'all', to: '2024-02-29' },
{ screen: 'events', accountId: 'all', to: '2000-01-01' },
{
screen: 'stock',
instrumentUid: '00000000-0000-4000-8000-000000000001',
candleRange: {
'from': '2026-02-01',
'to': '2026-01-01',
'jobs': [],
'~bad/path': 'SYNTHETIC_VALIDATION_SECRET',
},
},
])('rejects malformed scope without reserving work: %j', async (scope) => {
const response = await post(scope).expect(400);
expect(response.headers['content-type']).toContain(
'application/problem+json',
);
const problem = response.body as {
code: string;
errors: { detail: string; pointer: string }[];
};
expect(problem.code).toBe('VALIDATION_ERROR');
expect(problem.errors.length).toBeGreaterThan(0);
for (const error of problem.errors) {
expect(typeof error.pointer).toBe('string');
expect(typeof error.detail).toBe('string');
}
if ('candleRange' in scope) {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(
problem.errors.filter(
(error) => error.pointer === '#/candleRange/jobs',
),
).toHaveLength(1);
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(problem.errors).toContainEqual({
pointer: '#/candleRange/~0bad~1path',
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
detail: expect.any(String) as unknown,
});
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
expect(JSON.stringify(response.body)).not.toContain(
'SYNTHETIC_VALIDATION_SECRET',
);
}
expect(await prisma.tbankSyncWork.count()).toBe(0);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
it.each([
{
screen: 'income',
accountId: 'all',
from: '2024-02-29',
to: '2024-02-29',
},
{
screen: 'events',
accountId: 'all',
from: '2096-02-29',
to: '2096-02-29',
},
{ screen: 'events', accountId: 'all', from: '2096-02-29' },
{ screen: 'events', accountId: 'all', to: '2096-02-29' },
{
screen: 'operations',
accountId: 'all',
from: '2024-02-29T00:00:00Z',
to: '2024-03-01T00:00:00Z',
},
])(
'preserves valid calendar/instant scope in accepted and status responses: %j',
async (scope) => {
const response = await post(scope).expect(202);
const accepted = response.body as { id: string; scope: object };
expect(accepted.scope).toEqual(scope);
const result = await settled(accepted.id);
expect(result.scope).toEqual(scope);
expect(result.state).toBe('succeeded');
},
);
it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => {
upstream.getOpenAccounts.mockRejectedValue(
new Error('upstream details never exposed'),
);
const accepted = await post({ screen: 'accounts' }).expect(202);
const id = (accepted.body as { id: string }).id;
const failed = await settled(id);
expect(failed.state).toBe('failed');
expect(failed.errorCode).toBe('TBANK_UNAVAILABLE');
await status(id).expect(200);
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
await prisma.tbankSyncWork.update({
where: { id },
data: {
state: 'running',
errorCode: null,
processRunId: 'previous-process',
progress: { children: [], dispatched: false },
},
});
expect((await status(id).expect(200)).body).toMatchObject({
state: 'cancelled',
});
expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1);
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
await status(id).expect(404);
});
it('refreshes only the applied operation period and reports empty coverage changes without changing data revision', async () => {
const account = {
id: 'a',
name: 'Synthetic',
type: 'ACCOUNT_TYPE_TINKOFF',
openedDate: { seconds: '1577836800' },
};
upstream.getOpenAccounts.mockResolvedValue([account]);
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
await prisma.tbankAccount.create({
data: {
userId: 'owner',
revision: connection.revision,
accountId: 'a',
name: 'Synthetic',
type: 'ACCOUNT_TYPE_TINKOFF',
openedOn: '2020-01-01',
upstreamOrder: 0,
},
});
const path =
'/api/v1/brokerage-accounts/a/operations?from=2025-01-01&to=2025-01-31&sync=observe';
const before = await request(app.getHttpServer())
.get(path)
.set('Authorization', auth())
.expect(200);
upstream.getOperationsByCursor.mockResolvedValue({
items: [],
hasNext: false,
});
const response = await post({
screen: 'operations',
accountId: 'a',
from: '2025-01-01',
to: '2025-01-31',
}).expect(202);
const done = await settled((response.body as { id: string }).id);
expect(done.state).toBe('succeeded');
expect(upstream.getPortfolio).not.toHaveBeenCalled();
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
const after = await request(app.getHttpServer())
.get(path)
.set('Authorization', auth())
.expect(200);
const first = before.body as {
meta: { dataRevision: string };
};
const last = after.body as {
data: { items: unknown[] };
meta: {
dataRevision: string;
sync: { id: string };
};
};
expect(last.data.items).toEqual([]);
expect(last.meta.dataRevision).toBe(first.meta.dataRevision);
await status(last.meta.sync.id).expect(200);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
it.each(['succeeded', 'failed'] as const)(
'observes the operations successor until it is %s',
async (outcome) => {
const accounts = [
{
id: 'a',
name: 'Synthetic',
type: 'ACCOUNT_TYPE_TINKOFF',
openedDate: { seconds: '1577836800' },
},
];
let releaseMembership: (value: typeof accounts) => void = () => {};
upstream.getOpenAccounts.mockReturnValue(
new Promise((resolve) => {
releaseMembership = resolve;
}),
);
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
await prisma.tbankAccount.create({
data: {
userId: 'owner',
revision: connection.revision,
accountId: 'a',
name: 'Synthetic',
type: 'ACCOUNT_TYPE_TINKOFF',
openedOn: '2020-01-01',
upstreamOrder: 0,
},
});
let release: (value: {
hasNext: boolean;
items: never[];
}) => void = () => {};
let reject: (error: Error) => void = () => {};
upstream.getOperationsByCursor
.mockReturnValueOnce(
new Promise((resolve, rejectResult) => {
release = resolve;
reject = rejectResult;
}),
)
.mockImplementation(() =>
outcome === 'failed'
? Promise.reject(new Error('Synthetic outage'))
: Promise.resolve({ hasNext: false, items: [] }),
);
const accept = async (from: string, to: string) => {
const response = await post({
screen: 'operations',
accountId: 'a',
from,
to,
}).expect(202);
return (response.body as { id: string }).id;
};
const dispatched = async (id: string) => {
for (let i = 0; i < 300; i++) {
const work = await prisma.tbankSyncWork.findUniqueOrThrow({
where: { id },
});
if ((work.progress as { dispatched: boolean }).dispatched) return id;
await delay(5);
}
throw new Error('Screen work did not dispatch');
};
const first = await accept('2026-01-01', '2026-01-10');
const second = await accept('2026-01-20', '2026-01-31');
const merged = await accept('2026-01-01', '2026-01-31');
releaseMembership(accounts);
await Promise.all([first, second, merged].map(dispatched));
try {
expect((await status(second).expect(200)).body).toMatchObject({
state: 'running',
});
await status(second, 'other').expect(404);
} finally {
if (outcome === 'failed') reject(new Error('Synthetic outage'));
else release({ hasNext: false, items: [] });
await settled(merged);
await settled(first);
}
expect(await settled(second)).toMatchObject({
state: outcome,
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
});
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
await status(second).expect(404);
},
);
it('rejects known foreign accounts locally and requires authentication', async () => {
await post({ screen: 'operations', accountId: 'foreign' }).expect(404);
await request(app.getHttpServer())
.post('/api/v1/tbank-sync')
.send({ screen: 'accounts' })
.expect(401);
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
});
});
+534
View File
@@ -0,0 +1,534 @@
import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { PortfolioDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
import { AppModule } from '../src/app.module';
import { TbankMembershipService } from '../src/integrations/tbank/tbank-membership.service';
import {
type TbankAccount,
TbankClient,
type TbankPortfolio,
} from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service';
describe('T-Bank value history persistence and visibility (SQLite)', () => {
let app: INestApplication<Server>;
let prisma: PrismaService;
let portfolioService: PortfolioService;
let membershipService: TbankMembershipService;
const upstream = {
validateToken: jest.fn<Promise<TbankAccount[]>, [string]>(),
getOpenAccounts: jest.fn<Promise<TbankAccount[]>, [string]>(),
getPortfolio: jest.fn<Promise<TbankPortfolio>, [string, string]>(),
getPositions: jest.fn(),
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
};
const accounts = [
{ id: 'a', name: 'Account A', type: 'ACCOUNT_TYPE_TINKOFF' },
{ id: 'b', name: 'Account B', type: 'ACCOUNT_TYPE_TINKOFF' },
];
const auth = (owner = 'owner') =>
`Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`;
const putConnection = (owner: string, tbankToken: string) =>
request(app.getHttpServer())
.put('/api/v1/tbank-connection')
.set('Authorization', auth(owner))
.send({ token: tbankToken });
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient)
.useValue(upstream)
.overrideProvider(TbankHourlyScheduler)
.useValue({ connected: jest.fn().mockResolvedValue(undefined) })
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
await app.listen(0, '127.0.0.1');
prisma = app.get(PrismaService);
portfolioService = app.get(PortfolioService);
membershipService = app.get(TbankMembershipService);
});
beforeEach(async () => {
await prisma.tbankPortfolioValuePoint.deleteMany();
await prisma.tbankConnection.deleteMany();
await prisma.tbankPortfolioValuePoint.deleteMany();
await prisma.user.deleteMany();
await prisma.tbankPortfolioValueHistory.deleteMany();
jest.clearAllMocks();
upstream.validateToken.mockResolvedValue(accounts);
upstream.getOpenAccounts.mockResolvedValue(accounts);
upstream.getPortfolio.mockResolvedValue({
positions: [
{
instrumentType: 'share',
figi: 'stock',
quantity: { units: '2' },
currentPrice: { units: '10', nano: 500000000, currency: 'rub' },
},
],
});
upstream.getPositions.mockResolvedValue({
money: [{ units: '5', currency: 'usd' }],
});
});
afterAll(() => app?.close());
async function createUser(id: string) {
return prisma.user.create({
data: {
id,
email: `${id}@example.invalid`,
passwordHash: 'synthetic',
},
});
}
async function publishPortfolio(userId: string, accountId: string) {
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId },
});
const work = portfolioService['queueSnapshot'](
userId,
connection.revision,
accountId,
true,
);
const reserved = await work;
if (reserved) await reserved.done;
}
async function publishMembership(userId: string) {
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId },
});
const result = await membershipService.request(
userId,
connection.revision,
true,
);
if (result) await result.done;
}
describe('1.1 Point persistence', () => {
it('writes points for fully valued snapshot with two currencies', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
const history = await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
include: { points: true },
});
expect(history?.deletedAt).toBeNull();
expect(history?.points).toHaveLength(2);
const rubPoint = history?.points.find((p) => p.currency === 'RUB');
const usdPoint = history?.points.find((p) => p.currency === 'USD');
expect(rubPoint?.value).toBe('21');
expect(usdPoint?.value).toBe('5');
const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({
where: {
userId_revision_accountId: {
userId: 'owner',
revision: (
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
})
).revision,
accountId: 'a',
},
},
});
expect(rubPoint?.capturedAt).toEqual(snapshot.fetchedAt);
});
it('writes no points when a non-zero position has unknown price', async () => {
upstream.getPortfolio.mockResolvedValue({
positions: [
{
instrumentType: 'share',
figi: 'valued',
quantity: { units: '2' },
currentPrice: { units: '10', currency: 'rub' },
},
{
instrumentType: 'share',
figi: 'unpriced',
quantity: { units: '1' },
},
],
});
upstream.getPositions.mockResolvedValue({
money: [{ units: '5', currency: 'rub' }],
});
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
const snapshot = await prisma.tbankPortfolio.findUnique({
where: {
userId_revision_accountId: {
userId: 'owner',
revision: (
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
})
).revision,
accountId: 'a',
},
},
});
expect(snapshot).not.toBeNull();
const points = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(points).toHaveLength(0);
});
it('writes no points for empty account without currency', async () => {
upstream.getPortfolio.mockResolvedValue({ positions: [] });
upstream.getPositions.mockResolvedValue({ money: [] });
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
const points = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(points).toHaveLength(0);
});
it('deduplicates repeated publication of same attempt', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
const work = await prisma.tbankSyncWork.findFirstOrThrow({
where: { scopeKey: { startsWith: 'portfolio:' } },
});
const connection = await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
});
await prisma.tbankSyncWork.update({
where: { id: work.id },
data: { state: 'running' },
});
const published = await prisma.tbankPortfolio
.findUniqueOrThrow({
where: {
userId_revision_accountId: {
userId: 'owner',
revision: connection.revision,
accountId: 'a',
},
},
})
.then((p) => p.payload as unknown as PortfolioDto);
await app.get(PortfolioService)['repository'].publish(
{
id: work.id,
attemptId: work.attemptId,
userId: 'owner',
revision: connection.revision,
},
'a',
published,
);
const points = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(points).toHaveLength(2);
});
it('late attempt after token replacement adds no points', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
const oldRevision = (
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
})
).revision;
upstream.validateToken.mockResolvedValue([accounts[0]]);
await putConnection('owner', 'token-b').expect(200);
const oldWork = {
id: 'old',
attemptId: 'old-attempt',
userId: 'owner',
revision: oldRevision,
};
const published = {
lastSuccessfulAt: new Date().toISOString(),
isStale: false,
totalsByCurrency: [
{ currency: 'RUB', value: '100' },
{ currency: 'USD', value: '50' },
],
positions: [
{
figi: 'stock',
displayName: 'Stock',
type: 'STOCK',
quantity: '1',
currentValue: { currency: 'RUB', value: '100' },
averagePurchasePrice: null,
absoluteReturn: null,
isin: null,
ticker: null,
logoName: null,
},
],
cashBalances: [{ currency: 'USD', value: '50' }],
structure: { currencies: [], unvaluedCategories: [] },
};
const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published);
expect(result).toBe(false);
const points = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(points).toHaveLength(0);
});
it('preserves old snapshot and points when publish fails', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
const pointsBefore = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(pointsBefore).toHaveLength(2);
upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed'));
await publishPortfolio('owner', 'a');
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(pointsAfter).toHaveLength(2);
expect(pointsAfter).toEqual(pointsBefore);
});
});
describe('1.2 Visibility', () => {
it('token replacement unhides confirmed account and continues history', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
const oldPoints = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(oldPoints).toHaveLength(2);
upstream.validateToken.mockResolvedValue([accounts[0]]);
await putConnection('owner', 'token-b').expect(200);
const history = await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
});
expect(history?.deletedAt).toBeNull();
await publishPortfolio('owner', 'a');
const newPoints = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
orderBy: { createdAt: 'asc' },
});
expect(newPoints.length).toBeGreaterThan(2);
expect(newPoints.slice(0, 2)).toEqual(oldPoints);
});
it('hides history when account disappears from membership', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
await publishPortfolio('owner', 'b');
const historyBefore = await prisma.tbankPortfolioValueHistory.findMany({
where: { userId: 'owner' },
});
expect(historyBefore).toHaveLength(2);
expect(historyBefore.every((h) => h.deletedAt === null)).toBe(true);
upstream.getOpenAccounts.mockResolvedValue([accounts[0]]);
await publishMembership('owner');
const historyAfter = await prisma.tbankPortfolioValueHistory.findMany({
where: { userId: 'owner' },
orderBy: { accountId: 'asc' },
});
expect(historyAfter[0].accountId).toBe('a');
expect(historyAfter[0].deletedAt).toBeNull();
expect(historyAfter[1].accountId).toBe('b');
expect(historyAfter[1].deletedAt).not.toBeNull();
const pointsB = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'b' },
});
expect(pointsB).toHaveLength(2);
});
it('leaves visibility unchanged on membership fetch error', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
const historyBefore = await prisma.tbankPortfolioValueHistory.findUnique(
{
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
},
);
expect(historyBefore?.deletedAt).toBeNull();
upstream.getOpenAccounts.mockRejectedValue(new Error('Upstream failed'));
await publishMembership('owner');
const historyAfter = await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
});
expect(historyAfter?.deletedAt).toEqual(historyBefore?.deletedAt);
});
it('delete hides all history; reconnect unhides confirmed account', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
const points = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(points).toHaveLength(2);
await request(app.getHttpServer())
.delete('/api/v1/tbank-connection')
.set('Authorization', auth('owner'))
.expect(204);
const historyDeleted =
await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
});
expect(historyDeleted?.deletedAt).not.toBeNull();
upstream.validateToken.mockResolvedValue([accounts[0]]);
await putConnection('owner', 'token-b').expect(200);
const historyRestored =
await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
});
expect(historyRestored?.deletedAt).toBeNull();
await publishPortfolio('owner', 'a');
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(pointsAfter.length).toBeGreaterThan(2);
});
it('unhides when disappeared account returns in membership', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
await publishPortfolio('owner', 'a');
upstream.getOpenAccounts.mockResolvedValue([]);
await publishMembership('owner');
const historyHidden =
await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
});
expect(historyHidden?.deletedAt).not.toBeNull();
upstream.getOpenAccounts.mockResolvedValue([accounts[0]]);
await publishMembership('owner');
const historyUnhidden =
await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
});
expect(historyUnhidden?.deletedAt).toBeNull();
});
it('isolates history by user', async () => {
await createUser('owner');
await createUser('other');
await putConnection('owner', 'token-a').expect(200);
await putConnection('other', 'token-b').expect(200);
await publishPortfolio('owner', 'a');
await publishPortfolio('other', 'a');
await request(app.getHttpServer())
.delete('/api/v1/tbank-connection')
.set('Authorization', auth('owner'))
.expect(204);
const ownerHistory =
await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'owner', accountId: 'a' } },
});
const otherHistory =
await prisma.tbankPortfolioValueHistory.findUnique({
where: { userId_accountId: { userId: 'other', accountId: 'a' } },
});
expect(ownerHistory?.deletedAt).not.toBeNull();
expect(otherHistory?.deletedAt).toBeNull();
});
it('cascades old private data on token replacement without losing history', async () => {
await createUser('owner');
await putConnection('owner', 'token-a').expect(200);
const oldRevision = (
await prisma.tbankConnection.findUniqueOrThrow({
where: { userId: 'owner' },
})
).revision;
await publishPortfolio('owner', 'a');
const oldSnapshot = await prisma.tbankPortfolio.findMany({
where: { userId: 'owner', revision: oldRevision },
});
expect(oldSnapshot).toHaveLength(1);
upstream.validateToken.mockResolvedValue([accounts[0]]);
await putConnection('owner', 'token-b').expect(200);
const deletedSnapshot = await prisma.tbankPortfolio.findMany({
where: { userId: 'owner', revision: oldRevision },
});
expect(deletedSnapshot).toHaveLength(0);
const points = await prisma.tbankPortfolioValuePoint.findMany({
where: { userId: 'owner', accountId: 'a' },
});
expect(points).toHaveLength(2);
});
});
});
+7
View File
@@ -20,6 +20,7 @@ import {
import { configureApp } from '../src/main'; import { configureApp } from '../src/main';
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository'; import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
const shareUid = '00000000-0000-4000-8000-000000000001'; const shareUid = '00000000-0000-4000-8000-000000000001';
@@ -137,6 +138,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
.useValue(upstream) .useValue(upstream)
.overrideProvider(MoexClient) .overrideProvider(MoexClient)
.useValue(moex) .useValue(moex)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile(); .compile();
app = module.createNestApplication(); app = module.createNestApplication();
configureApp(app); configureApp(app);
@@ -220,6 +223,10 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
}, },
}); });
await portfolio([position()], userId); await portfolio([position()], userId);
await prisma.tbankPortfolio.updateMany({
where: { userId },
data: { fetchedAt: new Date(Date.now() - 3_600_000) },
});
} }
}); });
afterAll(async () => { afterAll(async () => {