refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196)
Backend verify / commits (pull_request) Successful in 1m25s
Backend verify / security (pull_request) Successful in 1m8s
Backend verify / verify (pull_request) Successful in 6m20s

This commit is contained in:
2026-11-05 22:22:48 +03:00
parent 83b515a453
commit d1c5f575cf
36 changed files with 182 additions and 832 deletions
@@ -6,7 +6,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import {
IncomeAnalyticsQueryDto,
IncomeAnalyticsResourceDto,
@@ -26,12 +25,10 @@ export class IncomeAnalyticsController {
@ApiOkResponse({ type: IncomeSummaryResourceDto })
allSummary(
@Req() request: Request & { user: AccessPayload },
@Query() query: InvestmentReadQueryDto,
) {
return this.income.summary(
request.user.sub,
'all',
query.sync === 'observe',
);
}
@@ -46,7 +43,6 @@ export class IncomeAnalyticsController {
'all',
query.from,
query.to,
query.sync === 'observe',
);
}
@@ -55,12 +51,10 @@ export class IncomeAnalyticsController {
summary(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.summary(
request.user.sub,
accountId,
query.sync === 'observe',
);
}
@@ -76,7 +70,6 @@ export class IncomeAnalyticsController {
accountId,
query.from,
query.to,
query.sync === 'observe',
);
}
}
@@ -5,14 +5,13 @@ import { MoneyDto } from '#src/common/money.dto';
import {
InvestmentDependencyDto,
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../investment-resource.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
export class IncomeAnalyticsQueryDto {
@ApiPropertyOptional({
format: 'date',
description:
@@ -44,14 +44,12 @@ export class IncomeAnalyticsService {
async summary(
userId: string,
accountId: string,
observe = false,
): Promise<IncomeSummaryResourceDto> {
const plan = planIncomePeriod(undefined, undefined);
const [selected] = await this.operations.collectRanges(
userId,
accountId,
() => [plan.selected],
observe,
);
if (selected.items === null) return { data: null, meta: selected.meta };
assert(selected.meta.lastSuccessfulAt);
@@ -71,14 +69,12 @@ export class IncomeAnalyticsService {
accountId: string,
from: string | undefined,
to: string | undefined,
observe = false,
): Promise<IncomeAnalyticsResourceDto> {
const plan = planIncomePeriod(from, to);
const [current] = await this.operations.collectRanges(
userId,
accountId,
() => [plan.selected],
observe,
);
const dependencies: IncomeDependencyDto[] = [
{ ...current.meta, role: 'selected' },
@@ -99,7 +99,7 @@ export class CandleService {
private readonly logger: StructuredLogger,
) {}
async read(userId: string, input: CandleInput, observe = false) {
async read(userId: string, input: CandleInput) {
const { revision } = await this.access.local(userId);
const [detail] = await this.sources.readMany(
userId,
@@ -119,7 +119,7 @@ export class CandleService {
const hasCompleteRange = Boolean(
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
);
if (!observe && (!saved || !hasCompleteRange)) {
if (!saved || !hasCompleteRange) {
await this.request(userId, revision, input, now);
}
const work = await this.repository.work(userId, revision, scope(input));
@@ -13,12 +13,10 @@ import {
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
import {
CatalogDiscoveryRepository,
catalogQuery,
type CatalogQuery,
catalogScope,
} from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import {
catalogSearchLimit,
selectCatalogInstruments,
@@ -26,7 +24,6 @@ import {
import { publicInstrument } from './public-instrument';
const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0;
const tickerAmbiguityLimit = 2;
@@ -34,203 +31,56 @@ const tickerAmbiguityLimit = 2;
@Injectable()
export class CatalogDiscoveryService {
constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly enrichment: CatalogEnrichmentService,
) {}
async read(userId: string, input: CatalogQuery) {
input = catalogQuery(input);
const connection = await this.access.local(userId);
const saved = await this.repository.read(input);
return {
data: saved.data,
meta: investmentResourceMeta({
resourceKey: catalogScope(input),
dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt,
freshForMs: Number.MAX_SAFE_INTEGER,
work: await this.repository.work(userId, connection.revision, input),
}),
};
}
async search(userId: string, input: CatalogQuery, observe = false) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input);
const sources = discovery.data
? await this.enrichment.readMany(
userId,
input.instrumentType,
discovery.data.map((item) => item.uid),
observe,
)
: [];
const dependencies = [
discovery.meta,
...sources.map((source) => source.meta),
];
return {
data:
discovery.data?.map((short) => ({
...short,
detail:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'detail',
)?.instrument ?? null,
price:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'last-price',
)?.price ?? null,
})) ?? null,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability:
discovery.data === null
? 'missing'
: sources.every((source) => source.meta.completeness === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: Number.MAX_SAFE_INTEGER,
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
async search(userId: string, input: CatalogQuery) {
return this.onlineSearch(userId, catalogQuery(input));
}
async detail(
userId: string,
instrumentType: 'bond' | 'share',
uid: string,
observe = false,
) {
if (!observe) {
const instrument = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.getShare(token, uid)
: 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(
userId,
instrumentType,
[uid],
observe,
['detail'],
const instrument = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid),
);
const source = sources[0];
if (source.meta.completeness === 'complete' && !source.instrument) {
throw new NotFoundException();
}
const instrument = source.instrument;
let slug = uid;
const dependencies = [source.meta];
if (
instrument?.ticker &&
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
) {
const alias = await this.read(userId, {
instrumentType,
query: instrument.ticker,
exactTicker: true,
});
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta);
}
if (!instrument) throw new NotFoundException();
return {
data: instrument ? { ...instrument, uid, slug } : null,
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt)
: null,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: tbankSourceTtlMs.portfolio,
work: source.meta.sync.id
? {
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
}
: null,
dependencies,
work: null,
}),
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
async byTicker(
userId: string,
instrumentType: 'bond' | 'share',
ticker: string,
observe = false,
) {
if (!observe) {
const query = ticker.trim().toUpperCase();
const found = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.findShares(token, query)
: 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(userId, {
const query = ticker.trim().toUpperCase();
const found = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query),
);
const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
query: ticker,
exactTicker: true,
});
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
@@ -238,34 +88,8 @@ export class CatalogDiscoveryService {
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = lookup.data
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
: null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return {
data: result?.data
? { ...result.data, slug: ticker.trim().toUpperCase() }
: null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.portfolio,
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
}
private async onlineSearch(userId: string, input: CatalogQuery) {
@@ -222,9 +222,8 @@ export class CatalogEnrichmentService {
instrumentType: 'bond' | 'share',
uid: string,
section: 'company' | 'metrics' | 'quote',
observe = false,
) {
if (section === 'company' && !observe) {
if (section === 'company') {
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
const instrument =
instrumentType === 'share'
@@ -254,25 +253,22 @@ export class CatalogEnrichmentService {
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
};
}
const kinds: CatalogSource['kind'][] =
section === 'company'
? ['detail', 'description', 'fundamentals']
: [
...(instrumentType === 'bond'
? ['bond-current' as const]
: ['detail' as const]),
'last-price',
'trading-status',
'comparison',
...(section === 'metrics'
? ['coupons' as const, 'yield' as const, 'rating' as const]
: []),
];
const kinds: CatalogSource['kind'][] = [
...(instrumentType === 'bond'
? ['bond-current' as const]
: ['detail' as const]),
'last-price',
'trading-status',
'comparison',
...(section === 'metrics'
? ['coupons' as const, 'yield' as const, 'rating' as const]
: []),
];
const sources = await this.readMany(
userId,
instrumentType,
[uid],
observe,
false,
kinds,
);
const first = sources[0];
@@ -300,7 +296,7 @@ export class CatalogEnrichmentService {
fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt)
: null,
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
freshForMs: tbankSourceTtlMs.quote,
work: null,
dependencies,
});
@@ -27,12 +27,10 @@ export class BondCatalogService {
async search(
userId: string,
query: string,
observe = false,
): Promise<BondSearchResourceDto> {
const result = await this.discovery.search(
userId,
{ instrumentType: 'bond', query },
observe,
);
return {
...result,
@@ -60,9 +58,8 @@ export class BondCatalogService {
async get(
userId: string,
uid: string,
observe = false,
): Promise<BondResourceDto> {
const result = await this.discovery.detail(userId, 'bond', uid, observe);
const result = await this.discovery.detail(userId, 'bond', uid);
return {
...result,
data: result.data
@@ -74,13 +71,11 @@ export class BondCatalogService {
async getByTicker(
userId: string,
ticker: string,
observe = false,
): Promise<BondResourceDto> {
const result = await this.discovery.byTicker(
userId,
'bond',
ticker,
observe,
);
return {
...result,
@@ -55,14 +55,12 @@ export class BondIncomeService {
async metrics(
userId: string,
uid: string,
observe = false,
): Promise<BondMetricsResourceDto> {
const result = await this.sources.section(
userId,
'bond',
uid,
'metrics',
observe,
);
return {
data: result.values.instrument
@@ -83,7 +81,6 @@ export class BondIncomeService {
async coupons(
userId: string,
uid: string,
observe = false,
): Promise<BondCouponsResourceDto> {
const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString());
@@ -94,7 +91,7 @@ export class BondIncomeService {
userId,
'bond',
[uid],
observe,
false,
['detail', 'coupons'],
{ from, to },
);
@@ -39,14 +39,12 @@ export class BondMarketService {
async quote(
userId: string,
uid: string,
observe = false,
): Promise<BondQuoteResourceDto> {
const result = await this.sources.section(
userId,
'bond',
uid,
'quote',
observe,
);
return {
data: result.values.instrument
@@ -60,12 +58,10 @@ export class BondMarketService {
userId: string,
uid: string,
period: BondCandlesQueryDto['period'] = 'month',
observe = false,
) {
return this.history.read(
userId,
{ uid, instrumentType: 'bond', period },
observe,
);
}
}
@@ -13,7 +13,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { InvestmentReadQueryDto } from '#src/modules/investments/investment-resource.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
import { BondIncomeService } from '../application/bond-income.service';
@@ -48,9 +47,8 @@ export class BondController {
metrics(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
return this.income.metrics(request.user.sub, uid);
}
@Get()
@@ -62,7 +60,6 @@ export class BondController {
return this.bonds.search(
request.user.sub,
query.query,
query.sync === 'observe',
);
}
@@ -71,12 +68,10 @@ export class BondController {
getByTicker(
@Req() request: Request & { user: AccessPayload },
@Param() params: BondTickerParamsDto,
@Query() query: InvestmentReadQueryDto,
) {
return this.bonds.getByTicker(
request.user.sub,
params.ticker,
query.sync === 'observe',
);
}
@@ -85,9 +80,8 @@ export class BondController {
get(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
return this.bonds.get(request.user.sub, uid);
}
@Get(':uid/quote')
@@ -95,9 +89,8 @@ export class BondController {
quote(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
return this.market.quote(request.user.sub, uid);
}
@Get(':uid/candles')
@@ -111,7 +104,6 @@ export class BondController {
request.user.sub,
uid,
query.period,
query.sync === 'observe',
);
}
@@ -129,8 +121,7 @@ export class BondController {
coupons(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
return this.income.coupons(request.user.sub, uid);
}
}
@@ -13,14 +13,12 @@ export class GetStockCompanyUseCase {
async execute(
userId: string,
uid: string,
observe = false,
): Promise<StockCompanyResourceDto> {
const result = await this.sources.section(
userId,
'share',
uid,
'company',
observe,
);
return {
data: result.values.instrument
@@ -23,12 +23,10 @@ export class StockCatalogService {
async search(
userId: string,
query: string,
observe = false,
): Promise<StockSearchResourceDto> {
const result = await this.discovery.search(
userId,
{ instrumentType: 'share', query },
observe,
);
return {
...result,
@@ -60,9 +58,8 @@ export class StockCatalogService {
async get(
userId: string,
uid: string,
observe = false,
): Promise<StockResourceDto> {
const result = await this.discovery.detail(userId, 'share', uid, observe);
const result = await this.discovery.detail(userId, 'share', uid);
return {
...result,
data: result.data
@@ -74,13 +71,11 @@ export class StockCatalogService {
async getByTicker(
userId: string,
ticker: string,
observe = false,
): Promise<StockResourceDto> {
const result = await this.discovery.byTicker(
userId,
'share',
ticker,
observe,
);
return {
...result,
@@ -52,7 +52,6 @@ export class StockIncomeService {
async dividends(
userId: string,
uid: string,
observe = false,
): Promise<StockDividendsResourceDto> {
const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString());
@@ -63,7 +62,7 @@ export class StockIncomeService {
userId,
'share',
[uid],
observe,
false,
['detail', 'dividends'],
{ from, to },
);
@@ -35,14 +35,12 @@ export class StockMarketService {
async quote(
userId: string,
uid: string,
observe = false,
): Promise<StockQuoteResourceDto> {
const result = await this.sources.section(
userId,
'share',
uid,
'quote',
observe,
);
return {
data: result.values.instrument
@@ -56,12 +54,10 @@ export class StockMarketService {
userId: string,
uid: string,
period: StockCandlesQueryDto['period'] = 'month',
observe = false,
) {
return this.history.read(
userId,
{ uid, instrumentType: 'share', period },
observe,
);
}
}
@@ -19,7 +19,6 @@ import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swa
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { InvestmentReadQueryDto } from '#src/modules/investments/investment-resource.dto';
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
import { StockCatalogService } from '../application/stock-catalog.service';
@@ -65,7 +64,6 @@ export class StockController {
return this.stocks.search(
request.user.sub,
query.query,
query.sync === 'observe',
);
}
@@ -74,12 +72,10 @@ export class StockController {
getByTicker(
@Req() request: Request & { user: AccessPayload },
@Param() params: StockTickerParamsDto,
@Query() query: InvestmentReadQueryDto,
) {
return this.stocks.getByTicker(
request.user.sub,
params.ticker,
query.sync === 'observe',
);
}
@@ -116,9 +112,8 @@ export class StockController {
get(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
return this.stocks.get(request.user.sub, uid);
}
@Get(':uid/quote')
@@ -126,9 +121,8 @@ export class StockController {
quote(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
return this.market.quote(request.user.sub, uid);
}
@Get(':uid/candles')
@@ -142,7 +136,6 @@ export class StockController {
request.user.sub,
uid,
query.period,
query.sync === 'observe',
);
}
@@ -160,12 +153,10 @@ export class StockController {
company(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.companyInfo.execute(
request.user.sub,
uid,
query.sync === 'observe',
);
}
@@ -174,12 +165,10 @@ export class StockController {
dividends(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.dividends(
request.user.sub,
uid,
query.sync === 'observe',
);
}
}
@@ -17,7 +17,6 @@ import {
MIN_INSTRUMENT_QUERY_LENGTH,
} from '#src/modules/investments/instrument-query';
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '#src/modules/investments/investment-resource.dto';
import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api';
@@ -104,7 +103,7 @@ export class StockScreenerPageDto {
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
}
export class StockSearchQueryDto extends InvestmentReadQueryDto {
export class StockSearchQueryDto {
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
@@ -126,7 +125,7 @@ export class StockTickerParamsDto {
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
declare ticker: string;
}
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
export class StockCandlesQueryDto {
@ApiPropertyOptional({
enum: ['month', 'halfYear', 'year', 'all'],
default: 'month',
@@ -1,5 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = [
'available',
@@ -17,13 +16,6 @@ export const attemptStateValues = [
'failed',
] as const;
export class InvestmentReadQueryDto {
@ApiPropertyOptional({ enum: ['observe'] })
@IsOptional()
@IsIn(['observe'])
declare sync?: 'observe';
}
export class InvestmentAttemptProblemDto {
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
@@ -238,7 +238,6 @@ export class BrokerageOperationsService {
userId: string,
accountId: string,
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
observe = false,
) {
const connection = await this.access.local(userId, accountId);
const input = {
@@ -252,21 +251,19 @@ export class BrokerageOperationsService {
periods: ranges,
};
let saved = await this.repository.readPage(input);
if (!observe) {
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
(saved?.periods ?? []).map((period) => ({
accountId: account.accountId,
...period,
})),
);
if (targets.length) {
await this.sync.readPlan(userId, targets, {
movingEnd: saved?.periods?.some((period) => period.movingEnd),
revision: connection.revision,
});
}
saved = await this.repository.readPage(input);
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
(saved?.periods ?? []).map((period) => ({
accountId: account.accountId,
...period,
})),
);
if (targets.length) {
await this.sync.readPlan(userId, targets, {
movingEnd: saved?.periods?.some((period) => period.movingEnd),
revision: connection.revision,
});
}
saved = await this.repository.readPage(input);
const now = new Date();
const hourly = await this.hourlySync.hourly(userId);
const membership = investmentResourceMeta({
@@ -1,43 +0,0 @@
import type { Request } from 'express';
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiExtraModels,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
EventsInvestmentScreenDto,
IncomeInvestmentScreenDto,
TbankSyncStatusDto,
TimestampInvestmentScreenDto,
} from './tbank-sync.dto';
import { TbankSyncService } from './tbank-sync.service';
@ApiTags('tbank-sync')
@ApiExtraModels(
EventsInvestmentScreenDto,
IncomeInvestmentScreenDto,
TimestampInvestmentScreenDto,
TbankSyncStatusDto,
)
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('tbank-sync')
export class TbankSyncController {
constructor(private readonly sync: TbankSyncService) {}
@Get(':id')
@ApiOkResponse({ type: TbankSyncStatusDto })
status(
@Req() request: Request & { user: AccessPayload },
@Param('id') id: string,
) {
return this.sync.status(request.user.sub, id);
}
}
@@ -209,19 +209,6 @@ export const investmentScreenSchema = {
},
};
export class TbankSyncStatusDto {
@ApiProperty({ format: 'uuid' }) declare id: string;
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
declare scope: InvestmentScreenDto | null;
@ApiProperty({ format: 'date-time' }) declare now: string;
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
declare state: 'cancelled' | 'failed' | 'running' | 'succeeded';
@ApiProperty({ type: String, nullable: true }) declare errorCode:
null | string;
}
export class HourlySyncStatusDto {
@ApiProperty({
enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'],
@@ -9,8 +9,6 @@ import { PortfolioModule } from '../portfolio/portfolio.module';
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
import { TbankHourlyScheduler } from './tbank-hourly.scheduler';
import { TbankSyncRepositoryModule } from './tbank-sync-repository.module';
import { TbankSyncController } from './tbank-sync.controller';
import { TbankSyncService } from './tbank-sync.service';
@Module({
imports: [
AuthModule,
@@ -21,8 +19,7 @@ import { TbankSyncService } from './tbank-sync.service';
UpcomingEventsModule,
TbankSyncRepositoryModule,
],
controllers: [TbankSyncController],
providers: [TbankSyncService, TbankHourlyScheduler],
providers: [TbankHourlyScheduler],
exports: [TbankHourlyScheduler],
})
export class TbankSyncModule {}
@@ -4,19 +4,9 @@ jest.mock('#src/prisma/prisma.service', () => ({
import {
hourlyProgress,
screenIntent,
screenProgress,
} from './tbank-sync.repository';
describe('screen sync intent', () => {
it('restores a persisted intent and rejects a corrupted one', () => {
const intent = { now: '2026-11-02T00:00:00.000Z', scope: { screen: 'accounts' } };
expect(screenIntent(intent)).toEqual(intent);
expect(() => screenIntent({ source: 'hourly' })).toThrow('Corrupted screen sync intent');
expect(() => screenIntent(null)).toThrow('Corrupted screen sync intent');
});
});
describe('screen sync progress', () => {
it('restores a persisted progress unchanged', () => {
const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true };
@@ -10,16 +10,6 @@ import { toJson } from '#src/prisma/to-json';
import type { InvestmentAttemptDto } from '../investment-resource.dto';
import type { InvestmentScreenDto } from './tbank-sync.dto';
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
// Json columns are written only by this repository; a shape check turns corruption into an explicit 500.
export function screenIntent(value: Prisma.JsonValue): ScreenIntent {
const intent = value as null | Partial<ScreenIntent>;
if (typeof intent?.now !== 'string' || typeof intent.scope?.screen !== 'string') {
throw new Error('Corrupted screen sync intent');
}
return intent as ScreenIntent;
}
export type ScreenProgress = {
attempts?: Record<string, string>;
children: string[];
@@ -189,30 +179,6 @@ export class TbankSyncRepository {
});
}
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 = screenProgress(work.progress);
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[],
@@ -1,103 +0,0 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { TbankAccessService } from '#src/integrations/tbank/tbank.api';
import { BrokerageOperationsSyncService } from '../operations/operations.api';
import { TbankSyncStatusDto } from './tbank-sync.dto';
import { screenIntent, TbankSyncRepository } from './tbank-sync.repository';
@Injectable()
export class TbankSyncService {
private readonly runId = randomUUID();
private readonly startedAt = new Date();
constructor(
private readonly repository: TbankSyncRepository,
private readonly access: TbankAccessService,
private readonly operations: BrokerageOperationsSyncService,
) {}
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 = screenIntent(saved.work.intent);
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';
if (
(await this.access.local(userId, intent.scope.accountId)).revision !==
connection.revision
) {
throw new NotFoundException();
}
return {
id,
...intent,
state,
errorCode: failed,
};
}
private async sourceStatus(
userId: string,
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
): Promise<TbankSyncStatusDto> {
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,
};
}
}
@@ -5,11 +5,10 @@ import { MoneyDto } from '#src/common/money.dto';
import { AccountReferenceDto } from '../account-reference.dto';
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../investment-resource.dto';
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
export class UpcomingEventsQueryDto {
@ApiPropertyOptional({
format: 'date',
description: