Compare commits
10
Commits
33f703d9a3
...
3cf883efbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cf883efbc | ||
|
|
8c074d85de | ||
|
|
e6f58ef0ee | ||
|
|
8ec172f48e | ||
|
|
b1906c8e3c | ||
|
|
a79f59a468 | ||
|
|
235c179ff8 | ||
|
|
f24fd98fbc | ||
|
|
05747dc375 | ||
|
|
eaf2f67b34 |
+6
-6
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. Пароль
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Generated
+304
@@ -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",
|
||||
|
||||
@@ -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'));
|
||||
@@ -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,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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()],
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MoneyDto {
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
value: string;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}` : ''}`;
|
||||
}
|
||||
|
||||
@@ -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,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}$/;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() ||
|
||||
|
||||
@@ -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; // Цена дилера
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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,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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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=';
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user