fix: harden tbank operation sync
This commit is contained in:
parent
6e30177294
commit
3fe4e7a4ec
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user