417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
import {
|
|
InstrumentMetadataService,
|
|
metadataKey,
|
|
TbankAccessService,
|
|
type TbankOperationItem,
|
|
tbankSourceTtlMs,
|
|
} from '../../../../integrations/tbank/tbank.api';
|
|
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
|
import { ledgerRange } from '../../investment-sync-plan';
|
|
import {
|
|
hourlyAttempt,
|
|
TbankSyncRepository,
|
|
} from '../../tbank-sync/tbank-sync.api';
|
|
import {
|
|
coverageAsOf,
|
|
deriveReadCoverage,
|
|
historyState,
|
|
serializeRange,
|
|
} from '../domain/operations-coverage';
|
|
import {
|
|
confirmedOperationRanges,
|
|
operationIntervalsOverlap,
|
|
planOperationRead,
|
|
uncoveredOperationIntervals,
|
|
} from '../domain/operations-sync-plan';
|
|
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
|
|
import { BrokerageOperationsReadRepository } from '../infrastructure/brokerage-operations-read.repository';
|
|
import {
|
|
BrokerageOperationsQueryDto,
|
|
DEFAULT_OPERATIONS_PAGE_SIZE,
|
|
} from '../presentation/brokerage-operation.dto';
|
|
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
|
import { LocalOperationsCursorCodec } from './local-operations-cursor.codec';
|
|
|
|
const FIRST_RESULT_OFFSET = 0;
|
|
const MISSING_MEMBERSHIP_REVISION = 0;
|
|
const STALE_FRESHNESS_MS = 0;
|
|
const NO_COVERAGE_ROWS = 0;
|
|
const LAST_ROW_INDEX = -1;
|
|
const UNIX_EPOCH_MS = 0;
|
|
|
|
@Injectable()
|
|
export class BrokerageOperationsService {
|
|
constructor(
|
|
private readonly access: TbankAccessService,
|
|
private readonly metadata: InstrumentMetadataService,
|
|
private readonly repository: BrokerageOperationsReadRepository,
|
|
private readonly sync: BrokerageOperationsSyncService,
|
|
private readonly cursorCodec: LocalOperationsCursorCodec,
|
|
private readonly hourlySync: TbankSyncRepository,
|
|
) {}
|
|
|
|
async read(
|
|
userId: string,
|
|
accountId: string,
|
|
query: BrokerageOperationsQueryDto,
|
|
) {
|
|
const connection = await this.access.local(userId, accountId);
|
|
const cursor = query.cursor ? this.cursorCodec.decode(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 intervals = selected.flatMap((account) =>
|
|
from < to ? [{ accountId: account.accountId, from, to }] : [],
|
|
);
|
|
// The ledger only starts the first-time load of missing intervals; the
|
|
// mutable tail belongs to the hourly walk and membership is not rechecked.
|
|
if (intervals.length) {
|
|
await this.sync.readPlan(userId, intervals, {
|
|
movingEnd,
|
|
revision: connection.revision,
|
|
});
|
|
}
|
|
const saved = await this.repository.readPage(readInput);
|
|
const hourly = await this.hourlySync.hourly(userId);
|
|
const accounts = saved?.connection.accounts ?? [];
|
|
const view = deriveReadCoverage({
|
|
accounts,
|
|
connectionMembershipAt: saved?.connection.membershipAt ?? null,
|
|
coverage: saved?.coverage ?? [],
|
|
from,
|
|
instrumentUid: query.instrumentUid,
|
|
movingEnd,
|
|
now,
|
|
to,
|
|
work: saved?.work ?? [],
|
|
});
|
|
const { targets, plan, confirmed, missing, available, complete, lastSuccessfulAt, history, knownBoundary, work } = view;
|
|
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.portfolio,
|
|
work: null,
|
|
}),
|
|
);
|
|
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,
|
|
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
|
dependencies: [membershipMeta, ...metadataDependencies],
|
|
coverage: {
|
|
requested: targets.map(serializeRange),
|
|
completeRanges: confirmed.map(serializeRange),
|
|
missingRanges: missing.map(serializeRange),
|
|
asOf: coverageAsOf(plan, accounts.map((account) => account.accountId)),
|
|
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
|
historyState: historyState(
|
|
knownBoundary,
|
|
history,
|
|
saved?.work ?? [],
|
|
saved?.coverage.length ?? NO_COVERAGE_ROWS,
|
|
),
|
|
},
|
|
});
|
|
const last = rows.at(LAST_ROW_INDEX);
|
|
const hasNext = Boolean(saved && saved.rows.length > limit);
|
|
return {
|
|
data:
|
|
meta.completeness === 'incomplete'
|
|
? 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.cursorCodec.encode({
|
|
binding: saved.binding,
|
|
movingEnd,
|
|
to: to.toISOString(),
|
|
at: last.occurredAt.toISOString(),
|
|
accountId: last.accountId,
|
|
id: last.brokerOperationId,
|
|
})
|
|
: null,
|
|
observedAt: now.toISOString(),
|
|
},
|
|
meta,
|
|
};
|
|
}
|
|
|
|
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);
|
|
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 now = new Date();
|
|
const hourly = await this.hourlySync.hourly(userId);
|
|
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,
|
|
}));
|
|
const rows = saved?.rows ?? [];
|
|
const instruments = await this.metadata.savedMany(
|
|
rows.map((row) => row.payload as TbankOperationItem),
|
|
);
|
|
return periods.map((period) => {
|
|
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 = confirmedOperationRanges(plan.intervals, sources);
|
|
const meta = investmentResourceMeta({
|
|
resourceKey: `income/${accountId}/selected/${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,
|
|
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
|
dependencies: [membership],
|
|
coverage: {
|
|
requested: targets.map(serializeRange),
|
|
completeRanges: confirmed.map(serializeRange),
|
|
missingRanges: uncoveredOperationIntervals(
|
|
plan.intervals,
|
|
confirmed,
|
|
).map(serializeRange),
|
|
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
|
|
? 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,
|
|
),
|
|
logoName:
|
|
instruments
|
|
.get(metadataKey(row.payload as TbankOperationItem))
|
|
?.data?.brand?.logoName?.trim() || null,
|
|
...(accountId === 'all'
|
|
? {
|
|
account: {
|
|
id: row.accountId,
|
|
name:
|
|
saved?.connection.accounts.find(
|
|
(account) => account.accountId === row.accountId,
|
|
)?.name ?? row.accountId,
|
|
},
|
|
}
|
|
: {}),
|
|
}))
|
|
: null,
|
|
};
|
|
});
|
|
}
|
|
}
|