fix: harden tbank operation sync

This commit is contained in:
Sergey Krylov 2026-06-17 06:47:18 +03:00
parent 6e30177294
commit 3fe4e7a4ec
2 changed files with 106 additions and 10 deletions

View File

@ -61,11 +61,95 @@ describe('BrokerOperationSyncService', () => {
});
expect(result.upserted).toBe(1);
expect(operations.getOperations).toHaveBeenNthCalledWith(2, 'acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
cursor: 'next',
limit: 1000,
state: 'OPERATION_STATE_EXECUTED',
});
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
}),
);
expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalled();
expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ lastCursor: 'next' }),
}),
);
});
it('stores operations under the synced account id', async () => {
vi.mocked(operations.getOperations).mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: false,
nextCursor: null,
asOf: '2026-06-16T00:00:00.000Z',
items: [
{
cursor: 'c1',
accountId: 'unexpected-account',
id: 'op-1',
parentOperationId: null,
date: null,
type: 'OPERATION_TYPE_BUY',
category: 'trade',
description: null,
state: null,
instrumentUid: null,
figi: null,
ticker: null,
classCode: null,
instrumentType: null,
payment: null,
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
},
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
await service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
});
expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } },
create: expect.objectContaining({ accountId: 'acc-1' }),
update: expect.objectContaining({ accountId: 'acc-1' }),
}),
);
});
it('fails when a page claims more data without a next cursor', async () => {
vi.mocked(operations.getOperations).mockResolvedValueOnce({
data: {
accountId: 'acc-1',
hasNext: true,
nextCursor: null,
asOf: '2026-06-16T00:00:00.000Z',
items: [],
},
meta: { fromCache: false, cachedAt: null },
});
const service = new BrokerOperationSyncService(operations, prisma);
await expect(
service.syncAccount('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-16T00:00:00.000Z',
}),
).rejects.toThrow('T-Bank returned hasNext without nextCursor');
});
});

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { BrokerOperation } from '../types/broker.types';
import { BrokerOperationsService } from './broker-operations.service';
@ -20,6 +20,7 @@ export class BrokerOperationSyncService {
range: BrokerOperationSyncRange,
): Promise<{ upserted: number }> {
let cursor: string | undefined;
let lastCursor: string | null = null;
let upserted = 0;
do {
@ -32,24 +33,35 @@ export class BrokerOperationSyncService {
});
for (const operation of page.data.items) {
await this.upsertOperation(operation);
await this.upsertOperation(accountId, operation);
upserted++;
}
cursor = page.data.nextCursor ?? undefined;
if (!page.data.hasNext) break;
const nextCursor = page.data.nextCursor ?? undefined;
if (page.data.hasNext && !nextCursor) {
throw new InternalServerErrorException('T-Bank returned hasNext without nextCursor');
}
if (nextCursor) {
lastCursor = nextCursor;
}
cursor = nextCursor;
if (!page.data.hasNext) {
break;
}
} while (cursor);
await this.prisma.brokerOperationSyncState.upsert({
where: { accountId },
create: {
accountId,
lastCursor: cursor ?? null,
lastCursor,
lastSyncedFrom: new Date(range.from),
lastSyncedTo: new Date(range.to),
},
update: {
lastCursor: cursor ?? null,
lastCursor,
lastSyncedFrom: new Date(range.from),
lastSyncedTo: new Date(range.to),
syncedAt: new Date(),
@ -59,11 +71,11 @@ export class BrokerOperationSyncService {
return { upserted };
}
private async upsertOperation(operation: BrokerOperation): Promise<void> {
private async upsertOperation(accountId: string, operation: BrokerOperation): Promise<void> {
const cursor =
operation.cursor || `${operation.id || 'operation'}:${operation.date || 'no-date'}`;
const data = {
accountId: operation.accountId,
accountId,
cursor,
operationId: operation.id,
parentOperationId: operation.parentOperationId,
@ -86,7 +98,7 @@ export class BrokerOperationSyncService {
};
await this.prisma.brokerOperation.upsert({
where: { accountId_cursor: { accountId: operation.accountId, cursor } },
where: { accountId_cursor: { accountId, cursor } },
create: data,
update: data,
});