24 Commits
Author SHA1 Message Date
ksv741 660b250ecd chore(release): 0.1.0
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 2m25s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 2m40s
2026-09-14 07:08:06 +03:00
ksv741 f01b82d366 chore: configure releases and develop workflow 2026-09-14 07:07:24 +03:00
ksv741 d4fc28cbbf docs: update system documentation references
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m5s
2026-09-14 05:51:54 +03:00
renovate-bot 97e864bc06 chore(deps): pin node.js to v24.21.0
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 2m27s
2026-09-13 20:45:06 +03:00
ksv741 121af701b7 chore(deps): upgrade backend to Nest 12 and TypeScript 6
Backend security / security (pull_request) Successful in 8s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m28s
2026-09-13 20:43:50 +03:00
ksv741 2bc8745495 chore(deps): update and pin backend dependencies
Backend security / security (pull_request) Successful in 13s
Backend security / security (push) Successful in 10s
Backend verify / verify (push) Successful in 2m53s
2026-09-13 20:29:13 +03:00
ksv741 4c8ba2fac9 ci: verify backend and secure dependency updates #1 #5 #6
Backend security / security (pull_request) Successful in 8s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 2m26s
2026-09-13 19:58:29 +03:00
renovate-bot a5156fbeb5 Add renovate.json 2026-09-13 14:02:04 +00:00
ksv741 197ffe2136 chore: upgrade Node to 24 LTS 2026-09-13 15:05:38 +03:00
ksv741 ce24a84b7f fix: make SQLite migrations and e2e reproducible 2026-09-13 14:52:09 +03:00
ksv741 8637ad2f34 refactor: centralize domain values and enforce numeric lint 2026-09-13 14:37:39 +03:00
ksv741 8ba005346d refactor: organize backend by business responsibility 2026-09-13 13:52:57 +03:00
ksv741 7fde3ece6e test: cover isolated SQLite invariants and durability 2026-09-13 12:33:17 +03:00
ksv741 346a6f0057 refactor: enforce strict backend typing
Enable strict TypeScript and reject non-null and definite-assignment assertions. Declare externally populated DTO fields, preserve runtime validation, and narrow values at their owners.

