feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import { accountHistory } from '../../investment-sync-plan';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
BrokerageOperationsRepository,
|
||||
type OperationAttempt,
|
||||
} from '../infrastructure/brokerage-operations.repository';
|
||||
import {
|
||||
operationIntervals,
|
||||
planOperationRead,
|
||||
type OperationInterval,
|
||||
uncoveredOperationIntervals,
|
||||
} from '../domain/operations-sync-plan';
|
||||
|
||||
const operationsPageSize = 1000;
|
||||
const priorities = { screen: 1, history: 2 } as const;
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageOperationsSyncService {
|
||||
private readonly processRunId = randomUUID();
|
||||
private readonly runningAttempts = new Set<string>();
|
||||
private readonly ready = new Map<
|
||||
string,
|
||||
{ attempt: OperationAttempt; priority: number }
|
||||
>();
|
||||
private draining = false;
|
||||
// Only short DB reservations/publications are serialized, never RPCs.
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly repository: BrokerageOperationsRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async readPlan(
|
||||
userId: string,
|
||||
intervals: OperationInterval[],
|
||||
options: {
|
||||
movingEnd?: boolean;
|
||||
force?: boolean;
|
||||
observe?: boolean;
|
||||
history?: boolean;
|
||||
revision?: string;
|
||||
} = {},
|
||||
) {
|
||||
const connection = await this.access.local(userId);
|
||||
if (
|
||||
!connection.membershipAt ||
|
||||
(options.revision && options.revision !== connection.revision)
|
||||
)
|
||||
return { plan: null, handle: null };
|
||||
if (
|
||||
intervals.some(
|
||||
(interval) =>
|
||||
!connection.accounts.some(
|
||||
(account) => account.accountId === interval.accountId,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new NotFoundException();
|
||||
const coverage = await this.repository.coverage(
|
||||
userId,
|
||||
connection.revision,
|
||||
intervals.map((interval) => interval.accountId),
|
||||
);
|
||||
const now = new Date();
|
||||
const movingTo = Math.max(
|
||||
...intervals.map((interval) => interval.to.valueOf()),
|
||||
);
|
||||
const targets = intervals.map((interval) =>
|
||||
options.movingEnd && interval.to.valueOf() === movingTo
|
||||
? { ...interval, to: now }
|
||||
: interval,
|
||||
);
|
||||
const plan = planOperationRead(targets, coverage, now, options);
|
||||
const complete = plan.intervals.every(
|
||||
(interval) => interval.availability === 'complete',
|
||||
);
|
||||
const handle =
|
||||
!options.observe && plan.load.length
|
||||
? await this.request(
|
||||
userId,
|
||||
plan.load,
|
||||
complete || Boolean(options.force),
|
||||
'screen',
|
||||
connection.revision,
|
||||
)
|
||||
: null;
|
||||
if (!options.observe && !options.force && options.history) {
|
||||
const accountIds = new Set(
|
||||
intervals.map((interval) => interval.accountId),
|
||||
);
|
||||
const history = accountHistory(
|
||||
connection.accounts.filter((account) =>
|
||||
accountIds.has(account.accountId),
|
||||
),
|
||||
now,
|
||||
);
|
||||
const missing = planOperationRead(history, coverage, now, {
|
||||
movingEnd: true,
|
||||
}).load;
|
||||
const remaining = uncoveredOperationIntervals(missing, targets);
|
||||
if (remaining.length)
|
||||
await this.request(
|
||||
userId,
|
||||
remaining,
|
||||
false,
|
||||
'history',
|
||||
connection.revision,
|
||||
);
|
||||
}
|
||||
return { plan, handle };
|
||||
}
|
||||
|
||||
async request(
|
||||
userId: string,
|
||||
intervals: OperationInterval[],
|
||||
atomic: boolean,
|
||||
priority: keyof typeof priorities = 'screen',
|
||||
revision?: string,
|
||||
): Promise<string | null> {
|
||||
const connection = await this.access.local(userId);
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
intervals.some(
|
||||
(interval) =>
|
||||
!connection.accounts.some(
|
||||
(account) => account.accountId === interval.accountId,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new NotFoundException();
|
||||
if (revision && revision !== connection.revision) return null;
|
||||
const cutoff = new Date();
|
||||
const bounded = operationIntervals(intervals)
|
||||
.map((interval) => ({
|
||||
...interval,
|
||||
to: new Date(Math.min(interval.to.valueOf(), cutoff.valueOf())),
|
||||
}))
|
||||
.filter((interval) => interval.from < interval.to);
|
||||
if (!bounded.length) return null;
|
||||
const reserve = this.reservations.then(async () => {
|
||||
const requested = await this.repository.request(
|
||||
userId,
|
||||
connection.revision,
|
||||
operationIntervals(bounded),
|
||||
this.processRunId,
|
||||
atomic,
|
||||
this.runningAttempts,
|
||||
priorities[priority],
|
||||
);
|
||||
if (!requested) return null;
|
||||
const { id, attemptId, revision } = requested.work;
|
||||
this.runningAttempts.add(attemptId);
|
||||
this.ready.set(attemptId, {
|
||||
attempt: { id, attemptId, userId, revision },
|
||||
priority: requested.work.priority,
|
||||
});
|
||||
if (!this.draining) {
|
||||
this.draining = true;
|
||||
// Startup and observation never enter this path.
|
||||
setImmediate(() => {
|
||||
void this.drain();
|
||||
});
|
||||
}
|
||||
return id;
|
||||
});
|
||||
this.reservations = reserve.catch(() => undefined);
|
||||
return reserve;
|
||||
}
|
||||
|
||||
async observe(userId: string, id: string) {
|
||||
const connection = await this.access.local(userId);
|
||||
const work = await this.repository.observe(userId, connection.revision, id);
|
||||
if (!work) throw new NotFoundException();
|
||||
return work;
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
try {
|
||||
while (this.ready.size) {
|
||||
const selected = [...this.ready.values()].sort(
|
||||
(a, b) => a.priority - b.priority,
|
||||
)[0];
|
||||
const { attempt } = selected;
|
||||
this.ready.delete(attempt.attemptId);
|
||||
let pending = false;
|
||||
try {
|
||||
pending = await this.step(attempt);
|
||||
} catch {
|
||||
this.logger.event(
|
||||
'error',
|
||||
'tbank.operations.sync.persistence_failed',
|
||||
{ userId: attempt.userId, code: 'TBANK_UNAVAILABLE' },
|
||||
);
|
||||
}
|
||||
const next = pending ? await this.repository.pending(attempt) : null;
|
||||
if (next) {
|
||||
const joined = this.ready.get(attempt.attemptId);
|
||||
this.ready.set(attempt.attemptId, {
|
||||
attempt,
|
||||
priority: Math.min(
|
||||
next.priority,
|
||||
joined?.priority ?? next.priority,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.ready.delete(attempt.attemptId);
|
||||
this.runningAttempts.delete(attempt.attemptId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.draining = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async step(attempt: OperationAttempt): Promise<boolean> {
|
||||
try {
|
||||
const plan = await this.repository.pending(attempt);
|
||||
if (!plan) return false;
|
||||
if (!plan.next) {
|
||||
const publish = this.reservations.then(async () => {
|
||||
const current = await this.repository.pending(attempt);
|
||||
if (!current) return false;
|
||||
if (current.next) return true;
|
||||
const published = await this.repository.publish(
|
||||
attempt,
|
||||
current.intervals,
|
||||
new Date(),
|
||||
true,
|
||||
);
|
||||
return published && Boolean(await this.repository.pending(attempt));
|
||||
});
|
||||
this.reservations = publish.catch(() => undefined);
|
||||
return await publish;
|
||||
}
|
||||
const { interval, cursor } = plan.next;
|
||||
const token = await this.access.tokenForAttempt(attempt);
|
||||
if (!token) return false;
|
||||
const response = await this.tbank.getOperationsByCursor(token, {
|
||||
accountId: interval.accountId,
|
||||
// Upstream inclusivity is not assumed; storage selects exact [from,to).
|
||||
from: {
|
||||
seconds: String(
|
||||
Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - 1,
|
||||
),
|
||||
},
|
||||
to: {
|
||||
seconds: String(
|
||||
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1,
|
||||
),
|
||||
},
|
||||
...(interval.instrumentUid
|
||||
? { instrumentId: interval.instrumentUid }
|
||||
: {}),
|
||||
cursor,
|
||||
limit: operationsPageSize,
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
});
|
||||
if (
|
||||
!(await this.repository.stagePage(attempt, interval, cursor, response))
|
||||
)
|
||||
return false;
|
||||
if (!plan.atomic && !response.hasNext) {
|
||||
if (
|
||||
!(await this.repository.publish(
|
||||
attempt,
|
||||
[interval],
|
||||
new Date(),
|
||||
true,
|
||||
))
|
||||
)
|
||||
return false;
|
||||
}
|
||||
// One page per turn: newly requested screen work can overtake backfill.
|
||||
// TbankClient alone owns the bounded RPC queue and retries.
|
||||
return true;
|
||||
} catch (error) {
|
||||
await this.repository.fail(
|
||||
attempt,
|
||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
|
||||
TbankAccessService: class TbankAccessService {},
|
||||
}));
|
||||
|
||||
import type { TbankAccountContext } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { BrokerageOperationsService } from './brokerage-operations.service';
|
||||
|
||||
describe('BrokerageOperationsService', () => {
|
||||
const access = { withAccount: jest.fn() };
|
||||
const tbank = {
|
||||
getOperationsByCursor: jest.fn(),
|
||||
};
|
||||
const requestContext = { set: jest.fn() };
|
||||
const metadata = { resolve: jest.fn() };
|
||||
const service = new BrokerageOperationsService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
requestContext as never,
|
||||
metadata as never,
|
||||
);
|
||||
let operationsRequest: unknown;
|
||||
let generation: string;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
access.withAccount.mockReset();
|
||||
tbank.getOperationsByCursor.mockReset();
|
||||
metadata.resolve.mockReset();
|
||||
generation = 'generation-1';
|
||||
access.withAccount.mockImplementation(
|
||||
(
|
||||
_userId: string,
|
||||
_accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<unknown>,
|
||||
) =>
|
||||
handler({
|
||||
token: 'token',
|
||||
generation,
|
||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
||||
}),
|
||||
);
|
||||
tbank.getOperationsByCursor.mockImplementation(
|
||||
(_token: string, request: unknown) => {
|
||||
operationsRequest = request;
|
||||
return Promise.resolve({ items: [] });
|
||||
},
|
||||
);
|
||||
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses the current UTC year by default and asks only for executed operations', async () => {
|
||||
await service.get('user', 'account', {});
|
||||
|
||||
const request = operationsRequest as {
|
||||
state: string;
|
||||
from: { seconds: string };
|
||||
};
|
||||
expect(request.state).toBe('OPERATION_STATE_EXECUTED');
|
||||
expect(request.from.seconds).toBe(
|
||||
String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => {
|
||||
tbank.getOperationsByCursor.mockResolvedValue({
|
||||
hasNext: true,
|
||||
nextCursor: 'next',
|
||||
items: [
|
||||
{ id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' },
|
||||
{
|
||||
id: 'fee',
|
||||
date: { seconds: '2' },
|
||||
type: 'OPERATION_TYPE_BROKER_FEE',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.get('user', 'account', {
|
||||
from: '2025-01-01',
|
||||
to: '2025-01-31',
|
||||
categories: ['income'],
|
||||
cursor: 'previous',
|
||||
limit: 10,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
items: [{ id: 'income' }],
|
||||
nextCursor: 'next',
|
||||
hasNext: true,
|
||||
});
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({
|
||||
from: { seconds: '1735689600' },
|
||||
to: { seconds: '1738367999', nanos: 999_999_999 },
|
||||
cursor: 'previous',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes resolved instrument logo metadata in a ledger entry', async () => {
|
||||
tbank.getOperationsByCursor.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'coupon',
|
||||
date: { seconds: '1' },
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
instrumentUid: 'instrument-uid',
|
||||
},
|
||||
],
|
||||
});
|
||||
metadata.resolve.mockResolvedValue({
|
||||
brand: { logoName: 'instrument-logo.png' },
|
||||
});
|
||||
|
||||
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
|
||||
items: [{ id: 'coupon', logoName: 'instrument-logo.png' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves repeated instrument metadata only once per ledger page', async () => {
|
||||
tbank.getOperationsByCursor.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
date: { seconds: '1' },
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
instrumentUid: 'instrument-uid',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
date: { seconds: '2' },
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
instrumentUid: 'instrument-uid',
|
||||
},
|
||||
],
|
||||
});
|
||||
metadata.resolve.mockResolvedValue({
|
||||
brand: { logoName: 'instrument-logo.png' },
|
||||
});
|
||||
|
||||
await service.get('user', 'account', {});
|
||||
|
||||
expect(metadata.resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => {
|
||||
tbank.getOperationsByCursor.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
date: { seconds: '1' },
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
instrumentUid: 'instrument-uid',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
date: { seconds: '2' },
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
instrumentUid: 'instrument-uid',
|
||||
figi: 'BBG000000001',
|
||||
},
|
||||
],
|
||||
});
|
||||
metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
brand: { logoName: 'fallback-logo.png' },
|
||||
});
|
||||
|
||||
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
|
||||
items: [
|
||||
{ id: 'first', logoName: null },
|
||||
{ id: 'second', logoName: 'fallback-logo.png' },
|
||||
],
|
||||
});
|
||||
expect(metadata.resolve).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'token',
|
||||
'instrument-uid',
|
||||
null,
|
||||
);
|
||||
expect(metadata.resolve).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
'instrument-uid',
|
||||
'BBG000000001',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
|
||||
await expect(
|
||||
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
|
||||
).resolves.toMatchObject({
|
||||
accountId: 'account',
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
});
|
||||
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects inaccessible accounts before reading operations', async () => {
|
||||
access.withAccount.mockRejectedValueOnce(new NotFoundException());
|
||||
|
||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
||||
expect.any(NotFoundException),
|
||||
);
|
||||
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains existing T-Bank error semantics', async () => {
|
||||
access.withAccount.mockRejectedValueOnce(
|
||||
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
||||
);
|
||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
tbank.getOperationsByCursor.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z'));
|
||||
|
||||
await service.get('user', 'account', {
|
||||
categories: ['tax', 'income', 'income'],
|
||||
});
|
||||
jest.advanceTimersByTime(500);
|
||||
await service.get('user', 'account', { categories: ['income', 'tax'] });
|
||||
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let a cached page bypass account ownership checks', async () => {
|
||||
const query = { from: '2025-01-01', to: '2025-01-31' };
|
||||
await service.get('user', 'account', query);
|
||||
access.withAccount.mockRejectedValueOnce(new NotFoundException());
|
||||
|
||||
await expect(service.get('user', 'account', query)).rejects.toEqual(
|
||||
expect.any(NotFoundException),
|
||||
);
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reuse a cached ledger page across connection generations', async () => {
|
||||
const query = { from: '2025-01-01', to: '2025-01-31' };
|
||||
|
||||
await service.get('user', 'account', query);
|
||||
await service.get('user', 'account', query);
|
||||
generation = 'generation-2';
|
||||
await service.get('user', 'account', query);
|
||||
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not reuse a cached ledger page after the connection is deleted', async () => {
|
||||
const query = { from: '2025-01-01', to: '2025-01-31' };
|
||||
await service.get('user', 'account', query);
|
||||
access.withAccount.mockRejectedValueOnce(
|
||||
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account', query)).rejects.toEqual(
|
||||
expect.any(ConflictException),
|
||||
);
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a late previous-generation page unavailable to ledger and income collection', async () => {
|
||||
let finishPrevious: ((value: unknown) => void) | undefined;
|
||||
const previous = new Promise((resolve) => {
|
||||
finishPrevious = resolve;
|
||||
});
|
||||
tbank.getOperationsByCursor
|
||||
.mockReturnValueOnce(previous)
|
||||
.mockResolvedValueOnce({
|
||||
items: [operation('current')],
|
||||
});
|
||||
const range = { from: '2025-01-01', to: '2025-01-31' };
|
||||
|
||||
const previousRequest = service.collectRanges('user', 'account', [range]);
|
||||
generation = 'generation-2';
|
||||
const current = await service.collectRanges('user', 'account', [range]);
|
||||
finishPrevious?.({ items: [operation('previous')] });
|
||||
await previousRequest;
|
||||
const reused = await service.collectRanges('user', 'account', [range]);
|
||||
|
||||
expect(current.pages[0][0].items[0].id).toBe('current');
|
||||
expect(reused.pages[0][0].items[0].id).toBe('current');
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('collects every cursor page and rejects a malformed continuation cursor', async () => {
|
||||
tbank.getOperationsByCursor
|
||||
.mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] })
|
||||
.mockResolvedValueOnce({ hasNext: false, items: [] });
|
||||
|
||||
await expect(
|
||||
service.collectRanges('user', 'account', [
|
||||
{ from: '2026-01-01', to: '2026-01-31' },
|
||||
]),
|
||||
).resolves.toMatchObject({
|
||||
pages: [[{ hasNext: true }, { hasNext: false }]],
|
||||
});
|
||||
|
||||
tbank.getOperationsByCursor.mockReset();
|
||||
tbank.getOperationsByCursor.mockResolvedValue({
|
||||
hasNext: true,
|
||||
nextCursor: 'same',
|
||||
items: [],
|
||||
});
|
||||
await expect(
|
||||
service.collectRanges('user', 'account', [
|
||||
{ from: '2026-02-01', to: '2026-02-28' },
|
||||
]),
|
||||
).rejects.toMatchObject({ response: { code: 'TBANK_UNAVAILABLE' } });
|
||||
});
|
||||
|
||||
it('collects an in-progress Moscow day only through the current instant', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
|
||||
|
||||
await service.collectRanges('user', 'account', [
|
||||
{ from: '2026-08-01', to: '2026-08-28' },
|
||||
]);
|
||||
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({
|
||||
from: { seconds: '1785531600' },
|
||||
to: { seconds: '1787867058' },
|
||||
limit: 1000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('evicts the least recently used ledger page after one hundred entries', async () => {
|
||||
for (let index = 0; index < 100; index += 1)
|
||||
await service.get('user', 'account', { cursor: String(index) });
|
||||
|
||||
await service.get('user', 'account', { cursor: '0' });
|
||||
await service.get('user', 'account', { cursor: '100' });
|
||||
await service.get('user', 'account', { cursor: '1' });
|
||||
|
||||
const pages = (service as unknown as { pages: { size: number } }).pages;
|
||||
expect(pages.size).toBe(100);
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
|
||||
});
|
||||
});
|
||||
|
||||
function operation(id: string) {
|
||||
return {
|
||||
id,
|
||||
date: { seconds: '1' },
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
import { operationIntervals, planOperationRead } from './operations-sync-plan';
|
||||
|
||||
describe('operations calendar windows', () => {
|
||||
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
|
||||
const result = operationIntervals([
|
||||
{
|
||||
accountId: 'a',
|
||||
from: new Date('2024-02-01T00:00:00+03:00'),
|
||||
to: new Date('2024-03-01T00:00:00+03:00'),
|
||||
},
|
||||
{
|
||||
accountId: 'a',
|
||||
from: new Date('2024-02-10Z'),
|
||||
to: new Date('2024-04-01Z'),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
result.map(({ from, to }) => [from.toISOString(), to.toISOString()]),
|
||||
).toEqual([
|
||||
['2024-01-31T21:00:00.000Z', '2024-02-01T00:00:00.000Z'],
|
||||
['2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
|
||||
['2024-03-01T00:00:00.000Z', '2024-04-01T00:00:00.000Z'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not combine account or instrument predicates and rejects invalid ranges', () => {
|
||||
const range = {
|
||||
from: new Date('2026-12-15Z'),
|
||||
to: new Date('2027-01-15Z'),
|
||||
};
|
||||
const result = operationIntervals([
|
||||
{ accountId: 'a', ...range },
|
||||
{ accountId: 'b', ...range },
|
||||
{ accountId: 'a', instrumentUid: 'uid', ...range },
|
||||
]);
|
||||
expect(result).toHaveLength(6);
|
||||
expect(() =>
|
||||
operationIntervals([{ accountId: 'a', from: range.to, to: range.from }]),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('operation completeness and freshness', () => {
|
||||
const from = new Date('2026-09-01Z');
|
||||
const asOf = new Date('2026-09-25T12:00:00Z');
|
||||
const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf };
|
||||
|
||||
it.each([1, 299, 300])(
|
||||
'preserves the observed complete end after %s seconds, separately from TTL',
|
||||
(seconds) => {
|
||||
const now = new Date(asOf.valueOf() + seconds * 1000);
|
||||
const plan = planOperationRead(
|
||||
[{ accountId: 'a', from, to: now }],
|
||||
[covered],
|
||||
now,
|
||||
{ movingEnd: true },
|
||||
);
|
||||
expect(plan.intervals[0]).toMatchObject({
|
||||
availability: 'complete',
|
||||
asOf,
|
||||
to: asOf,
|
||||
stale: seconds >= 300,
|
||||
});
|
||||
expect(plan.load).toEqual(
|
||||
seconds >= 300 ? [{ accountId: 'a', from, to: now }] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves a cursor observed end after the clock and calendar advance', () => {
|
||||
const cursorEnd = new Date(asOf.valueOf() + 1000);
|
||||
const plan = planOperationRead(
|
||||
[{ accountId: 'a', from, to: cursorEnd }],
|
||||
[covered],
|
||||
new Date('2026-10-01Z'),
|
||||
{ movingEnd: true },
|
||||
);
|
||||
expect(plan.intervals[0]).toMatchObject({
|
||||
availability: 'complete',
|
||||
to: asOf,
|
||||
asOf,
|
||||
});
|
||||
expect(plan.load).toEqual([]);
|
||||
});
|
||||
it('does not hide earlier holes or reuse an instrument subset as whole-account completeness', () => {
|
||||
const now = new Date(asOf.valueOf() + 1000);
|
||||
const partial = { ...covered, from: new Date('2026-09-10Z') };
|
||||
const plan = planOperationRead(
|
||||
[{ accountId: 'a', from, to: now }],
|
||||
[partial],
|
||||
now,
|
||||
{ movingEnd: true },
|
||||
);
|
||||
expect(plan.intervals[0]).toMatchObject({
|
||||
availability: 'partial',
|
||||
stale: false,
|
||||
});
|
||||
expect(plan.load).toEqual([{ accountId: 'a', from, to: partial.from }]);
|
||||
expect(
|
||||
planOperationRead(
|
||||
[{ accountId: 'a', from, to: now }],
|
||||
[{ ...covered, instrumentUid: 'uid' }],
|
||||
now,
|
||||
).intervals[0].availability,
|
||||
).toBe('missing');
|
||||
});
|
||||
|
||||
it('loads a newly explicit tail and closes the previous month after rollover', () => {
|
||||
const now = new Date(asOf.valueOf() + 1000);
|
||||
expect(
|
||||
planOperationRead([{ accountId: 'a', from, to: now }], [covered], now)
|
||||
.load,
|
||||
).toEqual([{ accountId: 'a', from: asOf, to: now }]);
|
||||
const monthEnd = new Date('2026-10-01Z');
|
||||
const rollover = new Date('2026-10-01T00:00:01Z');
|
||||
const plan = planOperationRead(
|
||||
[{ accountId: 'a', from, to: rollover }],
|
||||
[covered],
|
||||
rollover,
|
||||
{ movingEnd: true },
|
||||
);
|
||||
expect(plan.load).toEqual([
|
||||
{ accountId: 'a', from: asOf, to: monthEnd },
|
||||
{ accountId: 'a', from: monthEnd, to: rollover },
|
||||
]);
|
||||
expect(
|
||||
planOperationRead(
|
||||
[{ accountId: 'a', from, to: monthEnd }],
|
||||
[{ ...covered, to: monthEnd, asOf: monthEnd }],
|
||||
rollover,
|
||||
).load,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('force refresh reloads only the shown target even when coverage is fresh', () => {
|
||||
const target = { accountId: 'a', from: new Date('2026-09-20Z'), to: asOf };
|
||||
expect(
|
||||
planOperationRead([target], [covered], asOf, { force: true }).load,
|
||||
).toEqual([target]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
export type OperationInterval = {
|
||||
accountId: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
instrumentUid?: string;
|
||||
};
|
||||
|
||||
// Normalize duplicate/overlapping targets before creating a durable identity.
|
||||
// Calendar windows keep each provider walk bounded without changing [from,to).
|
||||
export function operationIntervals(
|
||||
requested: OperationInterval[],
|
||||
): OperationInterval[] {
|
||||
const groups = new Map<string, OperationInterval[]>();
|
||||
for (const interval of requested) {
|
||||
if (!(interval.from < interval.to))
|
||||
throw new Error('Invalid operations interval');
|
||||
const key = JSON.stringify([
|
||||
interval.accountId,
|
||||
interval.instrumentUid ?? '',
|
||||
]);
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(interval);
|
||||
groups.set(key, group);
|
||||
}
|
||||
const result: OperationInterval[] = [];
|
||||
for (const key of [...groups.keys()].sort()) {
|
||||
const merged: OperationInterval[] = [];
|
||||
for (const interval of (groups.get(key) ?? []).sort(
|
||||
(left, right) => left.from.valueOf() - right.from.valueOf(),
|
||||
)) {
|
||||
const previous = merged.at(-1);
|
||||
if (previous && interval.from <= previous.to)
|
||||
previous.to = new Date(
|
||||
Math.max(previous.to.valueOf(), interval.to.valueOf()),
|
||||
);
|
||||
else merged.push({ ...interval });
|
||||
}
|
||||
for (const interval of merged) {
|
||||
let from = interval.from;
|
||||
while (from < interval.to) {
|
||||
const nextMonth = new Date(
|
||||
Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + 1, 1),
|
||||
);
|
||||
const to = new Date(
|
||||
Math.min(nextMonth.valueOf(), interval.to.valueOf()),
|
||||
);
|
||||
result.push({ ...interval, from, to });
|
||||
from = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function operationIntervalsOverlap(
|
||||
left: OperationInterval,
|
||||
right: OperationInterval,
|
||||
): boolean {
|
||||
return (
|
||||
left.accountId === right.accountId &&
|
||||
(!left.instrumentUid ||
|
||||
!right.instrumentUid ||
|
||||
left.instrumentUid === right.instrumentUid) &&
|
||||
left.from < right.to &&
|
||||
right.from < left.to
|
||||
);
|
||||
}
|
||||
|
||||
export function uncoveredOperationIntervals(
|
||||
requested: OperationInterval[],
|
||||
existing: OperationInterval[],
|
||||
): OperationInterval[] {
|
||||
return requested.flatMap((interval) => {
|
||||
let fragments = [interval];
|
||||
for (const covered of existing) {
|
||||
fragments = fragments.flatMap((fragment) => {
|
||||
if (
|
||||
!operationIntervalsOverlap(fragment, covered) ||
|
||||
(covered.instrumentUid &&
|
||||
covered.instrumentUid !== fragment.instrumentUid)
|
||||
)
|
||||
return [fragment];
|
||||
return [
|
||||
...(fragment.from < covered.from
|
||||
? [{ ...fragment, to: covered.from }]
|
||||
: []),
|
||||
...(covered.to < fragment.to
|
||||
? [{ ...fragment, from: covered.to }]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
}
|
||||
return fragments;
|
||||
});
|
||||
}
|
||||
|
||||
export type OperationCoverage = OperationInterval & {
|
||||
fetchedAt: Date;
|
||||
asOf: Date;
|
||||
};
|
||||
const operationsFreshForMs = 300_000;
|
||||
|
||||
export function planOperationRead(
|
||||
requested: OperationInterval[],
|
||||
coverage: OperationCoverage[],
|
||||
now: Date,
|
||||
options: { movingEnd?: boolean; force?: boolean } = {},
|
||||
) {
|
||||
const monthStart = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
|
||||
);
|
||||
const observedEnd = Math.max(
|
||||
...requested.map((interval) => interval.to.valueOf()),
|
||||
);
|
||||
const load: OperationInterval[] = [];
|
||||
const intervals = operationIntervals(requested).map((target) => {
|
||||
const sources = coverage.filter(
|
||||
(row) =>
|
||||
row.accountId === target.accountId &&
|
||||
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
|
||||
row.from < target.to &&
|
||||
target.from < row.to,
|
||||
);
|
||||
const ranges = sources.map((row) => ({
|
||||
...row,
|
||||
instrumentUid: target.instrumentUid,
|
||||
}));
|
||||
const current = target.from >= monthStart && target.to <= now;
|
||||
const fresh = sources.filter(
|
||||
(row) => now.valueOf() - row.fetchedAt.valueOf() < operationsFreshForMs,
|
||||
);
|
||||
let effective = target;
|
||||
if (options.movingEnd && target.to.valueOf() === observedEnd) {
|
||||
const latest = sources
|
||||
.filter(
|
||||
(row) =>
|
||||
row.asOf.valueOf() === row.to.valueOf() && row.to > target.from,
|
||||
)
|
||||
.sort((left, right) => right.to.valueOf() - left.to.valueOf())[0];
|
||||
if (latest && latest.to < target.to)
|
||||
effective = { ...target, to: latest.to };
|
||||
}
|
||||
const missing = uncoveredOperationIntervals([effective], ranges);
|
||||
const complete = missing.length === 0;
|
||||
const stale =
|
||||
current &&
|
||||
sources.length > 0 &&
|
||||
uncoveredOperationIntervals(
|
||||
ranges
|
||||
.map((row) => ({
|
||||
...target,
|
||||
from: new Date(
|
||||
Math.max(effective.from.valueOf(), row.from.valueOf()),
|
||||
),
|
||||
to: new Date(Math.min(effective.to.valueOf(), row.to.valueOf())),
|
||||
}))
|
||||
.filter((row) => row.from < row.to),
|
||||
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
|
||||
).length > 0;
|
||||
if (options.force) load.push(target);
|
||||
else if (stale) load.push({ ...target, from: monthStart, to: now });
|
||||
else load.push(...missing);
|
||||
// Completeness is bounded by the observed end, never by the new wall clock.
|
||||
return {
|
||||
...effective,
|
||||
availability: complete
|
||||
? ('complete' as const)
|
||||
: sources.length
|
||||
? ('partial' as const)
|
||||
: ('missing' as const),
|
||||
stale,
|
||||
asOf: sources.length
|
||||
? new Date(
|
||||
Math.min(
|
||||
effective.to.valueOf(),
|
||||
Math.max(...sources.map((row) => row.asOf.valueOf())),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
return { intervals, load: operationIntervals(load) };
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} 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 {
|
||||
TbankOperationItem,
|
||||
TbankOperationsByCursorResponse,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
|
||||
import { operationCategory } from './brokerage-operation.mapper';
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
operationIntervalsOverlap,
|
||||
uncoveredOperationIntervals,
|
||||
} from '../domain/operations-sync-plan';
|
||||
export type { OperationInterval } from '../domain/operations-sync-plan';
|
||||
|
||||
export type OperationAttempt = {
|
||||
id: string;
|
||||
attemptId: string;
|
||||
userId: string;
|
||||
revision: string;
|
||||
};
|
||||
type OperationsIntent = {
|
||||
intervals: string[];
|
||||
atomic: boolean;
|
||||
atomicIntervals?: string[];
|
||||
screenIntervals?: string[];
|
||||
};
|
||||
type PageProgress = {
|
||||
complete: boolean;
|
||||
cursor: string;
|
||||
seen: string[];
|
||||
pages: number;
|
||||
};
|
||||
const progressItem = '';
|
||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageOperationsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async readPage(input: {
|
||||
userId: string;
|
||||
revision: string;
|
||||
accountId: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
instrumentUid?: string;
|
||||
categories: string[];
|
||||
limit?: number;
|
||||
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
|
||||
filterKey: string;
|
||||
cursor?: { binding: string; at: string; accountId: string; id: string };
|
||||
}) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const connection = await tx.tbankConnection.findUnique({
|
||||
where: {
|
||||
userId_revision: { userId: input.userId, revision: input.revision },
|
||||
invalidToken: false,
|
||||
},
|
||||
select: {
|
||||
membershipAt: true,
|
||||
membershipRevision: true,
|
||||
accounts: {
|
||||
where:
|
||||
input.accountId === 'all' ? {} : { accountId: input.accountId },
|
||||
orderBy: { accountId: 'asc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
name: true,
|
||||
openedOn: true,
|
||||
operationsRevision: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!connection) return null;
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
input.accountId !== 'all' &&
|
||||
!connection.accounts.length
|
||||
)
|
||||
throw new NotFoundException();
|
||||
const accounts = connection.accounts;
|
||||
const oldest = accounts.map((account) => account.openedOn).sort()[0];
|
||||
const openedAt =
|
||||
oldest && accounts.every((account) => account.openedOn)
|
||||
? new Date(`${oldest}T00:00:00+03:00`)
|
||||
: undefined;
|
||||
const periods = input.periods?.(openedAt).map((period) => {
|
||||
const bounds = dateOnlyBounds(period);
|
||||
// Date-only end is inclusive; persisted coverage is half-open.
|
||||
const end = new Date(`${period.to}T21:00:00Z`);
|
||||
return {
|
||||
...bounds,
|
||||
key: `${period.from}/${period.to}`,
|
||||
movingEnd: end.valueOf() > Date.now(),
|
||||
to: new Date(Math.min(end.valueOf(), Date.now())),
|
||||
};
|
||||
});
|
||||
const binding = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
input.userId,
|
||||
input.revision,
|
||||
input.accountId === 'all' ? connection.membershipRevision : null,
|
||||
accounts.map((account) => [
|
||||
account.accountId,
|
||||
account.operationsRevision,
|
||||
]),
|
||||
input.filterKey,
|
||||
]),
|
||||
)
|
||||
.digest('base64url');
|
||||
if (input.cursor && input.cursor.binding !== binding)
|
||||
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
||||
const scope = {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
accountId: { in: accounts.map((account) => account.accountId) },
|
||||
};
|
||||
const coverage = await tx.tbankOperationCoverage.findMany({
|
||||
where: scope,
|
||||
});
|
||||
const work = await tx.tbankSyncWork.findMany({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
scopeKey: { startsWith: 'operations:' },
|
||||
state: { in: ['running', 'failed'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
state: true,
|
||||
errorCode: true,
|
||||
intent: true,
|
||||
priority: true,
|
||||
},
|
||||
});
|
||||
const membershipWork = await tx.tbankSyncWork.findUnique({
|
||||
where: {
|
||||
userId_revision_scopeKey: {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
scopeKey: 'membership',
|
||||
},
|
||||
},
|
||||
select: { id: true, state: true, errorCode: true },
|
||||
});
|
||||
const after = input.cursor;
|
||||
const rows = await tx.tbankOperation.findMany({
|
||||
where: {
|
||||
...scope,
|
||||
...(periods
|
||||
? {
|
||||
OR: periods.map((period) => ({
|
||||
occurredAt: { gte: period.from, lt: period.to },
|
||||
})),
|
||||
}
|
||||
: { occurredAt: { gte: input.from, lt: input.to } }),
|
||||
...(input.instrumentUid
|
||||
? { instrumentUid: input.instrumentUid }
|
||||
: {}),
|
||||
...(input.categories.length
|
||||
? { category: { in: input.categories } }
|
||||
: {}),
|
||||
...(after
|
||||
? {
|
||||
OR: [
|
||||
{ occurredAt: { lt: new Date(after.at) } },
|
||||
{
|
||||
occurredAt: new Date(after.at),
|
||||
accountId: { gt: after.accountId },
|
||||
},
|
||||
{
|
||||
occurredAt: new Date(after.at),
|
||||
accountId: after.accountId,
|
||||
brokerOperationId: { gt: after.id },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [
|
||||
{ occurredAt: 'desc' },
|
||||
{ accountId: 'asc' },
|
||||
{ brokerOperationId: 'asc' },
|
||||
],
|
||||
...(input.limit === undefined ? {} : { take: input.limit + 1 }),
|
||||
});
|
||||
return {
|
||||
connection,
|
||||
periods,
|
||||
membershipWork,
|
||||
binding,
|
||||
rows,
|
||||
coverage: coverage.map(({ instrumentPredicate, ...row }) => ({
|
||||
...row,
|
||||
instrumentUid: instrumentPredicate || undefined,
|
||||
})),
|
||||
work: work.map(({ intent, ...row }) => ({
|
||||
...row,
|
||||
intervals: (intent as { intervals: string[] }).intervals.map(
|
||||
decodeInterval,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async coverage(userId: string, revision: string, accountIds: string[]) {
|
||||
const rows = await this.prisma.tbankOperationCoverage.findMany({
|
||||
where: {
|
||||
userId,
|
||||
revision,
|
||||
accountId: { in: accountIds },
|
||||
account: { connection: { invalidToken: false } },
|
||||
},
|
||||
});
|
||||
return rows.map(({ instrumentPredicate, ...row }) => ({
|
||||
...row,
|
||||
instrumentUid: instrumentPredicate || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async request(
|
||||
userId: string,
|
||||
revision: string,
|
||||
intervals: OperationInterval[],
|
||||
processRunId: string,
|
||||
atomic: boolean,
|
||||
runningAttempts: ReadonlySet<string>,
|
||||
priority: number,
|
||||
) {
|
||||
if (!intervals.length) throw new Error('Empty operations request');
|
||||
const scopeKey =
|
||||
'operations:' +
|
||||
createHash('sha256')
|
||||
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
|
||||
.digest('hex');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const locked = await tx.tbankConnection.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
revision,
|
||||
invalidToken: false,
|
||||
membershipAt: { not: null },
|
||||
},
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!locked.count) return null;
|
||||
const accounts = await tx.tbankAccount.findMany({
|
||||
where: { userId, revision },
|
||||
select: { accountId: true, openedOn: true },
|
||||
});
|
||||
if (
|
||||
intervals.some(
|
||||
(interval) =>
|
||||
!accounts.some(
|
||||
(account) => account.accountId === interval.accountId,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new NotFoundException();
|
||||
if (priority > 1) {
|
||||
for (const account of accounts.filter((account) =>
|
||||
intervals.some(
|
||||
(interval) => interval.accountId === account.accountId,
|
||||
),
|
||||
))
|
||||
await tx.tbankAccount.update({
|
||||
where: {
|
||||
userId_revision_accountId: {
|
||||
userId,
|
||||
revision,
|
||||
accountId: account.accountId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
historyFrom: new Date(
|
||||
account.openedOn
|
||||
? `${account.openedOn}T00:00:00Z`
|
||||
: '1970-01-01T00:00:00Z',
|
||||
),
|
||||
historyBoundary: account.openedOn ? 'known' : 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
const coverage = await tx.tbankOperationCoverage.findMany({
|
||||
where: {
|
||||
userId,
|
||||
revision,
|
||||
accountId: { in: intervals.map((interval) => interval.accountId) },
|
||||
},
|
||||
});
|
||||
const publicationAtomic =
|
||||
atomic ||
|
||||
intervals.every(
|
||||
(interval) =>
|
||||
uncoveredOperationIntervals(
|
||||
[interval],
|
||||
coverage
|
||||
.filter(
|
||||
(row) =>
|
||||
row.accountId === interval.accountId &&
|
||||
(!row.instrumentPredicate ||
|
||||
row.instrumentPredicate === interval.instrumentUid),
|
||||
)
|
||||
.map((row) => ({
|
||||
accountId: row.accountId,
|
||||
from: row.from,
|
||||
to: row.to,
|
||||
instrumentUid: interval.instrumentUid,
|
||||
})),
|
||||
).length === 0,
|
||||
);
|
||||
const active = await tx.tbankSyncWork.findMany({
|
||||
where: {
|
||||
userId,
|
||||
revision,
|
||||
state: 'running',
|
||||
scopeKey: { startsWith: 'operations:' },
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
const touching = active.filter((work) => {
|
||||
const pending = (work.intent as { intervals: string[] }).intervals.map(
|
||||
decodeInterval,
|
||||
);
|
||||
return pending.some((existing) =>
|
||||
intervals.some((incoming) =>
|
||||
operationIntervalsOverlap(existing, incoming),
|
||||
),
|
||||
);
|
||||
});
|
||||
const previous =
|
||||
touching[0] ??
|
||||
(await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: { userId, revision, scopeKey } },
|
||||
}));
|
||||
const identity = {
|
||||
userId,
|
||||
revision,
|
||||
scopeKey: previous?.scopeKey ?? scopeKey,
|
||||
};
|
||||
const previousIntent = previous?.intent as OperationsIntent | undefined;
|
||||
const upgradeGroup =
|
||||
publicationAtomic && previousIntent && !previousIntent.atomic;
|
||||
if (
|
||||
touching.length <= 1 &&
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === processRunId &&
|
||||
runningAttempts.has(previous.attemptId) &&
|
||||
!upgradeGroup
|
||||
) {
|
||||
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
||||
const additions =
|
||||
previous.scopeKey === scopeKey
|
||||
? []
|
||||
: uncoveredOperationIntervals(intervals, pending);
|
||||
const work = await tx.tbankSyncWork.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
intent: {
|
||||
atomic: previousIntent?.atomic ?? publicationAtomic,
|
||||
atomicIntervals: previousIntent?.atomic
|
||||
? [
|
||||
...new Set([
|
||||
...(previousIntent.atomicIntervals ??
|
||||
previousIntent.intervals),
|
||||
...additions.map(intervalKey),
|
||||
]),
|
||||
]
|
||||
: [],
|
||||
screenIntervals: [
|
||||
...new Set([
|
||||
...(previousIntent?.screenIntervals ??
|
||||
(previous.priority === 1
|
||||
? (previousIntent?.intervals ?? [])
|
||||
: [])),
|
||||
...(priority === 1
|
||||
? [...pending, ...additions]
|
||||
.filter((item) =>
|
||||
intervals.some((target) =>
|
||||
operationIntervalsOverlap(item, target),
|
||||
),
|
||||
)
|
||||
.map(intervalKey)
|
||||
: []),
|
||||
]),
|
||||
],
|
||||
intervals: (priority < previous.priority
|
||||
? [
|
||||
...pending.filter((item) =>
|
||||
intervals.some((target) =>
|
||||
operationIntervalsOverlap(item, target),
|
||||
),
|
||||
),
|
||||
...additions,
|
||||
...pending.filter(
|
||||
(item) =>
|
||||
!intervals.some((target) =>
|
||||
operationIntervalsOverlap(item, target),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [...pending, ...additions]
|
||||
).map(intervalKey),
|
||||
},
|
||||
priority: Math.min(priority, previous.priority),
|
||||
},
|
||||
});
|
||||
return { work, launch: false };
|
||||
}
|
||||
const recovering =
|
||||
previous &&
|
||||
previous.state !== 'complete' &&
|
||||
previous.state !== 'superseded';
|
||||
const retained = recovering
|
||||
? (previousIntent?.intervals.map(decodeInterval) ?? [])
|
||||
: [];
|
||||
// A bridge between two active groups must fence both producers before
|
||||
// either can publish a subset of the newly combined result.
|
||||
for (const joined of touching) {
|
||||
if (joined.id === previous?.id) continue;
|
||||
retained.push(
|
||||
...(joined.intent as { intervals: string[] }).intervals.map(
|
||||
decodeInterval,
|
||||
),
|
||||
);
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { id: joined.id },
|
||||
data: {
|
||||
state: 'superseded',
|
||||
progress: { successorId: previous?.id ?? '' },
|
||||
},
|
||||
});
|
||||
await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } });
|
||||
}
|
||||
if (previous)
|
||||
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||
const targets =
|
||||
recovering && !upgradeGroup && touching.length <= 1
|
||||
? previous?.scopeKey === scopeKey
|
||||
? retained
|
||||
: [...retained, ...uncoveredOperationIntervals(intervals, retained)]
|
||||
: operationIntervals([...retained, ...intervals]);
|
||||
const atomicTargets = [
|
||||
...(publicationAtomic ? intervals : []),
|
||||
...(recovering && previousIntent?.atomic
|
||||
? (previousIntent.atomicIntervals ?? previousIntent.intervals).map(
|
||||
decodeInterval,
|
||||
)
|
||||
: []),
|
||||
...touching.flatMap((work) => {
|
||||
const joined = work.intent as OperationsIntent;
|
||||
return joined.atomic
|
||||
? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval)
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
const intent = {
|
||||
intervals: targets.map(intervalKey),
|
||||
atomicIntervals: targets
|
||||
.filter((target) =>
|
||||
atomicTargets.some((atomic) =>
|
||||
operationIntervalsOverlap(target, atomic),
|
||||
),
|
||||
)
|
||||
.map(intervalKey),
|
||||
screenIntervals: targets
|
||||
.filter(
|
||||
(target) =>
|
||||
(priority === 1 &&
|
||||
intervals.some((incoming) =>
|
||||
operationIntervalsOverlap(target, incoming),
|
||||
)) ||
|
||||
(
|
||||
previousIntent?.screenIntervals ??
|
||||
(previous?.priority === 1
|
||||
? (previousIntent?.intervals ?? [])
|
||||
: [])
|
||||
).some((key) =>
|
||||
operationIntervalsOverlap(target, decodeInterval(key)),
|
||||
),
|
||||
)
|
||||
.map(intervalKey),
|
||||
atomic: atomicTargets.length > 0,
|
||||
};
|
||||
const data = {
|
||||
intent,
|
||||
state: 'running',
|
||||
attemptId: randomUUID(),
|
||||
processRunId,
|
||||
priority: Math.min(
|
||||
priority,
|
||||
previous?.priority ?? priority,
|
||||
...touching.map((work) => work.priority),
|
||||
),
|
||||
progress: {},
|
||||
errorCode: null,
|
||||
};
|
||||
const work = await tx.tbankSyncWork.upsert({
|
||||
where: { userId_revision_scopeKey: identity },
|
||||
create: { ...identity, ...data, id: randomUUID() },
|
||||
update: data,
|
||||
});
|
||||
return { work, launch: true };
|
||||
});
|
||||
}
|
||||
|
||||
async pending(attempt: OperationAttempt) {
|
||||
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
select: { intent: true, priority: true },
|
||||
});
|
||||
if (!work) return null;
|
||||
const intent = work.intent as OperationsIntent;
|
||||
const markers = await this.prisma.tbankSyncStage.findMany({
|
||||
where: {
|
||||
workId: attempt.id,
|
||||
attemptId: attempt.attemptId,
|
||||
itemKey: progressItem,
|
||||
},
|
||||
select: { sourceKey: true, payload: true },
|
||||
});
|
||||
const progress = new Map(
|
||||
markers.map((marker) => [
|
||||
marker.sourceKey,
|
||||
marker.payload as PageProgress,
|
||||
]),
|
||||
);
|
||||
const group = intent.atomic
|
||||
? (intent.atomicIntervals ?? intent.intervals)
|
||||
: intent.intervals;
|
||||
const nextKey = group.find((key) => !progress.get(key)?.complete);
|
||||
return {
|
||||
priority: work.priority,
|
||||
atomic: intent.atomic,
|
||||
intervals: group.map(decodeInterval),
|
||||
next: nextKey
|
||||
? {
|
||||
interval: decodeInterval(nextKey),
|
||||
cursor: progress.get(nextKey)?.cursor || undefined,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async observe(userId: string, revision: string, id: string) {
|
||||
let target = id;
|
||||
const seen = new Set<string>();
|
||||
while (!seen.has(target)) {
|
||||
seen.add(target);
|
||||
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||
where: {
|
||||
id: target,
|
||||
userId,
|
||||
revision,
|
||||
scopeKey: { startsWith: 'operations:' },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
state: true,
|
||||
progress: true,
|
||||
errorCode: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!work || work.state !== 'superseded')
|
||||
return work ? { ...work, id } : null;
|
||||
target = (work.progress as { successorId: string }).successorId;
|
||||
}
|
||||
throw new Error('Cyclic operations work successor');
|
||||
}
|
||||
|
||||
async fail(attempt: OperationAttempt, invalidToken: boolean) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (!(await claim(tx, attempt))) return false;
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { id: attempt.id },
|
||||
data: {
|
||||
state: 'failed',
|
||||
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
});
|
||||
if (invalidToken)
|
||||
await tx.tbankConnection.update({
|
||||
where: {
|
||||
userId_revision: {
|
||||
userId: attempt.userId,
|
||||
revision: attempt.revision,
|
||||
},
|
||||
},
|
||||
data: { invalidToken: true },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async stagePage(
|
||||
attempt: OperationAttempt,
|
||||
interval: OperationInterval,
|
||||
cursor: string | undefined,
|
||||
response: TbankOperationsByCursorResponse,
|
||||
): Promise<boolean> {
|
||||
const sourceKey = intervalKey(interval);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (!(await claim(tx, attempt))) return false;
|
||||
const stage = {
|
||||
workId: attempt.id,
|
||||
attemptId: attempt.attemptId,
|
||||
sourceKey,
|
||||
};
|
||||
const marker = await tx.tbankSyncStage.findUnique({
|
||||
where: {
|
||||
workId_attemptId_sourceKey_itemKey: {
|
||||
...stage,
|
||||
itemKey: progressItem,
|
||||
},
|
||||
},
|
||||
});
|
||||
const progress = marker?.payload as PageProgress | undefined;
|
||||
if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? ''))
|
||||
throw new Error('Unexpected operations page');
|
||||
if (response.hasNext === undefined)
|
||||
throw new Error('Missing operations pagination state');
|
||||
const nextCursor = response.nextCursor ?? '';
|
||||
if (
|
||||
response.hasNext &&
|
||||
(!nextCursor ||
|
||||
nextCursor === cursor ||
|
||||
progress?.seen.includes(nextCursor))
|
||||
)
|
||||
throw new Error('Invalid operations continuation');
|
||||
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()))
|
||||
throw new Error('Invalid operation timestamp');
|
||||
if (
|
||||
occurredAt < interval.from ||
|
||||
occurredAt >= interval.to ||
|
||||
(interval.instrumentUid &&
|
||||
operation.instrumentUid !== interval.instrumentUid)
|
||||
)
|
||||
continue;
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify({ ...operation, cursor: undefined }),
|
||||
) as Prisma.InputJsonObject;
|
||||
// Provider pagination position is not part of a financial row.
|
||||
const identity = { ...stage, itemKey: operation.id };
|
||||
const previous = await tx.tbankSyncStage.findUnique({
|
||||
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||
});
|
||||
if (previous && !isDeepStrictEqual(previous.payload, payload))
|
||||
throw new Error('Conflicting broker operation identity');
|
||||
if (!previous)
|
||||
await tx.tbankSyncStage.create({ data: { ...identity, payload } });
|
||||
}
|
||||
const payload: PageProgress = {
|
||||
complete: !response.hasNext,
|
||||
cursor: nextCursor,
|
||||
seen: [
|
||||
...(progress?.seen ?? []),
|
||||
...(response.hasNext ? [nextCursor] : []),
|
||||
],
|
||||
pages: (progress?.pages ?? 0) + 1,
|
||||
};
|
||||
await tx.tbankSyncStage.upsert({
|
||||
where: {
|
||||
workId_attemptId_sourceKey_itemKey: {
|
||||
...stage,
|
||||
itemKey: progressItem,
|
||||
},
|
||||
},
|
||||
create: { ...stage, itemKey: progressItem, payload },
|
||||
update: { payload },
|
||||
});
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { id: attempt.id },
|
||||
data: { progress: { sourceKey, ...payload } },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// A complete shown scope passes every account/month/comparison here together.
|
||||
// Initial missing history may pass one completed interval at a time.
|
||||
async publish(
|
||||
attempt: OperationAttempt,
|
||||
intervals: OperationInterval[],
|
||||
fetchedAt: Date,
|
||||
advanceWork = false,
|
||||
): Promise<boolean> {
|
||||
if (!intervals.length)
|
||||
throw new Error('Empty operations publication group');
|
||||
return this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
if (!(await claim(tx, attempt))) return false;
|
||||
const changedAccounts = new Set<string>();
|
||||
const groupRows = new Map<string, Prisma.JsonValue>();
|
||||
for (const interval of intervals) {
|
||||
const sourceKey = intervalKey(interval);
|
||||
const stage = {
|
||||
workId: attempt.id,
|
||||
attemptId: attempt.attemptId,
|
||||
sourceKey,
|
||||
};
|
||||
const marker = await tx.tbankSyncStage.findUnique({
|
||||
where: {
|
||||
workId_attemptId_sourceKey_itemKey: {
|
||||
...stage,
|
||||
itemKey: progressItem,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!(marker?.payload as PageProgress | undefined)?.complete)
|
||||
throw new Error('Incomplete operations publication group');
|
||||
const staged = await tx.tbankSyncStage.findMany({
|
||||
where: { ...stage, itemKey: { not: progressItem } },
|
||||
});
|
||||
for (const row of staged) {
|
||||
const identity = JSON.stringify([interval.accountId, row.itemKey]);
|
||||
const previous = groupRows.get(identity);
|
||||
if (
|
||||
previous !== undefined &&
|
||||
!isDeepStrictEqual(previous, row.payload)
|
||||
)
|
||||
throw new Error(
|
||||
'Conflicting broker operation identity across intervals',
|
||||
);
|
||||
groupRows.set(identity, row.payload);
|
||||
}
|
||||
const rows = staged.filter((row) => {
|
||||
const state = (row.payload as TbankOperationItem).state;
|
||||
return !state || state === 'OPERATION_STATE_EXECUTED';
|
||||
});
|
||||
const scope = {
|
||||
userId: attempt.userId,
|
||||
revision: attempt.revision,
|
||||
accountId: interval.accountId,
|
||||
};
|
||||
const existing = await tx.tbankOperation.findMany({
|
||||
where: {
|
||||
...scope,
|
||||
occurredAt: { gte: interval.from, lt: interval.to },
|
||||
...(interval.instrumentUid
|
||||
? { instrumentUid: interval.instrumentUid }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const incoming = new Set(rows.map((row) => row.itemKey));
|
||||
const removed = existing.filter(
|
||||
(row) => !incoming.has(row.brokerOperationId),
|
||||
);
|
||||
for (const row of removed)
|
||||
await tx.tbankOperation.delete({
|
||||
where: {
|
||||
userId_revision_accountId_brokerOperationId: {
|
||||
...scope,
|
||||
brokerOperationId: row.brokerOperationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (removed.length) changedAccounts.add(interval.accountId);
|
||||
for (const row of rows) {
|
||||
const operation = row.payload as TbankOperationItem;
|
||||
const identity = { ...scope, brokerOperationId: row.itemKey };
|
||||
const previous = await tx.tbankOperation.findUnique({
|
||||
where: { userId_revision_accountId_brokerOperationId: identity },
|
||||
});
|
||||
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,
|
||||
),
|
||||
),
|
||||
instrumentUid: operation.instrumentUid ?? null,
|
||||
category: operationCategory(operation.type),
|
||||
payload: row.payload as Prisma.InputJsonObject,
|
||||
};
|
||||
await tx.tbankOperation.upsert({
|
||||
where: { userId_revision_accountId_brokerOperationId: identity },
|
||||
create: { ...identity, ...data },
|
||||
update: data,
|
||||
});
|
||||
changedAccounts.add(interval.accountId);
|
||||
}
|
||||
const coverage = {
|
||||
...scope,
|
||||
instrumentPredicate: interval.instrumentUid ?? '',
|
||||
from: interval.from,
|
||||
to: interval.to,
|
||||
};
|
||||
await tx.tbankOperationCoverage.upsert({
|
||||
where: {
|
||||
userId_revision_accountId_instrumentPredicate_from_to: coverage,
|
||||
},
|
||||
create: { ...coverage, fetchedAt, asOf: interval.to },
|
||||
update: { fetchedAt, asOf: interval.to },
|
||||
});
|
||||
}
|
||||
for (const accountId of changedAccounts)
|
||||
await tx.tbankAccount.update({
|
||||
where: {
|
||||
userId_revision_accountId: {
|
||||
userId: attempt.userId,
|
||||
revision: attempt.revision,
|
||||
accountId,
|
||||
},
|
||||
},
|
||||
data: { operationsRevision: { increment: 1 } },
|
||||
});
|
||||
for (const interval of intervals)
|
||||
await tx.tbankSyncStage.deleteMany({
|
||||
where: {
|
||||
workId: attempt.id,
|
||||
attemptId: attempt.attemptId,
|
||||
sourceKey: intervalKey(interval),
|
||||
},
|
||||
});
|
||||
if (advanceWork) {
|
||||
const work = await tx.tbankSyncWork.findUniqueOrThrow({
|
||||
where: { id: attempt.id },
|
||||
});
|
||||
const intent = work.intent as OperationsIntent;
|
||||
const published = new Set(intervals.map(intervalKey));
|
||||
const remaining = intent.intervals.filter(
|
||||
(key) => !published.has(key),
|
||||
);
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { id: attempt.id },
|
||||
data: {
|
||||
intent: {
|
||||
...intent,
|
||||
intervals: remaining,
|
||||
atomic:
|
||||
intent.atomic &&
|
||||
(intent.atomicIntervals ?? intent.intervals).some(
|
||||
(key) => !published.has(key),
|
||||
),
|
||||
atomicIntervals: (
|
||||
intent.atomicIntervals ?? intent.intervals
|
||||
).filter((key) => !published.has(key)),
|
||||
},
|
||||
state: remaining.length ? 'running' : 'complete',
|
||||
priority: remaining.some((key) =>
|
||||
(
|
||||
intent.screenIntervals ??
|
||||
(work.priority === 1 ? intent.intervals : [])
|
||||
).includes(key),
|
||||
)
|
||||
? 1
|
||||
: 2,
|
||||
progress: { remainingIntervals: remaining.length },
|
||||
},
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function intervalKey(interval: OperationInterval): string {
|
||||
if (!(interval.from < interval.to))
|
||||
throw new Error('Invalid operations interval');
|
||||
return JSON.stringify([
|
||||
interval.accountId,
|
||||
interval.from.toISOString(),
|
||||
interval.to.toISOString(),
|
||||
interval.instrumentUid ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
async function claim(
|
||||
tx: Prisma.TransactionClient,
|
||||
attempt: OperationAttempt,
|
||||
): Promise<boolean> {
|
||||
const result = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
return result.count === 1;
|
||||
}
|
||||
|
||||
function decodeInterval(key: string): OperationInterval {
|
||||
const [accountId, from, to, instrumentUid] = JSON.parse(key) as string[];
|
||||
return {
|
||||
accountId,
|
||||
from: new Date(from),
|
||||
to: new Date(to),
|
||||
...(instrumentUid ? { instrumentUid } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
||||
export { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||
|
||||
export {
|
||||
operationIntervals,
|
||||
type OperationInterval,
|
||||
} from './domain/operations-sync-plan';
|
||||
|
||||
export { operationCategories } from './presentation/brokerage-operation.dto';
|
||||
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
|
||||
@@ -3,11 +3,17 @@ import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
||||
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
||||
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule],
|
||||
controllers: [BrokerageOperationsController],
|
||||
providers: [BrokerageOperationsService],
|
||||
exports: [BrokerageOperationsService],
|
||||
providers: [
|
||||
BrokerageOperationsService,
|
||||
BrokerageOperationsRepository,
|
||||
BrokerageOperationsSyncService,
|
||||
],
|
||||
exports: [BrokerageOperationsService, BrokerageOperationsSyncService],
|
||||
})
|
||||
export class OperationsModule {}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../../investment-resource.dto';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
@@ -30,7 +34,7 @@ export const operationCategories = [
|
||||
] as const;
|
||||
export type OperationCategory = (typeof operationCategories)[number];
|
||||
|
||||
export class BrokerageOperationsQueryDto {
|
||||
export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
@@ -144,3 +148,10 @@ export class BrokerageOperationsPageDto {
|
||||
@ApiProperty({ format: 'date-time' })
|
||||
declare observedAt: string;
|
||||
}
|
||||
|
||||
export class BrokerageOperationsResourceDto {
|
||||
@ApiProperty({ type: BrokerageOperationsPageDto, nullable: true })
|
||||
declare data: BrokerageOperationsPageDto | null;
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
+2
-2
@@ -6,13 +6,13 @@ import { BrokerageOperationsController } from './brokerage-operations.controller
|
||||
|
||||
describe('BrokerageOperationsController', () => {
|
||||
it('passes the authenticated user, account, and validated query to the service', async () => {
|
||||
const operations = { get: jest.fn() };
|
||||
const operations = { read: jest.fn() };
|
||||
const controller = new BrokerageOperationsController(operations as never);
|
||||
const query = { limit: 10 };
|
||||
const request = { user: { sub: 'user' } };
|
||||
|
||||
await controller.get(request as never, 'account', query);
|
||||
|
||||
expect(operations.get).toHaveBeenCalledWith('user', 'account', query);
|
||||
expect(operations.read).toHaveBeenCalledWith('user', 'account', query);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Request } from 'express';
|
||||
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
import {
|
||||
BrokerageOperationsPageDto,
|
||||
BrokerageOperationsResourceDto,
|
||||
BrokerageOperationsQueryDto,
|
||||
} from './brokerage-operation.dto';
|
||||
import { BrokerageOperationsService } from '../application/brokerage-operations.service';
|
||||
@@ -18,21 +18,21 @@ export class BrokerageOperationsController {
|
||||
constructor(private readonly operations: BrokerageOperationsService) {}
|
||||
|
||||
@Get('all/operations')
|
||||
@ApiOkResponse({ type: BrokerageOperationsPageDto })
|
||||
@ApiOkResponse({ type: BrokerageOperationsResourceDto })
|
||||
all(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Query() query: BrokerageOperationsQueryDto,
|
||||
) {
|
||||
return this.operations.getAll(request.user.sub, query);
|
||||
return this.operations.read(request.user.sub, 'all', query);
|
||||
}
|
||||
|
||||
@Get(':accountId/operations')
|
||||
@ApiOkResponse({ type: BrokerageOperationsPageDto })
|
||||
@ApiOkResponse({ type: BrokerageOperationsResourceDto })
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerageOperationsQueryDto,
|
||||
) {
|
||||
return this.operations.get(request.user.sub, accountId, query);
|
||||
return this.operations.read(request.user.sub, accountId, query);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user