diff --git a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts index ae67096..14bc828 100644 --- a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts @@ -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'); }); }); diff --git a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts index 4912b0f..2d54fb1 100644 --- a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts @@ -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 { + private async upsertOperation(accountId: string, operation: BrokerOperation): Promise { 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, });