Files
backend/src/modules/investments/tbank-sync/tbank-sync.service.ts
T
ksv741 4796d9d3f0
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
fix: harden auth and investment synchronization
Resolve A01-A10 and C01-C07 from the backend audit.
Preserve auth failures, forced candle reconciliation and forecast freshness.
Validate MOEX responses and nested HTTP errors; remove unused legacy paths.

Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability.
Record the remaining macOS native SQLite abort in the audit.
2026-09-27 14:09:05 +03:00

603 lines
19 KiB
TypeScript

import {
Injectable,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
TbankAccessService,
TbankMembershipService,
InstrumentMetadataService,
type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api';
import { PortfolioService } from '../portfolio/portfolio.api';
import {
BrokerageOperationsService,
BrokerageOperationsSyncService,
type OperationInterval,
} from '../operations/operations.api';
import {
CandleService,
CatalogDiscoveryService,
CatalogEnrichmentService,
} from '../instruments/instruments.api';
import {
StockIncomeService,
BondIncomeService,
} from '../instruments/instrument-income.api';
import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api';
import { IncomeAnalyticsService } from '../income/income-read.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
import { planInvestmentScreen } from '../investment-sync-plan';
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
import {
TbankSyncRepository,
type ScreenIntent,
} from './tbank-sync.repository';
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
import { normalizeScreen } from './tbank-screen';
import { investmentResourceMeta } from '../investment-resource.mapper';
@Injectable()
export class TbankSyncService {
private readonly runId = randomUUID();
private readonly startedAt = new Date();
private reservations: Promise<unknown> = Promise.resolve();
constructor(
private readonly repository: TbankSyncRepository,
private readonly access: TbankAccessService,
private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService,
private readonly ledger: BrokerageOperationsService,
private readonly metadata: InstrumentMetadataService,
private readonly discovery: CatalogDiscoveryService,
private readonly sources: CatalogEnrichmentService,
private readonly candles: CandleService,
private readonly accounts: BrokerageAccountsService,
private readonly income: IncomeAnalyticsService,
private readonly events: UpcomingEventsService,
private readonly stockIncome: StockIncomeService,
private readonly bondIncome: BondIncomeService,
) {}
async request(userId: string, input: InvestmentScreenDto) {
const scope = normalizeScreen(input);
const connection = await this.access.local(userId, scope.accountId);
const reservation = this.reservations.then(() =>
this.repository.reserve(userId, connection.revision, scope, this.runId),
);
this.reservations = reservation.catch(() => undefined);
const reserved = await reservation;
if (!reserved) throw new ServiceUnavailableException();
const intent = reserved.work.intent as unknown as ScreenIntent;
if (reserved.launch) {
const attempt = {
userId,
revision: connection.revision,
id: reserved.work.id,
attemptId: reserved.work.attemptId,
};
setImmediate(() => {
void this.dispatch(attempt, intent);
});
}
return { id: reserved.work.id, ...intent };
}
private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) {
const children: string[] = [];
const add = async (ids: (string | null | undefined)[]) => {
children.push(...ids.filter((id): id is string => Boolean(id)));
await this.repository.progress(attempt, children, false);
};
try {
const { userId, revision } = attempt;
const { scope } = intent;
const now = new Date(intent.now);
const member = await this.membership.request(userId, revision, true);
await add([member?.id]);
await member?.done;
const current = await this.access.local(userId, scope.accountId);
if (current.revision !== revision) return;
const accountScope = scope.accountId ?? 'all';
const selectedAccounts = current.accounts.filter(
(account) =>
accountScope === 'all' || account.accountId === accountScope,
);
if (
['accounts', 'account', 'events', 'stock', 'bond'].includes(
scope.screen,
)
) {
for (const account of scope.screen === 'stock' ||
scope.screen === 'bond'
? current.accounts
: selectedAccounts) {
const work = await this.portfolio.request(
userId,
revision,
account.accountId,
true,
);
await add([work?.id]);
await work?.done;
}
}
const saved = await this.portfolio.read(
userId,
scope.screen === 'stock' || scope.screen === 'bond'
? 'all'
: accountScope,
true,
);
const positions = saved.data?.positions ?? [];
const instruments = [
...new Map(
positions.flatMap((position) =>
position.instrumentUid &&
(position.type === 'STOCK' || position.type === 'BOND')
? [
[
position.instrumentUid,
{
uid: position.instrumentUid,
type: position.type,
historyFrom: null,
},
] as const,
]
: [],
),
).values(),
];
const plan = planInvestmentScreen(
{
...scope,
candleRange: scope.candleRange
? {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
}
: undefined,
},
{
accounts: selectedAccounts,
instruments:
scope.screen === 'stocks' || scope.screen === 'bonds'
? []
: instruments,
membershipKnown: Boolean(current.membershipAt),
},
now,
true,
);
const intervals: OperationInterval[] = plan.sources.flatMap((source) =>
source.kind === 'operations' &&
source.accountId &&
source.from &&
source.to
? [
{
accountId: source.accountId,
from: source.from,
to: source.to,
...(source.instrumentUid
? { instrumentUid: source.instrumentUid }
: {}),
},
]
: [],
);
if (intervals.length)
await add([
await this.operations.request(
userId,
intervals,
true,
'screen',
revision,
),
]);
for (const source of plan.sources) {
const uid = source.instrumentUid;
const type = source.instrumentType === 'BOND' ? 'bond' : 'share';
if (source.kind === 'catalog')
await add(
await this.discovery.refresh(userId, revision, {
instrumentType: type,
query: source.query ?? '',
}),
);
if (!uid) continue;
if (source.kind === 'metadata')
await add([
await this.metadata.request(userId, revision, {
instrumentUid: uid,
}),
]);
if (source.kind === 'quote')
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
[
type === 'bond' ? 'bond-current' : 'detail',
'last-price',
'trading-status',
'comparison',
],
),
);
if (source.kind === 'company')
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
['description', 'fundamentals'],
),
);
if (source.kind === 'metrics')
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
['coupons', 'yield', 'rating'],
),
);
if (source.kind === 'calendar' && source.from && source.to)
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
[
type === 'share'
? 'dividends'
: scope.screen === 'bond'
? 'coupons'
: 'bond-events',
],
{
from: source.from.toISOString().slice(0, 10),
to: source.to.toISOString().slice(0, 10),
ticker:
positions.find((position) => position.instrumentUid === uid)
?.ticker ?? undefined,
},
),
);
}
if (
(scope.screen === 'stock' || scope.screen === 'bond') &&
scope.instrumentUid
) {
await add([
await this.candles.request(
userId,
revision,
{
uid: scope.instrumentUid,
instrumentType: scope.screen === 'bond' ? 'bond' : 'share',
period: scope.candlePeriod ?? 'month',
...(scope.candleRange
? {
range: {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
},
}
: {}),
},
now,
true,
0,
),
]);
}
await this.repository.progress(attempt, children, true);
} catch {
await this.repository
.progress(attempt, children, true, 'TBANK_UNAVAILABLE')
.catch(() => undefined);
}
}
async status(userId: string, id: string): Promise<TbankSyncStatusDto> {
const connection = await this.access
.local(userId)
.catch((error: unknown) => {
if (error instanceof ConflictException) throw new NotFoundException();
throw error;
});
const saved = await this.repository.read(userId, connection.revision, id);
if (!saved) throw new NotFoundException();
if (!saved.work.scopeKey.startsWith('screen:'))
return this.sourceStatus(userId, saved.work);
const intent = saved.work.intent as unknown as ScreenIntent;
await this.access.local(userId, intent.scope.accountId);
const children = await Promise.all(
saved.children.map((child) =>
child.state === 'superseded'
? this.operations.observe(userId, child.id)
: Promise.resolve(child),
),
);
const running = children.some((child) => child.state === 'running');
const failed =
saved.work.errorCode ??
children.find((child) => child.errorCode)?.errorCode ??
null;
const state =
saved.work.processRunId !== this.runId &&
(!saved.progress.dispatched || running)
? 'cancelled'
: !saved.progress.dispatched || running
? 'running'
: failed
? 'failed'
: children.some((child) => child.state === 'cancelled')
? 'cancelled'
: 'succeeded';
const resources = await this.observe(userId, intent.scope);
if (
(await this.access.local(userId, intent.scope.accountId)).revision !==
connection.revision
)
throw new NotFoundException();
return {
id,
...intent,
state,
errorCode: failed,
progress: {
completed: children.filter((child) => child.state !== 'running').length,
total: children.length,
discovering: !saved.progress.dispatched,
},
resources,
};
}
private async sourceStatus(
userId: string,
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
): Promise<TbankSyncStatusDto> {
const resources: InvestmentResourceMetaDto[] = [];
const input = work.intent as Record<string, unknown>;
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
resources.push((await read).meta);
};
if (work.scopeKey === 'membership')
await add(this.accounts.list(userId, true));
else if (
input.source === 'portfolio' &&
typeof input.accountId === 'string'
)
await add(this.portfolio.read(userId, input.accountId, true));
else if (input.source === 'catalog') {
const type = input.instrumentType === 'bond' ? 'bond' : 'share';
if (typeof input.query === 'string')
await add(
this.discovery.search(
userId,
{
instrumentType: type,
query: input.query,
exactTicker: input.exactTicker === true,
},
true,
),
);
else if (
typeof input.uid === 'string' &&
typeof input.kind === 'string'
) {
const range =
typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[')
? (JSON.parse(input.rangeKey) as string[])
: null;
const sources = await this.sources.readMany(
userId,
type,
[input.uid],
true,
[
input.kind as Parameters<
CatalogEnrichmentService['readMany']
>[4] extends (infer K)[] | undefined
? K
: never,
],
range ? { from: range[0], to: range[1] } : undefined,
);
resources.push(...sources.map((source) => source.meta));
}
} else if (input.source === 'metadata') {
const identity = {
instrumentUid:
typeof input.instrumentUid === 'string' ? input.instrumentUid : null,
figi: typeof input.figi === 'string' ? input.figi : null,
};
const source = await this.metadata.read(userId, identity, true);
resources.push(
investmentResourceMeta({
resourceKey: work.scopeKey,
dataRevision: source?.dataRevision ?? '0',
availability: source?.complete ? 'complete' : 'missing',
fetchedAt: source?.fetchedAt ?? null,
freshForMs: Number.POSITIVE_INFINITY,
work,
}),
);
} else if (
work.scopeKey.startsWith('candles:') &&
typeof input.uid === 'string'
) {
await add(
this.candles.read(
userId,
{
uid: input.uid,
instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share',
period: input.period as 'month' | 'halfYear' | 'year' | 'all',
},
true,
),
);
} else if (
work.scopeKey.startsWith('operations:') &&
Array.isArray(input.intervals)
) {
for (const key of input.intervals as string[]) {
const [accountId, from, end, instrumentUid] = JSON.parse(
key,
) as string[];
await add(
this.ledger.read(userId, accountId, {
from,
to: new Date(Date.parse(end) - 1).toISOString(),
...(instrumentUid ? { instrumentUid } : {}),
sync: 'observe',
}),
);
}
}
const observed = work.scopeKey.startsWith('operations:')
? await this.operations.observe(userId, work.id)
: work;
const state =
observed.state === 'running'
? observed.updatedAt < this.startedAt
? 'cancelled'
: 'running'
: observed.state === 'failed'
? 'failed'
: observed.state === 'cancelled'
? 'cancelled'
: 'succeeded';
if ((await this.access.local(userId)).revision !== work.revision)
throw new NotFoundException();
return {
id: work.id,
scope: null,
now: work.createdAt.toISOString(),
state,
errorCode: observed.errorCode,
progress: {
completed: state === 'running' ? 0 : 1,
total: 1,
discovering: false,
},
resources,
};
}
private async observe(userId: string, scope: InvestmentScreenDto) {
const resources: InvestmentResourceMetaDto[] = [];
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
resources.push((await read).meta);
};
const account = scope.accountId ?? 'all';
if (scope.screen === 'accounts')
await add(this.accounts.list(userId, true));
if (scope.screen === 'account') {
await add(this.portfolio.read(userId, account, true));
await add(this.income.summary(userId, account, true));
await add(this.events.get(userId, account, { sync: 'observe' }));
}
if (['account', 'operations', 'stock', 'bond'].includes(scope.screen))
await add(
this.ledger.read(userId, account, {
from: scope.from,
to: scope.to,
instrumentUid: scope.instrumentUid,
categories: scope.categories,
limit: scope.limit,
sync: 'observe',
}),
);
if (scope.screen === 'income')
await add(
this.income.analytics(userId, account, scope.from, scope.to, true),
);
if (scope.screen === 'events')
await add(
this.events.get(userId, account, {
from: scope.from,
to: scope.to,
sync: 'observe',
}),
);
const type =
scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share';
if (scope.screen === 'stocks' || scope.screen === 'bonds')
await add(
this.discovery.search(
userId,
{ instrumentType: type, query: scope.query ?? '' },
true,
),
);
if (
(scope.screen === 'stock' || scope.screen === 'bond') &&
scope.instrumentUid
) {
const uid = scope.instrumentUid;
await add(this.discovery.detail(userId, type, uid, true));
await add(this.sources.section(userId, type, uid, 'quote', true));
await add(
this.sources.section(
userId,
type,
uid,
type === 'share' ? 'company' : 'metrics',
true,
),
);
await add(
this.candles.read(
userId,
{
uid,
instrumentType: type,
period: scope.candlePeriod ?? 'month',
...(scope.candleRange
? {
range: {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
},
}
: {}),
},
true,
),
);
await add(
type === 'share'
? this.stockIncome.readPosition(userId, uid, true)
: this.bondIncome.readPosition(userId, uid, true),
);
await add(
type === 'share'
? this.stockIncome.dividends(userId, uid, true)
: this.bondIncome.coupons(userId, uid, true),
);
}
return resources;
}
}