feat: persist tbank broker operations

This commit is contained in:
Sergey Krylov 2026-06-17 06:44:45 +03:00
parent ead40bff6f
commit 6e30177294
5 changed files with 270 additions and 11 deletions

View File

@ -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");

View File

@ -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
}

View File

@ -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();
});
});

View File

@ -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<void> {
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,
});
}
}

View File

@ -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 {}