Verified: backend verify (257 tests), unchanged OpenAPI, and 21 SQLite/HTTP tests on retry. The initial SQLite run had two timeouts; baseline HEAD passed 21 tests as well.
2026-09-13 12:17:46 +03:00
ksv741 3cf883efbc feat(cards): support inclusive date range queries 2026-09-12 21:52:17 +03:00
ksv741 8c074d85de chore: save pending changes 2026-09-12 19:59:10 +03:00
ksv741 e6f58ef0ee feat: add manual bank card ledger
Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
2026-09-12 18:34:18 +03:00
ksv741 8ec172f48e fix: recover portfolio instrument metadata on backend
Bound metadata concurrency and waiting, retry transient lookups, and retain known metadata during failures. Isolate in-flight lookups by token and preserve financial observation time.
2026-09-12 15:37:56 +03:00
ksv741 b1906c8e3c docs: update routes to system documentation 2026-09-12 14:45:47 +03:00
ksv741 a79f59a468 feat(events): support date range filtering 2026-09-12 11:57:41 +03:00
ksv741 235c179ff8 feat: add bond details, yields and credit ratings 2026-09-10 22:30:46 +03:00
ksv741 f24fd98fbc feat: add stock details and ticker resolution
Expose stock search, market data, company information, dividends and portfolio history with exact instrument identity and verified ticker aliases. Include stock and bond position counts in account summaries.
2026-09-10 21:23:14 +03:00
ksv741 05747dc375 feat(portfolio): expose asset allocation by currency 2026-09-09 21:30:11 +03:00
ksv741 eaf2f67b34 feat: aggregate portfolio data across accounts
Add authenticated all-account portfolio, operations, income and event endpoints. Preserve source accounts, exact currency totals and scoped cursor pagination.
2026-09-09 20:36:31 +03:00
184 changed files with 17537 additions and 3810 deletions
+6 -5
View File
@@ -6,15 +6,16 @@
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
| Новый module/provider/service; бизнес-логика; перенос responsibility | [Architecture](conventions/backend-architecture.md) | | Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.md) |
| TS vocabulary, domain literals, magic values | [Domain literals](conventions/backend-architecture.md#domain-literals-and-magic-values) |
| Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) | | Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) |
| Prisma/schema/query/persistence, transaction, migration, atomic write | [Data](conventions/backend-data.md) | | Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) |
| Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) | | Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) |
| JWT, cookie, login/refresh/logout, guard/access, token encryption, sensitive logs | [Security](conventions/security-auth.md) | | JWT, cookie, login/refresh/logout, guard/access, token encryption, sensitive logs | [Security](conventions/security-auth.md) |
| T-Bank/gRPC/proto, upstream retry/rate limit/cache, MoexClient | [Integrations](conventions/backend-integrations.md) | | External client/integration boundary; T-Bank/gRPC/proto, retry/rate limit/cache | [Integrations](conventions/backend-integrations.md) |
| Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) | | Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) |
| Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) | | Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../AGENTS.md) |
| Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [общий contract workflow](../../docs/development/runtime-openapi-verification.md) | | Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [Источник HTTP-контракта](../../docs/architecture/http-api.md#источник-контракта) |
Новый endpoint обычно требует HTTP API + Testing; Architecture/Data/Security Новый endpoint обычно требует HTTP API + Testing; Architecture/Data/Security
добавляются по новым ответственности, persistence или access rules. добавляются по новым ответственности, persistence или access rules.
+6 -6
View File
@@ -3,12 +3,12 @@
| Что нужно установить | Владелец | | Что нужно установить | Владелец |
| --- | --- | | --- | --- |
| Implementation decision и безопасный pattern | [CONVENTIONS](CONVENTIONS.md) | | 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 | | 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: Security](../../docs/AI_INDEX.md#security) | | 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/AI_INDEX.md#data) | | Prisma schema/data ownership и migration context | [docs: Data](../../docs/architecture/containers.md#хранимые-данные-и-границы-доступа) |
| T-Bank/gRPC/cache, logging | [docs: Integrations](../../docs/AI_INDEX.md#integrations) | | 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/AI_INDEX.md#domain) | | Money, account/position/income/cache meanings | [docs: Domain](../../docs/glossary.md) |
| Поведение конкретной feature | [docs: Features](../../docs/AI_INDEX.md#features) | | Поведение конкретной feature | [docs: Features](../../docs/README.md#предметные-области) |
| Существующие behavioral requirements при необходимости | [specs index](../../specs/AI_INDEX.md) | | Существующие behavioral requirements при необходимости | [specs index](../../specs/AI_INDEX.md) |
| Versions/scripts и scope автоматических checks | package.json/lockfile, ESLint/TypeScript/Jest/Prisma configs | | Versions/scripts и scope автоматических checks | package.json/lockfile, ESLint/TypeScript/Jest/Prisma configs |
+244 -26
View File
@@ -1,39 +1,257 @@
# Backend architecture # Backend architecture
## Выбор владельца ## Module-first organization
Найди существующий use case, его collaborators и tests до создания module/service. Backend — domain-oriented modular monolith с лёгким разделением
Сохрани ответственность в текущем module, если новая возможность относится к нему. по ответственности. `src/modules` группирует код по бизнес-возможности: `auth`, `banking/bank-cards`, области `investments`.
Module — граница связного набора providers, а не папка для каждого endpoint. В инвестициях отдельные Nest modules владеют `brokerage-accounts`, `portfolio`,
`operations`, `income`, `upcoming-events`, `instruments`, `tbank-connection`.
`instruments/stocks` и `instruments/bonds` — slices внутри одного модуля.
| Решение | Owner | ### Progressive structure
Три уровня организации: business module → responsibility layer → technical grouping
при реальной необходимости. Компактный module с понятными controller/DTO/service
может оставаться плоским. Ориентир 5–7 файлов не является порогом: учитывай число
ответственностей, наличие domain/repository/use cases и удобство просмотра каталога.
Когда в module смешаны несколько services, transport, расчёты и persistence,
группируй файлы по ответственности:
```text
feature/
├── feature.module.ts
├── feature.api.ts # только при существующих внешних потребителях
├── presentation/ # HTTP, DTO, guards
├── application/ # сценарии, orchestration, application input
├── domain/ # чистые бизнес-правила и расчёты
└── infrastructure/ # persistence, framework и boundary mapping
```
Создавай только нужные слои. В корне сложного module оставляй composition и
публичную boundary; production code и colocated tests размещай в слое владельца.
Внутри business slice действует то же правило, без отдельного Nest module ради папок.
`controllers/`, `dto/`, `services/`, `use-cases/`, `persistence/`, `mappers/`
допустимы только внутри слоя, когда множество однотипных файлов мешает навигации.
Для одного controller или пары DTO дополнительная поддиректория не нужна.
Пустые папки, новые barrels, CQRS и interfaces не требуются для этой организации.
В текущем коде слои используют `auth`, `bank-cards`, `portfolio`, `operations`
и slices `instruments/stocks`, `instruments/bonds`. Общие application helpers
инструментов находятся в `instruments/application`; `instruments.module.ts`
компонует оба slice. `stock-position.ts` и `instrument-position.ts` формируют
результат из portfolio DTO; `instrument-market.ts` обрабатывает внешние данные
и ошибки Nest. Это application code, а не независимая domain model.
`brokerage-accounts`, `income`, `upcoming-events`, `tbank-connection` остаются
компактными плоскими modules. Наличие tests само по себе не требует слоёв.
## Поиск и создание файла
При работе над feature проходи владельцев в порядке:
1. Найди business module.
2. Прочитай его `*.module.ts` (для slice — composition родительского module).
3. Проверь `presentation/`: controller и transport contract.
4. Проследи вызовы в `application/`: сценарии и services.
5. Проверь используемые правила в `domain/`.
6. Проверь `infrastructure/`: repository и технические реализации.
7. По обнаруженным зависимостям переходи в `integrations/` и `common/`.
В плоском module классифицируй файлы по suffix и следуй их ответственности.
Отсутствующий слой пропускай. Если business owner известен, начинай локально,
а не с repository-wide search.
**Rule of locality:** code that belongs to one business capability stays inside
that capability unless it is cross-cutting. Например, расчёты карт принадлежат
bank-cards/domain, даже если реализованы обычными функциями.
Перед созданием файла определи:
1. Какой business module владеет изменением? Сначала owner, затем common при
подтверждённой общей потребности.
2. Какова ответственность: HTTP/DTO/guard → presentation; сценарий/orchestration →
application; чистое business rule/calculation → domain; persistence или
module-specific technical implementation → infrastructure; внешний протокол → integrations.
3. Какой approved suffix из таблицы соответствует этой ответственности?
4. Какой слой владеет файлом? В компактном плоском module сохрани размещение рядом
с owner; в слоистом используй соответствующий слой.
5. Достаточно ли colocate файл рядом с существующими? Level 3 grouping создавай
только при заметном улучшении навигации по множеству однотипных файлов.
Если owner или роль не определены, сначала установи ответственность. Generic
`utils.ts`, `helpers.ts`, `manager.ts`, `handler.ts` и arbitrary directory её не заменяют.
## Naming & File Suffix Convention
Имена файлов — `kebab-case`. File suffix определяет responsibility, directory
определяет architectural layer. Размещение в таблице предпочтительно для
слоистого module; компактный плоский module сохраняет файлы в корне.
Cross-module consumers во всех строках ограничены публичной границей ниже.
| Suffix | Когда использовать | Где | Кто зависит | Когда не использовать |
| --- | --- | --- | --- | --- |
| `*.controller.ts` | HTTP endpoint / transport | presentation | Nest wiring | DB, расчёты, SDK, сложная оркестрация |
| `*.dto.ts` | Request/response, validation, OpenAPI | presentation | Controller; application boundary при совпадении shape | Универсальная domain model |
| `*.use-case.ts` | Один самостоятельный application scenario | application | Controller, другой orchestrator | Prisma queries, Request/Response, SDK implementation |
| `*.service.ts` | Конкретная cohesive reusable capability | рядом с owner; application/domain/integration по роли | Сценарии и services; domain — только от чистого domain service | Свалка несвязанных сценариев |
| `*.repository.ts` | Persistence queries и согласованные DB reads/writes | infrastructure | Application service/use case | Domain calculations, HTTP request, external transport |
| `*.client.ts` | Низкоуровневый внешний HTTP/gRPC/SDK | integrations или infrastructure | Application orchestration, boundary mapper | Пользовательский lifecycle и domain rules |
| `*.mapper.ts` | Meaningful conversion между моделями | обычно infrastructure у преобразуемой boundary | Код соответствующей boundary | Формальное копирование нескольких одинаковых полей |
| `*.policy.ts` | Cohesive decision rule | domain/application/integration по входным данным | Владелец правила; domain — от чистой policy | Persistence, transport, generic strategy |
| `*.guard.ts` | Nest guard | presentation | Nest/controller | Основная account authorization и финансовые правила |
| `*.strategy.ts` | Framework strategy, например JWT | infrastructure | Framework wiring | Обычный service без strategy contract |
| `*.module.ts` | Nest imports/providers/controllers/exports | module root | Composition root, importing module | Бизнес-логика |
| `*.types.ts` | Малая группа связанных TS types | рядом с owner | Потребители модели на разрешённых слоях | Глобальные types всего module, runtime validation |
| `*.api.ts` | Явный список публичных exports | root конкретного module/integration | Другие modules | DTO/client/service implementation или generic API logic |
| `*-input.ts` | Application input: нормализация и проверка формы, отличающейся от wire DTO | application | Application code | Копия DTO под новым именем; вариант `*.input.ts` |
| `*.calculations.ts`, `*.rules.ts` или предметное имя | Чистые бизнес-расчёты, value semantics | domain | Application и domain | Nest/Prisma/HTTP/SDK зависимости |
| `*.spec.ts` | Существующая unit/component проверка поведения | рядом с owner/slice | Test runner | Архитектурные проверки папок и trivial wiring |
`bank-card-input.ts` использует выбранный `*-input.ts`: переводит Money в
копейки, нормализует значения и проверяет application input. DTO не дублируется
отдельным Input type. `rub-money.ts` — domain-правила RUB; `bond-money.mapper.ts`
— преобразование ответа клиента. Эти роли не определяются словом money в имени.
Технические framework suffixes (`*.filter.ts`, `*.middleware.ts`,
`*.decorator.ts`, `*.config.ts`, `*.validation.ts`) сохраняют соответствующие
существующие роли в common/config. `tbank.crypto.ts` — локальная реализация
шифрования токена; имя не является разрешением создавать generic crypto layer.
HTTP/e2e naming и расположение — в [Testing](backend-testing.md).
Без зафиксированной необходимости не вводи `*.helper.ts`, `*.utils.ts`,
`*.manager.ts`, `*.processor.ts`, `*.handler.ts`, `*.provider.ts`, `*.factory.ts`.
Новая naming convention сначала получает конкретную роль и обоснование здесь.
Для data access выбран только `*.repository.ts`, не persistence/store/dao.
## Публичные границы
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
модулями providers, функции и типы. Внешний production import идёт через этот
API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный
публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma.
Внутри модуля используй прямые локальные импорты.
Tests могут импортировать внутренний owner для проверки поведения и mocks.
Новый export требует текущего потребителя; controller и persistence остаются
приватными. Не добавляй `index.ts` на каждом уровне.
Каждый provider регистрируется у одного владельца. Потребители импортируют
его module, а не повторяют provider: это сохраняет единственный кэш и лимитер.
`AuthModule` экспортирует guard; наличие JWT не заменяет account access check.
- `income` читает операции через `operations.api.ts`.
- `instruments` получает личные позиции через `portfolio.api.ts`.
- `tbank-connection` использует T-Bank integration и публичный сброс портфеля.
- `portfolio` и другие инвестиционные модули используют `TbankModule`, который
не зависит от business modules. Так управление подключением не образует цикл.
- `investment-calendar.ts` и `account-reference.dto.ts` разделяются только
инвестиционными областями; это не глобальная библиотека бизнес-правил.
## Ответственность и направление зависимостей
| Владелец | Ответственность |
| --- | --- | | --- | --- |
| Route, request/response mapping, status/cookie | Controller, [HTTP rules](backend-http-api.md) | | Controller / presentation | Route, verified identity, transport DTO, validation, status/cookie; вызов сценария |
| Business use case, orchestration, domain validation | Feature service | | Use case / application service | Оркестрация, business validation, доступ и согласованность результата |
| Input shape/format | DTO + configured ValidationPipe | | Domain | Чистые правила и расчёты; без NestJS, Prisma, HTTP/Swagger и внешних SDK |
| Атомарность/DB query | Сервис use case через PrismaService, [Data](backend-data.md) | | Persistence / infrastructure | Запросы, транзакции, технические реализации границ |
| Внешний protocol, deadline/retry/limiting | Existing client, [Integrations](backend-integrations.md) | | `src/integrations/{tbank,moex,ncr}` | Внешние протоколы, декодирование, retry/deadline/cache; без зависимостей от HTTP DTO business modules |
| Чистое вычисление/mapper без lifecycle и DI | Обычная функция рядом с domain owner | | `src/prisma` | Общий DB provider и lifecycle; не generic repository |
| Bootstrap/config/global cross-cutting concern | AppModule, config, существующий common owner |
**Безопасный путь:** расширь текущий owner; выделяй collaborator, когда он скрывает Основной вызов: presentation → application → domain; application использует
самостоятельный инвариант или координацию. Не вводи CQRS, generic repository, конкретные infrastructure providers, зарегистрированные module. Infrastructure
interface с одной реализацией или base controller только ради шаблона Nest. может использовать application types/input и domain rules; новые interfaces
Не превращай common в хранилище бизнес-логики и не переноси frontend модели сюда. ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
по [Data](backend-data.md); use case получает данные через repository и сам
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
DTO остаются транспортными runtime classes; существующее использование DTO
на application boundary допустимо по [HTTP API](backend-http-api.md).
## Существующие seams Сохраняй существующий error contract: HTTP exceptions остаются на application/
transport boundary. Чистому расчёту не нужен новый framework ошибок.
Точные ограничения — [HTTP](backend-http-api.md), [Data](backend-data.md),
[Security](security-auth.md), [Integrations](backend-integrations.md).
`TbankConnectionModule` уже объединяет счета, портфель, операции, доходы и события. ## Сценарии и размер сервисов
Новый связанный use case обычно добавляется в него; создание отдельного module
требует собственной ответственности, не только нового файла.
- [BrokerageOperationsController](../../src/tbank/brokerage-operations.controller.ts) передаёт user/account/query в service, без финансовых вычислений в controller. Разделяй независимые операции, а не строки. `BankCardsService` отвечает за карты,
- [TbankAccessService](../../src/tbank/tbank-access.service.ts) владеет connection/account access и generation; новые account use cases используют его вместо копирования guard/decrypt logic. `BankOperationsService` — журнал/переводы/возвраты, `ExpenseCategoriesService` —
- [decimal.ts](../../src/tbank/decimal.ts) — пример pure domain helper; Injectable и adapter для арифметики не нужны. категории, `GetBankSummaryUseCase` — сводку. `BankCardsRepository` сохраняет
общий transaction retry и owner lookup; денежные транзакции не разрываются.
Чистые расчёты банковских карт находятся в `domain` этого модуля.
Наличие guard подтверждает session, но не владение account. При пересечении У инструментов `StockCatalogService` / `BondCatalogService` хранят идентичность
access boundary добавь [Security](security-auth.md). Ошибка upstream не должна листинга и общий кэш slice; market service — котировки/свечи, income service — выплаты/личные позиции.
обходить общий HTTP filter или превращать чужой account в доступный. `GetStockCompanyUseCase` владеет сведениями об эмитенте. Ключи, TTL и pending
promises разделяют прежний экземпляр кэша; extract не должен менять generation.
Portfolio и operations сохраняют связную координацию снимков/страниц и кэшей.
Расчёты портфеля находятся в `portfolio/domain`; преобразование брокерских
позиций — в `tbank-portfolio.mapper.ts`. `bond-money.mapper.ts` преобразует
ответ клиента и не является value object.
Выбирай обычную функцию для чистого вычисления; repository contract — только
для реальной границы. CQRS/buses/events, BaseRepository/BaseService, DI token
для каждого класса, mapper/presenter для каждого DTO и новые dependencies
не нужны для структурного переноса.
## Common
`common` содержит общий error filter, logging, API decorators, тип `Money`,
его HTTP `MoneyDto` и точную decimal-арифметику, используемые несколькими областями. Код одного
модуля принадлежит этому модулю: например, RUB/kopecks rules — банковским картам,
protobuf units/nano conversion — T-Bank integration. Domain использует
`money.types.ts`, а не Swagger DTO; это общая форма денег для карт и портфеля.
Давай функциям предметные имена; не создавай глобальные `utils.ts`/`helpers.ts`.
## Domain literals and magic values
Finite business vocabulary must be modeled explicitly; incidental implementation
literals stay inline. Это относится к error codes, statuses, operation/account/
instrument/transaction types, permissions, roles, feature states и бизнес-режимам.
Перед новым определением найди existing owner/type/value. В branching и comparisons
используй символическое значение владельца; не повторяй raw domain literals.
Храни словарь у domain/module/entity с соблюдением границ слоёв, без глобальной
свалки `constants.ts`. Сохраняй принятый pattern; для нового project-owned словаря
без подходящего аналога используй `as const` с derived union:
```ts
const BankErrorCode = {
InvalidDate: "BANK_INVALID_DATE",
ImmutableOperation: "BANK_IMMUTABLE_OPERATION",
} as const;
type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
// Вместо code === "BANK_INVALID_DATE":
if (code === BankErrorCode.InvalidDate) { /* existing handling */ }
```
Значимые numeric policies (timeout, retry, page size, threshold, coefficient)
называй по смыслу и единицам: `REQUEST_TIMEOUT_MS`, `CACHE_TTL_SECONDS`,
`MAX_RETRY_COUNT`, `PAGE_SIZE`. Оставляй inline UI labels в принятом месте,
log messages, индексы, `index + 1`, `Math.max(value, 0)`, `page ?? 1`,
`percentage * 100` и очевидные локальные детали без semantic benefit.
При неясном owner/смысле оставь значение и укажи ограничение в review.
Не мигрируй корректные existing enum/union styles массово. Не добавляй ради
правила factories, registries, schemas, loaders, abstraction layers, dependencies,
custom lint/AST rules, generation/validation scripts или тесты существования
констант. Semantic review проверяет ownership, единое написание и неизменность
wire values; обычные behavior tests проверяют поведение.
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain,
а DTO получает допустимые значения через `Object.values(BankOperationKind)`.
Frontend переиспользует generated OpenAPI types/values по возможностям генератора,
а не определяет независимый backend-owned словарь. Внешние/generated enums
сохраняй; generated Prisma/protocol code вручную не меняй.
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм,
индексов, default values, enum/type declarations и readonly properties заданы
в ESLint config. Test fixtures/expected values остаются inline, остальные lint
правила продолжают действовать. Numeric policy получает предметное имя и единицы;
semantic magic strings проверяются conventions/review без нового инструмента.
## Logging ## Logging
+25 -4
View File
@@ -9,12 +9,33 @@ PrismaService — существующий shared DB provider с lifecycle пр
| Ситуация | Решение | | Ситуация | Решение |
| --- | --- | | --- | --- |
| Один query/write конкретного use case | Existing service + PrismaService | | Данные для use case | Concrete repository; use case не импортирует PrismaService/Prisma и не строит queries |
| Несколько записей обязаны выполниться вместе | Database transaction вокруг инварианта | | Несколько записей обязаны выполниться вместе | Database transaction вокруг инварианта |
| Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата | | Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата |
| Независимые reads | Не добавляй transaction ради внешнего вида | | Независимые reads | Не добавляй transaction ради внешнего вида |
| Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен | | Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен |
Размещение repository определяется [Architecture](backend-architecture.md):
в слоистом module это `infrastructure/`.
Repository — конкретный класс `*.repository.ts` у business owner, без обязательных
interface/port/adapter. Он владеет queries, includes/filtering, DB mapping и
согласованным чтением. Суффиксы `*.persistence.ts`, `*.store.ts`, `*.dao.ts`
для той же роли не используем. Nest module регистрирует repository приватно.
`BankCardsRepository.summaryData` проверяет владельца карты и получает операции
с категориями в одной read transaction. `GetBankSummaryUseCase` выбирает период,
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
не становятся domain entities: расчёт получает только нужные ему значения.
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
services (auth, lifecycle подключения, изменения карт/операций/категорий)
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
лишь набора pass-through методов. Новая самостоятельная read/write capability
получает repository method; самостоятельный use case не выполняет Prisma queries.
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для
существующих banking services. Такой callback с Prisma transaction client —
локальная техническая граница этих services, не API для domain или use cases.
Выбирай только нужные данные, сохраняй user/account ownership в запросах и Выбирай только нужные данные, сохраняй user/account ownership в запросах и
application checks. Предварительный lookup не заменяет unique constraint при race. application checks. Предварительный lookup не заменяет unique constraint при race.
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany` Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
@@ -23,15 +44,15 @@ application checks. Предварительный lookup не заменяет
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
lock во время retry внешней сети. Access/cache generation semantics — у 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, Для денежных значений используй existing decimal helpers и строковый wire value,
не JS floating-point арифметику для сумм. Currency — отдельная часть Money; не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
разные валюты не складываются без явно заданного conversion contract. разные валюты не складываются без явно заданного conversion contract.
Источник: [Money](../../../docs/architecture/domain-glossary.md#money), Источник: [Money](../../../docs/glossary.md#money),
пример: [decimal.ts](../../src/tbank/decimal.ts). UI formatting не меняет пример: [decimal.ts](../../src/common/decimal.ts). UI formatting не меняет
backend ownership расчётов и доменную семантику. backend ownership расчётов и доменную семантику.
## Schema и migration ## Schema и migration
+12 -6
View File
@@ -4,8 +4,7 @@
Сначала определи, меняется wire contract или только реализация существующего Сначала определи, меняется wire contract или только реализация существующего
use case. Для route/request/response/status/error shape читай use case. Для route/request/response/status/error shape читай
[HTTP semantics](../../../docs/architecture/http-api.md) и [HTTP semantics и источник контракта](../../../docs/architecture/http-api.md).
[общий contract workflow](../../../docs/development/runtime-openapi-verification.md).
| Проверка | Где выполнять | | Проверка | Где выполнять |
| --- | --- | | --- | --- |
@@ -16,16 +15,23 @@ use case. Для route/request/response/status/error shape читай
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter | | HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
ради обхода service. Request identity берётся из verified `request.user`, не из и не выполняет финансовые расчёты или сложную оркестрацию.
body/userId, предоставленного клиентом. Access rules активируют Security. Controller вызывает application use case/service. Identity берётся из verified
`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security.
## DTO и errors ## DTO и errors
DTO — runtime class: TS interface не предоставляет validation metadata. DTO — runtime class: TS interface не предоставляет validation metadata.
`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного
module или presentation. DTO можно передать application boundary при совпадении
формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO:
например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`.
Нормализованная форма input нужна только при смысловом отличии от wire contract;
выбор suffix принадлежит [Architecture](backend-architecture.md).
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`). ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён. Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
[operations DTO](../../src/tbank/brokerage-operation.dto.ts). Swagger metadata [operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata
описывает те же required/optional/nullable/enum/range значения. описывает те же required/optional/nullable/enum/range значения.
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
@@ -40,7 +46,7 @@ raw upstream error, Prisma record с secrets или stack. Текущая фор
## Пример и проверка ## Пример и проверка
[Operations controller](../../src/tbank/brokerage-operations.controller.ts) [Operations controller](../../src/modules/investments/operations/presentation/brokerage-operations.controller.ts)
показывает guard, user.sub, DTO query и service delegation. показывает guard, user.sub, DTO query и service delegation.
Для нового field обновляй canonical source и generated artifacts в определённом Для нового field обновляй canonical source и generated artifacts в определённом
направлении; body type frontend не исправляется вручную. направлении; body type frontend не исправляется вручную.
+27 -7
View File
@@ -3,14 +3,34 @@
## Выбор boundary ## Выбор boundary
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case
получают нормализованный результат через эти clients. Frontend не вызывает используют объявленные клиентом records; SDK clients, metadata и transport errors
остаются внутри integration. Frontend не вызывает
T-Bank и не получает credentials/proto client. Reference source — T-Bank и не получает credentials/proto client. Reference source —
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и [ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и
[connection behavior](../../../docs/features/tbank-connection.md). [connection behavior](../../../docs/integrations/tbank.md).
`modules/investments/tbank-connection` — пользовательская capability чтения,
создания/замены и удаления подключения. `integrations/tbank` — технические
RPC, credentials/access и resilience. Эти владельцы не объединяются из-за имени
поставщика; низкоуровневый client не зависит от пользовательского module.
`*.client.ts` содержит transport и его retries; `*.api.ts` только перечисляет
публичные exports. Все шесть `auth/operations/portfolio/tbank/moex/ncr.api.ts`
имеют одинаковую роль программной границы, определённую в
[Architecture](backend-architecture.md). Логика, DTO definitions и HTTP routes
в эти файлы не помещаются. Type exports обозначаются `export type`.
Преобразование provider records происходит на boundary: например,
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
остаётся отдельным чистым входом integration. Domain получает внутренние
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
client или mapper для простого копирования полей. Даты/units/nano разбираются
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
Для нового account use case сначала используй TbankAccessService: access, Для нового account use case сначала используй TbankAccessService: access,
connection generation и invalid-token translation не дублируются в callers. connection generation и invalid-token translation не дублируются в callers.
Чистый mapper финансового значения остаётся рядом с domain owner. Mapper внешних финансовых значений остаётся внутри business capability,
обычно в infrastructure по [Architecture](backend-architecture.md).
## Retry, cache и protocol ## Retry, cache и protocol
@@ -18,19 +38,19 @@ Existing client уже владеет bounded FIFO queue, rate limit, deadline
классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop
в service. Не отключай TLS verification ради окружения: используй документированный в service. Не отключай TLS verification ради окружения: используй документированный
CA mechanism. Точные параметры и ограничения принадлежат CA mechanism. Точные параметры и ограничения принадлежат
[connection configuration](../../../docs/features/tbank-connection.md#конфигурация-и-ограничения). [connection configuration](../../../docs/integrations/tbank.md#конфигурация-и-ограничения).
При query/cache change установи key, user/account/generation ownership, При query/cache change установи key, user/account/generation ownership,
freshness и поведение late response. Старый результат не может обслужить новую freshness и поведение late response. Старый результат не может обслужить новую
connection generation. Client Cached Snapshot frontend отличается от backend connection generation. Client Cached Snapshot frontend отличается от backend
cache/stale policy; semantics конкретного use case читай через cache/stale policy; semantics конкретного use case читай через
[feature index](../../../docs/AI_INDEX.md#features). [feature index](../../../docs/README.md#предметные-области).
Внешний auth failure и временная недоступность имеют разные public codes; Внешний auth failure и временная недоступность имеют разные public codes;
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger. и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger.
Vendor proto хранится в `src/tbank/proto/contracts`; для обновления сверяй Vendor proto хранится в `src/integrations/tbank/proto/contracts`; для обновления сверяй
официальный source и влияние на loader/DTO, не редактируй read-only references. официальный source и влияние на loader/DTO, не редактируй read-only references.
Generated artifacts, если есть, создавай из source, без ручной подгонки вывода. Generated artifacts, если есть, создавай из source, без ручной подгонки вывода.
@@ -40,7 +60,7 @@ Generated artifacts, если есть, создавай из source, без р
ответственного service. Отдельный abstraction/adapter оправдан новой ответственного service. Отдельный abstraction/adapter оправдан новой
ответственностью или несовместимым protocol, не только вторым методом. ответственностью или несовместимым protocol, не только вторым методом.
[TbankClient tests](../../src/tbank/tbank.client.spec.ts) — пример controlled [TbankClient tests](../../src/integrations/tbank/tbank.client.spec.ts) — пример controlled
upstream responses. Проверь queue/deadline/retry limit, token translation, upstream responses. Проверь queue/deadline/retry limit, token translation,
cache isolation и late response для затронутых ветвей. Fixtures синтетические; cache isolation и late response для затронутых ветвей. Fixtures синтетические;
реальная сеть, секреты и пользовательские accounts не нужны. реальная сеть, секреты и пользовательские accounts не нужны.
+16 -11
View File
@@ -2,7 +2,8 @@
## Выбор уровня ## Выбор уровня
Применяй [общие testing risks](../../../docs/development/engineering-governance.md#risk-based-testing). Проверяй риски доступа, денежных расчётов, целостности данных, конкурентных
изменений, публичного контракта и подтверждённых регрессий.
Тесты должны различать правильное и неправильное наблюдаемое поведение. Тесты должны различать правильное и неправильное наблюдаемое поведение.
| Что меняется | Проверка | | Что меняется | Проверка |
@@ -21,8 +22,8 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
## Случаи и примеры ## Случаи и примеры
- [BrokerageAccountsService tests](../../src/tbank/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы. - [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы.
- [AuthService tests](../../src/auth/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction. - [AuthService tests](../../src/modules/auth/application/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction.
- [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope. - [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope.
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank. - [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank.
@@ -36,11 +37,15 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
Полный `npm run verify` включает lint, оба typecheck, Jest и build. Полный `npm run verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change. `npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` существует, но известный Prisma migration blocker описан в `npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
[backend workflow](../../../docs/development/backend-workflow.md) и WL-P2-05 `npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
[registry](../../../docs/development/audit-remediation.md). Скрипт использует базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
фиксированную `/tmp/wealth-ledger-e2e.db`; не запускай его конкурентно как `prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`,
независимые DB tests. Для isolation выбери отдельную test DB и runner configuration. вызывающего ошибку fresh migration на Prisma 7.9.1. Подробности и регрессия запуска —
Если задаче нужна HTTP/DB proof, установи применимость blocker и выполни доступную [Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
изолированную проверку. Не объявляй покрытым то, что до tests не запустилось; HTTP suites требуют разрешения открывать локальные порты. Ошибка sandbox
сохрани точный failure и оставшуюся проверку. Unit/Swagger success не заменяет e2e. не считается успешным тестом; повторите проверку в подходящем окружении.
Для schema/data changes дополнительно `npm run test:durability -- <baseline>`.
Матрица рисков и подготовка совместимой предыдущей сборки — в том же README.
Mock Prisma не заменяет доказательство реальной DB concurrency.
+3 -3
View File
@@ -10,11 +10,11 @@ account access/connection generation — TbankAccessService; token crypto —
TbankTokenService/TbankCrypto. Расширяй существующую boundary, не добавляй TbankTokenService/TbankCrypto. Расширяй существующую boundary, не добавляй
второй JWT middleware, decrypt helper или refresh coordinator. второй 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) [ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md)
задают session semantics. Access хранится только в памяти frontend; refresh задают session semantics. Access хранится только в памяти frontend; refresh
передаётся httpOnly cookie. Source code конфигурации cookie — передаётся httpOnly cookie. Source code конфигурации cookie —
[AuthController](../../src/auth/auth.controller.ts); изменение flags/path требует [AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует
проверки browser/HTTP поведения, не только TypeScript. проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает Refresh rotation атомарна: один победитель, проигравший/superseded token получает
@@ -32,7 +32,7 @@ cases для соответствующего изменения.
T-Bank credentials остаются server-side и хранятся encrypted. Используй T-Bank credentials остаются server-side и хранятся encrypted. Используй
existing AES-256-GCM boundary с validated 32-byte key; не печатай plaintext, existing AES-256-GCM boundary с validated 32-byte key; не печатай plaintext,
шифротекст, cookie, Authorization или connection generation в logs/errors/API. шифротекст, 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. cache invalidation при замене/deletion и ограничение key rotation.
Не передавай Prisma User/Session целиком наружу; выбирай public DTO. Пароль Не передавай Prisma User/Session целиком наружу; выбирай public DTO. Пароль
+3
View File
@@ -0,0 +1,3 @@
{
"frontend": "0244acf429c525b23117f165bbe52ef5403eefd3"
}
+26
View File
@@ -0,0 +1,26 @@
name: Conventional commits
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
commits:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
+31
View File
@@ -0,0 +1,31 @@
name: Backend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+50
View File
@@ -0,0 +1,50 @@
name: Backend verify
on:
push:
branches: ['**']
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: https://github.com/actions/checkout@v4
with:
path: backend
- name: Checkout compatible peer revisions
env:
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
TARGET_BRANCH: ${{ github.base_ref || github.ref_name }}
run: |
set -eu
for repo in frontend; do
ref=develop
case "$SOURCE_BRANCH:$TARGET_BRANCH" in
release/*:*|*:main)
ref=$(jq -er --arg repo "$repo" '.[$repo]' .ci/refs.json)
printf '%s\n' "$ref" | grep -Eq '^[0-9a-f]{40}$'
;;
esac
target="../$repo"
if [ "$repo" = main ]; then target=..; fi
git init "$target"
git -C "$target" remote add origin "https://git.personal.ksv741.ru/WealthLedger/$repo.git"
git -C "$target" fetch --depth 1 origin "$ref"
git -C "$target" checkout --detach FETCH_HEAD
git -C "$target" rev-parse HEAD
done
git rev-parse HEAD
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: backend/.nvmrc
- run: npm ci
- name: Generate Prisma client before typechecks
run: npx --no-install prisma generate
- name: Run all backend gates
run: npm run verify
- name: Run isolated SQLite and runtime OpenAPI suites
run: npm run test:e2e
+17
View File
@@ -0,0 +1,17 @@
# Reviewed historical false positives only; new commits remain scanned.
# Test fixtures: base64 of the deterministic string 0123456789abcdef repeated twice.
25d7c5780b60343fecb4f1ae9a30392776eb5aec:src/config/env.validation.spec.ts:generic-api-key:30
31cbba8654f01adf11c52e12c2b286200d6cad7d:src/config/env.validation.spec.ts:generic-api-key:10
31cbba8654f01adf11c52e12c2b286200d6cad7d:src/config/env.validation.spec.ts:generic-api-key:17
31cbba8654f01adf11c52e12c2b286200d6cad7d:src/config/env.validation.spec.ts:generic-api-key:42
31cbba8654f01adf11c52e12c2b286200d6cad7d:test/e2e-env.cjs:generic-api-key:4
326fb67def9b3878d5447ef838ae96d50bf493d6:src/tbank/brokerage-operations.service.spec.ts:generic-api-key:15
34c2b79e276c9575996604f3f862bc866cfe44e7:src/tbank/upcoming-events.service.spec.ts:generic-api-key:15
c0a4a9388074bdce20138dd5649805ed24a9e9fa:src/tbank/brokerage-accounts.service.spec.ts:generic-api-key:12
ce24a84b7fb48091d2b91836eb7b702a2f811c96:test/e2e-env.cjs:generic-api-key:16
dfca11c81f7e6dadce3ca6f961d265c5e3f8ff8e:src/tbank/portfolio.service.spec.ts:generic-api-key:16
# Historical .env.example JWT_SECRET is an explicit replace-with placeholder.
4cbfc2621cfc54df3d09786905130c28a2323a04:.env.example:generic-api-key:4
# Historical CircleCI badge copied verbatim from nestjs/typescript-starter README.
# Verified against https://github.com/nestjs/typescript-starter/blob/master/README.md
f8dcfb71c1767696a9aa6741ddaf6575416c901e:README.md:generic-api-key:5
+1
View File
@@ -0,0 +1 @@
save-exact=true
+1 -1
View File
@@ -1 +1 @@
22 24.21.0
+106
View File
@@ -0,0 +1,106 @@
{
"git": {
"requireBranch": "release/*",
"requireCleanWorkingDir": true,
"requireUpstream": false,
"requireCommits": true,
"commitMessage": "chore(release): ${version}",
"tagName": "v${version}",
"tag": false,
"push": false
},
"npm": {
"publish": false
},
"github": {
"release": false
},
"gitlab": {
"release": false
},
"hooks": {
"after:git:init": "node scripts/check-release.cjs"
},
"plugins": {
"@release-it/conventional-changelog": {
"infile": "CHANGELOG.md",
"preset": {
"name": "conventionalcommits",
"types": [
{
"type": "chore",
"scope": "release",
"effect": "hidden"
},
{
"type": "feat",
"section": "Features",
"effect": "bump"
},
{
"type": "fix",
"section": "Bug Fixes",
"effect": "bump"
},
{
"type": "perf",
"section": "Performance",
"effect": "bump"
},
{
"type": "docs",
"section": "Documentation",
"effect": "bump"
},
{
"type": "spec",
"section": "Specifications",
"effect": "bump"
},
{
"type": "refactor",
"section": "Refactoring",
"effect": "bump"
},
{
"type": "build",
"section": "Build",
"effect": "bump"
},
{
"type": "ci",
"section": "CI",
"effect": "bump"
},
{
"type": "test",
"section": "Tests",
"effect": "bump"
},
{
"type": "style",
"section": "Styles",
"effect": "bump"
},
{
"type": "chore",
"section": "Maintenance",
"effect": "bump"
},
{
"type": "revert",
"section": "Reverts",
"effect": "bump"
}
]
},
"context": {
"host": "https://git.personal.ksv741.ru",
"owner": "WealthLedger",
"repository": "backend",
"linkReferences": true,
"commit": "commit"
}
}
}
}
+10 -3
View File
@@ -12,12 +12,19 @@ zero-warning ESLint, application/test TypeScript, Jest и build.
`lint:fix`/`format` пишут файлы и запускаются отдельно от доказательства проверки. `lint:fix`/`format` пишут файлы и запускаются отдельно от доказательства проверки.
`test:cov` — диагностика без общего threshold; scripts принадлежат `package.json`. `test:cov` — диагностика без общего threshold; scripts принадлежат `package.json`.
HTTP contract changes дополнительно проходят общий HTTP contract changes сохраняют [направление контракта](../docs/architecture/http-api.md#источник-контракта)
[contract workflow](../docs/development/runtime-openapi-verification.md). и проходят contract check. Общий e2e запускается на отдельных временных DB;
Общий e2e имеет известный environment blocker; применимость и маршрут проверки применимость и маршрут проверки
описаны в [backend testing](.ai/conventions/backend-testing.md). описаны в [backend testing](.ai/conventions/backend-testing.md).
Generated Prisma client не редактируется вручную. Credentials, авторизация Generated Prisma client не редактируется вручную. Credentials, авторизация
доступа к счетам и внешние интеграции остаются server-side. доступа к счетам и внешние интеграции остаются server-side.
ESLint/TypeScript не доказывают правильность domain boundaries, security или ESLint/TypeScript не доказывают правильность domain boundaries, security или
денежных вычислений: для них нужны semantic review и risk-based tests. денежных вычислений: для них нужны semantic review и risk-based tests.
## Ветки и релизы
Работа — в отдельной ветке от `develop`, затем PR в `develop`; в `main` напрямую
не пушить. Release/hotfix и синхронизация веток — по [root README](../README.md#релизы-и-ветки).
Commits и заголовки PR — Conventional Commits; для спецификаций допустим `spec:`.
Перед подготовкой релиза прочитай root README; версии вручную не повышать.
+83
View File
@@ -0,0 +1,83 @@
# Changelog
## 0.1.0 (2026-09-14)
### Features
* add authorization ([4cbfc26](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4cbfc2621cfc54df3d09786905130c28a2323a04))
* add bond details, yields and credit ratings ([235c179](https://git.personal.ksv741.ru/WealthLedger/backend/commit/235c179ff813dac3312b849928d11734bccba823))
* add brocker account list ([c0a4a93](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c0a4a9388074bdce20138dd5649805ed24a9e9fa))
* add logging ([25d7c57](https://git.personal.ksv741.ru/WealthLedger/backend/commit/25d7c5780b60343fecb4f1ae9a30392776eb5aec))
* add manual bank card ledger ([e6f58ef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e6f58ef0eef3fbae079270a0d429afd17e4cd3ac))
* add operations list ([326fb67](https://git.personal.ksv741.ru/WealthLedger/backend/commit/326fb67def9b3878d5447ef838ae96d50bf493d6))
* add portfolio ([dfca11c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/dfca11c81f7e6dadce3ca6f961d265c5e3f8ff8e))
* add stock details and ticker resolution ([f24fd98](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f24fd98fbcbbf952b11bb61f607ae0aec4456839))
* add tbank tokens ([31cbba8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31cbba8654f01adf11c52e12c2b286200d6cad7d))
* add агегку events ([34c2b79](https://git.personal.ksv741.ru/WealthLedger/backend/commit/34c2b79e276c9575996604f3f862bc866cfe44e7))
* aggregate portfolio data across accounts ([eaf2f67](https://git.personal.ksv741.ru/WealthLedger/backend/commit/eaf2f67b34b2f8b45a9f5d53c544b91304b333d3))
* **cards:** support inclusive date range queries ([3cf883e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/3cf883efbc6fceddc889c5aadc3c8ffb410c6de5))
* enrich ledger events with logo metadata ([84fabd2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/84fabd264fa539dfefb1172e27a038870a30d845))
* establish backend foundation ([ab21426](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ab21426478233b1cdfc7571c796f76d347874501))
* **events:** support date range filtering ([a79f59a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a79f59a468dd113147b6f1a04ed0efd9828046c6))
* expose instrument brand logo name in portfolio positions ([5375e55](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5375e5598a284238b995661dd2c0e9ce5901d190))
* expose instrument isin in portfolio positions ([5aa9bd3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5aa9bd395a8e49e423764eb7c4a88afe56332ad4))
* expose logos and ISINs for upcoming events ([33f703d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33f703d9a375e3d88e492fbb338e609d6c499d7f))
* **income:** add account income analytics API ([7ba4da0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7ba4da0d1b49afcb0db3d1756254e5d5e04f0c35))
* initial commit ([f8dcfb7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f8dcfb71c1767696a9aa6741ddaf6575416c901e))
* **portfolio:** expose asset allocation by currency ([05747dc](https://git.personal.ksv741.ru/WealthLedger/backend/commit/05747dc3757cdfd4c3d840ad0500861063a76b09))
* **portfolio:** expose position tickers ([924f60f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/924f60f65357781df967bfbaf1538f3400ad620a))
* **tbank:** add brokerage account summaries ([e489d45](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e489d45f889656183e88ad68d964f721b715cbdb))
* use money for upcoming event projections ([f1df104](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f1df1044623862770ecb40fd735e1aea08dc2a68))
### Bug Fixes
* align auth success status contract ([9bd3400](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9bd3400264de40a90b45d0cd997c2c752548c0bb))
* allow multi-year income analytics periods ([9fcc0ec](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9fcc0ec01068372e6756f60bb49d83643e07466d))
* harden auth and cache concurrency ([c77b3cf](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c77b3cf782f24ad3007add43f768d721e8f02386))
* harden backend runtime artifact hygiene ([6052565](https://git.personal.ksv741.ru/WealthLedger/backend/commit/60525656b8ecdc4b63956f0d7b77e4286961edc4))
* **income:** expose authoritative entry membership ([bdc3847](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bdc384766649bb48513055bb809f0d1b45b91f54))
* isolate dev output from production builds ([320e8a0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/320e8a04b0ad8e0d57a89e07768e600bc16bab36))
* little fixes ([e77ebc6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e77ebc6e8f78f96a4ee57c2684769ea4ec923017))
* make backend local verification safe ([71d57d6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/71d57d60bcf2186d4f8f02169d1bea4ea4d672e3))
* make SQLite migrations and e2e reproducible ([ce24a84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ce24a84b7fb48091d2b91836eb7b702a2f811c96))
* recover portfolio instrument metadata on backend ([8ec172f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8ec172f48eefa2237dc0c16919fbb2e726b39a65))
* **tbank:** isolate caches by connection generation ([19fe7e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/19fe7e75d59792e9c6d6b702ab192fa2d5ccc936))
* **tbank:** type connectedAt as date-time ([55b100d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/55b100d5f6bed126633432654a3353f862e6f428))
* type T-Bank client config ([18d7cfc](https://git.personal.ksv741.ru/WealthLedger/backend/commit/18d7cfcf9fecb89e99f7ba1f5d43602807ae98b2))
### Documentation
* **agents:** link audit remediation registry ([595fc04](https://git.personal.ksv741.ru/WealthLedger/backend/commit/595fc0458b3af37ef5eb0c647c401f39f34fe5f0))
* **ai:** add scoped backend conventions ([a83e26e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a83e26e5ef5d7cae3179a182fb5359a440538177))
* define canonical OpenSpec store ([fee4f8c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fee4f8c8e6e3aa8fc4c41522dc6697825316f8f6))
* refresh backend onboarding ([fec542a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fec542a275ed6c8273bb253ca4b58a497f74cd99))
* remove useless ([da17893](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da178933509512c05c501773947f35397783f249))
* update routes to system documentation ([b1906c8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b1906c8e3ca242a0e30bd63519504c0c070f0354))
* update system documentation references ([d4fc28c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d4fc28cbbf0bb76a8c11a68f09bbe41151067d88))
### Refactoring
* centralize domain values and enforce numeric lint ([8637ad2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8637ad2f346c7e03e709b3a52d1d67995415a707))
* enforce strict backend typing ([346a6f0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/346a6f0057178a2fe157c45ecf93740140c10440))
* organize backend by business responsibility ([8ba0053](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8ba005346dbbd515e2c1d71bf99fdacf4b73947f))
* refactor future event list ([e005541](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e0055414d6947e46fb58db1abe96f4ec3ea74f8c))
* **tbank:** centralize access rules ([130b303](https://git.personal.ksv741.ru/WealthLedger/backend/commit/130b303ec123f144d3833e5c7afa88d40f16f081))
### CI
* verify backend and secure dependency updates [#1](https://git.personal.ksv741.ru/WealthLedger/backend/issues/1) [#5](https://git.personal.ksv741.ru/WealthLedger/backend/issues/5) [#6](https://git.personal.ksv741.ru/WealthLedger/backend/issues/6) ([4c8ba2f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4c8ba2fac903c883849359aa354f3ffa749ca085))
### Tests
* cover isolated SQLite invariants and durability ([7fde3ec](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fde3ece6ec7143723140357602861cd2e20b3a5))
### Maintenance
* codify backend local verification ([2772d53](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2772d53647132e6119e411c6d18234db4301e541))
* configure releases and develop workflow ([f01b82d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f01b82d36686ac27495263071bc82b34ae2f9b01))
* **deps:** pin node.js to v24.21.0 ([97e864b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/97e864bc06a58737cae6669d42255f278d93e887))
* **deps:** update and pin backend dependencies ([2bc8745](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2bc87454954d0bd3eeab11634ed16eed869dbaab))
* **deps:** upgrade backend to Nest 12 and TypeScript 6 ([121af70](https://git.personal.ksv741.ru/WealthLedger/backend/commit/121af701b722467353a33bf05e0b27a40ed5736e))
* save pending changes ([8c074d8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8c074d85de3bc55d0ba750b86bc7b026649fa6bf))
* simplify backend verification ([fd4572a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fd4572a64563c588029af38cd7b4cddc3e014cdf))
* upgrade Node to 24 LTS ([197ffe2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/197ffe21368b337f27d27c22e473570258ebdc3b))
+113 -10
View File
@@ -5,13 +5,14 @@ NestJS API WealthLedger. Backend владеет auth, SQLite-данными и s
## Быстрый старт ## Быстрый старт
Требуется Node 22 (`.nvmrc`). Создайте `.env` из примера, замените placeholder Требуется Node 24 (`.nvmrc`). Создайте `.env` из примера, замените placeholder
секреты и установите зависимости строго из lockfile: секреты и установите зависимости строго из lockfile:
```bash ```bash
cp .env.example .env cp .env.example .env
nvm use --silent nvm use --silent
npm ci npm ci
npx prisma migrate deploy
npm run start:dev npm run start:dev
``` ```
@@ -31,9 +32,49 @@ npm run verify
Он запускает zero-warning lint, Он запускает zero-warning lint,
application/test typechecks, Jest и build. `npm run lint:fix` — отдельная application/test typechecks, Jest и build. `npm run lint:fix` — отдельная
записывающая команда, `npm run test:cov` — диагностика без общего числового записывающая команда, `npm run test:cov` — диагностика без общего числового
threshold. `npm run test:e2e` пока не является passing gate: Prisma schema threshold. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
engine останавливает migration до запуска Jest. Точный статус и trigger и runtime OpenAPI с соседним frontend checkout. Для полной backend-проверки
пересмотра находятся в [remediation registry](../docs/development/audit-remediation.md). выполните обе команды.
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
использует явные `rootDir` и типы Node/Jest в `tsconfig.json`. Переход на
TypeScript 7 требует поддержки со стороны `ts-jest` и `typescript-eslint`.
### CI и обновления зависимостей
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
`npm run verify` и `npm run test:e2e` на push и PR. Frontend snapshot для
разработки берётся из `develop`; для `main`, PR в `main` и `release/*` — из
полного SHA в `.ci/refs.json`. Лог сохраняет оба SHA. Изменение
контракта согласуется с этой frontend revision. Успешный unit gate не заменяет
SQLite/e2e; ошибка любой suite завершает job с FAIL.
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются.
Renovate группирует minor/patch updates, а major требует согласования в
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
Renovate использует `rangeStrategy: pin`. Обновляйте `package.json` вместе с
`package-lock.json`. Overrides в
`package.json` закрывают advisories транзитивных `multer`, `deepmerge-ts` и
`mysql2`; удаляйте их, когда родительские пакеты примут исправленные
версии. Совместимость Prisma override проверяется генерацией и SQLite migrations.
Required checks настраиваются отдельно в Gitea: наличие workflow само по себе
не блокирует merge.
Общий `tsconfig.json` включает `strict: true` и `noFallthroughCasesInSwitch`
для приложения, тестов и сборок. ESLint запрещает non-null assertions
(`value!`) и definite-assignment assertions (`field!: Type`).
Поля DTO, заполняемые вне конструктора, описываются через `declare`:
входные значения заполняет и проверяет Nest, ответные DTO описывают форму
возвращаемых объектов. `declare` не создаёт значение и не заменяет
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
после проверки значения.
## Документация ## Документация
@@ -42,13 +83,10 @@ engine останавливает migration до запуска Jest. Точны
- [HTTP API](.ai/conventions/backend-http-api.md), [data](.ai/conventions/backend-data.md) и [testing](.ai/conventions/backend-testing.md) - [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) - [Security/auth](.ai/conventions/security-auth.md) и [integrations](.ai/conventions/backend-integrations.md)
- [Backend workflow](../docs/development/backend-workflow.md) - [Карта проектных фактов](../docs/README.md)
- [Engineering governance и risk-based testing](../docs/development/engineering-governance.md) - [API contract и runtime OpenAPI](../docs/architecture/http-api.md#источник-контракта)
- [Карта проектных фактов](../docs/AI_INDEX.md) - [Обзор поставленной системы](../docs/overview.md)
- [API contract и runtime OpenAPI](../docs/development/runtime-openapi-verification.md)
- [Обзор поставленной системы](../docs/architecture/system-overview.md)
- [Архитектурные решения](../docs/adr/README.md) - [Архитектурные решения](../docs/adr/README.md)
- [Remediation registry](../docs/development/audit-remediation.md)
Из backend runtime OpenAPI можно read-only сверить с frontend snapshot: Из backend runtime OpenAPI можно read-only сверить с frontend snapshot:
@@ -59,3 +97,68 @@ npm run test:openapi-contract
Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит
свой verify; при изменении wire contract дополнительно выполняется contract check. свой verify; при изменении wire contract дополнительно выполняется contract check.
Обязательного OpenSpec/SDD lifecycle нет. Обязательного OpenSpec/SDD lifecycle нет.
## Реальная SQLite и сохранность данных
После `nvm use --silent` запускайте `npm run test:sqlite` при изменении auth,
карточных операций или DB boundary, `npm run test:e2e` — для всех HTTP suites.
Обе команды используют один runner: отдельный каталог `wealthledger-e2e-*`
в системном temp на каждую suite, один `DATABASE_URL` для migrations и Jest.
Внешний `DATABASE_URL` заменяется временным; `.env` и пользовательские данные
не изменяются. Прямой Jest без runner отклоняется до обращения к базе.
При ошибке migration/Jest или SIGINT/SIGTERM runner останавливает дочерние
процессы, удаляет свой каталог и возвращает ненулевой exit code.
Нужен доступ к локальным HTTP-портам; sandbox может его запрещать.
`prisma.config.ts` удаляет унаследованный `RUST_LOG` только в процессе Prisma CLI:
на Prisma 7.9.1 уровни `warn`/`error` вызывают пустой `Schema engine error`
при fresh migration, без переменной миграция проходит. Постоянный
`RUST_LOG=info` и обновление зависимостей не нужны. Logging приложения
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
три последовательных и два параллельных полных e2e, проверяет уникальность DB,
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
Логи остаются в каталоге из вывода. Для одной suite:
`node test/sqlite.cjs app.e2e-spec.ts`.
| Риск | Проверка реальной DB |
| --- | --- |
| Чужой пользователь | `bank-cards.e2e-spec.ts`: owner каждой карты, категории, покупки и стороны перевода |
| Повтор и конкурентный update | Те же tests: fingerprint, tombstone, версии, один refund в пределах покупки, archive/write |
| Конкурентный refresh | `app.e2e-spec.ts`: оба запроса читают прежний hash до CAS; один 200, один 401, cookie победителя работает после replay |
| Rollback и деньги | Карточные tests: откат source lock, атомарный перевод, точные суммы выше 2^53 и границы signed64 |
| Migration, restart, backup/restore | `test:durability`: прежнее приложение, новая schema, история, откат приложения и восстановление backup |
Prisma в этих suites не заменяется mocks. В refresh тесте барьер задерживает
настоящую подпись JWT, чтобы оба запроса дошли до реальной конкурентной записи.
Unit tests с mocks отдельно проверяют orchestration и не доказывают DB concurrency.
При изменении schema/migrations или сохранения данных дополнительно выполните
`npm run test:durability -- /absolute/path/to/baseline`. Он не входит в lint/verify.
Baseline — отдельная сборка до проверяемых миграций с неизменённым префиксом
migration history. Для текущей карточной схемы подходит revision
`8ec172f48eefa2237dc0c16919fbb2e726b39a65` (до добавления карт):
```bash
# Из backend; экспорт не содержит .env или локальную базу.
baseline_dir=$(mktemp -d /tmp/wealthledger-baseline-XXXXXX)
git archive 8ec172f48eefa2237dc0c16919fbb2e726b39a65 | tar -x -C "$baseline_dir"
(
cd "$baseline_dir"
nvm use --silent
npm ci
npm run build
)
nvm use --silent
npm run build
npm run test:durability -- "$baseline_dir"
```
Не подменяйте baseline текущей сборкой: runner отвергает тот же путь,
отсутствующую сборку и неподходящую migration history. Для следующей schema
выберите совместимую предыдущую revision и проверьте её API-сценарии runner.
Durability оставляет временные DB, логи и `result.json` по пути из вывода для
диагностики; он не откатывает schema пользовательской базы.
Версии и ветки: [процесс релиза](../README.md#релизы-и-ветки).
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] },
};
+52 -1
View File
@@ -26,10 +26,61 @@ export default tseslint.config(
}, },
{ {
rules: { rules: {
'no-magic-numbers': 'off',
'@typescript-eslint/no-magic-numbers': [
'error',
{
ignore: [
-60,
-24,
-10,
-2,
-1,
0,
1,
2,
10,
24,
60,
'-60n',
'-24n',
'-10n',
'-2n',
'-1n',
'0n',
'1n',
'2n',
'10n',
'24n',
'60n',
],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
ignoreEnums: true,
ignoreNumericLiteralTypes: true,
ignoreReadonlyClassProperties: true,
ignoreTypeIndexes: true,
enforceConst: true,
},
],
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
'no-restricted-syntax': [
'error',
{
selector:
'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
message:
'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
},
],
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }], 'prettier/prettier': ['error', { endOfLine: 'auto' }],
}, },
}, },
{
files: ['**/*.spec.ts', 'test/**/*.ts'],
rules: { '@typescript-eslint/no-magic-numbers': 'off' },
},
); );
+1 -1
View File
@@ -5,7 +5,7 @@
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true, "deleteOutDir": true,
"assets": [ "assets": [
{ "include": "tbank/proto/contracts/**/*.proto", "watchAssets": true } { "include": "integrations/tbank/proto/contracts/**/*.proto", "watchAssets": true }
] ]
} }
} }
+4731 -2041
View File
File diff suppressed because it is too large Load Diff
+88 -61
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.0.1", "version": "0.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": "22.x" "node": "v24.21.0"
}, },
"scripts": { "scripts": {
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
@@ -20,71 +20,88 @@
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:app": "tsc --project tsconfig.typecheck.app.json",
"typecheck:test": "tsc --project tsconfig.typecheck.test.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json",
"test": "jest", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:watch": "jest --watch", "test:watch": "npm test -- --watch",
"test:cov": "jest --coverage", "test:cov": "npm test -- --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "DATABASE_URL=file:/tmp/wealth-ledger-e2e.db prisma migrate deploy && NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json", "test:e2e": "node test/sqlite.cjs --all",
"test:openapi-contract": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json --runInBand openapi-contract.e2e-spec.ts", "test:sqlite": "node test/sqlite.cjs",
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build" "test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "node test/sqlite.cjs openapi-contract.e2e-spec.ts",
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build",
"release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci",
"commitlint": "commitlint"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.14.4", "@grpc/grpc-js": "1.14.4",
"@grpc/proto-loader": "^0.8.1", "@grpc/proto-loader": "0.8.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "12.0.1",
"@nestjs/config": "^4.0.4", "@nestjs/config": "12.0.0",
"@nestjs/core": "^11.0.1", "@nestjs/core": "12.0.1",
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "12.0.1",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "12.0.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "12.0.1",
"@nestjs/swagger": "11.4.5", "@nestjs/swagger": "12.0.1",
"@prisma/adapter-better-sqlite3": "^7.9.1", "@prisma/adapter-better-sqlite3": "7.10.0",
"@prisma/client": "^7.9.1", "@prisma/client": "7.10.0",
"bcrypt": "^6.0.0", "bcrypt": "6.0.0",
"better-sqlite3": "^12.6.0", "better-sqlite3": "13.0.3",
"class-transformer": "^0.5.1", "cheerio": "1.2.0",
"class-validator": "^0.15.1", "class-transformer": "0.5.1",
"cookie-parser": "^1.4.7", "class-validator": "0.15.1",
"lru-cache": "^11.2.4", "cookie-parser": "1.4.7",
"nestjs-pino": "^4.6.1", "lru-cache": "11.5.2",
"passport": "^0.7.0", "nestjs-pino": "5.1.0",
"passport-jwt": "^4.0.1", "passport": "0.7.0",
"pino-http": "^11.0.0", "passport-jwt": "4.0.1",
"reflect-metadata": "^0.2.2", "pino-http": "11.0.0",
"rxjs": "^7.8.1" "reflect-metadata": "0.2.2",
"rxjs": "7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@commitlint/cli": "21.2.2",
"@eslint/js": "^9.18.0", "@commitlint/config-conventional": "21.2.2",
"@nestjs/cli": "^11.0.0", "@eslint/eslintrc": "3.3.7",
"@nestjs/schematics": "^11.0.0", "@eslint/js": "10.0.1",
"@nestjs/testing": "^11.0.1", "@nestjs/cli": "12.0.0",
"@types/bcrypt": "^6.0.0", "@nestjs/schematics": "12.0.1",
"@types/cookie-parser": "^1.4.10", "@nestjs/testing": "12.0.1",
"@types/express": "^5.0.0", "@release-it/conventional-changelog": "12.0.0",
"@types/jest": "^30.0.0", "@types/bcrypt": "6.0.0",
"@types/node": "^22.10.7", "@types/cookie-parser": "1.4.10",
"@types/passport-jwt": "^4.0.1", "@types/express": "5.0.6",
"@types/supertest": "^6.0.2", "@types/jest": "30.0.0",
"eslint": "^9.18.0", "@types/node": "24.13.4",
"eslint-config-prettier": "^10.0.1", "@types/passport-jwt": "4.0.1",
"eslint-plugin-prettier": "^5.2.2", "@types/supertest": "7.2.1",
"globals": "^16.0.0", "conventional-changelog-conventionalcommits": "10.4.0",
"jest": "^30.0.0", "eslint": "10.10.0",
"prettier": "^3.4.2", "eslint-config-prettier": "10.1.8",
"prisma": "^7.9.1", "eslint-plugin-prettier": "5.5.6",
"source-map-support": "^0.5.21", "globals": "17.12.0",
"supertest": "^7.0.0", "jest": "30.5.1",
"ts-jest": "^29.2.5", "prettier": "3.9.6",
"ts-loader": "^9.5.2", "prisma": "7.10.0",
"ts-node": "^10.9.2", "release-it": "21.0.2",
"tsconfig-paths": "^4.2.0", "source-map-support": "0.5.21",
"typescript": "^5.7.3", "supertest": "7.2.2",
"typescript-eslint": "^8.20.0" "ts-jest": "29.4.12",
"ts-loader": "9.6.2",
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "6.0.3",
"typescript-eslint": "8.70.0"
}, },
"overrides": { "overrides": {
"@nestjs/swagger@11.4.5": { "@nestjs/platform-express": {
"js-yaml": "5.2.3" "multer": "2.3.0"
},
"@prisma/config@7.10.0": {
"deepmerge-ts": "8.0.2"
},
"prisma": {
"mysql2": "3.24.4"
} }
}, },
"jest": { "jest": {
@@ -103,5 +120,15 @@
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
},
"allowScripts": {
"@prisma/engines@7.10.0": true,
"prisma@7.10.0": true,
"bcrypt@6.0.0": true,
"better-sqlite3@12.11.1": true,
"fsevents@2.3.3": true,
"protobufjs@7.6.5": true,
"unrs-resolver@1.12.2": true,
"better-sqlite3@13.0.3": true
} }
} }
+4
View File
@@ -1,6 +1,10 @@
import 'dotenv/config'; import 'dotenv/config';
import { defineConfig } from 'prisma/config'; import { defineConfig } from 'prisma/config';
// Prisma 7.9.1 fresh SQLite migration fails with inherited RUST_LOG=warn/error.
// Use the engine default; application logging is configured separately.
delete process.env.RUST_LOG;
export default defineConfig({ export default defineConfig({
schema: 'prisma/schema.prisma', schema: 'prisma/schema.prisma',
datasource: { datasource: {
@@ -0,0 +1,69 @@
-- CreateTable
CREATE TABLE "BankCard" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"bankName" TEXT,
"lastFour" TEXT,
"openingBalanceKopecks" BIGINT NOT NULL,
"openingDate" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"archivedAt" DATETIME,
"createFingerprint" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "BankCard_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ExpenseCategory" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameKey" TEXT NOT NULL,
"seedKey" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
CONSTRAINT "ExpenseCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "BankCardOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"cardId" TEXT NOT NULL,
"targetCardId" TEXT,
"categoryId" TEXT,
"originalExpenseId" TEXT,
"amountKopecks" BIGINT NOT NULL,
"date" TEXT NOT NULL,
"description" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"deletedAt" DATETIME,
"createFingerprint" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "BankCardOperation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_targetCardId_fkey" FOREIGN KEY ("targetCardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ExpenseCategory" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_originalExpenseId_fkey" FOREIGN KEY ("originalExpenseId") REFERENCES "BankCardOperation" ("id") ON DELETE RESTRICT ON UPDATE NO ACTION
);
-- CreateIndex
CREATE INDEX "BankCard_userId_idx" ON "BankCard"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "ExpenseCategory_userId_nameKey_key" ON "ExpenseCategory"("userId", "nameKey");
-- CreateIndex
CREATE UNIQUE INDEX "ExpenseCategory_userId_seedKey_key" ON "ExpenseCategory"("userId", "seedKey");
-- CreateIndex
CREATE INDEX "BankCardOperation_userId_cardId_date_idx" ON "BankCardOperation"("userId", "cardId", "date");
-- CreateIndex
CREATE INDEX "BankCardOperation_userId_targetCardId_date_idx" ON "BankCardOperation"("userId", "targetCardId", "date");
-- CreateIndex
CREATE INDEX "BankCardOperation_originalExpenseId_idx" ON "BankCardOperation"("originalExpenseId");
@@ -0,0 +1 @@
ALTER TABLE "BankCard" ADD COLUMN "paymentSystem" TEXT CHECK ("paymentSystem" IN ('mir', 'visa', 'mastercard'));
+61
View File
@@ -12,6 +12,9 @@ model User {
id String @id id String @id
email String @unique email String @unique
passwordHash String passwordHash String
bankCards BankCard[]
expenseCategories ExpenseCategory[]
bankCardOperations BankCardOperation[]
sessions Session[] sessions Session[]
tbankConnection TbankConnection? tbankConnection TbankConnection?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -37,3 +40,61 @@ model Session {
@@index([userId]) @@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])
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommitTypeAll(chore)"
],
"timezone": "Europe/Moscow",
"labels": [
"dependencies"
],
"dependencyDashboard": true,
"rangeStrategy": "pin",
"packageRules": [
{
"matchUpdateTypes": [
"minor",
"patch"
],
"groupName": "non-major dependencies"
},
{
"matchUpdateTypes": [
"major"
],
"dependencyDashboardApproval": true
}
],
"baseBranchPatterns": [
"develop"
]
}
+10
View File
@@ -0,0 +1,10 @@
const assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process");
const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release");
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname")
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? "0.0.0",
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.");
+17 -3
View File
@@ -1,12 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { AuthModule } from './auth/auth.module'; import { randomUUID } from 'node:crypto';
import { LoggingModule } from './common/logging/logging.module'; import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation'; import { Environment, validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
import { IncomeModule } from './modules/investments/income/income.module';
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
import { OperationsModule } from './modules/investments/operations/operations.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { TbankConnectionModule } from './tbank/tbank-connection.module';
@Module({ @Module({
imports: [ imports: [
@@ -49,6 +56,13 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
LoggingModule, LoggingModule,
PrismaModule, PrismaModule,
AuthModule, AuthModule,
BankCardsModule,
BrokerageAccountsModule,
PortfolioModule,
OperationsModule,
IncomeModule,
UpcomingEventsModule,
InstrumentsModule,
TbankConnectionModule, TbankConnectionModule,
], ],
}) })
+11 -10
View File
@@ -1,6 +1,3 @@
const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number };
export type Decimal = { value: bigint; scale: number }; export type Decimal = { value: bigint; scale: number };
export function decimalFromString(value: string): Decimal { export function decimalFromString(value: string): Decimal {
@@ -14,13 +11,6 @@ export function decimalFromString(value: string): Decimal {
}; };
} }
export function decimal(value: ProtoDecimal): Decimal {
return {
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
scale: 9,
};
}
export function add(left: Decimal, right: Decimal): Decimal { export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale); const scale = Math.max(left.scale, right.scale);
return { return {
@@ -59,3 +49,14 @@ export function formatDecimal({ value, scale }: Decimal): string {
const fraction = digits.slice(-scale).replace(/0+$/, ''); const fraction = digits.slice(-scale).replace(/0+$/, '');
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; 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 };
}
@@ -1,4 +1,4 @@
import { applyDecorators } from '@nestjs/common'; import { HttpStatus, applyDecorators } from '@nestjs/common';
import { import {
ApiExtraModels, ApiExtraModels,
ApiProperty, ApiProperty,
@@ -8,10 +8,10 @@ import {
export class ProblemDetailsDto { export class ProblemDetailsDto {
@ApiProperty({ example: 401 }) @ApiProperty({ example: 401 })
status: number; declare status: number;
@ApiProperty({ example: 'UNAUTHORIZED' }) @ApiProperty({ example: 'UNAUTHORIZED' })
code: string; declare code: string;
@ApiProperty({ @ApiProperty({
required: false, required: false,
@@ -35,10 +35,13 @@ function problemResponse(status: number, description: string) {
export function ApiProblemResponses(): MethodDecorator & ClassDecorator { export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
return applyDecorators( return applyDecorators(
ApiExtraModels(ProblemDetailsDto), ApiExtraModels(ProblemDetailsDto),
problemResponse(400, 'Invalid request'), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
problemResponse(401, 'Authentication required'), problemResponse(HttpStatus.UNAUTHORIZED, 'Authentication required'),
problemResponse(404, 'Resource not found'), problemResponse(HttpStatus.NOT_FOUND, 'Resource not found'),
problemResponse(503, 'Upstream service unavailable'), problemResponse(
problemResponse(500, 'Internal server error'), HttpStatus.SERVICE_UNAVAILABLE,
'Upstream service unavailable',
),
problemResponse(HttpStatus.INTERNAL_SERVER_ERROR, 'Internal server error'),
); );
} }
+61
View File
@@ -0,0 +1,61 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import {
CreateBankCardDto,
UpdateBankCardDto,
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: CredentialsDto };
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
pipe.transform({ email: 'user@example.com' }, metadata),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
pipe.transform(
{ email: ' User@Example.com ', password: 'long-enough-password' },
metadata,
),
).resolves.toEqual({
email: 'user@example.com',
password: 'long-enough-password',
});
});
it('requires and validates nested money in a new card', async () => {
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
const card = {
id: '00000000-0000-4000-8000-000000000001',
name: 'Everyday',
openingDate: '2026-09-13',
};
for (const openingBalance of [undefined, null, {}, { currency: 'RUB' }]) {
await expect(
pipe.transform({ ...card, openingBalance }, metadata),
).rejects.toBeInstanceOf(BadRequestException);
}
const valid = {
...card,
openingBalance: { currency: 'RUB', value: '100.00' },
};
await expect(pipe.transform(valid, metadata)).resolves.toEqual(valid);
});
it('keeps update fields optional while requiring version and rejecting null', async () => {
const metadata = { type: 'body' as const, metatype: UpdateBankCardDto };
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
pipe.transform({ version: 1, name: null }, metadata),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
pipe.transform({ version: 1 }, metadata),
).resolves.toMatchObject({ version: 1 });
});
});
@@ -1,3 +1,4 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, NestMiddleware } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -46,11 +47,11 @@ export class RequestLoggingMiddleware implements NestMiddleware {
: {}), : {}),
}; };
const level = const level =
response.statusCode >= 500 response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error' ? 'error'
: response.statusCode === 429 || : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === 401 || response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === 403 response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn' ? 'warn'
: 'info'; : 'info';
this.logger.event(level, 'http.request.completed', context); this.logger.event(level, 'http.request.completed', context);
+10
View File
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types';
export class MoneyDto implements Money {
@ApiProperty()
declare currency: string;
@ApiProperty()
declare value: string;
}
+1
View File
@@ -0,0 +1 @@
export type Money = { currency: string; value: string };
+2
View File
@@ -0,0 +1,2 @@
export const MILLISECONDS_PER_SECOND = 1_000;
export const MILLISECONDS_PER_DAY = 86_400_000;
+19 -5
View File
@@ -1,3 +1,8 @@
const DEFAULT_PORT = 3000;
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
const MAX_PORT = 65_535;
const MIN_JWT_SECRET_LENGTH = 32;
const TOKEN_KEY_BYTES = 32;
export interface Environment { export interface Environment {
DATABASE_URL: string; DATABASE_URL: string;
FRONTEND_ORIGIN: string; FRONTEND_ORIGIN: string;
@@ -13,12 +18,14 @@ export interface Environment {
export function validateEnv(config: Record<string, unknown>): Environment { export function validateEnv(config: Record<string, unknown>): Environment {
const databaseUrl = config['DATABASE_URL']; const databaseUrl = config['DATABASE_URL'];
const frontendOrigin = config['FRONTEND_ORIGIN']; const frontendOrigin = config['FRONTEND_ORIGIN'];
const port = config['PORT'] ?? 3000; const port = config['PORT'] ?? DEFAULT_PORT;
const jwtSecret = config['JWT_SECRET']; const jwtSecret = config['JWT_SECRET'];
const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY']; const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY'];
const tbankEndpoint = const tbankEndpoint =
config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443'; config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443';
const tbankRateLimit = config['TBANK_RATE_LIMIT_PER_SECOND'] ?? 5; const tbankRateLimit =
config['TBANK_RATE_LIMIT_PER_SECOND'] ??
DEFAULT_TBANK_RATE_LIMIT_PER_SECOND;
const tbankCaCertPath = config['TBANK_CA_CERT_PATH']; const tbankCaCertPath = config['TBANK_CA_CERT_PATH'];
const logLevel = config['LOG_LEVEL'] ?? 'info'; const logLevel = config['LOG_LEVEL'] ?? 'info';
@@ -47,18 +54,25 @@ export function validateEnv(config: Record<string, unknown>): Environment {
} }
const parsedPort = Number(port); const parsedPort = Number(port);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { if (
!Number.isInteger(parsedPort) ||
parsedPort < 1 ||
parsedPort > MAX_PORT
) {
throw new Error('PORT must be a valid port number'); throw new Error('PORT must be a valid port number');
} }
if (typeof jwtSecret !== 'string' || jwtSecret.length < 32) { if (
typeof jwtSecret !== 'string' ||
jwtSecret.length < MIN_JWT_SECRET_LENGTH
) {
throw new Error('JWT_SECRET must be at least 32 characters long'); throw new Error('JWT_SECRET must be at least 32 characters long');
} }
if ( if (
typeof tokenEncryptionKey !== 'string' || typeof tokenEncryptionKey !== 'string' ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) || !/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) ||
Buffer.from(tokenEncryptionKey, 'base64').length !== 32 Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
) { ) {
throw new Error( throw new Error(
'TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key', 'TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key',
+2
View File
@@ -0,0 +1,2 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { type MoexBondEvent, MoexClient } from './moex.client';
+108
View File
@@ -0,0 +1,108 @@
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,
json: () =>
Promise.resolve({
description: { columns: ['name', 'value'], data: [] },
boards: { columns: ['secid'], data: [] },
}),
} as Response);
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
fetchMock.mockRestore();
});
});
@@ -1,5 +1,8 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
const FALLBACK_PAGE_SIZE = 100;
const baseUrl = 'https://iss.moex.com/iss'; const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000; const timeoutMs = 10_000;
@@ -10,9 +13,63 @@ export type MoexBondEvent =
type IssBlock = { columns?: unknown; data?: unknown }; type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>; type IssResponse = Record<string, IssBlock | undefined>;
export type MoexBondYield = {
value: string;
pricePercent: string;
date: string;
dateType: string | null;
tradeAt: string;
};
@Injectable() @Injectable()
export class MoexClient { 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[]> { async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([ const [coupons, offers, description] = await Promise.all([
@@ -67,8 +124,8 @@ export class MoexClient {
if (!paginated) return rows; if (!paginated) return rows;
const cursorBlock = response[`${block}.cursor`]; const cursorBlock = response[`${block}.cursor`];
if (!cursorBlock) { if (!cursorBlock) {
if (current.length < 100) return rows; if (current.length < FALLBACK_PAGE_SIZE) return rows;
start += 100; start += FALLBACK_PAGE_SIZE;
continue; continue;
} }
const cursor = parseRows(cursorBlock)[0]; const cursor = parseRows(cursorBlock)[0];
@@ -91,7 +148,7 @@ export class MoexClient {
const response = await fetch(url, { const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs), signal: AbortSignal.timeout(timeoutMs),
}); });
if (response.status === 404) return null; if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json(); const body: unknown = await response.json();
if (!body || typeof body !== 'object') if (!body || typeof body !== 'object')
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MoexClient } from './moex.client';
@Module({
providers: [MoexClient],
exports: [MoexClient],
})
export class MoexModule {}
@@ -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);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio';
import { LRUCache } from 'lru-cache';
const REGISTRY_CACHE_TTL_MS = 21_600_000;
const REQUEST_TIMEOUT_MS = 10_000;
const COLUMN_COUNT = 6;
const ISIN_COLUMN = 3;
const DATE_COLUMN = 5;
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: REGISTRY_CACHE_TTL_MS });
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(REQUEST_TIMEOUT_MS),
});
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 !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' ||
headings[ISIN_COLUMN] !== 'ISIN' ||
headings[DATE_COLUMN] !== 'Дата'
)
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 !== COLUMN_COUNT) throw new Error('NCR row changed');
const isin = cells.eq(ISIN_COLUMN).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(DATE_COLUMN).text().trim(),
);
const href = cells.eq(ISIN_COLUMN).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;
}
}
+2
View File
@@ -0,0 +1,2 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { NcrRatingsClient } from './ncr-ratings.client';
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client';
@Module({
providers: [NcrRatingsClient],
exports: [NcrRatingsClient],
})
export class NcrModule {}
@@ -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', () => { describe('decimal', () => {
it('keeps signed nanos exact without floats', () => { it('keeps signed nanos exact without floats', () => {
+12
View File
@@ -0,0 +1,12 @@
import { Decimal } from '../../common/decimal';
const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number };
export function decimal(value: ProtoDecimal): Decimal {
return {
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
scale: 9,
};
}
@@ -0,0 +1,294 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type { LRUCache } from 'lru-cache';
import assert from 'node:assert/strict';
import { InstrumentMetadataService } from './instrument-metadata.service';
import type { TbankInstrument } from './tbank.client';
describe('InstrumentMetadataService', () => {
const tbank = {
getInstrumentByFigi: jest.fn(),
getInstrumentByUid: jest.fn(),
};
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();
tbank.getInstrumentByUid.mockReset();
service = new InstrumentMetadataService(tbank as never, logger as never);
});
it('prefers the instrument UID when metadata is available', async () => {
tbank.getInstrumentByUid.mockResolvedValue({
name: 'Инструмент',
brand: { logoName: 'instrument.png' },
});
await expect(
service.resolve('token', 'uid-1', 'figi-1'),
).resolves.toMatchObject({
brand: { logoName: 'instrument.png' },
});
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('falls back to FIGI when the UID has no matching instrument', async () => {
tbank.getInstrumentByUid.mockResolvedValue(null);
tbank.getInstrumentByFigi.mockResolvedValue({
ticker: 'SBER',
brand: { logoName: 'sber.png' },
});
await expect(
service.resolve('token', 'uid-1', 'figi-1'),
).resolves.toMatchObject({
ticker: 'SBER',
});
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1');
});
it('returns a nullable fallback when instrument metadata is unavailable', async () => {
tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable'));
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
});
it('caches negative lookups and avoids repeated upstream requests', async () => {
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('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) | undefined;
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);
assert.ok(rejectInvalid);
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) | undefined;
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);
assert.ok(finish);
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) | undefined;
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();
assert.ok(fail);
fail(new Error('INVALID_TBANK_TOKEN'));
await Promise.resolve();
await Promise.resolve();
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,118 @@
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';
const metadataForMs = 86_400_000;
const maxCachedInstruments = 500;
const missingInstrument = Symbol('missingInstrument');
@Injectable()
export class InstrumentMetadataService {
private readonly metadata = new LRUCache<
string,
TbankInstrument | typeof missingInstrument
>({
max: maxCachedInstruments,
ttl: metadataForMs,
noDeleteOnStaleGet: true,
});
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
constructor(
private readonly tbank: TbankClient,
private readonly logger: StructuredLogger,
) {}
async resolve(
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) {
byUid = await this.lookup(
token,
`uid:${uid}`,
() => this.tbank.getInstrumentByUid(token, uid),
signal,
);
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
}
const instrumentFigi = figi?.trim();
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;
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();
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,
});
return found || value === null ? value : (previous ?? value);
} catch (error) {
this.logger.event('warn', 'instrument-metadata.failed', {
code:
error instanceof Error && 'response' in error
? ((error as { response?: { code?: string } }).response?.code ??
'UNKNOWN')
: 'UNKNOWN',
});
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw error;
return error instanceof ServiceUnavailableException ? previous : null;
}
}
}
@@ -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; // Цена дилера
}
@@ -5,12 +5,12 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({ jest.mock('../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
import { TbankAccessService } from './tbank-access.service';
import type { TbankAccountContext } from './tbank-access.service'; import type { TbankAccountContext } from './tbank-access.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
describe('TbankAccessService', () => { describe('TbankAccessService', () => {
@@ -4,14 +4,14 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { import {
SupportedTbankAccount,
accessibleTbankAccount, accessibleTbankAccount,
supportedTbankAccounts, supportedTbankAccounts,
} from './tbank-account-policy'; } from './tbank-account.policy';
import { TbankClient, TbankAccount } from './tbank.client';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
import { TbankAccount, TbankClient } from './tbank.client';
export type TbankConnectionContext = { export type TbankConnectionContext = {
token: string; token: string;
@@ -30,7 +30,9 @@ export class TbankAccessService {
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
) {} ) {}
async listSupportedAccounts(userId: string): Promise<BrokerageAccountDto[]> { async listSupportedAccounts(
userId: string,
): Promise<SupportedTbankAccount[]> {
return this.withSupportedAccounts(userId, ({ accounts }) => accounts); return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
} }
@@ -39,7 +41,7 @@ export class TbankAccessService {
handler: (context: { handler: (context: {
token: string; token: string;
generation: string; generation: string;
accounts: BrokerageAccountDto[]; accounts: SupportedTbankAccount[];
}) => Promise<T> | T, }) => Promise<T> | T,
): Promise<T> { ): Promise<T> {
return this.withConnection(userId, async ({ token, generation }) => return this.withConnection(userId, async ({ token, generation }) =>
@@ -1,7 +1,7 @@
import { import {
accessibleTbankAccount, accessibleTbankAccount,
supportedTbankAccounts, supportedTbankAccounts,
} from './tbank-account-policy'; } from './tbank-account.policy';
describe('tbank-account-policy', () => { describe('tbank-account-policy', () => {
it('normalizes supported account types and fallback names', () => { it('normalizes supported account types and fallback names', () => {
@@ -1,4 +1,4 @@
import { BrokerageAccountDto } from './brokerage-account.dto'; import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccount } from './tbank.client'; import { TbankAccount } from './tbank.client';
const accountTypes = { const accountTypes = {
@@ -15,10 +15,16 @@ const accountTypes = {
} as const; } as const;
type AccountType = keyof typeof accountTypes; type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = {
id: string;
name: string;
type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
};
export function supportedTbankAccounts( export function supportedTbankAccounts(
accounts: TbankAccount[], accounts: TbankAccount[],
): BrokerageAccountDto[] { ): SupportedTbankAccount[] {
return accounts.flatMap((account) => { return accounts.flatMap((account) => {
const details = accountTypes[account.type as AccountType]; const details = accountTypes[account.type as AccountType];
if (!details) return []; if (!details) return [];
@@ -46,7 +52,7 @@ export function accessibleTbankAccount(
function openedOn(seconds: string | number | undefined): string | null { function openedOn(seconds: string | number | undefined): string | null {
if (seconds === undefined) return null; if (seconds === undefined) return null;
const date = new Date(Number(seconds) * 1000); const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null; if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
@@ -0,0 +1,69 @@
import type {
TbankDecimal,
TbankInstrument,
TbankMoney,
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,6 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation'; import { Environment } from '../../config/env.validation';
import { TbankCrypto } from './tbank.crypto'; import { TbankCrypto } from './tbank.crypto';
@Injectable() @Injectable()
+23
View File
@@ -0,0 +1,23 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { InstrumentMetadataService } from './instrument-metadata.service';
export {
TbankAccessService,
type TbankConnectionContext,
} from './tbank-access.service';
export {
type TbankBond,
type TbankCandle,
type TbankFundamentals,
type TbankShare,
} from './tbank-market.types';
export { TbankTokenService } from './tbank-token.service';
export {
TbankClient,
type TbankDividend,
type TbankInstrument,
type TbankMoney,
type TbankOperationItem,
type TbankPortfolio,
type TbankPositions,
type TbankTimestamp,
} from './tbank.client';
+306
View File
@@ -0,0 +1,306 @@
import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => {
const request = {
accountId: 'account',
from: { seconds: '1' },
to: { seconds: '2' },
cursor: 'cursor',
limit: 10,
state: 'OPERATION_STATE_EXECUTED' as const,
};
it('passes a cursor operations request to OperationsService', async () => {
const client = Object.create(TbankClient.prototype) as TbankClient;
const call = jest.fn().mockResolvedValue({ items: [] });
(client as unknown as { call: jest.Mock }).call = call;
await expect(
client.getOperationsByCursor('token', request),
).resolves.toEqual({
items: [],
});
expect(call).toHaveBeenCalledWith(
'token',
undefined,
'getOperationsByCursor',
request,
);
});
it('passes a GetDividends request to InstrumentsService', async () => {
const client = Object.create(TbankClient.prototype) as TbankClient;
const call = jest.fn().mockResolvedValue({
dividends: [{ dividendType: 'Regular Cash' }],
});
(client as unknown as { call: jest.Mock }).call = call;
await expect(
client.getDividends('token', 'uid', '2026-08-19', '2026-09-18'),
).resolves.toEqual([{ dividendType: 'Regular Cash' }]);
expect(call).toHaveBeenCalledWith('token', undefined, 'getDividends', {
instrumentId: 'uid',
from: {
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
},
to: {
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)),
},
});
});
it('propagates the normalized upstream error', async () => {
const client = Object.create(TbankClient.prototype) as TbankClient;
const error = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
(client as unknown as { call: jest.Mock }).call = jest
.fn()
.mockRejectedValue(error);
await expect(client.getOperationsByCursor('token', request)).rejects.toBe(
error,
);
});
it('logs success, retry, and final failure with the RPC context', async () => {
jest.useFakeTimers();
const logger = { event: jest.fn(), info: jest.fn() };
const client = Object.create(TbankClient.prototype) as TbankClient;
(client as unknown as { logger: unknown }).logger = logger;
(client as unknown as { queue: unknown }).queue = {
size: 0,
run: (operation: () => unknown) => operation(),
};
const invoke = (...args: [string, unknown, string, object]) =>
(
client as unknown as {
call: (
token: string,
service: unknown,
method: string,
input: object,
) => Promise<unknown>;
}
).call(...args);
const requestRpc = jest
.fn()
.mockResolvedValueOnce({ ok: true })
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockRejectedValueOnce({ code: status.UNAVAILABLE });
(client as unknown as { request: jest.Mock }).request = requestRpc;
await expect(invoke('token', {}, 'getPortfolio', {})).resolves.toEqual({
ok: true,
});
const failed = invoke('token', {}, 'getPortfolio', {});
const rejected = expect(failed).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
await jest.advanceTimersByTimeAsync(1000);
await rejected;
expect(logger.event).toHaveBeenCalledWith(
'debug',
'tbank.rpc.completed',
expect.objectContaining({ operation: 'getPortfolio', attempts: 1 }),
);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 1 }),
);
expect(logger.event).toHaveBeenCalledWith(
'error',
'tbank.rpc.failed',
expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 2 }),
);
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);
});
});
+559
View File
@@ -0,0 +1,559 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import {
ChannelCredentials,
Client,
ClientUnaryCall,
loadPackageDefinition,
Metadata,
ServiceError,
status,
} from '@grpc/grpc-js';
import { loadSync } from '@grpc/proto-loader';
import {
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { Environment } from '../../config/env.validation';
import type {
TbankBond,
TbankCandle,
TbankCoupon,
TbankFundamentals,
TbankLastPrice,
TbankShare,
} from './tbank-market.types';
const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000;
type Unary<Request, Response> = (
request: Request,
metadata: Metadata,
options: { deadline: Date },
callback: (error: ServiceError | null, response: Response) => void,
) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>;
export type TbankDecimal = { units?: string | number; nano?: string | number };
export type TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = {
id: string;
type: string;
name: string;
openedDate?: { seconds?: string | number };
};
export type TbankPortfolio = {
positions?: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal;
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney;
}[];
};
export type TbankPositions = { money?: TbankMoney[] };
export type TbankInstrument = {
ticker?: string;
name?: string;
isin?: string;
brand?: { logoName?: string | null };
};
export type TbankDividend = {
dividendNet?: TbankMoney;
recordDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
lastBuyDate?: TbankTimestamp;
dividendType?: string;
};
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
export type TbankOperationItem = {
cursor?: string;
id?: string;
name?: string;
date?: TbankTimestamp;
type?: string;
description?: string;
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
payment?: TbankMoney;
price?: TbankMoney;
commission?: TbankMoney;
accruedInt?: TbankMoney;
quantity?: string | number;
quantityDone?: string | number;
};
export type TbankOperationsByCursorRequest = {
accountId: string;
instrumentId?: string;
from?: TbankTimestamp;
to?: TbankTimestamp;
cursor?: string;
limit?: number;
state: 'OPERATION_STATE_EXECUTED';
};
export type TbankOperationsByCursorResponse = {
hasNext?: boolean;
nextCursor?: string;
items?: TbankOperationItem[];
};
type TbankNamespace = {
tinkoff: {
public: { invest: { api: { contract: { v1: Record<string, unknown> } } } };
};
};
class RateLimiter {
private readonly waiting: (() => void)[] = [];
private starts = 0;
private windowStartedAt = Date.now();
constructor(private readonly perSecond: number) {}
get size(): number {
return this.waiting.length;
}
async run<T>(operation: () => Promise<T>): Promise<T> {
await new Promise<void>((resolve) => {
this.waiting.push(resolve);
this.drain();
});
try {
return await operation();
} finally {
this.drain();
}
}
private drain(): void {
const now = Date.now();
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now;
this.starts = 0;
}
while (this.starts < this.perSecond && this.waiting.length) {
this.starts += 1;
this.waiting.shift()?.();
}
if (this.waiting.length && this.starts === this.perSecond) {
setTimeout(
() => this.drain(),
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
);
}
}
}
@Injectable()
export class TbankClient {
private readonly queue: RateLimiter;
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>,
private readonly logger: StructuredLogger,
) {
const sourceProtoRoot = join(
process.cwd(),
'src/integrations/tbank/proto/contracts',
);
const protoRoot = existsSync(sourceProtoRoot)
? sourceProtoRoot
: join(process.cwd(), 'dist/integrations/tbank/proto/contracts');
const definition = loadSync(
[
'users.proto',
'operations.proto',
'instruments.proto',
'marketdata.proto',
].map((file) => join(protoRoot, file)),
{
includeDirs: [protoRoot],
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
},
);
const services = (
loadPackageDefinition(definition) as unknown as TbankNamespace
).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
const credentials = caPath
? ChannelCredentials.createSsl(readFileSync(caPath))
: ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) =>
new (
services[name] as new (
address: string,
credentials: ChannelCredentials,
) => TbankClientService
)(endpoint, credentials);
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'),
);
this.logger.info('tbank.client.configured');
}
async validateToken(token: string): Promise<void> {
await this.getOpenAccounts(token);
}
getOpenAccounts(token: string): Promise<TbankAccount[]> {
return this.call<{ accounts?: TbankAccount[] }>(
token,
this.users,
'getAccounts',
{ status: 'ACCOUNT_STATUS_OPEN' },
).then((response) => response.accounts ?? []);
}
getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> {
return this.call(token, this.operations, 'getPortfolio', { accountId });
}
getPositions(token: string, accountId: string): Promise<TbankPositions> {
return this.call(token, this.operations, 'getPositions', { accountId });
}
getOperationsByCursor(
token: string,
request: TbankOperationsByCursorRequest,
): Promise<TbankOperationsByCursorResponse> {
return this.call(token, this.operations, 'getOperationsByCursor', request);
}
getInstrumentByFigi(
token: string,
figi: string,
): Promise<TbankInstrument | null> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
}
getInstrumentByUid(
token: string,
instrumentUid: string,
): Promise<TbankInstrument | null> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
}
getDividends(
token: string,
instrumentId: string,
from: string,
to: string,
): Promise<TbankDividend[]> {
return this.call<{ dividends?: TbankDividend[] }>(
token,
this.instruments,
'getDividends',
{
instrumentId,
from: { seconds: String(utcSeconds(from)) },
to: { seconds: String(utcSeconds(to)) },
},
).then((response) => response.dividends ?? []);
}
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() / MILLISECONDS_PER_SECOND)),
},
to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
},
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> {
const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try {
if (queueFull)
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request),
);
this.logger.event('debug', 'tbank.rpc.completed', {
operation: method,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
return response;
} catch (error) {
const grpcCode = (error as ServiceError).code;
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,
...failureContext,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
await new Promise((resolve) =>
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
continue;
}
this.logger.event('error', 'tbank.rpc.failed', {
operation: method,
...failureContext,
attempts: queueFull && !metadataCall ? 0 : attempts,
durationMs: Math.round(performance.now() - startedAt),
});
if (
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
)
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN');
if (
grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall)
)
throw new NotFoundException();
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
}
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
private request<Response>(
token: string,
client: TbankClientService,
method: string,
request: object,
): Promise<Response> {
return new Promise((resolve, reject) => {
const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`);
client[method](
request as never,
metadata,
{ deadline: new Date(Date.now() + requestDeadlineMs) },
(error, response) => (error ? reject(error) : resolve(response)),
);
});
}
}
function utcSeconds(date: string): number {
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND);
}
@@ -1,5 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const KEY_SIZE_BYTES = 32;
const VERSION = 1; const VERSION = 1;
const NONCE_SIZE = 12; const NONCE_SIZE = 12;
const TAG_SIZE = 16; const TAG_SIZE = 16;
@@ -9,7 +11,8 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto { static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64'); const decoded = Buffer.from(key, 'base64');
if (decoded.length !== 32) throw new Error('Invalid token encryption key'); if (decoded.length !== KEY_SIZE_BYTES)
throw new Error('Invalid token encryption key');
return new TbankCrypto(decoded); return new TbankCrypto(decoded);
} }
+21
View File
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { InstrumentMetadataService } from './instrument-metadata.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
import { TbankClient } from './tbank.client';
@Module({
providers: [
TbankClient,
TbankTokenService,
TbankAccessService,
InstrumentMetadataService,
],
exports: [
TbankClient,
TbankTokenService,
TbankAccessService,
InstrumentMetadataService,
],
})
export class TbankModule {}
@@ -7,7 +7,7 @@ jest.mock('bcrypt', () => ({
compare: jest.fn(() => Promise.resolve(true)), compare: jest.fn(() => Promise.resolve(true)),
})); }));
jest.mock('../prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
@@ -6,10 +6,13 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../generated/prisma/client'; import { Session, User } from '../../../../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthUser, RefreshPayload } from './auth.types';
const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m'; const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d'; const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH = const DUMMY_PASSWORD_HASH =
@@ -39,7 +42,7 @@ export class AuthService {
data: { data: {
id: randomUUID(), id: randomUUID(),
email: normalizedEmail, email: normalizedEmail,
passwordHash: await bcrypt.hash(password, 12), passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
}, },
}); });
} catch (error) { } catch (error) {
@@ -162,7 +165,7 @@ export class AuthService {
); );
const data = { const data = {
refreshTokenHash: this.hash(refreshToken), refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
}; };
if (expectedRefreshTokenHash) { if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({ const updated = await this.prisma.session.updateMany({
+3
View File
@@ -0,0 +1,3 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { type AccessPayload } from './application/auth.types';
export { JwtAuthGuard } from './presentation/jwt-auth.guard';
@@ -2,11 +2,11 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { Environment } from '../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthController } from './auth.controller'; import { AuthController } from './presentation/auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
@Module({ @Module({
imports: [ imports: [
@@ -19,6 +19,7 @@ import { JwtStrategy } from './jwt.strategy';
}), }),
}), }),
], ],
exports: [JwtAuthGuard],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard], providers: [AuthService, JwtStrategy, JwtAuthGuard],
}) })
@@ -2,8 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { Environment } from '../config/env.validation'; import { Environment } from '../../../config/env.validation';
import { AccessPayload } from './auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -1,6 +1,6 @@
import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
jest.mock('./auth.service', () => ({ jest.mock('../application/auth.service', () => ({
AuthService: class AuthService {}, AuthService: class AuthService {},
})); }));
@@ -3,11 +3,12 @@ import {
Controller, Controller,
Get, Get,
HttpCode, HttpCode,
HttpStatus,
Post, Post,
Req, Req,
Res, Res,
UseGuards,
UnauthorizedException, UnauthorizedException,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
@@ -17,29 +18,29 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto'; import { CredentialsDto } from './auth.dto';
import { AuthService } from './auth.service'; import { AuthService } from '../application/auth.service';
import { AccessPayload } from './auth.types'; import { AccessPayload } from '../application/auth.types';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
const COOKIE_PATH = '/api/v1/auth'; const COOKIE_PATH = '/api/v1/auth';
class UserDto { class UserDto {
@ApiProperty() @ApiProperty()
id: string; declare id: string;
@ApiProperty() @ApiProperty()
email: string; declare email: string;
} }
class AuthenticationDto { class AuthenticationDto {
@ApiProperty() @ApiProperty()
accessToken: string; declare accessToken: string;
@ApiProperty({ type: UserDto }) @ApiProperty({ type: UserDto })
user: UserDto; declare user: UserDto;
} }
@ApiTags('auth') @ApiTags('auth')
@@ -49,7 +50,7 @@ export class AuthController {
constructor(private readonly auth: AuthService) {} constructor(private readonly auth: AuthService) {}
@Post('register') @Post('register')
@HttpCode(200) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async register( async register(
@Body() credentials: CredentialsDto, @Body() credentials: CredentialsDto,
@@ -62,7 +63,7 @@ export class AuthController {
} }
@Post('login') @Post('login')
@HttpCode(200) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async login( async login(
@Body() credentials: CredentialsDto, @Body() credentials: CredentialsDto,
@@ -75,7 +76,7 @@ export class AuthController {
} }
@Post('refresh') @Post('refresh')
@HttpCode(200) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async refresh( async refresh(
@Req() request: Request, @Req() request: Request,
@@ -92,7 +93,7 @@ export class AuthController {
} }
@Post('logout') @Post('logout')
@HttpCode(204) @HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse() @ApiNoContentResponse()
async logout( async logout(
@Req() request: Request, @Req() request: Request,
@@ -1,26 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { import {
IsString, IsString,
MinLength, MinLength,
Validate, Validate,
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger'; const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32;
const ASCII_DELETE = 127;
const MIN_PASSWORD_LENGTH = 12;
const MAX_PASSWORD_BYTES = 72;
@ValidatorConstraint({ name: 'email' }) @ValidatorConstraint({ name: 'email' })
class EmailConstraint { class EmailConstraint {
validate(value: unknown): boolean { validate(value: unknown): boolean {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
value.length <= 254 && value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === 2 && value.split('@').length === 2 &&
!value !value
.split('') .split('')
.some( .some(
(character) => (character) =>
/\s/.test(character) || /\s/.test(character) ||
character.charCodeAt(0) < 32 || character.charCodeAt(0) < ASCII_SPACE ||
character.charCodeAt(0) === 127, character.charCodeAt(0) === ASCII_DELETE,
) && ) &&
value.split('@').every(Boolean) value.split('@').every(Boolean)
); );
@@ -33,7 +39,10 @@ class EmailConstraint {
@ValidatorConstraint({ name: 'passwordBytes' }) @ValidatorConstraint({ name: 'passwordBytes' })
class PasswordBytesConstraint { class PasswordBytesConstraint {
validate(value: unknown): boolean { validate(value: unknown): boolean {
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72; return (
typeof value === 'string' &&
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
);
} }
defaultMessage(): string { defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes'; return 'must not exceed 72 UTF-8 bytes';
@@ -47,11 +56,15 @@ export class CredentialsDto {
typeof value === 'string' ? value.trim().toLowerCase() : value, typeof value === 'string' ? value.trim().toLowerCase() : value,
) )
@Validate(EmailConstraint) @Validate(EmailConstraint)
email: string; declare email: string;
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' }) @ApiProperty({
minLength: MIN_PASSWORD_LENGTH,
maxLength: MAX_PASSWORD_BYTES,
example: 'a secure password',
})
@IsString() @IsString()
@MinLength(12) @MinLength(MIN_PASSWORD_LENGTH)
@Validate(PasswordBytesConstraint) @Validate(PasswordBytesConstraint)
password: string; declare password: string;
} }
@@ -1,7 +1,7 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../common/logging/request-context.service'; import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from './auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
@@ -0,0 +1,135 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import {
bankPaymentSystems,
BankPeriodQueryDto,
CreateBankCardDto,
CreateBankOperationDto,
} from '../presentation/bank-card.dto';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
export const BankErrorCode = {
InvalidInput: 'BANK_INVALID_INPUT',
VersionConflict: 'BANK_VERSION_CONFLICT',
NotFound: 'BANK_NOT_FOUND',
InvalidMoney: 'BANK_INVALID_MONEY',
InvalidDate: 'BANK_INVALID_DATE',
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
InvalidRefund: 'BANK_INVALID_REFUND',
RefundLimit: 'BANK_REFUND_LIMIT',
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
IntentConflict: 'BANK_INTENT_CONFLICT',
OperationDeleted: 'BANK_OPERATION_DELETED',
CardArchived: 'BANK_CARD_ARCHIVED',
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
WriteConflict: 'BANK_WRITE_CONFLICT',
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
} as const;
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
throw new BadRequestException({ code });
}
export 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 };
}
export function conflict(
code: BankErrorCode = BankErrorCode.VersionConflict,
): never {
throw new ConflictException({ code });
}
export function missing(): never {
throw new NotFoundException({ code: BankErrorCode.NotFound });
}
export const fingerprint = (value: unknown) =>
createHash('sha256')
.update(
JSON.stringify(value, (_key, item: unknown) =>
typeof item === 'bigint' ? item.toString() : item,
),
)
.digest('hex');
export function amount(
value: Parameters<typeof parseRubMoney>[0],
positive = false,
) {
try {
return parseRubMoney(value, positive);
} catch {
return fail(BankErrorCode.InvalidMoney);
}
}
export function calendar(value: string) {
if (!isCalendarDate(value)) fail(BankErrorCode.InvalidDate);
return value;
}
export function currentDate(value: string) {
calendar(value);
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
}
export function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
fail();
return value.trim();
}
export function cardInput(dto: CreateBankCardDto) {
const bankName = dto.bankName?.trim() || null;
if (bankName && bankName.length > MAX_CARD_NAME_LENGTH) 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, MAX_CARD_NAME_LENGTH),
bankName,
lastFour: dto.lastFour || null,
openingBalanceKopecks: amount(dto.openingBalance),
openingDate: calendar(dto.openingDate),
};
}
export function operationInput(dto: CreateBankOperationDto) {
if (
dto.description != null &&
(typeof dto.description !== 'string' ||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
)
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,
};
}
@@ -0,0 +1,147 @@
import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankCardDto,
CreateBankCardDto,
UpdateBankCardDto,
} from '../presentation/bank-card.dto';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import {
cardInput,
BankErrorCode,
conflict,
currentDate,
fail,
fingerprint,
missing,
} from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankCardsService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
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,
};
}
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.repository.card(tx, userId, id)),
);
}
createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto),
hash = fingerprint(input);
return this.repository.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(BankErrorCode.IntentConflict);
return this.cardView(tx, existing);
}
currentDate(input.openingDate);
await this.repository.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.repository.write(async (tx) => {
const previous = await this.repository.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(BankErrorCode.CardArchived);
currentDate(input.openingDate);
if (
await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
)
fail(BankErrorCode.OpeningDateConflict);
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.repository.card(tx, userId, id));
});
}
}
@@ -0,0 +1,343 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { GoneException, Injectable } from '@nestjs/common';
import {
BankCardOperation,
Prisma,
} from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
BankOperationsQueryDto,
BankPageQueryDto,
CreateBankOperationDto,
UpdateBankOperationDto,
} from '../presentation/bank-card.dto';
import { rubMoney } from '../domain/rub-money';
import {
BankErrorCode,
conflict,
currentDate,
fail,
fingerprint,
missing,
operationInput,
periodBounds,
} from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankOperationsService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async operationView(
tx: Tx,
row: BankCardOperation,
): Promise<BankOperationDto> {
const card = await this.repository.card(tx, row.userId, row.cardId);
const target = row.targetCardId
? await this.repository.card(tx, row.userId, row.targetCardId)
: null;
const source = row.originalExpenseId
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
: null;
const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId
? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId },
})
: null;
const refunds =
row.kind === BankOperationKind.Expense || source
? await tx.bankCardOperation.findMany({
where: {
userId: row.userId,
originalExpenseId: source?.id ?? row.id,
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
cardId: row.cardId,
cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: category?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === BankOperationKind.Expense || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
}
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.repository.card(tx, userId, id, true);
if (
(id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate
)
fail(BankErrorCode.InvalidDate);
}
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
)
fail(BankErrorCode.ImmutableOperation);
if (input.kind === BankOperationKind.Transfer) {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
)
fail();
} else if (input.targetCardId) fail();
if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail();
if (!input.categoryId) {
const category = await 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 !== BankOperationKind.Refund && input.originalExpenseId)
fail();
if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail();
const source = await this.repository.operation(
tx,
userId,
input.originalExpenseId,
);
if (
source.deletedAt ||
source.kind !== BankOperationKind.Expense ||
source.cardId !== input.cardId
)
fail(BankErrorCode.InvalidRefund);
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(BankErrorCode.InvalidDate);
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks >
source.amountKopecks
)
conflict(BankErrorCode.RefundLimit);
}
if (old?.kind === BankOperationKind.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(BankErrorCode.DependentRefunds);
}
}
createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto),
hash = fingerprint(normalized);
return this.repository.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(BankErrorCode.IntentConflict);
if (existing.deletedAt)
throw new GoneException({ code: BankErrorCode.OperationDeleted });
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.repository.operation(tx, userId, id);
if (row.deletedAt) missing();
return this.operationView(tx, row);
});
}
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.repository.write(async (tx) => {
const old = await this.repository.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.repository.operation(tx, userId, id),
);
});
}
deleteOperation(userId: string, id: string, version: number) {
return this.repository.write(async (tx) => {
const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) return;
if (row.version !== version) conflict();
await this.repository.card(tx, userId, row.cardId, true);
if (row.targetCardId)
await this.repository.card(tx, userId, row.targetCardId, true);
if (
row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
)
conflict(BankErrorCode.DependentRefunds);
if (row.originalExpenseId) {
const source = await this.repository.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.repository.card(tx, userId, id);
const page = query.page ?? 1,
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE;
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limit,
take: limit,
});
return {
items: await Promise.all(
rows.map((row) => this.operationView(tx, row)),
),
total,
page,
hasNext: page * limit < total,
};
});
}
}
@@ -0,0 +1,73 @@
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
import { conflict, missing, name } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@Injectable()
export class ExpenseCategoriesService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
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, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => {
await this.repository.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.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,
};
});
}
}
@@ -0,0 +1,50 @@
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '../../../../prisma/prisma.service';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
describe('bank card summary categories', () => {
it('resolves category names and rejects an inconsistent category reference', async () => {
const categories = jest
.fn()
.mockResolvedValue([{ id: 'food', name: 'Продукты' }]);
const tx = {
bankCardOperation: {
findMany: jest.fn().mockResolvedValue([
{
kind: 'expense',
categoryId: 'food',
amountKopecks: 123n,
date: '2026-09-13',
originalExpense: null,
},
]),
},
expenseCategory: { findMany: categories },
};
const service = new GetBankSummaryUseCase(
new BankCardsRepository({
$transaction: (run: (client: typeof tx) => unknown) => run(tx),
} as unknown as PrismaService),
);
await expect(
service.execute('user', { month: '2026-09' }),
).resolves.toMatchObject({
categories: [
{
categoryId: 'food',
categoryName: 'Продукты',
net: { value: '1.23' },
},
],
});
categories.mockResolvedValue([]);
await expect(service.execute('user', { month: '2026-09' })).rejects.toThrow(
'Bank operation category is missing',
);
});
});
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { bankSummary } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { periodBounds } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@Injectable()
export class GetBankSummaryUseCase {
constructor(private readonly repository: BankCardsRepository) {}
async execute(userId: string, query: BankSummaryQueryDto) {
const period = periodBounds(query);
const { operations, categories } = await this.repository.summaryData(
userId,
period,
query.cardId,
);
const totals = bankSummary(period, operations);
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) => {
const category = categories.find((c) => c.id === row.categoryId);
if (!category) throw new Error('Bank operation category is missing');
return {
categoryId: row.categoryId,
categoryName: category.name,
expenses: rubMoney(row.expenses),
refunds: rubMoney(row.refunds),
net: rubMoney(row.net),
};
}),
};
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { BankCardsController } from './presentation/bank-cards.controller';
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
import { BankCardsService } from './application/bank-cards.service';
import { BankOperationsService } from './application/bank-operations.service';
import { ExpenseCategoriesService } from './application/expense-categories.service';
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
@Module({
imports: [AuthModule],
controllers: [BankCardsController],
providers: [
GetBankSummaryUseCase,
ExpenseCategoriesService,
BankOperationsService,
BankCardsRepository,
BankCardsService,
],
})
export class BankCardsModule {}
@@ -0,0 +1,3 @@
export const MAX_CARD_NAME_LENGTH = 100;
export const MAX_CATEGORY_NAME_LENGTH = 80;
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
@@ -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,113 @@
import { BankOperationKind } from './bank-operation-kind';
const ISO_MONTH_LENGTH = 7;
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 === BankOperationKind.Expense ||
operation.kind === BankOperationKind.Transfer
)
balance -= operation.amountKopecks;
else if (
operation.kind === BankOperationKind.Topup ||
operation.kind === BankOperationKind.Refund
)
balance += operation.amountKopecks;
}
if (
operation.kind === BankOperationKind.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, ISO_MONTH_LENGTH) !== period
: operation.date < period.from || operation.date > period.to
)
continue;
if (
operation.kind !== BankOperationKind.Expense &&
operation.kind !== BankOperationKind.Refund
)
continue;
const expense =
operation.kind === BankOperationKind.Expense
? operation.amountKopecks
: 0n;
const refund =
operation.kind === BankOperationKind.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,8 @@
export const BankOperationKind = {
Expense: 'expense',
Topup: 'topup',
Transfer: 'transfer',
Refund: 'refund',
} as const;
export type BankOperationKind =
(typeof BankOperationKind)[keyof typeof BankOperationKind];
@@ -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,30 @@
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types';
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
export function parseRubMoney(money: Money, 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 < MIN_KOPECKS || kopecks > MAX_KOPECKS)
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): Money {
return {
currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }),
};
}
@@ -0,0 +1,114 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankErrorCode,
conflict,
missing,
} from '../application/bank-card-input';
type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000;
const TRANSACTION_TIMEOUT_MS = 15_000;
const MAX_WRITE_RETRY_COUNT = 2;
const seeds = [
'Продукты',
'Кафе',
'Такси',
'Транспорт',
'Жильё',
'Здоровье',
'Покупки',
'Развлечения',
'Другое',
];
@Injectable()
export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await this.prisma.$transaction(run, {
maxWait: TRANSACTION_MAX_WAIT_MS,
timeout: TRANSACTION_TIMEOUT_MS,
});
} 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 < MAX_WRITE_RETRY_COUNT)
continue;
if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
throw error;
}
}
}
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(BankErrorCode.CardArchived);
return card;
}
async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing();
return row;
}
async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) {
const seedKey = index === seeds.length - 1 ? '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'),
},
});
}
}
summaryData(
userId: string,
period: { from: string; to: string },
cardId?: string,
) {
return this.prisma.$transaction(async (tx) => {
if (cardId) await this.card(tx, userId, cardId);
const rows = await tx.bankCardOperation.findMany({
where: {
userId,
deletedAt: null,
date: { gte: period.from, lte: period.to },
kind: { in: [BankOperationKind.Expense, BankOperationKind.Refund] },
...(cardId ? { cardId } : {}),
},
include: { originalExpense: { select: { categoryId: true } } },
});
const categories = await tx.expenseCategory.findMany({
where: { userId },
});
return {
operations: rows.map((row) => ({
...row,
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
})),
categories,
};
});
}
}
@@ -0,0 +1,278 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_CATEGORY_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import {
ApiProperty,
ApiPropertyOptional,
IntersectionType,
OmitType,
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';
export const DEFAULT_BANK_PAGE_SIZE = 50;
const MAX_BANK_PAGE_SIZE = 100;
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const;
const bankOperationKinds = Object.values(BankOperationKind);
export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString()
@Matches(/^-?\d+(\.\d{1,2})?$/)
declare value: string;
}
export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(MAX_CARD_NAME_LENGTH)
declare name: string;
@ApiPropertyOptional({
type: String,
nullable: true,
maxLength: MAX_CARD_NAME_LENGTH,
})
@IsOptional()
@Transform(trim)
@IsString()
@MaxLength(MAX_CARD_NAME_LENGTH)
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)
declare openingBalance: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
declare openingDate: string;
}
export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
@ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean()
archived?: boolean;
}
export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
declare name: string;
}
export class UpdateExpenseCategoryDto extends CategoryNameDto {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
}
export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds)
declare kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() declare 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)
declare amount: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string;
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsString()
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
description?: string;
}
export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare 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: MAX_BANK_PAGE_SIZE,
default: DEFAULT_BANK_PAGE_SIZE,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_BANK_PAGE_SIZE)
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)
declare version: number;
}
export class ExpenseCategoryDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: Boolean }) declare isDefault: boolean;
@ApiProperty({ type: Number }) declare version: number;
}
export class BankCardDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: String, nullable: true }) declare bankName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare lastFour:
string | null;
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
declare paymentSystem: string | null;
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
@ApiProperty({ type: String }) declare openingDate: string;
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
string | null;
@ApiProperty({ type: Number }) declare version: number;
}
export class BankOperationDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ enum: bankOperationKinds }) declare kind: BankOperationKind;
@ApiProperty({ type: String }) declare cardId: string;
@ApiProperty({ type: String }) declare cardName: string;
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
string | null;
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare categoryId:
string | null;
@ApiProperty({ type: String, nullable: true }) declare categoryName:
string | null;
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
string | null;
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
@ApiProperty({ type: MoneyDto, nullable: true })
declare availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) declare date: string;
@ApiProperty({ type: String, nullable: true }) declare description:
string | null;
@ApiProperty({ type: Number }) declare version: number;
@ApiProperty({ type: String }) declare createdAt: string;
}
export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true })
declare items: BankOperationDto[];
@ApiProperty({ type: Number }) declare page: number;
@ApiProperty({ type: Number }) declare total: number;
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
}
export class BankSummaryCategoryDto {
@ApiProperty({ type: String }) declare categoryId: string;
@ApiProperty({ type: String }) declare categoryName: string;
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare 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 }) declare cardId: string | null;
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
declare categories: BankSummaryCategoryDto[];
}
@@ -0,0 +1,188 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import type { Request } from 'express';
import {
ApiProblemResponses,
ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import {
BankCardDto,
BankCardsQueryDto,
BankOperationDto,
BankOperationsPageDto,
BankOperationsQueryDto,
BankPageQueryDto,
BankSummaryDto,
BankSummaryQueryDto,
BankVersionQueryDto,
CategoryNameDto,
CreateBankCardDto,
CreateBankOperationDto,
ExpenseCategoryDto,
UpdateBankCardDto,
UpdateBankOperationDto,
UpdateExpenseCategoryDto,
} from './bank-card.dto';
import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service';
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
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,
private readonly operations: BankOperationsService,
private readonly expenseCategories: ExpenseCategoriesService,
private readonly bankSummary: GetBankSummaryUseCase,
) {}
@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.expenseCategories.categories(req.user.sub);
}
@Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.expenseCategories.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.expenseCategories.updateCategory(
req.user.sub,
id,
body.name,
body.version,
);
}
@Get('summary')
@ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.bankSummary.execute(req.user.sub, query);
}
@Post('operations')
@ApiCreatedResponse({ type: BankOperationDto })
createOperation(
@Req() req: AuthRequest,
@Body() body: CreateBankOperationDto,
) {
return this.operations.createOperation(req.user.sub, body);
}
@Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.operations.getOperation(req.user.sub, id);
}
@Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
updateOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankOperationDto,
) {
return this.operations.updateOperation(req.user.sub, id, body);
}
@Delete('operations/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse()
deleteOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankVersionQueryDto,
) {
return this.operations.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.operations.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.operations.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,10 @@
import { ApiProperty } from '@nestjs/swagger';
/** Source account of a row in an all-accounts response. */
export class AccountReferenceDto {
@ApiProperty()
declare id: string;
@ApiProperty()
declare name: string;
}
@@ -0,0 +1,429 @@
import {
BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
import assert from 'node:assert/strict';
jest.mock('../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class {},
}));
import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service';
import { IncomeAnalyticsService } from './income/income-analytics.service';
import { BrokerageOperationsService } from './operations/application/brokerage-operations.service';
import { PortfolioService } from './portfolio/application/portfolio.service';
import { UpcomingEventsService } from './upcoming-events/upcoming-events.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,
});
assert.ok(first.nextCursor);
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);
assert.ok(first.nextCursor);
const second = await operations.getAll('user', {
...query,
cursor: first.nextCursor,
});
const repeated = await operations.getAll('user', {
...query,
cursor: first.nextCursor,
});
assert.ok(second.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);
assert.ok(first.nextCursor);
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();
});
});
@@ -1,17 +1,31 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from './portfolio.dto'; import { MoneyDto } from '../../../common/money.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
totalsByCurrency: MoneyDto[]; declare totalsByCurrency: MoneyDto[];
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero stock positions, not the number of units.',
})
declare stockPositionCount: number;
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero bond positions, not the number of units.',
})
declare bondPositionCount: number;
} }
export class BrokerageAccountDto { export class BrokerageAccountDto {
@ApiProperty() @ApiProperty()
id: string; declare id: string;
@ApiProperty() @ApiProperty()
name: string; declare name: string;
@ApiProperty({ @ApiProperty({
enum: [ enum: [
@@ -21,11 +35,11 @@ export class BrokerageAccountDto {
'MONEY_MARKET_FUND', 'MONEY_MARKET_FUND',
], ],
}) })
type: declare type:
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
@ApiProperty({ type: String, nullable: true, format: 'date' }) @ApiProperty({ type: String, nullable: true, format: 'date' })
openedOn: string | null; declare openedOn: string | null;
@ApiProperty({ type: PortfolioSummaryDto, nullable: true }) @ApiProperty({ type: PortfolioSummaryDto, nullable: true })
portfolioSummary?: PortfolioSummaryDto | null; portfolioSummary?: PortfolioSummaryDto | null;
@@ -1,9 +1,8 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { BrokerageAccountDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';

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