Backend security / security (push) Successful in 8s
Backend security / security (pull_request) Canceled after 0s
Backend verify / verify (push) Canceled after 9s
Backend verify / verify (pull_request) Canceled after 4s
Conventional commits / commits (pull_request) Successful in 31s
Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
651 lines
22 KiB
TypeScript
651 lines
22 KiB
TypeScript
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
|
|
import {
|
|
InstrumentMetadataService,
|
|
metadataKey,
|
|
TbankAccessService,
|
|
TbankMembershipService,
|
|
type TbankOperationItem,
|
|
tbankSourceTtlMs,
|
|
} from '../../../../integrations/tbank/tbank.api';
|
|
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
|
import {
|
|
accountHistory,
|
|
ledgerRange,
|
|
planInvestmentScreen,
|
|
} from '../../investment-sync-plan';
|
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
|
import {
|
|
operationIntervalsOverlap,
|
|
operationsSyncPriority,
|
|
planOperationRead,
|
|
uncoveredOperationIntervals,
|
|
} from '../domain/operations-sync-plan';
|
|
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
|
|
import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository';
|
|
import {
|
|
BrokerageOperationsQueryDto,
|
|
DEFAULT_OPERATIONS_PAGE_SIZE,
|
|
} from '../presentation/brokerage-operation.dto';
|
|
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
|
|
|
const EXCLUSIVE_END_OFFSET_MS = 1;
|
|
const FIRST_RESULT_OFFSET = 0;
|
|
const MISSING_MEMBERSHIP_REVISION = 0;
|
|
const STALE_FRESHNESS_MS = 0;
|
|
const UNKNOWN_AS_OF_MS = 0;
|
|
const LAST_ROW_INDEX = -1;
|
|
const UNIX_EPOCH_MS = 0;
|
|
|
|
type LocalOperationsCursor = {
|
|
accountId: string;
|
|
at: string;
|
|
binding: string;
|
|
id: string;
|
|
movingEnd?: boolean;
|
|
to: string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class BrokerageOperationsService {
|
|
constructor(
|
|
private readonly access: TbankAccessService,
|
|
private readonly metadata: InstrumentMetadataService,
|
|
private readonly repository: BrokerageOperationsRepository,
|
|
private readonly sync: BrokerageOperationsSyncService,
|
|
private readonly membership: TbankMembershipService,
|
|
private readonly config: ConfigService,
|
|
) {}
|
|
|
|
async read(
|
|
userId: string,
|
|
accountId: string,
|
|
query: BrokerageOperationsQueryDto,
|
|
) {
|
|
const connection = await this.access.local(userId, accountId);
|
|
const cursor = query.cursor ? this.decodeCursor(query.cursor) : undefined;
|
|
const selected = connection.accounts.filter(
|
|
(account) => accountId === 'all' || account.accountId === accountId,
|
|
);
|
|
const now = new Date();
|
|
const range = ledgerRange(
|
|
query.from,
|
|
query.to,
|
|
query.instrumentUid,
|
|
selected,
|
|
now,
|
|
);
|
|
const from = range.from;
|
|
const to = cursor ? new Date(cursor.to) : range.to;
|
|
const movingEnd = cursor?.movingEnd ?? range.to.valueOf() === now.valueOf();
|
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
const categories = [...new Set(query.categories ?? [])].sort();
|
|
const limit = query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE;
|
|
const filterKey = JSON.stringify([
|
|
accountId,
|
|
from.toISOString(),
|
|
query.to
|
|
? new Date(
|
|
/^\d{4}-\d{2}-\d{2}$/.test(query.to)
|
|
? `${query.to}T23:59:59.999Z`
|
|
: query.to,
|
|
).toISOString()
|
|
: 'now',
|
|
query.instrumentUid ?? null,
|
|
categories,
|
|
limit,
|
|
]);
|
|
const readInput = {
|
|
userId,
|
|
revision: connection.revision,
|
|
accountId,
|
|
from,
|
|
to,
|
|
instrumentUid: query.instrumentUid,
|
|
categories,
|
|
limit,
|
|
filterKey,
|
|
cursor,
|
|
};
|
|
if (cursor) await this.repository.readPage(readInput);
|
|
const screen = planInvestmentScreen(
|
|
{
|
|
screen: 'operations',
|
|
accountId,
|
|
from: from.toISOString(),
|
|
to: new Date(to.valueOf() - EXCLUSIVE_END_OFFSET_MS).toISOString(),
|
|
},
|
|
{
|
|
accounts: selected,
|
|
instruments: [],
|
|
membershipKnown: Boolean(connection.membershipAt),
|
|
},
|
|
now,
|
|
);
|
|
const intervals = screen.sources.flatMap((source) =>
|
|
source.kind === 'operations' &&
|
|
source.priority === 'screen' &&
|
|
source.accountId &&
|
|
source.from &&
|
|
source.to
|
|
? [{ accountId: source.accountId, from: source.from, to: source.to }]
|
|
: [],
|
|
);
|
|
if (query.sync !== 'observe') {
|
|
const discovery = await this.membership.request(
|
|
userId,
|
|
connection.revision,
|
|
);
|
|
if (discovery) {
|
|
void discovery.done
|
|
.then(async () => {
|
|
const current = await this.access.local(userId, accountId);
|
|
if (current.revision !== connection.revision) return;
|
|
const currentRange = ledgerRange(
|
|
query.from,
|
|
query.to,
|
|
query.instrumentUid,
|
|
current.accounts.filter(
|
|
(account) =>
|
|
accountId === 'all' || account.accountId === accountId,
|
|
),
|
|
now,
|
|
);
|
|
const targets = current.accounts
|
|
.filter(
|
|
(account) =>
|
|
accountId === 'all' || account.accountId === accountId,
|
|
)
|
|
.flatMap((account) =>
|
|
currentRange.from < currentRange.to
|
|
? [{ accountId: account.accountId, ...currentRange }]
|
|
: [],
|
|
);
|
|
await this.sync.readPlan(userId, targets, {
|
|
movingEnd,
|
|
history: true,
|
|
revision: connection.revision,
|
|
});
|
|
})
|
|
.catch(() => undefined);
|
|
} else if (intervals.length) {
|
|
await this.sync.readPlan(userId, intervals, {
|
|
movingEnd,
|
|
history: true,
|
|
revision: connection.revision,
|
|
});
|
|
}
|
|
}
|
|
const saved = await this.repository.readPage(readInput);
|
|
const accounts = saved?.connection.accounts ?? [];
|
|
const targets = accounts.flatMap((account) =>
|
|
from < to
|
|
? [
|
|
{
|
|
accountId: account.accountId,
|
|
from,
|
|
to,
|
|
instrumentUid: query.instrumentUid,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
|
movingEnd,
|
|
});
|
|
const confirmed = plan.intervals.flatMap((target) =>
|
|
(saved?.coverage ?? [])
|
|
.filter(
|
|
(row) =>
|
|
row.accountId === target.accountId &&
|
|
(!row.instrumentUid ||
|
|
row.instrumentUid === target.instrumentUid) &&
|
|
row.from < target.to &&
|
|
target.from < row.to,
|
|
)
|
|
.map((row) => ({
|
|
accountId: target.accountId,
|
|
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
|
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
|
})),
|
|
);
|
|
const missing = uncoveredOperationIntervals(
|
|
plan.intervals,
|
|
confirmed.map((range) => ({
|
|
...range,
|
|
instrumentUid: query.instrumentUid,
|
|
})),
|
|
);
|
|
const available = Boolean(saved?.connection.membershipAt);
|
|
const complete = available && !missing.length;
|
|
const relevant =
|
|
saved?.work.filter((work) =>
|
|
work.intervals.some((interval) =>
|
|
targets.some((target) => operationIntervalsOverlap(interval, target)),
|
|
),
|
|
) ?? [];
|
|
const work =
|
|
relevant.find((work) => work.state === 'running') ??
|
|
relevant.find((work) => work.state === 'failed') ??
|
|
null;
|
|
const sources = (saved?.coverage ?? []).filter((row) =>
|
|
targets.some(
|
|
(target) =>
|
|
operationIntervalsOverlap(row, target) &&
|
|
(!row.instrumentUid || row.instrumentUid === target.instrumentUid),
|
|
),
|
|
);
|
|
const lastSuccessfulAt =
|
|
sources
|
|
.map((row) => row.fetchedAt)
|
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
|
(complete ? saved?.connection.membershipAt : null) ??
|
|
null;
|
|
const historyTargets = accountHistory(accounts, now);
|
|
const history = planOperationRead(
|
|
historyTargets,
|
|
saved?.coverage ?? [],
|
|
now,
|
|
{ movingEnd: true },
|
|
);
|
|
const knownBoundary = accounts.every(
|
|
(account) => account.openedOn !== null,
|
|
);
|
|
const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? [];
|
|
const instruments = await this.metadata.savedMany(
|
|
rows.map((row) => row.payload as TbankOperationItem),
|
|
);
|
|
const metadataDependencies = [...instruments]
|
|
.filter(([, value]) => value.complete)
|
|
.map(([key, value]) =>
|
|
investmentResourceMeta({
|
|
resourceKey: `metadata/${key}`,
|
|
dataRevision: value.dataRevision,
|
|
availability: 'complete',
|
|
fetchedAt: value.fetchedAt,
|
|
freshForMs: tbankSourceTtlMs.metadata,
|
|
work: null,
|
|
}),
|
|
);
|
|
if (query.sync !== 'observe') {
|
|
const identities = new Map(
|
|
rows.map((row) => {
|
|
const identity = row.payload as TbankOperationItem;
|
|
return [
|
|
metadataKey(identity),
|
|
{ instrumentUid: identity.instrumentUid, figi: identity.figi },
|
|
] as const;
|
|
}),
|
|
);
|
|
for (const identity of identities.values()) {
|
|
if (!instruments.get(metadataKey(identity))?.fresh) {
|
|
await this.metadata.request(userId, connection.revision, identity);
|
|
}
|
|
}
|
|
}
|
|
const membershipMeta = investmentResourceMeta({
|
|
resourceKey: 'accounts/membership',
|
|
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
|
|
availability: available ? 'complete' : 'missing',
|
|
fetchedAt: saved?.connection.membershipAt ?? null,
|
|
freshForMs: tbankSourceTtlMs.membership,
|
|
work: saved?.membershipWork ?? null,
|
|
});
|
|
const meta = investmentResourceMeta({
|
|
resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`,
|
|
dataRevision: saved?.binding ?? 'missing',
|
|
availability: !available
|
|
? 'missing'
|
|
: complete
|
|
? 'complete'
|
|
: confirmed.length
|
|
? 'partial'
|
|
: 'missing',
|
|
fetchedAt: lastSuccessfulAt,
|
|
freshForMs: plan.intervals.some((interval) => interval.stale)
|
|
? STALE_FRESHNESS_MS
|
|
: Number.POSITIVE_INFINITY,
|
|
work: work ?? saved?.membershipWork ?? null,
|
|
dependencies: [membershipMeta, ...metadataDependencies],
|
|
coverage: {
|
|
requested: targets.map(serialize),
|
|
completeRanges: confirmed.map(serialize),
|
|
missingRanges: missing.map(serialize),
|
|
asOf:
|
|
accounts.length &&
|
|
accounts.every((account) =>
|
|
plan.intervals.some(
|
|
(interval) =>
|
|
interval.accountId === account.accountId && interval.asOf,
|
|
),
|
|
)
|
|
? new Date(
|
|
Math.min(
|
|
...accounts.map((account) =>
|
|
Math.max(
|
|
...plan.intervals
|
|
.filter(
|
|
(interval) =>
|
|
interval.accountId === account.accountId,
|
|
)
|
|
.map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS),
|
|
),
|
|
),
|
|
),
|
|
).toISOString()
|
|
: null,
|
|
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
|
historyState: saved?.work.some(
|
|
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
|
|
)
|
|
? 'loading'
|
|
: saved?.work.some(
|
|
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
|
|
)
|
|
? 'failed'
|
|
: knownBoundary &&
|
|
history.intervals.every(
|
|
(interval) => interval.availability === 'complete',
|
|
)
|
|
? 'complete'
|
|
: saved?.coverage.length
|
|
? 'partial'
|
|
: 'missing',
|
|
},
|
|
});
|
|
const last = rows.at(LAST_ROW_INDEX);
|
|
const hasNext = Boolean(saved && saved.rows.length > limit);
|
|
return {
|
|
data:
|
|
meta.availability === 'missing'
|
|
? null
|
|
: {
|
|
accountId,
|
|
items: rows.map((row) => ({
|
|
...mapBrokerageOperation(
|
|
row.payload as TbankOperationItem,
|
|
row.occurredAt,
|
|
),
|
|
...(accountId === 'all'
|
|
? {
|
|
account: {
|
|
id: row.accountId,
|
|
name:
|
|
accounts.find(
|
|
(account) => account.accountId === row.accountId,
|
|
)?.name ?? row.accountId,
|
|
},
|
|
}
|
|
: {}),
|
|
logoName:
|
|
instruments
|
|
.get(metadataKey(row.payload as TbankOperationItem))
|
|
?.data?.brand?.logoName?.trim() || null,
|
|
})),
|
|
hasNext,
|
|
nextCursor:
|
|
hasNext && last && saved
|
|
? this.encodeCursor({
|
|
binding: saved.binding,
|
|
movingEnd,
|
|
to: to.toISOString(),
|
|
at: last.occurredAt.toISOString(),
|
|
accountId: last.accountId,
|
|
id: last.brokerOperationId,
|
|
})
|
|
: null,
|
|
observedAt: now.toISOString(),
|
|
},
|
|
meta,
|
|
};
|
|
}
|
|
|
|
private encodeCursor(value: LocalOperationsCursor): string {
|
|
const body = Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
return `${body}.${createHmac('sha256', this.config.getOrThrow<string>('JWT_SECRET')).update(body).digest('base64url')}`;
|
|
}
|
|
|
|
private decodeCursor(value: string): LocalOperationsCursor {
|
|
try {
|
|
const [body, signature, extra] = value.split('.');
|
|
const expected = createHmac(
|
|
'sha256',
|
|
this.config.getOrThrow<string>('JWT_SECRET'),
|
|
)
|
|
.update(body)
|
|
.digest();
|
|
const actual = Buffer.from(signature ?? '', 'base64url');
|
|
if (
|
|
extra ||
|
|
actual.length !== expected.length ||
|
|
!timingSafeEqual(actual, expected)
|
|
) {
|
|
throw new Error();
|
|
}
|
|
const cursor = JSON.parse(
|
|
Buffer.from(body, 'base64url').toString(),
|
|
) as Partial<LocalOperationsCursor>;
|
|
if (
|
|
(cursor.movingEnd !== undefined &&
|
|
typeof cursor.movingEnd !== 'boolean') ||
|
|
!cursor.binding ||
|
|
!cursor.accountId ||
|
|
!cursor.id ||
|
|
!cursor.at ||
|
|
!cursor.to ||
|
|
!Number.isFinite(Date.parse(cursor.at)) ||
|
|
!Number.isFinite(Date.parse(cursor.to))
|
|
) {
|
|
throw new Error();
|
|
}
|
|
return cursor as LocalOperationsCursor;
|
|
} catch {
|
|
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
|
}
|
|
}
|
|
|
|
async collectRanges(
|
|
userId: string,
|
|
accountId: string,
|
|
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
|
|
observe = false,
|
|
) {
|
|
const connection = await this.access.local(userId, accountId);
|
|
const input = {
|
|
userId,
|
|
revision: connection.revision,
|
|
accountId,
|
|
from: new Date(UNIX_EPOCH_MS),
|
|
to: new Date(),
|
|
categories: ['income'],
|
|
filterKey: `income/${accountId}`,
|
|
periods: ranges,
|
|
};
|
|
let saved = await this.repository.readPage(input);
|
|
const schedule = async () => {
|
|
const current = await this.access.local(userId, accountId);
|
|
if (current.revision !== connection.revision) return;
|
|
const snapshot = await this.repository.readPage(input);
|
|
if (!snapshot?.connection.membershipAt) return;
|
|
const targets = snapshot.connection.accounts.flatMap((account) =>
|
|
(snapshot.periods ?? []).map((period) => ({
|
|
accountId: account.accountId,
|
|
...period,
|
|
})),
|
|
);
|
|
if (targets.length) {
|
|
await this.sync.readPlan(userId, targets, {
|
|
movingEnd: snapshot.periods?.some((period) => period.movingEnd),
|
|
history: true,
|
|
revision: connection.revision,
|
|
});
|
|
}
|
|
};
|
|
if (!observe) {
|
|
const discovery = await this.membership.request(
|
|
userId,
|
|
connection.revision,
|
|
);
|
|
if (discovery) void discovery.done.then(schedule).catch(() => undefined);
|
|
else await schedule();
|
|
saved = await this.repository.readPage(input);
|
|
}
|
|
const now = new Date();
|
|
const membership = investmentResourceMeta({
|
|
resourceKey: 'accounts/membership',
|
|
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
|
|
availability: saved?.connection.membershipAt ? 'complete' : 'missing',
|
|
fetchedAt: saved?.connection.membershipAt ?? null,
|
|
freshForMs: tbankSourceTtlMs.membership,
|
|
work: saved?.membershipWork ?? null,
|
|
});
|
|
// Plan even without a snapshot so cold responses retain the requested identity.
|
|
const periods =
|
|
saved?.periods ??
|
|
ranges(undefined).map((period) => ({
|
|
...dateOnlyBounds(period),
|
|
key: `${period.from}/${period.to}`,
|
|
movingEnd: false,
|
|
}));
|
|
return periods.map((period, index) => {
|
|
const targets = (saved?.connection.accounts ?? []).map((account) => ({
|
|
accountId: account.accountId,
|
|
...period,
|
|
to: period.movingEnd ? now : period.to,
|
|
}));
|
|
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
|
movingEnd: true,
|
|
});
|
|
const relevant = (saved?.work ?? []).filter((work) =>
|
|
work.intervals.some((range) =>
|
|
targets.some((target) => operationIntervalsOverlap(range, target)),
|
|
),
|
|
);
|
|
const work =
|
|
relevant.find((work) => work.state === 'running') ??
|
|
relevant.find((work) => work.state === 'failed') ??
|
|
saved?.membershipWork ??
|
|
null;
|
|
const known = Boolean(saved?.connection.membershipAt);
|
|
const complete =
|
|
known &&
|
|
plan.intervals.every((range) => range.availability === 'complete');
|
|
const sources = (saved?.coverage ?? []).filter(
|
|
(row) =>
|
|
!row.instrumentUid &&
|
|
targets.some((target) => operationIntervalsOverlap(row, target)),
|
|
);
|
|
const fetchedAt =
|
|
sources
|
|
.map((row) => row.fetchedAt)
|
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
|
(complete ? saved?.connection.membershipAt : null) ??
|
|
null;
|
|
const confirmed = plan.intervals.flatMap((target) =>
|
|
sources
|
|
.filter(
|
|
(row) =>
|
|
row.accountId === target.accountId &&
|
|
operationIntervalsOverlap(row, target),
|
|
)
|
|
.map((row) => ({
|
|
accountId: target.accountId,
|
|
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
|
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
|
})),
|
|
);
|
|
const meta = investmentResourceMeta({
|
|
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
|
|
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
|
|
dataRevision: saved?.binding ?? 'missing',
|
|
availability: !known
|
|
? 'missing'
|
|
: complete
|
|
? 'complete'
|
|
: sources.length
|
|
? 'partial'
|
|
: 'missing',
|
|
fetchedAt,
|
|
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
|
|
work,
|
|
dependencies: [membership],
|
|
coverage: {
|
|
requested: targets.map(serialize),
|
|
completeRanges: confirmed.map(serialize),
|
|
missingRanges: uncoveredOperationIntervals(
|
|
plan.intervals,
|
|
confirmed,
|
|
).map(serialize),
|
|
asOf:
|
|
complete && targets.length
|
|
? new Date(
|
|
Math.min(
|
|
...targets.map((target) =>
|
|
Math.max(
|
|
...plan.intervals
|
|
.filter(
|
|
(range) => range.accountId === target.accountId,
|
|
)
|
|
.map(
|
|
(range) =>
|
|
range.asOf?.valueOf() ?? range.to.valueOf(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
).toISOString()
|
|
: null,
|
|
historyBoundary: (saved?.connection.accounts ?? []).every(
|
|
(account) => account.openedOn,
|
|
)
|
|
? 'known'
|
|
: 'unknown',
|
|
historyState: complete
|
|
? 'complete'
|
|
: sources.length
|
|
? 'partial'
|
|
: 'missing',
|
|
},
|
|
});
|
|
return {
|
|
meta,
|
|
items: complete
|
|
? (saved?.rows ?? [])
|
|
.filter((row) =>
|
|
plan.intervals.some(
|
|
(interval) =>
|
|
interval.accountId === row.accountId &&
|
|
row.occurredAt >= interval.from &&
|
|
row.occurredAt < interval.to,
|
|
),
|
|
)
|
|
.map((row) => ({
|
|
...mapBrokerageOperation(
|
|
row.payload as TbankOperationItem,
|
|
row.occurredAt,
|
|
),
|
|
...(accountId === 'all'
|
|
? {
|
|
account: {
|
|
id: row.accountId,
|
|
name:
|
|
saved?.connection.accounts.find(
|
|
(account) => account.accountId === row.accountId,
|
|
)?.name ?? row.accountId,
|
|
},
|
|
}
|
|
: {}),
|
|
}))
|
|
: null,
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
function serialize(range: { accountId: string; from: Date; to: Date }) {
|
|
return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() };
|
|
}
|