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");
|
||||||
@ -51,3 +51,44 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
portfolios Portfolio[]
|
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 { TBankController } from './tbank.controller';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
import { BrokerInstrumentsService } from './services/broker-instruments.service';
|
import { BrokerInstrumentsService } from './services/broker-instruments.service';
|
||||||
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
import { TBankClientService } from './services/tbank-client.service';
|
import { TBankClientService } from './services/tbank-client.service';
|
||||||
@ -14,6 +15,7 @@ import { TBankClientService } from './services/tbank-client.service';
|
|||||||
BrokerInstrumentsService,
|
BrokerInstrumentsService,
|
||||||
BrokerPortfolioService,
|
BrokerPortfolioService,
|
||||||
BrokerOperationsService,
|
BrokerOperationsService,
|
||||||
|
BrokerOperationSyncService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TBankClientService,
|
TBankClientService,
|
||||||
@ -21,6 +23,7 @@ import { TBankClientService } from './services/tbank-client.service';
|
|||||||
BrokerInstrumentsService,
|
BrokerInstrumentsService,
|
||||||
BrokerPortfolioService,
|
BrokerPortfolioService,
|
||||||
BrokerOperationsService,
|
BrokerOperationsService,
|
||||||
|
BrokerOperationSyncService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TBankModule {}
|
export class TBankModule {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user