fix: harden auth and investment synchronization
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

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.
This commit is contained in:
2026-09-27 14:09:05 +03:00
parent 5d3835e796
commit 4796d9d3f0
55 changed files with 1954 additions and 569 deletions
+20 -24
View File
@@ -29,9 +29,8 @@ export class AuthService {
email: string,
password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const normalizedEmail = this.normalizeEmail(email);
const existing = await this.prisma.user.findUnique({
where: { email: normalizedEmail },
where: { email },
});
if (existing)
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
@@ -41,7 +40,7 @@ export class AuthService {
user = await this.prisma.user.create({
data: {
id: randomUUID(),
email: normalizedEmail,
email,
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
},
});
@@ -58,7 +57,7 @@ export class AuthService {
password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
const user = await this.prisma.user.findUnique({
where: { email: this.normalizeEmail(email) },
where: { email },
});
const valid = await bcrypt.compare(
password,
@@ -103,26 +102,27 @@ export class AuthService {
async logout(token?: string): Promise<void> {
if (!token) return;
let payload: RefreshPayload;
try {
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
}
} catch {
// Logout is intentionally idempotent.
return;
}
if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
}
}
@@ -187,10 +187,6 @@ export class AuthService {
};
}
private normalizeEmail(email: string): string {
return email;
}
private hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
@@ -1,21 +0,0 @@
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
jest.mock('../application/auth.service', () => ({
AuthService: class AuthService {},
}));
import { AuthController } from './auth.controller';
describe('AuthController response statuses', () => {
it.each(['register', 'login', 'refresh'] as const)(
'%s explicitly returns HTTP 200',
(handler) => {
// Nest stores @HttpCode metadata on the unbound handler function.
const handlerTarget = Object.getOwnPropertyDescriptor(
AuthController.prototype,
handler,
)?.value as object;
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200);
},
);
});
@@ -87,7 +87,7 @@ export class AuthController {
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
this.clearCookie(response);
if (error instanceof UnauthorizedException) this.clearCookie(response);
throw error;
}
}
@@ -116,7 +116,9 @@ describe('bank card calculations', () => {
amountKopecks: 5000n,
},
];
expect(bankSummary('2026-09', operations)).toEqual({
expect(
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, operations),
).toEqual({
expenses: 120n,
refunds: 500n,
net: -380n,
@@ -125,7 +127,9 @@ describe('bank card calculations', () => {
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
],
});
expect(bankSummary('2026-07', operations)).toEqual({
expect(
bankSummary({ from: '2026-07-01', to: '2026-07-31' }, operations),
).toEqual({
expenses: 0n,
refunds: 0n,
net: 0n,
@@ -141,7 +145,12 @@ describe('bank card calculations', () => {
amountKopecks: 2n ** 63n - 1n,
};
const total = 2n * (2n ** 63n - 1n);
expect(bankSummary('2026-09', [operation, operation])).toEqual({
expect(
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, [
operation,
operation,
]),
).toEqual({
expenses: total,
refunds: 0n,
net: total,
@@ -1,6 +1,5 @@
import { BankOperationKind } from './bank-operation-kind';
const ISO_MONTH_LENGTH = 7;
export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Moscow',
@@ -54,7 +53,7 @@ export function bankBalance(
}
export function bankSummary(
period: string | { from: string; to: string },
period: { from: string; to: string },
operations: Array<{
kind: string;
date: string;
@@ -70,12 +69,7 @@ export function bankSummary(
>();
// ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) {
if (
typeof period === 'string'
? operation.date.slice(0, ISO_MONTH_LENGTH) !== period
: operation.date < period.from || operation.date > period.to
)
continue;
if (operation.date < period.from || operation.date > period.to) continue;
if (
operation.kind !== BankOperationKind.Expense &&
operation.kind !== BankOperationKind.Refund
@@ -0,0 +1,113 @@
jest.mock('../infrastructure/candle.repository', () => ({
CandleRepository: class {},
}));
jest.mock('./catalog-enrichment.service', () => ({
CatalogEnrichmentService: class {},
}));
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
TbankAccessService: class {},
TbankClient: class {},
}));
import { CandleService } from './candle.service';
describe('CandleService forced demand', () => {
it.each([false, true])(
'reconciles the full range through the latest forced timestamp while force=%s is loading',
async (initialForce) => {
let entered = () => {};
let release = () => {};
let complete = () => {};
const started = new Promise<void>((resolve) => (entered = resolve));
const blocked = new Promise<void>((resolve) => (release = resolve));
const finished = new Promise<void>((resolve) => (complete = resolve));
let completed = 0;
const work = { id: 'work', attemptId: 'attempt' };
const repository = {
reserve: jest
.fn()
.mockResolvedValueOnce({ work, launch: true })
.mockResolvedValueOnce({
work: { id: 'other', attemptId: 'other-attempt' },
launch: true,
})
.mockResolvedValue({ work, launch: false }),
read: jest.fn().mockResolvedValue({
coverage: [
{
from: new Date('2026-01-01Z'),
to: new Date('2026-02-01Z'),
isComplete: true,
fetchedAt: new Date('2026-02-01Z'),
},
],
}),
stage: jest.fn().mockResolvedValue(true),
publish: jest.fn((_attempt, _intent, done = true) => {
if (done && ++completed === 2) complete();
return Promise.resolve(true);
}),
fail: jest.fn(),
};
const sources = {
ensureDetail: jest.fn().mockResolvedValue({
first_1dayCandleDate: {
seconds: String(Date.parse('2026-01-01Z') / 1000),
},
}),
};
const upstream = { getCandles: jest.fn().mockResolvedValue([]) };
upstream.getCandles.mockImplementationOnce(async () => {
entered();
await blocked;
return [];
});
const service = new CandleService(
repository as never,
sources as never,
{ tokenForAttempt: jest.fn().mockResolvedValue('token') } as never,
upstream as never,
{ event: jest.fn() } as never,
);
const input = {
uid: 'uid',
instrumentType: 'share' as const,
period: 'all' as const,
};
await service.request(
'owner',
'revision',
input,
new Date('2026-02-02T12:00:00Z'),
initialForce,
2,
);
await started;
const latest = new Date('2026-02-02T13:00:00Z');
await service.request(
'owner',
'revision',
{ ...input, uid: 'other' },
latest,
false,
1,
);
expect(
await service.request('owner', 'revision', input, latest, true, 0),
).toBe('work');
release();
await finished;
expect(upstream.getCandles).toHaveBeenCalledWith(
'token',
'uid',
new Date('2026-01-01Z'),
latest,
);
expect(repository.fail).not.toHaveBeenCalled();
expect(
upstream.getCandles.mock.calls.map((call: unknown[]) => call[1]),
).toEqual(['uid', 'uid', 'other']);
},
);
});
@@ -13,8 +13,8 @@ import {
TbankAccessService,
TbankClient,
type TbankShare,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { CandleRepository } from '../infrastructure/candle.repository';
@@ -39,8 +39,12 @@ type Producer = {
force: boolean;
priority: number;
observers: TbankSyncAttempt[];
ranges?: CandleRange[];
nextWindow?: number;
pass?: {
now: Date;
force: boolean;
ranges: CandleRange[];
nextWindow: number;
};
};
const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000;
@@ -323,9 +327,14 @@ export class CandleService {
id: reserved.work.id,
attemptId: reserved.work.attemptId,
};
const producer = this.producers.get(scope(input));
if (producer) {
producer.force ||= force;
producer.priority = Math.min(producer.priority, priority);
if (force && now > producer.now) producer.now = now;
}
if (reserved.launch) {
this.active.add(attempt.attemptId);
const producer = this.producers.get(scope(input));
if (producer) producer.observers.push(attempt);
else {
const next = { input, attempt, now, force, priority, observers: [] };
@@ -346,7 +355,7 @@ export class CandleService {
}
private async load(producer: Producer) {
const { input, attempt, now } = producer;
const { input, attempt } = producer;
const instrument = await this.sources.ensureDetail(
attempt.userId,
attempt.revision,
@@ -354,20 +363,24 @@ export class CandleService {
input.uid,
);
if (!instrument) throw new NotFoundException();
const range = rangeFor(input, instrument, now);
if (!producer.ranges) {
let pass = producer.pass;
if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) {
const { now, force } = producer;
const range = rangeFor(input, instrument, now);
const saved = await this.repository.read(input.uid, range);
producer.ranges = (
producer.force ? [range] : candleGaps(range, saved.coverage, now)
)
.flatMap(candleWindows)
.reverse();
producer.nextWindow = 0;
pass = producer.pass = {
now,
force,
ranges: (force ? [range] : candleGaps(range, saved.coverage, now))
.flatMap(candleWindows)
.reverse(),
nextWindow: 0,
};
}
const ranges = producer.ranges;
const index = producer.nextWindow ?? 0;
const { ranges, now, force } = pass;
const index = pass.nextWindow;
let window = ranges[index];
if (window && !producer.force) {
if (window && !force) {
const latest = await this.repository.read(input.uid, window);
const gaps = candleGaps(window, latest.coverage, now)
.flatMap(candleWindows)
@@ -398,7 +411,7 @@ export class CandleService {
if (!(await this.repository.stage(attempt, window, rows, now)))
return false;
if (
!producer.force &&
!force &&
!(await this.repository.publish(
attempt,
{ uid: input.uid, ranges: [window] },
@@ -406,13 +419,10 @@ export class CandleService {
))
)
return false;
producer.nextWindow = index + 1;
if (producer.nextWindow < ranges.length) return null;
pass.nextWindow = index + 1;
if (pass.nextWindow < ranges.length) return null;
}
return this.repository.publish(attempt, {
uid: input.uid,
ranges: producer.force ? ranges : [],
});
return true;
}
private async drain() {
@@ -443,20 +453,43 @@ export class CandleService {
code = response.code;
}
}
if (!success) await this.repository.fail(producer.attempt, code);
this.active.delete(producer.attempt.attemptId);
for (const observer of producer.observers) {
if (
!success ||
!(await this.repository.completeObserver(
observer,
producer.attempt,
))
)
await this.repository.fail(observer, code);
this.active.delete(observer.attemptId);
// Seal publication with reservations so a late forced join cannot be
// acknowledged after the final demand check but before work completion.
const finished = this.reservations.then(async () => {
const pass = producer.pass;
if (success && pass) {
if (producer.force && (!pass.force || producer.now > pass.now))
return false;
try {
success = await this.repository.publish(producer.attempt, {
uid: producer.input.uid,
ranges: pass.force ? pass.ranges : [],
});
} catch {
success = false;
}
}
if (!success) await this.repository.fail(producer.attempt, code);
this.active.delete(producer.attempt.attemptId);
for (const observer of producer.observers) {
if (
!success ||
!(await this.repository.completeObserver(
observer,
producer.attempt,
))
)
await this.repository.fail(observer, code);
this.active.delete(observer.attemptId);
}
this.producers.delete(scope(producer.input));
return true;
});
this.reservations = finished.catch(() => undefined);
if (!(await finished)) {
this.queue.push(producer);
continue;
}
this.producers.delete(scope(producer.input));
if (success && !producer.force && producer.input.period !== 'all')
await this.request(
producer.attempt.userId,
@@ -11,8 +11,8 @@ import {
TbankClient,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
CatalogDiscoveryRepository,
@@ -1,4 +1,3 @@
import type { TbankDividend } from '../../../../integrations/tbank/tbank.api';
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
import { CandleRepository } from '../infrastructure/candle.repository';
import { candleGaps, utcDay } from '../domain/candle-coverage';
@@ -14,11 +13,6 @@ import {
} from '../../../../common/time';
import { MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import type {
TbankCandle,
TbankFundamentals,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types';
import { previousClose, timestamp } from './instrument-market';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
@@ -29,10 +23,14 @@ import {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankBond,
type TbankCandle,
type TbankCoupon,
type TbankDividend,
type TbankFundamentals,
type TbankLastPrice,
type TbankShare,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
CatalogDiscoveryRepository,
@@ -1,5 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { ServiceUnavailableException } from '@nestjs/common';
import type {
TbankCandle,
TbankTimestamp,
@@ -36,16 +35,3 @@ export function previousClose(
null
);
}
export async function optional<T>(
operation: () => Promise<T>,
name: string,
unavailable: string[],
): Promise<T | null> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ServiceUnavailableException)) throw error;
unavailable.push(name);
return null;
}
}
@@ -1,8 +1,8 @@
import {
PortfolioDto,
PortfolioPositionDto,
} from '../../../portfolio/presentation/portfolio.dto';
import { stockPosition } from './stock-position';
} from '../../portfolio/presentation/portfolio.dto';
import { instrumentPosition } from './instrument-position';
const money = (value: string, currency = 'RUB') => ({ value, currency });
const row = (
@@ -34,13 +34,14 @@ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
describe('stock position', () => {
it('weights costs exactly and keeps preferred shares separate', () => {
const result = stockPosition(
const result = instrumentPosition(
portfolio([
row(),
row('3', '20', '70'),
row('100', '1', '100', 'preferred'),
]),
'common',
'STOCK',
);
expect(result.quantity).toBe('5');
expect(result.averagePurchasePrice).toEqual(money('16'));
@@ -49,14 +50,18 @@ describe('stock position', () => {
expect(result.returnPercent).toBe('25');
expect(result.accounts[0].returnPercent).toBe('50');
});
it('does not round large amounts through floating point', () => {
const result = stockPosition(
portfolio([row('9007199254740993', '0.1', '900719925474099.3')]),
'common',
);
expect(result.quantity).toBe('9007199254740993');
expect(result.averagePurchasePrice?.value).toBe('0.1');
});
it.each(['9007199254740993', '0.25'])(
'preserves exact quantity %s',
(quantity) => {
const result = instrumentPosition(
portfolio([row(quantity, '0.1', '900719925474099.3')]),
'common',
'STOCK',
);
expect(result.quantity).toBe(quantity);
expect(result.averagePurchasePrice?.value).toBe('0.1');
},
);
it('does not sum currencies or treat unknown values as zero', () => {
const second = {
...row(),
@@ -64,7 +69,11 @@ describe('stock position', () => {
averagePurchasePrice: null,
absoluteReturn: null,
};
const result = stockPosition(portfolio([row(), second]), 'common');
const result = instrumentPosition(
portfolio([row(), second]),
'common',
'STOCK',
);
expect(result.quantity).toBe('4');
expect(result.currentValue).toBeNull();
expect(result.averagePurchasePrice).toBeNull();
@@ -72,14 +81,14 @@ describe('stock position', () => {
expect(result.returnPercent).toBeNull();
});
it('keeps no-position and offsetting long/short holdings honest', () => {
expect(stockPosition(portfolio([]), 'common')).toMatchObject({
expect(instrumentPosition(portfolio([]), 'common', 'STOCK')).toMatchObject({
quantity: '0',
accounts: [],
currentValue: null,
returnPercent: null,
});
expect(
stockPosition(portfolio([row(), row('-2')]), 'common')
instrumentPosition(portfolio([row(), row('-2')]), 'common', 'STOCK')
.averagePurchasePrice,
).toBeNull();
});
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { createHash } 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 { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import {
confirmedCandleCoverage,
type CandleRange,
@@ -78,8 +78,37 @@ export class CandleRepository {
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
)
return { work: previous, launch: false };
) {
const previousIntent = previous.intent as Prisma.JsonObject;
const previousNow =
typeof previousIntent.now === 'string' ? previousIntent.now : '';
const nextPriority = Math.min(previous.priority, priority);
if (
nextPriority === previous.priority &&
(!intent.force ||
(previousIntent.force === true &&
(!intent.now || intent.now <= previousNow)))
)
return { work: previous, launch: false };
const work = await tx.tbankSyncWork.update({
where: { id: previous.id },
data: {
priority: nextPriority,
intent: {
...previousIntent,
...(intent.force
? {
force: true,
...(intent.now && intent.now > previousNow
? { now: intent.now }
: {}),
}
: {}),
},
},
});
return { work, launch: false };
}
if (previous)
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
const data = {
@@ -3,10 +3,10 @@ import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
import type {
TbankBond,
TbankShare,
TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
export type CatalogQuery = {
@@ -1,5 +0,0 @@
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position';
export function stockPosition(portfolio: PortfolioDto, uid: string) {
return instrumentPosition(portfolio, uid, 'STOCK');
}
@@ -1,5 +1,3 @@
jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class {} }));
import {
planInvestmentScreen,
ledgerRange,
@@ -3,7 +3,7 @@ import { dateOnlyBounds, moscowDate } from './investment-calendar';
import {
operationIntervals,
type OperationInterval,
} from './operations/operations.api';
} from './operations/domain/operations-sync-plan';
export type InvestmentScreen = {
screen:
@@ -356,7 +356,10 @@ export class BrokerageOperationsService {
: {
accountId,
items: rows.map((row) => ({
...mapBrokerageOperation(row.payload as TbankOperationItem),
...mapBrokerageOperation(
row.payload as TbankOperationItem,
row.occurredAt,
),
...(accountId === 'all'
? {
account: {
@@ -616,7 +619,10 @@ export class BrokerageOperationsService {
),
)
.map((row) => ({
...mapBrokerageOperation(row.payload as TbankOperationItem),
...mapBrokerageOperation(
row.payload as TbankOperationItem,
row.occurredAt,
),
...(accountId === 'all'
? {
account: {
@@ -4,6 +4,17 @@ import {
} from './brokerage-operation.mapper';
describe('mapBrokerageOperation', () => {
it('keeps subsecond timestamps at persisted millisecond precision and the standalone epoch default', () => {
expect(
mapBrokerageOperation({
date: { seconds: '1768000000', nanos: 600999999 },
}).occurredAt,
).toBe('2026-01-09T23:06:40.600Z');
expect(mapBrokerageOperation({}).occurredAt).toBe(
'1970-01-01T00:00:00.000Z',
);
});
it.each([
['OPERATION_TYPE_COUPON', 'income'],
['OPERATION_TYPE_DIVIDEND', 'income'],
@@ -10,6 +10,8 @@ import {
OperationCategory,
} from '../presentation/brokerage-operation.dto';
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
const types: Record<string, OperationCategory> = {
OPERATION_TYPE_BUY: 'trade',
OPERATION_TYPE_BUY_CARD: 'trade',
@@ -42,10 +44,11 @@ export function operationCategory(type: string | undefined): OperationCategory {
export function mapBrokerageOperation(
operation: TbankOperationItem,
occurredAt = operationTimestamp(operation.date),
): BrokerageOperationDto {
return {
id: operation.id ?? operation.cursor ?? '',
occurredAt: timestamp(operation.date),
occurredAt: occurredAt.toISOString(),
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
category: operationCategory(operation.type),
description: operation.description?.trim() || operation.name?.trim() || '',
@@ -82,10 +85,11 @@ function money(value: TbankMoney | undefined) {
: null;
}
function timestamp(
export function operationTimestamp(
value: { seconds?: string | number; nanos?: number } | undefined,
) {
return new Date(
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND,
).toISOString();
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND +
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
);
}
@@ -5,7 +5,6 @@ import {
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { Prisma } from '../../../../../generated/prisma/client';
import type {
@@ -17,7 +16,10 @@ import {
dateOnlyBounds,
moscowDate,
} from '../../investment-calendar';
import { operationCategory } from './brokerage-operation.mapper';
import {
operationCategory,
operationTimestamp,
} from './brokerage-operation.mapper';
import {
type OperationInterval,
operationIntervals,
@@ -45,7 +47,6 @@ type PageProgress = {
pages: number;
};
const progressItem = '';
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
@Injectable()
export class BrokerageOperationsRepository {
@@ -650,13 +651,11 @@ export class BrokerageOperationsRepository {
for (const operation of response.items ?? []) {
if (!operation.id?.trim())
throw new Error('Missing broker operation identity');
const occurredAt = new Date(
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
Math.floor(
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
),
);
if (!Number.isFinite(occurredAt.valueOf()))
const occurredAt = operationTimestamp(operation.date);
if (
operation.date?.seconds === undefined ||
!Number.isFinite(occurredAt.valueOf())
)
throw new Error('Invalid operation timestamp');
if (
occurredAt < interval.from ||
@@ -793,12 +792,7 @@ export class BrokerageOperationsRepository {
if (previous && isDeepStrictEqual(previous.payload, operation))
continue;
const data = {
occurredAt: new Date(
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
Math.floor(
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
),
),
occurredAt: operationTimestamp(operation.date),
instrumentUid: operation.instrumentUid ?? null,
category: operationCategory(operation.type),
payload: row.payload as Prisma.InputJsonObject,
@@ -2,10 +2,7 @@
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
export { BrokerageOperationsService } from './application/brokerage-operations.service';
export {
operationIntervals,
type OperationInterval,
} from './domain/operations-sync-plan';
export type { OperationInterval } from './domain/operations-sync-plan';
export { operationCategories } from './presentation/brokerage-operation.dto';
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import {
@@ -67,8 +68,23 @@ export class ForecastService {
dependencies
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
const payload = stored ? (stored.payload as T) : null;
const data =
payload &&
'positionIsStale' in payload &&
'positionAt' in payload &&
typeof payload.positionAt === 'string'
? {
...payload,
positionIsStale: tbankSourceNeedsRefresh(
'portfolio',
new Date(payload.positionAt),
new Date(Date.now()),
),
}
: payload;
return {
data: stored ? (stored.payload as T) : null,
data,
meta: investmentResourceMeta({
resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? 0),
@@ -80,7 +96,10 @@ export class ForecastService {
? 'partial'
: 'missing',
fetchedAt: first ? new Date(first) : null,
freshForMs: Number.POSITIVE_INFINITY,
freshForMs:
data && 'positionIsStale' in data && data.positionIsStale === true
? 0
: Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null,
dependencies,
}),
@@ -14,6 +14,7 @@ import {
} from '@nestjs/common';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
@@ -511,12 +512,3 @@ export class PortfolioService {
: null;
}
}
function decimalFromString(value: string) {
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}
@@ -1,5 +1,4 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { totalsByCurrency } from './infrastructure/tbank-portfolio.mapper';
export {
PortfolioDto,
PortfolioPositionDto,
@@ -10,7 +10,6 @@ import {
Req,
UseGuards,
ValidationPipe,
type ValidationError,
} from '@nestjs/common';
import {
ApiAcceptedResponse,
@@ -58,8 +57,8 @@ export class TbankSyncController {
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
exceptionFactory: (message) =>
new BadRequestException({ message: descriptorErrors(message) }),
validationError: { target: false, value: false },
exceptionFactory: (message) => new BadRequestException({ message }),
}),
)
input: unknown,
@@ -75,13 +74,3 @@ export class TbankSyncController {
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}/`),
]);
}
@@ -2,7 +2,7 @@ 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 { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
import type { InvestmentScreenDto } from './tbank-sync.dto';
export type ScreenIntent = { scope: InvestmentScreenDto; now: string };
export type ScreenProgress = {
@@ -9,6 +9,7 @@ import {
TbankAccessService,
TbankMembershipService,
InstrumentMetadataService,
type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api';
import { PortfolioService } from '../portfolio/portfolio.api';
import {
@@ -30,7 +31,6 @@ 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,
@@ -303,6 +303,7 @@ export class TbankSyncService {
},
now,
true,
0,
),
]);
}
@@ -327,10 +328,17 @@ export class TbankSyncService {
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 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 ??
saved.children.find((child) => child.errorCode)?.errorCode ??
children.find((child) => child.errorCode)?.errorCode ??
null;
const state =
saved.work.processRunId !== this.runId &&
@@ -340,7 +348,7 @@ export class TbankSyncService {
? 'running'
: failed
? 'failed'
: saved.children.some((child) => child.state === 'cancelled')
: children.some((child) => child.state === 'cancelled')
? 'cancelled'
: 'succeeded';
const resources = await this.observe(userId, intent.scope);
@@ -355,9 +363,8 @@ export class TbankSyncService {
state,
errorCode: failed,
progress: {
completed: saved.children.filter((child) => child.state !== 'running')
.length,
total: saved.children.length,
completed: children.filter((child) => child.state !== 'running').length,
total: children.length,
discovering: !saved.progress.dispatched,
},
resources,