10 Commits
Author SHA1 Message Date
ksv741 3cf883efbc feat(cards): support inclusive date range queries 2026-09-12 21:52:17 +03:00
ksv741 8c074d85de chore: save pending changes 2026-09-12 19:59:10 +03:00
ksv741 e6f58ef0ee feat: add manual bank card ledger
Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
2026-09-12 18:34:18 +03:00
ksv741 8ec172f48e fix: recover portfolio instrument metadata on backend
Bound metadata concurrency and waiting, retry transient lookups, and retain known metadata during failures. Isolate in-flight lookups by token and preserve financial observation time.
2026-09-12 15:37:56 +03:00
ksv741 b1906c8e3c docs: update routes to system documentation 2026-09-12 14:45:47 +03:00
ksv741 a79f59a468 feat(events): support date range filtering 2026-09-12 11:57:41 +03:00
ksv741 235c179ff8 feat: add bond details, yields and credit ratings 2026-09-10 22:30:46 +03:00
ksv741 f24fd98fbc feat: add stock details and ticker resolution
Expose stock search, market data, company information, dividends and portfolio history with exact instrument identity and verified ticker aliases. Include stock and bond position counts in account summaries.
2026-09-10 21:23:14 +03:00
ksv741 05747dc375 feat(portfolio): expose asset allocation by currency 2026-09-09 21:30:11 +03:00
ksv741 eaf2f67b34 feat: aggregate portfolio data across accounts
Add authenticated all-account portfolio, operations, income and event endpoints. Preserve source accounts, exact currency totals and scoped cursor pagination.
2026-09-09 20:36:31 +03:00
80 changed files with 9604 additions and 267 deletions
+6 -6
View File
@@ -3,12 +3,12 @@
| Что нужно установить | Владелец |
| --- | --- |
| Implementation decision и безопасный pattern | [CONVENTIONS](CONVENTIONS.md) |
| HTTP errors, DTO semantics, public wire shape | [docs: HTTP API](../../docs/AI_INDEX.md#http-api); runtime OpenAPI из Nest — единственный editable source |
| Auth/session, account access, token encryption | [docs: Security](../../docs/AI_INDEX.md#security) |
| Prisma schema/data ownership и migration context | [docs: Data](../../docs/AI_INDEX.md#data) |
| T-Bank/gRPC/cache, logging | [docs: Integrations](../../docs/AI_INDEX.md#integrations) |
| Money, account/position/income/cache meanings | [docs: Domain](../../docs/AI_INDEX.md#domain) |
| Поведение конкретной feature | [docs: Features](../../docs/AI_INDEX.md#features) |
| HTTP errors, DTO semantics, public wire shape | [docs: HTTP API](../../docs/architecture/http-api.md); runtime OpenAPI из Nest — единственный editable source |
| Auth/session, account access, token encryption | [Аутентификация](../../docs/domains/authentication/overview.md), [доступ к данным](../../docs/architecture/containers.md#хранимые-данные-и-границы-доступа), [подключение брокера](../../docs/integrations/tbank.md) |
| Prisma schema/data ownership и migration context | [docs: Data](../../docs/architecture/containers.md#хранимые-данные-и-границы-доступа) |
| T-Bank/gRPC/cache, logging | [T-Bank](../../docs/integrations/tbank.md), [свежесть данных](../../docs/architecture/cross-cutting/data-freshness.md), [наблюдаемость](../../docs/architecture/http-api.md#наблюдаемость) |
| Money, account/position/income/cache meanings | [docs: Domain](../../docs/glossary.md) |
| Поведение конкретной feature | [docs: Features](../../docs/README.md#предметные-области) |
| Существующие behavioral requirements при необходимости | [specs index](../../specs/AI_INDEX.md) |
| Versions/scripts и scope автоматических checks | package.json/lockfile, ESLint/TypeScript/Jest/Prisma configs |
+3 -3
View File
@@ -23,15 +23,15 @@ application checks. Предварительный lookup не заменяет
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
lock во время retry внешней сети. Access/cache generation semantics — у
[T-Bank source](../../../docs/features/tbank-connection.md#граница-финансового-кэша).
[T-Bank source](../../../docs/integrations/tbank.md#граница-финансового-кэша).
## Деньги и точность
Для денежных значений используй existing decimal helpers и строковый wire value,
не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
разные валюты не складываются без явно заданного conversion contract.
Источник: [Money](../../../docs/architecture/domain-glossary.md#money),
пример: [decimal.ts](../../src/tbank/decimal.ts). UI formatting не меняет
Источник: [Money](../../../docs/glossary.md#money),
пример: [decimal.ts](../../src/common/decimal.ts). UI formatting не меняет
backend ownership расчётов и доменную семантику.
## Schema и migration
+3 -3
View File
@@ -6,7 +6,7 @@ T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. С
получают нормализованный результат через эти clients. Frontend не вызывает
T-Bank и не получает credentials/proto client. Reference source —
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и
[connection behavior](../../../docs/features/tbank-connection.md).
[connection behavior](../../../docs/integrations/tbank.md).
Для нового account use case сначала используй TbankAccessService: access,
connection generation и invalid-token translation не дублируются в callers.
@@ -18,13 +18,13 @@ Existing client уже владеет bounded FIFO queue, rate limit, deadline
классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop
в service. Не отключай TLS verification ради окружения: используй документированный
CA mechanism. Точные параметры и ограничения принадлежат
[connection configuration](../../../docs/features/tbank-connection.md#конфигурация-и-ограничения).
[connection configuration](../../../docs/integrations/tbank.md#конфигурация-и-ограничения).
При query/cache change установи key, user/account/generation ownership,
freshness и поведение late response. Старый результат не может обслужить новую
connection generation. Client Cached Snapshot frontend отличается от backend
cache/stale policy; semantics конкретного use case читай через
[feature index](../../../docs/AI_INDEX.md#features).
[feature index](../../../docs/README.md#предметные-области).
Внешний auth failure и временная недоступность имеют разные public codes;
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
+1 -1
View File
@@ -37,7 +37,7 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
`npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` существует, но известный Prisma migration blocker описан в
[backend workflow](../../../docs/development/backend-workflow.md) и WL-P2-05
[ограничении сквозной проверки](../../../docs/development/verification.md#известное-ограничение-сквозной-проверки) и WL-P2-05
[registry](../../../docs/development/audit-remediation.md). Скрипт использует
фиксированную `/tmp/wealth-ledger-e2e.db`; не запускай его конкурентно как
независимые DB tests. Для isolation выбери отдельную test DB и runner configuration.
+2 -2
View File
@@ -10,7 +10,7 @@ account access/connection generation — TbankAccessService; token crypto —
TbankTokenService/TbankCrypto. Расширяй существующую boundary, не добавляй
второй JWT middleware, decrypt helper или refresh coordinator.
[Authentication](../../../docs/features/authentication.md) и
[Authentication](../../../docs/domains/authentication/overview.md) и
[ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md)
задают session semantics. Access хранится только в памяти frontend; refresh
передаётся httpOnly cookie. Source code конфигурации cookie —
@@ -32,7 +32,7 @@ cases для соответствующего изменения.
T-Bank credentials остаются server-side и хранятся encrypted. Используй
existing AES-256-GCM boundary с validated 32-byte key; не печатай plaintext,
шифротекст, cookie, Authorization или connection generation в logs/errors/API.
[Connection source](../../../docs/features/tbank-connection.md) определяет
[Connection source](../../../docs/integrations/tbank.md) определяет
cache invalidation при замене/deletion и ограничение key rotation.
Не передавай Prisma User/Session целиком наружу; выбирай public DTO. Пароль
+3 -3
View File
@@ -42,11 +42,11 @@ engine останавливает migration до запуска Jest. Точны
- [HTTP API](.ai/conventions/backend-http-api.md), [data](.ai/conventions/backend-data.md) и [testing](.ai/conventions/backend-testing.md)
- [Security/auth](.ai/conventions/security-auth.md) и [integrations](.ai/conventions/backend-integrations.md)
- [Backend workflow](../docs/development/backend-workflow.md)
- [Границы проверки API и данных](../docs/development/verification.md#api-и-хранение-данных)
- [Engineering governance и risk-based testing](../docs/development/engineering-governance.md)
- [Карта проектных фактов](../docs/AI_INDEX.md)
- [Карта проектных фактов](../docs/README.md)
- [API contract и runtime OpenAPI](../docs/development/runtime-openapi-verification.md)
- [Обзор поставленной системы](../docs/architecture/system-overview.md)
- [Обзор поставленной системы](../docs/overview.md)
- [Архитектурные решения](../docs/adr/README.md)
- [Remediation registry](../docs/development/audit-remediation.md)
+304
View File
@@ -22,6 +22,7 @@
"@prisma/client": "^7.9.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0",
"cheerio": "1.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
@@ -4852,6 +4853,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -5139,6 +5146,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/cheerio": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz",
"integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.0.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.12.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -5562,6 +5611,34 @@
"node": ">= 8"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5896,6 +5973,61 @@
"node": ">=0.3.1"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
@@ -6034,6 +6166,31 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -6057,6 +6214,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
@@ -7264,6 +7433,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -9386,6 +9586,18 @@
"node": ">=8"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -9583,6 +9795,55 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -11788,6 +12049,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -12166,6 +12436,40 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -42,6 +42,7 @@
"@prisma/client": "^7.9.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0",
"cheerio": "1.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
@@ -0,0 +1,69 @@
-- CreateTable
CREATE TABLE "BankCard" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"bankName" TEXT,
"lastFour" TEXT,
"openingBalanceKopecks" BIGINT NOT NULL,
"openingDate" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"archivedAt" DATETIME,
"createFingerprint" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "BankCard_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ExpenseCategory" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameKey" TEXT NOT NULL,
"seedKey" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
CONSTRAINT "ExpenseCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "BankCardOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"cardId" TEXT NOT NULL,
"targetCardId" TEXT,
"categoryId" TEXT,
"originalExpenseId" TEXT,
"amountKopecks" BIGINT NOT NULL,
"date" TEXT NOT NULL,
"description" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"deletedAt" DATETIME,
"createFingerprint" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "BankCardOperation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_targetCardId_fkey" FOREIGN KEY ("targetCardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ExpenseCategory" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_originalExpenseId_fkey" FOREIGN KEY ("originalExpenseId") REFERENCES "BankCardOperation" ("id") ON DELETE RESTRICT ON UPDATE NO ACTION
);
-- CreateIndex
CREATE INDEX "BankCard_userId_idx" ON "BankCard"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "ExpenseCategory_userId_nameKey_key" ON "ExpenseCategory"("userId", "nameKey");
-- CreateIndex
CREATE UNIQUE INDEX "ExpenseCategory_userId_seedKey_key" ON "ExpenseCategory"("userId", "seedKey");
-- CreateIndex
CREATE INDEX "BankCardOperation_userId_cardId_date_idx" ON "BankCardOperation"("userId", "cardId", "date");
-- CreateIndex
CREATE INDEX "BankCardOperation_userId_targetCardId_date_idx" ON "BankCardOperation"("userId", "targetCardId", "date");
-- CreateIndex
CREATE INDEX "BankCardOperation_originalExpenseId_idx" ON "BankCardOperation"("originalExpenseId");
@@ -0,0 +1 @@
ALTER TABLE "BankCard" ADD COLUMN "paymentSystem" TEXT CHECK ("paymentSystem" IN ('mir', 'visa', 'mastercard'));
+61
View File
@@ -12,6 +12,9 @@ model User {
id String @id
email String @unique
passwordHash String
bankCards BankCard[]
expenseCategories ExpenseCategory[]
bankCardOperations BankCardOperation[]
sessions Session[]
tbankConnection TbankConnection?
createdAt DateTime @default(now())
@@ -37,3 +40,61 @@ model Session {
@@index([userId])
}
model BankCard {
id String @id
userId String
name String
bankName String?
lastFour String?
paymentSystem String?
openingBalanceKopecks BigInt
openingDate String
version Int @default(1)
archivedAt DateTime?
createFingerprint String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
operations BankCardOperation[] @relation("SourceCard")
incomingOperations BankCardOperation[] @relation("TargetCard")
@@index([userId])
}
model ExpenseCategory {
id String @id
userId String
name String
nameKey String
seedKey String?
version Int @default(1)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
operations BankCardOperation[]
@@unique([userId, nameKey])
@@unique([userId, seedKey])
}
model BankCardOperation {
id String @id
userId String
kind String
cardId String
targetCardId String?
categoryId String?
originalExpenseId String?
amountKopecks BigInt
date String
description String?
version Int @default(1)
deletedAt DateTime?
createFingerprint String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
card BankCard @relation("SourceCard", fields: [cardId], references: [id], onDelete: Restrict)
targetCard BankCard? @relation("TargetCard", fields: [targetCardId], references: [id], onDelete: Restrict)
category ExpenseCategory? @relation(fields: [categoryId], references: [id], onDelete: Restrict)
originalExpense BankCardOperation? @relation("RefundExpense", fields: [originalExpenseId], references: [id], onDelete: Restrict, onUpdate: NoAction)
refunds BankCardOperation[] @relation("RefundExpense")
@@index([userId, cardId, date])
@@index([userId, targetCardId, date])
@@index([originalExpenseId])
}
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
import { LoggerModule } from 'nestjs-pino';
import { BankCardsModule } from './bank-cards/bank-cards.module';
import { AuthModule } from './auth/auth.module';
import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation';
@@ -49,6 +50,7 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
LoggingModule,
PrismaModule,
AuthModule,
BankCardsModule,
TbankConnectionModule,
],
})
@@ -0,0 +1,153 @@
import {
bankBalance,
bankSummary,
isCalendarDate,
moscowToday,
} from './bank-card.calculations';
describe('bank card calculations', () => {
it('uses Moscow midnight for today and the current month', () => {
expect(moscowToday(new Date('2026-08-31T20:59:59.999Z'))).toBe(
'2026-08-31',
);
expect(moscowToday(new Date('2026-08-31T21:00:00.000Z'))).toBe(
'2026-09-01',
);
});
it.each([
'0001-01-01',
'9999-12-31',
'2024-02-29',
'2000-02-29',
'2026-09-12',
])('accepts calendar date %s', (value) => {
expect(isCalendarDate(value)).toBe(true);
});
it.each([
'0000-01-01',
'10000-01-01',
'2026-02-29',
'1900-02-29',
'2026-04-31',
'2026-00-01',
'2026-13-01',
'2026-01-00',
'2026-01-32',
'2026-1-01',
'2026-09-12\n',
'2026-09-12T00:00:00Z',
'',
])('rejects invalid calendar date %s', (value) => {
expect(isCalendarDate(value)).toBe(false);
});
it('folds expenses, topups, refunds and both transfer sides exactly', () => {
const operations = [
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 10n },
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 20n },
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 15n },
{ kind: 'refund', cardId: 'a', targetCardId: null, amountKopecks: 5n },
{ kind: 'transfer', cardId: 'a', targetCardId: 'b', amountKopecks: 120n },
{ kind: 'expense', cardId: 'c', targetCardId: null, amountKopecks: 300n },
];
expect(bankBalance('a', 100n, operations.slice(0, 2))).toBe(70n);
expect(bankBalance('a', 100n, operations)).toBe(-30n);
expect(bankBalance('b', 0n, operations)).toBe(120n);
expect(bankBalance('unused', -1n, operations)).toBe(-1n);
});
it('does not bound computed balances by the storage range', () => {
expect(
bankBalance('a', 2n ** 63n - 1n, [
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 1n },
]),
).toBe(2n ** 63n);
expect(
bankBalance('a', -(2n ** 63n), [
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 1n },
]),
).toBe(-(2n ** 63n) - 1n);
});
it('counts refunds in their own month and inherited category, excluding transfers and topups', () => {
const operations = [
{
kind: 'expense',
date: '2026-08-31',
categoryId: 'food',
amountKopecks: 1000n,
},
{
kind: 'refund',
date: '2026-09-01',
categoryId: 'food',
amountKopecks: 500n,
},
{
kind: 'expense',
date: '2026-09-30',
categoryId: 'food',
amountKopecks: 100n,
},
{
kind: 'expense',
date: '2026-09-03',
categoryId: 'travel',
amountKopecks: 20n,
},
{
kind: 'transfer',
date: '2026-09-03',
categoryId: null,
amountKopecks: 2000n,
},
{
kind: 'topup',
date: '2026-09-03',
categoryId: null,
amountKopecks: 3000n,
},
{
kind: 'expense',
date: '2026-10-01',
categoryId: 'travel',
amountKopecks: 5000n,
},
];
expect(bankSummary('2026-09', operations)).toEqual({
expenses: 120n,
refunds: 500n,
net: -380n,
categories: [
{ categoryId: 'food', expenses: 100n, refunds: 500n, net: -400n },
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
],
});
expect(bankSummary('2026-07', operations)).toEqual({
expenses: 0n,
refunds: 0n,
net: 0n,
categories: [],
});
});
it('keeps summary sums beyond signed 64-bit exact', () => {
const operation = {
kind: 'expense',
date: '2026-09-01',
categoryId: 'food',
amountKopecks: 2n ** 63n - 1n,
};
const total = 2n * (2n ** 63n - 1n);
expect(bankSummary('2026-09', [operation, operation])).toEqual({
expenses: total,
refunds: 0n,
net: total,
categories: [
{ categoryId: 'food', expenses: total, refunds: 0n, net: total },
],
});
});
});
+91
View File
@@ -0,0 +1,91 @@
export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Moscow',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
}
export function isCalendarDate(value: string): boolean {
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
if (value.startsWith('0000')) return false;
const date = new Date(`${value}T00:00:00.000Z`);
return (
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
);
}
export function bankBalance(
cardId: string,
opening: bigint,
operations: Array<{
kind: string;
cardId: string;
targetCardId: string | null;
amountKopecks: bigint;
}>,
): bigint {
// ponytail: O(N) history fold; optimize only after measuring growing histories.
let balance = opening;
for (const operation of operations) {
if (operation.cardId === cardId) {
if (operation.kind === 'expense' || operation.kind === 'transfer')
balance -= operation.amountKopecks;
else if (operation.kind === 'topup' || operation.kind === 'refund')
balance += operation.amountKopecks;
}
if (operation.kind === 'transfer' && operation.targetCardId === cardId)
balance += operation.amountKopecks;
}
return balance;
}
export function bankSummary(
period: string | { from: string; to: string },
operations: Array<{
kind: string;
date: string;
amountKopecks: bigint;
categoryId: string | null;
}>,
) {
let expenses = 0n;
let refunds = 0n;
const categories = new Map<
string,
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
>();
// ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) {
if (
typeof period === 'string'
? operation.date.slice(0, 7) !== period
: operation.date < period.from || operation.date > period.to
)
continue;
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
expenses += expense;
refunds += refund;
if (operation.categoryId !== null) {
const category = categories.get(operation.categoryId) ?? {
categoryId: operation.categoryId,
expenses: 0n,
refunds: 0n,
net: 0n,
};
category.expenses += expense;
category.refunds += refund;
category.net = category.expenses - category.refunds;
categories.set(category.categoryId, category);
}
}
return {
expenses,
refunds,
net: expenses - refunds,
categories: [...categories.values()],
};
}
+259
View File
@@ -0,0 +1,259 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
IntersectionType,
PartialType,
} from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { MoneyDto } from '../common/money.dto';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const;
export const bankOperationKinds = [
'expense',
'topup',
'transfer',
'refund',
] as const;
export type BankOperationKind = (typeof bankOperationKinds)[number];
export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString()
@Matches(/^-?\d+(\.\d{1,2})?$/)
value: string;
}
export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
@ApiProperty({ minLength: 1, maxLength: 100 })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(100)
name: string;
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 })
@IsOptional()
@Transform(trim)
@IsString()
@MaxLength(100)
bankName?: string | null;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional()
@Matches(/^[0-9]{4}$/)
lastFour?: string | null;
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
@IsOptional()
@IsIn(bankPaymentSystems)
paymentSystem?: string | null;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
openingBalance: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
openingDate: string;
}
export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
@ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean()
archived?: boolean;
}
export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: 80 })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(80)
name: string;
}
export class UpdateExpenseCategoryDto extends CategoryNameDto {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
}
export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
@ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds)
kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() cardId: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
targetCardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
categoryId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
originalExpenseId?: string;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
amount: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
date: string;
@ApiPropertyOptional({ maxLength: 500 })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsString()
@MaxLength(500)
description?: string;
}
export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
}
export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] })
@IsOptional()
@IsIn(['true', 'false'])
includeArchived?: string;
}
export class BankPageQueryDto {
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
export class BankPeriodQueryDto {
@ApiPropertyOptional({
pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$',
description: 'Use month or both from and to, exclusively.',
})
@IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive start date; requires to.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
from?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive end date; requires from.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
to?: string;
}
export class BankOperationsQueryDto extends IntersectionType(
BankPageQueryDto,
BankPeriodQueryDto,
) {}
export class BankSummaryQueryDto extends BankPeriodQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cardId?: string;
}
export class BankVersionQueryDto {
@ApiProperty({ minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
version: number;
}
export class ExpenseCategoryDto {
@ApiProperty({ type: String }) id: string;
@ApiProperty({ type: String }) name: string;
@ApiProperty({ type: Boolean }) isDefault: boolean;
@ApiProperty({ type: Number }) version: number;
}
export class BankCardDto {
@ApiProperty({ type: String }) id: string;
@ApiProperty({ type: String }) name: string;
@ApiProperty({ type: String, nullable: true }) bankName: string | null;
@ApiProperty({ type: String, nullable: true }) lastFour: string | null;
@ApiProperty({ enum: bankPaymentSystems, nullable: true }) paymentSystem:
string | null;
@ApiProperty({ type: MoneyDto }) openingBalance: MoneyDto;
@ApiProperty({ type: String }) openingDate: string;
@ApiProperty({ type: MoneyDto }) balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) archivedAt: string | null;
@ApiProperty({ type: Number }) version: number;
}
export class BankOperationDto {
@ApiProperty({ type: String }) id: string;
@ApiProperty({ enum: bankOperationKinds }) kind: BankOperationKind;
@ApiProperty({ type: String }) cardId: string;
@ApiProperty({ type: String }) cardName: string;
@ApiProperty({ type: String, nullable: true }) targetCardId: string | null;
@ApiProperty({ type: String, nullable: true }) targetCardName: string | null;
@ApiProperty({ type: String, nullable: true }) categoryId: string | null;
@ApiProperty({ type: String, nullable: true }) categoryName: string | null;
@ApiProperty({ type: String, nullable: true }) originalExpenseId:
string | null;
@ApiProperty({ type: MoneyDto }) amount: MoneyDto;
@ApiProperty({ type: MoneyDto, nullable: true })
availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) date: string;
@ApiProperty({ type: String, nullable: true }) description: string | null;
@ApiProperty({ type: Number }) version: number;
@ApiProperty({ type: String }) createdAt: string;
}
export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true })
items: BankOperationDto[];
@ApiProperty({ type: Number }) page: number;
@ApiProperty({ type: Number }) total: number;
@ApiProperty({ type: Boolean }) hasNext: boolean;
}
export class BankSummaryCategoryDto {
@ApiProperty({ type: String }) categoryId: string;
@ApiProperty({ type: String }) categoryName: string;
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
}
export class BankSummaryDto {
@ApiPropertyOptional({ type: String }) month?: string;
@ApiPropertyOptional({ format: 'date' }) from?: string;
@ApiPropertyOptional({ format: 'date' }) to?: string;
@ApiProperty({ type: String, nullable: true }) cardId: string | null;
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
categories: BankSummaryCategoryDto[];
}
+175
View File
@@ -0,0 +1,175 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiTags,
ApiResponse,
getSchemaPath,
} from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
ApiProblemResponses,
ProblemDetailsDto,
} from '../common/decorators/api-safe-error-response.decorator';
import {
BankCardDto,
BankCardsQueryDto,
BankOperationDto,
BankOperationsPageDto,
BankOperationsQueryDto,
BankPageQueryDto,
BankSummaryDto,
BankSummaryQueryDto,
BankVersionQueryDto,
CategoryNameDto,
CreateBankCardDto,
CreateBankOperationDto,
ExpenseCategoryDto,
UpdateBankCardDto,
UpdateBankOperationDto,
UpdateExpenseCategoryDto,
} from './bank-card.dto';
import { BankCardsService } from './bank-cards.service';
type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({
status: 409,
description:
'BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT',
content: {
'application/problem+json': {
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
},
},
})
@ApiResponse({
status: 410,
description:
'BANK_OPERATION_DELETED: creation replay refers to a deleted operation',
content: {
'application/problem+json': {
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
},
},
})
@ApiTags('bank-cards')
@ApiBearerAuth()
@ApiProblemResponses()
@UseGuards(JwtAuthGuard)
@Controller('bank-cards')
export class BankCardsController {
constructor(private readonly cards: BankCardsService) {}
@Get()
@ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
return this.cards.list(req.user.sub, query.includeArchived === 'true');
}
@Post()
@ApiCreatedResponse({ type: BankCardDto })
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
return this.cards.createCard(req.user.sub, body);
}
@Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) {
return this.cards.categories(req.user.sub);
}
@Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.cards.createCategory(req.user.sub, body.name);
}
@Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto })
updateCategory(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateExpenseCategoryDto,
) {
return this.cards.updateCategory(req.user.sub, id, body.name, body.version);
}
@Get('summary')
@ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.cards.summary(req.user.sub, query);
}
@Post('operations')
@ApiCreatedResponse({ type: BankOperationDto })
createOperation(
@Req() req: AuthRequest,
@Body() body: CreateBankOperationDto,
) {
return this.cards.createOperation(req.user.sub, body);
}
@Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.getOperation(req.user.sub, id);
}
@Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
updateOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankOperationDto,
) {
return this.cards.updateOperation(req.user.sub, id, body);
}
@Delete('operations/:id')
@HttpCode(204)
@ApiNoContentResponse()
deleteOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankVersionQueryDto,
) {
return this.cards.deleteOperation(req.user.sub, id, query.version);
}
@Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto })
history(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankOperationsQueryDto,
) {
return this.cards.history(req.user.sub, id, query);
}
@Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto })
expenses(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankPageQueryDto,
) {
return this.cards.history(req.user.sub, id, query, true);
}
@Get(':id')
@ApiOkResponse({ type: BankCardDto })
get(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.get(req.user.sub, id);
}
@Patch(':id')
@ApiOkResponse({ type: BankCardDto })
update(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankCardDto,
) {
return this.cards.updateCard(req.user.sub, id, body);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { BankCardsController } from './bank-cards.controller';
import { BankCardsService } from './bank-cards.service';
@Module({ controllers: [BankCardsController], providers: [BankCardsService] })
export class BankCardsModule {}
+708
View File
@@ -0,0 +1,708 @@
import {
BadRequestException,
ConflictException,
GoneException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import {
Prisma,
BankCard,
BankCardOperation,
} from '../../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { parseRubMoney, rubMoney } from '../common/rub-money';
import {
bankBalance,
bankSummary,
isCalendarDate,
moscowToday,
} from './bank-card.calculations';
import {
bankPaymentSystems,
BankCardDto,
BankOperationDto,
BankOperationsQueryDto,
BankPageQueryDto,
BankPeriodQueryDto,
BankSummaryQueryDto,
CreateBankCardDto,
CreateBankOperationDto,
UpdateBankCardDto,
UpdateBankOperationDto,
} from './bank-card.dto';
type Tx = Prisma.TransactionClient;
const seeds = [
'Продукты',
'Кафе',
'Такси',
'Транспорт',
'Жильё',
'Здоровье',
'Покупки',
'Развлечения',
'Другое',
];
const fail = (code = 'BANK_INVALID_INPUT'): never => {
throw new BadRequestException({ code });
};
function periodBounds(query: BankPeriodQueryDto) {
if (query.month && !query.from && !query.to) {
const from = `${query.month}-01`;
if (!isCalendarDate(from)) return fail();
const end = new Date(`${from}T00:00:00Z`);
end.setUTCMonth(end.getUTCMonth() + 1, 0);
return { from, to: end.toISOString().slice(0, 10) };
}
if (
query.month ||
!query.from ||
!query.to ||
!isCalendarDate(query.from) ||
!isCalendarDate(query.to) ||
query.from > query.to
)
return fail();
return { from: query.from, to: query.to };
}
const conflict = (code = 'BANK_VERSION_CONFLICT'): never => {
throw new ConflictException({ code });
};
const missing = (): never => {
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
};
const fingerprint = (value: unknown) =>
createHash('sha256')
.update(
JSON.stringify(value, (_key, item: unknown) =>
typeof item === 'bigint' ? item.toString() : item,
),
)
.digest('hex');
function amount(value: Parameters<typeof parseRubMoney>[0], positive = false) {
try {
return parseRubMoney(value, positive);
} catch {
return fail('BANK_INVALID_MONEY');
}
}
function calendar(value: string) {
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
return value;
}
function currentDate(value: string) {
calendar(value);
if (value > moscowToday()) fail('BANK_INVALID_DATE');
}
function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
fail();
return value.trim();
}
function cardInput(dto: CreateBankCardDto) {
const bankName = dto.bankName?.trim() || null;
if (bankName && bankName.length > 100) fail();
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
if (
dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
)
fail();
return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, 100),
bankName,
lastFour: dto.lastFour || null,
openingBalanceKopecks: amount(dto.openingBalance),
openingDate: calendar(dto.openingDate),
};
}
function operationInput(dto: CreateBankOperationDto) {
if (
dto.description != null &&
(typeof dto.description !== 'string' || dto.description.length > 500)
)
fail();
return {
kind: dto.kind,
cardId: dto.cardId,
targetCardId: dto.targetCardId ?? null,
categoryId: dto.categoryId ?? null,
originalExpenseId: dto.originalExpenseId ?? null,
amountKopecks: amount(dto.amount, true),
date: calendar(dto.date),
description: dto.description || null,
};
}
@Injectable()
export class BankCardsService {
constructor(private readonly prisma: PrismaService) {}
private async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await this.prisma.$transaction(run, {
maxWait: 10000,
timeout: 15000,
});
} catch (error) {
const code =
error instanceof Prisma.PrismaClientKnownRequestError
? error.code
: '';
const busy =
code === 'P2034' ||
code === 'P2028' ||
(error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < 2) continue;
if (busy) conflict('BANK_WRITE_CONFLICT');
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
throw error;
}
}
}
private async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing();
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
return card;
}
private async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing();
return row;
}
private async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) {
const seedKey = index === 8 ? 'other' : String(index);
if (
!(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } },
}))
)
await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
seedKey,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
}
}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
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,
bankName: card.bankName,
lastFour: card.lastFour,
paymentSystem: card.paymentSystem,
openingDate: card.openingDate,
openingBalance: rubMoney(card.openingBalanceKopecks),
balance: rubMoney(bankBalance(card.id, card.openingBalanceKopecks, rows)),
version: card.version,
archivedAt: card.archivedAt?.toISOString() ?? null,
};
}
private async operationView(
tx: Tx,
row: BankCardOperation,
): Promise<BankOperationDto> {
const card = await this.card(tx, row.userId, row.cardId);
const target = row.targetCardId
? await this.card(tx, row.userId, row.targetCardId)
: null;
const source = row.originalExpenseId
? await this.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 === '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 === 'expense' || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
}
list(userId: string, includeArchived = false) {
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.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.card(tx, userId, id)),
);
}
createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto),
hash = fingerprint(input);
return this.write(async (tx) => {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
return this.cardView(tx, existing);
}
currentDate(input.openingDate);
await this.seed(tx, userId);
return this.cardView(
tx,
await tx.bankCard.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.write(async (tx) => {
const previous = await this.card(tx, userId, id);
if (previous.version !== dto.version) conflict();
const input = cardInput({
id,
name: dto.name ?? previous.name,
bankName:
dto.bankName === undefined
? (previous.bankName ?? undefined)
: (dto.bankName ?? undefined),
lastFour:
dto.lastFour === undefined
? (previous.lastFour ?? undefined)
: (dto.lastFour ?? undefined),
paymentSystem:
dto.paymentSystem === undefined
? previous.paymentSystem
: dto.paymentSystem,
openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate,
});
const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) conflict('BANK_CARD_ARCHIVED');
currentDate(input.openingDate);
if (
await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
)
fail('BANK_OPENING_DATE_CONFLICT');
const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version },
data: {
...input,
paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 },
...(dto.archived === undefined
? {}
: {
archivedAt: dto.archived
? (previous.archivedAt ?? new Date())
: null,
}),
},
});
if (changed.count !== 1) conflict();
return this.cardView(tx, await this.card(tx, userId, 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, value: string) {
const label = name(value, 80);
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, value: string, version: number) {
const label = name(value, 80);
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 !== 1
)
conflict();
return {
id,
name: label,
isDefault: row!.seedKey === 'other',
version: version + 1,
};
});
}
private async validateOperation(
tx: Tx,
userId: string,
input: ReturnType<typeof operationInput>,
old?: BankCardOperation,
) {
const cards = new Set(
[input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter(
(id): id is string => Boolean(id),
),
);
currentDate(input.date);
for (const id of cards) {
const card = await this.card(tx, userId, id, true);
if (
(id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate
)
fail('BANK_INVALID_DATE');
}
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
)
fail('BANK_IMMUTABLE_OPERATION');
if (input.kind === 'transfer') {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
)
fail();
} else if (input.targetCardId) fail();
if (input.kind === 'expense') {
if (input.originalExpenseId) fail();
if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing();
input.categoryId = category!.id;
}
if (
!(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId },
}))
)
missing();
} else if (input.categoryId) fail();
if (input.kind !== 'refund' && input.originalExpenseId) fail();
if (input.kind === 'refund') {
if (!input.originalExpenseId) fail();
const source = await this.operation(tx, userId, input.originalExpenseId!);
if (
source.deletedAt ||
source.kind !== 'expense' ||
source.cardId !== input.cardId
)
fail('BANK_INVALID_REFUND');
if (
(
await tx.bankCardOperation.updateMany({
where: {
id: source.id,
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== 1
)
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('BANK_INVALID_DATE');
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks >
source.amountKopecks
)
conflict('BANK_REFUND_LIMIT');
}
if (old?.kind === 'expense') {
const refunds = await tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if (
refunds.length &&
(input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks)
)
conflict('BANK_DEPENDENT_REFUNDS');
}
}
createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto),
hash = fingerprint(normalized);
return this.write(async (tx) => {
const existing = await tx.bankCardOperation.findUnique({
where: { id: dto.id },
});
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
if (existing.deletedAt)
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
return this.operationView(tx, existing);
}
const input = { ...normalized };
await this.validateOperation(tx, userId, input);
return this.operationView(
tx,
await tx.bankCardOperation.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => {
const row = await this.operation(tx, userId, id);
if (row.deletedAt) missing();
return this.operationView(tx, row);
});
}
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.write(async (tx) => {
const old = await this.operation(tx, userId, id);
if (old.deletedAt) missing();
if (old.version !== dto.version) conflict();
const input = operationInput({
id,
kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']),
cardId: dto.cardId ?? old.cardId,
targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined,
categoryId: dto.categoryId ?? old.categoryId ?? undefined,
originalExpenseId:
dto.originalExpenseId ?? old.originalExpenseId ?? undefined,
amount: dto.amount ?? rubMoney(old.amountKopecks),
date: dto.date ?? old.date,
description: dto.description ?? old.description ?? undefined,
});
await this.validateOperation(tx, userId, input, old);
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } },
})
).count !== 1
)
conflict();
return this.operationView(tx, await this.operation(tx, userId, id));
});
}
deleteOperation(userId: string, id: string, version: number) {
return this.write(async (tx) => {
const row = await this.operation(tx, userId, id);
if (row.deletedAt) return;
if (row.version !== version) conflict();
await this.card(tx, userId, row.cardId, true);
if (row.targetCardId) await this.card(tx, userId, row.targetCardId, true);
if (
row.kind === 'expense' &&
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
)
conflict('BANK_DEPENDENT_REFUNDS');
if (row.originalExpenseId) {
const source = await this.operation(tx, userId, row.originalExpenseId);
if (
(
await tx.bankCardOperation.updateMany({
where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } },
})
).count !== 1
)
conflict();
}
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== 1
)
conflict();
});
}
history(
userId: string,
id: string,
query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false,
) {
const period = expenses
? undefined
: periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => {
await this.card(tx, userId, id);
const page = query.page ?? 1,
limit = query.limit ?? 50;
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(expenses
? { kind: 'expense' }
: { date: { gte: period!.from, lte: period!.to } }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limit,
take: limit,
});
return {
items: await Promise.all(
rows.map((row) => this.operationView(tx, row)),
),
total,
page,
hasNext: page * limit < total,
};
});
}
summary(userId: string, query: BankSummaryQueryDto) {
const period = periodBounds(query);
return this.prisma.$transaction(async (tx) => {
if (query.cardId) await this.card(tx, userId, query.cardId);
const rows = await tx.bankCardOperation.findMany({
where: {
userId,
deletedAt: null,
date: { gte: period.from, lte: period.to },
kind: { in: ['expense', 'refund'] },
...(query.cardId ? { cardId: query.cardId } : {}),
},
include: { originalExpense: { select: { categoryId: true } } },
});
const totals = bankSummary(
period,
rows.map((row) => ({
...row,
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
})),
);
const categories = await tx.expenseCategory.findMany({
where: { userId },
});
return {
month: query.month,
...period,
cardId: query.cardId ?? null,
expenses: rubMoney(totals.expenses),
refunds: rubMoney(totals.refunds),
net: rubMoney(totals.net),
categories: totals.categories.map((row) => ({
categoryId: row.categoryId,
categoryName: categories.find((c) => c.id === row.categoryId)!.name,
expenses: rubMoney(row.expenses),
refunds: rubMoney(row.refunds),
net: rubMoney(row.net),
})),
};
});
}
}
+62
View File
@@ -0,0 +1,62 @@
export type Decimal = { value: bigint; scale: number };
export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
throw new Error('Invalid decimal string');
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}
export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale);
return {
value:
left.value * 10n ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale),
scale,
};
}
export function subtract(left: Decimal, right: Decimal): Decimal {
return add(left, { value: -right.value, scale: right.scale });
}
export function multiply(left: Decimal, right: Decimal): Decimal {
return { value: left.value * right.value, scale: left.scale + right.scale };
}
export function isZero(value: Decimal): boolean {
return value.value === 0n;
}
export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
}
export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0';
const sign = value < 0n ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
const digits = (value < 0n ? -value : value)
.toString()
.padStart(scale + 1, '0');
const whole = digits.slice(0, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, '');
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
}
// Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
const n = numerator < 0n ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale };
}
+9
View File
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class MoneyDto {
@ApiProperty()
currency: string;
@ApiProperty()
value: string;
}
+57
View File
@@ -0,0 +1,57 @@
import { parseRubMoney, rubMoney } from './rub-money';
describe('RUB money', () => {
it.each([
['0', 0n],
['-100', -10000n],
['0.01', 1n],
['123.4', 12340n],
['90071992547409.93', 9007199254740993n],
['92233720368547758.07', 2n ** 63n - 1n],
['-92233720368547758.08', -(2n ** 63n)],
])('roundtrips %s exactly', (value, kopecks) => {
expect(parseRubMoney({ currency: 'RUB', value })).toBe(kopecks);
expect(rubMoney(kopecks)).toEqual({ currency: 'RUB', value });
});
it.each([
'92233720368547758.08',
'-92233720368547758.09',
'1.001',
'1.000',
'NaN',
'Infinity',
'1e2',
'1,23',
'',
' 1',
'1\n',
'01',
])('rejects invalid input %s without rounding', (value) => {
expect(() => parseRubMoney({ currency: 'RUB', value })).toThrow();
});
it('rejects other currencies and non-string values', () => {
expect(() => parseRubMoney({ currency: 'USD', value: '1' })).toThrow();
expect(() =>
parseRubMoney({ currency: 'RUB', value: 1 as unknown as string }),
).toThrow();
});
it('requires positive operation amounts, including at the maximum', () => {
for (const value of ['0', '-0.00', '-0.01'])
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
expect(
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
).toBe(2n ** 63n - 1n);
});
it('formats computed sums beyond the storage range without losing precision', () => {
expect(rubMoney(2n * (2n ** 63n - 1n))).toEqual({
currency: 'RUB',
value: '184467440737095516.14',
});
expect(rubMoney(-1n)).toEqual({ currency: 'RUB', value: '-0.01' });
});
});
+27
View File
@@ -0,0 +1,27 @@
import { decimalFromString, formatDecimal } from './decimal';
import { MoneyDto } from './money.dto';
export function parseRubMoney(money: MoneyDto, positive = false): bigint {
if (
money?.currency !== 'RUB' ||
typeof money.value !== 'string' ||
money.value !== money.value.trim()
)
throw new TypeError('Expected RUB money with a decimal string');
const amount = decimalFromString(money.value);
if (amount.scale > 2)
throw new RangeError('RUB money supports at most two decimal places');
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n)
throw new RangeError('RUB money is outside the signed 64-bit range');
if (positive && kopecks <= 0n)
throw new RangeError('Operation amount must be positive');
return kopecks;
}
export function rubMoney(kopecks: bigint): MoneyDto {
return {
currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }),
};
}
+10
View File
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
/** Source account of a row in an all-accounts response. */
export class AccountReferenceDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
}
+422
View File
@@ -0,0 +1,422 @@
import {
BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('./tbank-access.service', () => ({ TbankAccessService: class {} }));
import { BrokerageOperationsService } from './brokerage-operations.service';
import { IncomeAnalyticsService } from './income-analytics.service';
import { PortfolioService } from './portfolio.service';
import { UpcomingEventsService } from './upcoming-events.service';
import type { TbankConnectionContext } from './tbank-access.service';
function setup() {
const context = {
token: 'token',
generation: 'first',
accounts: [
{ id: 'a', name: 'Основной', openedOn: '2020-01-01' },
{ id: 'b', name: 'ИИС', openedOn: '2026-01-01' },
],
};
const access = {
withSupportedAccounts: jest.fn(
(_user: string, handler: (value: typeof context) => unknown) =>
handler(context),
),
withAccountForConnection: jest.fn(
(
_connection: TbankConnectionContext,
_id: string,
handler: () => unknown,
) => handler(),
),
};
const tbank = {
getOperationsByCursor: jest.fn(),
getPortfolio: jest.fn().mockResolvedValue({ positions: [] }),
getPositions: jest.fn().mockResolvedValue({ money: [] }),
getDividends: jest.fn().mockResolvedValue([]),
getInstrumentByFigi: jest.fn().mockResolvedValue({ name: 'Облигация' }),
};
const metadata = { resolve: jest.fn().mockResolvedValue(null) };
const requestContext = { set: jest.fn() };
const operations = new BrokerageOperationsService(
access as never,
tbank as never,
requestContext as never,
metadata as never,
);
const portfolio = new PortfolioService(
access as never,
tbank as never,
requestContext as never,
metadata as never,
);
const moex = { bondEvents: jest.fn().mockResolvedValue([]) };
const events = new UpcomingEventsService(
access as never,
tbank as never,
moex as never,
requestContext as never,
{ info: jest.fn(), event: jest.fn() } as never,
);
return {
context,
access,
tbank,
operations,
portfolio,
events,
moex,
income: new IncomeAnalyticsService(operations),
};
}
const operation = (
id: string,
second: number,
type = 'OPERATION_TYPE_COUPON',
value = 1,
) => ({
id,
date: { seconds: String(second) },
type,
payment: { currency: 'RUB', units: value },
});
const query = { from: '2025-01-01', to: '2026-12-31', limit: 2 };
describe('All accounts', () => {
it('does not truncate instrument history when an account opening date is missing', async () => {
const { context, operations, tbank } = setup();
Object.assign(context.accounts[0], { openedOn: null });
tbank.getOperationsByCursor.mockResolvedValue({ items: [] });
await operations.getAll('user', { instrumentUid: 'common' });
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
from: { seconds: '0' },
instrumentId: 'common',
}),
);
});
it('filters upstream by UID, keeps sold-position history and binds continuations to the instrument', async () => {
const { operations, tbank } = setup();
tbank.getOperationsByCursor.mockImplementation(
(
_token: string,
request: { instrumentId: string; accountId: string },
) => ({
items: [
{
...operation(
request.accountId + '-dividend',
9,
'OPERATION_TYPE_DIVIDEND',
),
instrumentUid: request.instrumentId,
},
{
...operation('wrong-paper', 8, 'OPERATION_TYPE_DIVIDEND'),
instrumentUid: 'preferred',
},
],
}),
);
const first = await operations.getAll('user', {
instrumentUid: 'common',
limit: 1,
});
expect(first.items).toHaveLength(1);
expect(first.items[0].instrumentUid).toBe('common');
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
'token',
expect.objectContaining({
instrumentId: 'common',
from: { seconds: '1577836800' },
}),
);
await expect(
operations.getAll('user', {
instrumentUid: 'preferred',
limit: 1,
cursor: first.nextCursor!,
}),
).rejects.toBeInstanceOf(BadRequestException);
const second = await operations.getAll('user', {
instrumentUid: 'common',
limit: 1,
cursor: first.nextCursor!,
});
expect(second.items[0].instrumentUid).toBe('common');
expect(second.items[0].id).not.toBe(first.items[0].id);
expect(second.hasNext).toBe(false);
});
afterEach(() => jest.useRealTimers());
it('merges exact amounts by currency and keeps equal instruments on different accounts', async () => {
const { portfolio, tbank } = setup();
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'SAME',
instrumentType: 'share',
quantity: { units: 1 },
currentPrice: { currency: 'RUB', nano: 100000000 },
},
],
});
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }],
}));
const result = await portfolio.getAll('user');
expect(result.structure.currencies).toMatchObject([
{
currency: 'RUB',
totalValue: '0.4',
categories: [
{ type: 'STOCK', value: '0.2', sharePercent: '50' },
{ type: 'BOND', value: '0' },
{ type: 'OTHER', value: '0' },
{ type: 'CASH', value: '0.2', sharePercent: '50' },
],
},
{ currency: 'USD', totalValue: '0.2' },
]);
expect(result.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '0.4' },
{ currency: 'USD', value: '0.2' },
]);
expect(result.cashBalances).toEqual([
{ currency: 'RUB', value: '0.2' },
{ currency: 'USD', value: '0.2' },
]);
expect(result.positions.map((position) => position.account?.id)).toEqual([
'a',
'b',
]);
});
it('rejects missing access and a partial portfolio instead of publishing incomplete totals', async () => {
const { portfolio, access, tbank } = setup();
access.withSupportedAccounts.mockImplementationOnce(() => {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
});
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('marks an aggregate stale and uses its oldest snapshot timestamp', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-01T10:00:00Z'));
const { portfolio, tbank } = setup();
const first = await portfolio.getAll('user');
jest.setSystemTime(new Date('2026-09-01T10:01:00Z'));
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
const result = await portfolio.getAll('user');
expect(result.isStale).toBe(true);
expect(result.lastSuccessfulAt).toBe(first.lastSuccessfulAt);
});
it('paginates interleaved streams without dropping buffered rows, preserving duplicate IDs', async () => {
const { operations, tbank } = setup();
tbank.getOperationsByCursor.mockImplementation(
(
_token: string,
{ accountId, cursor }: { accountId: string; cursor?: string },
) => {
if (accountId === 'a')
return cursor
? { items: [operation('last', 3)] }
: {
items: [operation('same', 9), operation('a2', 5)],
hasNext: true,
nextCursor: 'a-next',
};
return { items: [operation('same', 8), operation('b2', 4)] };
},
);
const first = await operations.getAll('user', query);
const second = await operations.getAll('user', {
...query,
cursor: first.nextCursor!,
});
const repeated = await operations.getAll('user', {
...query,
cursor: first.nextCursor!,
});
const third = await operations.getAll('user', {
...query,
cursor: second.nextCursor!,
});
expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([
['same', 'a'],
['same', 'b'],
]);
expect(second.items.map((item) => item.id)).toEqual(['a2', 'b2']);
expect(repeated).toEqual(second);
expect(third.items.map((item) => item.id)).toEqual(['last']);
expect(third.hasNext).toBe(false);
});
it('continues through empty filtered pages and breaks ties by account', async () => {
const { operations, tbank } = setup();
tbank.getOperationsByCursor.mockImplementation(
(_token: string, { cursor }: { cursor?: string }) =>
cursor
? { items: [operation('income', 5)] }
: {
items: [operation('fee', 8, 'OPERATION_TYPE_BROKER_FEE')],
hasNext: true,
nextCursor: 'next',
},
);
const page = await operations.getAll('user', {
...query,
categories: ['income'],
});
expect(page.items.map((item) => item.account?.id)).toEqual(['a', 'b']);
expect(page.items.every((item) => item.category === 'income')).toBe(true);
});
it('rejects damaged cursor chains and partial streams', async () => {
const { operations, tbank } = setup();
tbank.getOperationsByCursor.mockResolvedValue({
items: [],
hasNext: true,
nextCursor: 'loop',
});
await expect(operations.getAll('user', query)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
tbank.getOperationsByCursor.mockRejectedValue(
new ServiceUnavailableException(),
);
await expect(
operations.getAll('another-user', query),
).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('binds continuations to user, connection, accounts and filters', async () => {
const { operations, context, tbank } = setup();
tbank.getOperationsByCursor.mockResolvedValue({
items: [operation('first', 8), operation('last', 4)],
});
const first = await operations.getAll('user', query);
const next = { ...query, cursor: first.nextCursor! };
await expect(operations.getAll('other', next)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
operations.getAll('user', { ...next, categories: ['fee'] }),
).rejects.toBeInstanceOf(BadRequestException);
context.generation = 'replaced';
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
BadRequestException,
);
context.generation = 'first';
context.accounts.pop();
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
});
it('collects income across the same cohort and computes comparisons from all receipts', async () => {
const { income, tbank } = setup();
tbank.getOperationsByCursor.mockImplementation(
(
_token: string,
{ accountId, from }: { accountId: string; from: { seconds: string } },
) => {
const year = new Date(Number(from.seconds) * 1000).getUTCFullYear();
const second =
Date.parse(
year === 2025 ? '2025-08-15T12:00:00Z' : '2026-08-15T12:00:00Z',
) / 1000;
return {
items:
year === 2025 && accountId === 'b'
? []
: [
operation(
'same',
second,
'OPERATION_TYPE_COUPON',
year === 2025 ? 2 : 3,
),
],
};
},
);
const result = await income.analytics(
'user',
'all',
'2026-08-01',
'2026-08-31',
);
expect(result.totalsByCurrency[0].total.value).toBe('6');
expect(
result.yearComparison?.totalsByCurrency[0].compared.total.value,
).toBe('2');
expect(
result.yearComparison?.totalsByCurrency[0].difference.total.value,
).toBe('4');
expect(result.entries.map((entry) => entry.account?.id)).toEqual([
'a',
'b',
]);
expect(
new Set(result.entries.map((entry) => entry.instrumentKey)).size,
).toBe(2);
});
it('preserves separate future events and fails the section when one source fails', async () => {
const { events, tbank, moex } = setup();
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'BOND',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 2 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: today, value: '0.1', currency: 'RUB' },
]);
const result = await events.getAll('user');
expect(result.events.map((event) => event.account?.id)).toEqual(['a', 'b']);
expect(result.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '0.4' },
]);
tbank.getPortfolio
.mockResolvedValueOnce({ positions: [] })
.mockRejectedValueOnce(new ServiceUnavailableException());
await expect(events.getAll('user')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('returns valid empty results for an empty account set', async () => {
const { context, portfolio, operations, income, events, tbank } = setup();
context.accounts = [];
expect((await portfolio.getAll('user')).positions).toEqual([]);
expect((await operations.getAll('user', query)).hasNext).toBe(false);
expect((await income.summary('user', 'all')).totalsByCurrency).toEqual([]);
expect((await events.getAll('user')).events).toEqual([]);
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
});
+101
View File
@@ -0,0 +1,101 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { BondService } from './bond.service';
import {
BondCandlesDto,
BondMetricsDto,
BondCandlesQueryDto,
BondCouponsDto,
BondDto,
BondTickerParamsDto,
BondPositionDto,
BondQuoteDto,
BondSearchQueryDto,
BondSearchResultDto,
} from './bond.dto';
@ApiTags('bonds')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('bonds')
export class BondController {
constructor(private readonly bonds: BondService) {}
@Get(':uid/metrics')
@ApiOkResponse({ type: BondMetricsDto })
metrics(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.metrics(request.user.sub, uid);
}
@Get()
@ApiOkResponse({ type: [BondSearchResultDto] })
search(
@Req() request: Request & { user: AccessPayload },
@Query() query: BondSearchQueryDto,
) {
return this.bonds.search(request.user.sub, query.query);
}
@Get('by-ticker/:ticker')
@ApiOkResponse({ type: BondDto })
getByTicker(
@Req() request: Request & { user: AccessPayload },
@Param() params: BondTickerParamsDto,
) {
return this.bonds.getByTicker(request.user.sub, params.ticker);
}
@Get(':uid')
@ApiOkResponse({ type: BondDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.get(request.user.sub, uid);
}
@Get(':uid/quote')
@ApiOkResponse({ type: BondQuoteDto })
quote(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.quote(request.user.sub, uid);
}
@Get(':uid/candles')
@ApiOkResponse({ type: BondCandlesDto })
candles(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: BondCandlesQueryDto,
) {
return this.bonds.candles(request.user.sub, uid, query.period);
}
@Get(':uid/position')
@ApiOkResponse({ type: BondPositionDto })
position(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.position(request.user.sub, uid);
}
@Get(':uid/coupons')
@ApiOkResponse({ type: BondCouponsDto })
coupons(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.coupons(request.user.sub, uid);
}
}
+155
View File
@@ -0,0 +1,155 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../common/money.dto';
import {
StockSearchQueryDto,
StockTickerParamsDto,
StockCandlesQueryDto,
StockCandlesDto,
StockPositionDto,
} from './stock.dto';
export class BondSearchQueryDto extends StockSearchQueryDto {}
export class BondTickerParamsDto extends StockTickerParamsDto {}
export class BondCandlesQueryDto extends StockCandlesQueryDto {}
export class BondCandlesDto extends StockCandlesDto {}
export class BondPositionDto extends StockPositionDto {}
export class BondDto {
@ApiProperty({ format: 'uuid' }) instrumentUid: string;
@ApiProperty({
description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
})
slug: string;
@ApiProperty() name: string;
@ApiProperty({ type: String, nullable: true }) isin: string | null;
@ApiProperty({ type: String, nullable: true }) classCode: string | null;
@ApiProperty({ type: String, nullable: true }) realExchange: string | null;
@ApiProperty({ type: Boolean, nullable: true }) apiTradeAvailable:
boolean | null;
@ApiProperty({ type: String, nullable: true }) ticker: string | null;
@ApiProperty({ type: String, nullable: true }) logoName: string | null;
@ApiProperty({ type: String, nullable: true }) currency: string | null;
@ApiProperty({ type: String, nullable: true }) sector: string | null;
@ApiProperty({ type: Number, nullable: true }) lot: number | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
historyFrom: string | null;
@ApiProperty({ type: MoneyDto, nullable: true }) nominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
initialNominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) aci: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) maturityDate: string | null;
@ApiProperty({ type: String, nullable: true }) callDate: string | null;
@ApiProperty({ type: String, nullable: true }) bondType: string | null;
@ApiProperty({ type: String, nullable: true }) riskLevel: string | null;
@ApiProperty({ type: Number, nullable: true }) couponQuantityPerYear:
number | null;
@ApiProperty({ type: Boolean, nullable: true }) floatingCoupon:
boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) perpetual: boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) amortization: boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) subordinated: boolean | null;
}
export class BondSearchResultDto extends BondDto {
@ApiProperty({
type: String,
nullable: true,
description: 'Clean price in percent of nominal, not money.',
})
pricePercent: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt:
string | null;
}
export class BondQuoteDto {
@ApiProperty({ type: String, nullable: true }) pricePercent: string | null;
@ApiProperty({
type: MoneyDto,
nullable: true,
description:
'Indicative clean value at the current nominal; no FX conversion.',
})
cleanPrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) aci: MoneyDto | null;
@ApiProperty({
type: MoneyDto,
nullable: true,
description:
'Indicative clean value plus current accrued interest, excluding fees.',
})
dirtyPrice: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) changePercentagePoints:
string | null;
@ApiProperty({ type: String, nullable: true }) previousCloseDate:
string | null;
@ApiProperty({ type: String, nullable: true }) tradingStatus: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt:
string | null;
@ApiProperty({ type: [String] }) unavailable: string[];
@ApiProperty({ format: 'date-time' }) observedAt: string;
}
export class BondCouponDto {
@ApiProperty({ type: String, nullable: true }) number: string | null;
@ApiProperty({ type: String, nullable: true }) paymentDate: string | null;
@ApiProperty({ type: String, nullable: true }) recordDate: string | null;
@ApiProperty({ type: String, nullable: true }) couponType: string | null;
@ApiProperty({ type: Number, nullable: true }) periodDays: number | null;
@ApiProperty({ type: MoneyDto, nullable: true }) perBond: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
estimatedAmount: MoneyDto | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) timing:
'upcoming' | 'past' | 'undated';
}
export class BondCouponsDto {
@ApiProperty({ type: [BondCouponDto] }) coupons: BondCouponDto[];
@ApiProperty() from: string;
@ApiProperty() to: string;
@ApiProperty() observedAt: string;
@ApiProperty({ type: String, nullable: true }) positionAt: string | null;
@ApiProperty() estimateUnavailable: boolean;
@ApiProperty() positionIsStale: boolean;
}
export class BondYieldDto {
@ApiProperty({ description: 'MOEX effective annual yield in percent.' })
value: string;
@ApiProperty() pricePercent: string;
@ApiProperty({ format: 'date' }) date: string;
@ApiProperty({
type: String,
nullable: true,
description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.',
})
dateType: string | null;
@ApiProperty({ format: 'date-time' }) tradeAt: string;
}
export class BondCreditRatingDto {
@ApiProperty({ enum: ['NCR'] }) agency: 'NCR';
@ApiProperty({ type: String, nullable: true }) value: string | null;
@ApiProperty({ enum: ['active', 'withdrawn'] }) status:
'active' | 'withdrawn';
@ApiProperty({ format: 'date' }) date: string;
@ApiProperty({ format: 'uri' }) sourceUrl: string;
@ApiProperty({ format: 'date-time' }) observedAt: string;
}
export class BondMetricsDto {
@ApiProperty({ type: BondCreditRatingDto, nullable: true })
creditRating: BondCreditRatingDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) coupon: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date' }) couponDate:
string | null;
@ApiProperty({ type: String, nullable: true }) couponType: string | null;
@ApiProperty({ type: Number, nullable: true }) couponPeriodDays:
number | null;
@ApiProperty({
type: String,
nullable: true,
description:
'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.',
})
couponYieldPercent: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
couponYieldPriceAt: string | null;
@ApiProperty({ type: BondYieldDto, nullable: true })
effectiveYield: BondYieldDto | null;
@ApiProperty({ type: [String] }) unavailable: string[];
@ApiProperty({ format: 'date-time' }) observedAt: string;
}
+481
View File
@@ -0,0 +1,481 @@
jest.mock('./tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import {
ConflictException,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { BondService } from './bond.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
import { NcrRatingsClient } from './ncr-ratings.client';
import { MoexClient } from './moex.client';
import { PortfolioService } from './portfolio.service';
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
const money = (units: string, currency = 'rub') => ({ units, currency });
const cacheClock = performance;
const bond = {
uid: 'uid',
ticker: 'BOND',
isin: 'ISIN',
instrumentKind: 'INSTRUMENT_TYPE_BOND',
nominal: money('500'),
initialNominal: money('1000'),
aciValue: money('12'),
amortizationFlag: true,
first_1dayCandleDate: time('2024-01-01'),
};
function setup() {
let generation = 'a';
const tbank = {
findBonds: jest.fn().mockResolvedValue([bond]),
getBond: jest.fn().mockResolvedValue(bond),
getLastPrice: jest.fn().mockResolvedValue({
instrumentUid: 'uid',
price: { units: '98', nano: 125000000 },
time: time('2026-09-10T10:00:00Z'),
}),
getLastPrices: jest.fn().mockResolvedValue([]),
getTradingStatus: jest
.fn()
.mockResolvedValue('SECURITY_TRADING_STATUS_NORMAL_TRADING'),
getCandles: jest.fn().mockResolvedValue([
{ time: time('2026-09-09'), close: { units: '99' }, isComplete: true },
{ time: time('2026-09-10'), close: { units: '100' }, isComplete: true },
]),
getBondCoupons: jest.fn().mockResolvedValue([]),
};
const access = {
withConnection: jest.fn(
(user: string, handler: (context: unknown) => unknown) =>
handler({ token: user, generation }),
),
};
const portfolios = {
getAll: jest.fn().mockResolvedValue({
positions: [],
lastSuccessfulAt: '2026-09-10T00:00:00Z',
isStale: false,
}),
};
const ratings = { rating: jest.fn().mockResolvedValue(null) };
const moex = { bondYield: jest.fn().mockResolvedValue(null) };
const service = new BondService(
access as unknown as TbankAccessService,
tbank as unknown as TbankClient,
portfolios as unknown as PortfolioService,
moex as unknown as MoexClient,
ratings as unknown as NcrRatingsClient,
);
return {
service,
ratings,
moex,
tbank,
access,
portfolios,
changeGeneration: () => {
generation = 'b';
},
};
}
describe('bond market use case', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z'));
jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now());
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => {
const { service, tbank, moex } = setup();
tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' });
tbank.getBondCoupons.mockResolvedValue([
{
couponDate: time('2026-12-10'),
payOneBond: money('40'),
couponPeriod: 91,
couponType: 'COUPON_TYPE_VARIABLE',
},
{
couponDate: time('2026-10-10'),
payOneBond: money('30'),
couponPeriod: 90,
couponType: 'COUPON_TYPE_FIX',
},
{
couponDate: time('2026-09-01'),
payOneBond: money('90'),
couponPeriod: 90,
},
]);
const effectiveYield = {
value: '15.6262',
pricePercent: '53.384',
date: '2041-05-15',
dateType: 'MATDATE',
tradeAt: '2026-09-10T18:49:39Z',
};
moex.bondYield.mockResolvedValue(effectiveYield);
const [metrics] = await Promise.all([
service.metrics('user', 'uid'),
service.coupons('user', 'uid'),
service.quote('user', 'uid'),
]);
expect(metrics).toMatchObject({
coupon: { value: '30', currency: 'RUB' },
couponDate: '2026-10-10',
couponType: 'COUPON_TYPE_FIX',
couponPeriodDays: 90,
couponYieldPercent: '24.7983',
effectiveYield,
});
expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'ISIN', 'TQOB');
expect(tbank.getBond).toHaveBeenCalledTimes(1);
expect(tbank.getBondCoupons).toHaveBeenCalledTimes(1);
expect(tbank.getLastPrice).toHaveBeenCalledTimes(1);
});
it.each(['unknown', 'currency', 'period', 'zero-price'])(
'does not invent coupon yield with %s inputs',
async (scenario) => {
const { service, tbank } = setup();
tbank.getBondCoupons.mockResolvedValue([
{
couponDate: time('2026-10-10'),
payOneBond:
scenario === 'unknown'
? undefined
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
couponPeriod: scenario === 'period' ? 0 : 90,
},
{
couponDate: time('2026-12-10'),
payOneBond: money('50'),
couponPeriod: 90,
},
]);
if (scenario === 'zero-price')
tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } });
expect(await service.metrics('user', 'uid')).toMatchObject({
couponDate: '2026-10-10',
couponYieldPercent: null,
});
},
);
it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => {
const { service, tbank, moex } = setup();
tbank.getBond.mockResolvedValue({
...bond,
classCode: 'TQOB',
floatingCouponFlag: true,
riskLevel: 'RISK_LEVEL_LOW',
});
moex.bondYield.mockRejectedValue(new ServiceUnavailableException());
expect(await service.metrics('user', 'uid')).toMatchObject({
effectiveYield: null,
coupon: null,
couponType: 'COUPON_TYPE_FLOATING',
couponYieldPercent: null,
unavailable: ['yield'],
});
tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException());
await expect(service.metrics('other', 'uid')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('returns only the rating of the exact issue and isolates a registry outage', async () => {
const { service, ratings } = setup();
const creditRating = {
agency: 'NCR',
value: 'AAA.ru',
status: 'active',
date: '2026-04-17',
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
observedAt: '2026-09-10T10:00:00Z',
};
ratings.rating.mockResolvedValue(creditRating);
expect(await service.metrics('user', 'uid')).toMatchObject({
creditRating,
});
expect(ratings.rating).toHaveBeenCalledWith('ISIN');
ratings.rating.mockRejectedValue(new ServiceUnavailableException());
expect(await service.metrics('other', 'uid')).toMatchObject({
creditRating: null,
unavailable: ['rating'],
});
});
it('opens concurrent sections within one upstream metadata request budget', async () => {
const { service, tbank } = setup();
tbank.getBond
.mockReset()
.mockResolvedValueOnce(bond)
.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(
Promise.all([
service.getByTicker('user', 'BOND'),
service.quote('user', 'uid'),
service.candles('user', 'uid'),
service.coupons('user', 'uid'),
service.metrics('user', 'uid'),
]),
).resolves.toHaveLength(5);
expect(tbank.getBond).toHaveBeenCalledTimes(1);
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
});
it('searches thirty issues without a separate ticker lookup for each result', async () => {
const { service, tbank } = setup();
const issues = Array.from({ length: 30 }, (_, index) => ({
...bond,
uid: `uid-${index}`,
isin: `ISIN-${index}`,
ticker: `BOND-${index}`,
}));
tbank.findBonds.mockResolvedValue(issues);
tbank.getBond.mockImplementation((_token: string, uid: string) =>
Promise.resolve(issues.find((item) => item.uid === uid)),
);
expect(await service.search('user', 'BOND')).toHaveLength(30);
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
expect(tbank.getBond).toHaveBeenCalledTimes(30);
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
await service.getByTicker('user', 'BOND-0');
await Promise.all([
service.quote('user', 'uid-0'),
service.candles('user', 'uid-0'),
service.coupons('user', 'uid-0'),
]);
expect(tbank.getBond).toHaveBeenCalledTimes(30);
expect(tbank.findBonds).toHaveBeenCalledTimes(2);
});
it('refreshes current nominal and accrued interest after the metadata TTL', async () => {
const { service, tbank } = setup();
await service.get('user', 'uid');
await jest.advanceTimersByTimeAsync(30_001);
tbank.getBond.mockResolvedValue({
...bond,
nominal: money('400'),
aciValue: money('3'),
});
expect(await service.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
dirtyPrice: { value: '395.5' },
});
expect(tbank.getBond).toHaveBeenCalledTimes(2);
});
it('keeps late metadata responses within their original connection generation', async () => {
const { service, tbank, changeGeneration } = setup();
let resolveOld!: (value: typeof bond) => void;
tbank.getBond.mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
);
const oldQuote = service.quote('user', 'uid');
changeGeneration();
tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') });
expect(await service.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
});
resolveOld(bond);
await oldQuote;
expect(await service.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
});
expect(tbank.getBond).toHaveBeenCalledTimes(2);
});
it('keeps percent quotes separate from money and uses current amortized nominal', async () => {
const { service } = setup();
expect(await service.quote('user', 'uid')).toMatchObject({
pricePercent: '98.125',
cleanPrice: { value: '490.625', currency: 'RUB' },
aci: { value: '12', currency: 'RUB' },
dirtyPrice: { value: '502.625', currency: 'RUB' },
changePercentagePoints: '-0.875',
previousCloseDate: '2026-09-09',
});
});
it('does not invent dirty prices or mix currencies when accrued interest is absent or mismatched', async () => {
const { service, tbank } = setup();
tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined });
expect(await service.quote('user', 'uid')).toMatchObject({
aci: null,
dirtyPrice: null,
});
tbank.getBond.mockResolvedValueOnce({
...bond,
aciValue: money('12', 'usd'),
});
expect(await service.quote('other', 'uid')).toMatchObject({
dirtyPrice: null,
});
tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined });
expect(await service.quote('third', 'uid')).toMatchObject({
pricePercent: '98.125',
cleanPrice: null,
dirtyPrice: null,
});
});
it('preserves absent coupon flags and dates without reporting false or zero', async () => {
const { service } = setup();
expect(await service.get('user', 'uid')).toMatchObject({
floatingCoupon: null,
perpetual: null,
couponQuantityPerYear: null,
maturityDate: null,
nominal: { value: '500', currency: 'RUB' },
amortization: true,
});
});
it('keeps distinct issues, deduplicates ISIN listings, and prices the selected exact UID', async () => {
const { service, tbank } = setup();
const listings = [
{ ...bond, uid: 'off', apiTradeAvailableFlag: false },
{ ...bond, uid: 'uid', apiTradeAvailableFlag: true },
{ ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' },
{ ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
];
tbank.findBonds.mockResolvedValue(listings);
tbank.getBond.mockImplementation((_token: string, uid: string) =>
Promise.resolve(listings.find((b) => b.uid === uid)),
);
tbank.getLastPrices.mockResolvedValue([
{ instrumentUid: 'uid', price: { units: '99' } },
{ instrumentUid: 'off', price: { units: '1' } },
]);
const results = await service.search('user', 'BOND');
expect(results.map((x) => x.instrumentUid)).toEqual(['uid', 'second']);
expect(results[0]).toMatchObject({ pricePercent: '99', slug: 'uid' });
expect((await service.get('user', 'off')).slug).toBe('off');
});
it('rejects ambiguous tickers and wrong instrument identities', async () => {
const { service, tbank } = setup();
tbank.findBonds.mockResolvedValue([
bond,
{ ...bond, uid: 'other', isin: 'OTHER' },
]);
await expect(service.getByTicker('user', 'bond')).rejects.toBeInstanceOf(
ConflictException,
);
tbank.getBond.mockResolvedValue({ ...bond, uid: 'other' });
await expect(service.get('user', 'uid')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('preserves catalog results on enrichment outage but never suppresses auth failures', async () => {
const { service, tbank } = setup();
tbank.getBond.mockRejectedValue(new ServiceUnavailableException());
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
expect(await service.search('user', 'BOND')).toMatchObject([
{ instrumentUid: 'uid', pricePercent: null, nominal: { value: '500' } },
]);
tbank.getBond.mockRejectedValue(new UnauthorizedException());
await expect(service.search('other', 'BOND')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => {
const { service, tbank } = setup();
const history = await service.candles('user', 'uid', 'all');
expect(history.currency).toBeNull();
expect(history.candles.map((c) => c.close)).toEqual(['99', '100']);
for (const [, , from, to] of tbank.getCandles.mock.calls as [
string,
string,
Date,
Date,
][])
expect(to.valueOf() - from.valueOf()).toBeLessThanOrEqual(365 * 86400000);
tbank.getBond.mockResolvedValue({
...bond,
first_1dayCandleDate: undefined,
});
await expect(service.candles('other', 'uid', 'all')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('isolates caches by user and connection generation and retries failures', async () => {
const { service, tbank, changeGeneration } = setup();
await Promise.all([service.get('a', 'uid'), service.get('a', 'uid')]);
expect(tbank.getBond).toHaveBeenCalledTimes(1);
await service.get('b', 'uid');
changeGeneration();
await service.get('a', 'uid');
expect(tbank.getBond).toHaveBeenCalledTimes(3);
tbank.getBond.mockRejectedValueOnce(new ServiceUnavailableException());
await expect(service.get('c', 'uid')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
await expect(service.get('c', 'uid')).resolves.toMatchObject({
instrumentUid: 'uid',
});
});
it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => {
const { service, tbank, portfolios } = setup();
const holding = {
instrumentUid: 'uid',
type: 'BOND',
quantity: '2',
averagePurchasePrice: { value: '490', currency: 'RUB' },
currentValue: { value: '981.25', currency: 'RUB' },
absoluteReturn: { value: '1.25', currency: 'RUB' },
};
portfolios.getAll.mockResolvedValue({
positions: [
holding,
{ ...holding, instrumentUid: 'other', quantity: '99' },
{ ...holding, type: 'STOCK', quantity: '999' },
],
lastSuccessfulAt: '2026-09-10T00:00:00Z',
isStale: true,
});
tbank.getBondCoupons.mockResolvedValue([
{
couponNumber: '1',
couponDate: time('2026-10-01'),
fixDate: time('2026-09-29'),
payOneBond: money('10'),
},
{
couponNumber: '2',
couponDate: time('2026-10-01'),
fixDate: time('2026-09-09'),
payOneBond: money('10'),
},
{
couponNumber: '3',
couponDate: time('2026-10-01'),
payOneBond: money('10'),
},
{
couponNumber: '4',
couponDate: time('2026-10-01'),
fixDate: time('2026-09-29'),
},
{
couponNumber: '5',
couponDate: time('2025-10-01'),
payOneBond: money('10'),
},
]);
const result = await service.coupons('user', 'uid');
expect(
result.coupons.find((c) => c.number === '1')?.estimatedAmount,
).toEqual({ value: '20', currency: 'RUB' });
expect(
result.coupons
.filter((c) => c.number !== '1')
.every((c) => c.estimatedAmount === null),
).toBe(true);
expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull();
expect(result.positionIsStale).toBe(true);
expect(await service.position('user', 'uid')).toMatchObject({
quantity: '2',
currentValue: { value: '981.25' },
});
});
});
+587
View File
@@ -0,0 +1,587 @@
import {
timestamp,
moscowDay,
optional,
previousClose,
} from './instrument-market';
import {
Injectable,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import {
decimalFromString,
divide,
formatDecimal,
multiply,
subtract,
add,
} from '../common/decimal';
import { decimal } from './decimal';
import { NcrRatingsClient } from './ncr-ratings.client';
import { MoexClient } from './moex.client';
import { PortfolioService } from './portfolio.service';
import { instrumentPosition } from './instrument-position';
import {
TbankAccessService,
type TbankConnectionContext,
} from './tbank-access.service';
import { TbankClient, type TbankMoney } from './tbank.client';
import type { TbankBond } from './tbank-market.types';
import {
BondMetricsDto,
BondCandlesDto,
BondCandlesQueryDto,
BondDto,
BondPositionDto,
BondQuoteDto,
BondCouponsDto,
BondSearchResultDto,
} from './bond.dto';
const dayMs = 86_400_000;
@Injectable()
export class BondService {
private readonly cache = new LRUCache<string, Promise<object>>({
max: 300,
ttl: 30_000,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly portfolios: PortfolioService,
private readonly moex: MoexClient,
private readonly ratings: NcrRatingsClient,
) {}
search(userId: string, query: string): Promise<BondSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (connection) => {
const { token } = connection;
const found = await this.tbank.findBonds(token, query);
const bonds = selectBonds(found, query).slice(0, 30);
if (!bonds.length) return [];
// Batch prices and share metadata with detail sections. Resolve readable
// aliases only when an issue is opened, not for every search result.
const [details, prices] = await Promise.all([
Promise.all(
bonds.map(
async (bond) =>
(await optional(
() =>
this.bond(userId, connection, bond.uid!)
.then(toBond)
.catch((error: unknown) => {
if (error instanceof NotFoundException)
return toBond(bond);
throw error;
}),
'details',
[],
)) ?? toBond(bond),
),
),
optional(
() =>
this.tbank.getLastPrices(
token,
bonds.map((bond) => bond.uid!),
),
'prices',
[],
),
]);
return details.map((detail, index) => {
const short = toBond(bonds[index]);
const last = prices?.find(
(price) => price.instrumentUid === detail.instrumentUid,
);
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null,
priceAt: timestamp(last?.time),
};
});
});
}
get(userId: string, uid: string): Promise<BondDto> {
return this.cached(userId, `bond:${uid}`, async (connection) => {
const result = toBond(await this.bond(userId, connection, uid));
if (
!result.ticker ||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
)
return result;
try {
const match = await this.tickerMatch(userId, result.ticker);
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
} catch (error) {
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
if (
!(error instanceof NotFoundException) &&
!(error instanceof ConflictException) &&
!(error instanceof ServiceUnavailableException)
)
throw error;
}
return result;
});
}
async getByTicker(userId: string, ticker: string): Promise<BondDto> {
const match = await this.tickerMatch(userId, ticker);
return {
...(await this.get(userId, match.uid!)),
slug: ticker.trim().toUpperCase(),
};
}
private tickerMatch(userId: string, ticker: string): Promise<TbankBond> {
const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (connection) => {
const { token } = connection;
const found = await this.tbank.findBonds(token, normalized);
const matches = selectBonds(
found.filter(
(item) => item.ticker?.trim().toUpperCase() === normalized,
),
normalized,
);
if (!matches.length) throw new NotFoundException();
if (matches.length > 1)
throw new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' });
return matches[0];
});
}
quote(userId: string, uid: string): Promise<BondQuoteDto> {
return this.cached(userId, `quote:${uid}`, async (connection) => {
const { token } = connection;
const instrument = await this.bond(userId, connection, uid);
const unavailable: string[] = [];
const [last, tradingStatus] = await Promise.all([
optional(
() => this.tbank.getLastPrice(token, uid),
'price',
unavailable,
),
optional(
() => this.tbank.getTradingStatus(token, uid),
'status',
unavailable,
),
]);
const priceAt = timestamp(last?.time);
const pricePercent = last?.price
? formatDecimal(decimal(last.price))
: null;
const nominal = money(instrument.nominal);
const aci = money(instrument.aciValue);
const cleanPrice =
pricePercent !== null &&
nominal &&
decimalFromString(nominal.value).value > 0n
? {
currency: nominal.currency,
value: formatDecimal(
divide(
multiply(
decimalFromString(pricePercent),
decimalFromString(nominal.value),
),
decimalFromString('100'),
9,
),
),
}
: null;
const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency
? {
currency: cleanPrice.currency,
value: formatDecimal(
add(
decimalFromString(cleanPrice.value),
decimalFromString(aci.value),
),
),
}
: null;
const previous = priceAt
? await optional(
async () =>
previousClose(
await this.tbank.getCandles(
token,
uid,
new Date(Date.parse(priceAt) - 20 * dayMs),
new Date(priceAt),
),
priceAt,
),
'comparison',
unavailable,
)
: null;
return {
pricePercent,
cleanPrice,
aci,
dirtyPrice,
priceAt,
tradingStatus,
changePercentagePoints:
last?.price && previous?.close
? formatDecimal(
subtract(decimal(last.price), decimal(previous.close)),
)
: null,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
metrics(userId: string, uid: string): Promise<BondMetricsDto> {
return this.cached(userId, `metrics:${uid}`, async (connection) => {
const instrument = await this.bond(userId, connection, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, quote, effectiveYield, creditRating] = await Promise.all([
optional(
() => this.couponEvents(userId, connection, uid, from, to),
'coupons',
unavailable,
),
optional(() => this.quote(userId, uid), 'quote', unavailable),
instrument.ticker && instrument.isin && instrument.classCode
? optional(
() =>
this.moex.bondYield(
instrument.ticker!,
instrument.isin!,
instrument.classCode!,
),
'yield',
unavailable,
)
: null,
instrument.isin
? optional(
() => this.ratings.rating(instrument.isin!),
'rating',
unavailable,
)
: null,
]);
const next = events
?.filter((event) => {
const date = timestamp(event.couponDate)?.slice(0, 10);
return date && date >= today;
})
.sort(
(a, b) =>
Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds),
)[0];
const coupon = money(next?.payOneBond);
const period = next?.couponPeriod;
const couponPeriodDays =
period && Number.isInteger(period) && period > 0 ? period : null;
const clean = quote?.cleanPrice;
const couponYieldPercent =
coupon &&
couponPeriodDays &&
clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= 0n &&
decimalFromString(clean.value).value > 0n
? formatDecimal(
divide(
multiply(
decimalFromString(coupon.value),
decimalFromString('36500'),
),
multiply(
decimalFromString(String(couponPeriodDays)),
decimalFromString(clean.value),
),
4,
),
)
: null;
const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
? next.couponType
: instrument.floatingCouponFlag === true
? 'COUPON_TYPE_FLOATING'
: null;
return {
creditRating,
coupon,
couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null,
couponType,
couponPeriodDays,
couponYieldPercent,
couponYieldPriceAt:
couponYieldPercent !== null ? (quote?.priceAt ?? null) : null,
effectiveYield,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
private couponEvents(
userId: string,
connection: TbankConnectionContext,
uid: string,
from: string,
to: string,
) {
return this.cachedForConnection(
userId,
connection,
`coupon-events:${uid}:${from}:${to}`,
() => this.tbank.getBondCoupons(connection.token, uid, from, to),
);
}
coupons(userId: string, uid: string): Promise<BondCouponsDto> {
return this.cached(userId, `coupons:${uid}`, async (connection) => {
await this.bond(userId, connection, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.couponEvents(userId, connection, uid, from, to),
optional(() => this.position(userId, uid), 'position', unavailable),
]);
const quantity =
position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
const coupons = events
.map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
const perBond = money(event.payOneBond);
const timing = !paymentDate
? ('undated' as const)
: paymentDate >= today
? ('upcoming' as const)
: ('past' as const);
return {
number: event.couponNumber ?? null,
paymentDate,
recordDate,
perBond,
couponType: event.couponType ?? null,
periodDays: event.couponPeriod ?? null,
timing,
estimatedAmount:
quantity &&
perBond &&
timing === 'upcoming' &&
recordDate &&
recordDate >= today
? {
currency: perBond.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)),
),
}
: null,
};
})
.sort((a, b) =>
(a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''),
);
return {
coupons,
from,
to,
observedAt: new Date().toISOString(),
positionAt: quantity ? (position?.observedAt ?? null) : null,
estimateUnavailable: unavailable.length > 0,
positionIsStale: position?.isStale ?? false,
};
});
}
candles(
userId: string,
uid: string,
period: BondCandlesQueryDto['period'] = 'month',
): Promise<BondCandlesDto> {
return this.cached(
userId,
`candles:${uid}:${period}`,
async (connection) => {
const { token } = connection;
const bond = await this.bond(userId, connection, uid);
const historyFrom = timestamp(bond.first_1dayCandleDate);
const to = new Date();
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
);
if (period === 'all') {
if (!historyFrom)
throw new ServiceUnavailableException({
code: 'BOND_HISTORY_START_UNAVAILABLE',
});
from.setTime(Date.parse(historyFrom));
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
from.setTime(Date.parse(historyFrom));
const collected = new Map<string, BondCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf());
for (const candle of await this.tbank.getCandles(
token,
uid,
new Date(start),
new Date(end),
)) {
const time = timestamp(candle.time);
if (
time &&
candle.close &&
Date.parse(time) >= from.valueOf() - dayMs &&
Date.parse(time) <= to.valueOf()
)
collected.set(time, {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
});
}
start = end;
}
return {
candles: [...collected.values()].sort((a, b) =>
a.time.localeCompare(b.time),
),
currency: null,
historyFrom,
observedAt: new Date().toISOString(),
};
},
);
}
async position(userId: string, uid: string): Promise<BondPositionDto> {
return this.cached(userId, `position:${uid}`, async () =>
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
);
}
private bond(
userId: string,
connection: TbankConnectionContext,
uid: string,
): Promise<TbankBond> {
return this.cachedForConnection(
userId,
connection,
`metadata:${uid}`,
async () => {
const bond = await this.tbank.getBond(connection.token, uid);
if (!bond || bond.uid !== uid) throw new NotFoundException();
return bond;
},
);
}
private cached<T extends object>(
userId: string,
key: string,
operation: (connection: TbankConnectionContext) => Promise<T>,
): Promise<T> {
return this.access.withConnection(userId, (connection) =>
this.cachedForConnection(userId, connection, key, () =>
operation(connection),
),
);
}
private cachedForConnection<T extends object>(
userId: string,
{ generation }: TbankConnectionContext,
key: string,
operation: () => Promise<T>,
): Promise<T> {
const cacheKey = JSON.stringify([userId, generation, key]);
const cached = this.cache.get(cacheKey);
if (cached) return cached as Promise<T>;
const pending = operation();
this.cache.set(cacheKey, pending);
return pending.catch((error) => {
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
throw error;
});
}
}
function selectBonds(found: TbankBond[], query: string): TbankBond[] {
const seen = new Set<string>();
return found
.filter(
(bond) => bond.uid && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND',
)
.sort(
(a, b) =>
Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') ||
a.uid!.localeCompare(b.uid!),
)
.filter((bond) => {
// A missing ISIN must not collapse unrelated catalog instruments.
const key = bond.isin?.trim()
? `isin:${bond.isin.trim().toUpperCase()}`
: `uid:${bond.uid!}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function toBond(bond: TbankBond): BondDto {
return {
instrumentUid: bond.uid!,
slug: bond.uid!,
name: bond.name?.trim() || bond.ticker || bond.uid!,
ticker: bond.ticker?.trim() || null,
isin: bond.isin?.trim() || null,
classCode: bond.classCode?.trim() || null,
realExchange: bond.realExchange || null,
apiTradeAvailable: bond.apiTradeAvailableFlag ?? null,
logoName: bond.brand?.logoName?.trim() || null,
currency: bond.currency?.toUpperCase() || null,
bondType: bond.bondType || null,
sector: bond.sector || null,
lot: bond.lot && bond.lot > 0 ? bond.lot : null,
historyFrom: timestamp(bond.first_1dayCandleDate),
nominal: money(bond.nominal),
initialNominal: money(bond.initialNominal),
aci: money(bond.aciValue),
maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null,
callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null,
couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
floatingCoupon: bond.floatingCouponFlag ?? null,
perpetual: bond.perpetualFlag ?? null,
amortization: bond.amortizationFlag ?? null,
subordinated: bond.subordinatedFlag ?? null,
riskLevel: bond.riskLevel || null,
};
}
function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase();
return value && currency
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
+15 -1
View File
@@ -1,9 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true })
totalsByCurrency: MoneyDto[];
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero stock positions, not the number of units.',
})
stockPositionCount: number;
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero bond positions, not the number of units.',
})
bondPositionCount: number;
}
export class BrokerageAccountDto {
+47 -2
View File
@@ -73,14 +73,22 @@ describe('BrokerageAccountsService', () => {
type: 'BROKERAGE',
name: 'Основной',
openedOn: '2024-08-13',
portfolioSummary: { totalsByCurrency: [] },
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
{
id: 'iis',
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
openedOn: null,
portfolioSummary: { totalsByCurrency: [] },
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
]);
});
@@ -115,6 +123,8 @@ describe('BrokerageAccountsService', () => {
openedOn: null,
portfolioSummary: {
totalsByCurrency: [{ currency: 'RUB', value: '6.5' }],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
{
@@ -124,6 +134,8 @@ describe('BrokerageAccountsService', () => {
openedOn: null,
portfolioSummary: {
totalsByCurrency: [{ currency: 'USD', value: '4' }],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
]);
@@ -131,6 +143,39 @@ describe('BrokerageAccountsService', () => {
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second');
});
it('counts non-zero stock and bond positions even without a valuation', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
]);
tbank.getPortfolio.mockResolvedValue({
positions: [
{ instrumentType: 'share', quantity: { units: 200 } },
{ instrumentType: 'SHARE', quantity: { units: -5 } },
{ instrumentType: 'share', quantity: { units: 0, nano: 1 } },
{ instrumentType: 'bond', quantity: { units: 80 } },
{ instrumentType: 'bond', quantity: { units: 0 } },
{ instrumentType: 'share' },
{ instrumentType: 'etf', quantity: { units: 10 } },
{ instrumentType: 'currency', quantity: { units: 100 } },
{ quantity: { units: 1 } },
],
virtualPositions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
});
await expect(service.list('user-1')).resolves.toMatchObject([
{
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 3,
bondPositionCount: 1,
},
},
]);
});
it('localizes an unavailable summary without suppressing another account', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: tokens.encrypt('token'),
+14 -1
View File
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ServiceUnavailableException } from '@nestjs/common';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { isZero } from '../common/decimal';
import { decimal } from './decimal';
import { totalsByCurrency } from './portfolio-totals';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
@@ -39,13 +41,24 @@ export class BrokerageAccountsService {
positionsResult.status === 'rejected'
)
return { ...account, portfolioSummary: null };
const positions = portfolioResult.value.positions ?? [];
let stockPositionCount = 0;
let bondPositionCount = 0;
for (const position of positions) {
if (isZero(decimal(position.quantity ?? {}))) continue;
const type = position.instrumentType?.toLowerCase();
if (type === 'share') stockPositionCount += 1;
if (type === 'bond') bondPositionCount += 1;
}
return {
...account,
portfolioSummary: {
totalsByCurrency: totalsByCurrency(
portfolioResult.value.positions ?? [],
positions,
positionsResult.value.money ?? [],
),
stockPositionCount,
bondPositionCount,
},
};
}),
+16 -1
View File
@@ -7,11 +7,14 @@ import {
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
import { AccountReferenceDto } from './account-reference.dto';
export const operationCategories = [
'trade',
@@ -24,6 +27,15 @@ export const operationCategories = [
export type OperationCategory = (typeof operationCategories)[number];
export class BrokerageOperationsQueryDto {
@ApiPropertyOptional({
format: 'uuid',
description:
'Instrument UID. Without from, includes history since the earliest available account opening.',
})
@IsOptional()
@IsUUID()
instrumentUid?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
@@ -59,6 +71,9 @@ export class BrokerageOperationsQueryDto {
}
export class BrokerageOperationDto {
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
@ApiProperty()
id: string;
@@ -46,3 +46,19 @@ describe('mapBrokerageOperation', () => {
});
});
});
describe('executed trade quantity', () => {
it('uses the executed part, and does not invent execution quantity when absent', () => {
expect(
mapBrokerageOperation({
type: 'OPERATION_TYPE_BUY',
quantity: '100',
quantityDone: '7',
}).quantity,
).toBe('7');
expect(
mapBrokerageOperation({ type: 'OPERATION_TYPE_BUY', quantity: '100' })
.quantity,
).toBeNull();
});
});
+14 -2
View File
@@ -1,4 +1,5 @@
import { decimal, formatDecimal } from './decimal';
import { formatDecimal } from '../common/decimal';
import { decimal } from './decimal';
import {
BrokerageOperationDto,
OperationCategory,
@@ -15,6 +16,9 @@ const types: Record<string, OperationCategory> = {
OPERATION_TYPE_COUPON: 'income',
OPERATION_TYPE_DIVIDEND: 'income',
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
OPERATION_TYPE_DIV_EXT: 'income',
OPERATION_TYPE_TRANS_IIS_BS: 'transfer',
OPERATION_TYPE_TRANS_BS_BS: 'transfer',
OPERATION_TYPE_BOND_TAX: 'tax',
OPERATION_TYPE_TAX: 'tax',
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
@@ -48,7 +52,15 @@ export function mapBrokerageOperation(
logoName: null,
payment: money(operation.payment),
quantity:
operation.quantity === undefined ? null : String(operation.quantity),
(operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity) === undefined
? null
: String(
operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity,
),
price: money(operation.price),
accruedInterest: money(operation.accruedInt),
commission: money(operation.commission),
@@ -18,6 +18,15 @@ import { BrokerageOperationsService } from './brokerage-operations.service';
export class BrokerageOperationsController {
constructor(private readonly operations: BrokerageOperationsService) {}
@Get('all/operations')
@ApiOkResponse({ type: BrokerageOperationsPageDto })
all(
@Req() request: Request & { user: AccessPayload },
@Query() query: BrokerageOperationsQueryDto,
) {
return this.operations.getAll(request.user.sub, query);
}
@Get(':accountId/operations')
@ApiOkResponse({ type: BrokerageOperationsPageDto })
get(
+267 -5
View File
@@ -1,4 +1,11 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { AccountReferenceDto } from './account-reference.dto';
import { BrokerageOperationDto } from './brokerage-operation.dto';
import { LRUCache } from 'lru-cache';
import { RequestContextService } from '../common/logging/request-context.service';
import {
@@ -14,6 +21,21 @@ import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
import { DateOnlyRange, dateOnlyBounds } from './income-period';
type OperationStream = {
account: AccountReferenceDto;
pending: BrokerageOperationDto[];
cursor?: string;
finished: boolean;
seen: string[];
lastOccurredAt?: number;
};
type AllOperationsContinuation = {
signature: string;
range: { from: Date; to: Date };
streams: OperationStream[];
page?: Promise<BrokerageOperationsPageDto>;
};
const freshForMs = 30_000;
const maxCachedPages = 100;
@@ -25,6 +47,15 @@ export class BrokerageOperationsService {
ttlAutopurge: true,
});
private readonly continuations = new LRUCache<
string,
AllOperationsContinuation
>({
max: 100,
ttl: 5 * 60_000,
ttlAutopurge: true,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
@@ -40,8 +71,235 @@ export class BrokerageOperationsService {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const range = this.range(query);
return this.access.withAccount(userId, accountId, ({ token, generation }) =>
this.page(token, generation, userId, accountId, query, range),
return this.access.withAccount(
userId,
accountId,
({ token, generation, account }) =>
this.page(
token,
generation,
userId,
accountId,
query,
query.instrumentUid && !query.from
? {
...range,
from: new Date(Number(account.openedDate?.seconds ?? 0) * 1000),
}
: range,
),
);
}
async getAll(
userId: string,
query: BrokerageOperationsQueryDto,
): Promise<BrokerageOperationsPageDto> {
return this.access.withSupportedAccounts(
userId,
async ({ token, generation, accounts }) => {
const signature = JSON.stringify({
userId,
generation,
accounts: accounts.map((account) => account.id).sort(),
instrumentUid: query.instrumentUid ?? null,
from: query.from ?? null,
to: query.to ?? null,
categories: [...new Set(query.categories ?? [])].sort(),
limit: query.limit ?? 100,
});
const continuation = query.cursor
? this.continuations.get(query.cursor)
: undefined;
if (
query.cursor &&
(!continuation || continuation.signature !== signature)
)
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
if (continuation?.page) return continuation.page;
const oldest = accounts
.map((account) => account.openedOn)
.filter((date): date is string => Boolean(date))
.sort()[0];
const range =
continuation?.range ??
this.range({
...query,
from:
query.from ??
(query.instrumentUid
? ((accounts.every((account) => account.openedOn)
? oldest
: undefined) ?? '1970-01-01')
: undefined),
});
const streams: OperationStream[] = continuation
? continuation.streams.map((stream) => ({
...stream,
pending: [...stream.pending],
seen: [...stream.seen],
}))
: accounts.map(({ id, name }) => ({
account: { id, name },
pending: [],
finished: false,
seen: [],
}));
const result = this.mergePage(
token,
generation,
userId,
query,
signature,
range,
streams,
);
if (continuation) continuation.page = result;
try {
return await result;
} catch (error) {
if (continuation) continuation.page = undefined;
throw error;
}
},
);
}
private async mergePage(
token: string,
generation: string,
userId: string,
query: BrokerageOperationsQueryDto,
signature: string,
range: { from: Date; to: Date },
streams: OperationStream[],
): Promise<BrokerageOperationsPageDto> {
const items: BrokerageOperationDto[] = [];
// Each stream retains unconsumed rows. A page boundary never discards them.
while (items.length < (query.limit ?? 100)) {
for (const stream of streams) {
while (!stream.pending.length && !stream.finished) {
const page = await this.page(
token,
generation,
userId,
stream.account.id,
{ ...query, cursor: stream.cursor },
range,
);
for (const item of page.items) {
const time = Date.parse(item.occurredAt);
if (
!Number.isFinite(time) ||
(stream.lastOccurredAt !== undefined &&
time > stream.lastOccurredAt)
)
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
stream.lastOccurredAt = time;
}
if (page.hasNext) {
if (!page.nextCursor || stream.seen.includes(page.nextCursor))
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
stream.seen.push(page.nextCursor);
stream.cursor = page.nextCursor;
} else stream.finished = true;
stream.pending = page.items.map((item) => ({
...item,
account: stream.account,
}));
}
}
const next = streams
.filter((stream) => stream.pending.length)
.sort(
(left, right) =>
Date.parse(right.pending[0].occurredAt) -
Date.parse(left.pending[0].occurredAt) ||
left.account.id.localeCompare(right.account.id),
)[0];
if (!next) break;
items.push(next.pending.shift()!);
}
const hasNext = streams.some(
(stream) => stream.pending.length || !stream.finished,
);
const nextCursor = hasNext ? randomUUID() : null;
if (nextCursor)
this.continuations.set(nextCursor, { signature, range, streams });
return {
accountId: 'all',
items,
hasNext,
nextCursor,
observedAt: new Date().toISOString(),
};
}
private async collectAllRanges(
userId: string,
ranges:
| DateOnlyRange[]
| ((accountOpenedAt: Date | undefined) => DateOnlyRange[]),
): Promise<{
accountOpenedAt: Date | undefined;
pages: BrokerageOperationsPageDto[][];
}> {
return this.access.withSupportedAccounts(
userId,
async ({ token, generation, accounts }) => {
// The current cohort includes newly opened accounts with no earlier receipts.
const oldest = accounts
.map((account) => account.openedOn)
.filter((date): date is string => Boolean(date))
.sort()[0];
const accountOpenedAt =
oldest && accounts.every((account) => account.openedOn)
? new Date(`${oldest}T00:00:00+03:00`)
: undefined;
const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
const pages: BrokerageOperationsPageDto[][] = selected.map(() => []);
for (const account of accounts) {
for (const [index, range] of selected.entries()) {
const collected = await this.collectRange(
token,
generation,
userId,
account.id,
range,
);
pages[index].push(
...collected.map((page) => ({
...page,
items: page.items.map((item) => ({
...item,
account: { id: account.id, name: account.name },
})),
})),
);
}
}
return {
accountOpenedAt,
pages: pages.map((group) => [
{
...emptyPage('all'),
items: group
.flatMap((page) => page.items)
.sort(
(left, right) =>
Date.parse(right.occurredAt) -
Date.parse(left.occurredAt) ||
left.account!.id.localeCompare(right.account!.id),
),
},
]),
};
},
);
}
@@ -55,6 +313,7 @@ export class BrokerageOperationsService {
accountOpenedAt: Date | undefined;
pages: BrokerageOperationsPageDto[][];
}> {
if (accountId === 'all') return this.collectAllRanges(userId, ranges);
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withAccount(
userId,
@@ -134,11 +393,13 @@ export class BrokerageOperationsService {
cursor: query.cursor,
limit: query.limit ?? 100,
state: 'OPERATION_STATE_EXECUTED',
...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}),
});
const items = (response.items ?? []).filter(
(item) =>
!query.categories?.length ||
query.categories.includes(operationCategory(item.type)),
(!query.instrumentUid || item.instrumentUid === query.instrumentUid) &&
(!query.categories?.length ||
query.categories.includes(operationCategory(item.type))),
);
const logoNames = new Map<string, Promise<string | null>>();
const page = {
@@ -213,6 +474,7 @@ function cacheKey(
from: range.from.toISOString(),
to: end,
categories,
instrumentUid: query.instrumentUid ?? null,
cursor: query.cursor ?? null,
limit: query.limit ?? 100,
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { add, decimal, formatDecimal, multiply, subtract } from './decimal';
import { add, formatDecimal, multiply, subtract } from '../common/decimal';
import { decimal } from './decimal';
describe('decimal', () => {
it('keeps signed nanos exact without floats', () => {
+2 -51
View File
@@ -1,18 +1,8 @@
import { Decimal } from '../common/decimal';
const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number };
export type Decimal = { value: bigint; scale: number };
export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
throw new Error('Invalid decimal string');
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}
export function decimal(value: ProtoDecimal): Decimal {
return {
@@ -20,42 +10,3 @@ export function decimal(value: ProtoDecimal): Decimal {
scale: 9,
};
}
export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale);
return {
value:
left.value * 10n ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale),
scale,
};
}
export function subtract(left: Decimal, right: Decimal): Decimal {
return add(left, { value: -right.value, scale: right.scale });
}
export function multiply(left: Decimal, right: Decimal): Decimal {
return { value: left.value * right.value, scale: left.scale + right.scale };
}
export function isZero(value: Decimal): boolean {
return value.value === 0n;
}
export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
}
export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0';
const sign = value < 0n ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
const digits = (value < 0n ? -value : value)
.toString()
.padStart(scale + 1, '0');
const whole = digits.slice(0, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, '');
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
}
+15
View File
@@ -19,6 +19,21 @@ import { IncomeAnalyticsService } from './income-analytics.service';
export class IncomeAnalyticsController {
constructor(private readonly income: IncomeAnalyticsService) {}
@Get('all/income-summary')
@ApiOkResponse({ type: IncomeSummaryDto })
allSummary(@Req() request: Request & { user: AccessPayload }) {
return this.income.summary(request.user.sub, 'all');
}
@Get('all/income-analytics')
@ApiOkResponse({ type: IncomeAnalyticsDto })
allAnalytics(
@Req() request: Request & { user: AccessPayload },
@Query() query: IncomeAnalyticsQueryDto,
) {
return this.income.analytics(request.user.sub, 'all', query.from, query.to);
}
@Get(':accountId/income-summary')
@ApiOkResponse({ type: IncomeSummaryDto })
summary(
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
import { BrokerageOperationDto } from './brokerage-operation.dto';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
+3 -2
View File
@@ -7,7 +7,8 @@ import {
decimalFromString,
formatDecimal,
subtract,
} from './decimal';
} from '../common/decimal';
import {
DateOnlyRange,
IncomeBucketGranularity,
@@ -228,7 +229,7 @@ function instrumentKey(entry: BrokerageOperationDto): string {
if (entry.figi) return `figi:${entry.figi}`;
if (entry.ticker || entry.instrumentType)
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
return `operation:${entry.id}`;
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
}
function difference(
+44
View File
@@ -0,0 +1,44 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type { TbankTimestamp } from './tbank.client';
import type { TbankCandle } from './tbank-market.types';
export function timestamp(value?: TbankTimestamp): string | null {
const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0
? new Date(seconds * 1000).toISOString()
: null;
}
export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10);
}
export function previousClose(
candles: TbankCandle[],
priceAt: string,
): TbankCandle | null {
return (
candles
.filter((candle) => {
const time = timestamp(candle.time);
return (
candle.isComplete &&
candle.close &&
time &&
time.slice(0, 10) < moscowDay(priceAt)
);
})
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
null
);
}
export async function optional<T>(
operation: () => Promise<T>,
name: string,
unavailable: string[],
): Promise<T | null> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ServiceUnavailableException)) throw error;
unavailable.push(name);
return null;
}
}
@@ -1,4 +1,7 @@
import { InstrumentMetadataService } from './instrument-metadata.service';
import { ServiceUnavailableException } from '@nestjs/common';
import type { LRUCache } from 'lru-cache';
import type { TbankInstrument } from './tbank.client';
describe('InstrumentMetadataService', () => {
const tbank = {
@@ -8,6 +11,13 @@ describe('InstrumentMetadataService', () => {
const logger = { event: jest.fn() };
let service: InstrumentMetadataService;
function rememberExpired(key: string, value: TbankInstrument): void {
const cache = (
service as unknown as { metadata: LRUCache<string, TbankInstrument> }
).metadata;
cache.set(key, value, { ttl: 1, start: performance.now() - 1000 });
}
beforeEach(() => {
jest.clearAllMocks();
tbank.getInstrumentByFigi.mockReset();
@@ -58,4 +68,223 @@ describe('InstrumentMetadataService', () => {
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
});
it('tries FIGI when UID metadata has no usable name or ticker', async () => {
tbank.getInstrumentByUid.mockResolvedValue({ name: ' ', ticker: '' });
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Облигация' });
await expect(
service.resolve('token', 'uid-1', 'figi-1'),
).resolves.toMatchObject({ name: 'Облигация' });
});
it('retains expired successful metadata through temporary failures and refreshes it later', async () => {
const known = {
name: 'Облигация',
ticker: 'BOND',
brand: { logoName: 'bond.png' },
};
tbank.getInstrumentByUid.mockResolvedValueOnce(known);
await service.resolve('token', 'uid-1', null);
rememberExpired('uid:uid-1', known);
tbank.getInstrumentByUid.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
known,
);
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
known,
);
tbank.getInstrumentByUid.mockResolvedValue({
...known,
name: 'Новое название',
});
await expect(
service.resolve('token', 'uid-1', null),
).resolves.toMatchObject({ name: 'Новое название' });
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(4);
});
it('does not mask an invalid token as missing metadata or retry by FIGI', async () => {
const error = new Error('INVALID_TBANK_TOKEN');
tbank.getInstrumentByUid.mockRejectedValue(error);
await expect(service.resolve('token', 'uid-1', 'figi-1')).rejects.toBe(
error,
);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('keeps known metadata through an incomplete refresh without renewing its freshness', async () => {
const known = { name: 'Облигация', ticker: 'BOND' };
rememberExpired('uid:uid-1', known);
tbank.getInstrumentByUid
.mockResolvedValueOnce({ name: ' ', ticker: '', isin: 'RU0000000001' })
.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
known,
);
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
known,
);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('keeps a known FIGI name after partial UID metadata and does not cache the partial response', async () => {
const known = { name: 'Облигация', ticker: 'BOND' };
rememberExpired('figi:figi-1', known);
tbank.getInstrumentByUid
.mockResolvedValueOnce({ isin: 'RU0000000001' })
.mockResolvedValueOnce({ name: 'Новое название' });
tbank.getInstrumentByFigi.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
known,
);
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual({
name: 'Новое название',
});
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
});
it('returns cold partial fields without caching them', async () => {
const partial = { isin: 'RU0000000001' };
tbank.getInstrumentByUid.mockResolvedValue(partial);
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
partial,
);
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
partial,
);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
});
it('replaces an expired name with authoritative confirmed absence', async () => {
rememberExpired('uid:uid-1', { name: 'Старое название' });
tbank.getInstrumentByUid.mockResolvedValue(null);
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
});
it('isolates in-flight credentials while sharing successful public metadata', async () => {
const error = new Error('INVALID_TBANK_TOKEN');
const known = { name: 'Облигация' };
let rejectInvalid!: (reason: Error) => void;
tbank.getInstrumentByUid.mockImplementation((token: string) =>
token === 'invalid-token'
? new Promise((_resolve, reject) => {
rejectInvalid = reject;
})
: Promise.resolve(known),
);
const invalid = expect(
service.resolve('invalid-token', 'uid-1', null),
).rejects.toBe(error);
const valid = service.resolve('valid-token', 'uid-1', null);
rejectInvalid(error);
await invalid;
await expect(valid).resolves.toEqual(known);
await expect(
service.resolve('another-token', 'uid-1', null),
).resolves.toEqual(known);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
});
it('returns cached or nullable metadata without starting requests after abort', async () => {
const known = { name: 'Облигация' };
rememberExpired('figi:figi-1', known);
const controller = new AbortController();
controller.abort();
await expect(
service.resolve('token', 'uid-1', 'figi-1', controller.signal),
).resolves.toEqual(known);
await expect(
service.resolve('token', 'uid-2', 'figi-2', controller.signal),
).resolves.toBeNull();
expect(tbank.getInstrumentByUid).not.toHaveBeenCalled();
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('returns expired metadata promptly when the waiting caller aborts', async () => {
const known = { name: 'Облигация' };
rememberExpired('uid:uid-1', known);
tbank.getInstrumentByUid.mockReturnValue(new Promise(() => {}));
const controller = new AbortController();
const result = service.resolve(
'token',
'uid-1',
'figi-1',
controller.signal,
);
controller.abort();
await expect(result).resolves.toEqual(known);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
const known = { name: 'Облигация' };
let finish!: (value: TbankInstrument) => void;
tbank.getInstrumentByUid.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const controller = new AbortController();
const aborted = service.resolve(
'token',
'uid-1',
'figi-1',
controller.signal,
);
controller.abort();
await expect(aborted).resolves.toBeNull();
const waiting = service.resolve('token', 'uid-1', null);
finish(known);
await expect(waiting).resolves.toEqual(known);
await expect(
service.resolve('another-token', 'uid-1', null),
).resolves.toEqual(known);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('does not start FIGI lookup after an aborted UID lookup fails later', async () => {
let fail!: (reason: Error) => void;
tbank.getInstrumentByUid.mockReturnValue(
new Promise((_resolve, reject) => {
fail = reject;
}),
);
const controller = new AbortController();
const result = service.resolve(
'token',
'uid-1',
'figi-1',
controller.signal,
);
controller.abort();
await expect(result).resolves.toBeNull();
fail(new Error('INVALID_TBANK_TOKEN'));
await Promise.resolve();
await Promise.resolve();
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
});
+51 -19
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { createHash } from 'node:crypto';
import { StructuredLogger } from '../common/logging/structured-logger.service';
import { TbankClient, type TbankInstrument } from './tbank.client';
@@ -15,6 +16,7 @@ export class InstrumentMetadataService {
>({
max: maxCachedInstruments,
ttl: metadataForMs,
noDeleteOnStaleGet: true,
});
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
@@ -27,51 +29,79 @@ export class InstrumentMetadataService {
token: string,
instrumentUid: string | null | undefined,
figi: string | null | undefined,
signal?: AbortSignal,
): Promise<TbankInstrument | null> {
const uid = instrumentUid?.trim();
let byUid: TbankInstrument | null = null;
if (uid) {
const byUid = await this.lookup(`uid:${uid}`, () =>
this.tbank.getInstrumentByUid(token, uid),
byUid = await this.lookup(
token,
`uid:${uid}`,
() => this.tbank.getInstrumentByUid(token, uid),
signal,
);
if (byUid) return byUid;
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
}
const instrumentFigi = figi?.trim();
return instrumentFigi
? this.lookup(`figi:${instrumentFigi}`, () =>
this.tbank.getInstrumentByFigi(token, instrumentFigi),
const byFigi = instrumentFigi
? await this.lookup(
token,
`figi:${instrumentFigi}`,
() => this.tbank.getInstrumentByFigi(token, instrumentFigi),
signal,
)
: null;
return byFigi ?? byUid;
}
private async lookup(
token: string,
key: string,
fetch: () => Promise<TbankInstrument | null>,
signal?: AbortSignal,
): Promise<TbankInstrument | null> {
const cached = this.metadata.get(key);
if (cached !== undefined)
return cached === missingInstrument ? null : cached;
const pending = this.pending.get(key);
if (pending) return pending;
const request = this.fetch(key, fetch);
this.pending.set(key, request);
try {
return await request;
} finally {
this.pending.delete(key);
if (signal?.aborted) return this.lastKnown(key);
const owner = createHash('sha256').update(token).digest('hex');
const requestKey = `${owner}:${key}`;
let pending = this.pending.get(requestKey);
if (!pending) {
pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => {
this.pending.delete(requestKey);
});
this.pending.set(requestKey, pending);
}
if (!signal) return pending;
return new Promise((resolve, reject) => {
const onAbort = () => resolve(this.lastKnown(key));
signal.addEventListener('abort', onAbort, { once: true });
void pending.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
private lastKnown(key: string): TbankInstrument | null {
const value = this.metadata.peek(key, { allowStale: true });
return value === missingInstrument ? null : (value ?? null);
}
private async fetch(
key: string,
request: () => Promise<TbankInstrument | null>,
previous: TbankInstrument | null,
): Promise<TbankInstrument | null> {
try {
const value = await request();
this.metadata.set(key, value ?? missingInstrument);
const found = Boolean(value?.name?.trim() || value?.ticker?.trim());
if (value === null || found)
this.metadata.set(key, value ?? missingInstrument);
this.logger.event('debug', 'instrument-metadata.resolved', {
found: Boolean(value?.name?.trim() || value?.ticker?.trim()),
found,
});
return value;
return found || value === null ? value : (previous ?? value);
} catch (error) {
this.logger.event('warn', 'instrument-metadata.failed', {
code:
@@ -80,7 +110,9 @@ export class InstrumentMetadataService {
'UNKNOWN')
: 'UNKNOWN',
});
return null;
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw error;
return error instanceof ServiceUnavailableException ? previous : null;
}
}
}
+103
View File
@@ -0,0 +1,103 @@
import {
add,
decimalFromString,
divide,
formatDecimal,
multiply,
} from '../common/decimal';
import { MoneyDto } from '../common/money.dto';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { StockPositionDto } from './stock.dto';
export function instrumentPosition(
portfolio: PortfolioDto,
uid: string,
type: 'STOCK' | 'BOND',
): StockPositionDto {
const positions = portfolio.positions.filter(
(p) => p.type === type && p.instrumentUid === uid,
);
const zero = decimalFromString('0');
const quantity = positions
.map((p) => decimalFromString(p.quantity))
.reduce(add, zero);
const costs = positions.map(cost);
const totalCost = sumComplete(costs);
const currentValue = sumComplete(positions.map((p) => p.currentValue));
const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn));
const positive =
positions.length > 0 &&
positions.every((p) => decimalFromString(p.quantity).value > 0n);
return {
quantity: formatDecimal(quantity),
currentValue,
absoluteReturn,
averagePurchasePrice:
positive && totalCost
? {
currency: totalCost.currency,
value: formatDecimal(
divide(decimalFromString(totalCost.value), quantity, 9),
),
}
: null,
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
accounts: positions.map((p) => ({
...p,
returnPercent:
decimalFromString(p.quantity).value > 0n
? percent(p.absoluteReturn, cost(p))
: null,
})),
isStale: portfolio.isStale,
observedAt: portfolio.lastSuccessfulAt,
};
}
function cost(position: PortfolioPositionDto): MoneyDto | null {
const price = position.averagePurchasePrice;
return price
? {
currency: price.currency,
value: formatDecimal(
multiply(
decimalFromString(price.value),
decimalFromString(position.quantity),
),
),
}
: null;
}
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency;
if (!currency || values.some((v) => !v || v.currency !== currency))
return null;
return {
currency,
value: formatDecimal(
values.reduce(
(sum, value) => add(sum, decimalFromString(value!.value)),
decimalFromString('0'),
),
),
};
}
function percent(
result: MoneyDto | null,
base: MoneyDto | null,
): string | null {
if (
!base ||
!result ||
base.currency !== result.currency ||
decimalFromString(base.value).value <= 0n
)
return null;
return formatDecimal(
divide(
multiply(decimalFromString(result.value), decimalFromString('100')),
decimalFromString(base.value),
6,
),
);
}
+91
View File
@@ -1,6 +1,97 @@
import { MoexClient } from './moex.client';
describe('MoexClient', () => {
afterEach(() => jest.restoreAllMocks());
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,
json: () =>
Promise.resolve({
securities: {
columns: ['SECID', 'ISIN', 'BOARDID'],
data: [['BOND', 'RU000TEST', 'TQOB']],
},
marketdata_yields: {
columns: [
'SECID',
'BOARDID',
'EFFECTIVEYIELD',
'PRICE',
'YIELDDATE',
'YIELDDATETYPE',
'TRADEMOMENT',
],
data: [
[
'BOND',
'OTHER',
99,
20,
'2028-12-01',
'OFFERDATE',
'2026-09-10 18:49:00',
],
[
'BOND',
'TQOB',
-0.5,
101,
'2028-12-01',
'OFFERDATE',
'2026-09-10 18:49:00',
],
],
},
}),
} as Response);
const client = new MoexClient();
expect(await client.bondYield('BOND', 'RU000TEST', 'TQOB')).toEqual({
value: '-0.5',
pricePercent: '101',
date: '2028-12-01',
dateType: 'OFFERDATE',
tradeAt: '2026-09-10T15:49:00.000Z',
});
expect(await client.bondYield('BOND', 'WRONG', 'TQOB')).toBeNull();
expect(await client.bondYield('BOND', 'RU000TEST', 'OTHER')).toBeNull();
});
it.each([null, 0])(
'does not report unknown yield as zero: %s',
async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
securities: {
columns: ['SECID', 'ISIN', 'BOARDID'],
data: [['BOND', 'ISIN', 'TQOB']],
},
marketdata_yields: {
columns: [
'SECID',
'BOARDID',
'EFFECTIVEYIELD',
'PRICE',
'YIELDDATE',
'TRADEMOMENT',
],
data: [
[
'BOND',
'TQOB',
value,
100,
'2030-01-01',
'2026-09-10 12:00:00',
],
],
},
}),
} as Response);
const result = await new MoexClient().bondYield('BOND', 'ISIN', 'TQOB');
expect(result?.value ?? null).toBe(value === null ? null : '0');
},
);
it('treats a successful response without the requested block as no events', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
+54
View File
@@ -10,9 +10,63 @@ export type MoexBondEvent =
type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>;
export type MoexBondYield = {
value: string;
pricePercent: string;
date: string;
dateType: string | null;
tradeAt: string;
};
@Injectable()
export class MoexClient {
async bondYield(
secid: string,
isin: string,
board: string,
): Promise<MoexBondYield | null> {
const query = new URLSearchParams({
'iss.meta': 'off',
'iss.only': 'securities,marketdata_yields',
});
const response = await this.request(
`${baseUrl}/engines/stock/markets/bonds/securities/${encodeURIComponent(secid)}.json?${query}`,
);
if (!response) return null;
const listing = parseRows(response.securities).find(
(row) =>
row.secid === secid && row.isin === isin && row.boardid === board,
);
if (!listing) return null;
const row = parseRows(response.marketdata_yields).find(
(row) => row.secid === secid && row.boardid === board,
);
if (!row) return null;
const value = numberText(row.effectiveyield);
const pricePercent = numberText(row.price);
const date = text(row.yielddate);
const tradeAt = text(row.trademoment);
if (
value === null ||
pricePercent === null ||
Number(pricePercent) <= 0 ||
!date ||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
!Number.isFinite(Date.parse(date)) ||
!tradeAt ||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
)
return null;
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
if (!Number.isFinite(Date.parse(tradeIso))) return null;
return {
value,
pricePercent,
date,
dateType: text(row.yielddatetype),
tradeAt: new Date(tradeIso).toISOString(),
};
}
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([
+85
View File
@@ -0,0 +1,85 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client';
const isin = 'RU000A10EW44';
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
`<tr><td>Эмитент</td><td>Облигация</td><td>${value}</td><td><a href="${href}">${code}</a></td><td>REG</td><td>17.04.2026</td></tr>`;
const table = (rows: string) =>
`<table id="issues-table"><thead><tr><th>Лицо</th><th>Выпуск</th><th>Рейтинг</th><th>ISIN</th><th>Регномер</th><th>Дата</th></tr></thead><tbody>${rows}</tbody></table>`;
const response = (html: string) =>
({ ok: true, text: () => Promise.resolve(html) }) as Response;
const cacheClock = performance;
describe('public NCR issue ratings', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z'));
jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now());
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
it('matches only exact ISIN and shares one public registry download', async () => {
const fetchMock = jest
.spyOn(global, 'fetch')
.mockResolvedValue(response(table(row())));
const client = new NcrRatingsClient();
const [rating, missing] = await Promise.all([
client.rating(isin),
client.rating('RU000A1065B1'),
]);
expect(rating).toEqual({
agency: 'NCR',
value: 'AAA.ru',
status: 'active',
date: '2026-04-17',
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
observedAt: '2026-09-10T10:00:00.000Z',
});
expect(missing).toBeNull();
expect(await client.rating('issuer-name')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(6 * 60 * 60_000 + 1);
await client.rating(isin);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('preserves withdrawal and never displays an issuer rating as an issue rating', async () => {
jest
.spyOn(global, 'fetch')
.mockResolvedValue(response(table(row('рейтинг отозван'))));
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({
status: 'withdrawn',
value: null,
});
});
it.each([
'<html>Service unavailable</html>',
table(row('AAA.ru', isin, 'https://untrusted.test/')),
table(row('unrecognized')),
table(row() + row('AA.ru')),
table(''),
])(
'reports changed or ambiguous registry as unavailable, not unrated',
async (html) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(html));
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
},
);
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
const fetchMock = jest
.spyOn(global, 'fetch')
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(response(table(row())));
const client = new NcrRatingsClient();
await expect(client.rating(isin)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
await expect(client.rating(isin)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(60_001);
expect(await client.rating(isin)).toMatchObject({ value: 'AAA.ru' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio';
import { LRUCache } from 'lru-cache';
const registryUrl = 'https://ratings.ru/ratings/issues/';
export type NcrRating = {
agency: 'NCR';
value: string | null;
status: 'active' | 'withdrawn';
date: string;
sourceUrl: string;
observedAt: string;
};
@Injectable()
export class NcrRatingsClient {
// Public registry contains no user data. Share one download across all issues.
private readonly cache = new LRUCache<
string,
Promise<Map<string, NcrRating>>
>({ max: 1, ttl: 6 * 60 * 60_000 });
async rating(isin: string): Promise<NcrRating | null> {
const key = isin.trim().toUpperCase();
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(key)) return null;
let pending = this.cache.get('registry');
if (!pending) {
pending = this.registry().catch(() => {
// Bound outage traffic too; never present a failed download as no rating.
if (this.cache.get('registry') === pending)
this.cache.set('registry', pending, { ttl: 60_000 });
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
});
this.cache.set('registry', pending);
}
return (await pending).get(key) ?? null;
}
private async registry(): Promise<Map<string, NcrRating>> {
const response = await fetch(registryUrl, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error('NCR unavailable');
const $ = load(await response.text());
const table = $('#issues-table');
const headings = table
.find('thead th')
.map((_, cell) => $(cell).text().trim())
.get();
if (
table.length !== 1 ||
headings.length !== 6 ||
headings[2] !== 'Рейтинг' ||
headings[3] !== 'ISIN' ||
headings[5] !== 'Дата'
)
throw new Error('NCR registry changed');
const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => {
const cells = $(row).find('td');
if (cells.length !== 6) throw new Error('NCR row changed');
const isin = cells.eq(3).text().trim().toUpperCase();
// Unassigned issues cannot safely be matched to a held instrument.
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
if (
!withdrawn &&
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test(
raw,
)
)
throw new Error('NCR rating changed');
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(5).text().trim(),
);
const href = cells.eq(3).find('a').attr('href');
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
throw new Error('NCR identity missing');
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
if (
!Number.isFinite(Date.parse(date)) ||
new Date(date).toISOString().slice(0, 10) !== date
)
throw new Error('NCR date invalid');
const rating: NcrRating = {
agency: 'NCR',
value: withdrawn ? null : raw,
status: withdrawn ? 'withdrawn' : 'active',
date,
sourceUrl: `https://ratings.ru${href}`,
observedAt,
};
const previous = result.get(isin);
if (
previous &&
previous.date === date &&
(previous.value !== rating.value || previous.status !== rating.status)
)
throw new Error('NCR rating ambiguous');
if (!previous || previous.date < date) result.set(isin, rating);
});
if (!result.size) throw new Error('NCR registry empty');
return result;
}
}
+113
View File
@@ -0,0 +1,113 @@
import { portfolioStructure } from './portfolio-structure';
import { PortfolioPositionDto } from './portfolio.dto';
const position = (
type: string,
value: string | null,
currency = 'RUB',
): PortfolioPositionDto => ({
type,
currentValue: value === null ? null : { currency, value },
quantity: '1',
displayName: 'Fixture',
figi: null,
isin: null,
ticker: null,
logoName: null,
absoluteReturn: null,
averagePurchasePrice: null,
});
describe('portfolio structure', () => {
it('includes cash once and keeps currency instruments among other assets', () => {
const result = portfolioStructure(
[
position('STOCK', '60'),
position('BOND', '20'),
position('CURRENCY', '10'),
],
[{ currency: 'RUB', value: '10' }],
);
expect(result.currencies[0]).toEqual({
currency: 'RUB',
totalValue: '100',
hasNegativeValues: false,
categories: [
{ type: 'STOCK', value: '60', sharePercent: '60' },
{ type: 'BOND', value: '20', sharePercent: '20' },
{ type: 'OTHER', value: '10', sharePercent: '10' },
{ type: 'CASH', value: '10', sharePercent: '10' },
],
});
});
it('sums beyond Number precision and separates currencies without FX', () => {
const result = portfolioStructure(
[
position('STOCK', '9007199254740993.000000001'),
position('STOCK', '0.000000009'),
position('BOND', '2', 'USD'),
],
[{ currency: 'USD', value: '1' }],
);
expect(result.currencies[0].totalValue).toBe('9007199254740993.00000001');
expect(result.currencies[1].totalValue).toBe('3');
expect(result.currencies[1].categories[1].sharePercent).toBe('66.666667');
expect(result.currencies[1].categories[3].sharePercent).toBe('33.333333');
});
it('represents cash-only, empty and zero-valued portfolios', () => {
expect(portfolioStructure([], []).currencies).toEqual([]);
expect(
portfolioStructure([], [{ currency: 'EUR', value: '10' }]).currencies[0]
.categories[3].sharePercent,
).toBe('100');
expect(
portfolioStructure(
[position('STOCK', '0')],
[],
).currencies[0].categories.every((item) => item.sharePercent === null),
).toBe(true);
});
it('retains unvalued categories without assigning zero or guessing a currency', () => {
const result = portfolioStructure(
[position('BOND', null), position('BOND', null), position('STOCK', '50')],
[],
);
expect(result.unvaluedCategories).toEqual([{ type: 'BOND', count: 2 }]);
expect(result.currencies[0].totalValue).toBe('50');
expect(
result.currencies[0].categories.every(
(item) => item.sharePercent === null,
),
).toBe(true);
expect(
portfolioStructure([position('OTHER', null)], []).currencies,
).toEqual([]);
});
it.each([
[[position('STOCK', '-20'), position('STOCK', '100')], []],
[
[],
[
{ currency: 'RUB', value: '-20' },
{ currency: 'RUB', value: '100' },
],
],
[[position('BOND', '-20')], []],
[[position('BOND', '-20'), position('BOND', '20')], []],
])(
'preserves negative constituents even when their category nets positive or zero',
(positions, cash) => {
const result = portfolioStructure(positions, cash);
expect(result.currencies[0].hasNegativeValues).toBe(true);
expect(
result.currencies[0].categories.every(
(item) => item.sharePercent === null,
),
).toBe(true);
},
);
});
+80
View File
@@ -0,0 +1,80 @@
import { add, decimalFromString, formatDecimal } from '../common/decimal';
import { MoneyDto } from '../common/money.dto';
import {
PortfolioPositionDto,
PortfolioStructureDto,
PortfolioStructureCategoryDto,
} from './portfolio.dto';
const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const;
// Build from positions and cash, never from totals (which already include cash).
export function portfolioStructure(
positions: PortfolioPositionDto[],
cash: MoneyDto[],
): PortfolioStructureDto {
const currencies = new Map<
string,
{
values: ReturnType<typeof decimalFromString>[];
hasNegativeValues: boolean;
}
>();
const unvalued = new Map<string, number>();
const include = (type: string, money: MoneyDto) => {
const group = currencies.get(money.currency) ?? {
values: categoryTypes.map(() => decimalFromString('0')),
hasNegativeValues: false,
};
const index = categoryTypes.findIndex((category) => category === type);
const value = decimalFromString(money.value);
group.values[index] = add(group.values[index], value);
group.hasNegativeValues ||= value.value < 0n;
currencies.set(money.currency, group);
};
for (const position of positions) {
const type = ['STOCK', 'BOND'].includes(position.type)
? position.type
: 'OTHER';
if (position.currentValue) include(type, position.currentValue);
else unvalued.set(type, (unvalued.get(type) ?? 0) + 1);
}
for (const amount of cash) include('CASH', amount);
return {
unvaluedCategories: [...unvalued].map(([type, count]) => ({ type, count })),
currencies: [...currencies]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, group]) => {
const total = group.values.reduce(add, decimalFromString('0'));
const canCalculateShares =
!unvalued.size && !group.hasNegativeValues && total.value > 0n;
const categories: PortfolioStructureCategoryDto[] = group.values.map(
(value, index) => ({
type: categoryTypes[index],
value: formatDecimal(value),
// Rounded to six decimal places using integer arithmetic. Only the
// resulting dimensionless percentage is passed to chart geometry.
sharePercent: canCalculateShares
? formatDecimal({
value:
(value.value *
10n ** BigInt(total.scale - value.scale) *
100_000_000n +
total.value / 2n) /
total.value,
scale: 6,
})
: null,
}),
);
return {
currency,
totalValue: formatDecimal(total),
hasNegativeValues: group.hasNegativeValues,
categories,
};
}),
};
}
+27 -3
View File
@@ -1,5 +1,13 @@
import { add, decimal, formatDecimal, isZero, multiply } from './decimal';
import { MoneyDto } from './portfolio.dto';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
} from '../common/decimal';
import { decimal } from './decimal';
import { MoneyDto } from '../common/money.dto';
import { TbankPortfolio, TbankPositions } from './tbank.client';
function totalsByCurrency(
@@ -34,4 +42,20 @@ function totalsByCurrency(
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
}
export { totalsByCurrency };
function sumMoneyByCurrency(amounts: MoneyDto[]): MoneyDto[] {
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
for (const amount of amounts) {
totals.set(
amount.currency,
add(
totals.get(amount.currency) ?? decimal({}),
decimalFromString(amount.value),
),
);
}
return [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
}
export { totalsByCurrency, sumMoneyByCurrency };
+6
View File
@@ -15,6 +15,12 @@ import { PortfolioService } from './portfolio.service';
export class PortfolioController {
constructor(private readonly portfolio: PortfolioService) {}
@Get('all/portfolio')
@ApiOkResponse({ type: PortfolioDto })
all(@Req() request: Request & { user: AccessPayload }) {
return this.portfolio.getAll(request.user.sub);
}
@Get(':accountId/portfolio')
@ApiOkResponse({ type: PortfolioDto })
get(
+64 -9
View File
@@ -1,14 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
export class MoneyDto {
@ApiProperty()
currency: string;
@ApiProperty()
value: string;
}
import { MoneyDto } from '../common/money.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AccountReferenceDto } from './account-reference.dto';
export class PortfolioPositionDto {
@ApiPropertyOptional({ type: String, nullable: true })
instrumentUid?: string | null;
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
@ApiProperty({ type: String, nullable: true })
figi: string | null;
@@ -52,6 +52,58 @@ export class PortfolioPositionDto {
logoName: string | null;
}
export class PortfolioStructureCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER', 'CASH'] })
type: string;
@ApiProperty({ description: 'Exact known value as a decimal string.' })
value: string;
@ApiProperty({
type: String,
nullable: true,
description:
'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.',
})
sharePercent: string | null;
}
export class PortfolioStructureCurrencyDto {
@ApiProperty()
currency: string;
@ApiProperty()
totalValue: string;
@ApiProperty({
description: 'Any negative constituent, even if netted positive.',
})
hasNegativeValues: boolean;
@ApiProperty({ type: [PortfolioStructureCategoryDto] })
categories: PortfolioStructureCategoryDto[];
}
export class PortfolioUnvaluedCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] })
type: string;
@ApiProperty({ minimum: 1 })
count: number;
}
export class PortfolioStructureDto {
@ApiProperty({ type: [PortfolioStructureCurrencyDto] })
currencies: PortfolioStructureCurrencyDto[];
@ApiProperty({
type: [PortfolioUnvaluedCategoryDto],
description:
'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.',
})
unvaluedCategories: PortfolioUnvaluedCategoryDto[];
}
export class PortfolioDto {
@ApiProperty({ format: 'date-time' })
lastSuccessfulAt: string;
@@ -67,4 +119,7 @@ export class PortfolioDto {
@ApiProperty({ type: MoneyDto, isArray: true })
cashBalances: MoneyDto[];
@ApiProperty({ type: PortfolioStructureDto })
structure: PortfolioStructureDto;
}
+74
View File
@@ -184,6 +184,80 @@ describe('PortfolioService', () => {
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => {
const positions = Array.from({ length: 60 }, (_, index) => ({
figi: `BATCH-${index}`,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 10 },
}));
tbank.getPortfolio.mockResolvedValue({ positions });
let active = 0;
tbank.getInstrumentByFigi.mockImplementation(
async (_token: string, figi: string) => {
active += 1;
try {
if (active > 50)
throw new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
await Promise.resolve();
return {
name: `Название ${figi}`,
ticker: figi,
isin: `ISIN-${figi}`,
brand: { logoName: `${figi}.png` },
};
} finally {
active -= 1;
}
},
);
const result = await service.get('user', 'account');
expect(result.positions).toMatchObject(
positions.map((position, index) => ({
displayName: `Название BATCH-${index}`,
type: position.instrumentType === 'share' ? 'STOCK' : 'BOND',
ticker: `BATCH-${index}`,
isin: `ISIN-BATCH-${index}`,
logoName: `BATCH-${index}.png`,
quantity: '2',
currentValue: { currency: 'RUB', value: '20' },
})),
);
});
it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => {
jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') });
try {
tbank.getPortfolio.mockResolvedValue({
positions: Array.from({ length: 60 }, (_, index) => ({
figi: `BUDGET-${index}`,
instrumentUid: `budget-uid-${index}`,
instrumentType: index % 2 ? 'bond' : 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 10 },
})),
});
tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {}));
const pending = service.get('user', 'account');
await jest.advanceTimersByTimeAsync(0);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
await jest.advanceTimersByTimeAsync(30_000);
const result = await pending;
expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z');
expect(result.positions).toHaveLength(60);
expect(result.positions[59]).toMatchObject({
displayName: 'BUDGET-59',
currentValue: { currency: 'RUB', value: '20' },
});
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
await service.get('user', 'account');
await service.get('user', 'account');
+128 -51
View File
@@ -2,20 +2,27 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { RequestContextService } from '../common/logging/request-context.service';
import {
add,
decimal,
formatDecimal,
isZero,
multiply,
subtract,
} from './decimal';
import { totalsByCurrency } from './portfolio-totals';
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { TbankAccessService } from './tbank-access.service';
} from '../common/decimal';
import { decimal } from './decimal';
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
import { portfolioStructure } from './portfolio-structure';
import { MoneyDto } from '../common/money.dto';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import {
TbankAccessService,
TbankConnectionContext,
} from './tbank-access.service';
import { InstrumentMetadataService } from './instrument-metadata.service';
import { TbankClient, TbankMoney } from './tbank.client';
const freshForMs = 30_000;
const fallbackForMs = 300_000;
const positionsBatchSize = 5;
const metadataTimeoutMs = 30_000;
const types: Record<string, string> = {
share: 'STOCK',
bond: 'BOND',
@@ -46,49 +53,90 @@ export class PortfolioService {
async get(userId: string, accountId: string): Promise<PortfolioDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const key = `${userId}:${accountId}`;
return this.access.withConnection(userId, async ({ token, generation }) => {
const cached = this.snapshots.get(key);
if (
cached?.generation === generation &&
Date.now() - cached.storedAt <= freshForMs
)
return cached.snapshot;
try {
const snapshot = await this.access.withAccountForConnection(
{ token, generation },
accountId,
async () => {
const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
return this.normalize(
token,
portfolio.positions ?? [],
positions.money ?? [],
);
},
);
this.snapshots.set(key, {
generation,
snapshot,
storedAt: Date.now(),
return this.access.withConnection(userId, (connection) =>
this.snapshot(userId, accountId, connection),
);
}
async getAll(userId: string): Promise<PortfolioDto> {
return this.access.withSupportedAccounts(userId, async (context) => {
const portfolios: PortfolioDto[] = [];
for (const account of context.accounts) {
const portfolio = await this.snapshot(userId, account.id, context);
portfolios.push({
...portfolio,
positions: portfolio.positions.map((position) => ({
...position,
account: { id: account.id, name: account.name },
})),
});
return snapshot;
} catch (error) {
if (
error instanceof ServiceUnavailableException &&
cached?.generation === generation &&
Date.now() - cached.storedAt <= fallbackForMs
) {
return { ...cached.snapshot, isStale: true };
}
throw error;
}
return {
lastSuccessfulAt:
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
new Date().toISOString(),
isStale: portfolios.some((portfolio) => portfolio.isStale),
totalsByCurrency: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
),
cashBalances: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.cashBalances),
),
positions: portfolios.flatMap((portfolio) => portfolio.positions),
structure: portfolioStructure(
portfolios.flatMap((portfolio) => portfolio.positions),
portfolios.flatMap((portfolio) => portfolio.cashBalances),
),
};
});
}
private async snapshot(
userId: string,
accountId: string,
{ token, generation }: TbankConnectionContext,
): Promise<PortfolioDto> {
const key = `${userId}:${accountId}`;
const cached = this.snapshots.get(key);
if (
cached?.generation === generation &&
Date.now() - cached.storedAt <= freshForMs
)
return cached.snapshot;
try {
const snapshot = await this.access.withAccountForConnection(
{ token, generation },
accountId,
async () => {
const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
return this.normalize(
token,
portfolio.positions ?? [],
positions.money ?? [],
);
},
);
this.snapshots.set(key, {
generation,
snapshot,
storedAt: Date.parse(snapshot.lastSuccessfulAt),
});
return snapshot;
} catch (error) {
if (
error instanceof ServiceUnavailableException &&
cached?.generation === generation &&
Date.now() - cached.storedAt <= fallbackForMs
) {
return { ...cached.snapshot, isStale: true };
}
throw error;
}
}
invalidate(userId: string): void {
for (const key of this.snapshots.keys())
if (key.startsWith(`${userId}:`)) this.snapshots.delete(key);
@@ -106,9 +154,29 @@ export class PortfolioService {
}[],
money: TbankMoney[],
): Promise<PortfolioDto> {
const positions = await Promise.all(
upstream.map((position) => this.position(token, position)),
);
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), metadataTimeoutMs);
const positions: (PortfolioPositionDto | null)[] = [];
try {
for (
let offset = 0;
offset < upstream.length;
offset += positionsBatchSize
) {
positions.push(
...(await Promise.all(
upstream
.slice(offset, offset + positionsBatchSize)
.map((position) =>
this.position(token, position, controller.signal),
),
)),
);
}
} finally {
clearTimeout(timeout);
}
const cash = new Map<string, ReturnType<typeof decimal>>();
for (const value of money) {
const currency = value.currency?.trim().toUpperCase();
@@ -120,20 +188,23 @@ export class PortfolioService {
currency,
value: formatDecimal(value),
}));
const normalizedPositions = positions.filter(
(position): position is PortfolioPositionDto => position !== null,
);
return {
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
lastSuccessfulAt,
isStale: false,
totalsByCurrency: totalsByCurrency(upstream, money),
positions: positions.filter(
(position): position is PortfolioPositionDto => position !== null,
),
positions: normalizedPositions,
cashBalances,
structure: portfolioStructure(normalizedPositions, cashBalances),
};
}
private async position(
token: string,
position: Parameters<PortfolioService['normalize']>[1][number],
signal: AbortSignal,
): Promise<PortfolioPositionDto | null> {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
@@ -159,9 +230,15 @@ export class PortfolioService {
),
}
: null;
const metadata = await this.metadata.resolve(token, instrumentUid, figi);
const metadata = await this.metadata.resolve(
token,
instrumentUid,
figi,
signal,
);
return {
figi,
instrumentUid,
displayName:
metadata?.name?.trim() ||
metadata?.ticker?.trim() ||
+692
View File
@@ -0,0 +1,692 @@
syntax = "proto3";
package tinkoff.public.invest.api.contract.v1;
option go_package = "./;investapi";
option java_package = "ru.tinkoff.piapi.contract.v1";
option java_multiple_files = true;
option csharp_namespace = "Tinkoff.InvestApi.V1";
option objc_class_prefix = "TIAPI";
option php_namespace = "Tinkoff\\Invest\\V1";
import "google/protobuf/timestamp.proto";
import "common.proto";
import "google/api/field_behavior.proto";
service MarketDataService {//Сервис для получения биржевой информации:<br/> 1. Свечи.<br/> 2. Стаканы.<br/> 3. Торговые статусы.<br/> 4. Лента сделок.
//GetCandles — исторические свечи по инструменту
rpc GetCandles(GetCandlesRequest) returns (GetCandlesResponse);
//GetLastPrices — цены последних сделок по инструментам
rpc GetLastPrices(GetLastPricesRequest) returns (GetLastPricesResponse);
//GetOrderBook — стакан по инструменту
rpc GetOrderBook(GetOrderBookRequest) returns (GetOrderBookResponse);
//GetTradingStatus — статус торгов по инструменту
rpc GetTradingStatus(GetTradingStatusRequest) returns (GetTradingStatusResponse);
//GetTradingStatuses — статус торгов по инструментам
rpc GetTradingStatuses(GetTradingStatusesRequest) returns (GetTradingStatusesResponse);
//GetLastTrades — обезличенные сделки
//Обезличенные сделки по инструменту. Метод гарантирует получение информации за последний час.
rpc GetLastTrades(GetLastTradesRequest) returns (GetLastTradesResponse);
//GetClosePrices — цены закрытия торговой сессии по инструментам
rpc GetClosePrices(GetClosePricesRequest) returns (GetClosePricesResponse);
//GetTechAnalysis — технические индикаторы по инструменту
rpc GetTechAnalysis(GetTechAnalysisRequest) returns (GetTechAnalysisResponse);
//GetMarketValues — рыночные данные по инструментам
rpc GetMarketValues(GetMarketValuesRequest) returns (GetMarketValuesResponse);
}
service MarketDataStreamService {
//MarketDataStream — bidirectional стрим предоставления биржевой информации
rpc MarketDataStream(stream MarketDataRequest) returns (stream MarketDataResponse);
//MarketDataServerSideStream — server-side стрим предоставления биржевой информации
rpc MarketDataServerSideStream(MarketDataServerSideStreamRequest) returns (stream MarketDataResponse);
}
//Запрос подписки или отписки на определенные биржевые данные.
message MarketDataRequest {
oneof payload {
SubscribeCandlesRequest subscribe_candles_request = 1; //Запрос подписки на свечи.
SubscribeOrderBookRequest subscribe_order_book_request = 2; //Запрос подписки на стаканы.
SubscribeTradesRequest subscribe_trades_request = 3; //Запрос подписки на ленту обезличенных сделок.
SubscribeInfoRequest subscribe_info_request = 4; //Запрос подписки на торговые статусы инструментов.
SubscribeLastPriceRequest subscribe_last_price_request = 5; //Запрос подписки на цены последних сделок.
GetMySubscriptions get_my_subscriptions = 6; //Запрос своих подписок.
PingRequest ping = 7; //Запрос проверки активности соединения.
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
}
}
message MarketDataServerSideStreamRequest {
SubscribeCandlesRequest subscribe_candles_request = 1; //Запрос подписки на свечи.
SubscribeOrderBookRequest subscribe_order_book_request = 2; //Запрос подписки на стаканы.
SubscribeTradesRequest subscribe_trades_request = 3; //Запрос подписки на ленту обезличенных сделок.
SubscribeInfoRequest subscribe_info_request = 4; //Запрос подписки на торговые статусы инструментов.
SubscribeLastPriceRequest subscribe_last_price_request = 5; //Запрос подписки на цены последних сделок.
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
}
//Пакет биржевой информации по подписке.
message MarketDataResponse {
oneof payload {
SubscribeCandlesResponse subscribe_candles_response = 1; //Результат подписки на свечи.
SubscribeOrderBookResponse subscribe_order_book_response = 2; //Результат подписки на стаканы.
SubscribeTradesResponse subscribe_trades_response = 3; //Результат подписки на поток обезличенных сделок.
SubscribeInfoResponse subscribe_info_response = 4; //Результат подписки на торговые статусы инструментов.
Candle candle = 5; //Свеча.
Trade trade = 6; //Сделки.
OrderBook orderbook = 7; //Стакан.
TradingStatus trading_status = 8; //Торговый статус.
Ping ping = 9; //Проверка активности стрима.
SubscribeLastPriceResponse subscribe_last_price_response = 10; //Результат подписки на цены последние сделок по инструментам.
LastPrice last_price = 11; //Цена последней сделки.
OpenInterest open_interest = 12; //Открытый интерес.
}
}
// subscribeCandles | Изменения статуса подписки на свечи.
message SubscribeCandlesRequest {
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
repeated CandleInstrument instruments = 2; //Массив инструментов для подписки на свечи.
bool waiting_close = 3; //Флаг ожидания закрытия временного интервала для отправки свечи.
optional GetCandlesRequest.CandleSource candle_source_type = 9; //Источник свечей.
}
//Тип операции со списком подписок.
enum SubscriptionAction {
SUBSCRIPTION_ACTION_UNSPECIFIED = 0; //Статус подписки не определен.
SUBSCRIPTION_ACTION_SUBSCRIBE = 1; //Подписаться.
SUBSCRIPTION_ACTION_UNSUBSCRIBE = 2; //Отписаться.
}
//Интервал свечи.
enum SubscriptionInterval {
SUBSCRIPTION_INTERVAL_UNSPECIFIED = 0; //Интервал свечи не определен.
SUBSCRIPTION_INTERVAL_ONE_MINUTE = 1; //Минутные свечи.
SUBSCRIPTION_INTERVAL_FIVE_MINUTES = 2; //Пятиминутные свечи.
SUBSCRIPTION_INTERVAL_FIFTEEN_MINUTES = 3; //Пятнадцатиминутные свечи.
SUBSCRIPTION_INTERVAL_ONE_HOUR = 4; //Часовые свечи.
SUBSCRIPTION_INTERVAL_ONE_DAY = 5; //Дневные свечи.
SUBSCRIPTION_INTERVAL_2_MIN = 6; //Двухминутные свечи.
SUBSCRIPTION_INTERVAL_3_MIN = 7; //Трехминутные свечи.
SUBSCRIPTION_INTERVAL_10_MIN = 8; //Десятиминутные свечи.
SUBSCRIPTION_INTERVAL_30_MIN = 9; //Тридцатиминутные свечи.
SUBSCRIPTION_INTERVAL_2_HOUR = 10; //Двухчасовые свечи.
SUBSCRIPTION_INTERVAL_4_HOUR = 11; //Четырехчасовые свечи.
SUBSCRIPTION_INTERVAL_WEEK = 12; //Недельные свечи.
SUBSCRIPTION_INTERVAL_MONTH = 13; //Месячные свечи.
}
//Запрос изменения статус подписки на свечи.
message CandleInstrument {
string figi = 1 [deprecated = true]; // Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
SubscriptionInterval interval = 2; //Интервал свечей. Двухчасовые и четырехчасовые свечи в стриме отсчитываются с 0:00 по UTC.
string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
}
//Результат изменения статус подписки на свечи.
message SubscribeCandlesResponse {
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
repeated CandleSubscription candles_subscriptions = 2; //Массив статусов подписки на свечи.
}
//Статус подписки на свечи.
message CandleSubscription {
string figi = 1; //FIGI-идентификатор инструмента.
SubscriptionInterval interval = 2; //Интервал свечей.
SubscriptionStatus subscription_status = 3; //Статус подписки.
string instrument_uid = 4; //UID инструмента.
bool waiting_close = 5; //Флаг ожидания закрытия временного интервала для отправки свечи.
string stream_id = 6; //Идентификатор открытого соединения.
string subscription_id = 7; //Идентификатор подписки в формате `UUID`.
SubscriptionAction subscription_action = 8; //Действие подписки.
optional GetCandlesRequest.CandleSource candle_source_type = 9; //Источник свечей.
string ticker = 10; //Тикер инструмента.
string class_code = 11; //Класс-код (секция торгов).
}
//Результат подписки.
enum SubscriptionStatus {
SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Статус подписки не определен.
SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
SUBSCRIPTION_STATUS_INSTRUMENT_NOT_FOUND = 2; //Инструмент не найден.
SUBSCRIPTION_STATUS_SUBSCRIPTION_ACTION_IS_INVALID = 3; //Некорректный статус подписки. [Список возможных значений](./marketdata#subscriptionaction).
SUBSCRIPTION_STATUS_DEPTH_IS_INVALID = 4; //Некорректная глубина стакана. Доступные значения — 1, 10, 20, 30, 40, 50.
SUBSCRIPTION_STATUS_INTERVAL_IS_INVALID = 5; //Некорректный интервал свечей. [Список возможных значений](./marketdata#subscriptioninterval).
SUBSCRIPTION_STATUS_LIMIT_IS_EXCEEDED = 6; //Превышен лимит на общее количество подписок в рамках стрима. [Лимитная политика](./limits/).
SUBSCRIPTION_STATUS_INTERNAL_ERROR = 7; //Внутренняя ошибка сервиса.
SUBSCRIPTION_STATUS_TOO_MANY_REQUESTS = 8; //Превышен лимит на количество запросов на подписки в течение установленного отрезка времени.
SUBSCRIPTION_STATUS_SUBSCRIPTION_NOT_FOUND = 9; // Активная подписка не найдена. Ошибка может возникнуть только при отписке от несуществующей подписки.
SUBSCRIPTION_STATUS_SOURCE_IS_INVALID = 10; //Указан некорректный источник.
}
//Запрос на изменение статуса подписки на стаканы.
message SubscribeOrderBookRequest {
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
repeated OrderBookInstrument instruments = 2; //Массив инструментов для подписки на стаканы.
}
//Запрос подписки на стаканы.
message OrderBookInstrument {
string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
int32 depth = 2; //Глубина стакана.
string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
OrderBookType order_book_type = 4; //Тип стакана. Значение по умолчанию — `ORDERBOOK_TYPE_ALL`, стакан биржевой и дилера.
}
//Результат изменения статуса подписки на стаканы.
message SubscribeOrderBookResponse {
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
repeated OrderBookSubscription order_book_subscriptions = 2; //Массив статусов подписки на стаканы.
}
//Статус подписки.
message OrderBookSubscription {
string figi = 1; //FIGI-идентификатор инструмента.
int32 depth = 2; //Глубина стакана.
SubscriptionStatus subscription_status = 3; //Статус подписки.
string instrument_uid = 4; //UID инструмента.
string stream_id = 5; //Идентификатор открытого соединения.
string subscription_id = 6; //Идентификатор подписки в формате `UUID`.
OrderBookType order_book_type = 7; //Тип стакана.
SubscriptionAction subscription_action = 8; //Действие подписки.
string ticker = 9; //Тикер инструмента.
string class_code = 10; //Класс-код (секция торгов).
}
//Типы источников сделок.
enum TradeSourceType {
TRADE_SOURCE_UNSPECIFIED = 0; //Тип источника сделки не определён.
TRADE_SOURCE_EXCHANGE = 1; // Биржевые сделки.
TRADE_SOURCE_DEALER = 2; // Сделки дилера.
TRADE_SOURCE_ALL = 3; // Все сделки.
}
//Изменение статуса подписки на поток обезличенных сделок.
message SubscribeTradesRequest {
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
repeated TradeInstrument instruments = 2; //Массив инструментов для подписки на поток обезличенных сделок.
TradeSourceType trade_source = 3; //Тип источника сделок. Значение по умолчанию — `TRADE_SOURCE_ALL`, все сделки.
bool with_open_interest = 4; //Флаг открытого интереса. **true** - в стриме дополнительно передается информация об открытом интересе для фьючерсов
}
//Запрос подписки на поток обезличенных сделок.
message TradeInstrument {
string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`.
string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
}
//Результат изменения статуса подписки на поток обезличенных сделок.
message SubscribeTradesResponse {
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
repeated TradeSubscription trade_subscriptions = 2; //Массив статусов подписки на поток сделок.
TradeSourceType trade_source = 3; //Тип источника сделок.
}
//Статус подписки.
message TradeSubscription {
string figi = 1; //FIGI-идентификатор инструмента.
SubscriptionStatus subscription_status = 2; //Статус подписки.
string instrument_uid = 3; //UID инструмента.
string stream_id = 4; //Идентификатор открытого соединения.
string subscription_id = 5; //Идентификатор подписки в формате UUID.
bool with_open_interest = 6; //Флаг открытого интереса. **true** - в стриме дополнительно передается информация об открытом интересе для фьючерсов
SubscriptionAction subscription_action = 7; //Действие подписки.
string ticker = 8; //Тикер инструмента.
string class_code = 9; //Класс-код (секция торгов).
}
//Изменение статуса подписки на торговый статус инструмента.
message SubscribeInfoRequest {
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
repeated InfoInstrument instruments = 2; //Массив инструментов для подписки на торговый статус.
}
//Запрос подписки на торговый статус.
message InfoInstrument {
string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`.
string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
}
//Результат изменения статуса подписки на торговый статус.
message SubscribeInfoResponse {
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
repeated InfoSubscription info_subscriptions = 2; //Массив статусов подписки на торговый статус.
}
//Статус подписки.
message InfoSubscription {
string figi = 1; //FIGI-идентификатор инструмента.
SubscriptionStatus subscription_status = 2; //Статус подписки.
string instrument_uid = 3; //UID инструмента.
string stream_id = 4; //Идентификатор открытого соединения.
string subscription_id = 5; //Идентификатор подписки в формате UUID.
SubscriptionAction subscription_action = 6; //Действие подписки.
string ticker = 7; //Тикер инструмента.
string class_code = 8; //Класс-код (секция торгов).
}
//Изменение статуса подписки на цену последней сделки по инструменту.
message SubscribeLastPriceRequest {
SubscriptionAction subscription_action = 1; //Изменение статуса подписки.
repeated LastPriceInstrument instruments = 2; //Массив инструментов для подписки на цену последней сделки.
}
//Запрос подписки на последнюю цену.
message LastPriceInstrument {
string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`.
string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
}
//Результат изменения статуса подписки на цену последней сделки.
message SubscribeLastPriceResponse {
string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id).
repeated LastPriceSubscription last_price_subscriptions = 2; //Массив статусов подписки на цену последней сделки.
}
//Статус подписки на цену последней сделки.
message LastPriceSubscription {
string figi = 1; //FIGI-идентификатор инструмента.
SubscriptionStatus subscription_status = 2; //Статус подписки.
string instrument_uid = 3; //UID инструмента.
string stream_id = 4; //Идентификатор открытого соединения.
string subscription_id = 5; //Идентификатор подписки в формате `UUID`.
SubscriptionAction subscription_action = 6; //Действие подписки.
string ticker = 7; //Тикер инструмента.
string class_code = 8; //Класс-код (секция торгов).
}
//Пакет свечей в рамках стрима.
message Candle {
string figi = 1; //FIGI-идентификатор инструмента.
SubscriptionInterval interval = 2; //Интервал свечи.
Quotation open = 3; //Цена открытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation high = 4; //Максимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation low = 5; //Минимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation close = 6; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
int64 volume = 7; //Объем сделок в лотах.
google.protobuf.Timestamp time = 8; //Время начала интервала свечи по UTC.
google.protobuf.Timestamp last_trade_ts = 9; //Время последней сделки, вошедшей в свечу по UTC.
string instrument_uid = 10; //UID инструмента.
string ticker = 11; //Тикер инструмента.
string class_code = 12; //Класс-код (секция торгов).
int64 volume_buy = 13; //Объем торгов на покупку.
int64 volume_sell = 14; //Объём торгов на продажу.
CandleSource candle_source_type = 19; //Источник свечей.
}
//Пакет стаканов в рамках стрима.
message OrderBook {
string figi = 1; //FIGI-идентификатор инструмента.
int32 depth = 2; //Глубина стакана.
bool is_consistent = 3; //Флаг консистентности стакана. **false** — не все заявки попали в стакан из-за сетевых задержек или нарушения порядка доставки.
repeated Order bids = 4; //Массив предложений.
repeated Order asks = 5; //Массив спроса.
google.protobuf.Timestamp time = 6; //Время формирования стакана в часовом поясе UTC по времени биржи.
Quotation limit_up = 7; //Верхний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation limit_down = 8; //Нижний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
string instrument_uid = 9; //UID инструмента.
OrderBookType order_book_type = 10; //Тип стакана.
string ticker = 11; //Тикер инструмента.
string class_code = 12; //Класс-код (секция торгов).
}
//Массив предложений/спроса.
message Order {
Quotation price = 1; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
int64 quantity = 2; //Количество в лотах.
}
//Информация о сделке.
message Trade {
string figi = 1; //FIGI-идентификатор инструмента.
TradeDirection direction = 2; //Направление сделки.
Quotation price = 3; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
int64 quantity = 4; //Количество лотов.
google.protobuf.Timestamp time = 5; //Время сделки в часовом поясе UTC по времени биржи.
string instrument_uid = 6; //UID инструмента.
TradeSourceType trade_source = 7; //Тип источника сделки.
string ticker = 8; //Тикер инструмента.
string class_code = 9; //Класс-код (секция торгов).
}
//Направление сделки.
enum TradeDirection {
TRADE_DIRECTION_UNSPECIFIED = 0; //Направление сделки не определено.
TRADE_DIRECTION_BUY = 1; //Покупка.
TRADE_DIRECTION_SELL = 2; //Продажа.
}
//Пакет изменения торгового статуса.
message TradingStatus {
string figi = 1; //FIGI-идентификатор инструмента.
SecurityTradingStatus trading_status = 2; //Статус торговли инструментом.
google.protobuf.Timestamp time = 3; //Время изменения торгового статуса по UTC.
bool limit_order_available_flag = 4; //Признак доступности выставления лимитной заявки по инструменту.
bool market_order_available_flag = 5; //Признак доступности выставления рыночной заявки по инструменту.
string instrument_uid = 6; //UID инструмента.
string ticker = 7; //Тикер инструмента.
string class_code = 8; //Класс-код (секция торгов).
}
//Запрос исторических свечей.
message GetCandlesRequest {
optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC.
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC.
CandleInterval interval = 4 [(google.api.field_behavior) = REQUIRED]; //Интервал запрошенных свечей.
optional string instrument_id = 5; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
optional CandleSource candle_source_type = 7; //Тип источника свечи.
optional int32 limit = 10; //Максимальное количество свечей в ответе.
enum CandleSource {
CANDLE_SOURCE_UNSPECIFIED = 0; //Все свечи.
CANDLE_SOURCE_EXCHANGE = 1; //Биржевые свечи.
CANDLE_SOURCE_INCLUDE_WEEKEND = 3; //Все свечи с учетом торговли по выходным.
}
}
//Интервал свечей. Максимальное значение интервала приведено ориентировочно, может отличаться в большую сторону в зависимости от параметров запроса.
enum CandleInterval {
CANDLE_INTERVAL_UNSPECIFIED = 0; //Интервал не определен.
CANDLE_INTERVAL_1_MIN = 1; //От 1 минуты до 1 дня. Максимальное значение `limit` — 2400.
CANDLE_INTERVAL_5_MIN = 2; //От 5 минут до недели. Максимальное значение `limit` — 2400.
CANDLE_INTERVAL_15_MIN = 3; //От 15 минут до 3 недель. Максимальное значение `limit` — 2400.
CANDLE_INTERVAL_HOUR = 4; //От 1 часа до 3 месяцев. Максимальное значение `limit` — 2400.
CANDLE_INTERVAL_DAY = 5; //От 1 дня до 6 лет. Максимальное значение `limit` — 2400.
CANDLE_INTERVAL_2_MIN = 6; //От 2 минут до 1 дня. Максимальное значение `limit` — 1200.
CANDLE_INTERVAL_3_MIN = 7; //От 3 минут до 1 дня. Максимальное значение `limit` — 750.
CANDLE_INTERVAL_10_MIN = 8; //От 10 минут до недели. Максимальное значение `limit` — 1200.
CANDLE_INTERVAL_30_MIN = 9; //От 30 минут до 3 недель. Максимальное значение `limit` — 1200.
CANDLE_INTERVAL_2_HOUR = 10; //От 2 часов до 3 месяцев. Максимальное значение `limit` — 2400.
CANDLE_INTERVAL_4_HOUR = 11; //От 4 часов до 3 месяцев. Максимальное значение `limit` — 700.
CANDLE_INTERVAL_WEEK = 12; //От 1 недели до 5 лет. Максимальное значение `limit` — 300.
CANDLE_INTERVAL_MONTH = 13; //От 1 месяца до 10 лет. Максимальное значение `limit` — 120.
CANDLE_INTERVAL_5_SEC = 14; //От 5 секунд до 200 минут. Максимальное значение `limit` — 2500.
CANDLE_INTERVAL_10_SEC = 15; //От 10 секунд до 200 минут. Максимальное значение `limit` — 1250.
CANDLE_INTERVAL_30_SEC = 16; //От 30 секунд до 20 часов. Максимальное значение `limit` — 2500.
}
enum CandleSource {
CANDLE_SOURCE_UNSPECIFIED = 0; //Источник свечей не определен.
CANDLE_SOURCE_EXCHANGE = 1; //Биржевые свечи.
CANDLE_SOURCE_DEALER_WEEKEND = 2; //Свечи дилера в результате торговли по выходным.
}
//Список свечей.
message GetCandlesResponse {
repeated HistoricCandle candles = 1; //Массив свечей.
}
//Информация о свече.
message HistoricCandle {
Quotation open = 1; //Цена открытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation high = 2; //Максимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation low = 3; //Минимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation close = 4; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
int64 volume = 5; //Объем торгов в лотах.
google.protobuf.Timestamp time = 6; //Время свечи в часовом поясе UTC.
bool is_complete = 7; //Признак завершенности свечи. **false** — свеча за текущие интервал еще сформирована не полностью.
CandleSource candle_source = 9; //Тип источника свечи
int64 volume_buy = 10; //Объем торгов на покупку.
int64 volume_sell = 11; //Объём торгов на продажу.
}
//Запрос получения цен последних сделок.
message GetLastPricesRequest {
repeated string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
repeated string instrument_id = 2; //Массив идентификаторов инструмента. Принимает значения `figi`, `instrument_uid` или `ticker + '_' + class_code`.
LastPriceType last_price_type = 3; //Тип запрашиваемой последней цены.
optional InstrumentStatus instrument_status = 9; //Статус запрашиваемых инструментов. [Возможные значения](#instrumentstatus).
}
//Список цен последних сделок.
message GetLastPricesResponse {
repeated LastPrice last_prices = 1; //Массив цен последних сделок.
}
//Информация о цене последней сделки.
message LastPrice {
string figi = 1; //FIGI инструмента.
Quotation price = 2; //Цена последней сделки за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
google.protobuf.Timestamp time = 3; //Время получения последней цены в часовом поясе UTC по времени биржи.
string ticker = 9; //Тикер инструмента.
string class_code = 10; //Класс-код (секция торгов).
string instrument_uid = 11; //UID инструмента.
LastPriceType last_price_type = 12; //Тип последней цены.
}
message OpenInterest {
string instrument_uid = 1; //UID инструмента.
google.protobuf.Timestamp time = 2; //Время получения открытого интереса в часовом поясе UTC по времени биржи.
int64 open_interest = 3; //Открытый интерес.
string ticker = 4; //Тикер инструмента.
string class_code = 5; //Класс-код (секция торгов).
}
//Запрос стакана.
message GetOrderBookRequest {
optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
int32 depth = 2 [(google.api.field_behavior) = REQUIRED]; //Глубина стакана.
optional string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
}
//Информация о стакане.
message GetOrderBookResponse {
string figi = 1; //FIGI-идентификатор инструмента.
int32 depth = 2; //Глубина стакана.
repeated Order bids = 3; //Множество пар значений на покупку.
repeated Order asks = 4; //Множество пар значений на продажу.
Quotation last_price = 5; //Цена последней сделки за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation close_price = 6; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation limit_up = 7; //Верхний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
Quotation limit_down = 8; //Нижний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15).
string instrument_uid = 9; //UID инструмента.
string ticker = 10; //Тикер инструмента.
string class_code = 11; //Класс-код (секция торгов).
google.protobuf.Timestamp last_price_ts = 21; //Время получения цены последней сделки.
google.protobuf.Timestamp close_price_ts = 22; //Время получения цены закрытия.
google.protobuf.Timestamp orderbook_ts = 23; //Время формирования стакана на бирже.
}
//Запрос получения торгового статуса.
message GetTradingStatusRequest {
optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
optional string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`
}
//Запрос получения торгового статуса.
message GetTradingStatusesRequest {
repeated string instrument_id = 1; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`
}
//Информация о торговом статусе.
message GetTradingStatusesResponse {
repeated GetTradingStatusResponse trading_statuses = 1; //Массив информации о торговых статусах.
}
//Информация о торговом статусе.
message GetTradingStatusResponse {
string figi = 1; //FIGI-идентификатор инструмента.
SecurityTradingStatus trading_status = 2; //Статус торговли инструментом.
bool limit_order_available_flag = 3; //Признак доступности выставления лимитной заявки по инструменту.
bool market_order_available_flag = 4; //Признак доступности выставления рыночной заявки по инструменту.
bool api_trade_available_flag = 5; //Признак доступности торгов через API.
string instrument_uid = 6; //UID инструмента.
bool bestprice_order_available_flag = 8; //Признак доступности завяки по лучшей цене.
bool only_best_price = 9; //Признак доступности только заявки по лучшей цене.
string ticker = 10; //Тикер инструмента.
string class_code = 11; //Класс-код (секция торгов).
}
//Запрос обезличенных сделок за последний час.
message GetLastTradesRequest {
optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`.
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC.
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC.
optional string instrument_id = 4; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`
TradeSourceType trade_source = 5; //Тип источника сделок. Значение по умолчанию — `TRADE_SOURCE_ALL`, все сделки.
}
//Обезличенных сделок за последний час.
message GetLastTradesResponse {
repeated Trade trades = 1; //Массив сделок.
}
//Запрос активных подписок. Возвращает по одному сообщению на каждый тип активных подписок — `SubscribeLastPriceResponse`, `SubscribeInfoResponse`, `SubscribeTradesResponse`, `SubscribeOrderBookResponse`, `SubscribeCandlesResponse`.
message GetMySubscriptions {}
//Запрос цен закрытия торговой сессии по инструментам.
message GetClosePricesRequest {
repeated InstrumentClosePriceRequest instruments = 1 [(google.api.field_behavior) = REQUIRED]; //Массив по инструментам.
optional InstrumentStatus instrument_status = 9; //Статус запрашиваемых инструментов. [Возможные значения](#instrumentstatus).
}
//Запрос цен закрытия торговой сессии по инструменту.
message InstrumentClosePriceRequest {
string instrument_id = 1; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`
}
//Цены закрытия торговой сессии по инструментам.
message GetClosePricesResponse {
repeated InstrumentClosePriceResponse close_prices = 1; //Массив по инструментам.
}
//Цена закрытия торговой сессии по инструменту.
message InstrumentClosePriceResponse {
string figi = 1; //FIGI инструмента.
string instrument_uid = 2; //UID инструмента.
string ticker = 3; //Тикер инструмента.
string class_code = 4; //Класс-код (секция торгов).
Quotation price = 11; //Цена закрытия торговой сессии.
Quotation evening_session_price = 12; //Цена последней сделки с вечерней сессии. Цена публикуется биржей по торговым дням и в нерабочие дни не обновляется.
google.protobuf.Timestamp time = 21; //Дата совершения торгов.
google.protobuf.Timestamp evening_session_price_time = 23; //Дата цены закрытия вечерней сессии.
}
message GetTechAnalysisRequest {
IndicatorType indicator_type = 1 [(google.api.field_behavior) = REQUIRED]; //Тип технического индикатора.
string instrument_uid = 2 [(google.api.field_behavior) = REQUIRED]; //UID инструмента.
google.protobuf.Timestamp from = 3 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC.
google.protobuf.Timestamp to = 4 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC.
IndicatorInterval interval = 5 [(google.api.field_behavior) = REQUIRED]; //Интервал, за который рассчитывается индикатор.
TypeOfPrice type_of_price = 6 [(google.api.field_behavior) = REQUIRED]; //Тип цены, который используется при расчете индикатора.
int32 length = 7; //Торговый период, за который рассчитывается индикатор.
Deviation deviation = 8; //Параметры отклонения.
Smoothing smoothing = 9; //Параметры сглаживания.
message Smoothing {
int32 fast_length = 1; //Короткий период сглаживания для первой экспоненциальной скользящей средней (EMA).
int32 slow_length = 2; //Длинный период сглаживания для второй экспоненциальной скользящей средней (EMA).
int32 signal_smoothing = 3; //Период сглаживания для третьей экспоненциальной скользящей средней (EMA)
}
message Deviation {
Quotation deviation_multiplier = 1; //Количество стандартных отклонений, на которые отступают верхняя и нижняя границы.
}
//Интервал свечи.
enum IndicatorInterval {
INDICATOR_INTERVAL_UNSPECIFIED = 0; //Интервал не определен.
INDICATOR_INTERVAL_ONE_MINUTE = 1; //1 минута.
INDICATOR_INTERVAL_FIVE_MINUTES = 2; //5 минут.
INDICATOR_INTERVAL_FIFTEEN_MINUTES = 3; //15 минут.
INDICATOR_INTERVAL_ONE_HOUR = 4; //1 час.
INDICATOR_INTERVAL_ONE_DAY = 5; //1 день.
INDICATOR_INTERVAL_2_MIN = 6; //2 минуты.
INDICATOR_INTERVAL_3_MIN = 7; //3 минуты.
INDICATOR_INTERVAL_10_MIN = 8; //10 минут.
INDICATOR_INTERVAL_30_MIN = 9; //30 минут.
INDICATOR_INTERVAL_2_HOUR = 10; //2 часа.
INDICATOR_INTERVAL_4_HOUR = 11; //4 часа.
INDICATOR_INTERVAL_WEEK = 12; //Неделя.
INDICATOR_INTERVAL_MONTH = 13; //Месяц.
}
enum TypeOfPrice {
TYPE_OF_PRICE_UNSPECIFIED = 0; //Не указано.
TYPE_OF_PRICE_CLOSE = 1; //Цена закрытия.
TYPE_OF_PRICE_OPEN = 2; //Цена открытия.
TYPE_OF_PRICE_HIGH = 3; //Максимальное значение за выбранный интервал.
TYPE_OF_PRICE_LOW = 4; //Минимальное значение за выбранный интервал.
TYPE_OF_PRICE_AVG = 5; //Среднее значение по показателям [ (close + open + high + low) / 4 ].
}
enum IndicatorType {
INDICATOR_TYPE_UNSPECIFIED = 0; //Не определен.
INDICATOR_TYPE_BB = 1; //Bollinger Bands — линия Боллинжера.
INDICATOR_TYPE_EMA = 2; //Exponential Moving Average — EMA, экспоненциальная скользящая средняя.
INDICATOR_TYPE_RSI = 3; //Relative Strength Index — индекс относительной силы.
INDICATOR_TYPE_MACD = 4; //Moving Average Convergence/Divergence — схождение/расхождение скользящих средних.
INDICATOR_TYPE_SMA = 5; //Simple Moving Average — простое скользящее среднее.
}
}
message GetTechAnalysisResponse {
repeated TechAnalysisItem technical_indicators = 1; //Массив значений результатов технического анализа.
message TechAnalysisItem {
google.protobuf.Timestamp timestamp = 1; //Временная метка по UTC, для которой были рассчитаны значения индикатора.
optional Quotation middle_band = 2; //Значение простого скользящего среднего (средней линии).
optional Quotation upper_band = 3; //Значение верхней линии Боллинджера.
optional Quotation lower_band = 4; //Значение нижней линии Боллинджера.
optional Quotation signal = 5; //Значение сигнальной линии.
optional Quotation macd = 6; //Значение линии MACD.
}
}
message GetMarketValuesRequest {
repeated string instrument_id = 1; //Массив идентификаторов инструментов. Принимает значения `figi`, `instrument_uid` или `ticker + '_' + class_code`.
repeated MarketValueType values = 2; //Массив запрашиваемых параметров.
}
message GetMarketValuesResponse {
repeated MarketValueInstrument instruments = 1; //Массив значений параметров.
}
message MarketValueInstrument {
string instrument_uid = 1; //Идентификатор инструмента.
repeated MarketValue values = 2; //Массив параметров инструмента.
string ticker = 3; //Тикер инструмента.
string class_code = 4; //Класс-код (секция торгов).
}
message MarketValue {
optional MarketValueType type = 1; //Тип параметра.
optional Quotation value = 2; //Значение.
optional google.protobuf.Timestamp time = 3; //Дата и время.
}
enum MarketValueType {
INSTRUMENT_VALUE_UNSPECIFIED = 0; //Не определен.
INSTRUMENT_VALUE_LAST_PRICE = 1; //Последняя биржевая цена.
INSTRUMENT_VALUE_LAST_PRICE_DEALER = 2; //Последняя цена дилера.
INSTRUMENT_VALUE_CLOSE_PRICE = 3; //Цена закрытия.
INSTRUMENT_VALUE_EVENING_SESSION_PRICE = 4; //Цена последней сделки с вечерней сессии.
INSTRUMENT_VALUE_OPEN_INTEREST = 5; // Открытый интерес, возвращается только для фьючерсов.
INSTRUMENT_VALUE_THEOR_PRICE = 6; //Теоретическая цена, возвращается только для опционов.
INSTRUMENT_VALUE_YIELD = 7; //Доходность
}
enum OrderBookType {
ORDERBOOK_TYPE_UNSPECIFIED = 0; //Не определен.
ORDERBOOK_TYPE_EXCHANGE = 1; //Биржевой стакан.
ORDERBOOK_TYPE_DEALER = 2; //Стакан дилера.
ORDERBOOK_TYPE_ALL = 3; //Стакан биржевой и дилера.
}
//Тип последней цены
enum LastPriceType {
LAST_PRICE_UNSPECIFIED = 0; //Не определен.
LAST_PRICE_EXCHANGE = 1; // Цена биржи.
LAST_PRICE_DEALER = 2; // Цена дилера
}
+83
View File
@@ -0,0 +1,83 @@
import { stockPosition } from './stock-position';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
const money = (value: string, currency = 'RUB') => ({ value, currency });
const row = (
quantity = '2',
average = '10',
value = '30',
uid = 'common',
): PortfolioPositionDto => ({
instrumentUid: uid,
figi: uid,
displayName: uid,
type: 'STOCK',
quantity,
averagePurchasePrice: money(average),
currentValue: money(value),
absoluteReturn: money('10'),
isin: null,
ticker: null,
logoName: null,
});
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
positions,
cashBalances: [],
totalsByCurrency: [],
isStale: false,
lastSuccessfulAt: '2026-09-10T00:00:00Z',
structure: { currencies: [], unvaluedCategories: [] },
});
describe('stock position', () => {
it('weights costs exactly and keeps preferred shares separate', () => {
const result = stockPosition(
portfolio([
row(),
row('3', '20', '70'),
row('100', '1', '100', 'preferred'),
]),
'common',
);
expect(result.quantity).toBe('5');
expect(result.averagePurchasePrice).toEqual(money('16'));
expect(result.currentValue).toEqual(money('100'));
expect(result.absoluteReturn).toEqual(money('20'));
expect(result.returnPercent).toBe('25');
expect(result.accounts[0].returnPercent).toBe('50');
});
it('does not round large amounts through floating point', () => {
const result = stockPosition(
portfolio([row('9007199254740993', '0.1', '900719925474099.3')]),
'common',
);
expect(result.quantity).toBe('9007199254740993');
expect(result.averagePurchasePrice?.value).toBe('0.1');
});
it('does not sum currencies or treat unknown values as zero', () => {
const second = {
...row(),
currentValue: money('2', 'USD'),
averagePurchasePrice: null,
absoluteReturn: null,
};
const result = stockPosition(portfolio([row(), second]), 'common');
expect(result.quantity).toBe('4');
expect(result.currentValue).toBeNull();
expect(result.averagePurchasePrice).toBeNull();
expect(result.absoluteReturn).toBeNull();
expect(result.returnPercent).toBeNull();
});
it('keeps no-position and offsetting long/short holdings honest', () => {
expect(stockPosition(portfolio([]), 'common')).toMatchObject({
quantity: '0',
accounts: [],
currentValue: null,
returnPercent: null,
});
expect(
stockPosition(portfolio([row(), row('-2')]), 'common')
.averagePurchasePrice,
).toBeNull();
});
});
+5
View File
@@ -0,0 +1,5 @@
import { instrumentPosition } from './instrument-position';
import type { PortfolioDto } from './portfolio.dto';
export function stockPosition(portfolio: PortfolioDto, uid: string) {
return instrumentPosition(portfolio, uid, 'STOCK');
}
+101
View File
@@ -0,0 +1,101 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { StockService } from './stock.service';
import {
StockCandlesDto,
StockCandlesQueryDto,
StockCompanyDto,
StockDividendsDto,
StockDto,
StockTickerParamsDto,
StockPositionDto,
StockQuoteDto,
StockSearchQueryDto,
StockSearchResultDto,
} from './stock.dto';
@ApiTags('stocks')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('stocks')
export class StockController {
constructor(private readonly stocks: StockService) {}
@Get()
@ApiOkResponse({ type: [StockSearchResultDto] })
search(
@Req() request: Request & { user: AccessPayload },
@Query() query: StockSearchQueryDto,
) {
return this.stocks.search(request.user.sub, query.query);
}
@Get('by-ticker/:ticker')
@ApiOkResponse({ type: StockDto })
getByTicker(
@Req() request: Request & { user: AccessPayload },
@Param() params: StockTickerParamsDto,
) {
return this.stocks.getByTicker(request.user.sub, params.ticker);
}
@Get(':uid')
@ApiOkResponse({ type: StockDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.stocks.get(request.user.sub, uid);
}
@Get(':uid/quote')
@ApiOkResponse({ type: StockQuoteDto })
quote(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.stocks.quote(request.user.sub, uid);
}
@Get(':uid/candles')
@ApiOkResponse({ type: StockCandlesDto })
candles(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: StockCandlesQueryDto,
) {
return this.stocks.candles(request.user.sub, uid, query.period);
}
@Get(':uid/position')
@ApiOkResponse({ type: StockPositionDto })
position(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.stocks.position(request.user.sub, uid);
}
@Get(':uid/company')
@ApiOkResponse({ type: StockCompanyDto })
company(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.stocks.company(request.user.sub, uid);
}
@Get(':uid/dividends')
@ApiOkResponse({ type: StockDividendsDto })
dividends(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.stocks.dividends(request.user.sub, uid);
}
}
+150
View File
@@ -0,0 +1,150 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
MaxLength,
Matches,
MinLength,
} from 'class-validator';
import { MoneyDto } from '../common/money.dto';
import { PortfolioPositionDto } from './portfolio.dto';
export class StockSearchQueryDto {
@ApiProperty({ minLength: 2, maxLength: 80 })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MinLength(2)
@MaxLength(80)
query: string;
}
export class StockTickerParamsDto {
@ApiProperty({
minLength: 1,
maxLength: 80,
pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$',
})
@IsString()
@MinLength(1)
@MaxLength(80)
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
ticker: string;
}
export class StockCandlesQueryDto {
@ApiPropertyOptional({
enum: ['month', 'halfYear', 'year', 'all'],
default: 'month',
})
@IsOptional()
@IsIn(['month', 'halfYear', 'year', 'all'])
period?: 'month' | 'halfYear' | 'year' | 'all';
}
export class StockDto {
@ApiProperty({ format: 'uuid' }) instrumentUid: string;
@ApiProperty({
description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
})
slug: string;
@ApiProperty() name: string;
@ApiProperty({ type: String, nullable: true }) isin: string | null;
@ApiProperty({ type: String, nullable: true }) classCode: string | null;
@ApiProperty({ type: String, nullable: true }) realExchange: string | null;
@ApiProperty({ type: Boolean, nullable: true }) apiTradeAvailable:
boolean | null;
@ApiProperty({ type: String, nullable: true }) ticker: string | null;
@ApiProperty({ type: String, nullable: true }) logoName: string | null;
@ApiProperty({ type: String, nullable: true }) currency: string | null;
@ApiProperty({ type: String, nullable: true }) shareType: string | null;
@ApiProperty({ type: String, nullable: true }) sector: string | null;
@ApiProperty({ type: Number, nullable: true }) lot: number | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
historyFrom: string | null;
}
export class StockSearchResultDto extends StockDto {
@ApiProperty({ type: MoneyDto, nullable: true }) price: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt:
string | null;
}
export class StockQuoteDto {
@ApiProperty({ type: MoneyDto, nullable: true }) price: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt:
string | null;
@ApiProperty({ type: String, nullable: true }) tradingStatus: string | null;
@ApiProperty({ type: MoneyDto, nullable: true }) change: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) changePercent: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date' })
previousCloseDate: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
previousClose: MoneyDto | null;
@ApiProperty({ type: [String] }) unavailable: string[];
@ApiProperty({ format: 'date-time' }) observedAt: string;
}
export class StockCandleDto {
@ApiProperty({ format: 'date-time' }) time: string;
@ApiProperty() close: string;
@ApiProperty() isComplete: boolean;
}
export class StockCandlesDto {
@ApiProperty({ type: [StockCandleDto] }) candles: StockCandleDto[];
@ApiProperty({ type: String, nullable: true }) currency: string | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
historyFrom: string | null;
@ApiProperty({ format: 'date-time' }) observedAt: string;
}
export class StockPositionRowDto extends PortfolioPositionDto {
@ApiProperty({ type: String, nullable: true }) returnPercent: string | null;
}
export class StockPositionDto {
@ApiProperty() quantity: string;
@ApiProperty({ type: MoneyDto, nullable: true })
currentValue: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
averagePurchasePrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true })
absoluteReturn: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) returnPercent: string | null;
@ApiProperty({ type: [StockPositionRowDto] }) accounts: StockPositionRowDto[];
@ApiProperty() isStale: boolean;
@ApiProperty({ format: 'date-time' }) observedAt: string;
}
export class StockMetricDto {
@ApiProperty() key: string;
@ApiProperty() value: number;
@ApiProperty({ enum: ['money', 'ratio', 'percent'] }) unit:
'money' | 'ratio' | 'percent';
@ApiProperty({ type: String, nullable: true }) currency: string | null;
@ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] }) period:
'TTM' | 'MRQ' | null;
}
export class StockCompanyDto {
@ApiProperty({ type: String, nullable: true }) description: string | null;
@ApiProperty({ type: [StockMetricDto] }) metrics: StockMetricDto[];
@ApiProperty({ type: [String] }) unavailable: string[];
@ApiProperty({ format: 'date-time' }) observedAt: string;
}
export class StockDividendDto {
@ApiProperty({ type: MoneyDto, nullable: true }) perShare: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date' }) recordDate:
string | null;
@ApiProperty({ type: String, nullable: true, format: 'date' }) lastBuyDate:
string | null;
@ApiProperty({ type: String, nullable: true, format: 'date' }) paymentDate:
string | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) timing:
'upcoming' | 'past' | 'undated';
@ApiProperty({ type: MoneyDto, nullable: true })
estimatedAmount: MoneyDto | null;
}
export class StockDividendsDto {
@ApiProperty({ type: [StockDividendDto] }) dividends: StockDividendDto[];
@ApiProperty({ format: 'date' }) from: string;
@ApiProperty({ format: 'date' }) to: string;
@ApiProperty({ format: 'date-time' }) observedAt: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
positionAt: string | null;
@ApiProperty() estimateUnavailable: boolean;
@ApiProperty() positionIsStale: boolean;
}
+354
View File
@@ -0,0 +1,354 @@
jest.mock('./tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import {
ServiceUnavailableException,
ConflictException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { StockService, previousClose } from './stock.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
import { PortfolioService } from './portfolio.service';
const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) });
const share = {
uid: 'uid',
assetUid: 'asset',
currency: 'rub',
sector: 'financial',
first_1dayCandleDate: time('2025-11-20'),
};
function setup() {
let generation = 'a';
const tbank = {
findShares: jest.fn().mockResolvedValue([]),
getLastPrices: jest.fn().mockResolvedValue([]),
getShare: jest.fn().mockResolvedValue(share),
getCandles: jest.fn().mockResolvedValue([]),
getAssetDescription: jest.fn().mockResolvedValue('Company'),
getFundamentals: jest.fn().mockResolvedValue({
assetUid: 'asset',
currency: 'rub',
peRatioTtm: 6,
netIncomeTtm: 10,
}),
getDividends: jest.fn().mockResolvedValue([]),
};
const access = {
withConnection: jest.fn(
(user: string, handler: (context: unknown) => unknown) =>
handler({ token: user, generation }),
),
};
const portfolios = {
getAll: jest.fn().mockResolvedValue({
positions: [],
lastSuccessfulAt: '2026-09-10T00:00:00Z',
isStale: false,
}),
};
const service = new StockService(
access as unknown as TbankAccessService,
tbank as unknown as TbankClient,
portfolios as unknown as PortfolioService,
);
return {
service,
tbank,
portfolios,
changeGeneration: () => {
generation = 'b';
},
};
}
describe('stock market use case', () => {
beforeEach(() =>
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
);
afterEach(() => jest.useRealTimers());
it('returns unique ISINs, keeps preferred shares separate and matches prices by selected UID', async () => {
const { service, tbank } = setup();
const listings = [
{
uid: 'special',
ticker: 'SBER',
classCode: 'SPEQ',
apiTradeAvailableFlag: false,
},
{
uid: 'preferred',
ticker: 'SBERP',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
{
uid: 'common',
ticker: 'SBER',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
].map((item) => ({
...item,
name: 'Сбербанк',
isin: item.uid === 'preferred' ? 'ISINP' : 'ISIN',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
}));
tbank.findShares.mockResolvedValue([
...listings,
{ uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' },
]);
tbank.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve({
...listings.find((item) => item.uid === uid),
currency: 'rub',
brand: { logoName: 'sber.png' },
shareType:
uid === 'preferred' ? 'SHARE_TYPE_PREFERRED' : 'SHARE_TYPE_COMMON',
}),
);
tbank.getLastPrices.mockResolvedValue([
{
instrumentUid: 'preferred',
price: { units: '305' },
time: time('2026-09-09'),
},
{
instrumentUid: 'common',
price: { units: '306', nano: 120000000 },
time: time('2026-09-10'),
},
]);
const result = await service.search('user', 'SBER');
expect(result.map((item) => item.instrumentUid)).toEqual([
'common',
'preferred',
]);
expect(result[0]).toMatchObject({
classCode: 'TQBR',
isin: 'ISIN',
logoName: 'sber.png',
price: { currency: 'RUB', value: '306.12' },
priceAt: '2026-09-10T00:00:00.000Z',
});
expect(result[1]).toMatchObject({
shareType: 'SHARE_TYPE_PREFERRED',
price: { value: '305' },
});
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
});
it('prefers TQBR among equally available listings, deduplicates before the limit and keeps missing ISINs separate', async () => {
const { service, tbank } = setup();
const hit = {
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
ticker: 'SBER',
apiTradeAvailableFlag: true,
};
tbank.findShares.mockResolvedValue([
...Array.from({ length: 35 }, (_, i) => ({
...hit,
uid: `alternate-${i}`,
isin: ' isin ',
classCode: 'SPEQ',
})),
{ ...hit, uid: 'common', isin: 'ISIN', classCode: 'TQBR' },
{
...hit,
uid: 'preferred',
ticker: 'SBERP',
isin: 'ISINP',
classCode: 'TQBR',
},
{ ...hit, uid: 'unknown-a', isin: '' },
{ ...hit, uid: 'unknown-b' },
]);
tbank.getShare.mockResolvedValue(null);
const result = await service.search('user', 'SBER');
expect(result.map((item) => item.instrumentUid)).toEqual([
'common',
'unknown-a',
'unknown-b',
'preferred',
]);
expect(tbank.getShare).toHaveBeenCalledTimes(4);
});
it('retains catalog identity when enrichment is unavailable, but propagates auth errors', async () => {
const { service, tbank } = setup();
tbank.findShares.mockResolvedValue([
{
uid: 'uid',
ticker: 'SBER',
classCode: 'TQBR',
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
},
]);
tbank.getShare.mockRejectedValue(new ServiceUnavailableException());
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
expect(await service.search('user', 'SBER')).toEqual([
expect.objectContaining({
instrumentUid: 'uid',
classCode: 'TQBR',
price: null,
logoName: null,
}),
]);
tbank.getShare.mockResolvedValue(null);
expect(await service.search('user', 'sber')).toEqual([
expect.objectContaining({ instrumentUid: 'uid' }),
]);
tbank.getShare.mockRejectedValue(new UnauthorizedException());
await expect(service.search('another-user', 'SBER')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('skips enrichment for an empty catalog result', async () => {
const { service, tbank } = setup();
expect(await service.search('user', 'none')).toEqual([]);
expect(tbank.getShare).not.toHaveBeenCalled();
expect(tbank.getLastPrices).not.toHaveBeenCalled();
});
it('resolves an exact ticker, preserves other listing UIDs and does not confuse SBER with SBERP', async () => {
const { service, tbank } = setup();
const listings = [
{
uid: 'common',
ticker: 'SBER',
isin: 'COMMON',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
{
uid: 'special',
ticker: 'SBER',
isin: 'COMMON',
classCode: 'SPEQ',
apiTradeAvailableFlag: false,
},
{
uid: 'preferred',
ticker: 'SBERP',
isin: 'PREFERRED',
classCode: 'TQBR',
apiTradeAvailableFlag: true,
},
].map((item) => ({ ...item, instrumentKind: 'INSTRUMENT_TYPE_SHARE' }));
tbank.findShares.mockResolvedValue(listings);
tbank.getShare.mockImplementation((_token: string, uid: string) =>
Promise.resolve(listings.find((item) => item.uid === uid)),
);
expect(await service.getByTicker('user', 'sberp')).toMatchObject({
instrumentUid: 'preferred',
slug: 'SBERP',
});
expect(await service.get('user', 'common')).toMatchObject({
instrumentUid: 'common',
slug: 'SBER',
});
expect(await service.get('user', 'special')).toMatchObject({
instrumentUid: 'special',
slug: 'special',
});
await expect(service.getByTicker('user', 'SB')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('refuses ambiguous tickers while keeping exact UID pages accessible', async () => {
const { service, tbank } = setup();
const listings = ['one', 'two'].map((uid) => ({
uid,
ticker: 'SAME',
isin: uid,
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
}));
tbank.findShares.mockResolvedValue(listings);
tbank.getShare.mockResolvedValue(listings[0]);
await expect(service.getByTicker('user', 'SAME')).rejects.toBeInstanceOf(
ConflictException,
);
expect(await service.get('user', 'one')).toMatchObject({
instrumentUid: 'one',
slug: 'one',
});
});
it('keeps exact pages if alias lookup fails and partitions ticker resolution by connection', async () => {
const { service, tbank, changeGeneration } = setup();
tbank.getShare.mockResolvedValue({ ...share, ticker: 'F' });
tbank.findShares.mockRejectedValueOnce(new ServiceUnavailableException());
expect(await service.get('user', 'uid')).toMatchObject({ slug: 'uid' });
tbank.findShares.mockResolvedValue([
{ ...share, ticker: 'F', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
]);
expect(await service.getByTicker('user', 'F')).toMatchObject({
instrumentUid: 'uid',
slug: 'F',
});
changeGeneration();
expect(await service.getByTicker('user', 'F')).toMatchObject({ slug: 'F' });
expect(tbank.findShares).toHaveBeenCalledTimes(3);
});
it('uses a complete previous trading day, never today’s main close', () => {
const candles = [
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: true },
{ time: time('2026-09-10'), close: { units: '11' }, isComplete: true },
{ time: time('2026-09-08'), close: { units: '9' }, isComplete: false },
];
expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]);
expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]);
});
it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => {
const { service, tbank } = setup();
tbank.getCandles.mockResolvedValue([
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: false },
]);
const result = await service.candles('user', 'uid', 'all');
expect(tbank.getCandles).toHaveBeenCalledWith(
'user',
'uid',
new Date('2025-11-20'),
expect.any(Date),
);
expect(result.candles).toHaveLength(1);
expect(result.candles[0].isComplete).toBe(false);
});
it('does not manufacture zero fundamentals and isolates optional failures', async () => {
const { service, tbank } = setup();
tbank.getAssetDescription.mockRejectedValue(
new ServiceUnavailableException(),
);
const result = await service.company('user', 'uid');
expect(result.description).toBeNull();
expect(result.unavailable).toEqual(['description']);
expect(result.metrics.map((item) => item.key)).toEqual([
'peRatioTtm',
'netIncomeTtm',
]);
});
it('isolates cache by user and connection generation', async () => {
const { service, tbank, changeGeneration } = setup();
await service.get('a', 'uid');
await service.get('a', 'uid');
expect(tbank.getShare).toHaveBeenCalledTimes(1);
await service.get('b', 'uid');
changeGeneration();
await service.get('a', 'uid');
expect(tbank.getShare).toHaveBeenCalledTimes(3);
});
it('keeps public dividends when there is no holding or position retrieval fails', async () => {
const { service, tbank, portfolios } = setup();
portfolios.getAll.mockRejectedValue(new ServiceUnavailableException());
tbank.getDividends.mockResolvedValue([
{
dividendNet: { currency: 'rub', units: '32', nano: 880000000 },
recordDate: time('2026-10-13'),
paymentDate: time('2026-10-27'),
},
]);
const result = await service.dividends('user', 'uid');
expect(result.estimateUnavailable).toBe(true);
expect(result.dividends[0]).toMatchObject({
perShare: { currency: 'RUB', value: '32.88' },
estimatedAmount: null,
timing: 'upcoming',
});
});
});
+511
View File
@@ -0,0 +1,511 @@
import {
timestamp,
moscowDay,
optional,
previousClose,
} from './instrument-market';
export { previousClose } from './instrument-market';
import {
Injectable,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import {
decimalFromString,
divide,
formatDecimal,
multiply,
subtract,
} from '../common/decimal';
import { decimal } from './decimal';
import { PortfolioService } from './portfolio.service';
import { stockPosition } from './stock-position';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
import type { TbankFundamentals, TbankShare } from './tbank-market.types';
import {
StockCandlesDto,
StockCandlesQueryDto,
StockCompanyDto,
StockDividendDto,
StockDividendsDto,
StockDto,
StockMetricDto,
StockPositionDto,
StockQuoteDto,
StockSearchResultDto,
} from './stock.dto';
const dayMs = 86_400_000;
@Injectable()
export class StockService {
private readonly cache = new LRUCache<string, Promise<object>>({
max: 300,
ttl: 30_000,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly portfolios: PortfolioService,
) {}
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (token) => {
const found = await this.tbank.findShares(token, query);
const shares = selectShares(found, query).slice(0, 30);
if (!shares.length) return [];
// One market-data request for the list; failed enrichment must not hide a catalog result.
const [details, prices] = await Promise.all([
Promise.all(
shares.map(
async (share) =>
(await optional(
() =>
this.get(userId, share.uid!).catch((error: unknown) => {
if (error instanceof NotFoundException) return stock(share);
throw error;
}),
'details',
[],
)) ?? stock(share),
),
),
optional(
() =>
this.tbank.getLastPrices(
token,
shares.map((share) => share.uid!),
),
'prices',
[],
),
]);
return details.map((detail, index) => {
const short = stock(shares[index]);
const last = prices?.find(
(price) => price.instrumentUid === detail.instrumentUid,
);
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
price:
last?.price && detail.currency
? {
currency: detail.currency,
value: formatDecimal(decimal(last.price)),
}
: null,
priceAt: timestamp(last?.time),
};
});
});
}
get(userId: string, uid: string): Promise<StockDto> {
return this.cached(userId, `share:${uid}`, async (token) => {
const result = stock(await this.share(token, uid));
if (
!result.ticker ||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
)
return result;
try {
const match = await this.tickerMatch(userId, result.ticker);
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
} catch (error) {
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
if (
!(error instanceof NotFoundException) &&
!(error instanceof ConflictException) &&
!(error instanceof ServiceUnavailableException)
)
throw error;
}
return result;
});
}
async getByTicker(userId: string, ticker: string): Promise<StockDto> {
const match = await this.tickerMatch(userId, ticker);
return {
...(await this.get(userId, match.uid!)),
slug: ticker.trim().toUpperCase(),
};
}
private tickerMatch(userId: string, ticker: string): Promise<TbankShare> {
const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (token) => {
const found = await this.tbank.findShares(token, normalized);
const matches = selectShares(
found.filter(
(item) => item.ticker?.trim().toUpperCase() === normalized,
),
normalized,
);
if (!matches.length) throw new NotFoundException();
if (matches.length > 1)
throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' });
return matches[0];
});
}
quote(userId: string, uid: string): Promise<StockQuoteDto> {
return this.cached(userId, `quote:${uid}`, async (token) => {
const share = await this.share(token, uid);
const unavailable: string[] = [];
const [last, tradingStatus] = await Promise.all([
optional(
() => this.tbank.getLastPrice(token, uid),
'price',
unavailable,
),
optional(
() => this.tbank.getTradingStatus(token, uid),
'status',
unavailable,
),
]);
const priceAt = timestamp(last?.time);
const currency = share.currency?.toUpperCase();
const price =
last?.price && currency
? { currency, value: formatDecimal(decimal(last.price)) }
: null;
const previous = priceAt
? await optional(
async () => {
const end = new Date(priceAt);
return previousClose(
await this.tbank.getCandles(
token,
uid,
new Date(end.valueOf() - 20 * dayMs),
end,
),
priceAt,
);
},
'comparison',
unavailable,
)
: null;
const previousPrice =
previous?.close && currency
? { currency, value: formatDecimal(decimal(previous.close)) }
: null;
const difference =
price && previousPrice
? subtract(
decimalFromString(price.value),
decimalFromString(previousPrice.value),
)
: null;
return {
price,
priceAt,
tradingStatus,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
previousClose: previousPrice,
change:
difference && currency
? { currency, value: formatDecimal(difference) }
: null,
changePercent:
difference &&
previousPrice &&
decimalFromString(previousPrice.value).value > 0n
? formatDecimal(
divide(
multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value),
6,
),
)
: null,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
candles(
userId: string,
uid: string,
period: StockCandlesQueryDto['period'] = 'month',
): Promise<StockCandlesDto> {
return this.cached(userId, `candles:${uid}:${period}`, async (token) => {
const share = await this.share(token, uid);
const historyFrom = timestamp(share.first_1dayCandleDate);
const to = new Date();
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
);
if (period === 'all') {
if (!historyFrom)
throw new ServiceUnavailableException({
code: 'STOCK_HISTORY_START_UNAVAILABLE',
});
from.setTime(Date.parse(historyFrom));
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
from.setTime(Date.parse(historyFrom));
const collected = new Map<string, StockCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf());
for (const candle of await this.tbank.getCandles(
token,
uid,
new Date(start),
new Date(end),
)) {
const time = timestamp(candle.time);
if (
time &&
candle.close &&
Date.parse(time) >= from.valueOf() - dayMs &&
Date.parse(time) <= to.valueOf()
)
collected.set(time, {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
});
}
start = end;
}
return {
candles: [...collected.values()].sort((a, b) =>
a.time.localeCompare(b.time),
),
currency: share.currency?.toUpperCase() || null,
historyFrom,
observedAt: new Date().toISOString(),
};
});
}
async position(userId: string, uid: string): Promise<StockPositionDto> {
return this.cached(userId, `position:${uid}`, async () =>
stockPosition(await this.portfolios.getAll(userId), uid),
);
}
company(userId: string, uid: string): Promise<StockCompanyDto> {
return this.cached(userId, `company:${uid}`, async (token) => {
const share = await this.share(token, uid);
const unavailable: string[] = [];
if (!share.assetUid)
return {
description: null,
metrics: [],
unavailable,
observedAt: new Date().toISOString(),
};
const [description, fundamentals] = await Promise.all([
optional(
() => this.tbank.getAssetDescription(token, share.assetUid!),
'description',
unavailable,
),
optional(
() => this.tbank.getFundamentals(token, share.assetUid!),
'fundamentals',
unavailable,
),
]);
return {
description,
metrics: metrics(fundamentals, share.sector),
unavailable,
observedAt: new Date().toISOString(),
};
});
}
dividends(userId: string, uid: string): Promise<StockDividendsDto> {
return this.cached(userId, `dividends:${uid}`, async (token) => {
await this.share(token, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.tbank.getDividends(token, uid, from, to),
optional(() => this.position(userId, uid), 'position', unavailable),
]);
const quantity =
position &&
position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => {
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
const paymentDate =
timestamp(event.paymentDate)?.slice(0, 10) ?? null;
const lastBuyDate =
timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
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 timing: StockDividendDto['timing'] =
(paymentDate ?? recordDate)
? (paymentDate ?? recordDate)! >= today
? 'upcoming'
: 'past'
: 'undated';
const estimatedAmount =
quantity &&
perShare &&
recordDate &&
recordDate >= today &&
timing === 'upcoming'
? {
currency: perShare.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)),
),
}
: null;
return {
perShare,
recordDate,
lastBuyDate,
paymentDate,
timing,
estimatedAmount,
};
})
.sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? ''));
return {
dividends,
from,
to,
observedAt: new Date().toISOString(),
positionAt: quantity ? (position?.observedAt ?? null) : null,
estimateUnavailable: unavailable.length > 0,
positionIsStale: position?.isStale ?? false,
};
});
}
private async share(token: string, uid: string): Promise<TbankShare> {
const share = await this.tbank.getShare(token, uid);
if (!share || share.uid !== uid) throw new NotFoundException();
return share;
}
private cached<T extends object>(
userId: string,
key: string,
operation: (token: string) => Promise<T>,
): Promise<T> {
return this.access.withConnection(userId, ({ token, generation }) => {
const cacheKey = JSON.stringify([userId, generation, key]);
const cached = this.cache.get(cacheKey);
if (cached) return cached as Promise<T>;
const pending = operation(token);
this.cache.set(cacheKey, pending);
return pending.catch((error) => {
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
throw error;
});
});
}
}
function selectShares(found: TbankShare[], query: string): TbankShare[] {
const seen = new Set<string>();
return found
.filter(
(share) => share.uid && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE',
)
.sort(
(a, b) =>
Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') ||
a.uid!.localeCompare(b.uid!),
)
.filter((share) => {
// A missing ISIN must not collapse unrelated catalog instruments.
const key = share.isin?.trim()
? `isin:${share.isin.trim().toUpperCase()}`
: `uid:${share.uid!}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function stock(share: TbankShare): StockDto {
return {
instrumentUid: share.uid!,
slug: share.uid!,
name: share.name?.trim() || share.ticker || share.uid!,
ticker: share.ticker?.trim() || null,
isin: share.isin?.trim() || null,
classCode: share.classCode?.trim() || null,
realExchange: share.realExchange || null,
apiTradeAvailable: share.apiTradeAvailableFlag ?? null,
logoName: share.brand?.logoName?.trim() || null,
currency: share.currency?.toUpperCase() || null,
shareType: share.shareType || null,
sector: share.sector || null,
lot: share.lot && share.lot > 0 ? share.lot : null,
historyFrom: timestamp(share.first_1dayCandleDate),
};
}
function metrics(
values: TbankFundamentals | null,
sector?: string,
): StockMetricDto[] {
if (!values) return [];
const bank = sector?.toLowerCase() === 'financial';
const selection: [
keyof TbankFundamentals,
StockMetricDto['unit'],
StockMetricDto['period'],
][] = [
['marketCapitalization', 'money', null],
['peRatioTtm', 'ratio', 'TTM'],
...(bank
? ([
['priceToBookTtm', 'ratio', 'TTM'],
['roe', 'percent', null],
] as typeof selection)
: ([
['revenueTtm', 'money', 'TTM'],
['ebitdaTtm', 'money', 'TTM'],
] as typeof selection)),
['netIncomeTtm', 'money', 'TTM'],
...(!bank
? ([
['totalDebtMrq', 'money', 'MRQ'],
['netDebtToEbitda', 'ratio', null],
] as typeof selection)
: []),
];
return selection.flatMap(([key, unit, period]) => {
const value = values[key];
const currency = values.currency?.toUpperCase() || null;
return typeof value === 'number' &&
Number.isFinite(value) &&
(unit !== 'money' || currency)
? [
{
key,
value,
unit,
period,
currency: unit === 'money' ? currency : null,
},
]
: [];
});
}
+10
View File
@@ -1,3 +1,8 @@
import { NcrRatingsClient } from './ncr-ratings.client';
import { BondController } from './bond.controller';
import { BondService } from './bond.service';
import { StockController } from './stock.controller';
import { StockService } from './stock.service';
import { Module } from '@nestjs/common';
import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -19,6 +24,8 @@ import { IncomeAnalyticsService } from './income-analytics.service';
@Module({
controllers: [
StockController,
BondController,
TbankConnectionController,
BrokerageAccountsController,
BrokerageOperationsController,
@@ -27,6 +34,8 @@ import { IncomeAnalyticsService } from './income-analytics.service';
IncomeAnalyticsController,
],
providers: [
StockService,
BondService,
TbankClient,
TbankConnectionService,
BrokerageAccountsService,
@@ -34,6 +43,7 @@ import { IncomeAnalyticsService } from './income-analytics.service';
InstrumentMetadataService,
PortfolioService,
MoexClient,
NcrRatingsClient,
UpcomingEventsService,
TbankTokenService,
TbankAccessService,
+69
View File
@@ -0,0 +1,69 @@
import type {
TbankDecimal,
TbankMoney,
TbankInstrument,
TbankTimestamp,
} from './tbank.client';
export type TbankShare = TbankInstrument & {
uid?: string;
figi?: string;
assetUid?: string;
classCode?: string;
realExchange?: string;
apiTradeAvailableFlag?: boolean;
currency?: string;
shareType?: string;
instrumentKind?: string;
sector?: string;
lot?: number;
first_1dayCandleDate?: TbankTimestamp;
};
export type TbankCandle = {
time?: TbankTimestamp;
close?: TbankDecimal;
isComplete?: boolean;
};
export type TbankLastPrice = {
instrumentUid?: string;
price?: TbankDecimal;
time?: TbankTimestamp;
};
export type TbankFundamentals = {
assetUid?: string;
currency?: string;
marketCapitalization?: number;
peRatioTtm?: number;
priceToBookTtm?: number;
revenueTtm?: number;
netIncomeTtm?: number;
ebitdaTtm?: number;
roe?: number;
totalDebtMrq?: number;
netDebtToEbitda?: number;
};
export type TbankBond = Omit<TbankShare, 'shareType'> & {
nominal?: TbankMoney;
initialNominal?: TbankMoney;
aciValue?: TbankMoney;
maturityDate?: TbankTimestamp;
callDate?: TbankTimestamp;
couponQuantityPerYear?: number;
floatingCouponFlag?: boolean;
perpetualFlag?: boolean;
amortizationFlag?: boolean;
subordinatedFlag?: boolean;
bondType?: string;
riskLevel?: string;
};
export type TbankCoupon = {
couponNumber?: string;
couponDate?: TbankTimestamp;
fixDate?: TbankTimestamp;
payOneBond?: TbankMoney;
couponType?: string;
couponStartDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponPeriod?: number;
};
+185 -1
View File
@@ -1,4 +1,4 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { status } from '@grpc/grpc-js';
import { TbankClient } from './tbank.client';
@@ -120,3 +120,187 @@ describe('TbankClient operations', () => {
jest.useRealTimers();
});
});
describe('TbankClient metadata retries', () => {
const instrument = { name: 'Облигация', ticker: 'BOND' };
let client: TbankClient;
let requestRpc: jest.Mock;
let queue: { size: number; run: jest.Mock };
let logger: {
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
};
beforeEach(() => {
jest.useFakeTimers();
requestRpc = jest.fn();
queue = {
size: 0,
run: jest.fn((operation: () => Promise<unknown>) => operation()),
};
logger = {
event: jest.fn<void, [string, string, Record<string, unknown>]>(),
};
client = Object.assign(
Object.create(TbankClient.prototype) as TbankClient,
{
request: requestRpc,
queue,
logger,
},
);
});
afterEach(() => {
jest.useRealTimers();
});
it.each([
status.UNAVAILABLE,
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
])(
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
async (code) => {
requestRpc
.mockRejectedValueOnce({ code })
.mockRejectedValueOnce({ code })
.mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid');
await jest.advanceTimersByTimeAsync(1999);
expect(requestRpc).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(3999);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(3);
expect(jest.getTimerCount()).toBe(0);
},
);
it('rechecks a full queue before admitting a metadata retry', async () => {
queue.size = 50;
requestRpc.mockResolvedValue({ instrument });
const result = client.getInstrumentByFigi('token', 'figi');
await jest.advanceTimersByTimeAsync(1999);
expect(queue.run).not.toHaveBeenCalled();
queue.size = 49;
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(1);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
);
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
});
it('does not bypass queue admission after an upstream failure', async () => {
requestRpc
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid');
queue.size = 50;
await jest.advanceTimersByTimeAsync(2000);
expect(queue.run).toHaveBeenCalledTimes(1);
queue.size = 0;
await jest.advanceTimersByTimeAsync(4000);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(2);
});
it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50;
const result = expect(
client.getInstrumentByUid('token', 'uid'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3);
expect(logger.event).toHaveBeenLastCalledWith(
'error',
'tbank.rpc.failed',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
);
expect(jest.getTimerCount()).toBe(0);
});
it('stops retrying an unavailable upstream after three RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
const result = expect(
client.getInstrumentByUid('token', 'uid'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).toHaveBeenCalledTimes(3);
expect(jest.getTimerCount()).toBe(0);
});
it.each([status.UNAUTHENTICATED, status.PERMISSION_DENIED])(
'does not retry or mask metadata authentication status %s',
async (code) => {
requestRpc.mockRejectedValue({ code });
await expect(client.getInstrumentByUid('token', 'uid')).rejects.toThrow(
'INVALID_TBANK_TOKEN',
);
expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
},
);
it.each(['getInstrumentByUid', 'getInstrumentByFigi'] as const)(
'returns confirmed absence from %s without retrying',
async (method) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
},
);
it('keeps confirmed absence from ShareBy as a not-found error', async () => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
await expect(client.getShare('token', 'MISSING')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
});
it('does not retry invalid metadata arguments', async () => {
requestRpc.mockRejectedValue({ code: status.INVALID_ARGUMENT });
await expect(
client.getInstrumentByUid('token', 'uid'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
});
it('keeps other RPCs failing immediately when the queue is full', async () => {
queue.size = 50;
await expect(
client.getPortfolio('token', 'account'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(queue.run).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
});
+234 -38
View File
@@ -1,4 +1,8 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ChannelCredentials,
@@ -15,6 +19,15 @@ import { join } from 'node:path';
import { Environment } from '../config/env.validation';
import { StructuredLogger } from '../common/logging/structured-logger.service';
import type {
TbankShare,
TbankBond,
TbankCoupon,
TbankCandle,
TbankLastPrice,
TbankFundamentals,
} from './tbank-market.types';
const requestDeadlineMs = 15_000;
type Unary<Request, Response> = (
@@ -53,6 +66,8 @@ export type TbankInstrument = {
export type TbankDividend = {
dividendNet?: TbankMoney;
recordDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
lastBuyDate?: TbankTimestamp;
dividendType?: string;
};
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
@@ -72,9 +87,11 @@ export type TbankOperationItem = {
commission?: TbankMoney;
accruedInt?: TbankMoney;
quantity?: string | number;
quantityDone?: string | number;
};
export type TbankOperationsByCursorRequest = {
accountId: string;
instrumentId?: string;
from?: TbankTimestamp;
to?: TbankTimestamp;
cursor?: string;
@@ -140,6 +157,8 @@ export class TbankClient {
private readonly users: TbankClientService;
private readonly operations: TbankClientService;
private readonly instruments: TbankClientService;
private readonly sparseInstruments: TbankClientService;
private readonly marketData: TbankClientService;
constructor(
config: ConfigService<Environment, true>,
@@ -150,9 +169,12 @@ export class TbankClient {
? sourceProtoRoot
: join(process.cwd(), 'dist/tbank/proto/contracts');
const definition = loadSync(
['users.proto', 'operations.proto', 'instruments.proto'].map((file) =>
join(protoRoot, file),
),
[
'users.proto',
'operations.proto',
'instruments.proto',
'marketdata.proto',
].map((file) => join(protoRoot, file)),
{
includeDirs: [protoRoot],
keepCase: false,
@@ -180,6 +202,26 @@ export class TbankClient {
this.users = create('UsersService');
this.operations = create('OperationsService');
this.instruments = create('InstrumentsService');
this.marketData = create('MarketDataService');
// Preserve field presence for fundamentals; the established clients retain their decoding contract.
const sparse = (
loadPackageDefinition(
loadSync(join(protoRoot, 'instruments.proto'), {
includeDirs: [protoRoot],
keepCase: false,
longs: String,
enums: String,
defaults: false,
oneofs: true,
}),
) as unknown as TbankNamespace
).tinkoff.public.invest.api.contract.v1;
this.sparseInstruments = new (
sparse.InstrumentsService as new (
address: string,
credentials: ChannelCredentials,
) => TbankClientService
)(endpoint, credentials);
this.queue = new RateLimiter(
config.getOrThrow<number>('TBANK_RATE_LIMIT_PER_SECOND'),
);
@@ -217,26 +259,14 @@ export class TbankClient {
token: string,
figi: string,
): Promise<TbankInstrument | null> {
return this.call(token, this.instruments, 'getInstrumentBy', {
idType: 'INSTRUMENT_ID_TYPE_FIGI',
id: figi,
}).then(
(response: { instrument?: TbankInstrument }) =>
response.instrument ?? null,
);
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
}
getInstrumentByUid(
token: string,
instrumentUid: string,
): Promise<TbankInstrument | null> {
return this.call(token, this.instruments, 'getInstrumentBy', {
idType: 'INSTRUMENT_ID_TYPE_UID',
id: instrumentUid,
}).then(
(response: { instrument?: TbankInstrument }) =>
response.instrument ?? null,
);
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
}
getDividends(
@@ -254,24 +284,178 @@ export class TbankClient {
);
}
findShares(token: string, query: string): Promise<TbankShare[]> {
return this.call<{ instruments?: TbankShare[] }>(
token,
this.instruments,
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
},
).then((response) => response.instruments ?? []);
}
findBonds(token: string, query: string): Promise<TbankBond[]> {
return this.call<{ instruments?: TbankBond[] }>(
token,
this.sparseInstruments,
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_BOND',
},
).then((response) => response.instruments ?? []);
}
getBond(token: string, uid: string): Promise<TbankBond | null> {
return this.call<{ instrument?: TbankBond }>(
token,
this.sparseInstruments,
'bondBy',
{
idType: 'INSTRUMENT_ID_TYPE_UID',
id: uid,
},
).then((response) => response.instrument ?? null);
}
getBondCoupons(
token: string,
uid: string,
from: string,
to: string,
): Promise<TbankCoupon[]> {
return this.call<{ events?: TbankCoupon[] }>(
token,
this.sparseInstruments,
'getBondCoupons',
{
instrumentId: uid,
from: { seconds: String(utcSeconds(from)) },
to: { seconds: String(utcSeconds(to)) },
},
).then((response) => response.events ?? []);
}
getShare(token: string, uid: string): Promise<TbankShare | null> {
return this.call<{ instrument?: TbankShare }>(
token,
this.instruments,
'shareBy',
{
idType: 'INSTRUMENT_ID_TYPE_UID',
id: uid,
},
).then((response) => response.instrument ?? null);
}
getAssetDescription(token: string, assetUid: string): Promise<string | null> {
return this.call<{ asset?: { description?: string } }>(
token,
this.instruments,
'getAssetBy',
{
id: assetUid,
},
).then((response) => response.asset?.description?.trim() || null);
}
getFundamentals(
token: string,
assetUid: string,
): Promise<TbankFundamentals | null> {
return this.call<{ fundamentals?: TbankFundamentals[] }>(
token,
this.sparseInstruments,
'getAssetFundamentals',
{
assets: [assetUid],
},
).then(
(response) =>
response.fundamentals?.find((item) => item.assetUid === assetUid) ??
null,
);
}
getLastPrices(token: string, uids: string[]): Promise<TbankLastPrice[]> {
return this.call<{ lastPrices?: TbankLastPrice[] }>(
token,
this.marketData,
'getLastPrices',
{ instrumentId: uids },
).then((response) => response.lastPrices ?? []);
}
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<string | null> {
return this.call<{ tradingStatus?: string }>(
token,
this.marketData,
'getTradingStatus',
{
instrumentId: uid,
},
).then((response) => response.tradingStatus || null);
}
getCandles(
token: string,
uid: string,
from: Date,
to: Date,
): Promise<TbankCandle[]> {
return this.call<{ candles?: TbankCandle[] }>(
token,
this.marketData,
'getCandles',
{
instrumentId: uid,
from: { seconds: String(Math.floor(from.valueOf() / 1000)) },
to: { seconds: String(Math.floor(to.valueOf() / 1000)) },
interval: 'CANDLE_INTERVAL_DAY',
limit: 2400,
},
).then((response) => response.candles ?? []);
}
private async getInstrument(
token: string,
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI',
id: string,
): Promise<TbankInstrument | null> {
try {
const response = await this.call<{ instrument?: TbankInstrument }>(
token,
this.instruments,
'getInstrumentBy',
{ idType, id },
);
return response.instrument ?? null;
} catch (error) {
if (error instanceof NotFoundException) return null;
throw error;
}
}
private async call<Response>(
token: string,
client: TbankClientService,
method: string,
request: object,
): Promise<Response> {
if (this.queue.size >= 50) {
this.logger.event('error', 'tbank.rpc.failed', {
operation: method,
code: 'TBANK_UNAVAILABLE',
attempts: 0,
});
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
for (let attempts = 1; attempts <= 2; attempts += 1) {
const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? 3 : 2;
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
const startedAt = performance.now();
const queueFull = this.queue.size >= 50;
try {
if (queueFull)
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request),
);
@@ -283,31 +467,43 @@ export class TbankClient {
return response;
} catch (error) {
const grpcCode = (error as ServiceError).code;
const retryable = [
status.UNAVAILABLE,
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
].includes(grpcCode);
if (retryable && attempts < 2) {
const failureContext = queueFull
? { code: 'TBANK_UNAVAILABLE' }
: { grpcCode };
const retryable = queueFull
? metadataCall
: [
status.UNAVAILABLE,
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
this.logger.event('warn', 'tbank.rpc.retry', {
operation: method,
grpcCode,
...failureContext,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) =>
setTimeout(resolve, metadataCall ? attempts * 2000 : 1000),
);
continue;
}
this.logger.event('error', 'tbank.rpc.failed', {
operation: method,
grpcCode,
attempts,
...failureContext,
attempts: queueFull && !metadataCall ? 0 : attempts,
durationMs: Math.round(performance.now() - startedAt),
});
if (
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
)
throw new Error('INVALID_TBANK_TOKEN');
if (
grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall)
)
throw new NotFoundException();
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
}
+8 -2
View File
@@ -8,7 +8,13 @@ describe('UpcomingEventsController', () => {
it('passes the authenticated user and account to the service', async () => {
const events = { get: jest.fn() };
const controller = new UpcomingEventsController(events as never);
await controller.get({ user: { sub: 'user' } } as never, 'account');
expect(events.get).toHaveBeenCalledWith('user', 'account');
await controller.get({ user: { sub: 'user' } } as never, 'account', {
from: '2026-09-01',
to: '2026-09-30',
});
expect(events.get).toHaveBeenCalledWith('user', 'account', {
from: '2026-09-01',
to: '2026-09-30',
});
});
});
+16 -3
View File
@@ -1,10 +1,13 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { UpcomingEventsDto } from './upcoming-events.dto';
import {
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
import { UpcomingEventsService } from './upcoming-events.service';
@ApiTags('brokerage-accounts')
@@ -15,12 +18,22 @@ import { UpcomingEventsService } from './upcoming-events.service';
export class UpcomingEventsController {
constructor(private readonly events: UpcomingEventsService) {}
@Get('all/events')
@ApiOkResponse({ type: UpcomingEventsDto })
all(
@Req() request: Request & { user: AccessPayload },
@Query() query: UpcomingEventsQueryDto,
) {
return this.events.getAll(request.user.sub, query);
}
@Get(':accountId/events')
@ApiOkResponse({ type: UpcomingEventsDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: UpcomingEventsQueryDto,
) {
return this.events.get(request.user.sub, accountId);
return this.events.get(request.user.sub, accountId, query);
}
}
+28 -2
View File
@@ -1,7 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from './portfolio.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, Matches } from 'class-validator';
import { AccountReferenceDto } from './account-reference.dto';
import { MoneyDto } from '../common/money.dto';
export class UpcomingEventsQueryDto {
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsDateString({ strict: true })
from?: string;
@ApiPropertyOptional({
format: 'date',
description:
'Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsDateString({ strict: true })
to?: string;
}
export class UpcomingEventDto {
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
@ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] })
type: 'coupon' | 'dividend' | 'maturity' | 'offer';
+96
View File
@@ -50,6 +50,102 @@ describe('UpcomingEventsService', () => {
tbank.getDividends.mockResolvedValue([]);
});
it('includes both selected boundaries beyond the default window and isolates dividend caches by period', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SHARE',
instrumentType: 'share',
quantity: { units: 2 },
},
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
{ type: 'maturity', date: '2099-03-01', value: '1000', currency: 'RUB' },
{ type: 'offer', date: '2099-01-31' },
]);
tbank.getDividends.mockImplementation(
(_token: string, _id: string, from: string) =>
Promise.resolve([
{
dividendNet: { units: 5, currency: 'RUB' },
recordDate: {
seconds: String(Date.parse(`${from}T00:00:00Z`) / 1000),
},
},
]),
);
const february = { from: '2099-02-01', to: '2099-02-28' };
const first = await service.get('user', 'account', february);
expect(first.events.map(({ type, date }) => ({ type, date }))).toEqual([
{ type: 'coupon', date: february.from },
{ type: 'dividend', date: february.from },
{ type: 'offer', date: february.to },
]);
expect(first.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '16' },
]);
const march = await service.get('user', 'account', {
from: '2099-03-01',
to: '2099-03-01',
});
expect(march.events.map((event) => event.type)).toEqual([
'dividend',
'maturity',
]);
expect(march.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '10' },
]);
await service.get('user', 'account', february);
expect(tbank.getDividends).toHaveBeenCalledTimes(2);
expect(tbank.getDividends).toHaveBeenNthCalledWith(
1,
'token',
'S',
february.from,
february.to,
);
expect(tbank.getDividends).toHaveBeenNthCalledWith(
2,
'token',
'S',
'2099-03-01',
'2099-03-01',
);
});
it('defaults the end to 30 days after the selected start', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'offer', date: '2099-01-01' },
{ type: 'offer', date: '2099-01-31' },
{ type: 'offer', date: '2099-02-01' },
]);
const result = await service.get('user', 'account', { from: '2099-01-01' });
expect(result.events.map((event) => event.date)).toEqual([
'2099-01-01',
'2099-01-31',
]);
});
it('projects exact amounts and preserves registry dates and offers', async () => {
const now = new Date();
const day = (offset: number) =>
+103 -53
View File
@@ -1,16 +1,17 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { RequestContextService } from '../common/logging/request-context.service';
import { StructuredLogger } from '../common/logging/structured-logger.service';
import {
add,
decimal,
decimalFromString,
formatDecimal,
isZero,
multiply,
} from './decimal';
} from '../common/decimal';
import { decimal } from './decimal';
import { MoexBondEvent, MoexClient } from './moex.client';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
import { TbankAccessService } from './tbank-access.service';
import {
TbankClient,
@@ -18,7 +19,11 @@ import {
TbankInstrument,
TbankPortfolio,
} from './tbank.client';
import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto';
import {
UpcomingEventDto,
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
@@ -46,53 +51,97 @@ export class UpcomingEventsService {
private readonly logger: StructuredLogger,
) {}
async get(userId: string, accountId: string): Promise<UpcomingEventsDto> {
async get(
userId: string,
accountId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withAccount(userId, accountId, async ({ token }) => {
const portfolio = await this.tbank.getPortfolio(token, accountId);
const window = dateWindow();
const supportedPositions = (portfolio.positions ?? []).filter(supported);
const events: UpcomingEventDto[][] = [];
for (
let offset = 0;
offset < supportedPositions.length;
offset += positionsBatchSize
) {
const batch = supportedPositions.slice(
offset,
offset + positionsBatchSize,
);
events.push(
...(await Promise.all(
batch.map((position) => this.positionEvents(token, position)),
)),
);
}
const projected = events
.flat()
.filter(
(event) => event.date >= window.from && event.date <= window.to,
);
this.logger.info('upcoming-events.calculated', {
count: projected.length,
});
return {
accountId,
calculatedAt: new Date().toISOString(),
events: projected.sort(
return this.access.withAccount(userId, accountId, ({ token }) =>
this.forAccount(token, accountId, window),
);
}
async getAll(
userId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
return this.access.withSupportedAccounts(
userId,
async ({ token, accounts }) => {
const events: UpcomingEventDto[] = [];
for (const account of accounts) {
const result = await this.forAccount(token, account.id, window);
events.push(
...result.events.map((event) => ({
...event,
account: { id: account.id, name: account.name },
})),
);
}
events.sort(
(left, right) =>
left.date.localeCompare(right.date) ||
left.type.localeCompare(right.type) ||
left.ticker.localeCompare(right.ticker),
),
incomeTotalsByCurrency: incomeTotals(projected),
};
left.account!.id.localeCompare(right.account!.id),
);
return {
accountId: 'all',
calculatedAt: new Date().toISOString(),
events,
incomeTotalsByCurrency: incomeTotals(events),
};
},
);
}
private async forAccount(
token: string,
accountId: string,
window: { from: string; to: string },
): Promise<UpcomingEventsDto> {
const portfolio = await this.tbank.getPortfolio(token, accountId);
const supportedPositions = (portfolio.positions ?? []).filter(supported);
const events: UpcomingEventDto[][] = [];
for (
let offset = 0;
offset < supportedPositions.length;
offset += positionsBatchSize
) {
const batch = supportedPositions.slice(
offset,
offset + positionsBatchSize,
);
events.push(
...(await Promise.all(
batch.map((position) => this.positionEvents(token, position, window)),
)),
);
}
const projected = events
.flat()
.filter((event) => event.date >= window.from && event.date <= window.to);
this.logger.info('upcoming-events.calculated', {
count: projected.length,
});
return {
accountId,
calculatedAt: new Date().toISOString(),
events: projected.sort(
(left, right) =>
left.date.localeCompare(right.date) ||
left.type.localeCompare(right.type) ||
left.ticker.localeCompare(right.ticker),
),
incomeTotalsByCurrency: incomeTotals(projected),
};
}
private async positionEvents(
token: string,
position: Position,
window: { from: string; to: string },
): Promise<UpcomingEventDto[]> {
const ticker = position.ticker?.trim();
const instrument = ticker
@@ -113,7 +162,7 @@ export class UpcomingEventsService {
};
const quantity = decimal(position.quantity ?? {});
if (position.instrumentType?.toLowerCase() === 'share') {
const dividends = await this.dividends(token, position);
const dividends = await this.dividends(token, position, window);
this.logger.event('debug', 'upcoming-events.source-result', {
count: dividends.length,
});
@@ -146,13 +195,13 @@ export class UpcomingEventsService {
private async dividends(
token: string,
position: Position,
window: { from: string; to: string },
): Promise<TbankDividend[]> {
const instrumentId = position.instrumentUid ?? position.figi;
if (!instrumentId) return [];
const window = dateWindow();
const dividends = await this.cached(
this.dividendCache,
instrumentId,
JSON.stringify([instrumentId, window.from, window.to]),
dividendCacheTtlMs,
() =>
this.tbank.getDividends(token, instrumentId, window.from, window.to),
@@ -233,13 +282,14 @@ function supported(position: Position): boolean {
);
}
function dateWindow() {
const now = new Date();
const from = now.toISOString().slice(0, 10);
const end = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 30),
);
return { from, to: end.toISOString().slice(0, 10) };
function dateWindow(query: UpcomingEventsQueryDto) {
const from = query.from ?? new Date().toISOString().slice(0, 10);
const end = new Date(`${from}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + 30);
const to = query.to ?? end.toISOString().slice(0, 10);
if (from > to)
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
return { from, to };
}
function projectedMoney(
+101
View File
@@ -0,0 +1,101 @@
import { ConflictException, INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { Server } from 'node:http';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { TbankAccessService } from '../src/tbank/tbank-access.service';
describe('All accounts HTTP boundary', () => {
let app: INestApplication<Server>;
const withSupportedAccounts = jest.fn();
const token = new JwtService().sign(
{ sub: 'account-owner', type: 'access' },
{ secret: process.env.JWT_SECRET },
);
const paths = [
'portfolio',
'operations',
'income-summary',
'income-analytics',
'events',
];
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankAccessService)
.useValue({ withSupportedAccounts })
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
withSupportedAccounts.mockReset();
withSupportedAccounts.mockImplementation(
(_user: string, handler: (context: unknown) => unknown) =>
handler({ token: 'unused', generation: 'first', accounts: [] }),
);
});
afterAll(() => app.close());
it.each(paths)('requires authentication for /all/%s', async (path) => {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/all/${path}`)
.expect(401);
expect(withSupportedAccounts).not.toHaveBeenCalled();
});
it.each(paths)(
'routes /all/%s to the aggregate with verified identity',
async (path) => {
const response = await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/all/${path}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(withSupportedAccounts).toHaveBeenCalledWith(
'account-owner',
expect.any(Function),
);
expect(response.body).not.toHaveProperty('token');
expect(response.body).not.toHaveProperty('generation');
if (path === 'portfolio')
expect(response.body).toMatchObject({ positions: [] });
else expect(response.body).toMatchObject({ accountId: 'all' });
},
);
it.each([
'operations?limit=0',
'operations?limit=1001',
'operations?categories=unknown',
'operations?from=not-a-date',
'income-analytics?from=2026-08-01',
'income-analytics?from=2026-08-31&to=2026-08-01',
])('validates /all/%s in the configured pipeline', async (path) => {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/all/${path}`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
});
it('returns a recoverable code for an expired or foreign continuation', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/operations?cursor=unknown')
.set('Authorization', `Bearer ${token}`)
.expect(400);
expect(response.body).toMatchObject({ code: 'INVALID_OPERATIONS_CURSOR' });
});
it('preserves connection errors without a partial success response', async () => {
withSupportedAccounts.mockRejectedValue(
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
);
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/portfolio')
.set('Authorization', `Bearer ${token}`)
.expect(409);
expect(response.body).toMatchObject({ code: 'TBANK_NOT_CONNECTED' });
});
});
+74
View File
@@ -0,0 +1,74 @@
const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const { mkdtempSync, openSync, closeSync, writeFileSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { join, resolve } = require('node:path');
const { once } = require('node:events');
const { createServer } = require('node:net');
const { randomUUID } = require('node:crypto');
const repo = resolve(__dirname, '..');
assert.ok(process.argv[2], 'Pass a separately built baseline repository directory');
const baseline = resolve(process.argv[2]);
const Database = require(join(repo, 'node_modules/better-sqlite3'));
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
let file = join(dir, 'history.db');
const env = { ...process.env, DATABASE_URL: `file:${file}`, RUST_LOG: 'info', NODE_ENV: 'development', LOG_LEVEL: 'error', FRONTEND_ORIGIN: 'http://localhost:5189', JWT_SECRET: 'fictional-bank-durability-secret-long-enough', TOKEN_ENCRYPTION_KEY: Buffer.from('0123456789abcdef0123456789abcdef').toString('base64') };
let child, origin, run = 0;
const credentials = { email: 'durability@example.test', password: 'fictional password long enough' };
const rub = value => ({ currency: 'RUB', value });
async function start(root) {
const server = createServer(); server.listen(0, '127.0.0.1'); await once(server,'listening'); const port=server.address().port; await new Promise(r=>server.close(r));
origin=`http://127.0.0.1:${port}`;
const log = openSync(join(dir,`app-${++run}.log`),'w');
child=spawn(process.execPath,[join(root,'dist/src/main.js')],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`,PORT:String(port)},stdio:['ignore',log,log]}); closeSync(log);
for(let i=0;i<150;i++){if(child.exitCode!==null)throw new Error(`app exited ${child.exitCode}`);try{const r=await fetch(`${origin}/api/v1/auth/me`);if(r.status===401)return;}catch{} await new Promise(r=>setTimeout(r,100));}
throw new Error('app startup timeout');
}
async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode!==null)return; const ended=once(c,'exit');c.kill('SIGTERM');await ended;}
async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;}
function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});}
function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}}
(async()=>{
try{
migrate(baseline); await start(baseline);
const auth=await http('auth/register','POST',credentials); const owner=auth.user.id;
await stop();
const db=new Database(file);db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner,'fictional-encrypted-token-never-used',new Date().toISOString());await db.backup(join(dir,'before-migration.db'));db.close();
const beforeUsers=rows('User'), beforeSessions=rows('Session');
migrate(repo);
assert.deepEqual(rows('User'),beforeUsers); assert.deepEqual(rows('Session'),beforeSessions); const migratedDb=new Database(file,{readonly:true});assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');migratedDb.close();
await start(repo); let token=(await http('auth/login','POST',credentials)).accessToken;
const intent={id:randomUUID(),name:'Долговременная',openingDate:'2026-01-01',openingBalance:rub('1000')};
let card=await http('bank-cards','POST',intent,token,201);
const category=(await http('bank-cards/categories','GET',null,token)).find(x=>x.isDefault);
await http(`bank-cards/categories/${category.id}`,'PATCH',{name:'Сохранённая категория',version:category.version},token);
const expenseIntent={id:randomUUID(),kind:'expense',cardId:card.id,amount:rub('100'),date:'2026-08-01'};
let expense=await http('bank-cards/operations','POST',expenseIntent,token,201);
const refundIntent={id:randomUUID(),kind:'refund',cardId:card.id,originalExpenseId:expense.id,amount:rub('100'),date:'2026-09-01'};
const refund=await http('bank-cards/operations','POST',refundIntent,token,201);
const deletedIntent={id:randomUUID(),kind:'topup',cardId:card.id,amount:rub('12.34'),date:'2026-09-01'};
const deleted=await http('bank-cards/operations','POST',deletedIntent,token,201);
await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204);
card=await http(`bank-cards/${card.id}`,'GET',null,token);
card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token);
const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')};
await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;
assert.deepEqual(await http('bank-cards','POST',intent,token,201),card);
assert.deepEqual(await http('bank-cards/operations','POST',refundIntent,token,201),refund);
await http('bank-cards/operations','POST',deletedIntent,token,410);
assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);
await stop();
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close();
await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop();
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored;
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id));
const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close();
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2));
console.log(`PASS durability ${dir}`);
}finally{await stop();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+771
View File
@@ -0,0 +1,771 @@
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { PrismaService } from '../src/prisma/prisma.service';
import {
BankCardDto,
BankOperationDto,
BankOperationsPageDto,
BankSummaryDto,
ExpenseCategoryDto,
} from '../src/bank-cards/bank-card.dto';
jest.setTimeout(60000);
describe('Manual bank cards configured HTTP and real SQLite', () => {
let app: INestApplication<Server>, db: PrismaService;
const owner = randomUUID(),
stranger = randomUUID();
const token = (user = owner) =>
new JwtService().sign(
{ sub: user, type: 'access' },
{ secret: process.env.JWT_SECRET },
);
const money = (value: string) => ({ currency: 'RUB', value });
const auth = (
method: 'get' | 'post' | 'patch' | 'delete',
path: string,
user = owner,
) =>
request(app.getHttpServer())
[method]('/api/v1/bank-cards' + path)
.set('Authorization', `Bearer ${token(user)}`);
const createCard = async (value = '1000', user = owner, extra = {}) =>
(
await auth('post', '', user)
.send({
id: randomUUID(),
name: 'Вымышленная карта',
openingDate: '2026-01-01',
openingBalance: money(value),
...extra,
})
.expect(201)
).body as BankCardDto;
const getCard = async (id: string) =>
(await auth('get', '/' + id).expect(200)).body as BankCardDto;
const getOp = async (id: string) =>
(await auth('get', '/operations/' + id).expect(200))
.body as BankOperationDto;
const op = (cardId: string, kind = 'expense', value = '100', extra = {}) => ({
id: randomUUID(),
cardId,
kind,
amount: money(value),
date: '2026-08-01',
...extra,
});
const createOp = async (body: object) =>
(await auth('post', '/operations').send(body).expect(201))
.body as BankOperationDto;
const categories = async () =>
(await auth('get', '/categories').expect(200)).body as ExpenseCategoryDto[];
const summary = async (month: string, cardId?: string) =>
(
await auth(
'get',
`/summary?month=${month}${cardId ? '&cardId=' + cardId : ''}`,
).expect(200)
).body as BankSummaryDto;
const start = async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
db = app.get(PrismaService);
};
beforeAll(async () => {
const url = process.env.DATABASE_URL;
if (
!url ||
!/^file:\/(?:private\/)?tmp\/bank-cards-[^/]+\/.+\.db$/.test(url)
)
throw new Error(
'Pass a unique /tmp/bank-cards-*/test.db DATABASE_URL. No shared/user database is allowed.',
);
execFileSync(
process.execPath,
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
{
cwd: process.cwd(),
env: { ...process.env, RUST_LOG: 'info' },
stdio: 'pipe',
},
);
await start();
await db.user.createMany({
data: [
{
id: owner,
email: owner + '@example.test',
passwordHash: 'fictional',
},
{
id: stranger,
email: stranger + '@example.test',
passwordHash: 'fictional',
},
],
});
});
afterAll(async () => {
if (app) await app.close();
});
it('requires auth and reads never seed data or need T-Bank', async () => {
for (const path of ['', '/categories', '/summary?month=2026-08'])
await request(app.getHttpServer())
.get('/api/v1/bank-cards' + path)
.expect(401);
expect((await auth('get', '').expect(200)).body).toEqual([]);
expect(await categories()).toEqual([]);
expect(await db.expenseCategory.count({ where: { userId: owner } })).toBe(
0,
);
});
it('seeds once concurrently, replays card creation and preserves current archived state', async () => {
const body = {
id: randomUUID(),
name: ' Основная ',
openingDate: '2026-01-01',
openingBalance: money('1000'),
};
const responses = await Promise.all([
auth('post', '').send(body),
auth('post', '').send(body),
auth('post', '').send({ ...body, id: randomUUID() }),
]);
expect(responses.map((r) => r.status)).toEqual([201, 201, 201]);
expect(await db.bankCard.count({ where: { userId: owner } })).toBe(2);
expect(await categories()).toHaveLength(9);
await auth('post', '')
.send({ ...body, name: 'Другое имя' })
.expect(409);
const archived = (
await auth('patch', '/' + body.id)
.send({ version: 1, archived: true })
.expect(200)
).body as BankCardDto;
const replay = (await auth('post', '').send(body).expect(201))
.body as BankCardDto;
expect(replay).toEqual(archived);
expect(replay.name).toBe('Основная');
});
it('roundtrips >2^53 and signed64 boundaries, and calculates beyond a stored value', async () => {
for (const value of [
'90071992547409.93',
'92233720368547758.07',
'-92233720368547758.08',
]) {
const card = await createCard(value);
expect(card.balance.value).toBe(value);
expect((await getCard(card.id)).openingBalance.value).toBe(value);
}
const card = await createCard('92233720368547758.07');
await createOp(op(card.id, 'topup', '0.01'));
expect((await getCard(card.id)).balance.value).toBe('92233720368547758.08');
const exact = await createCard('1');
await createOp(op(exact.id, 'expense', '0.10'));
await createOp(op(exact.id, 'expense', '0.20'));
expect((await getCard(exact.id)).balance.value).toBe('0.7');
await createOp(op(exact.id, 'expense', '1'));
expect((await getCard(exact.id)).balance.value).toBe('-0.3');
});
it('clears optional card metadata and changes opening balance without synthetic operations', async () => {
const card = await createCard('0', owner, {
bankName: 'Fake Bank',
lastFour: '1234',
});
const changed = (
await auth('patch', '/' + card.id)
.send({
version: 1,
bankName: null,
lastFour: null,
openingBalance: money('-25'),
})
.expect(200)
).body as BankCardDto;
expect(changed).toMatchObject({
bankName: null,
lastFour: null,
balance: money('-25'),
});
expect(
await db.bankCardOperation.count({ where: { cardId: card.id } }),
).toBe(0);
expect((await summary('2026-08', card.id)).net.value).toBe('0');
});
it('rejects explicit null on nonnullable write fields without changing values or versions', async () => {
const card = await createCard();
const expense = await createOp(op(card.id));
const archived = (
await auth('patch', '/' + card.id)
.send({ version: card.version, archived: true })
.expect(200)
).body as BankCardDto;
for (const field of ['archived', 'openingBalance', 'openingDate', 'name']) {
await auth('patch', '/' + card.id)
.send({ version: archived.version, [field]: null })
.expect(400);
expect(await getCard(card.id)).toEqual(archived);
}
await auth('patch', '/' + card.id)
.send({ version: archived.version, archived: false })
.expect(200);
for (const field of [
'amount',
'kind',
'cardId',
'date',
'description',
'targetCardId',
'categoryId',
'originalExpenseId',
]) {
await auth('patch', '/operations/' + expense.id)
.send({ version: expense.version, [field]: null })
.expect(400);
expect(await getOp(expense.id)).toEqual(expense);
}
const before = await db.bankCardOperation.count();
for (const field of [
'amount',
'description',
'targetCardId',
'categoryId',
'originalExpenseId',
]) {
await auth('post', '/operations')
.send({ ...op(card.id), [field]: null })
.expect(400);
}
expect(await db.bankCardOperation.count()).toBe(before);
});
it('validates actual money, calendar, field and operation-shape inputs without writes', async () => {
const card = await createCard();
const before = await db.bankCardOperation.count();
for (const amount of [
money('0'),
money('-1'),
money('1.001'),
money('92233720368547758.08'),
{ currency: 'USD', value: '1' },
money('NaN'),
])
await auth('post', '/operations')
.send({ ...op(card.id), amount })
.expect(400);
for (const extra of [
{ date: '2026-02-30' },
{ date: '2099-01-01' },
{ date: '2025-12-31' },
{ description: 'x'.repeat(501) },
{ kind: 'topup', categoryId: (await categories())[0].id },
{ kind: 'transfer', targetCardId: card.id },
])
await auth('post', '/operations')
.send(op(card.id, 'expense', '1', extra))
.expect(400);
expect(await db.bankCardOperation.count()).toBe(before);
for (const extra of [
{ name: ' ' },
{ name: 'x'.repeat(101) },
{ bankName: 'x'.repeat(101) },
{ lastFour: '123' },
{ openingDate: '2026-02-30' },
{ openingBalance: money('-92233720368547758.09') },
])
await auth('post', '')
.send({
id: randomUUID(),
name: 'Card',
openingDate: '2026-01-01',
openingBalance: money('0'),
...extra,
})
.expect(400);
});
it('scopes every card/category/expense link and edit to owner', async () => {
const own = await createCard(),
foreign = await createCard('0', stranger);
const foreignCategories = (
await auth('get', '/categories', stranger).expect(200)
).body as ExpenseCategoryDto[];
const foreignOp = (
await auth('post', '/operations', stranger)
.send(op(foreign.id))
.expect(201)
).body as BankOperationDto;
for (const path of [
'/' + foreign.id,
'/' + foreign.id + '/operations?month=2026-08',
'/' + foreign.id + '/expenses',
'/operations/' + foreignOp.id,
'/summary?month=2026-08&cardId=' + foreign.id,
])
await auth('get', path).expect(404);
for (const body of [
op(foreign.id),
op(own.id, 'expense', '1', { categoryId: foreignCategories[0].id }),
op(own.id, 'transfer', '1', { targetCardId: foreign.id }),
op(own.id, 'refund', '1', { originalExpenseId: foreignOp.id }),
])
await auth('post', '/operations').send(body).expect(404);
await auth('patch', '/' + foreign.id)
.send({ version: 1, archived: true })
.expect(404);
await auth('patch', '/categories/' + foreignCategories[0].id)
.send({ version: 1, name: 'No' })
.expect(404);
await auth('patch', '/operations/' + foreignOp.id)
.send({ version: 1, amount: money('2') })
.expect(404);
await auth('delete', '/operations/' + foreignOp.id + '?version=1').expect(
404,
);
expect((await getCard(own.id)).balance.value).toBe('1000');
});
it('renames default category without role loss; duplicates and stale rename conflict', async () => {
const category = (await categories()).find((c) => c.isDefault)!;
await auth('patch', '/categories/' + category.id)
.send({ version: category.version, name: 'Остальное' })
.expect(200);
await auth('patch', '/categories/' + category.id)
.send({ version: category.version, name: 'Stale' })
.expect(409);
await auth('post', '/categories').send({ name: ' кафе ' }).expect(409);
const custom = (
await auth('post', '/categories').send({ name: 'Питомец' }).expect(201)
).body as ExpenseCategoryDto;
expect(
(await categories()).find((c) => c.id === category.id),
).toMatchObject({ name: 'Остальное', isDefault: true });
const card = await createCard();
expect((await createOp(op(card.id))).categoryId).toBe(category.id);
expect(custom.name).toBe('Питомец');
});
it('keeps both independent expenses, exactly one same-intent operation and tombstone after editing/deleting', async () => {
const card = await createCard();
const body = op(card.id);
const responses = await Promise.all([
auth('post', '/operations').send(body),
auth('post', '/operations').send(body),
auth('post', '/operations').send(op(card.id, 'expense', '200')),
]);
expect(responses.map((r) => r.status)).toEqual([201, 201, 201]);
expect((await getCard(card.id)).balance.value).toBe('700');
await auth('post', '/operations')
.send({ ...body, amount: money('200') })
.expect(409);
const edited = (
await auth('patch', '/operations/' + body.id)
.send({ version: 1, amount: money('150') })
.expect(200)
).body as BankOperationDto;
expect(
(
(await auth('post', '/operations').send(body).expect(201))
.body as BankOperationDto
).amount.value,
).toBe('150');
await auth('delete', '/operations/' + body.id + '?version=1').expect(409);
await auth(
'delete',
`/operations/${body.id}?version=${edited.version}`,
).expect(204);
await auth('delete', '/operations/' + body.id + '?version=1').expect(204);
await auth('post', '/operations').send(body).expect(410);
expect((await getCard(card.id)).balance.value).toBe('800');
});
it('transfers atomically, edits both sides, validates old and new cards and opening dates', async () => {
const a = await createCard('100'),
b = await createCard('0'),
late = await createCard('0', owner, { openingDate: '2026-09-01' });
await auth('post', '/operations')
.send(op(a.id, 'transfer', '150', { targetCardId: late.id }))
.expect(400);
const transfer = await createOp(
op(a.id, 'transfer', '150', { targetCardId: b.id }),
);
expect((await getCard(a.id)).balance.value).toBe('-50');
expect((await getCard(b.id)).balance.value).toBe('150');
const history = (
await auth('get', `/${b.id}/operations?month=2026-08`).expect(200)
).body as BankOperationsPageDto;
expect(history.items[0]).toMatchObject({
id: transfer.id,
cardId: a.id,
targetCardId: b.id,
});
expect((await summary('2026-08', b.id)).expenses.value).toBe('0');
await auth('patch', '/' + b.id)
.send({ version: 1, openingDate: '2026-08-02' })
.expect(400);
const edited = (
await auth('patch', '/operations/' + transfer.id)
.send({ version: 1, amount: money('50') })
.expect(200)
).body as BankOperationDto;
expect((await getCard(a.id)).balance.value).toBe('50');
expect((await getCard(b.id)).balance.value).toBe('50');
await auth(
'delete',
`/operations/${transfer.id}?version=${edited.version}`,
).expect(204);
expect((await getCard(a.id)).balance.value).toBe('100');
expect((await getCard(b.id)).balance.value).toBe('0');
});
it('serializes refund limits, replays full refund before limit checks, excludes own value during edit', async () => {
const card = await createCard();
const expense = await createOp(op(card.id, 'expense', '1000'));
const responses = await Promise.all([
auth('post', '/operations').send(
op(card.id, 'refund', '600', {
date: '2026-09-01',
originalExpenseId: expense.id,
}),
),
auth('post', '/operations').send(
op(card.id, 'refund', '600', {
date: '2026-09-01',
originalExpenseId: expense.id,
}),
),
]);
expect(responses.map((r) => r.status).sort()).toEqual([201, 409]);
const body = op(card.id, 'refund', '400', {
date: '2026-09-01',
originalExpenseId: expense.id,
});
const refund = await createOp(body);
expect(refund.availableRefund?.value).toBe('400');
await auth('post', '/operations').send(body).expect(201);
await auth('patch', '/operations/' + refund.id)
.send({ version: refund.version, description: 'Тот же полный возврат' })
.expect(200);
const sept = await summary('2026-09', card.id);
expect(sept).toMatchObject({
expenses: money('0'),
refunds: money('1000'),
net: money('-1000'),
});
const august = await summary('2026-08', card.id);
expect(august.net.value).toBe('1000');
const choices = (await auth('get', '/' + card.id + '/expenses').expect(200))
.body as BankOperationsPageDto;
expect(choices.items[0].availableRefund?.value).toBe('0');
const current = await getOp(expense.id);
for (const patch of [
{ amount: money('999') },
{ date: '2026-09-02' },
{ cardId: (await createCard()).id },
])
await auth('patch', '/operations/' + expense.id)
.send({ version: current.version, ...patch })
.expect(409);
await auth(
'delete',
`/operations/${expense.id}?version=${current.version}`,
).expect(409);
const category = (await categories()).find((c) => !c.isDefault)!;
await auth('patch', '/operations/' + expense.id)
.send({ version: current.version, categoryId: category.id })
.expect(200);
expect((await summary('2026-09', card.id)).categories[0].categoryId).toBe(
category.id,
);
});
it('rejects invalid refund references, dates, immutable kind and purchase link', async () => {
const card = await createCard(),
other = await createCard();
const purchase = await createOp(op(card.id)),
topup = await createOp(op(card.id, 'topup'));
for (const body of [
op(card.id, 'refund', '1', { originalExpenseId: topup.id }),
op(other.id, 'refund', '1', { originalExpenseId: purchase.id }),
op(card.id, 'refund', '1', {
originalExpenseId: purchase.id,
date: '2026-07-01',
}),
])
await auth('post', '/operations').send(body).expect(400);
const refund = await createOp(
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
);
await auth('patch', '/operations/' + refund.id)
.send({ version: refund.version, originalExpenseId: topup.id })
.expect(400);
await auth('patch', '/operations/' + topup.id)
.send({ version: topup.version, kind: 'expense' })
.expect(400);
});
it('moves plain operations and month totals, preserves full balance with stable pagination', async () => {
const a = await createCard(),
b = await createCard();
const first = await createOp(op(a.id, 'expense', '10'));
await createOp(op(a.id, 'expense', '20'));
await createOp(op(a.id, 'expense', '30'));
const pages: BankOperationsPageDto[] = [];
for (let page = 1; page <= 3; page++)
pages.push(
(
await auth(
'get',
`/${a.id}/operations?month=2026-08&page=${page}&limit=1`,
).expect(200)
).body as BankOperationsPageDto,
);
expect(new Set(pages.flatMap((p) => p.items.map((i) => i.id))).size).toBe(
3,
);
expect(pages.map((p) => p.hasNext)).toEqual([true, true, false]);
expect((await getCard(a.id)).balance.value).toBe('940');
expect((await summary('2026-08', a.id)).expenses.value).toBe('60');
await auth('patch', '/operations/' + first.id)
.send({ version: first.version, cardId: b.id, date: '2026-09-01' })
.expect(200);
expect((await getCard(a.id)).balance.value).toBe('950');
expect((await getCard(b.id)).balance.value).toBe('990');
expect((await summary('2026-09', b.id)).net.value).toBe('10');
});
it('serializes archive with writes, blocks financial changes, keeps metadata editable and all-card history', async () => {
const card = await createCard();
const expense = await createOp(op(card.id));
const total = (await summary('2026-08')).net.value;
await auth('patch', '/' + card.id)
.send({ version: 1, archived: true })
.expect(200);
for (const body of [
op(card.id),
op(card.id, 'transfer', '1', { targetCardId: (await createCard()).id }),
])
await auth('post', '/operations').send(body).expect(409);
await auth('patch', '/operations/' + expense.id)
.send({ version: 1, description: 'no' })
.expect(409);
await auth('delete', `/operations/${expense.id}?version=1`).expect(409);
await auth('patch', '/' + card.id)
.send({ version: 2, openingBalance: money('200') })
.expect(409);
await auth('patch', '/' + card.id)
.send({ version: 2, name: 'Архивная' })
.expect(200);
expect((await summary('2026-08')).net.value).toBe(total);
await auth('patch', '/' + card.id)
.send({ version: 1, archived: false })
.expect(409);
await auth('patch', '/' + card.id)
.send({ version: 3, archived: false })
.expect(200);
await createOp(op(card.id));
const racing = await createCard();
const results = await Promise.all([
auth('patch', '/' + racing.id).send({ version: 1, archived: true }),
auth('post', '/operations').send(op(racing.id)),
]);
expect(results[0].status).toBe(200);
expect([201, 409]).toContain(results[1].status);
expect((await getCard(racing.id)).balance.value).toBe(
results[1].status === 201 ? '900' : '1000',
);
});
it('serializes a refund against reducing its purchase, and rolls back rejected source locks', async () => {
const card = await createCard();
const expense = await createOp(op(card.id, 'expense', '1000'));
const results = await Promise.all([
auth('post', '/operations').send(
op(card.id, 'refund', '600', { originalExpenseId: expense.id }),
),
auth('patch', '/operations/' + expense.id).send({
version: expense.version,
amount: money('500'),
}),
]);
expect([201, 409]).toContain(results[0].status);
expect([200, 409]).toContain(results[1].status);
expect(results.filter((r) => r.status < 300)).toHaveLength(1);
const current = await getOp(expense.id);
const invalid = op(card.id, 'refund', '1', {
originalExpenseId: expense.id,
date: '2026-07-01',
});
await auth('post', '/operations').send(invalid).expect(400);
expect((await getOp(expense.id)).version).toBe(current.version);
const refunds = await db.bankCardOperation.findMany({
where: { originalExpenseId: expense.id, deletedAt: null },
});
const source = await db.bankCardOperation.findUniqueOrThrow({
where: { id: expense.id },
});
expect(
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) <=
source.amountKopecks,
).toBe(true);
});
it('serializes opening-date changes and validates archived previous transfer sides', async () => {
const card = await createCard();
const results = await Promise.all([
auth('patch', '/' + card.id).send({
version: 1,
openingDate: '2026-09-01',
}),
auth('post', '/operations').send(op(card.id)),
]);
expect(results.filter((r) => r.status < 300)).toHaveLength(1);
const current = await getCard(card.id);
const rows = await db.bankCardOperation.findMany({
where: { cardId: card.id, deletedAt: null },
});
expect(rows.every((row) => row.date >= current.openingDate)).toBe(true);
const a = await createCard(),
b = await createCard(),
c = await createCard();
const transfer = await createOp(
op(a.id, 'transfer', '10', { targetCardId: b.id }),
);
await auth('patch', '/' + b.id)
.send({ version: 1, archived: true })
.expect(200);
await auth('patch', '/operations/' + transfer.id)
.send({ version: 1, targetCardId: c.id })
.expect(409);
expect((await getCard(b.id)).balance.value).toBe('1010');
expect((await getCard(c.id)).balance.value).toBe('1000');
});
it('deleting refunds releases the purchase and a later replay never restores either record', async () => {
const card = await createCard();
const purchaseBody = op(card.id),
purchase = await createOp(purchaseBody);
const refundBody = op(card.id, 'refund', '50', {
originalExpenseId: purchase.id,
}),
refund = await createOp(refundBody);
await auth(
'delete',
`/operations/${refund.id}?version=${refund.version}`,
).expect(204);
const current = await getOp(purchase.id);
expect(current.availableRefund?.value).toBe('100');
await auth(
'delete',
`/operations/${purchase.id}?version=${current.version}`,
).expect(204);
await auth('post', '/operations').send(refundBody).expect(410);
await auth('post', '/operations').send(purchaseBody).expect(410);
await auth('post', '/operations')
.send(op(card.id, 'refund', '1', { originalExpenseId: purchase.id }))
.expect(400);
expect((await getCard(card.id)).balance.value).toBe('1000');
});
it('preserves history, fingerprints, archive and deleted replay across app restart', async () => {
const card = await createCard();
const purchase = await createOp(op(card.id));
const refund = await createOp(
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
);
const deletedBody = op(card.id, 'topup', '20');
const deleted = await createOp(deletedBody);
await auth('delete', `/operations/${deleted.id}?version=1`).expect(204);
await auth('patch', '/' + card.id)
.send({ version: 1, archived: true })
.expect(200);
const before = await getCard(card.id);
await app.close();
await start();
expect(await getCard(card.id)).toEqual(before);
expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id);
await auth('post', '/operations').send(deletedBody).expect(410);
expect((await categories()).find((c) => c.isDefault)?.name).toBe(
'Остальное',
);
});
it('enforces actual FK and unique constraints', async () => {
await expect(
db.expenseCategory.create({
data: {
id: randomUUID(),
userId: randomUUID(),
name: 'Invalid',
nameKey: 'invalid',
},
}),
).rejects.toMatchObject({ code: 'P2003' });
const category = (await categories())[0];
await expect(
db.expenseCategory.create({
data: {
id: randomUUID(),
userId: owner,
name: category.name,
nameKey: category.name.toLocaleLowerCase('ru-RU'),
},
}),
).rejects.toMatchObject({ code: 'P2002' });
});
it('persists payment systems, preserves omitted metadata and allows clearing', async () => {
const card = await createCard('1000', owner, { paymentSystem: 'mir' });
expect((await getCard(card.id)).paymentSystem).toBe('mir');
await auth('patch', '/' + card.id)
.send({ version: 1, name: 'Renamed' })
.expect(200);
expect((await getCard(card.id)).paymentSystem).toBe('mir');
await auth('patch', '/' + card.id)
.send({ version: 2, paymentSystem: 'visa' })
.expect(200);
expect((await getCard(card.id)).paymentSystem).toBe('visa');
await auth('patch', '/' + card.id)
.send({ version: 3, paymentSystem: 'unknown' })
.expect(400);
await auth('patch', '/' + card.id)
.send({ version: 3, paymentSystem: null })
.expect(200);
expect((await getCard(card.id)).paymentSystem).toBeNull();
expect((await createCard()).paymentSystem).toBeNull();
expect(
(await createCard('1000', owner, { paymentSystem: 'mastercard' }))
.paymentSystem,
).toBe('mastercard');
});
it('filters journal and summary inclusively across months and rejects invalid periods', async () => {
const card = await createCard();
const dates = ['2026-07-30', '2026-07-31', '2026-08-01', '2026-08-02'];
for (const date of dates)
await createOp(op(card.id, 'expense', '10', { date }));
const range = 'from=2026-07-31&to=2026-08-01';
const history = (
await auth('get', `/${card.id}/operations?${range}&limit=1`).expect(200)
).body as BankOperationsPageDto;
expect(history.total).toBe(2);
expect(history.items.map((item) => item.date)).toEqual(['2026-08-01']);
expect(history.hasNext).toBe(true);
const result = (
await auth('get', `/summary?cardId=${card.id}&${range}`).expect(200)
).body as BankSummaryDto;
expect(result.net.value).toBe('20');
const single = (
await auth(
'get',
`/${card.id}/operations?from=2026-07-31&to=2026-07-31`,
).expect(200)
).body as BankOperationsPageDto;
expect(single.total).toBe(1);
for (const invalid of [
'',
'from=2026-07-31',
'to=2026-08-01',
'from=2026-08-02&to=2026-08-01',
'from=2026-02-30&to=2026-08-01',
`${range}&month=2026-08`,
]) {
await auth('get', `/${card.id}/operations?${invalid}`).expect(400);
await auth('get', `/summary?${invalid}`).expect(400);
}
});
});
+174
View File
@@ -0,0 +1,174 @@
import {
INestApplication,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { Server } from 'node:http';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { BondService } from '../src/tbank/bond.service';
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Bond HTTP contract', () => {
let app: INestApplication<Server>;
const bonds = {
getByTicker: jest.fn(),
get: jest.fn(),
search: jest.fn(),
candles: jest.fn(),
metrics: jest.fn(),
};
const token = new JwtService().sign(
{ sub: 'bond-reader', type: 'access' },
{ secret: process.env.JWT_SECRET },
);
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(BondService)
.useValue(bonds)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
jest.clearAllMocks();
bonds.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' });
bonds.search.mockResolvedValue([]);
bonds.getByTicker.mockResolvedValue({
instrumentUid: uid,
slug: 'SU26238RMFS4',
});
});
afterAll(() => app.close());
it('requires authentication before invoking the use case', async () => {
await request(app.getHttpServer()).get(`/api/v1/bonds/${uid}`).expect(401);
expect(bonds.get).not.toHaveBeenCalled();
});
it.each([
'/bonds/not-a-uid',
'/bonds/not-a-uid/metrics',
'/bonds/by-ticker/%20',
'/bonds/by-ticker/' + 'S'.repeat(81),
'/bonds?query=x',
'/bonds?query=',
`/bonds/${uid}/candles?period=century`,
'/brokerage-accounts/all/operations?instrumentUid=not-a-uid',
])('validates %s in the configured pipeline', async (path) => {
await request(app.getHttpServer())
.get(`/api/v1${path}`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
expect(bonds.get).not.toHaveBeenCalled();
expect(bonds.candles).not.toHaveBeenCalled();
});
it('serves coupon, yield and agency rating through the authenticated metrics endpoint', async () => {
const metrics = {
coupon: { currency: 'RUB', value: '30' },
couponDate: '2026-10-10',
couponType: 'COUPON_TYPE_FIX',
couponPeriodDays: 90,
couponYieldPercent: '12.5',
couponYieldPriceAt: '2026-09-10T10:00:00Z',
effectiveYield: {
value: '15.6',
pricePercent: '53.4',
date: '2041-05-15',
dateType: 'MATDATE',
tradeAt: '2026-09-10T10:00:00Z',
},
creditRating: {
agency: 'NCR',
value: 'AAA.ru',
status: 'active',
date: '2026-04-17',
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
observedAt: '2026-09-10T10:00:00Z',
},
unavailable: [],
observedAt: '2026-09-10T10:00:00Z',
};
bonds.metrics.mockResolvedValue(metrics);
await request(app.getHttpServer())
.get(`/api/v1/bonds/${uid}/metrics`)
.expect(401);
expect(bonds.metrics).not.toHaveBeenCalled();
const response = await request(app.getHttpServer())
.get(`/api/v1/bonds/${uid}/metrics`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toEqual(metrics);
expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid);
});
it('uses verified identity and supports a direct instrument URL', async () => {
await request(app.getHttpServer())
.get(`/api/v1/bonds/${uid}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid);
});
it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])(
'resolves ticker %s through the authenticated endpoint',
async (ticker) => {
const path = `/api/v1/bonds/by-ticker/${encodeURIComponent(ticker)}`;
await request(app.getHttpServer()).get(path).expect(401);
const response = await request(app.getHttpServer())
.get(path)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
instrumentUid: uid,
slug: 'SU26238RMFS4',
});
expect(bonds.getByTicker).toHaveBeenCalledWith('bond-reader', ticker);
},
);
it('returns an explicit conflict for an ambiguous ticker', async () => {
bonds.getByTicker.mockRejectedValueOnce(
new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' }),
);
const response = await request(app.getHttpServer())
.get('/api/v1/bonds/by-ticker/SAME')
.set('Authorization', `Bearer ${token}`)
.expect(409);
expect(response.body).toMatchObject({ code: 'BOND_TICKER_AMBIGUOUS' });
});
it('returns listing identity and nullable search prices through the search endpoint', async () => {
const item = {
instrumentUid: uid,
name: 'Sber',
classCode: 'TQBR',
isin: 'ISIN',
apiTradeAvailable: true,
price: { currency: 'RUB', value: '306.12' },
priceAt: null,
};
bonds.search.mockResolvedValue([item]);
const response = await request(app.getHttpServer())
.get('/api/v1/bonds?query=SU26238RMFS4')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(bonds.search).toHaveBeenCalledWith('bond-reader', 'SU26238RMFS4');
expect(response.body).toEqual([item]);
});
it('preserves safe not-found and upstream errors', async () => {
bonds.get.mockRejectedValueOnce(new NotFoundException());
await request(app.getHttpServer())
.get(`/api/v1/bonds/${uid}`)
.set('Authorization', `Bearer ${token}`)
.expect(404);
bonds.get.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
const response = await request(app.getHttpServer())
.get(`/api/v1/bonds/${uid}`)
.set('Authorization', `Bearer ${token}`)
.expect(503);
expect(response.body).toMatchObject({ code: 'TBANK_UNAVAILABLE' });
expect(response.body).not.toHaveProperty('stack');
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
process.env.DATABASE_URL = 'file:/tmp/wealth-ledger-e2e.db';
process.env.DATABASE_URL ??= 'file:/tmp/wealth-ledger-e2e.db';
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';
process.env.TOKEN_ENCRYPTION_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
+160
View File
@@ -0,0 +1,160 @@
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import type { Server } from 'node:http';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { TbankAccessService } from '../src/tbank/tbank-access.service';
import { TbankClient } from '../src/tbank/tbank.client';
import { PortfolioService } from '../src/tbank/portfolio.service';
describe('Portfolio structure HTTP contract', () => {
let app: INestApplication<Server>;
const context = {
token: 'fixture',
generation: 'fixture',
accounts: [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
],
};
const tbank = {
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getInstrumentByFigi: jest.fn().mockResolvedValue(null),
getInstrumentByUid: jest.fn().mockResolvedValue(null),
};
const access = {
withConnection: (_user: string, action: (value: unknown) => unknown) =>
action(context),
withSupportedAccounts: (
_user: string,
action: (value: unknown) => unknown,
) => action(context),
withAccountForConnection: (
_context: unknown,
_id: string,
action: () => unknown,
) => action(),
};
const token = new JwtService().sign(
{ sub: 'structure-user', type: 'access' },
{ secret: process.env.JWT_SECRET },
);
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankAccessService)
.useValue(access)
.overrideProvider(TbankClient)
.useValue(tbank)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
app.get(PortfolioService).invalidate('structure-user');
tbank.getPortfolio.mockResolvedValue({
positions: [
{
instrumentType: 'share',
quantity: { units: 2 },
currentPrice: { currency: 'RUB', units: 30 },
},
],
});
tbank.getPositions.mockResolvedValue({
money: [{ currency: 'RUB', units: 40 }],
});
});
afterAll(() => app.close());
it.each(['a', 'all'])(
'serializes exact structure including cash for %s',
async (id) => {
const response = await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/${id}/portfolio`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
structure: {
unvaluedCategories: [],
currencies: [
{
currency: 'RUB',
totalValue: id === 'all' ? '200' : '100',
hasNegativeValues: false,
categories: [
{
type: 'STOCK',
value: id === 'all' ? '120' : '60',
sharePercent: '60',
},
{ type: 'BOND', value: '0', sharePercent: '0' },
{ type: 'OTHER', value: '0', sharePercent: '0' },
{
type: 'CASH',
value: id === 'all' ? '80' : '40',
sharePercent: '40',
},
],
},
],
},
});
},
);
it('retains unknown valuations on the wire', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
});
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/a/portfolio')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
structure: {
unvaluedCategories: [{ type: 'BOND', count: 1 }],
currencies: [
{
categories: [
{ sharePercent: null },
{ sharePercent: null },
{ sharePercent: null },
{ value: '40', sharePercent: null },
],
},
],
},
});
});
it('keeps a negative cash constituent visible to aggregate eligibility after netting', async () => {
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
money: [{ currency: 'RUB', units: id === 'a' ? -10 : 40 }],
}));
const response = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/portfolio')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
structure: {
currencies: [
{
hasNegativeValues: true,
totalValue: '150',
categories: [
{ value: '120', sharePercent: null },
{ sharePercent: null },
{ sharePercent: null },
{ value: '30', sharePercent: null },
],
},
],
},
});
});
});
+131
View File
@@ -0,0 +1,131 @@
import {
INestApplication,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { Server } from 'node:http';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { StockService } from '../src/tbank/stock.service';
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Stock HTTP contract', () => {
let app: INestApplication<Server>;
const stocks = {
getByTicker: jest.fn(),
get: jest.fn(),
search: jest.fn(),
candles: jest.fn(),
};
const token = new JwtService().sign(
{ sub: 'stock-reader', type: 'access' },
{ secret: process.env.JWT_SECRET },
);
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(StockService)
.useValue(stocks)
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
jest.clearAllMocks();
stocks.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' });
stocks.search.mockResolvedValue([]);
stocks.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SBERP' });
});
afterAll(() => app.close());
it('requires authentication before invoking the use case', async () => {
await request(app.getHttpServer()).get(`/api/v1/stocks/${uid}`).expect(401);
expect(stocks.get).not.toHaveBeenCalled();
});
it.each([
'/stocks/not-a-uid',
'/stocks/by-ticker/%20',
'/stocks/by-ticker/' + 'S'.repeat(81),
'/stocks?query=x',
'/stocks?query=',
`/stocks/${uid}/candles?period=century`,
'/brokerage-accounts/all/operations?instrumentUid=not-a-uid',
])('validates %s in the configured pipeline', async (path) => {
await request(app.getHttpServer())
.get(`/api/v1${path}`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
expect(stocks.get).not.toHaveBeenCalled();
expect(stocks.candles).not.toHaveBeenCalled();
});
it('uses verified identity and supports a direct instrument URL', async () => {
await request(app.getHttpServer())
.get(`/api/v1/stocks/${uid}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid);
});
it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])(
'resolves ticker %s through the authenticated endpoint',
async (ticker) => {
const path = `/api/v1/stocks/by-ticker/${encodeURIComponent(ticker)}`;
await request(app.getHttpServer()).get(path).expect(401);
const response = await request(app.getHttpServer())
.get(path)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
instrumentUid: uid,
slug: 'SBERP',
});
expect(stocks.getByTicker).toHaveBeenCalledWith('stock-reader', ticker);
},
);
it('returns an explicit conflict for an ambiguous ticker', async () => {
stocks.getByTicker.mockRejectedValueOnce(
new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' }),
);
const response = await request(app.getHttpServer())
.get('/api/v1/stocks/by-ticker/SAME')
.set('Authorization', `Bearer ${token}`)
.expect(409);
expect(response.body).toMatchObject({ code: 'STOCK_TICKER_AMBIGUOUS' });
});
it('returns listing identity and nullable search prices through the search endpoint', async () => {
const item = {
instrumentUid: uid,
name: 'Sber',
classCode: 'TQBR',
isin: 'ISIN',
apiTradeAvailable: true,
price: { currency: 'RUB', value: '306.12' },
priceAt: null,
};
stocks.search.mockResolvedValue([item]);
const response = await request(app.getHttpServer())
.get('/api/v1/stocks?query=SBER')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER');
expect(response.body).toEqual([item]);
});
it('preserves safe not-found and upstream errors', async () => {
stocks.get.mockRejectedValueOnce(new NotFoundException());
await request(app.getHttpServer())
.get(`/api/v1/stocks/${uid}`)
.set('Authorization', `Bearer ${token}`)
.expect(404);
stocks.get.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
const response = await request(app.getHttpServer())
.get(`/api/v1/stocks/${uid}`)
.set('Authorization', `Bearer ${token}`)
.expect(503);
expect(response.body).toMatchObject({ code: 'TBANK_UNAVAILABLE' });
expect(response.body).not.toHaveProperty('stack');
});
});
+112
View File
@@ -0,0 +1,112 @@
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import type { Server } from 'node:http';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { TbankAccessService } from '../src/tbank/tbank-access.service';
import { TbankClient } from '../src/tbank/tbank.client';
import { MoexClient } from '../src/tbank/moex.client';
describe('Upcoming events date filter HTTP boundary', () => {
let app: INestApplication<Server>;
const getPortfolio = jest.fn();
const token = new JwtService().sign(
{ sub: 'account-owner', type: 'access' },
{ secret: process.env.JWT_SECRET },
);
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankAccessService)
.useValue({
withAccount: (
_user: string,
_id: string,
handler: (ctx: unknown) => unknown,
) => handler({ token: 'unused' }),
withSupportedAccounts: (
_user: string,
handler: (ctx: unknown) => unknown,
) =>
handler({
token: 'unused',
accounts: [
{ id: 'account', name: 'Main' },
{ id: 'second', name: 'Second' },
],
}),
})
.overrideProvider(TbankClient)
.useValue({
getPortfolio,
getInstrumentByFigi: () => Promise.resolve(null),
})
.overrideProvider(MoexClient)
.useValue({
bondEvents: () =>
Promise.resolve([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
{ type: 'offer', date: '2099-03-01' },
]),
})
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
getPortfolio.mockReset().mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
});
afterAll(() => app.close());
describe.each(['account', 'all'])('%s account scope', (scope) => {
it('returns only the requested inclusive period and matching totals', async () => {
const response = await request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/${scope}/events?from=2099-02-01&to=2099-02-28`,
)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
accountId: scope,
events:
scope === 'all'
? [
{ date: '2099-02-01', account: { id: 'account' } },
{ date: '2099-02-01', account: { id: 'second' } },
{ date: '2099-02-28', account: { id: 'account' } },
{ date: '2099-02-28', account: { id: 'second' } },
]
: [{ date: '2099-02-01' }, { date: '2099-02-28' }],
incomeTotalsByCurrency: [
{ currency: 'RUB', value: scope === 'all' ? '12' : '6' },
],
});
});
it.each([
'from=not-a-date',
'from=2099-02-30',
'to=2099-13-01',
'from=2099-02-01T00:00:00Z',
'from=2099-02-28&to=2099-02-01',
])('rejects invalid dates before upstream access: %s', async (query) => {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/${scope}/events?${query}`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
expect(getPortfolio).not.toHaveBeenCalled();
});
});
});