feat: persist tbank broker operations
This commit is contained in:
parent
ead40bff6f
commit
6e30177294
@ -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");
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user