From 6e30177294c8a9c56f39dcf3461f0e43e0e1ae31 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 06:44:45 +0300 Subject: [PATCH] feat: persist tbank broker operations --- .../migration.sql | 50 ++++++++++ apps/backend/prisma/schema.prisma | 63 ++++++++++--- .../broker-operation-sync.service.spec.ts | 71 ++++++++++++++ .../services/broker-operation-sync.service.ts | 94 +++++++++++++++++++ .../backend/src/modules/tbank/tbank.module.ts | 3 + 5 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260616201023_add_broker_operations/migration.sql create mode 100644 apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts create mode 100644 apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts diff --git a/apps/backend/prisma/migrations/20260616201023_add_broker_operations/migration.sql b/apps/backend/prisma/migrations/20260616201023_add_broker_operations/migration.sql new file mode 100644 index 0000000..ffc9ad1 --- /dev/null +++ b/apps/backend/prisma/migrations/20260616201023_add_broker_operations/migration.sql @@ -0,0 +1,50 @@ +-- CreateTable +CREATE TABLE "BrokerOperation" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "accountId" TEXT NOT NULL, + "cursor" TEXT, + "operationId" TEXT, + "parentOperationId" TEXT, + "date" DATETIME, + "type" TEXT NOT NULL, + "category" TEXT NOT NULL, + "state" TEXT, + "instrumentUid" TEXT, + "figi" TEXT, + "ticker" TEXT, + "classCode" TEXT, + "payment" TEXT, + "price" TEXT, + "commission" TEXT, + "yield" TEXT, + "accruedInt" TEXT, + "quantity" INTEGER, + "quantityDone" INTEGER, + "raw" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "BrokerOperationSyncState" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "accountId" TEXT NOT NULL, + "lastCursor" TEXT, + "lastSyncedFrom" DATETIME, + "lastSyncedTo" DATETIME, + "syncedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE INDEX "BrokerOperation_accountId_date_idx" ON "BrokerOperation"("accountId", "date"); + +-- CreateIndex +CREATE INDEX "BrokerOperation_accountId_type_idx" ON "BrokerOperation"("accountId", "type"); + +-- CreateIndex +CREATE UNIQUE INDEX "BrokerOperation_accountId_cursor_key" ON "BrokerOperation"("accountId", "cursor"); + +-- CreateIndex +CREATE UNIQUE INDEX "BrokerOperationSyncState_accountId_key" ON "BrokerOperationSyncState"("accountId"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 59f70ab..8645590 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -7,35 +7,35 @@ datasource db { } model Portfolio { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) userId Int name String description String? - currency String @default("RUB") + currency String @default("RUB") targets String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - positions Position[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + positions Position[] @@unique([userId, name]) } model Position { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) portfolioId Int secid String - type String @default("share") + type String @default("share") quantity Int buyPrice Float? buyDate DateTime? notes String? tags String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) + portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) @@unique([portfolioId, secid]) } @@ -51,3 +51,44 @@ model User { updatedAt DateTime @updatedAt portfolios Portfolio[] } + +model BrokerOperation { + id Int @id @default(autoincrement()) + accountId String + cursor String? + operationId String? + parentOperationId String? + date DateTime? + type String + category String + state String? + instrumentUid String? + figi String? + ticker String? + classCode String? + payment String? + price String? + commission String? + yield String? + accruedInt String? + quantity Int? + quantityDone Int? + raw String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, cursor]) + @@index([accountId, date]) + @@index([accountId, type]) +} + +model BrokerOperationSyncState { + id Int @id @default(autoincrement()) + accountId String @unique + lastCursor String? + lastSyncedFrom DateTime? + lastSyncedTo DateTime? + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} 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 new file mode 100644 index 0000000..ae67096 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts @@ -0,0 +1,71 @@ +import { PrismaService } from '../../prisma/prisma.service'; +import { BrokerOperationSyncService } from './broker-operation-sync.service'; +import { BrokerOperationsService } from './broker-operations.service'; + +describe('BrokerOperationSyncService', () => { + const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; + const prisma = { + brokerOperation: { upsert: vi.fn() }, + brokerOperationSyncState: { upsert: vi.fn() }, + } as unknown as PrismaService; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('syncs operation pages and stores raw payload', async () => { + vi.mocked(operations.getOperations) + .mockResolvedValueOnce({ + data: { + accountId: 'acc-1', + hasNext: true, + nextCursor: 'next', + asOf: '2026-06-16T00:00:00.000Z', + items: [ + { + cursor: 'c1', + accountId: 'acc-1', + id: 'op-1', + parentOperationId: null, + date: '2026-06-16T00:00:00.000Z', + type: 'OPERATION_TYPE_BUY', + category: 'trade', + description: null, + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'uid-1', + figi: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 10, + quantityDone: 10, + }, + ], + }, + meta: { fromCache: false, cachedAt: null }, + }) + .mockResolvedValueOnce({ + data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] }, + meta: { fromCache: false, cachedAt: null }, + }); + + const service = new BrokerOperationSyncService(operations, prisma); + const result = await service.syncAccount('acc-1', { + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-16T00:00:00.000Z', + }); + + expect(result.upserted).toBe(1); + expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } }, + }), + ); + expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 0000000..4912b0f --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { BrokerOperation } from '../types/broker.types'; +import { BrokerOperationsService } from './broker-operations.service'; + +type BrokerOperationSyncRange = { + from: string; + to: string; +}; + +@Injectable() +export class BrokerOperationSyncService { + constructor( + private readonly operationsService: BrokerOperationsService, + private readonly prisma: PrismaService, + ) {} + + async syncAccount( + accountId: string, + range: BrokerOperationSyncRange, + ): Promise<{ upserted: number }> { + let cursor: string | undefined; + let upserted = 0; + + do { + const page = await this.operationsService.getOperations(accountId, { + from: range.from, + to: range.to, + cursor, + limit: 1000, + state: 'OPERATION_STATE_EXECUTED', + }); + + for (const operation of page.data.items) { + await this.upsertOperation(operation); + upserted++; + } + + cursor = page.data.nextCursor ?? undefined; + if (!page.data.hasNext) break; + } while (cursor); + + await this.prisma.brokerOperationSyncState.upsert({ + where: { accountId }, + create: { + accountId, + lastCursor: cursor ?? null, + lastSyncedFrom: new Date(range.from), + lastSyncedTo: new Date(range.to), + }, + update: { + lastCursor: cursor ?? null, + lastSyncedFrom: new Date(range.from), + lastSyncedTo: new Date(range.to), + syncedAt: new Date(), + }, + }); + + return { upserted }; + } + + private async upsertOperation(operation: BrokerOperation): Promise { + const cursor = + operation.cursor || `${operation.id || 'operation'}:${operation.date || 'no-date'}`; + const data = { + accountId: operation.accountId, + cursor, + operationId: operation.id, + parentOperationId: operation.parentOperationId, + date: operation.date ? new Date(operation.date) : null, + type: operation.type, + category: operation.category, + state: operation.state, + instrumentUid: operation.instrumentUid, + figi: operation.figi, + ticker: operation.ticker, + classCode: operation.classCode, + payment: operation.payment ? JSON.stringify(operation.payment) : null, + price: operation.price ? JSON.stringify(operation.price) : null, + commission: operation.commission ? JSON.stringify(operation.commission) : null, + yield: operation.yield ? JSON.stringify(operation.yield) : null, + accruedInt: operation.accruedInt ? JSON.stringify(operation.accruedInt) : null, + quantity: operation.quantity, + quantityDone: operation.quantityDone, + raw: JSON.stringify(operation), + }; + + await this.prisma.brokerOperation.upsert({ + where: { accountId_cursor: { accountId: operation.accountId, cursor } }, + create: data, + update: data, + }); + } +} diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts index 81d3663..964158b 100644 --- a/apps/backend/src/modules/tbank/tbank.module.ts +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TBankController } from './tbank.controller'; import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerInstrumentsService } from './services/broker-instruments.service'; +import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { TBankClientService } from './services/tbank-client.service'; @@ -14,6 +15,7 @@ import { TBankClientService } from './services/tbank-client.service'; BrokerInstrumentsService, BrokerPortfolioService, BrokerOperationsService, + BrokerOperationSyncService, ], exports: [ TBankClientService, @@ -21,6 +23,7 @@ import { TBankClientService } from './services/tbank-client.service'; BrokerInstrumentsService, BrokerPortfolioService, BrokerOperationsService, + BrokerOperationSyncService, ], }) export class TBankModule {}