feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { normalizeScreen } from './tbank-screen';
|
||||
|
||||
describe('manual sync screen boundary', () => {
|
||||
it('normalizes equivalent descriptors to the same scope', () => {
|
||||
expect(normalizeScreen({ screen: 'stocks', query: ' sber ' })).toEqual({
|
||||
screen: 'stocks',
|
||||
query: 'SBER',
|
||||
});
|
||||
expect(
|
||||
JSON.stringify(
|
||||
normalizeScreen({
|
||||
screen: 'operations',
|
||||
accountId: 'a',
|
||||
categories: ['tax', 'income', 'tax'],
|
||||
limit: 20,
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
JSON.stringify(
|
||||
normalizeScreen({
|
||||
limit: 20,
|
||||
categories: ['income', 'tax'],
|
||||
accountId: 'a',
|
||||
screen: 'operations',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
it('rejects missing identities and fields that would expand another screen', () => {
|
||||
for (const scope of [
|
||||
{ screen: 'accounts', accountId: 'a' },
|
||||
{ screen: 'account' },
|
||||
{ screen: 'stock' },
|
||||
{ screen: 'stocks', query: ' ' },
|
||||
{ screen: 'events', accountId: 'a', instrumentUid: 'uid' },
|
||||
{ screen: 'income', accountId: 'a', from: '2026-01-01' },
|
||||
{
|
||||
screen: 'events',
|
||||
accountId: 'a',
|
||||
from: '2026-02-01',
|
||||
to: '2026-01-01',
|
||||
},
|
||||
] as Parameters<typeof normalizeScreen>[0][])
|
||||
expect(() => normalizeScreen(scope)).toThrow();
|
||||
});
|
||||
it('retains inclusive calendar days, rejecting instant values for date-only owners', () => {
|
||||
const scope = {
|
||||
screen: 'events' as const,
|
||||
accountId: 'all',
|
||||
from: '2026-01-01',
|
||||
to: '2026-01-01',
|
||||
};
|
||||
expect(normalizeScreen(scope)).toEqual(scope);
|
||||
expect(() =>
|
||||
normalizeScreen({ ...scope, from: '2026-01-01T00:00:00Z' }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
normalizeScreen({
|
||||
screen: 'stock',
|
||||
instrumentUid: 'uid',
|
||||
candleRange: { from: '2026-01-02', to: '2026-01-01' },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||
|
||||
// Reject fields from other screens before reserving any source work.
|
||||
export function normalizeScreen(
|
||||
input: InvestmentScreenDto,
|
||||
): InvestmentScreenDto {
|
||||
const commonPeriod = ['from', 'to'];
|
||||
const ledger = [...commonPeriod, 'categories', 'limit'];
|
||||
const allowed: Record<InvestmentScreenDto['screen'], string[]> = {
|
||||
accounts: [],
|
||||
account: ['accountId', ...ledger],
|
||||
operations: ['accountId', 'instrumentUid', ...ledger],
|
||||
income: ['accountId', ...commonPeriod],
|
||||
events: ['accountId', ...commonPeriod],
|
||||
stocks: ['query'],
|
||||
bonds: ['query'],
|
||||
stock: [
|
||||
'accountId',
|
||||
'instrumentUid',
|
||||
...ledger,
|
||||
'candlePeriod',
|
||||
'candleRange',
|
||||
],
|
||||
bond: [
|
||||
'accountId',
|
||||
'instrumentUid',
|
||||
...ledger,
|
||||
'candlePeriod',
|
||||
'candleRange',
|
||||
],
|
||||
};
|
||||
const fail = (): never => {
|
||||
throw new BadRequestException({
|
||||
message: [
|
||||
{
|
||||
property: 'scope',
|
||||
constraints: { scope: 'Invalid screen descriptor' },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
if (!allowed[input.screen]) fail();
|
||||
for (const key of Object.keys(input))
|
||||
if (
|
||||
key !== 'screen' &&
|
||||
input[key as keyof InvestmentScreenDto] !== undefined &&
|
||||
!allowed[input.screen].includes(key)
|
||||
)
|
||||
fail();
|
||||
const accountId = input.accountId?.trim();
|
||||
const query = input.query?.trim().toUpperCase();
|
||||
if (
|
||||
['account', 'operations', 'income', 'events'].includes(input.screen) &&
|
||||
!accountId
|
||||
)
|
||||
fail();
|
||||
if (
|
||||
['stocks', 'bonds'].includes(input.screen) &&
|
||||
(!query || query.length < 2)
|
||||
)
|
||||
fail();
|
||||
if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail();
|
||||
if (input.accountId !== undefined && !accountId) fail();
|
||||
if (['income', 'events'].includes(input.screen)) {
|
||||
if (Boolean(input.from) !== Boolean(input.to)) fail();
|
||||
for (const value of [input.from, input.to])
|
||||
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail();
|
||||
}
|
||||
if (input.from && input.to && new Date(input.from) > new Date(input.to))
|
||||
fail();
|
||||
if (
|
||||
input.candleRange &&
|
||||
new Date(input.candleRange.from) >= new Date(input.candleRange.to)
|
||||
)
|
||||
fail();
|
||||
// Fixed field order is part of coalescing, irrespective of JSON input order.
|
||||
return {
|
||||
screen: input.screen,
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(input.from ? { from: input.from } : {}),
|
||||
...(input.to ? { to: input.to } : {}),
|
||||
...(input.instrumentUid
|
||||
? { instrumentUid: input.instrumentUid.toLowerCase() }
|
||||
: {}),
|
||||
...(query ? { query } : {}),
|
||||
...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}),
|
||||
...(input.candleRange
|
||||
? {
|
||||
candleRange: {
|
||||
from: new Date(input.candleRange.from).toISOString(),
|
||||
to: new Date(input.candleRange.to).toISOString(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(input.categories
|
||||
? { categories: [...new Set(input.categories)].sort() }
|
||||
: {}),
|
||||
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Body,
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
ValidationPipe,
|
||||
type ValidationError,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiAcceptedResponse,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import {
|
||||
InvestmentScreenDto,
|
||||
TbankSyncAcceptedDto,
|
||||
TbankSyncStatusDto,
|
||||
} from './tbank-sync.dto';
|
||||
import { TbankSyncService } from './tbank-sync.service';
|
||||
|
||||
@ApiTags('tbank-sync')
|
||||
@ApiProblemResponses()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tbank-sync')
|
||||
export class TbankSyncController {
|
||||
constructor(private readonly sync: TbankSyncService) {}
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiBody({ type: InvestmentScreenDto })
|
||||
@ApiAcceptedResponse({ type: TbankSyncAcceptedDto })
|
||||
request(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
expectedType: InvestmentScreenDto,
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
exceptionFactory: (message) =>
|
||||
new BadRequestException({ message: descriptorErrors(message) }),
|
||||
}),
|
||||
)
|
||||
input: unknown,
|
||||
) {
|
||||
return this.sync.request(request.user.sub, input as InvestmentScreenDto);
|
||||
}
|
||||
@Get(':id')
|
||||
@ApiOkResponse({ type: TbankSyncStatusDto })
|
||||
status(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.sync.status(request.user.sub, id);
|
||||
}
|
||||
}
|
||||
|
||||
function descriptorErrors(
|
||||
errors: ValidationError[],
|
||||
prefix = '',
|
||||
): ValidationError[] {
|
||||
return errors.flatMap((error) => [
|
||||
{ ...error, property: `${prefix}${error.property}` },
|
||||
...descriptorErrors(error.children ?? [], `${prefix}${error.property}/`),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import { operationCategories } from '../operations/operations.api';
|
||||
|
||||
const maxAccountIdLength = 256;
|
||||
const maxQueryLength = 80;
|
||||
const maxPageSize = 1000;
|
||||
|
||||
export const investmentScreens = [
|
||||
'accounts',
|
||||
'account',
|
||||
'operations',
|
||||
'income',
|
||||
'events',
|
||||
'stocks',
|
||||
'bonds',
|
||||
'stock',
|
||||
'bond',
|
||||
] as const;
|
||||
export class SyncCandleRangeDto {
|
||||
@ApiProperty({ format: 'date-time' }) @IsDateString() declare from: string;
|
||||
@ApiProperty({ format: 'date-time' }) @IsDateString() declare to: string;
|
||||
}
|
||||
export class InvestmentScreenDto {
|
||||
@ApiProperty({ enum: investmentScreens })
|
||||
@IsIn(investmentScreens)
|
||||
declare screen: (typeof investmentScreens)[number];
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(maxAccountIdLength)
|
||||
accountId?: string;
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString({ strict: true })
|
||||
from?: string;
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString({ strict: true })
|
||||
to?: string;
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
instrumentUid?: string;
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(maxQueryLength)
|
||||
query?: string;
|
||||
@ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] })
|
||||
@IsOptional()
|
||||
@IsIn(['month', 'halfYear', 'year', 'all'])
|
||||
candlePeriod?: 'month' | 'halfYear' | 'year' | 'all';
|
||||
@ApiPropertyOptional({ type: SyncCandleRangeDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => SyncCandleRangeDto)
|
||||
candleRange?: SyncCandleRangeDto;
|
||||
@ApiPropertyOptional({ enum: operationCategories, isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(operationCategories, { each: true })
|
||||
categories?: (typeof operationCategories)[number][];
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: maxPageSize })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(maxPageSize)
|
||||
limit?: number;
|
||||
}
|
||||
export class TbankSyncAcceptedDto {
|
||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||
@ApiProperty({ type: InvestmentScreenDto })
|
||||
declare scope: InvestmentScreenDto;
|
||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||
}
|
||||
export class TbankSyncProgressDto {
|
||||
@ApiProperty() declare completed: number;
|
||||
@ApiProperty() declare total: number;
|
||||
@ApiProperty() declare discovering: boolean;
|
||||
}
|
||||
export class TbankSyncStatusDto {
|
||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||
@ApiProperty({ type: InvestmentScreenDto, nullable: true })
|
||||
declare scope: InvestmentScreenDto | null;
|
||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
|
||||
declare state: 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
@ApiProperty({ type: String, nullable: true }) declare errorCode:
|
||||
string | null;
|
||||
@ApiProperty({ type: TbankSyncProgressDto })
|
||||
declare progress: TbankSyncProgressDto;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto, isArray: true })
|
||||
declare resources: InvestmentResourceMetaDto[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { OperationsModule } from '../operations/operations.module';
|
||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||
import { InstrumentsModule } from '../instruments/instruments.module';
|
||||
import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module';
|
||||
import { IncomeModule } from '../income/income.module';
|
||||
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
|
||||
import { TbankSyncController } from './tbank-sync.controller';
|
||||
import { TbankSyncService } from './tbank-sync.service';
|
||||
import { TbankSyncRepository } from './tbank-sync.repository';
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
TbankModule,
|
||||
PortfolioModule,
|
||||
OperationsModule,
|
||||
InstrumentSourcesModule,
|
||||
InstrumentsModule,
|
||||
BrokerageAccountsModule,
|
||||
IncomeModule,
|
||||
UpcomingEventsModule,
|
||||
],
|
||||
controllers: [TbankSyncController],
|
||||
providers: [TbankSyncService, TbankSyncRepository],
|
||||
})
|
||||
export class TbankSyncModule {}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { Prisma } from '../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service';
|
||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||
export type ScreenIntent = { scope: InvestmentScreenDto; now: string };
|
||||
export type ScreenProgress = {
|
||||
children: string[];
|
||||
attempts?: Record<string, string>;
|
||||
dispatched: boolean;
|
||||
};
|
||||
@Injectable()
|
||||
export class TbankSyncRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
reserve(
|
||||
userId: string,
|
||||
revision: string,
|
||||
scope: InvestmentScreenDto,
|
||||
runId: string,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const valid = await tx.tbankConnection.updateMany({
|
||||
where: { userId, revision, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!valid.count) return null;
|
||||
const scopeKey = `screen:${createHash('sha256').update(JSON.stringify(scope)).digest('hex')}`;
|
||||
const key = { userId, revision, scopeKey };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: key },
|
||||
});
|
||||
if (previous?.state === 'running' && previous.processRunId === runId) {
|
||||
const progress = previous.progress as ScreenProgress;
|
||||
const children = progress.children ?? [];
|
||||
if (
|
||||
!progress.dispatched ||
|
||||
(await tx.tbankSyncWork.count({
|
||||
where: { userId, revision, id: { in: children }, state: 'running' },
|
||||
}))
|
||||
)
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
attemptId: randomUUID(),
|
||||
processRunId: runId,
|
||||
state: 'running',
|
||||
priority: 0,
|
||||
intent: JSON.parse(
|
||||
JSON.stringify({ scope, now: new Date().toISOString() }),
|
||||
) as Prisma.InputJsonObject,
|
||||
progress: { children: [], dispatched: false },
|
||||
errorCode: null,
|
||||
};
|
||||
const work = await tx.tbankSyncWork.upsert({
|
||||
where: { userId_revision_scopeKey: key },
|
||||
create: { ...key, ...data, id: randomUUID() },
|
||||
update: { ...data, id: randomUUID() },
|
||||
});
|
||||
return { work, launch: true };
|
||||
});
|
||||
}
|
||||
read(userId: string, revision: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const work = await tx.tbankSyncWork.findFirst({
|
||||
where: { userId, revision, id },
|
||||
});
|
||||
if (!work) return null;
|
||||
const progress = work.progress as ScreenProgress;
|
||||
const children = await tx.tbankSyncWork.findMany({
|
||||
where: { userId, revision, id: { in: progress.children ?? [] } },
|
||||
select: { id: true, attemptId: true, state: true, errorCode: true },
|
||||
});
|
||||
return {
|
||||
work,
|
||||
children: children.map((child) =>
|
||||
progress.attempts?.[child.id] &&
|
||||
progress.attempts[child.id] !== child.attemptId
|
||||
? { ...child, state: 'cancelled', errorCode: null }
|
||||
: child,
|
||||
),
|
||||
progress,
|
||||
};
|
||||
});
|
||||
}
|
||||
async progress(
|
||||
attempt: TbankSyncAttempt,
|
||||
children: string[],
|
||||
dispatched: boolean,
|
||||
errorCode: string | null = null,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.tbankSyncWork.findFirst({ where: attempt });
|
||||
if (!previous) return { count: 0 };
|
||||
const old = previous.progress as ScreenProgress;
|
||||
const rows = await tx.tbankSyncWork.findMany({
|
||||
where: {
|
||||
userId: attempt.userId,
|
||||
revision: attempt.revision,
|
||||
id: { in: children },
|
||||
},
|
||||
select: { id: true, attemptId: true },
|
||||
});
|
||||
const attempts = {
|
||||
...Object.fromEntries(rows.map((row) => [row.id, row.attemptId])),
|
||||
...old.attempts,
|
||||
};
|
||||
return tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: {
|
||||
progress: { children: [...new Set(children)], attempts, dispatched },
|
||||
...(errorCode ? { state: 'failed', errorCode } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankMembershipService,
|
||||
InstrumentMetadataService,
|
||||
} 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 type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service';
|
||||
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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
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 running = saved.children.some((child) => child.state === 'running');
|
||||
const failed =
|
||||
saved.work.errorCode ??
|
||||
saved.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'
|
||||
: saved.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: saved.children.filter((child) => child.state !== 'running')
|
||||
.length,
|
||||
total: saved.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user