codex/tbank-broker-portfolios-design #15

Merged
ksv741 merged 17 commits from codex/tbank-broker-portfolios-design into main 2026-06-17 07:46:51 +03:00
5 changed files with 270 additions and 11 deletions
Showing only changes of commit 6e30177294 - Show all commits

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 { model Portfolio {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
name String name String
description String? description String?
currency String @default("RUB") currency String @default("RUB")
targets String? targets String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
positions Position[] positions Position[]
@@unique([userId, name]) @@unique([userId, name])
} }
model Position { model Position {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
portfolioId Int portfolioId Int
secid String secid String
type String @default("share") type String @default("share")
quantity Int quantity Int
buyPrice Float? buyPrice Float?
buyDate DateTime? buyDate DateTime?
notes String? notes String?
tags String? tags String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
@@unique([portfolioId, secid]) @@unique([portfolioId, secid])
} }
@ -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
}

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