feat: add hourly portfolio sync history
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped

This commit is contained in:
2026-10-09 09:04:41 +03:00
parent 03e75f8d2b
commit 0057925888
49 changed files with 1674 additions and 1870 deletions
@@ -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 { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Req,
UseGuards,
ValidationPipe,
} from '@nestjs/common';
import {
ApiAcceptedResponse,
ApiBearerAuth,
ApiBody,
ApiExtraModels,
ApiOkResponse,
ApiTags,
@@ -27,9 +13,6 @@ import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
EventsInvestmentScreenDto,
IncomeInvestmentScreenDto,
InvestmentScreenDto,
investmentScreenSchema,
TbankSyncAcceptedDto,
TbankSyncStatusDto,
TimestampInvestmentScreenDto,
} from './tbank-sync.dto';
@@ -37,9 +20,10 @@ import { TbankSyncService } from './tbank-sync.service';
@ApiTags('tbank-sync')
@ApiExtraModels(
IncomeInvestmentScreenDto,
EventsInvestmentScreenDto,
IncomeInvestmentScreenDto,
TimestampInvestmentScreenDto,
TbankSyncStatusDto,
)
@ApiProblemResponses()
@ApiBearerAuth()
@@ -47,28 +31,6 @@ import { TbankSyncService } from './tbank-sync.service';
@Controller('tbank-sync')
export class TbankSyncController {
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')
@ApiOkResponse({ type: TbankSyncStatusDto })
status(
@@ -227,3 +227,46 @@ export class TbankSyncStatusDto {
@ApiProperty({ type: String, nullable: true }) declare errorCode:
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 { OperationsModule } from '../operations/operations.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
import { TbankHourlyScheduler } from './tbank-hourly.scheduler';
import { TbankSyncController } from './tbank-sync.controller';
import { TbankSyncRepository } from './tbank-sync.repository';
import { TbankSyncService } from './tbank-sync.service';
@@ -15,8 +17,10 @@ import { TbankSyncService } from './tbank-sync.service';
PortfolioModule,
OperationsModule,
InstrumentSourcesModule,
UpcomingEventsModule,
],
controllers: [TbankSyncController],
providers: [TbankSyncService, TbankSyncRepository],
providers: [TbankSyncService, TbankSyncRepository, TbankHourlyScheduler],
exports: [TbankHourlyScheduler, TbankSyncRepository],
})
export class TbankSyncModule {}
@@ -12,6 +12,15 @@ export type ScreenProgress = {
children: string[];
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()
export class TbankSyncRepository {
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'
? 'all'
: accountScope,
true,
);
const positions = saved.data?.positions ?? [];
const instruments = [