2 Commits
Author SHA1 Message Date
ksv741 bf502737f2 refactor: tidy tbank-sync claim comments (WealthLedger/backend#166)
Backend verify / commits (pull_request) Successful in 1m37s
Backend verify / security (pull_request) Successful in 1m14s
Backend verify / verify (pull_request) Successful in 6m17s
2026-11-04 18:44:01 +03:00
ksv741 c7bf09e2a1 refactor: share tbank-sync claim and generation lock (WealthLedger/backend#166) 2026-11-04 18:44:01 +03:00
175 changed files with 2633 additions and 4066 deletions
+6 -6
View File
@@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
чистые расчёты portfolio/banking остаются у domain owner.
PrismaService владеет DB lifecycle. Application services/use cases получают
данные через concrete repositories; запросы и CAS живут в infrastructure
по [data convention](conventions/data.md).
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные
через concrete repositories; существующие связные CRUD/transaction services
сохраняют прямые queries по [data convention](conventions/data.md).
Money — currency + decimal string; расчёты используют точные общие helpers.
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
@@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio
конкретные infrastructure providers, зарегистрированные module. Infrastructure
может использовать application types/input и domain rules; новые interfaces
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
infrastructure. Application не строит Prisma queries: use case и связный CRUD
service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface
на каждую таблицу не требуется.
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
по [Data](conventions/data.md); use case получает данные через repository и сам
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
DTO остаются транспортными runtime classes; существующее использование DTO
на application boundary допустимо по [HTTP API](conventions/http-api.md).
+10 -10
View File
@@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
не становятся domain entities: расчёт получает только нужные ему значения.
Application-код не импортирует PrismaService и не строит Prisma queries.
Репозиторий владеет запросом и CAS-проверкой результата, а application —
правилами и формированием ответа. Типы Prisma допустимы только в type-only
импортах для существующей транзакционной границы. Не создавай обёртку
на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write`
сохраняет retry/transaction boundary для банковских записей; callback с
transaction client — локальная техническая граница, не API для domain.
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
services (auth, lifecycle подключения, изменения карт/операций/категорий)
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
лишь набора pass-through методов. Новая самостоятельная read/write capability
получает repository method; самостоятельный use case не выполняет Prisma queries.
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для
существующих banking services. Такой callback с Prisma transaction client —
локальная техническая граница этих services, не API для domain или use cases.
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
application checks. Предварительный lookup не заменяет unique constraint при race.
Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет
conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP
error contract. Сначала пойми invariant; не копируй его flow в другой use case.
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case.
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
-36
View File
@@ -1,41 +1,5 @@
# Changelog
## [2.4.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.4.0...v2.4.1) (2026-11-06)
### Bug Fixes
* **events:** refresh saved payout calendars once per hourly pass (WealthLedger/backend[#53](https://git.personal.ksv741.ru/WealthLedger/backend/issues/53)) ([#157](https://git.personal.ksv741.ru/WealthLedger/backend/issues/157)) ([aeb4647](https://git.personal.ksv741.ru/WealthLedger/backend/commit/aeb4647fda3806a419ff3578834c34fff97ae3e8))
* **portfolio:** publish new complete forecast regardless of request window (WealthLedger/backend[#55](https://git.personal.ksv741.ru/WealthLedger/backend/issues/55)) ([#156](https://git.personal.ksv741.ru/WealthLedger/backend/issues/156)) ([169bee1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/169bee1da5731a1330e995a4a508da544f791387))
### Refactoring
* **api:** describe bank operation create body as oneOf by kind (WealthLedger/backend[#172](https://git.personal.ksv741.ru/WealthLedger/backend/issues/172)) ([#173](https://git.personal.ksv741.ru/WealthLedger/backend/issues/173)) ([02e0d56](https://git.personal.ksv741.ru/WealthLedger/backend/commit/02e0d560c84606d6372cbac001dc94edc348995e))
* check persisted json payload shape instead of double casts ([#152](https://git.personal.ksv741.ru/WealthLedger/backend/issues/152)) ([#154](https://git.personal.ksv741.ru/WealthLedger/backend/issues/154)) ([a80bbbf](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a80bbbf1e28954812d2d32c6e8f9f67f5a5d42a4))
* enforce repository boundary in application (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([e3ecb8c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e3ecb8c15e61ccd8de9acb4954a38932099eefc3))
* expose upcoming-events public api and guard its boundary (WealthLedger/backend[#163](https://git.personal.ksv741.ru/WealthLedger/backend/issues/163)) ([#195](https://git.personal.ksv741.ru/WealthLedger/backend/issues/195)) ([83b515a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/83b515a4536a6c534bcdec82f9f43f9005a44c35))
* extract catalog enrichment source loader (WealthLedger/backend[#169](https://git.personal.ksv741.ru/WealthLedger/backend/issues/169)) ([#186](https://git.personal.ksv741.ru/WealthLedger/backend/issues/186)) ([c5ccfea](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5ccfeadea7a9235ebdd6171d9b6f7b89ed2e1e7))
* inject TbankAccessService directly into stock screener ([#151](https://git.personal.ksv741.ru/WealthLedger/backend/issues/151)) ([#155](https://git.personal.ksv741.ru/WealthLedger/backend/issues/155)) ([554410a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/554410a2716ce13d7202fd27a621c72734b5e6c8))
* move auth persistence into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([61c5525](https://git.personal.ksv741.ru/WealthLedger/backend/commit/61c55256e756b9187e1087215d35db3fd30ac148))
* move bank card queries into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3d4801](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3d4801cfe67ecf301c58c894676b13b8833c4a6))
* move category persistence into bank repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([efac720](https://git.personal.ksv741.ru/WealthLedger/backend/commit/efac7201e06f49b264854566b2e3f73931f3f6cc))
* replace deep imports with native node aliases (WealthLedger/backend[#165](https://git.personal.ksv741.ru/WealthLedger/backend/issues/165)) ([#193](https://git.personal.ksv741.ru/WealthLedger/backend/issues/193)) ([07cfd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/07cfd08df9b13ac3fd13da153b12acf159329cea))
* retire legacy tbank-sync status and sync=observe (WealthLedger/backend[#196](https://git.personal.ksv741.ru/WealthLedger/backend/issues/196)) ([d792baa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d792baaf59a1c204458ec249ee3ce907f3564455))
* route bank card reads through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3ca11d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3ca11d1410899bb9c6707113ba9fd1f3b30e4bf))
* route bank operation queries through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([cea77f6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cea77f6d568809d989462c121f50b4e07e7efca5))
* share prisma json codec and check progress shape (WealthLedger/backend[#170](https://git.personal.ksv741.ru/WealthLedger/backend/issues/170)) ([#191](https://git.personal.ksv741.ru/WealthLedger/backend/issues/191)) ([85a7196](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85a71967fa4e85427a991c973c6528206332cf5e))
* share sync reservation join rule (WealthLedger/backend[#119](https://git.personal.ksv741.ru/WealthLedger/backend/issues/119)) ([#162](https://git.personal.ksv741.ru/WealthLedger/backend/issues/162)) ([fa0a6e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa0a6e56030bb1c0302cd7588d30e7fa91e19c11))
* share tbank-sync claim and generation lock (WealthLedger/backend[#166](https://git.personal.ksv741.ru/WealthLedger/backend/issues/166)) ([#188](https://git.personal.ksv741.ru/WealthLedger/backend/issues/188)) ([5d31b3e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5d31b3e6dd37d1c79b61c43ed820947b5b38aaa1))
* single bank operation projection and domain rules (WealthLedger/backend[#168](https://git.personal.ksv741.ru/WealthLedger/backend/issues/168)) ([#189](https://git.personal.ksv741.ru/WealthLedger/backend/issues/189)) ([deda7ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/deda7ffc2cc8185eba48a18c37272c404a3e9983))
* split brokerage operations repository and cursor codec (WealthLedger/backend[#167](https://git.personal.ksv741.ru/WealthLedger/backend/issues/167)) ([#190](https://git.personal.ksv741.ru/WealthLedger/backend/issues/190)) ([cd78b43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cd78b43783cb7469e44b9531d165603df258b55e))
### CI
* drop stale npm audit advisory ignore (WealthLedger/backend[#158](https://git.personal.ksv741.ru/WealthLedger/backend/issues/158)) ([#159](https://git.personal.ksv741.ru/WealthLedger/backend/issues/159)) ([1a5de9b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a5de9bebd060a5ff2ca188ad862c4ec5aa06ffd))
### Maintenance
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/backend/issues/6)) ([#160](https://git.personal.ksv741.ru/WealthLedger/backend/issues/160)) ([bade5fa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bade5fae1c3170b38717b26eda56ca20183f4ec8))
## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01)
### Features
+14 -1
View File
@@ -53,6 +53,11 @@
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
@@ -96,7 +101,7 @@
"count": 2
},
"sonarjs/no-nested-conditional": {
"count": 6
"count": 9
}
},
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
@@ -130,6 +135,11 @@
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 6
}
},
"test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": {
"count": 1
@@ -148,6 +158,9 @@
"test/tbank-ledger-local.e2e-spec.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
},
"sonarjs/no-nested-template-literals": {
"count": 3
}
}
}
+17 -26
View File
@@ -15,18 +15,6 @@ import globals from 'globals';
import tseslint from 'typescript-eslint';
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
const guardedModules = [
{
name: 'tbank-sync',
except: [
'./tbank-sync.api.ts',
'./tbank-sync.dto.ts',
'./tbank-sync.module.ts',
'./tbank-sync-repository.module.ts',
],
},
{ name: 'upcoming-events', except: ['./upcoming-events.api.ts', './upcoming-events.module.ts'] },
];
const prismaImports = [
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
];
@@ -170,14 +158,13 @@ export default defineConfig(
},
},
{
files: ['src/**/application/**/*.ts'],
files: ['src/**/*.use-case.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: prismaImports,
allowTypeImports: true,
message: 'Application code obtains data through concrete repositories; type-only imports are allowed.',
message: 'Use cases obtain data through concrete repositories.',
}],
}],
},
@@ -225,20 +212,24 @@ export default defineConfig(
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
},
},
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
// A module may import its own internals, so each guarded module gets a block without its zone.
...[undefined, ...guardedModules].map((own) => ({
files: own ? [`src/modules/investments/${own.name}/**/*.ts`] : ['src/**/*.ts'],
ignores: own ? tests : [...tests, ...guardedModules.map(({ name }) => `src/modules/investments/${name}/**`)],
{
files: ['src/**/*.ts'],
ignores: [...tests, 'src/modules/investments/tbank-sync/**'],
rules: {
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
'import-x/no-restricted-paths': ['error', {
zones: guardedModules.filter((module) => module !== own).map(({ except, name }) => ({
zones: [{
target: './src',
from: `./src/modules/investments/${name}`,
except,
message: `Use ${name}.api for cross-module access.`,
})),
from: './src/modules/investments/tbank-sync',
except: [
'./tbank-sync.api.ts',
'./tbank-sync.dto.ts',
'./tbank-sync.module.ts',
'./tbank-sync-repository.module.ts',
],
message: 'Use tbank-sync.api for cross-module access.',
}],
}],
},
})),
},
);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wealth-ledger-backend",
"version": "2.4.1",
"version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wealth-ledger-backend",
"version": "2.4.1",
"version": "2.4.0",
"license": "UNLICENSED",
"dependencies": {
"@grpc/grpc-js": "1.14.6",
+3 -17
View File
@@ -1,19 +1,9 @@
{
"name": "wealth-ledger-backend",
"version": "2.4.1",
"version": "2.4.0",
"description": "",
"author": "",
"private": true,
"imports": {
"#generated/*": {
"development": "./dist-dev/generated/*",
"default": "./dist/generated/*"
},
"#src/*": {
"development": "./dist-dev/src/*",
"default": "./dist/src/*"
}
},
"license": "UNLICENSED",
"engines": {
"node": "v24.22.1"
@@ -24,8 +14,8 @@
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
"format": "npm run lint:fix",
"start": "nest start",
"start:dev": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --watch --path tsconfig.dev.json",
"start:debug": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --debug --watch --path tsconfig.dev.json",
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js",
"lint": "npm run lint:check",
"lint:check": "eslint . --max-warnings=0",
@@ -141,10 +131,6 @@
}
},
"jest": {
"moduleNameMapper": {
"^#generated/(.*)$": "<rootDir>/../generated/$1",
"^#src/(.*)$": "<rootDir>/$1"
},
"moduleFileExtensions": [
"js",
"json",
@@ -1,15 +0,0 @@
CREATE TABLE "MarketIndexDailyClose" (
"secid" TEXT NOT NULL,
"tradeDate" TEXT NOT NULL,
"close" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketIndexDailyClose_pkey" PRIMARY KEY ("secid","tradeDate")
);
CREATE TABLE "MarketIndexSyncState" (
"secid" TEXT NOT NULL PRIMARY KEY,
"lastAttemptAt" DATETIME,
"lastSuccessAt" DATETIME,
"coveredFrom" TEXT,
"coveredTo" TEXT
);
@@ -1,32 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_TbankPortfolioValuePoint" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"value" TEXT NOT NULL,
"capturedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "accountId", "attemptId"),
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- RUB-only: keep the RUB row per key; non-RUB rows are dropped.
INSERT INTO "new_TbankPortfolioValuePoint" ("accountId", "attemptId", "capturedAt", "createdAt", "userId", "value")
SELECT "accountId", "attemptId", "capturedAt", "createdAt", "userId", "value"
FROM "TbankPortfolioValuePoint"
WHERE "currency" = 'RUB';
DROP TABLE "TbankPortfolioValuePoint";
ALTER TABLE "new_TbankPortfolioValuePoint" RENAME TO "TbankPortfolioValuePoint";
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
-- RUB-only: persisted JSON payloads written in the old multi-currency shape
-- are stale. A missing snapshot/forecast is the app's normal "not synced yet"
-- state: reads report availability 'missing' and the hourly walk rebuilds them
-- on the next pass. Delete the stale rows instead of failing reads with 500.
DELETE FROM "TbankPortfolio";
DELETE FROM "TbankForecast";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -1,3 +0,0 @@
-- RUB-only: snapshots were dropped by the previous migration; clearing the hourly
-- schedule makes the next scheduler tick rebuild them instead of waiting for the term.
DELETE FROM "TbankSyncWork" WHERE "scopeKey" = 'hourly';
+3 -20
View File
@@ -8,24 +8,6 @@ datasource db {
provider = "sqlite"
}
// Public MOEX index closes contain no user holdings or credentials.
model MarketIndexDailyClose {
secid String
tradeDate String
close String
createdAt DateTime @default(now())
@@id([secid, tradeDate])
}
model MarketIndexSyncState {
secid String @id
lastAttemptAt DateTime?
lastSuccessAt DateTime?
coveredFrom String?
coveredTo String?
}
model User {
id String @id
email String @unique
@@ -101,17 +83,18 @@ model TbankPortfolioValueHistory {
@@id([userId, accountId])
}
// One row per fully valued successful portfolio attempt (RUB only).
// One row per currency of a fully valued successful portfolio attempt.
model TbankPortfolioValuePoint {
userId String
accountId String
attemptId String
currency String
value String
capturedAt DateTime
createdAt DateTime @default(now())
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, accountId, attemptId])
@@id([userId, accountId, attemptId, currency])
@@index([userId, accountId, capturedAt])
}
-2
View File
@@ -9,7 +9,6 @@ import { BrokerageAccountsModule } from './modules/investments/brokerage-account
import { IncomeModule } from './modules/investments/income/income.module';
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
import { OperationsModule } from './modules/investments/operations/operations.module';
import { PerformanceModule } from './modules/investments/performance/performance.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
@@ -28,7 +27,6 @@ import { PrismaModule } from './prisma/prisma.module';
BankCardsModule,
BrokerageAccountsModule,
PortfolioModule,
PerformanceModule,
OperationsModule,
IncomeModule,
UpcomingEventsModule,
-122
View File
@@ -8,73 +8,6 @@ const emptyEvents = {
describe('MoexClient', () => {
afterEach(() => jest.restoreAllMocks());
it('loads all pages of index closes without dropping a page', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
const start = Number(new URL(input as string).searchParams.get('start'));
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({
'history': {
columns: ['TRADEDATE', 'CLOSE'],
data: start === 0 ? [['2026-10-01', 5752.22]] : [['2026-10-02', 5745.61]],
},
'history.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[start, 2, 1]],
},
}),
} as Response);
});
expect(await new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02')).toEqual([
{ tradeDate: '2026-10-01', close: '5752.22' },
{ tradeDate: '2026-10-02', close: '5745.61' },
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('rejects a later malformed page instead of returning partial index history', async () => {
jest.spyOn(global, 'fetch').mockImplementation((input) => {
const start = Number(new URL(input as string).searchParams.get('start'));
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({
'history': {
columns: ['TRADEDATE', 'CLOSE'],
data: [[start ? '2026-10-02' : '2026-10-01', start ? null : 100]],
},
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[start, 2, 1]] },
}),
} as Response);
});
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects an unsuccessful index history response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 503 } as Response);
await expect(new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects a timed-out index history request', async () => {
jest.spyOn(global, 'fetch').mockRejectedValue(new DOMException('Timed out', 'TimeoutError'));
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects duplicate or out-of-order trading dates', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true, status: 200,
json: () => Promise.resolve({
'history': { columns: ['TRADEDATE', 'CLOSE'], data: [['2026-10-02', 100], ['2026-10-01', 101]] },
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, 2, 100]] },
}),
} as Response);
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
@@ -340,59 +273,4 @@ describe('MoexClient', () => {
expect(starts).toEqual(['0', '1']);
},
);
it('filters non-RUB coupons and maturity out of bond events', async () => {
const pages: Record<string, unknown> = {
coupons: Response.json({
'coupons': {
columns: ['coupondate', 'value', 'faceunit'],
data: [
['2099-02-01', '2', 'SUR'],
['2099-02-02', '3', 'USD'],
],
},
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 2, 100]],
},
}),
offers: Response.json({
'offers': {
columns: ['offerdate'],
data: [['2099-02-28']],
},
'offers.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, 100]],
},
}),
description: Response.json({
description: {
columns: ['name', 'value'],
data: [
['MATDATE', '2099-02-28'],
['FACEVALUE', '500'],
['FACEUNIT', 'USD'],
],
},
}),
};
const fetchMock = jest
.spyOn(global, 'fetch')
.mockImplementation((input) => {
const url = new URL(input instanceof Request ? input.url : String(input));
const block = url.searchParams.get('iss.only')?.split(',')[0] ?? 'description';
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names of this test's page map.
const page = pages[block] ?? pages.description;
return Promise.resolve(page as Response);
});
try {
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
]);
} finally {
fetchMock.mockRestore();
}
});
});
+6 -67
View File
@@ -10,13 +10,6 @@ const EMPTY_PAGE_SIZE = 0;
const MIN_CURSOR_VALUE = 0;
const FALLBACK_PAGE_SIZE = 100;
const INDEX_COLUMNS = ['TRADEDATE', 'CLOSE'];
const ISO_DATE_LENGTH = 10;
const ZERO_CLOSE = 0;
const ISO_DATE_START = 0;
const LAST_HISTORY_ROW = -1;
const isoDayPattern = /^\d{4}-\d{2}-\d{2}$/;
const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000;
@@ -35,64 +28,9 @@ export type MoexBondYield = {
tradeAt: string;
value: string;
};
export type MoexIndexClose = { close: string; tradeDate: string };
@Injectable()
export class MoexClient {
async indexHistory(
secid: 'IMOEX' | 'MCFTRR',
from: string,
till: string,
): Promise<MoexIndexClose[]> {
const path = `history/engines/stock/markets/index/securities/${secid}.json`;
const rows: MoexIndexClose[] = [];
let start = FIRST_PAGE_OFFSET;
for (;;) {
const query = new URLSearchParams({
from,
till,
'start': String(start),
'iss.meta': 'off',
'history.columns': 'TRADEDATE,CLOSE',
});
const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
const block = response.history;
if (
!block || !Array.isArray(block.columns) ||
block.columns.length !== INDEX_COLUMNS.length ||
// eslint-disable-next-line security/detect-object-injection -- Offset comes from a fixed-length validated columns array.
block.columns.some((column, offset) => column !== INDEX_COLUMNS[offset])
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
const current = parseRows(block);
let previousDate = rows.at(LAST_HISTORY_ROW)?.tradeDate;
for (const row of current) {
const tradeDate = text(row.tradedate);
const close = numberText(row.close);
if (
!tradeDate || !isoDayPattern.test(tradeDate) ||
Number.isNaN(Date.parse(`${tradeDate}T00:00:00Z`)) ||
new Date(`${tradeDate}T00:00:00Z`).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== tradeDate ||
!close || Number(close) <= ZERO_CLOSE ||
(previousDate !== undefined && tradeDate <= previousDate)
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
rows.push({ tradeDate, close });
previousDate = tradeDate;
}
const cursor = parseRows(response['history.cursor']);
const index = integer(cursor[0]?.index);
const total = integer(cursor[0]?.total);
const pageSize = integer(cursor[0]?.pagesize);
if (
cursor.length !== EXPECTED_CURSOR_ROWS || index !== start ||
total === null || pageSize === null || pageSize === EMPTY_PAGE_SIZE ||
current.length !== Math.min(pageSize, total - start)
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
if (index + pageSize >= total) return rows;
start = index + pageSize;
}
}
async bondYield(
secid: string,
isin: string,
@@ -157,9 +95,9 @@ export class MoexClient {
for (const row of coupons) {
const date = text(row.coupondate);
const value = numberText(row.value);
// RUB-only: coupons in other currencies are filtered out at this ingest boundary.
if (date && value && currencyText(row.faceunit) === 'RUB') {
events.push({ type: 'coupon', date, value, currency: 'RUB' });
const currency = currencyText(row.faceunit);
if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency });
}
}
for (const row of offers) {
@@ -171,8 +109,9 @@ export class MoexClient {
);
const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE'));
if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') {
events.push({ type: 'maturity', date, value, currency: 'RUB' });
const currency = currencyText(values.get('FACEUNIT'));
if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency });
}
return events;
}
+1 -9
View File
@@ -1,6 +1,4 @@
import type { Money } from '../../common/money.types';
import { Decimal, formatDecimal } from '../../common/decimal';
import { Decimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0;
@@ -8,12 +6,6 @@ const NANO = 1_000_000_000n;
export type ProtoDecimal = { nano?: number | string; units?: number | string };
export function rubMoney(value?: ProtoDecimal & { currency?: string }): Money | null {
return value?.currency?.trim().toUpperCase() === 'RUB'
? { currency: 'RUB', value: formatDecimal(decimal(value)) }
: null;
}
export function decimal(value: ProtoDecimal): Decimal {
return {
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
@@ -5,12 +5,11 @@ import { isDeepStrictEqual } from 'node:util';
import type {
TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow,
} from '#generated/prisma/client';
Prisma,
} from '../../../generated/prisma/client';
import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { toJson } from '../../prisma/to-json';
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
import { TbankErrorCode } from './tbank-error-code';
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
@@ -207,7 +206,9 @@ export class InstrumentMetadataRepository {
const previous = await tx.tbankInstrument.findUnique({
where: { provider_uid: key },
});
const payload = toJson(instrument);
const payload = JSON.parse(
JSON.stringify(instrument),
) as Prisma.InputJsonObject;
const data = {
metadata: payload,
figi:
-6
View File
@@ -49,17 +49,11 @@ export enum TbankOperationType {
Dividend = 'OPERATION_TYPE_DIVIDEND',
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
InpMulti = 'OPERATION_TYPE_INP_MULTI',
Input = 'OPERATION_TYPE_INPUT',
InputAcquiring = 'OPERATION_TYPE_INPUT_ACQUIRING',
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
InputSwift = 'OPERATION_TYPE_INPUT_SWIFT',
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
OutMulti = 'OPERATION_TYPE_OUT_MULTI',
Output = 'OPERATION_TYPE_OUTPUT',
OutputAcquiring = 'OPERATION_TYPE_OUTPUT_ACQUIRING',
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
OutputSwift = 'OPERATION_TYPE_OUTPUT_SWIFT',
Sell = 'OPERATION_TYPE_SELL',
SellCard = 'OPERATION_TYPE_SELL_CARD',
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Prisma } from '#generated/prisma/client';
import type { Prisma } from '../../../generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service';
const NO_UPDATED_ROWS = 0;
@@ -2,14 +2,12 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto';
import { AuthRepository } from '../infrastructure/auth.repository';
jest.mock('bcrypt', () => ({
hash: jest.fn(() => Promise.resolve('password-hash')),
compare: jest.fn(() => Promise.resolve(true)),
}));
jest.mock('#src/prisma/prisma.service', () => ({
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
@@ -53,7 +51,7 @@ describe('AuthService', () => {
delete: jest.fn(),
},
};
const service = new AuthService(new AuthRepository(prisma as never), jwt);
const service = new AuthService(prisma as never, jwt);
beforeEach(() => {
jest.clearAllMocks();
+67 -23
View File
@@ -7,14 +7,15 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto';
import type { Session, User } from '#generated/prisma/client';
import { HttpErrorCode } from '#src/common/error-code';
import { AuthRepository } from '../infrastructure/auth.repository';
import { Session, User } from '../../../../generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service';
import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12;
export const REFRESH_TTL_MS = 604_800_000;
@@ -27,7 +28,7 @@ const DUMMY_PASSWORD_HASH =
@Injectable()
export class AuthService {
constructor(
private readonly repository: AuthRepository,
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
@@ -35,18 +36,27 @@ export class AuthService {
email: string,
password: string,
): Promise<AuthenticationResult> {
const existing = await this.repository.findUserByEmail(email);
const existing = await this.prisma.user.findUnique({
where: { email },
});
if (existing) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
const user = await this.repository.createUser({
id: randomUUID(),
email,
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
});
if (!user) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
let user: User;
try {
user = await this.prisma.user.create({
data: {
id: randomUUID(),
email,
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
},
});
} catch (error) {
if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
throw error;
}
return this.createSession(user);
}
@@ -55,7 +65,9 @@ export class AuthService {
email: string,
password: string,
): Promise<AuthenticationResult> {
const user = await this.repository.findUserByEmail(email);
const user = await this.prisma.user.findUnique({
where: { email },
});
const valid = await bcrypt.compare(
password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
@@ -80,7 +92,10 @@ export class AuthService {
if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
const session = await this.repository.findSessionWithUser(payload.sid);
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
include: { user: true },
});
if (
!session ||
session.userId !== payload.sub ||
@@ -88,7 +103,7 @@ export class AuthService {
session.expiresAt <= new Date()
) {
if (session && !session.revokedAt) {
await this.repository.deleteSession(session.id);
await this.prisma.session.delete({ where: { id: session.id } });
}
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
@@ -109,13 +124,18 @@ export class AuthService {
return;
}
if (payload.type !== 'refresh') return;
const session = await this.repository.findSession(payload.sid);
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.repository.revokeSession(session.id);
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
}
}
@@ -124,7 +144,7 @@ export class AuthService {
}
async userById(id: string): Promise<User> {
const user = await this.repository.findUserById(id);
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user;
}
@@ -132,7 +152,14 @@ export class AuthService {
private async createSession(
user: User,
): Promise<AuthenticationResult> {
const session = await this.repository.createSession(user.id, randomUUID());
const session = await this.prisma.session.create({
data: {
id: randomUUID(),
userId: user.id,
refreshTokenHash: '',
expiresAt: new Date(),
},
});
return this.rotateSession({ ...session, user });
}
@@ -153,8 +180,16 @@ export class AuthService {
refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
};
if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
} else {
await this.prisma.session.update({ where: { id: session.id }, data });
}
return {
user: this.toUser(session.user),
@@ -170,3 +205,12 @@ export class AuthService {
return createHash('sha256').update(token).digest('hex');
}
}
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
+1 -2
View File
@@ -5,7 +5,6 @@ import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation';
import { AuthService } from './application/auth.service';
import { AuthRepository } from './infrastructure/auth.repository';
import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@@ -23,6 +22,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
],
exports: [JwtAuthGuard],
controllers: [AuthController],
providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard],
providers: [AuthService, JwtStrategy, JwtAuthGuard],
})
export class AuthModule {}
@@ -1,81 +0,0 @@
import { Injectable } from '@nestjs/common';
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
import { PrismaService } from '#src/prisma/prisma.service';
const EXPECTED_ROTATED_SESSIONS = 1;
@Injectable()
export class AuthRepository {
constructor(private readonly prisma: PrismaService) {}
findUserByEmail(email: string) {
return this.prisma.user.findUnique({ where: { email } });
}
async createUser(data: { email: string; id: string; passwordHash: string }) {
try {
return await this.prisma.user.create({ data });
} catch (error) {
if (isUniqueConstraintError(error)) return null;
throw error;
}
}
findUserById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
findSessionWithUser(id: string) {
return this.prisma.session.findUnique({
where: { id },
include: { user: true },
});
}
findSession(id: string) {
return this.prisma.session.findUnique({ where: { id } });
}
deleteSession(id: string) {
return this.prisma.session.delete({ where: { id } });
}
revokeSession(id: string) {
return this.prisma.session.update({
where: { id },
data: { revokedAt: new Date() },
});
}
createSession(userId: string, id: string) {
return this.prisma.session.create({
data: { id, userId, refreshTokenHash: '', expiresAt: new Date() },
});
}
async rotateSession(
id: string,
data: { expiresAt: Date; refreshTokenHash: string },
expectedRefreshTokenHash?: string,
): Promise<boolean> {
if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({
where: { id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
return updated.count === EXPECTED_ROTATED_SESSIONS;
}
await this.prisma.session.update({ where: { id }, data });
return true;
}
}
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
@@ -3,9 +3,8 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '#src/common/error-code';
import { Environment } from '#src/config/env.validation';
import { HttpErrorCode } from '../../../common/error-code';
import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types';
@Injectable()
@@ -20,9 +20,8 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { HttpErrorCode } from '#src/common/error-code';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { HttpErrorCode } from '../../../common/error-code';
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { LoginDto, RegisterDto } from './auth.dto';
@@ -5,8 +5,7 @@ import {
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import type { BankCard, Prisma } from '#generated/prisma/client';
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@@ -20,13 +20,24 @@ import {
missing,
} from './bank-card-input';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankCardsService {
constructor(private readonly repository: BankCardsRepository) {}
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await this.repository.cardOperations(tx, card);
const rows = await tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
return {
id: card.id,
name: card.name,
@@ -42,15 +53,20 @@ export class BankCardsService {
}
list(userId: string, includeArchived = false) {
return this.repository.read(async (tx) =>
Promise.all((await this.repository.cards(tx, userId, includeArchived)).map(
(card) => this.cardView(tx, card),
)),
return this.prisma.$transaction(async (tx) =>
Promise.all(
(
await tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
).map((card) => this.cardView(tx, card)),
),
);
}
get(userId: string, id: string) {
return this.repository.read(async (tx) =>
return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)),
);
}
@@ -59,7 +75,7 @@ export class BankCardsService {
const input = cardInput(dto),
hash = fingerprint(input);
return this.repository.write(async (tx) => {
const existing = await this.repository.cardById(tx, dto.id);
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) {
if (existing.userId !== userId) missing();
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -72,11 +88,8 @@ export class BankCardsService {
await this.repository.seed(tx, userId);
return this.cardView(
tx,
await this.repository.createCard(tx, {
...input,
id: dto.id,
userId,
createFingerprint: hash,
await tx.bankCard.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
@@ -109,16 +122,20 @@ export class BankCardsService {
}
currentDate(input.openingDate);
if (
await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate)
await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
) {
fail(BankErrorCode.OpeningDateConflict);
}
const updated = await this.repository.updateCard(
tx,
userId,
id,
dto.version,
{
const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version },
data: {
...input,
paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 },
@@ -130,8 +147,9 @@ export class BankCardsService {
: null,
}),
},
);
return this.cardView(tx, updated);
});
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.cardView(tx, await this.repository.card(tx, userId, id));
});
}
}
@@ -1,16 +1,12 @@
import { GoneException, Injectable } from '@nestjs/common';
import type {
import {
BankCardOperation,
Prisma,
} from '#generated/prisma/client';
} from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import {
operationFieldConflict,
operationKindChanged,
} from '../domain/bank-operation.rules';
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import {
@@ -32,22 +28,117 @@ import {
periodBounds,
} from './bank-card-input';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankOperationsService {
constructor(private readonly repository: BankCardsRepository) {}
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async operationView(
tx: Tx,
row: BankCardOperation,
): Promise<BankOperationDto> {
return (await this.pageViews(tx, [row]))[0];
const card = await this.repository.card(tx, row.userId, row.cardId);
const target = row.targetCardId
? await this.repository.card(tx, row.userId, row.targetCardId)
: null;
const source = row.originalExpenseId
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
: null;
const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId
? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId },
})
: null;
const refunds =
row.kind === BankOperationKind.Expense || source
? await tx.bankCardOperation.findMany({
where: {
userId: row.userId,
originalExpenseId: source?.id ?? row.id,
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
cardId: row.cardId,
cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: category?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === BankOperationKind.Expense || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
}
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
const userId = rows[0]?.userId;
if (!userId) return [];
const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows);
const cardIds = new Set(
rows.flatMap((row) =>
[row.cardId, row.targetCardId].filter(
(id): id is string => id !== null,
),
),
);
const sourceIds = new Set(
rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])),
);
const [cards, sources] = await Promise.all([
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
tx.bankCardOperation.findMany({
where: { userId, id: { in: [...sourceIds] } },
}),
]);
const categoryIds = new Set(
rows
.map(
(row) =>
sources.find((source) => source.id === row.originalExpenseId)
?.categoryId ?? row.categoryId,
)
.filter((id): id is string => id !== null),
);
const expenseIds = new Set(
rows.flatMap((row) => {
const source = sources.find((item) => item.id === row.originalExpenseId);
return row.kind === BankOperationKind.Expense || source
? [source?.id ?? row.id]
: [];
}),
);
const [categories, refunds] = await Promise.all([
tx.expenseCategory.findMany({
where: { userId, id: { in: [...categoryIds] } },
}),
tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: { in: [...expenseIds] },
deletedAt: null,
},
}),
]);
const cardsById = new Map(cards.map((card) => [card.id, card]));
const sourcesById = new Map(sources.map((source) => [source.id, source]));
const categoriesById = new Map(categories.map((category) => [category.id, category]));
@@ -116,28 +207,49 @@ export class BankOperationsService {
fail(BankErrorCode.InvalidDate);
}
}
if (old && operationKindChanged(old.kind, input.kind,
old.originalExpenseId, input.originalExpenseId)) {
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
) {
fail(BankErrorCode.ImmutableOperation);
}
if (operationFieldConflict(input)) fail();
if (input.kind === BankOperationKind.Transfer) {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
) {
fail();
}
} else if (input.targetCardId) fail();
if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail();
if (!input.categoryId) {
const category = await this.repository.defaultCategory(tx, userId);
const category = await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing();
input.categoryId = category.id;
}
if (
!(await this.repository.category(tx, userId, input.categoryId))
!(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId },
}))
) {
missing();
}
} else if (input.categoryId) fail();
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
fail();
}
if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail();
const source = await this.repository.operation(
tx,
userId,
input.originalExpenseId as string,
input.originalExpenseId,
);
if (
source.deletedAt ||
@@ -146,11 +258,29 @@ export class BankOperationsService {
) {
fail(BankErrorCode.InvalidRefund);
}
await this.repository.updateOperation(tx,
{ id: source.id, userId, version: source.version, deletedAt: null },
{ version: { increment: 1 } },
);
const refunds = await this.repository.refunds(tx, userId, source.id, old?.id);
if (
(
await tx.bankCardOperation.updateMany({
where: {
id: source.id,
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: source.id,
deletedAt: null,
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
@@ -161,7 +291,9 @@ export class BankOperationsService {
}
}
if (old?.kind === BankOperationKind.Expense) {
const refunds = await this.repository.refunds(tx, userId, old.id);
const refunds = await tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if (
refunds.length &&
(input.cardId !== old.cardId ||
@@ -178,7 +310,9 @@ export class BankOperationsService {
const normalized = operationInput(dto),
hash = fingerprint(normalized);
return this.repository.write(async (tx) => {
const existing = await this.repository.operationById(tx, dto.id);
const existing = await tx.bankCardOperation.findUnique({
where: { id: dto.id },
});
if (existing) {
if (existing.userId !== userId) missing();
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -194,15 +328,15 @@ export class BankOperationsService {
await this.validateOperation(tx, userId, input);
return this.operationView(
tx,
await this.repository.createOperation(tx, {
...input, id: dto.id, userId, createFingerprint: hash,
await tx.bankCardOperation.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
getOperation(userId: string, id: string) {
return this.repository.read(async (tx) => {
return this.prisma.$transaction(async (tx) => {
const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) missing();
return this.operationView(tx, row);
@@ -227,10 +361,16 @@ export class BankOperationsService {
description: dto.description ?? old.description ?? undefined,
});
await this.validateOperation(tx, userId, input, old);
await this.repository.updateOperation(tx,
{ id, userId, version: dto.version, deletedAt: null },
{ ...input, version: { increment: 1 } },
);
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
return this.operationView(
tx,
await this.repository.operation(tx, userId, id),
@@ -249,7 +389,9 @@ export class BankOperationsService {
}
if (
row.kind === BankOperationKind.Expense &&
(await this.repository.dependentRefund(tx, userId, id))
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
) {
conflict(BankErrorCode.DependentRefunds);
}
@@ -259,15 +401,27 @@ export class BankOperationsService {
userId,
row.originalExpenseId,
);
await this.repository.updateOperation(tx,
{ id: source.id, userId, version: source.version },
{ version: { increment: 1 } },
);
if (
(
await tx.bankCardOperation.updateMany({
where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
}
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
await this.repository.updateOperation(tx,
{ id, userId, version, deletedAt: null },
{ deletedAt: new Date(), version: { increment: 1 } },
);
});
}
@@ -280,13 +434,25 @@ export class BankOperationsService {
const period = expenses
? undefined
: periodBounds(query as BankOperationsQueryDto);
return this.repository.read(async (tx) => {
return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
page = query.page ?? FIRST_PAGE;
const { total, rows } = await this.repository.operationPage(
tx, userId, id, period, limit, page,
);
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return {
items: await this.pageViews(tx, rows),
total,
@@ -1,24 +1,81 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { name } from './bank-card-input';
import { conflict, missing, name } from './bank-card-input';
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
@Injectable()
export class ExpenseCategoriesService {
constructor(private readonly repository: BankCardsRepository) {}
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
async categories(userId: string) {
return this.repository.categories(userId);
return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
}
createCategory(userId: string, value: string) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.createCategory(userId, label);
return this.repository.write(async (tx) => {
await this.repository.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.updateCategory(userId, id, label, version);
return this.repository.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + VERSION_INCREMENT,
};
});
}
}
@@ -1,9 +1,8 @@
jest.mock('#src/prisma/prisma.service', () => ({
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
jest.mock('#generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '#src/prisma/prisma.service';
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '../../../../prisma/prisma.service';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
@@ -1,5 +1,4 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import { BankOperationKind } from './bank-operation-kind';
import { ZERO_KOPECKS } from './rub-money';
@@ -1,141 +0,0 @@
import { BankOperationKind } from './bank-operation-kind';
import {
operationFieldConflict,
OperationFieldRule,
operationKindChanged,
} from './bank-operation.rules';
const cardId = 'card-1';
function fields(
overrides: Partial<Parameters<typeof operationFieldConflict>[0]> = {},
) {
return { kind: BankOperationKind.Topup, cardId, ...overrides };
}
describe('operationFieldConflict', () => {
it('allows a valid topup', () => {
expect(operationFieldConflict(fields())).toBeNull();
});
it('rejects a topup with a purchase link', () => {
expect(
operationFieldConflict(
fields({ originalExpenseId: 'op-1' }),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('rejects a non-transfer with a target card', () => {
expect(
operationFieldConflict(fields({ targetCardId: 'card-2' })),
).toBe(OperationFieldRule.InvalidCombination);
});
describe('expense', () => {
it('allows the default category to be resolved by the service', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Expense }),
),
).toBeNull();
});
it('rejects a purchase link', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Expense,
categoryId: 'cat-1',
originalExpenseId: 'op-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('allows a plain categorized expense', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Expense, categoryId: 'cat-1' }),
),
).toBeNull();
});
});
describe('transfer', () => {
it('requires a target card', () => {
expect(
operationFieldConflict(fields({ kind: BankOperationKind.Transfer })),
).toBe(OperationFieldRule.MissingTargetCard);
});
it('rejects a transfer to the same card', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: cardId,
}),
),
).toBe(OperationFieldRule.SameTargetCard);
});
it('rejects a transfer with a category or purchase link', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: 'card-2',
categoryId: 'cat-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: 'card-2',
originalExpenseId: 'op-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
});
describe('refund', () => {
it('requires a purchase link', () => {
expect(
operationFieldConflict(fields({ kind: BankOperationKind.Refund })),
).toBe(OperationFieldRule.MissingOriginalExpense);
});
it('rejects a category', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Refund,
originalExpenseId: 'op-1',
categoryId: 'cat-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('allows a linked refund', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Refund, originalExpenseId: 'op-1' }),
),
).toBeNull();
});
});
});
describe('operationKindChanged', () => {
it('flags kind and purchase link changes', () => {
expect(operationKindChanged('expense', 'expense', 'op-1', 'op-1')).toBe(false);
expect(operationKindChanged('expense', 'refund', null, null)).toBe(true);
expect(operationKindChanged('refund', 'refund', 'op-1', null)).toBe(true);
expect(operationKindChanged('refund', 'refund', null, 'op-1')).toBe(true);
});
});
@@ -1,79 +0,0 @@
import { BankOperationKind } from './bank-operation-kind';
export type OperationFields = {
cardId: string;
categoryId?: null | string;
kind: BankOperationKind;
originalExpenseId?: null | string;
targetCardId?: null | string;
};
export enum OperationFieldRule {
InvalidCombination = 'invalidCombination',
MissingOriginalExpense = 'missingOriginalExpense',
MissingTargetCard = 'missingTargetCard',
SameTargetCard = 'sameTargetCard',
}
function transferConflict(fields: OperationFields): null | OperationFieldRule {
if (!fields.targetCardId) {
return OperationFieldRule.MissingTargetCard;
}
if (fields.targetCardId === fields.cardId) {
return OperationFieldRule.SameTargetCard;
}
if (fields.categoryId || fields.originalExpenseId) {
return OperationFieldRule.InvalidCombination;
}
return null;
}
function expenseConflict(fields: OperationFields): null | OperationFieldRule {
if (fields.originalExpenseId) {
return OperationFieldRule.InvalidCombination;
}
// Expense without a category uses the user's default category in the service.
return null;
}
function refundConflict(fields: OperationFields): null | OperationFieldRule {
return fields.originalExpenseId
? null
: OperationFieldRule.MissingOriginalExpense;
}
/**
* Pure kind/field compatibility rules for bank operations; no Prisma or Nest.
* Returns the first violated rule, or null when the combination is allowed.
*/
export function operationFieldConflict(
fields: OperationFields,
): null | OperationFieldRule {
if (fields.kind === BankOperationKind.Transfer) {
return transferConflict(fields);
}
if (fields.targetCardId) return OperationFieldRule.InvalidCombination;
if (fields.kind === BankOperationKind.Expense) {
return expenseConflict(fields);
}
if (fields.categoryId) return OperationFieldRule.InvalidCombination;
if (fields.kind === BankOperationKind.Refund) {
return refundConflict(fields);
}
// Topup: no target, no category, no purchase link.
return fields.originalExpenseId
? OperationFieldRule.InvalidCombination
: null;
}
export function operationKindChanged(
oldKind: string,
newKind: string,
oldOriginalExpenseId: null | string,
newOriginalExpenseId: null | string,
): boolean {
return (
oldKind !== newKind ||
oldOriginalExpenseId !== newOriginalExpenseId
);
}
@@ -1,6 +1,6 @@
import type { Money } from '#src/common/money.types';
import type { Money } from '../../../../common/money.types';
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '#src/common/decimal';
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
export const ZERO_KOPECKS = 0n;
@@ -1,24 +1,18 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { BankCard, BankCardOperation, Prisma } from '#generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
import { PrismaService } from '#src/prisma/prisma.service';
import { Prisma } from '../../../../../generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankErrorCode,
conflict,
missing,
} from '../application/bank-card-input';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1;
const EXPECTED_UPDATED_CARDS = 1;
const EXPECTED_UPDATED_OPERATIONS = 1;
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000;
@@ -38,220 +32,6 @@ const seeds = [
@Injectable()
export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {}
read<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
return this.prisma.$transaction(run);
}
cards(tx: Tx, userId: string, includeArchived = false) {
return tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
cardOperations(tx: Tx, card: BankCard) {
return tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
}
operationById(tx: Tx, id: string) {
return tx.bankCardOperation.findUnique({ where: { id } });
}
createOperation(tx: Tx, data: Prisma.BankCardOperationUncheckedCreateInput) {
return tx.bankCardOperation.create({ data });
}
async updateOperation(
tx: Tx,
where: Prisma.BankCardOperationWhereInput,
data: Prisma.BankCardOperationUpdateManyMutationInput,
) {
const result = await tx.bankCardOperation.updateMany({ where, data });
if (result.count !== EXPECTED_UPDATED_OPERATIONS) conflict();
}
category(tx: Tx, userId: string, id: string) {
return tx.expenseCategory.findFirst({ where: { id, userId } });
}
defaultCategory(tx: Tx, userId: string) {
return tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
}
refunds(tx: Tx, userId: string, originalExpenseId: string, excludeId?: string) {
return tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId,
deletedAt: null,
...(excludeId ? { id: { not: excludeId } } : {}),
},
});
}
dependentRefund(tx: Tx, userId: string, originalExpenseId: string) {
return tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId, deletedAt: null },
});
}
async operationViewData(tx: Tx, userId: string, rows: BankCardOperation[]) {
const cardIds = new Set(rows.flatMap((row) =>
[row.cardId, row.targetCardId].filter((id): id is string => id !== null),
));
const sourceIds = new Set(rows.flatMap((row) =>
row.originalExpenseId ? [row.originalExpenseId] : [],
));
const [cards, sources] = await Promise.all([
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
tx.bankCardOperation.findMany({ where: { userId, id: { in: [...sourceIds] } } }),
]);
const categoryIds = new Set(rows.map((row) =>
sources.find((source) => source.id === row.originalExpenseId)?.categoryId ?? row.categoryId,
).filter((id): id is string => id !== null));
const expenseIds = new Set(rows.flatMap((row) => {
const source = sources.find((item) => item.id === row.originalExpenseId);
return row.kind === BankOperationKind.Expense || source ? [source?.id ?? row.id] : [];
}));
const [categories, refunds] = await Promise.all([
tx.expenseCategory.findMany({ where: { userId, id: { in: [...categoryIds] } } }),
tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: { in: [...expenseIds] }, deletedAt: null },
}),
]);
return { cards, sources, categories, refunds };
}
async operationPage(
tx: Tx,
userId: string,
id: string,
period: undefined | { from: string; to: string },
limit: number,
page: number,
) {
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return { total, rows };
}
cardById(tx: Tx, id: string) {
return tx.bankCard.findUnique({ where: { id } });
}
createCard(tx: Tx, data: Prisma.BankCardUncheckedCreateInput) {
return tx.bankCard.create({ data });
}
async updateCard(
tx: Tx,
userId: string,
id: string,
version: number,
data: Prisma.BankCardUpdateManyMutationInput,
) {
const changed = await tx.bankCard.updateMany({
where: { id, userId, version },
data,
});
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.card(tx, userId, id);
}
hasOperationsBefore(tx: Tx, userId: string, id: string, openingDate: string) {
return tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
});
}
async categories(userId: string) {
return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
}
createCategory(userId: string, label: string) {
return this.write(async (tx) => {
await this.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, label: string, version: number) {
return this.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + VERSION_INCREMENT,
};
});
}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
try {
@@ -24,8 +24,7 @@ import {
ValidateNested,
} from 'class-validator';
import { MoneyDto } from '#src/common/money.dto';
import { MoneyDto } from '../../../../common/money.dto';
import {
FIRST_PAGE,
MAX_CARD_NAME_LENGTH,
@@ -29,9 +29,8 @@ import {
import {
ApiProblemResponses,
ProblemDetailsDto,
} from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
} from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service';
@@ -1,12 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '#src/common/money.dto';
import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { PortfolioStructureDto } from '../portfolio/portfolio.api';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class UnrealizedReturnDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ example: '12.345678901' })
declare absolute: string;
@@ -21,17 +23,17 @@ export class UnrealizedReturnDto {
}
export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto })
declare totals: MoneyDto;
@ApiProperty({ type: MoneyDto, isArray: true })
declare totalsByCurrency: MoneyDto[];
@ApiProperty({ type: MoneyDto })
declare cash: MoneyDto;
@ApiProperty({ type: MoneyDto, isArray: true })
declare cashBalances: MoneyDto[];
@ApiProperty({ type: PortfolioStructureDto })
declare structure: PortfolioStructureDto;
@ApiProperty({ type: UnrealizedReturnDto })
declare unrealizedReturn: UnrealizedReturnDto;
@ApiProperty({ type: UnrealizedReturnDto, isArray: true })
declare unrealizedReturnByCurrency: UnrealizedReturnDto[];
@ApiProperty({
type: 'integer',
@@ -3,8 +3,7 @@ import type { Request } from 'express';
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
@@ -1,4 +1,4 @@
jest.mock('#src/prisma/prisma.service', () => ({
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
import { investmentResourceMeta } from '../investment-resource.mapper';
@@ -33,7 +33,7 @@ describe('saved brokerage account projection', () => {
});
it('keeps exact saved totals and independent missing summaries', async () => {
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
const structure = { currencies: [], unvaluedCategories: [] };
portfolio.saved.mockResolvedValue({
membership: meta,
accounts: [
@@ -44,9 +44,11 @@ describe('saved brokerage account projection', () => {
openedOn: null,
meta,
snapshot: {
cash: { currency: 'RUB', value: '10.000000001' },
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
structure,
totals: { currency: 'RUB', value: '100000000.000000001' },
totalsByCurrency: [
{ currency: 'RUB', value: '100000000.000000001' },
],
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
},
},
@@ -62,14 +64,14 @@ describe('saved brokerage account projection', () => {
});
const response = await service.list('owner');
expect(response.data?.[0].portfolioSummary).toEqual({
cash: { currency: 'RUB', value: '10.000000001' },
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
structure,
totals: { currency: 'RUB', value: '100000000.000000001' },
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
stockPositionCount: 1,
bondPositionCount: 1,
fundPositionCount: 0,
totalPositionCount: 2,
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
unrealizedReturnByCurrency: [],
});
expect(response.data?.[1].portfolioSummary).toBeNull();
expect(response.meta.availability).toBe('available');
@@ -77,65 +79,74 @@ describe('saved brokerage account projection', () => {
});
it('returns an empty summary from an empty saved portfolio', async () => {
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] };
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
snapshot: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] },
snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] },
}],
});
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' },
cashBalances: [], structure, totalsByCurrency: [],
stockPositionCount: 0, bondPositionCount: 0,
fundPositionCount: 0, totalPositionCount: 0,
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
unrealizedReturnByCurrency: [],
});
});
it('calculates exact returns, partial data, and zero cost basis', async () => {
it('calculates exact returns by currency, partial data, and zero cost basis', async () => {
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
snapshot: {
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' },
cashBalances: [], structure: {}, totalsByCurrency: [
{ currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' },
],
positions: [
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
{ type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } },
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'RUB', value: '2.5' }, absoluteReturn: null },
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null },
{ type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } },
{ type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } },
{ type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
],
},
}],
});
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
stockPositionCount: 1, bondPositionCount: 2,
fundPositionCount: 1, totalPositionCount: 4,
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true },
stockPositionCount: 2, bondPositionCount: 2,
fundPositionCount: 1, totalPositionCount: 6,
unrealizedReturnByCurrency: [
{ currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true },
{ currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false },
{ currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true },
],
});
});
it('omits return percent for short positions', async () => {
it('omits return percent for a currency with short positions', async () => {
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
snapshot: {
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' },
cashBalances: [], structure: {}, totalsByCurrency: [],
positions: [
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } },
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } },
],
},
}],
});
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual(
{ absolute: '-19', costBasis: '-190', percent: null, partial: false },
);
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([
{ currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false },
{ currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false },
]);
});
it('observes the latest hourly walk without starting another one', async () => {
@@ -2,15 +2,14 @@ import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import {
add,
decimalFromString,
divide,
formatDecimal,
isZero,
multiply,
} from '#src/common/decimal';
} from '../../../common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals';
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
import {
@@ -22,42 +21,61 @@ import {
const PERCENT_DECIMAL_PLACES = 2;
const ZERO_QUANTITY = 0n;
function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto {
const absoluteReturn = positions.reduce(
(sum, position) => position.absoluteReturn
? add(sum, decimalFromString(position.absoluteReturn.value))
: sum,
decimalFromString('0'),
function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
// Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
const investments = positions.filter((position) => position.type !== 'CURRENCY');
const absolute = sumMoneyByCurrency(
investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []),
);
const costBasis = positions
.filter((position) => position.absoluteReturn && position.averagePurchasePrice)
.reduce(
(sum, position) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above.
add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))),
decimalFromString('0'),
);
const partial = positions.some(
(position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue),
const costs = sumMoneyByCurrency(
investments.flatMap((position) =>
position.absoluteReturn && position.averagePurchasePrice
? [{
currency: position.absoluteReturn.currency,
value: formatDecimal(multiply(
decimalFromString(position.averagePurchasePrice.value),
decimalFromString(position.quantity),
)),
}]
: [],
),
);
const partial = new Set(
investments.flatMap((position) => {
const money = position.absoluteReturn
? null
: position.averagePurchasePrice ?? position.currentValue;
return money ? [money.currency] : [];
}),
);
// A short position has negative cost basis, so a percent would flip sign or net out.
const withShorts = positions.some(
(position) =>
(position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) &&
decimalFromString(position.quantity).value < ZERO_QUANTITY,
const withShorts = new Set(
investments.flatMap((position) => {
const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue;
return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : [];
}),
);
return {
absolute: formatDecimal(absoluteReturn),
costBasis: formatDecimal(costBasis),
percent: isZero(costBasis) || withShorts
? null
: formatDecimal(divide(
multiply(absoluteReturn, decimalFromString('100')),
costBasis,
PERCENT_DECIMAL_PLACES,
)),
partial,
};
const currencies = new Set([...absolute, ...costs].map((money) => money.currency));
return [...new Set([...currencies, ...partial])]
.sort((left, right) => left.localeCompare(right))
.map((currency) => {
const absoluteReturn = absolute.find((money) => money.currency === currency)?.value ?? '0';
const costBasis = costs.find((money) => money.currency === currency)?.value ?? '0';
const cost = decimalFromString(costBasis);
return {
currency,
absolute: absoluteReturn,
costBasis,
percent: isZero(cost) || withShorts.has(currency)
? null
: formatDecimal(divide(
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
cost,
PERCENT_DECIMAL_PLACES,
)),
partial: partial.has(currency),
};
});
}
@Injectable()
@@ -79,10 +97,10 @@ export class BrokerageAccountsService {
openedOn: account.openedOn,
portfolioSummary: account.snapshot
? {
totals: account.snapshot.totals,
cash: account.snapshot.cash,
totalsByCurrency: account.snapshot.totalsByCurrency,
cashBalances: account.snapshot.cashBalances,
structure: account.snapshot.structure,
unrealizedReturn: unrealizedReturn(account.snapshot.positions),
unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions),
stockPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'STOCK',
).length,
@@ -3,9 +3,9 @@ import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import {
IncomeAnalyticsQueryDto,
IncomeAnalyticsResourceDto,
@@ -25,10 +25,12 @@ export class IncomeAnalyticsController {
@ApiOkResponse({ type: IncomeSummaryResourceDto })
allSummary(
@Req() request: Request & { user: AccessPayload },
@Query() query: InvestmentReadQueryDto,
) {
return this.income.summary(
request.user.sub,
'all',
query.sync === 'observe',
);
}
@@ -43,6 +45,7 @@ export class IncomeAnalyticsController {
'all',
query.from,
query.to,
query.sync === 'observe',
);
}
@@ -51,10 +54,12 @@ export class IncomeAnalyticsController {
summary(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.summary(
request.user.sub,
accountId,
query.sync === 'observe',
);
}
@@ -70,6 +75,7 @@ export class IncomeAnalyticsController {
accountId,
query.from,
query.to,
query.sync === 'observe',
);
}
}
@@ -1,17 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '#src/common/money.dto';
import { MoneyDto } from '../../../common/money.dto';
import {
InvestmentDependencyDto,
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../investment-resource.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
export class IncomeAnalyticsQueryDto {
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
@ApiPropertyOptional({
format: 'date',
description:
@@ -39,7 +39,10 @@ export class IncomePeriodDto {
declare to: string;
}
export class IncomeTotalDto {
export class IncomeCurrencySummaryDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: MoneyDto })
declare coupon: MoneyDto;
@@ -57,8 +60,8 @@ export class IncomeSummaryDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeTotalDto })
declare total: IncomeTotalDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare totalsByCurrency: IncomeCurrencySummaryDto[];
@ApiProperty({ format: 'date-time' })
declare calculatedAt: string;
@@ -68,8 +71,8 @@ export class IncomeBucketDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeTotalDto })
declare total: IncomeTotalDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare totalsByCurrency: IncomeCurrencySummaryDto[];
}
export class IncomeInstrumentDto {
@@ -1,4 +1,4 @@
jest.mock('#src/prisma/prisma.service', () => ({
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
jest.mock('../operations/application/brokerage-operations.service', () => ({
@@ -57,22 +57,22 @@ describe('IncomeAnalyticsService', () => {
ticker: 'T',
instrumentType: 'bond',
},
key: 'uid:UID',
key: 'uid:UID:RUB',
},
{
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
key: 'figi:FIGI',
key: 'figi:FIGI:RUB',
},
{
identity: { ticker: 'T', instrumentType: 'bond' },
key: 'ticker:T:bond',
key: 'ticker:T:bond:RUB',
},
{ identity: { ticker: 'T' }, key: 'ticker:T:' },
{ identity: { ticker: 'T' }, key: 'ticker:T::RUB' },
{
identity: { ticker: null, instrumentType: 'bond' },
key: 'ticker::bond',
key: 'ticker::bond:RUB',
},
{ identity: { ticker: null }, key: 'operation:receipt' },
{ identity: { ticker: null }, key: 'operation:receipt:RUB' },
])(
'uses one membership for entry and aggregate: $key',
async ({ identity, key }) => {
@@ -102,7 +102,7 @@ describe('IncomeAnalyticsService', () => {
},
);
it('preserves entry order, repeated IDs and signed exact totals', async () => {
it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => {
const selected = [
{
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
@@ -112,6 +112,10 @@ describe('IncomeAnalyticsService', () => {
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
instrumentUid: 'UID',
},
{
...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'),
instrumentUid: 'UID',
},
];
seed(selected);
@@ -127,7 +131,7 @@ describe('IncomeAnalyticsService', () => {
expect(result.entries).toEqual(
selected.map((item) => ({
...item,
instrumentKey: 'uid:UID',
instrumentKey: `uid:UID:${item.payment?.currency}`,
})),
);
expect(
@@ -142,7 +146,14 @@ describe('IncomeAnalyticsService', () => {
),
).toEqual([
{
key: 'uid:UID',
key: 'uid:UID:USD',
receiptCount: 1,
coupon: { currency: 'USD', value: '20' },
dividend: { currency: 'USD', value: '0' },
total: { currency: 'USD', value: '20' },
},
{
key: 'uid:UID:RUB',
receiptCount: 2,
coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-0.25' },
@@ -151,7 +162,7 @@ describe('IncomeAnalyticsService', () => {
]);
expect(
result.entries
.filter((item) => item.instrumentKey === 'uid:UID')
.filter((item) => item.instrumentKey === 'uid:UID:RUB')
.map((item) => item.payment?.value),
).toEqual(['10.25', '-0.25']);
});
@@ -180,32 +191,26 @@ describe('IncomeAnalyticsService', () => {
expect(response.data?.buckets).toEqual([
{
period: { from: '2026-01-14', to: '2026-01-14' },
total: {
coupon: { currency: 'RUB', value: '0.1' },
dividend: { currency: 'RUB', value: '0' },
totalsByCurrency: [expect.objectContaining({
total: { currency: 'RUB', value: '0.1' },
},
})],
},
{
period: { from: '2026-01-15', to: '2026-01-15' },
total: {
totalsByCurrency: [expect.objectContaining({
coupon: { currency: 'RUB', value: '0.2' },
dividend: { currency: 'RUB', value: '-0.1' },
total: { currency: 'RUB', value: '0.1' },
},
})],
},
{
period: { from: '2026-01-16', to: '2026-01-16' },
total: {
coupon: { currency: 'RUB', value: '0' },
dividend: { currency: 'RUB', value: '0' },
total: { currency: 'RUB', value: '0' },
},
totalsByCurrency: [],
},
]);
});
it('aggregates only exact coupon and dividend payments', async () => {
it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation(
(
_user: string,
@@ -218,6 +223,7 @@ describe('IncomeAnalyticsService', () => {
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
],
].map((items, index) => ({ items, meta: completeMeta(index) }));
},
@@ -232,14 +238,22 @@ describe('IncomeAnalyticsService', () => {
const result = response.data;
assert(result);
expect(result.total).toEqual({
coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-2.5' },
total: { currency: 'RUB', value: '7.75' },
});
expect(result.totalsByCurrency).toEqual([
expect.objectContaining({
currency: 'RUB',
coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-2.5' },
total: { currency: 'RUB', value: '7.75' },
}),
expect.objectContaining({
currency: 'USD',
total: { currency: 'USD', value: '1' },
}),
]);
expect(result.entries.map((entry) => entry.id)).toEqual([
'coupon',
'dividend',
'usd',
]);
expect(result.buckets).toHaveLength(31);
});
@@ -6,9 +6,8 @@ import {
compare,
decimalFromString,
formatDecimal,
} from '#src/common/decimal';
import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
} from '../../../common/decimal';
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { investmentResourceMeta } from '../investment-resource.mapper';
import {
@@ -18,11 +17,11 @@ import {
import {
IncomeAnalyticsEntryDto,
IncomeAnalyticsResourceDto,
IncomeCurrencySummaryDto,
IncomeDependencyDto,
IncomeInstrumentDto,
IncomeSummaryDto,
IncomeSummaryResourceDto,
IncomeTotalDto,
} from './income-analytics.dto';
import {
IncomeBucketGranularity,
@@ -44,12 +43,14 @@ export class IncomeAnalyticsService {
async summary(
userId: string,
accountId: string,
observe = false,
): Promise<IncomeSummaryResourceDto> {
const plan = planIncomePeriod(undefined, undefined);
const [selected] = await this.operations.collectRanges(
userId,
accountId,
() => [plan.selected],
observe,
);
if (selected.items === null) return { data: null, meta: selected.meta };
assert(selected.meta.lastSuccessfulAt);
@@ -69,12 +70,14 @@ export class IncomeAnalyticsService {
accountId: string,
from: string | undefined,
to: string | undefined,
observe = false,
): Promise<IncomeAnalyticsResourceDto> {
const plan = planIncomePeriod(from, to);
const [current] = await this.operations.collectRanges(
userId,
accountId,
() => [plan.selected],
observe,
);
const dependencies: IncomeDependencyDto[] = [
{ ...current.meta, role: 'selected' },
@@ -105,7 +108,7 @@ export class IncomeAnalyticsService {
assert(current.meta.lastSuccessfulAt);
const selected = qualifying(current.items).map((entry) => ({
...entry,
instrumentKey: instrumentKey(entry),
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
}));
return {
data: {
@@ -139,19 +142,23 @@ function summaryResponse(
entries: BrokerageOperationDto[],
calculatedAt: string,
): IncomeSummaryDto {
return { accountId, period, total: totals(entries), calculatedAt };
return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
}
function totals(entries: BrokerageOperationDto[]): IncomeTotalDto {
const total = zeroTotal();
function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
const values = new Map<string, Total>();
for (const entry of entries) {
if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value);
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
total.coupon = add(total.coupon, value);
} else total.dividend = add(total.dividend, value);
values.set(entry.payment.currency, total);
}
return presentTotal(total);
return [...values]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, total]) => presentTotal(currency, total));
}
function buckets(
@@ -165,7 +172,7 @@ function buckets(
}));
return rangeBuckets(range, granularity).map((period) => ({
period,
total: totals(
totalsByCurrency: totals(
datedEntries
.filter(({ date }) => date >= period.from && date <= period.to)
.map(({ entry }) => entry),
@@ -185,7 +192,7 @@ function instruments(
return [...groups]
.map(([key, group]) => {
const first = group[0];
const total = totals(group);
const [total] = totals(group);
return {
key,
figi: first.figi,
@@ -221,10 +228,15 @@ function zeroTotal(): Total {
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
}
function presentTotal(value: Total): IncomeTotalDto {
function presentTotal(
currency: string,
value: Total,
): IncomeCurrencySummaryDto {
const total = add(value.coupon, value.dividend);
return {
coupon: { currency: 'RUB', value: formatDecimal(value.coupon) },
dividend: { currency: 'RUB', value: formatDecimal(value.dividend) },
total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) },
currency,
coupon: { currency, value: formatDecimal(value.coupon) },
dividend: { currency, value: formatDecimal(value.dividend) },
total: { currency, value: formatDecimal(total) },
};
}
@@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '#src/common/time';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
import {
DateOnlyRange,
dateParts,
@@ -4,7 +4,7 @@ jest.mock('../infrastructure/candle.repository', () => ({
jest.mock('./catalog-enrichment.service', () => ({
CatalogEnrichmentService: class {},
}));
jest.mock('#src/integrations/tbank/tbank.api', () => ({
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
TbankAccessService: class {},
TbankClient: class {},
}));
@@ -5,16 +5,15 @@ import {
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '#src/common/time';
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
TbankClient,
type TbankShare,
type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
candleGaps,
@@ -99,7 +98,7 @@ export class CandleService {
private readonly logger: StructuredLogger,
) {}
async read(userId: string, input: CandleInput) {
async read(userId: string, input: CandleInput, observe = false) {
const { revision } = await this.access.local(userId);
const [detail] = await this.sources.readMany(
userId,
@@ -119,7 +118,7 @@ export class CandleService {
const hasCompleteRange = Boolean(
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
);
if (!saved || !hasCompleteRange) {
if (!observe && (!saved || !hasCompleteRange)) {
await this.request(userId, revision, input, now);
}
const work = await this.repository.work(userId, revision, scope(input));
@@ -1,20 +0,0 @@
import { CatalogDiscoveryService } from './catalog-discovery.service';
jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {} }));
describe('catalog discovery currency filtering', () => {
it('uses details to filter InstrumentShort results before resolving ticker ambiguity', async () => {
const short = (uid: string) => ({ uid, ticker: 'SBER', instrumentKind: 'INSTRUMENT_TYPE_SHARE' });
const client = {
findShares: jest.fn().mockResolvedValue([short('a'), short('b')]),
getShare: jest.fn().mockImplementation((_token: string, uid: string) =>
Promise.resolve({ uid, ticker: 'SBER', currency: uid === 'a' ? 'USD' : 'RUB' })),
getLastPrices: jest.fn().mockResolvedValue([]),
};
const access = { online: jest.fn().mockImplementation((_user: string, run: (connection: { token: string }) => unknown) => run({ token: 'fixture' })) };
const service = new CatalogDiscoveryService(access as never, client as never);
const search = await service.search('user', { instrumentType: 'share', query: 'SBER' });
expect(search.data.map((item) => item.uid)).toEqual(['b']);
expect((await service.byTicker('user', 'share', 'SBER')).data?.uid).toBe('b');
});
});
@@ -8,15 +8,16 @@ import {
TbankAccessService,
TbankClient,
tbankSourceTtlMs,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
import {
CatalogDiscoveryRepository,
catalogQuery,
type CatalogQuery,
catalogScope,
} from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import {
catalogSearchLimit,
selectCatalogInstruments,
@@ -24,6 +25,7 @@ import {
import { publicInstrument } from './public-instrument';
const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0;
const tickerAmbiguityLimit = 2;
@@ -31,65 +33,203 @@ const tickerAmbiguityLimit = 2;
@Injectable()
export class CatalogDiscoveryService {
constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly enrichment: CatalogEnrichmentService,
) {}
async search(userId: string, input: CatalogQuery) {
return this.onlineSearch(userId, catalogQuery(input));
async read(userId: string, input: CatalogQuery) {
input = catalogQuery(input);
const connection = await this.access.local(userId);
const saved = await this.repository.read(input);
return {
data: saved.data,
meta: investmentResourceMeta({
resourceKey: catalogScope(input),
dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt,
freshForMs: Number.MAX_SAFE_INTEGER,
work: await this.repository.work(userId, connection.revision, input),
}),
};
}
async search(userId: string, input: CatalogQuery, observe = false) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input);
const sources = discovery.data
? await this.enrichment.readMany(
userId,
input.instrumentType,
discovery.data.map((item) => item.uid),
observe,
)
: [];
const dependencies = [
discovery.meta,
...sources.map((source) => source.meta),
];
return {
data:
discovery.data?.map((short) => ({
...short,
detail:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'detail',
)?.instrument ?? null,
price:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'last-price',
)?.price ?? null,
})) ?? null,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability:
discovery.data === null
? 'missing'
: sources.every((source) => source.meta.completeness === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: Number.MAX_SAFE_INTEGER,
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
}
async detail(
userId: string,
instrumentType: 'bond' | 'share',
uid: string,
observe = false,
) {
const instrument = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid),
if (!observe) {
const instrument = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid),
);
if (!instrument) throw new NotFoundException();
return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
const sources = await this.enrichment.readMany(
userId,
instrumentType,
[uid],
observe,
['detail'],
);
// RUB-only: a non-RUB instrument line (e.g. USD line of an SPB-traded stock) is not available.
if (!instrument || instrument.currency?.toUpperCase() !== 'RUB') throw new NotFoundException();
const source = sources[0];
if (source.meta.completeness === 'complete' && !source.instrument) {
throw new NotFoundException();
}
const instrument = source.instrument;
let slug = uid;
const dependencies = [source.meta];
if (
instrument?.ticker &&
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
) {
const alias = await this.read(userId, {
instrumentType,
query: instrument.ticker,
exactTicker: true,
});
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta);
}
return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
data: instrument ? { ...instrument, uid, slug } : null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
work: source.meta.sync.id
? {
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
async byTicker(
userId: string,
instrumentType: 'bond' | 'share',
ticker: string,
observe = false,
) {
const query = ticker.trim().toUpperCase();
const found = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query),
);
const candidates = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
if (!observe) {
const query = ticker.trim().toUpperCase();
const found = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query),
);
const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
? InstrumentErrorCode.StockTickerAmbiguous
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
}
const lookup = await this.read(userId, {
instrumentType,
);
const details = await this.access.online(userId, ({ token }) => Promise.all(
candidates.map((item) => instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid)),
));
const matches = candidates.filter((_item, index) =>
details.at(index)?.currency?.trim().toUpperCase() === 'RUB',
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
query: ticker,
exactTicker: true,
});
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
@@ -97,8 +237,34 @@ export class CatalogDiscoveryService {
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
const result = lookup.data
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
: null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return {
data: result?.data
? { ...result.data, slug: ticker.trim().toUpperCase() }
: null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.portfolio,
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
}
private async onlineSearch(userId: string, input: CatalogQuery) {
@@ -113,7 +279,7 @@ export class CatalogDiscoveryService {
: found,
input.query,
input.instrumentType,
);
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
const [details, prices] = await Promise.all([
Promise.all(
instruments.map((item) =>
@@ -131,13 +297,12 @@ export class CatalogDiscoveryService {
const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => {
const detail = details.at(index);
if (detail?.currency?.trim().toUpperCase() !== 'RUB') return [];
return [{
...item,
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
}];
}).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
});
return {
data,
meta: investmentResourceMeta({
@@ -1,8 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
TbankClient,
@@ -10,8 +10,7 @@ import {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
CatalogDiscoveryRepository,
@@ -222,8 +221,9 @@ export class CatalogEnrichmentService {
instrumentType: 'bond' | 'share',
uid: string,
section: 'company' | 'metrics' | 'quote',
observe = false,
) {
if (section === 'company') {
if (section === 'company' && !observe) {
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
const instrument =
instrumentType === 'share'
@@ -253,22 +253,25 @@ export class CatalogEnrichmentService {
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
};
}
const kinds: CatalogSource['kind'][] = [
...(instrumentType === 'bond'
? ['bond-current' as const]
: ['detail' as const]),
'last-price',
'trading-status',
'comparison',
...(section === 'metrics'
? ['coupons' as const, 'yield' as const, 'rating' as const]
: []),
];
const kinds: CatalogSource['kind'][] =
section === 'company'
? ['detail', 'description', 'fundamentals']
: [
...(instrumentType === 'bond'
? ['bond-current' as const]
: ['detail' as const]),
'last-price',
'trading-status',
'comparison',
...(section === 'metrics'
? ['coupons' as const, 'yield' as const, 'rating' as const]
: []),
];
const sources = await this.readMany(
userId,
instrumentType,
[uid],
false,
observe,
kinds,
);
const first = sources[0];
@@ -296,7 +299,7 @@ export class CatalogEnrichmentService {
fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.quote,
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
work: null,
dependencies,
});
@@ -1,6 +1,6 @@
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
import { TbankInstrumentType } from '#src/integrations/tbank/tbank-protocol';
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
export const catalogSearchLimit = 30;
@@ -3,13 +3,13 @@ import { Injectable } from '@nestjs/common';
import {
decimalFromString,
divide,
} from '#src/common/decimal';
} from '../../../../common/decimal';
import {
MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND,
} from '#src/common/time';
import { type MoexBondEvent, MoexClient } from '#src/integrations/moex/moex.api';
import { NcrRatingsClient } from '#src/integrations/ncr/ncr.api';
} from '../../../../common/time';
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import {
type TbankBond,
type TbankCandle,
@@ -20,8 +20,7 @@ import {
type TbankLastPrice,
type TbankShare,
type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import {
@@ -1,9 +1,9 @@
import type {
TbankCandle,
TbankTimestamp,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
@@ -27,11 +27,11 @@ const row = (
});
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
positions,
cash: { currency: 'RUB', value: '0' },
totals: { currency: 'RUB', value: '0' },
cashBalances: [],
totalsByCurrency: [],
isStale: false,
lastSuccessfulAt: '2026-09-10T00:00:00Z',
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
structure: { currencies: [], unvaluedCategories: [] },
});
describe('stock position', () => {
@@ -64,10 +64,10 @@ describe('stock position', () => {
expect(result.averagePurchasePrice?.value).toBe('0.1');
},
);
it('does not treat unknown values as zero', () => {
it('does not sum currencies or treat unknown values as zero', () => {
const second = {
...row(),
currentValue: null,
currentValue: money('2', 'USD'),
averagePurchasePrice: null,
absoluteReturn: null,
};
@@ -4,9 +4,8 @@ import {
divide,
formatDecimal,
multiply,
} from '#src/common/decimal';
import { MoneyDto } from '#src/common/money.dto';
} from '../../../../common/decimal';
import { MoneyDto } from '../../../../common/money.dto';
import {
PortfolioDto,
PortfolioPositionDto,
@@ -46,7 +45,7 @@ export function instrumentPosition(
averagePurchasePrice:
positive && totalCost
? {
currency: 'RUB',
currency: totalCost.currency,
value: formatDecimal(
divide(
decimalFromString(totalCost.value),
@@ -83,12 +82,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
: null;
}
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
if (!values.length || !values.every((value): value is MoneyDto => value !== null)) return null;
const present = values;
const currency = values[0]?.currency;
if (
!currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
) {
return null;
}
return {
currency: 'RUB',
currency,
value: formatDecimal(
present.reduce(
values.reduce(
(sum, value) => add(sum, decimalFromString(value.value)),
decimalFromString('0'),
),
@@ -102,6 +106,7 @@ function percent(
if (
!base ||
!result ||
base.currency !== result.currency ||
decimalFromString(base.value).value <= ZERO_AMOUNT
) {
return null;
@@ -1,7 +1,7 @@
import type {
TbankBond,
TbankShare,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
import { publicInstrument } from './public-instrument';
@@ -1,7 +1,7 @@
import type {
TbankBond,
TbankShare,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
export function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
@@ -1,10 +1,9 @@
import { Injectable } from '@nestjs/common';
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
import { formatDecimal } from '#src/common/decimal';
import { decimal } from '#src/integrations/tbank/decimal';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market';
import {
@@ -15,8 +14,7 @@ import {
type IdentifiedBond = TbankBond & { uid: string };
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { money } from '../infrastructure/bond-money.mapper';
const MIN_LOT_EXCLUSIVE = 0;
@@ -27,10 +25,12 @@ export class BondCatalogService {
async search(
userId: string,
query: string,
observe = false,
): Promise<BondSearchResourceDto> {
const result = await this.discovery.search(
userId,
{ instrumentType: 'bond', query },
observe,
);
return {
...result,
@@ -58,8 +58,9 @@ export class BondCatalogService {
async get(
userId: string,
uid: string,
observe = false,
): Promise<BondResourceDto> {
const result = await this.discovery.detail(userId, 'bond', uid);
const result = await this.discovery.detail(userId, 'bond', uid, observe);
return {
...result,
data: result.data
@@ -71,11 +72,13 @@ export class BondCatalogService {
async getByTicker(
userId: string,
ticker: string,
observe = false,
): Promise<BondResourceDto> {
const result = await this.discovery.byTicker(
userId,
'bond',
ticker,
observe,
);
return {
...result,
@@ -5,20 +5,19 @@ import {
divide,
formatDecimal,
multiply,
} from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { isTbankProtocolValue, TbankCouponType } from '#src/integrations/tbank/tbank-protocol';
} from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
import {
TbankAccessService,
type TbankBond,
type TbankCoupon,
tbankSourceNeedsRefresh,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../../integrations/tbank/tbank.api';
import {
ForecastService,
PortfolioService,
} from '#src/modules/investments/portfolio/portfolio.api';
} from '../../../portfolio/portfolio.api';
import {
CatalogEnrichmentService,
type CatalogPayload,
@@ -55,12 +54,14 @@ export class BondIncomeService {
async metrics(
userId: string,
uid: string,
observe = false,
): Promise<BondMetricsResourceDto> {
const result = await this.sources.section(
userId,
'bond',
uid,
'metrics',
observe,
);
return {
data: result.values.instrument
@@ -81,6 +82,7 @@ export class BondIncomeService {
async coupons(
userId: string,
uid: string,
observe = false,
): Promise<BondCouponsResourceDto> {
const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString());
@@ -91,7 +93,7 @@ export class BondIncomeService {
userId,
'bond',
[uid],
false,
observe,
['detail', 'coupons'],
{ from, to },
);
@@ -178,7 +180,7 @@ export function bondCoupons(
recordDate &&
recordDate >= today
? {
currency: 'RUB',
currency: perBond.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)),
),
@@ -227,6 +229,7 @@ export function bondMetrics(
coupon &&
couponPeriodDays &&
clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
decimalFromString(clean.value).value > ZERO_AMOUNT
? formatDecimal(
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import {
add,
@@ -9,10 +9,9 @@ import {
formatDecimal,
multiply,
subtract,
} from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { decimal } from '#src/integrations/tbank/decimal';
} from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service';
import {
CatalogEnrichmentService,
@@ -39,12 +38,14 @@ export class BondMarketService {
async quote(
userId: string,
uid: string,
observe = false,
): Promise<BondQuoteResourceDto> {
const result = await this.sources.section(
userId,
'bond',
uid,
'quote',
observe,
);
return {
data: result.values.instrument
@@ -58,10 +59,12 @@ export class BondMarketService {
userId: string,
uid: string,
period: BondCandlesQueryDto['period'] = 'month',
observe = false,
) {
return this.history.read(
userId,
{ uid, instrumentType: 'bond', period },
observe,
);
}
}
@@ -82,7 +85,7 @@ export function bondQuote(
nominal &&
decimalFromString(nominal.value).value > ZERO_AMOUNT
? {
currency: 'RUB',
currency: nominal.currency,
value: formatDecimal(
divide(
multiply(
@@ -96,9 +99,9 @@ export function bondQuote(
}
: null;
const dirtyPrice =
cleanPrice && aci
cleanPrice && aci && aci.currency === cleanPrice.currency
? {
currency: 'RUB',
currency: cleanPrice.currency,
value: formatDecimal(
add(
decimalFromString(cleanPrice.value),
@@ -1,9 +1,9 @@
import type { PortfolioDto } from '#src/modules/investments/portfolio/portfolio.api';
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position';
import { bondCoupons, bondMetrics } from './bond-income.service';
import { bondQuote } from './bond-market.service';
jest.mock('#src/prisma/prisma.service', () => ({
jest.mock('../../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
@@ -1 +1,10 @@
export { rubMoney as money } from '#src/integrations/tbank/decimal';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase();
return value && currency
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
@@ -11,9 +11,9 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
import { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service';
@@ -47,8 +47,9 @@ export class BondController {
metrics(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.metrics(request.user.sub, uid);
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
}
@Get()
@@ -60,6 +61,7 @@ export class BondController {
return this.bonds.search(
request.user.sub,
query.query,
query.sync === 'observe',
);
}
@@ -68,10 +70,12 @@ export class BondController {
getByTicker(
@Req() request: Request & { user: AccessPayload },
@Param() params: BondTickerParamsDto,
@Query() query: InvestmentReadQueryDto,
) {
return this.bonds.getByTicker(
request.user.sub,
params.ticker,
query.sync === 'observe',
);
}
@@ -80,8 +84,9 @@ export class BondController {
get(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.bonds.get(request.user.sub, uid);
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
}
@Get(':uid/quote')
@@ -89,8 +94,9 @@ export class BondController {
quote(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.market.quote(request.user.sub, uid);
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
}
@Get(':uid/candles')
@@ -104,6 +110,7 @@ export class BondController {
request.user.sub,
uid,
query.period,
query.sync === 'observe',
);
}
@@ -121,7 +128,8 @@ export class BondController {
coupons(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.coupons(request.user.sub, uid);
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
}
}
@@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '#src/common/money.dto';
import { InvestmentResourceMetaDto } from '#src/modules/investments/investment-resource.dto';
import { MoneyDto } from '../../../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
import {
StockCandlesDto,
StockCandlesQueryDto,
@@ -1,4 +1,4 @@
import { MILLISECONDS_PER_DAY } from '#src/common/time';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
const FIRST_RANGE_END_INDEX = 1;
@@ -1,19 +1,17 @@
import { Injectable } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import type { Prisma } from '#generated/prisma/client';
import type { Prisma } from '../../../../../generated/prisma/client';
import {
claimSyncWork,
lockConnectionGeneration,
} from '#src/integrations/tbank/tbank-sync-claim';
} from '../../../../integrations/tbank/tbank-sync-claim';
import {
isJoinableRunningAttempt,
type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '#src/prisma/prisma.service';
import { toJson } from '#src/prisma/to-json';
} from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
type CandleRange,
confirmedCandleCoverage,
@@ -120,7 +118,7 @@ export class CandleRepository {
processRunId: runId,
state: 'running',
priority,
intent: toJson(intent),
intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject,
progress: {},
errorCode: null,
};
@@ -178,7 +176,9 @@ export class CandleRepository {
sourceKey: 'candles',
itemKey: key,
};
const payload = toJson({ range, candles: rows, fetchedAt });
const payload = JSON.parse(
JSON.stringify({ range, candles: rows, fetchedAt }),
) as Prisma.InputJsonObject;
await tx.tbankSyncStage.upsert({
where: { workId_attemptId_sourceKey_itemKey: identity },
create: { ...identity, payload },
@@ -2,11 +2,12 @@ import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '#src/prisma/prisma.service';
import { toJson } from '#src/prisma/to-json';
import type { Prisma } from '../../../../../generated/prisma/client';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
const UNCHANGED_REVISION_INCREMENT = 0;
const REVISION_INCREMENT = 1;
@@ -171,7 +172,9 @@ export class CatalogDiscoveryRepository {
progress: {},
});
if (!claimed) return false;
const payload = toJson(value);
const payload = JSON.parse(
JSON.stringify(value),
) as Prisma.InputJsonObject;
const identity = { provider, uid: input.uid };
await tx.tbankInstrument.upsert({
where: { provider_uid: identity },
@@ -194,10 +197,12 @@ export class CatalogDiscoveryRepository {
const previous = await tx.tbankInstrument.findUniqueOrThrow({
where: { provider_uid: identity },
});
const metadata = toJson({
...instrument,
instrumentType: input.instrumentType,
});
const metadata = JSON.parse(
JSON.stringify({
...instrument,
instrumentType: input.instrumentType,
}),
) as Prisma.InputJsonObject;
await tx.tbankInstrument.update({
where: { provider_uid: identity },
data: {
@@ -336,20 +341,22 @@ export class CatalogDiscoveryRepository {
for (const instrument of instruments) {
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
const identity = { provider, uid: instrument.uid };
const payload = toJson({
uid: instrument.uid,
figi: instrument.figi,
ticker: instrument.ticker,
name: instrument.name,
isin: instrument.isin,
classCode: instrument.classCode,
instrumentKind: instrument.instrumentKind,
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
currency: instrument.currency,
brand: instrument.brand
? { logoName: instrument.brand.logoName }
: undefined,
});
const payload = JSON.parse(
JSON.stringify({
uid: instrument.uid,
figi: instrument.figi,
ticker: instrument.ticker,
name: instrument.name,
isin: instrument.isin,
classCode: instrument.classCode,
instrumentKind: instrument.instrumentKind,
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
currency: instrument.currency,
brand: instrument.brand
? { logoName: instrument.brand.logoName }
: undefined,
}),
) as Prisma.InputJsonObject;
await tx.tbankInstrument.upsert({
where: { provider_uid: identity },
update: {},
@@ -1,10 +1,8 @@
import type { TbankCandle } from '#src/integrations/tbank/tbank.api';
import { formatDecimal } from '#src/common/decimal';
import { decimal } from '#src/integrations/tbank/decimal';
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
import type { SavedCandle } from '../domain/candle-coverage';
import { formatDecimal } from '../../../../common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal';
import { timestamp } from '../application/instrument-market';
function toSavedCandle(candle: TbankCandle): SavedCandle {
@@ -1,9 +1,8 @@
import { Module } from '@nestjs/common';
import { MoexModule } from '#src/integrations/moex/moex.module';
import { NcrModule } from '#src/integrations/ncr/ncr.module';
import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { MoexModule } from '../../../integrations/moex/moex.module';
import { NcrModule } from '../../../integrations/ncr/ncr.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { CandleService } from './application/candle.service';
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { BondCatalogService } from './bonds/application/bond-catalog.service';
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import type { TbankFundamentals } from '#src/integrations/tbank/tbank.api';
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
import {
@@ -13,12 +13,14 @@ export class GetStockCompanyUseCase {
async execute(
userId: string,
uid: string,
observe = false,
): Promise<StockCompanyResourceDto> {
const result = await this.sources.section(
userId,
'share',
uid,
'company',
observe,
);
return {
data: result.values.instrument
@@ -70,17 +72,16 @@ export function metrics(
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
const value = values[key];
const currency = values.currency?.toUpperCase() || null;
const rub = currency === 'RUB';
return typeof value === 'number' &&
Number.isFinite(value) &&
(unit !== 'money' || rub)
(unit !== 'money' || currency)
? [
{
key,
value,
unit,
period,
currency: unit === 'money' ? 'RUB' : null,
currency: unit === 'money' ? currency : null,
},
]
: [];
@@ -1,10 +1,9 @@
import { Injectable } from '@nestjs/common';
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import { formatDecimal } from '#src/common/decimal';
import { decimal } from '#src/integrations/tbank/decimal';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market';
import {
@@ -23,10 +22,12 @@ export class StockCatalogService {
async search(
userId: string,
query: string,
observe = false,
): Promise<StockSearchResourceDto> {
const result = await this.discovery.search(
userId,
{ instrumentType: 'share', query },
observe,
);
return {
...result,
@@ -45,7 +46,7 @@ export class StockCatalogService {
price:
last?.price && detail.currency
? {
currency: 'RUB',
currency: detail.currency,
value: formatDecimal(decimal(last.price)),
}
: null,
@@ -58,8 +59,9 @@ export class StockCatalogService {
async get(
userId: string,
uid: string,
observe = false,
): Promise<StockResourceDto> {
const result = await this.discovery.detail(userId, 'share', uid);
const result = await this.discovery.detail(userId, 'share', uid, observe);
return {
...result,
data: result.data
@@ -71,11 +73,13 @@ export class StockCatalogService {
async getByTicker(
userId: string,
ticker: string,
observe = false,
): Promise<StockResourceDto> {
const result = await this.discovery.byTicker(
userId,
'share',
ticker,
observe,
);
return {
...result,
@@ -4,19 +4,18 @@ import {
decimalFromString,
formatDecimal,
multiply,
} from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { rubMoney } from '#src/integrations/tbank/decimal';
} from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '../../../../../integrations/tbank/decimal';
import {
TbankAccessService,
type TbankDividend,
tbankSourceNeedsRefresh,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../../integrations/tbank/tbank.api';
import {
ForecastService,
PortfolioService,
} from '#src/modules/investments/portfolio/portfolio.api';
} from '../../../portfolio/portfolio.api';
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
import { moscowDay, timestamp } from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position';
@@ -52,6 +51,7 @@ export class StockIncomeService {
async dividends(
userId: string,
uid: string,
observe = false,
): Promise<StockDividendsResourceDto> {
const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString());
@@ -62,7 +62,7 @@ export class StockIncomeService {
userId,
'share',
[uid],
false,
observe,
['detail', 'dividends'],
{ from, to },
);
@@ -131,7 +131,11 @@ export function stockDividends(
const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const perShare = rubMoney(event.dividendNet);
const currency = event.dividendNet?.currency?.toUpperCase();
const perShare =
currency && event.dividendNet
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
: null;
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
const eventDate = paymentDate ?? recordDate;
const timing: StockDividendDto['timing'] = eventDate
@@ -146,7 +150,7 @@ export function stockDividends(
recordDate >= today &&
timing === 'upcoming'
? {
currency: 'RUB',
currency: perShare.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)),
),
@@ -6,10 +6,9 @@ import {
formatDecimal,
multiply,
subtract,
} from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { rubMoney } from '#src/integrations/tbank/decimal';
} from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service';
import {
CatalogEnrichmentService,
@@ -35,12 +34,14 @@ export class StockMarketService {
async quote(
userId: string,
uid: string,
observe = false,
): Promise<StockQuoteResourceDto> {
const result = await this.sources.section(
userId,
'share',
uid,
'quote',
observe,
);
return {
data: result.values.instrument
@@ -54,10 +55,12 @@ export class StockMarketService {
userId: string,
uid: string,
period: StockCandlesQueryDto['period'] = 'month',
observe = false,
) {
return this.history.read(
userId,
{ uid, instrumentType: 'share', period },
observe,
);
}
}
@@ -70,8 +73,15 @@ export function stockQuote(
const { price: last, candle: previous, tradingStatus = null } = values;
const share = values.instrument;
const priceAt = timestamp(last?.time);
const price = last?.price ? rubMoney({ ...last.price, currency: share?.currency }) : null;
const previousPrice = previous?.close ? rubMoney({ ...previous.close, currency: share?.currency }) : null;
const currency = share?.currency?.toUpperCase();
const price =
last?.price && currency
? { currency, value: formatDecimal(decimal(last.price)) }
: null;
const previousPrice =
previous?.close && currency
? { currency, value: formatDecimal(decimal(previous.close)) }
: null;
const difference =
price && previousPrice
? subtract(
@@ -86,8 +96,8 @@ export function stockQuote(
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
previousClose: previousPrice,
change:
difference
? { currency: 'RUB', value: formatDecimal(difference) }
difference && currency
? { currency, value: formatDecimal(difference) }
: null,
changePercent:
difference &&
@@ -1,10 +1,10 @@
jest.mock('#src/integrations/tbank/tbank.api', () => ({
jest.mock('../../../../../integrations/tbank/tbank.api', () => ({
TbankAccessService: class {},
}));
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import { StockScreenerService } from './stock-screener.service';
@@ -87,6 +87,7 @@ describe('StockScreenerService', () => {
client.getAllShares.mockResolvedValueOnce([
share(1, {
countryOfRisk: undefined,
currency: undefined,
isin: undefined,
realExchange: undefined,
sector: undefined,
@@ -95,7 +96,7 @@ describe('StockScreenerService', () => {
}),
]);
const result = await service.search('user-1', {});
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: 'RUB', isin: null, logoName: null, ticker: null }));
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null }));
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
@@ -6,12 +6,11 @@ import {
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { TbankShare } from '#src/integrations/tbank/tbank-market.types';
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { TbankAccessService } from '#src/integrations/tbank/tbank.api';
import { TbankClient } from '#src/integrations/tbank/tbank.client';
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
import { TbankAccessService } from '../../../../../integrations/tbank/tbank.api';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import {
selectStockScreenerInstruments,
type StockScreenerConditions,
@@ -2,7 +2,7 @@ import { previousClose } from '../../application/instrument-market';
import { metrics } from './get-stock-company.use-case';
import { stockDividends } from './stock-income.service';
import { stockQuote } from './stock-market.service';
jest.mock('#src/prisma/prisma.service', () => ({
jest.mock('../../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
@@ -1,4 +1,4 @@
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
@@ -29,23 +29,16 @@ describe('selectStockScreenerInstruments', () => {
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
],
},
{ query: 'star', sector: 'energy' },
{ currency: 'RUB', query: 'star', sector: 'energy' },
);
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
});
it('filters out non-RUB instruments', () => {
const result = selectStockScreenerInstruments(
{ asOf, instruments: [share({ currency: 'USD' }), share({ currency: undefined })] },
{},
);
expect(result.instruments).toEqual([]);
});
it.each([
['sector', { sector: 'energy' }, share({ sector: undefined })],
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
['currency', { currency: 'USD' }, share({ currency: undefined })],
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
@@ -1,4 +1,4 @@
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
export type StockScreenerCatalog = {
asOf: Date;
@@ -7,6 +7,7 @@ export type StockScreenerCatalog = {
export type StockScreenerConditions = {
countryOfRisk?: string;
currency?: string;
maxLot?: number;
query?: string;
realExchange?: string;
@@ -75,7 +76,12 @@ function matches(
) {
return false;
}
if (instrument.currency?.toUpperCase() !== 'RUB') return false;
if (
conditions.currency !== undefined &&
instrument.currency !== conditions.currency
) {
return false;
}
if (
conditions.realExchange !== undefined &&
instrument.realExchange !== conditions.realExchange
@@ -17,9 +17,9 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
import { StockCatalogService } from '../application/stock-catalog.service';
import { StockIncomeService } from '../application/stock-income.service';
@@ -64,6 +64,7 @@ export class StockController {
return this.stocks.search(
request.user.sub,
query.query,
query.sync === 'observe',
);
}
@@ -72,10 +73,12 @@ export class StockController {
getByTicker(
@Req() request: Request & { user: AccessPayload },
@Param() params: StockTickerParamsDto,
@Query() query: InvestmentReadQueryDto,
) {
return this.stocks.getByTicker(
request.user.sub,
params.ticker,
query.sync === 'observe',
);
}
@@ -89,7 +92,7 @@ export class StockController {
criteria: StockScreenerCriteriaDto,
) {
// The global whitelist pipe strips unknown keys before this param pipe runs, so only the raw body can reject them.
const allowed = new Set(['countryOfRisk', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
throw new BadRequestException();
}
@@ -112,8 +115,9 @@ export class StockController {
get(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.stocks.get(request.user.sub, uid);
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
}
@Get(':uid/quote')
@@ -121,8 +125,9 @@ export class StockController {
quote(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.market.quote(request.user.sub, uid);
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
}
@Get(':uid/candles')
@@ -136,6 +141,7 @@ export class StockController {
request.user.sub,
uid,
query.period,
query.sync === 'observe',
);
}
@@ -153,10 +159,12 @@ export class StockController {
company(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.companyInfo.execute(
request.user.sub,
uid,
query.sync === 'observe',
);
}
@@ -165,10 +173,12 @@ export class StockController {
dividends(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) {
return this.income.dividends(
request.user.sub,
uid,
query.sync === 'observe',
);
}
}
@@ -11,15 +11,16 @@ import {
MinLength,
} from 'class-validator';
import { MoneyDto } from '#src/common/money.dto';
import { MoneyDto } from '../../../../../common/money.dto';
import {
MAX_INSTRUMENT_QUERY_LENGTH,
MIN_INSTRUMENT_QUERY_LENGTH,
} from '#src/modules/investments/instrument-query';
} from '../../../instrument-query';
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '#src/modules/investments/investment-resource.dto';
import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api';
} from '../../../investment-resource.dto';
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MIN_TICKER_LENGTH = 1;
const MAX_SCREENER_QUERY_LENGTH = 80;
@@ -52,6 +53,7 @@ export class StockScreenerCriteriaDto {
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
currency?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@@ -102,7 +104,7 @@ export class StockScreenerPageDto {
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
}
export class StockSearchQueryDto {
export class StockSearchQueryDto extends InvestmentReadQueryDto {
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
@@ -124,7 +126,7 @@ export class StockTickerParamsDto {
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
declare ticker: string;
}
export class StockCandlesQueryDto {
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
@ApiPropertyOptional({
enum: ['month', 'halfYear', 'year', 'all'],
default: 'month',
@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = [
'available',
@@ -16,6 +17,13 @@ export const attemptStateValues = [
'failed',
] as const;
export class InvestmentReadQueryDto {
@ApiPropertyOptional({ enum: ['observe'] })
@IsOptional()
@IsIn(['observe'])
declare sync?: 'observe';
}
export class InvestmentAttemptProblemDto {
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { MoexModule } from '#src/integrations/moex/moex.module';
import { MarketIndexRepository } from './market-index.repository';
import { MarketIndexHistoryService } from './market-index.service';
@Module({
imports: [MoexModule],
providers: [MarketIndexHistoryService, MarketIndexRepository],
exports: [MarketIndexHistoryService],
})
export class MarketIndexModule {}
@@ -1,52 +0,0 @@
import { Injectable } from '@nestjs/common';
import type { MoexIndexClose } from '#src/integrations/moex/moex.client';
import { PrismaService } from '#src/prisma/prisma.service';
@Injectable()
export class MarketIndexRepository {
constructor(private readonly prisma: PrismaService) {}
async read(secid: 'IMOEX' | 'MCFTRR', from: string, to: string) {
const [closes, state] = await Promise.all([
this.prisma.marketIndexDailyClose.findMany({
where: { secid, tradeDate: { gte: from, lte: to } },
orderBy: { tradeDate: 'asc' },
}),
this.prisma.marketIndexSyncState.findUnique({ where: { secid } }),
]);
return { closes, state };
}
async attempt(secid: 'IMOEX' | 'MCFTRR', at: Date) {
await this.prisma.marketIndexSyncState.upsert({
where: { secid }, create: { secid, lastAttemptAt: at }, update: { lastAttemptAt: at },
});
}
// coveredTo is the `till` MOEX answered for: a close for today that is not
// published yet is re-checked by the service while the last close < to.
async save(secid: 'IMOEX' | 'MCFTRR', closes: MoexIndexClose[], from: string, to: string, at: Date) {
await this.prisma.$transaction(async (tx) => {
for (const close of closes) {
await tx.marketIndexDailyClose.upsert({
where: { secid_tradeDate: { secid, tradeDate: close.tradeDate } },
create: { secid, ...close },
update: { close: close.close },
});
}
const state = await tx.marketIndexSyncState.findUniqueOrThrow({ where: { secid } });
const joined = Boolean(state.coveredFrom && state.coveredTo &&
from <= state.coveredTo && to >= state.coveredFrom);
await tx.marketIndexSyncState.update({
where: { secid },
data: {
lastSuccessAt: at,
coveredFrom: joined && state.coveredFrom && state.coveredFrom < from ? state.coveredFrom : from,
coveredTo: joined && state.coveredTo && state.coveredTo > to ? state.coveredTo : to,
},
});
});
}
}
@@ -1,121 +0,0 @@
import { MarketIndexHistoryService } from './market-index.service';
jest.mock('./market-index.repository', () => ({ MarketIndexRepository: class {} }));
type State = {
coveredFrom: null | string;
coveredTo: null | string;
lastAttemptAt: Date | null;
lastSuccessAt: Date | null;
};
type Close = { close: string; tradeDate: string };
const HOUR_MS = 3_600_000;
// In-memory stand-in mirroring the repository's merge rules.
function setup(history: Close[]) {
let state: null | State = null;
const stored = new Map<string, string>();
const repository = {
read: jest.fn((_: string, from: string, to: string) => Promise.resolve({
closes: [...stored.entries()]
.filter(([date]) => date >= from && date <= to)
.sort(([a], [b]) => a.localeCompare(b))
.map(([tradeDate, close]) => ({ tradeDate, close })),
state: state && { ...state },
})),
attempt: jest.fn((_: string, at: Date) => {
state = { coveredFrom: null, coveredTo: null, lastSuccessAt: null, ...state, lastAttemptAt: at };
return Promise.resolve();
}),
save: jest.fn((_: string, closes: Close[], from: string, to: string, at: Date) => {
for (const close of closes) stored.set(close.tradeDate, close.close);
const current = state as State;
const joined = Boolean(current.coveredFrom && current.coveredTo &&
from <= current.coveredTo && to >= current.coveredFrom);
state = {
...current,
lastSuccessAt: at,
coveredFrom: joined && current.coveredFrom && current.coveredFrom < from ? current.coveredFrom : from,
coveredTo: joined && current.coveredTo && current.coveredTo > to ? current.coveredTo : to,
};
return Promise.resolve();
}),
};
const moex = {
indexHistory: jest.fn((_: string, from: string, till: string) => Promise.resolve(
history.filter((row) => row.tradeDate >= from && row.tradeDate <= till),
)),
};
const service = new MarketIndexHistoryService(
repository as never, moex as never, { event: jest.fn() } as never,
);
return { service, moex, setHistory: (rows: Close[]) => history.splice(0, history.length, ...rows) };
}
const closes = (...dates: string[]) => dates.map((tradeDate) => ({ tradeDate, close: '100' }));
describe('MarketIndexHistoryService', () => {
afterEach(() => jest.useRealTimers());
it('loads a ten-day lookback once and reuses the confirmed range', async () => {
const { service, moex } = setup(closes('2026-09-30', '2026-10-02', '2026-10-05'));
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3);
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3);
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
expect(moex.indexHistory).toHaveBeenCalledWith('MCFTRR', '2026-09-22', '2026-10-05');
});
it('extends history backward at once, even right after a successful load', async () => {
const { service, moex } = setup(closes('2023-01-03', '2026-09-30', '2026-10-05'));
await service.read('MCFTRR', '2026-10-01', '2026-10-05');
expect(await service.read('MCFTRR', '2023-01-05', '2026-10-05')).toHaveLength(3);
expect(moex.indexHistory).toHaveBeenLastCalledWith('MCFTRR', '2022-12-26', '2026-10-05');
});
it('re-checks a missing tail close after an hour, but not sooner', async () => {
jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') });
const { service, moex, setHistory } = setup(closes('2026-09-30', '2026-10-02'));
await service.read('MCFTRR', '2026-10-02', '2026-10-05');
setHistory(closes('2026-09-30', '2026-10-02', '2026-10-05'));
await service.read('MCFTRR', '2026-10-02', '2026-10-05');
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + HOUR_MS + 1);
const rows = await service.read('MCFTRR', '2026-10-02', '2026-10-05');
expect(rows?.at(-1)?.tradeDate).toBe('2026-10-05');
expect(moex.indexHistory).toHaveBeenCalledTimes(2);
});
it('keeps saved history usable when a later refresh fails and throttles retries', async () => {
jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') });
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
await service.read('MCFTRR', '2026-10-02', '2026-10-02');
jest.setSystemTime(Date.now() + 1000);
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2);
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2);
expect(moex.indexHistory).toHaveBeenCalledTimes(2);
});
it('returns no history when nothing was ever loaded and MOEX fails', async () => {
const { service, moex } = setup([]);
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toBeNull();
});
it('refuses a range that does not reach the last cash flow', async () => {
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
await service.read('MCFTRR', '2026-10-02', '2026-10-02');
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-09', '2026-10-08')).toBeNull();
});
it('shares one request for parallel reads', async () => {
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
await Promise.all([
service.read('IMOEX', '2026-10-02', '2026-10-05'),
service.read('IMOEX', '2026-10-02', '2026-10-05'),
]);
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
});
});
@@ -1,115 +0,0 @@
import { Injectable } from '@nestjs/common';
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { MoexClient, type MoexIndexClose } from '#src/integrations/moex/moex.client';
import { MarketIndexRepository } from './market-index.repository';
const HOUR_MS = 3_600_000;
const LOOKBACK_DAYS = 10;
const ISO_DATE_LENGTH = 10;
const ISO_DATE_START = 0;
const LAST_CLOSE = -1;
const MAX_REFRESH_ROUNDS = 2;
const FIRST_ROUND = 0;
type IndexId = 'IMOEX' | 'MCFTRR';
type Saved = Awaited<ReturnType<MarketIndexRepository['read']>>;
function shiftDays(date: string, days: number): string {
const value = new Date(`${date}T00:00:00Z`);
value.setUTCDate(value.getUTCDate() + days);
return value.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
}
function within(at: Date | null | undefined, ms: number) {
return Boolean(at && Date.now() - at.valueOf() < ms);
}
@Injectable()
export class MarketIndexHistoryService {
private readonly pending = new Map<IndexId, Promise<void>>();
constructor(
private readonly repository: MarketIndexRepository,
private readonly moex: MoexClient,
private readonly logger: StructuredLogger,
) {}
/**
* Closes covering [from − 10 days, to]. `requiredThrough` is the latest date
* that must have a close on or after it (the last cash flow); null when the
* confirmed history does not cover the period.
*/
async read(
secid: IndexId,
from: string,
to: string,
requiredThrough = from,
): Promise<MoexIndexClose[] | null> {
const rangeFrom = shiftDays(from, -LOOKBACK_DAYS);
let saved = await this.repository.read(secid, rangeFrom, to);
for (let round = FIRST_ROUND; round < MAX_REFRESH_ROUNDS; round++) {
const range = this.refreshRange(saved, rangeFrom, to);
if (!range) break;
const job = this.pending.get(secid) ?? this.start(secid, range.from, to);
await job;
saved = await this.repository.read(secid, rangeFrom, to);
}
return this.covered(saved, from, rangeFrom, to, requiredThrough)
? saved.closes.map(({ tradeDate, close }) => ({ tradeDate, close }))
: null;
}
// coveredFrom..coveredTo is the range MOEX was successfully asked about.
// Extending it backward is fetched at once; an unpublished tail close is
// re-checked at most hourly; a failed attempt is retried at most hourly.
private refreshRange(saved: Saved, rangeFrom: string, to: string) {
const state = saved.state;
const lastAttempt = state?.lastAttemptAt ?? null;
const lastSuccess = state?.lastSuccessAt ?? null;
// Success is stamped with its own attempt time, so equal means success.
const failed = lastAttempt !== null && (lastSuccess === null || lastSuccess.valueOf() !== lastAttempt.valueOf());
if (failed && within(lastAttempt, HOUR_MS)) return null;
if (!state?.coveredFrom || !state.coveredTo || state.coveredFrom > rangeFrom) return { from: rangeFrom };
if (state.coveredTo < to) return { from: state.coveredTo };
const last = saved.closes.at(LAST_CLOSE)?.tradeDate;
if ((!last || last < to) && !within(lastSuccess, HOUR_MS)) return { from: last ?? rangeFrom };
return null;
}
private covered(saved: Saved, from: string, rangeFrom: string, to: string, requiredThrough: string) {
const last = saved.closes.at(LAST_CLOSE)?.tradeDate;
return Boolean(
saved.state?.coveredFrom && saved.state.coveredFrom <= rangeFrom &&
saved.closes.some((row) => row.tradeDate <= from) &&
last && last >= shiftDays(to, -LOOKBACK_DAYS) &&
// The last cash flow must lie inside the checked range, otherwise it
// would be priced by a stale close.
saved.state.coveredTo && saved.state.coveredTo >= requiredThrough,
);
}
private start(secid: IndexId, from: string, to: string): Promise<void> {
const job = this.refresh(secid, from, to).finally(() => this.pending.delete(secid));
this.pending.set(secid, job);
return job;
}
private async refresh(secid: IndexId, from: string, to: string): Promise<void> {
const at = new Date();
const started = Date.now();
await this.repository.attempt(secid, at);
try {
const closes = await this.moex.indexHistory(secid, from, to);
await this.repository.save(secid, closes, from, to, at);
this.logger.event('info', 'integration.moex.index_history.succeeded', {
operation: secid, count: closes.length, durationMs: Date.now() - started,
});
} catch {
this.logger.event('warn', 'integration.moex.index_history.failed', {
operation: secid, durationMs: Date.now() - started,
});
}
}
}
@@ -1,12 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
import { TbankAccessService, TbankClient } from '#src/integrations/tbank/tbank.api';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
import {
type OperationInterval,
operationIntervals,
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import {
InstrumentMetadataService,
@@ -7,51 +8,54 @@ import {
TbankAccessService,
type TbankOperationItem,
tbankSourceTtlMs,
} from '#src/integrations/tbank/tbank.api';
} from '../../../../integrations/tbank/tbank.api';
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { ledgerRange } from '../../investment-sync-plan';
import { accountHistory, ledgerRange } from '../../investment-sync-plan';
import {
hourlyAttempt,
TbankSyncRepository,
} from '../../tbank-sync/tbank-sync.api';
import {
coverageAsOf,
deriveReadCoverage,
historyState,
serializeRange,
} from '../domain/operations-coverage';
import { OperationErrorCode } from '../domain/operation-error-code';
import {
confirmedOperationRanges,
operationIntervalsOverlap,
operationsSyncPriority,
planOperationRead,
uncoveredOperationIntervals,
} from '../domain/operations-sync-plan';
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
import { BrokerageOperationsReadRepository } from '../infrastructure/brokerage-operations-read.repository';
import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository';
import {
BrokerageOperationsQueryDto,
DEFAULT_OPERATIONS_PAGE_SIZE,
} from '../presentation/brokerage-operation.dto';
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
import { LocalOperationsCursorCodec } from './local-operations-cursor.codec';
const FIRST_RESULT_OFFSET = 0;
const MISSING_MEMBERSHIP_REVISION = 0;
const STALE_FRESHNESS_MS = 0;
const NO_COVERAGE_ROWS = 0;
const UNKNOWN_AS_OF_MS = 0;
const LAST_ROW_INDEX = -1;
const UNIX_EPOCH_MS = 0;
type LocalOperationsCursor = {
accountId: string;
at: string;
binding: string;
id: string;
movingEnd?: boolean;
to: string;
};
@Injectable()
export class BrokerageOperationsService {
constructor(
private readonly access: TbankAccessService,
private readonly metadata: InstrumentMetadataService,
private readonly repository: BrokerageOperationsReadRepository,
private readonly repository: BrokerageOperationsRepository,
private readonly sync: BrokerageOperationsSyncService,
private readonly cursorCodec: LocalOperationsCursorCodec,
private readonly config: ConfigService,
private readonly hourlySync: TbankSyncRepository,
) {}
@@ -61,7 +65,7 @@ export class BrokerageOperationsService {
query: BrokerageOperationsQueryDto,
) {
const connection = await this.access.local(userId, accountId);
const cursor = query.cursor ? this.cursorCodec.decode(query.cursor) : undefined;
const cursor = query.cursor ? this.decodeCursor(query.cursor) : undefined;
const selected = connection.accounts.filter(
(account) => accountId === 'all' || account.accountId === accountId,
);
@@ -120,18 +124,64 @@ export class BrokerageOperationsService {
const saved = await this.repository.readPage(readInput);
const hourly = await this.hourlySync.hourly(userId);
const accounts = saved?.connection.accounts ?? [];
const view = deriveReadCoverage({
accounts,
connectionMembershipAt: saved?.connection.membershipAt ?? null,
coverage: saved?.coverage ?? [],
from,
instrumentUid: query.instrumentUid,
const targets = accounts.flatMap((account) =>
from < to
? [
{
accountId: account.accountId,
from,
to,
instrumentUid: query.instrumentUid,
},
]
: [],
);
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
movingEnd,
now,
to,
work: saved?.work ?? [],
});
const { targets, plan, confirmed, missing, available, complete, lastSuccessfulAt, history, knownBoundary, work } = view;
const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
const missing = uncoveredOperationIntervals(
plan.intervals,
confirmed.map((range) => ({
...range,
instrumentUid: query.instrumentUid,
})),
);
const available = Boolean(saved?.connection.membershipAt);
const complete = available && !missing.length;
const relevant =
saved?.work.filter((work) =>
work.intervals.some((interval) =>
targets.some((target) => operationIntervalsOverlap(interval, target)),
),
) ?? [];
const work =
relevant.find((work) => work.state === 'running') ??
relevant.find((work) => work.state === 'failed') ??
null;
const sources = (saved?.coverage ?? []).filter((row) =>
targets.some(
(target) =>
operationIntervalsOverlap(row, target) &&
(!row.instrumentUid || row.instrumentUid === target.instrumentUid),
),
);
const lastSuccessfulAt =
sources
.map((row) => row.fetchedAt)
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
(complete ? saved?.connection.membershipAt : null) ??
null;
const historyTargets = accountHistory(accounts, now);
const history = planOperationRead(
historyTargets,
saved?.coverage ?? [],
now,
{ movingEnd: true },
);
const knownBoundary = accounts.every(
(account) => account.openedOn !== null,
);
const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? [];
const instruments = await this.metadata.savedMany(
rows.map((row) => row.payload as TbankOperationItem),
@@ -174,17 +224,49 @@ export class BrokerageOperationsService {
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
dependencies: [membershipMeta, ...metadataDependencies],
coverage: {
requested: targets.map(serializeRange),
completeRanges: confirmed.map(serializeRange),
missingRanges: missing.map(serializeRange),
asOf: coverageAsOf(plan, accounts.map((account) => account.accountId)),
requested: targets.map(serialize),
completeRanges: confirmed.map(serialize),
missingRanges: missing.map(serialize),
asOf:
accounts.length &&
accounts.every((account) =>
plan.intervals.some(
(interval) =>
interval.accountId === account.accountId && interval.asOf,
),
)
? new Date(
Math.min(
...accounts.map((account) =>
Math.max(
...plan.intervals
.filter(
(interval) =>
interval.accountId === account.accountId,
)
.map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS),
),
),
),
).toISOString()
: null,
historyBoundary: knownBoundary ? 'known' : 'unknown',
historyState: historyState(
knownBoundary,
history,
saved?.work ?? [],
saved?.coverage.length ?? NO_COVERAGE_ROWS,
),
historyState: saved?.work.some(
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
)
? 'loading'
: saved?.work.some(
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
)
? 'failed'
: knownBoundary &&
history.intervals.every(
(interval) => interval.availability === 'complete',
)
? 'complete'
: saved?.coverage.length
? 'partial'
: 'missing',
},
});
const last = rows.at(LAST_ROW_INDEX);
@@ -219,7 +301,7 @@ export class BrokerageOperationsService {
hasNext,
nextCursor:
hasNext && last && saved
? this.cursorCodec.encode({
? this.encodeCursor({
binding: saved.binding,
movingEnd,
to: to.toISOString(),
@@ -234,10 +316,55 @@ export class BrokerageOperationsService {
};
}
private encodeCursor(value: LocalOperationsCursor): string {
const body = Buffer.from(JSON.stringify(value)).toString('base64url');
return `${body}.${createHmac('sha256', this.config.getOrThrow<string>('JWT_SECRET')).update(body).digest('base64url')}`;
}
private decodeCursor(value: string): LocalOperationsCursor {
try {
const [body, signature, extra] = value.split('.');
const expected = createHmac(
'sha256',
this.config.getOrThrow<string>('JWT_SECRET'),
)
.update(body)
.digest();
const actual = Buffer.from(signature ?? '', 'base64url');
if (
extra ||
actual.length !== expected.length ||
!timingSafeEqual(actual, expected)
) {
throw new Error();
}
const cursor = JSON.parse(
Buffer.from(body, 'base64url').toString(),
) as Partial<LocalOperationsCursor>;
if (
(cursor.movingEnd !== undefined &&
typeof cursor.movingEnd !== 'boolean') ||
!cursor.binding ||
!cursor.accountId ||
!cursor.id ||
!cursor.at ||
!cursor.to ||
!Number.isFinite(Date.parse(cursor.at)) ||
!Number.isFinite(Date.parse(cursor.to))
) {
throw new Error();
}
return cursor as LocalOperationsCursor;
} catch {
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
}
}
async collectRanges(
userId: string,
accountId: string,
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
observe = false,
) {
const connection = await this.access.local(userId, accountId);
const input = {
@@ -251,19 +378,21 @@ export class BrokerageOperationsService {
periods: ranges,
};
let saved = await this.repository.readPage(input);
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
(saved?.periods ?? []).map((period) => ({
accountId: account.accountId,
...period,
})),
);
if (targets.length) {
await this.sync.readPlan(userId, targets, {
movingEnd: saved?.periods?.some((period) => period.movingEnd),
revision: connection.revision,
});
if (!observe) {
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
(saved?.periods ?? []).map((period) => ({
accountId: account.accountId,
...period,
})),
);
if (targets.length) {
await this.sync.readPlan(userId, targets, {
movingEnd: saved?.periods?.some((period) => period.movingEnd),
revision: connection.revision,
});
}
saved = await this.repository.readPage(input);
}
saved = await this.repository.readPage(input);
const now = new Date();
const hourly = await this.hourlySync.hourly(userId);
const membership = investmentResourceMeta({
@@ -337,12 +466,12 @@ export class BrokerageOperationsService {
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
dependencies: [membership],
coverage: {
requested: targets.map(serializeRange),
completeRanges: confirmed.map(serializeRange),
requested: targets.map(serialize),
completeRanges: confirmed.map(serialize),
missingRanges: uncoveredOperationIntervals(
plan.intervals,
confirmed,
).map(serializeRange),
).map(serialize),
asOf:
complete && targets.length
? new Date(
@@ -412,3 +541,7 @@ export class BrokerageOperationsService {
});
}
}
function serialize(range: { accountId: string; from: Date; to: Date }) {
return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() };
}
@@ -1,23 +0,0 @@
import { decode, encode } from './local-operations-cursor.codec';
const secret = 'secret';
const cursor = {
accountId: 'account',
at: '2026-01-01T00:00:00.000Z',
binding: 'owner-filter-binding',
id: 'operation',
movingEnd: false,
to: '2026-02-01T00:00:00.000Z',
};
describe('local operations cursor', () => {
it('roundtrips the opaque cursor, including its owner and filter binding', () => {
expect(decode(encode(cursor, secret), secret)).toEqual(cursor);
});
it('rejects a tampered cursor and a cursor signed with a different key', () => {
const encoded = encode(cursor, secret);
expect(() => decode(`${encoded}x`, secret)).toThrow();
expect(() => decode(encoded, 'different-secret')).toThrow();
});
});
@@ -1,72 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { OperationErrorCode } from '../domain/operation-error-code';
export type LocalOperationsCursor = {
accountId: string;
at: string;
binding: string;
id: string;
movingEnd?: boolean;
to: string;
};
// Opaque cursor: base64url body + HMAC-SHA256 signature over JWT_SECRET.
// Wire format and validation are part of the HTTP contract and must not change.
export function encode(value: LocalOperationsCursor, secret: string): string {
const body = Buffer.from(JSON.stringify(value)).toString('base64url');
return `${body}.${createHmac('sha256', secret).update(body).digest('base64url')}`;
}
export function decode(value: string, secret: string): LocalOperationsCursor {
try {
const [body, signature, extra] = value.split('.');
const expected = createHmac('sha256', secret).update(body).digest();
const actual = Buffer.from(signature ?? '', 'base64url');
if (
extra ||
actual.length !== expected.length ||
!timingSafeEqual(actual, expected)
) {
throw new Error();
}
const cursor = JSON.parse(
Buffer.from(body, 'base64url').toString(),
) as Partial<LocalOperationsCursor>;
if (
(cursor.movingEnd !== undefined &&
typeof cursor.movingEnd !== 'boolean') ||
!cursor.binding ||
!cursor.accountId ||
!cursor.id ||
!cursor.at ||
!cursor.to ||
!Number.isFinite(Date.parse(cursor.at)) ||
!Number.isFinite(Date.parse(cursor.to))
) {
throw new Error();
}
return cursor as LocalOperationsCursor;
} catch {
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
}
}
@Injectable()
export class LocalOperationsCursorCodec {
constructor(private readonly config: ConfigService) {}
encode(value: LocalOperationsCursor): string {
return encode(value, this.secret());
}
decode(value: string): LocalOperationsCursor {
return decode(value, this.secret());
}
private secret(): string {
return this.config.getOrThrow<string>('JWT_SECRET');
}
}
@@ -1,165 +0,0 @@
import { accountHistory } from '../../investment-sync-plan';
import { confirmedOperationRanges, type OperationCoverage,
type OperationInterval,
operationIntervalsOverlap,
operationsSyncPriority,
planOperationRead,
uncoveredOperationIntervals } from './operations-sync-plan';
const UNKNOWN_AS_OF_MS = 0;
type WorkState = {
errorCode: null | string;
id: string;
intervals: OperationInterval[];
priority: number;
state: string;
};
type CoverageAccount = { accountId: string; openedOn: null | string };
// Pure derivation of the ledger read view: what was requested, what is
// confirmed, what is missing, and which sync work is currently relevant.
export function deriveReadCoverage(input: {
accounts: CoverageAccount[];
connectionMembershipAt: Date | null;
coverage: OperationCoverage[];
from: Date;
instrumentUid?: string;
movingEnd: boolean;
now: Date;
to: Date;
work: WorkState[];
}) {
const targets = input.accounts.flatMap((account) =>
input.from < input.to
? [
{
accountId: account.accountId,
from: input.from,
to: input.to,
instrumentUid: input.instrumentUid,
},
]
: [],
);
const plan = planOperationRead(targets, input.coverage, input.now, {
movingEnd: input.movingEnd,
});
const confirmed = confirmedOperationRanges(plan.intervals, input.coverage);
const missing = uncoveredOperationIntervals(
plan.intervals,
confirmed.map((range) => ({
...range,
instrumentUid: input.instrumentUid,
})),
);
const available = Boolean(input.connectionMembershipAt);
const complete = available && !missing.length;
const relevant = input.work.filter((work) =>
work.intervals.some((interval) =>
targets.some((target) => operationIntervalsOverlap(interval, target)),
),
);
const work =
relevant.find((work) => work.state === 'running') ??
relevant.find((work) => work.state === 'failed') ??
null;
const sources = input.coverage.filter((row) =>
targets.some(
(target) =>
operationIntervalsOverlap(row, target) &&
(!row.instrumentUid || row.instrumentUid === target.instrumentUid),
),
);
const lastSuccessfulAt =
sources
.map((row) => row.fetchedAt)
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
(complete ? input.connectionMembershipAt : null) ??
null;
const history = planOperationRead(
accountHistory(input.accounts, input.now),
input.coverage,
input.now,
{ movingEnd: true },
);
const knownBoundary = input.accounts.every(
(account) => account.openedOn !== null,
);
return {
targets,
plan,
confirmed,
missing,
available,
complete,
work,
lastSuccessfulAt,
history,
knownBoundary,
};
}
export function coverageAsOf(
plan: ReturnType<typeof planOperationRead>,
accountIds: string[],
): null | string {
if (
accountIds.length &&
accountIds.every((accountId) =>
plan.intervals.some(
(interval) =>
interval.accountId === accountId && interval.asOf,
),
)
) {
return new Date(
Math.min(
...accountIds.map((accountId) =>
Math.max(
...plan.intervals
.filter((interval) => interval.accountId === accountId)
.map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS),
),
),
),
).toISOString();
}
return null;
}
export function historyState(
knownBoundary: boolean,
history: ReturnType<typeof planOperationRead>,
work: WorkState[],
coverageCount: number,
): 'complete' | 'failed' | 'loading' | 'missing' | 'partial' {
if (
work.some(
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
)
) {
return 'loading';
}
if (
work.some(
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
)
) {
return 'failed';
}
if (
knownBoundary &&
history.intervals.every(
(interval) => interval.availability === 'complete',
)
) {
return 'complete';
}
return coverageCount ? 'partial' : 'missing';
}
export function serializeRange(range: { accountId: string; from: Date; to: Date }) {
return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() };
}
@@ -1,67 +0,0 @@
import { type OperationInterval,
operationIntervalsOverlap,
operationsSyncPriority } from './operations-sync-plan';
export type OperationsIntent = {
atomic: boolean;
atomicIntervals?: string[];
intervals: string[];
screenIntervals?: string[];
};
export function intervalKey(interval: OperationInterval): string {
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
if (!(interval.from < interval.to)) {
throw new Error('Invalid operations interval');
}
return JSON.stringify([
interval.accountId,
interval.from.toISOString(),
interval.to.toISOString(),
interval.instrumentUid ?? '',
]);
}
export function decodeInterval(key: string): OperationInterval {
const [accountId, from, to, instrumentUid] = JSON.parse(key) as string[];
return {
accountId,
from: new Date(from),
to: new Date(to),
...(instrumentUid ? { instrumentUid } : {}),
};
}
export function extendRunningIntent(input: {
additions: OperationInterval[];
intervals: OperationInterval[];
pending: OperationInterval[];
previous: OperationsIntent | undefined;
previousPriority: number;
priority: number;
publicationAtomic: boolean;
}): OperationsIntent {
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
const overlapsRequested = (item: OperationInterval) =>
intervals.some((target) => operationIntervalsOverlap(item, target));
const retainedScreen = previous?.screenIntervals ??
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
const addedScreen = priority === operationsSyncPriority.screen
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
: [];
const ordered = priority < previousPriority
? [
...pending.filter(overlapsRequested),
...additions,
...pending.filter((item) => !overlapsRequested(item)),
]
: [...pending, ...additions];
return {
atomic: previous?.atomic ?? publicationAtomic,
atomicIntervals: previous?.atomic
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
: [],
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
intervals: ordered.map(intervalKey),
};
}
@@ -56,24 +56,6 @@ describe('mapBrokerageOperation', () => {
payment: null,
});
});
it('filters non-RUB money out of every money field at ingest', () => {
expect(
mapBrokerageOperation({
id: 'usd',
date: { seconds: '1' },
type: 'OPERATION_TYPE_DIVIDEND',
payment: { currency: 'usd', units: '21' },
price: { currency: 'USD', units: '10' },
commission: { currency: 'EUR', units: '1' },
}),
).toMatchObject({
id: 'usd',
payment: null,
price: null,
commission: null,
});
});
});
describe('executed trade quantity', () => {
@@ -1,8 +1,8 @@
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
import { rubMoney } from '#src/integrations/tbank/decimal';
import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
import { TbankOperationItem } from '#src/integrations/tbank/tbank.api';
import { formatDecimal } from '../../../../common/decimal';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
import { type OperationCategory } from '../../operation-category';
import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto';
@@ -55,7 +55,7 @@ export function mapBrokerageOperation(
ticker: text(operation.ticker),
instrumentType: text(operation.instrumentType),
logoName: null,
payment: rubMoney(operation.payment),
payment: money(operation.payment),
quantity:
(operationCategory(operation.type) === 'trade'
? operation.quantityDone
@@ -66,9 +66,9 @@ export function mapBrokerageOperation(
? operation.quantityDone
: operation.quantity,
),
price: rubMoney(operation.price),
accruedInterest: rubMoney(operation.accruedInt),
commission: rubMoney(operation.commission),
price: money(operation.price),
accruedInterest: money(operation.accruedInt),
commission: money(operation.commission),
};
}
@@ -76,6 +76,13 @@ function text(value: string | undefined): null | string {
return value?.trim() || null;
}
function money(value: TbankMoney | undefined) {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
export function operationTimestamp(
value: undefined | { nanos?: number; seconds?: number | string },
) {
@@ -1,193 +0,0 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '#src/prisma/prisma.service';
import {
dateOnlyBounds,
DateOnlyRange,
moscowDate,
} from '../../investment-calendar';
import { OperationErrorCode } from '../domain/operation-error-code';
import { decodeInterval } from '../domain/operations-interval-codec';
const CURSOR_LOOKAHEAD_ROWS = 1;
@Injectable()
export class BrokerageOperationsReadRepository {
constructor(private readonly prisma: PrismaService) {}
async readPage(input: {
accountId: string;
categories: string[];
cursor?: { accountId: string; at: string; binding: string; id: string };
filterKey: string;
from: Date;
instrumentUid?: string;
limit?: number;
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
revision: string;
to: Date;
userId: string;
}) {
return this.prisma.$transaction(async (tx) => {
const connection = await tx.tbankConnection.findUnique({
where: {
userId_revision: { userId: input.userId, revision: input.revision },
invalidToken: false,
},
select: {
membershipAt: true,
membershipRevision: true,
accounts: {
where:
input.accountId === 'all' ? {} : { accountId: input.accountId },
orderBy: { accountId: 'asc' },
select: {
accountId: true,
name: true,
openedOn: true,
operationsRevision: true,
},
},
},
});
if (!connection) return null;
if (
connection.membershipAt &&
input.accountId !== 'all' &&
!connection.accounts.length
) {
throw new NotFoundException();
}
const accounts = connection.accounts;
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
const oldest = accounts.map((account) => account.openedOn).sort()[0];
const openedAt =
oldest && accounts.every((account) => account.openedOn)
? new Date(`${oldest}T00:00:00+03:00`)
: undefined;
const periods = input.periods?.(openedAt).map((period) => {
const now = new Date();
const bounds = dateOnlyBounds(period, now);
return {
...bounds,
key: `${period.from}/${period.to}`,
movingEnd: period.to >= moscowDate(now),
};
});
const binding = createHash('sha256')
.update(
JSON.stringify([
input.userId,
input.revision,
input.accountId === 'all' ? connection.membershipRevision : null,
accounts.map((account) => [
account.accountId,
account.operationsRevision,
]),
input.filterKey,
]),
)
.digest('base64url');
if (input.cursor && input.cursor.binding !== binding) {
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
}
const scope = {
userId: input.userId,
revision: input.revision,
accountId: { in: accounts.map((account) => account.accountId) },
};
const coverage = await tx.tbankOperationCoverage.findMany({
where: scope,
});
const work = await tx.tbankSyncWork.findMany({
where: {
userId: input.userId,
revision: input.revision,
scopeKey: { startsWith: 'operations:' },
state: { in: ['running', 'failed'] },
},
select: {
id: true,
state: true,
errorCode: true,
intent: true,
priority: true,
},
});
const membershipWork = await tx.tbankSyncWork.findUnique({
where: {
userId_revision_scopeKey: {
userId: input.userId,
revision: input.revision,
scopeKey: 'membership',
},
},
select: { id: true, state: true, errorCode: true },
});
const after = input.cursor;
const rows = await tx.tbankOperation.findMany({
where: {
...scope,
...(periods
? {
OR: periods.map((period) => ({
occurredAt: { gte: period.from, lt: period.to },
})),
}
: { occurredAt: { gte: input.from, lt: input.to } }),
...(input.instrumentUid
? { instrumentUid: input.instrumentUid }
: {}),
...(input.categories.length
? { category: { in: input.categories } }
: {}),
...(after
? {
OR: [
{ occurredAt: { lt: new Date(after.at) } },
{
occurredAt: new Date(after.at),
accountId: { gt: after.accountId },
},
{
occurredAt: new Date(after.at),
accountId: after.accountId,
brokerOperationId: { gt: after.id },
},
],
}
: {}),
},
orderBy: [
{ occurredAt: 'desc' },
{ accountId: 'asc' },
{ brokerOperationId: 'asc' },
],
...(input.limit === undefined ? {} : { take: input.limit + CURSOR_LOOKAHEAD_ROWS }),
});
return {
connection,
periods,
membershipWork,
binding,
rows,
coverage: coverage.map(({ instrumentPredicate, ...row }) => ({
...row,
instrumentUid: instrumentPredicate || undefined,
})),
work: work.map(({ intent, ...row }) => ({
...row,
intervals: (intent as { intervals: string[] }).intervals.map(
decodeInterval,
),
})),
};
});
}
}
@@ -1,25 +1,27 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '#generated/prisma/client';
import type { Prisma } from '../../../../../generated/prisma/client';
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { isTbankProtocolValue, TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
import {
isJoinableRunningAttempt,
type TbankOperationItem, type TbankOperationsByCursorResponse,
} from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '#src/prisma/prisma.service';
import { toJson } from '#src/prisma/to-json';
} from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
decodeInterval,
extendRunningIntent,
intervalKey,
type OperationsIntent,
} from '../domain/operations-interval-codec';
dateOnlyBounds,
DateOnlyRange,
moscowDate,
} from '../../investment-calendar';
import { OperationErrorCode } from '../domain/operation-error-code';
import {
type OperationInterval,
operationIntervals,
@@ -32,13 +34,13 @@ import {
operationTimestamp,
} from './brokerage-operation.mapper';
const CURSOR_LOOKAHEAD_ROWS = 1;
const EMPTY_INTERVAL_COUNT = 0;
const SINGLE_WORK_GROUP = 1;
const INITIAL_PAGE_COUNT = 0;
const PAGE_INCREMENT = 1;
const EXPECTED_UPDATED_WORK_ITEMS = 1;
export type { OperationsIntent } from '../domain/operations-interval-codec';
export type { OperationInterval } from '../domain/operations-sync-plan';
export type OperationAttempt = {
@@ -47,6 +49,12 @@ export type OperationAttempt = {
revision: string;
userId: string;
};
type OperationsIntent = {
atomic: boolean;
atomicIntervals?: string[];
intervals: string[];
screenIntervals?: string[];
};
type PageProgress = {
complete: boolean;
cursor: string;
@@ -59,6 +67,176 @@ const progressItem = '';
export class BrokerageOperationsRepository {
constructor(private readonly prisma: PrismaService) {}
async readPage(input: {
accountId: string;
categories: string[];
cursor?: { accountId: string; at: string; binding: string; id: string };
filterKey: string;
from: Date;
instrumentUid?: string;
limit?: number;
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
revision: string;
to: Date;
userId: string;
}) {
return this.prisma.$transaction(async (tx) => {
const connection = await tx.tbankConnection.findUnique({
where: {
userId_revision: { userId: input.userId, revision: input.revision },
invalidToken: false,
},
select: {
membershipAt: true,
membershipRevision: true,
accounts: {
where:
input.accountId === 'all' ? {} : { accountId: input.accountId },
orderBy: { accountId: 'asc' },
select: {
accountId: true,
name: true,
openedOn: true,
operationsRevision: true,
},
},
},
});
if (!connection) return null;
if (
connection.membershipAt &&
input.accountId !== 'all' &&
!connection.accounts.length
) {
throw new NotFoundException();
}
const accounts = connection.accounts;
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
const oldest = accounts.map((account) => account.openedOn).sort()[0];
const openedAt =
oldest && accounts.every((account) => account.openedOn)
? new Date(`${oldest}T00:00:00+03:00`)
: undefined;
const periods = input.periods?.(openedAt).map((period) => {
const now = new Date();
const bounds = dateOnlyBounds(period, now);
return {
...bounds,
key: `${period.from}/${period.to}`,
movingEnd: period.to >= moscowDate(now),
};
});
const binding = createHash('sha256')
.update(
JSON.stringify([
input.userId,
input.revision,
input.accountId === 'all' ? connection.membershipRevision : null,
accounts.map((account) => [
account.accountId,
account.operationsRevision,
]),
input.filterKey,
]),
)
.digest('base64url');
if (input.cursor && input.cursor.binding !== binding) {
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
}
const scope = {
userId: input.userId,
revision: input.revision,
accountId: { in: accounts.map((account) => account.accountId) },
};
const coverage = await tx.tbankOperationCoverage.findMany({
where: scope,
});
const work = await tx.tbankSyncWork.findMany({
where: {
userId: input.userId,
revision: input.revision,
scopeKey: { startsWith: 'operations:' },
state: { in: ['running', 'failed'] },
},
select: {
id: true,
state: true,
errorCode: true,
intent: true,
priority: true,
},
});
const membershipWork = await tx.tbankSyncWork.findUnique({
where: {
userId_revision_scopeKey: {
userId: input.userId,
revision: input.revision,
scopeKey: 'membership',
},
},
select: { id: true, state: true, errorCode: true },
});
const after = input.cursor;
const rows = await tx.tbankOperation.findMany({
where: {
...scope,
...(periods
? {
OR: periods.map((period) => ({
occurredAt: { gte: period.from, lt: period.to },
})),
}
: { occurredAt: { gte: input.from, lt: input.to } }),
...(input.instrumentUid
? { instrumentUid: input.instrumentUid }
: {}),
...(input.categories.length
? { category: { in: input.categories } }
: {}),
...(after
? {
OR: [
{ occurredAt: { lt: new Date(after.at) } },
{
occurredAt: new Date(after.at),
accountId: { gt: after.accountId },
},
{
occurredAt: new Date(after.at),
accountId: after.accountId,
brokerOperationId: { gt: after.id },
},
],
}
: {}),
},
orderBy: [
{ occurredAt: 'desc' },
{ accountId: 'asc' },
{ brokerOperationId: 'asc' },
],
...(input.limit === undefined ? {} : { take: input.limit + CURSOR_LOOKAHEAD_ROWS }),
});
return {
connection,
periods,
membershipWork,
binding,
rows,
coverage: coverage.map(({ instrumentPredicate, ...row }) => ({
...row,
instrumentUid: instrumentPredicate || undefined,
})),
work: work.map(({ intent, ...row }) => ({
...row,
intervals: (intent as { intervals: string[] }).intervals.map(
decodeInterval,
),
})),
};
});
}
async coverage(userId: string, revision: string, accountIds: string[]) {
const rows = await this.prisma.tbankOperationCoverage.findMany({
where: {
@@ -471,7 +649,9 @@ export class BrokerageOperationsRepository {
) {
continue;
}
const payload = toJson({ ...operation, cursor: undefined });
const payload = JSON.parse(
JSON.stringify({ ...operation, cursor: undefined }),
) as Prisma.InputJsonObject;
// Provider pagination position is not part of a financial row.
const identity = { ...stage, itemKey: operation.id };
const previous = await tx.tbankSyncStage.findUnique({
@@ -695,6 +875,53 @@ export class BrokerageOperationsRepository {
}
}
function extendRunningIntent(input: {
additions: OperationInterval[];
intervals: OperationInterval[];
pending: OperationInterval[];
previous: OperationsIntent | undefined;
previousPriority: number;
priority: number;
publicationAtomic: boolean;
}): OperationsIntent {
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
const overlapsRequested = (item: OperationInterval) =>
intervals.some((target) => operationIntervalsOverlap(item, target));
const retainedScreen = previous?.screenIntervals ??
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
const addedScreen = priority === operationsSyncPriority.screen
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
: [];
const ordered = priority < previousPriority
? [
...pending.filter(overlapsRequested),
...additions,
...pending.filter((item) => !overlapsRequested(item)),
]
: [...pending, ...additions];
return {
atomic: previous?.atomic ?? publicationAtomic,
atomicIntervals: previous?.atomic
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
: [],
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
intervals: ordered.map(intervalKey),
};
}
function intervalKey(interval: OperationInterval): string {
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
if (!(interval.from < interval.to)) {
throw new Error('Invalid operations interval');
}
return JSON.stringify([
interval.accountId,
interval.from.toISOString(),
interval.to.toISOString(),
interval.instrumentUid ?? '',
]);
}
async function claim(
tx: Prisma.TransactionClient,
attempt: OperationAttempt,
@@ -702,3 +929,13 @@ async function claim(
const count = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
return count === EXPECTED_UPDATED_WORK_ITEMS;
}
function decodeInterval(key: string): OperationInterval {
const [accountId, from, to, instrumentUid] = JSON.parse(key) as string[];
return {
accountId,
from: new Date(from),
to: new Date(to),
...(instrumentUid ? { instrumentUid } : {}),
};
}
@@ -1,13 +1,10 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
import { BrokerageOperationsService } from './application/brokerage-operations.service';
import { LocalOperationsCursorCodec } from './application/local-operations-cursor.codec';
import { BrokerageOperationsReadRepository } from './infrastructure/brokerage-operations-read.repository';
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
@@ -17,8 +14,6 @@ import { BrokerageOperationsController } from './presentation/brokerage-operatio
providers: [
BrokerageOperationsService,
BrokerageOperationsRepository,
BrokerageOperationsReadRepository,
LocalOperationsCursorCodec,
BrokerageOperationsSyncService,
],
exports: [BrokerageOperationsService, BrokerageOperationsSyncService],
@@ -13,8 +13,7 @@ import {
Min,
} from 'class-validator';
import { MoneyDto } from '#src/common/money.dto';
import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto';
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { operationCategories, type OperationCategory } from '../../operation-category';
@@ -1,4 +1,4 @@
jest.mock('#src/prisma/prisma.service', () => ({
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));

Some files were not shown because too many files have changed in this diff Show More