refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
+3 -3
View File
@@ -6,12 +6,12 @@
| Решение / сигнал | Читать |
| --- | --- |
| Новый module/provider/service; бизнес-логика; перенос responsibility | [Architecture](conventions/backend-architecture.md) |
| Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.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) |
| 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) |
| Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) |
| Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [общий contract workflow](../../docs/development/runtime-openapi-verification.md) |
+194 -26
View File
@@ -1,39 +1,207 @@
# Backend architecture
## Выбор владельца
## Module-first organization
Найди существующий use case, его collaborators и tests до создания module/service.
Сохрани ответственность в текущем module, если новая возможность относится к нему.
Module — граница связного набора providers, а не папка для каждого endpoint.
Backend — domain-oriented modular monolith с лёгким разделением
по ответственности. `src/modules` группирует код по бизнес-возможности: `auth`, `banking/bank-cards`, области `investments`.
В инвестициях отдельные 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) |
| Business use case, orchestration, domain validation | Feature service |
| Input shape/format | DTO + configured ValidationPipe |
| Атомарность/DB query | Сервис use case через PrismaService, [Data](backend-data.md) |
| Внешний protocol, deadline/retry/limiting | Existing client, [Integrations](backend-integrations.md) |
| Чистое вычисление/mapper без lifecycle и DI | Обычная функция рядом с domain owner |
| Bootstrap/config/global cross-cutting concern | AppModule, config, существующий common owner |
| Controller / presentation | Route, verified identity, transport DTO, validation, status/cookie; вызов сценария |
| Use case / application service | Оркестрация, business validation, доступ и согласованность результата |
| Domain | Чистые правила и расчёты; без NestJS, Prisma, HTTP/Swagger и внешних SDK |
| Persistence / infrastructure | Запросы, транзакции, технические реализации границ |
| `src/integrations/{tbank,moex,ncr}` | Внешние протоколы, декодирование, retry/deadline/cache; без зависимостей от HTTP DTO business modules |
| `src/prisma` | Общий DB provider и lifecycle; не generic repository |
**Безопасный путь:** расширь текущий owner; выделяй collaborator, когда он скрывает
самостоятельный инвариант или координацию. Не вводи CQRS, generic repository,
interface с одной реализацией или base controller только ради шаблона Nest.
Не превращай common в хранилище бизнес-логики и не переноси frontend модели сюда.
Основной вызов: presentation → application → domain; application использует
конкретные infrastructure providers, зарегистрированные module. Infrastructure
может использовать application types/input и domain rules; новые interfaces
ради инверсии зависимостей не обязательны. 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.
- [TbankAccessService](../../src/tbank/tbank-access.service.ts) владеет connection/account access и generation; новые account use cases используют его вместо копирования guard/decrypt logic.
- [decimal.ts](../../src/tbank/decimal.ts) — пример pure domain helper; Injectable и adapter для арифметики не нужны.
Разделяй независимые операции, а не строки. `BankCardsService` отвечает за карты,
`BankOperationsService` — журнал/переводы/возвраты, `ExpenseCategoriesService` —
категории, `GetBankSummaryUseCase` — сводку. `BankCardsRepository` сохраняет
общий transaction retry и owner lookup; денежные транзакции не разрываются.
Чистые расчёты банковских карт находятся в `domain` этого модуля.
Наличие guard подтверждает session, но не владение account. При пересечении
access boundary добавь [Security](security-auth.md). Ошибка upstream не должна
обходить общий HTTP filter или превращать чужой account в доступный.
У инструментов `StockCatalogService` / `BondCatalogService` хранят идентичность
листинга и общий кэш slice; market service — котировки/свечи, income service — выплаты/личные позиции.
`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`.
## Logging
+22 -1
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 вокруг инварианта |
| Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата |
| Независимые reads | Не добавляй transaction ради внешнего вида |
| Общая 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 в запросах и
application checks. Предварительный lookup не заменяет unique constraint при race.
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
+11 -4
View File
@@ -16,16 +16,23 @@ use case. Для route/request/response/status/error shape читай
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
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 — 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`).
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
Для 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 значения.
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
@@ -40,7 +47,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.
Для нового field обновляй canonical source и generated artifacts в определённом
направлении; body type frontend не исправляется вручную.
+24 -4
View File
@@ -3,14 +3,34 @@
## Выбор boundary
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 —
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.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,
connection generation и invalid-token translation не дублируются в callers.
Чистый mapper финансового значения остаётся рядом с domain owner.
Mapper внешних финансовых значений остаётся внутри business capability,
обычно в infrastructure по [Architecture](backend-architecture.md).
## Retry, cache и protocol
@@ -30,7 +50,7 @@ cache/stale policy; semantics конкретного use case читай чер
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
и общем 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.
Generated artifacts, если есть, создавай из source, без ручной подгонки вывода.
@@ -40,7 +60,7 @@ Generated artifacts, если есть, создавай из source, без р
ответственного service. Отдельный abstraction/adapter оправдан новой
ответственностью или несовместимым 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,
cache isolation и late response для затронутых ветвей. Fixtures синтетические;
реальная сеть, секреты и пользовательские accounts не нужны.
+2 -2
View File
@@ -21,8 +21,8 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
## Случаи и примеры
- [BrokerageAccountsService tests](../../src/tbank/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.
- [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы.
- [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.
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank.
+1 -1
View File
@@ -14,7 +14,7 @@ TbankTokenService/TbankCrypto. Расширяй существующую boundar
[ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md)
задают session semantics. Access хранится только в памяти frontend; refresh
передаётся httpOnly cookie. Source code конфигурации cookie —
[AuthController](../../src/auth/auth.controller.ts); изменение flags/path требует
[AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует
проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
+1 -1
View File
@@ -5,7 +5,7 @@
"compilerOptions": {
"deleteOutDir": true,
"assets": [
{ "include": "tbank/proto/contracts/**/*.proto", "watchAssets": true }
{ "include": "integrations/tbank/proto/contracts/**/*.proto", "watchAssets": true }
]
}
}
+16 -4
View File
@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
import { LoggerModule } from 'nestjs-pino';
import { BankCardsModule } from './bank-cards/bank-cards.module';
import { AuthModule } from './auth/auth.module';
import { randomUUID } from 'node:crypto';
import { LoggingModule } from './common/logging/logging.module';
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 { TbankConnectionModule } from './tbank/tbank-connection.module';
@Module({
imports: [
@@ -51,6 +57,12 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
PrismaModule,
AuthModule,
BankCardsModule,
BrokerageAccountsModule,
PortfolioModule,
OperationsModule,
IncomeModule,
UpcomingEventsModule,
InstrumentsModule,
TbankConnectionModule,
],
})
-5
View File
@@ -1,5 +0,0 @@
import { Module } from '@nestjs/common';
import { BankCardsController } from './bank-cards.controller';
import { BankCardsService } from './bank-cards.service';
@Module({ controllers: [BankCardsController], providers: [BankCardsService] })
export class BankCardsModule {}
-712
View File
@@ -1,712 +0,0 @@
import {
BadRequestException,
ConflictException,
GoneException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import {
Prisma,
BankCard,
BankCardOperation,
} from '../../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { parseRubMoney, rubMoney } from '../common/rub-money';
import {
bankBalance,
bankSummary,
isCalendarDate,
moscowToday,
} from './bank-card.calculations';
import {
bankPaymentSystems,
BankCardDto,
BankOperationDto,
BankOperationsQueryDto,
BankPageQueryDto,
BankPeriodQueryDto,
BankSummaryQueryDto,
CreateBankCardDto,
CreateBankOperationDto,
UpdateBankCardDto,
UpdateBankOperationDto,
} from './bank-card.dto';
type Tx = Prisma.TransactionClient;
const seeds = [
'Продукты',
'Кафе',
'Такси',
'Транспорт',
'Жильё',
'Здоровье',
'Покупки',
'Развлечения',
'Другое',
];
function fail(code = 'BANK_INVALID_INPUT'): never {
throw new BadRequestException({ code });
}
function periodBounds(query: BankPeriodQueryDto) {
if (query.month && !query.from && !query.to) {
const from = `${query.month}-01`;
if (!isCalendarDate(from)) return fail();
const end = new Date(`${from}T00:00:00Z`);
end.setUTCMonth(end.getUTCMonth() + 1, 0);
return { from, to: end.toISOString().slice(0, 10) };
}
if (
query.month ||
!query.from ||
!query.to ||
!isCalendarDate(query.from) ||
!isCalendarDate(query.to) ||
query.from > query.to
)
return fail();
return { from: query.from, to: query.to };
}
function conflict(code = 'BANK_VERSION_CONFLICT'): never {
throw new ConflictException({ code });
}
function missing(): never {
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
}
const fingerprint = (value: unknown) =>
createHash('sha256')
.update(
JSON.stringify(value, (_key, item: unknown) =>
typeof item === 'bigint' ? item.toString() : item,
),
)
.digest('hex');
function amount(value: Parameters<typeof parseRubMoney>[0], positive = false) {
try {
return parseRubMoney(value, positive);
} catch {
return fail('BANK_INVALID_MONEY');
}
}
function calendar(value: string) {
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
return value;
}
function currentDate(value: string) {
calendar(value);
if (value > moscowToday()) fail('BANK_INVALID_DATE');
}
function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
fail();
return value.trim();
}
function cardInput(dto: CreateBankCardDto) {
const bankName = dto.bankName?.trim() || null;
if (bankName && bankName.length > 100) fail();
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
if (
dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
)
fail();
return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, 100),
bankName,
lastFour: dto.lastFour || null,
openingBalanceKopecks: amount(dto.openingBalance),
openingDate: calendar(dto.openingDate),
};
}
function operationInput(dto: CreateBankOperationDto) {
if (
dto.description != null &&
(typeof dto.description !== 'string' || dto.description.length > 500)
)
fail();
return {
kind: dto.kind,
cardId: dto.cardId,
targetCardId: dto.targetCardId ?? null,
categoryId: dto.categoryId ?? null,
originalExpenseId: dto.originalExpenseId ?? null,
amountKopecks: amount(dto.amount, true),
date: calendar(dto.date),
description: dto.description || null,
};
}
@Injectable()
export class BankCardsService {
constructor(private readonly prisma: PrismaService) {}
private async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await this.prisma.$transaction(run, {
maxWait: 10000,
timeout: 15000,
});
} catch (error) {
const code =
error instanceof Prisma.PrismaClientKnownRequestError
? error.code
: '';
const busy =
code === 'P2034' ||
code === 'P2028' ||
(error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < 2) continue;
if (busy) conflict('BANK_WRITE_CONFLICT');
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
throw error;
}
}
}
private async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing();
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
return card;
}
private async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing();
return row;
}
private async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) {
const seedKey = index === 8 ? 'other' : String(index);
if (
!(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } },
}))
)
await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
seedKey,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
}
}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
return {
id: card.id,
name: card.name,
bankName: card.bankName,
lastFour: card.lastFour,
paymentSystem: card.paymentSystem,
openingDate: card.openingDate,
openingBalance: rubMoney(card.openingBalanceKopecks),
balance: rubMoney(bankBalance(card.id, card.openingBalanceKopecks, rows)),
version: card.version,
archivedAt: card.archivedAt?.toISOString() ?? null,
};
}
private async operationView(
tx: Tx,
row: BankCardOperation,
): Promise<BankOperationDto> {
const card = await this.card(tx, row.userId, row.cardId);
const target = row.targetCardId
? await this.card(tx, row.userId, row.targetCardId)
: null;
const source = row.originalExpenseId
? await this.operation(tx, row.userId, row.originalExpenseId)
: null;
const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId
? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId },
})
: null;
const refunds =
row.kind === 'expense' || source
? await tx.bankCardOperation.findMany({
where: {
userId: row.userId,
originalExpenseId: source?.id ?? row.id,
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
cardId: row.cardId,
cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: category?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === 'expense' || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
}
list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) =>
Promise.all(
(
await tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
).map((card) => this.cardView(tx, card)),
),
);
}
get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.card(tx, userId, id)),
);
}
createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto),
hash = fingerprint(input);
return this.write(async (tx) => {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
return this.cardView(tx, existing);
}
currentDate(input.openingDate);
await this.seed(tx, userId);
return this.cardView(
tx,
await tx.bankCard.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.write(async (tx) => {
const previous = await this.card(tx, userId, id);
if (previous.version !== dto.version) conflict();
const input = cardInput({
id,
name: dto.name ?? previous.name,
bankName:
dto.bankName === undefined
? (previous.bankName ?? undefined)
: (dto.bankName ?? undefined),
lastFour:
dto.lastFour === undefined
? (previous.lastFour ?? undefined)
: (dto.lastFour ?? undefined),
paymentSystem:
dto.paymentSystem === undefined
? previous.paymentSystem
: dto.paymentSystem,
openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate,
});
const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) conflict('BANK_CARD_ARCHIVED');
currentDate(input.openingDate);
if (
await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
)
fail('BANK_OPENING_DATE_CONFLICT');
const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version },
data: {
...input,
paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 },
...(dto.archived === undefined
? {}
: {
archivedAt: dto.archived
? (previous.archivedAt ?? new Date())
: null,
}),
},
});
if (changed.count !== 1) conflict();
return this.cardView(tx, await this.card(tx, userId, id));
});
}
async categories(userId: string) {
return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
}
createCategory(userId: string, value: string) {
const label = name(value, 80);
return this.write(async (tx) => {
await this.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, 80);
return this.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== 1
)
conflict();
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + 1,
};
});
}
private async validateOperation(
tx: Tx,
userId: string,
input: ReturnType<typeof operationInput>,
old?: BankCardOperation,
) {
const cards = new Set(
[input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter(
(id): id is string => Boolean(id),
),
);
currentDate(input.date);
for (const id of cards) {
const card = await this.card(tx, userId, id, true);
if (
(id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate
)
fail('BANK_INVALID_DATE');
}
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
)
fail('BANK_IMMUTABLE_OPERATION');
if (input.kind === 'transfer') {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
)
fail();
} else if (input.targetCardId) fail();
if (input.kind === 'expense') {
if (input.originalExpenseId) fail();
if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing();
input.categoryId = category.id;
}
if (
!(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId },
}))
)
missing();
} else if (input.categoryId) fail();
if (input.kind !== 'refund' && input.originalExpenseId) fail();
if (input.kind === 'refund') {
if (!input.originalExpenseId) fail();
const source = await this.operation(tx, userId, input.originalExpenseId);
if (
source.deletedAt ||
source.kind !== 'expense' ||
source.cardId !== input.cardId
)
fail('BANK_INVALID_REFUND');
if (
(
await tx.bankCardOperation.updateMany({
where: {
id: source.id,
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== 1
)
conflict();
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: source.id,
deletedAt: null,
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail('BANK_INVALID_DATE');
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks >
source.amountKopecks
)
conflict('BANK_REFUND_LIMIT');
}
if (old?.kind === 'expense') {
const refunds = await tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if (
refunds.length &&
(input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks)
)
conflict('BANK_DEPENDENT_REFUNDS');
}
}
createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto),
hash = fingerprint(normalized);
return this.write(async (tx) => {
const existing = await tx.bankCardOperation.findUnique({
where: { id: dto.id },
});
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
if (existing.deletedAt)
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
return this.operationView(tx, existing);
}
const input = { ...normalized };
await this.validateOperation(tx, userId, input);
return this.operationView(
tx,
await tx.bankCardOperation.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => {
const row = await this.operation(tx, userId, id);
if (row.deletedAt) missing();
return this.operationView(tx, row);
});
}
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.write(async (tx) => {
const old = await this.operation(tx, userId, id);
if (old.deletedAt) missing();
if (old.version !== dto.version) conflict();
const input = operationInput({
id,
kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']),
cardId: dto.cardId ?? old.cardId,
targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined,
categoryId: dto.categoryId ?? old.categoryId ?? undefined,
originalExpenseId:
dto.originalExpenseId ?? old.originalExpenseId ?? undefined,
amount: dto.amount ?? rubMoney(old.amountKopecks),
date: dto.date ?? old.date,
description: dto.description ?? old.description ?? undefined,
});
await this.validateOperation(tx, userId, input, old);
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } },
})
).count !== 1
)
conflict();
return this.operationView(tx, await this.operation(tx, userId, id));
});
}
deleteOperation(userId: string, id: string, version: number) {
return this.write(async (tx) => {
const row = await this.operation(tx, userId, id);
if (row.deletedAt) return;
if (row.version !== version) conflict();
await this.card(tx, userId, row.cardId, true);
if (row.targetCardId) await this.card(tx, userId, row.targetCardId, true);
if (
row.kind === 'expense' &&
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
)
conflict('BANK_DEPENDENT_REFUNDS');
if (row.originalExpenseId) {
const source = await this.operation(tx, userId, row.originalExpenseId);
if (
(
await tx.bankCardOperation.updateMany({
where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } },
})
).count !== 1
)
conflict();
}
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== 1
)
conflict();
});
}
history(
userId: string,
id: string,
query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false,
) {
const period = expenses
? undefined
: periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => {
await this.card(tx, userId, id);
const page = query.page ?? 1,
limit = query.limit ?? 50;
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: '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,
};
});
}
summary(userId: string, query: BankSummaryQueryDto) {
const period = periodBounds(query);
return this.prisma.$transaction(async (tx) => {
if (query.cardId) await this.card(tx, userId, query.cardId);
const rows = await tx.bankCardOperation.findMany({
where: {
userId,
deletedAt: null,
date: { gte: period.from, lte: period.to },
kind: { in: ['expense', 'refund'] },
...(query.cardId ? { cardId: query.cardId } : {}),
},
include: { originalExpense: { select: { categoryId: true } } },
});
const totals = bankSummary(
period,
rows.map((row) => ({
...row,
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
})),
);
const categories = await tx.expenseCategory.findMany({
where: { userId },
});
return {
month: query.month,
...period,
cardId: query.cardId ?? null,
expenses: rubMoney(totals.expenses),
refunds: rubMoney(totals.refunds),
net: rubMoney(totals.net),
categories: totals.categories.map((row) => {
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),
};
}),
};
});
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../auth/auth.dto';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import {
CreateBankCardDto,
UpdateBankCardDto,
} from '../bank-cards/bank-card.dto';
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
+2 -1
View File
@@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types';
export class MoneyDto {
export class MoneyDto implements Money {
@ApiProperty()
declare currency: string;
+1
View File
@@ -0,0 +1 @@
export type Money = { currency: string; value: string };
+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';
+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 {}
+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,4 @@
import { add, formatDecimal, multiply, subtract } from '../common/decimal';
import { add, formatDecimal, multiply, subtract } from '../../common/decimal';
import { decimal } from './decimal';
describe('decimal', () => {
@@ -1,4 +1,4 @@
import { Decimal } from '../common/decimal';
import { Decimal } from '../../common/decimal';
const NANO = 1_000_000_000n;
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { InstrumentMetadataService } from './instrument-metadata.service';
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', () => {
@@ -1,7 +1,7 @@
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 { StructuredLogger } from '../../common/logging/structured-logger.service';
import { TbankClient, type TbankInstrument } from './tbank.client';
const metadataForMs = 86_400_000;
@@ -5,12 +5,12 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({
jest.mock('../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { TbankAccessService } from './tbank-access.service';
import type { TbankAccountContext } from './tbank-access.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
describe('TbankAccessService', () => {
@@ -4,14 +4,14 @@ import {
NotFoundException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { PrismaService } from '../../prisma/prisma.service';
import {
SupportedTbankAccount,
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account-policy';
import { TbankClient, TbankAccount } from './tbank.client';
} from './tbank-account.policy';
import { TbankTokenService } from './tbank-token.service';
import { TbankAccount, TbankClient } from './tbank.client';
export type TbankConnectionContext = {
token: string;
@@ -30,7 +30,9 @@ export class TbankAccessService {
private readonly tbank: TbankClient,
) {}
async listSupportedAccounts(userId: string): Promise<BrokerageAccountDto[]> {
async listSupportedAccounts(
userId: string,
): Promise<SupportedTbankAccount[]> {
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
}
@@ -39,7 +41,7 @@ export class TbankAccessService {
handler: (context: {
token: string;
generation: string;
accounts: BrokerageAccountDto[];
accounts: SupportedTbankAccount[];
}) => Promise<T> | T,
): Promise<T> {
return this.withConnection(userId, async ({ token, generation }) =>
@@ -1,7 +1,7 @@
import {
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account-policy';
} from './tbank-account.policy';
describe('tbank-account-policy', () => {
it('normalizes supported account types and fallback names', () => {
@@ -1,4 +1,3 @@
import { BrokerageAccountDto } from './brokerage-account.dto';
import { TbankAccount } from './tbank.client';
const accountTypes = {
@@ -15,10 +14,16 @@ const accountTypes = {
} as const;
type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = {
id: string;
name: string;
type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
};
export function supportedTbankAccounts(
accounts: TbankAccount[],
): BrokerageAccountDto[] {
): SupportedTbankAccount[] {
return accounts.flatMap((account) => {
const details = accountTypes[account.type as AccountType];
if (!details) return [];
@@ -1,7 +1,7 @@
import type {
TbankDecimal,
TbankMoney,
TbankInstrument,
TbankMoney,
TbankTimestamp,
} from './tbank.client';
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { Environment } from '../../config/env.validation';
import { TbankCrypto } from './tbank.crypto';
@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';
@@ -1,5 +1,5 @@
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => {
@@ -1,9 +1,3 @@
import {
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ChannelCredentials,
Client,
@@ -14,18 +8,24 @@ import {
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 { Environment } from '../config/env.validation';
import { StructuredLogger } from '../common/logging/structured-logger.service';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { Environment } from '../../config/env.validation';
import type {
TbankShare,
TbankBond,
TbankCoupon,
TbankCandle,
TbankLastPrice,
TbankCoupon,
TbankFundamentals,
TbankLastPrice,
TbankShare,
} from './tbank-market.types';
const requestDeadlineMs = 15_000;
@@ -164,10 +164,13 @@ export class TbankClient {
config: ConfigService<Environment, true>,
private readonly logger: StructuredLogger,
) {
const sourceProtoRoot = join(process.cwd(), 'src/tbank/proto/contracts');
const sourceProtoRoot = join(
process.cwd(),
'src/integrations/tbank/proto/contracts',
);
const protoRoot = existsSync(sourceProtoRoot)
? sourceProtoRoot
: join(process.cwd(), 'dist/tbank/proto/contracts');
: join(process.cwd(), 'dist/integrations/tbank/proto/contracts');
const definition = loadSync(
[
'users.proto',
+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)),
}));
jest.mock('../prisma/prisma.service', () => ({
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
@@ -6,8 +6,8 @@ import {
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { Session, User } from '../../../../generated/prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types';
const ACCESS_TTL = '15m';
+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 { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { Environment } from '../config/env.validation';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { JwtStrategy } from './jwt.strategy';
import { Environment } from '../../config/env.validation';
import { AuthController } from './presentation/auth.controller';
import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './infrastructure/jwt.strategy';
@Module({
imports: [
@@ -19,6 +19,7 @@ import { JwtStrategy } from './jwt.strategy';
}),
}),
],
exports: [JwtAuthGuard],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard],
})
@@ -2,8 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Environment } from '../config/env.validation';
import { AccessPayload } from './auth.types';
import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -1,6 +1,6 @@
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
jest.mock('./auth.service', () => ({
jest.mock('../application/auth.service', () => ({
AuthService: class AuthService {},
}));
@@ -6,8 +6,8 @@ import {
Post,
Req,
Res,
UseGuards,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
@@ -17,11 +17,11 @@ import {
ApiTags,
} from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto';
import { AuthService } from './auth.service';
import { AccessPayload } from './auth.types';
import { AuthService } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types';
import { JwtAuthGuard } from './jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
const REFRESH_COOKIE = 'refresh_token';
const COOKIE_PATH = '/api/v1/auth';
@@ -1,11 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsString,
MinLength,
Validate,
ValidatorConstraint,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
@ValidatorConstraint({ name: 'email' })
class EmailConstraint {
@@ -1,7 +1,7 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../common/logging/request-context.service';
import { AccessPayload } from './auth.types';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
@@ -0,0 +1,109 @@
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 function fail(code = 'BANK_INVALID_INPUT'): 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 = 'BANK_VERSION_CONFLICT'): never {
throw new ConflictException({ code });
}
export function missing(): never {
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
}
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('BANK_INVALID_MONEY');
}
}
export function calendar(value: string) {
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
return value;
}
export function currentDate(value: string) {
calendar(value);
if (value > moscowToday()) fail('BANK_INVALID_DATE');
}
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 > 100) fail();
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
if (
dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
)
fail();
return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, 100),
bankName,
lastFour: dto.lastFour || null,
openingBalanceKopecks: amount(dto.openingBalance),
openingDate: calendar(dto.openingDate),
};
}
export function operationInput(dto: CreateBankOperationDto) {
if (
dto.description != null &&
(typeof dto.description !== 'string' || dto.description.length > 500)
)
fail();
return {
kind: dto.kind,
cardId: dto.cardId,
targetCardId: dto.targetCardId ?? null,
categoryId: dto.categoryId ?? null,
originalExpenseId: dto.originalExpenseId ?? null,
amountKopecks: amount(dto.amount, true),
date: calendar(dto.date),
description: dto.description || null,
};
}
@@ -0,0 +1,145 @@
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,
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('BANK_INTENT_CONFLICT');
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('BANK_CARD_ARCHIVED');
currentDate(input.openingDate);
if (
await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
)
fail('BANK_OPENING_DATE_CONFLICT');
const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version },
data: {
...input,
paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 },
...(dto.archived === undefined
? {}
: {
archivedAt: dto.archived
? (previous.archivedAt ?? new Date())
: null,
}),
},
});
if (changed.count !== 1) conflict();
return this.cardView(tx, await this.repository.card(tx, userId, id));
});
}
}
@@ -0,0 +1,339 @@
import { GoneException, Injectable } from '@nestjs/common';
import {
BankCardOperation,
Prisma,
} from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankOperationDto,
BankOperationsQueryDto,
BankPageQueryDto,
CreateBankOperationDto,
UpdateBankOperationDto,
} from '../presentation/bank-card.dto';
import { rubMoney } from '../domain/rub-money';
import {
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 === 'expense' || source
? await tx.bankCardOperation.findMany({
where: {
userId: row.userId,
originalExpenseId: source?.id ?? row.id,
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
cardId: row.cardId,
cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: category?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === 'expense' || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
}
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('BANK_INVALID_DATE');
}
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
)
fail('BANK_IMMUTABLE_OPERATION');
if (input.kind === 'transfer') {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
)
fail();
} else if (input.targetCardId) fail();
if (input.kind === 'expense') {
if (input.originalExpenseId) fail();
if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing();
input.categoryId = category.id;
}
if (
!(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId },
}))
)
missing();
} else if (input.categoryId) fail();
if (input.kind !== 'refund' && input.originalExpenseId) fail();
if (input.kind === 'refund') {
if (!input.originalExpenseId) fail();
const source = await this.repository.operation(
tx,
userId,
input.originalExpenseId,
);
if (
source.deletedAt ||
source.kind !== 'expense' ||
source.cardId !== input.cardId
)
fail('BANK_INVALID_REFUND');
if (
(
await tx.bankCardOperation.updateMany({
where: {
id: source.id,
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== 1
)
conflict();
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: source.id,
deletedAt: null,
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail('BANK_INVALID_DATE');
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks >
source.amountKopecks
)
conflict('BANK_REFUND_LIMIT');
}
if (old?.kind === 'expense') {
const refunds = await tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if (
refunds.length &&
(input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks)
)
conflict('BANK_DEPENDENT_REFUNDS');
}
}
createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto),
hash = fingerprint(normalized);
return this.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('BANK_INTENT_CONFLICT');
if (existing.deletedAt)
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
return this.operationView(tx, existing);
}
const input = { ...normalized };
await this.validateOperation(tx, userId, input);
return this.operationView(
tx,
await tx.bankCardOperation.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => {
const row = await this.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 === 'expense' &&
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
)
conflict('BANK_DEPENDENT_REFUNDS');
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 ?? 50;
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: '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,72 @@
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, 80);
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, 80);
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,
};
});
}
}
@@ -1,7 +1,10 @@
jest.mock('../prisma/prisma.service', () => ({ PrismaService: class {} }));
jest.mock('../../generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '../prisma/prisma.service';
import { BankCardsService } from './bank-cards.service';
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 () => {
@@ -22,12 +25,14 @@ describe('bank card summary categories', () => {
},
expenseCategory: { findMany: categories },
};
const service = new BankCardsService({
$transaction: (run: (client: typeof tx) => unknown) => run(tx),
} as unknown as PrismaService);
const service = new GetBankSummaryUseCase(
new BankCardsRepository({
$transaction: (run: (client: typeof tx) => unknown) => run(tx),
} as unknown as PrismaService),
);
await expect(
service.summary('user', { month: '2026-09' }),
service.execute('user', { month: '2026-09' }),
).resolves.toMatchObject({
categories: [
{
@@ -38,7 +43,7 @@ describe('bank card summary categories', () => {
],
});
categories.mockResolvedValue([]);
await expect(service.summary('user', { month: '2026-09' })).rejects.toThrow(
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 {}
@@ -1,7 +1,7 @@
import { decimalFromString, formatDecimal } from './decimal';
import { MoneyDto } from './money.dto';
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types';
export function parseRubMoney(money: MoneyDto, positive = false): bigint {
export function parseRubMoney(money: Money, positive = false): bigint {
if (
money?.currency !== 'RUB' ||
typeof money.value !== 'string' ||
@@ -19,7 +19,7 @@ export function parseRubMoney(money: MoneyDto, positive = false): bigint {
return kopecks;
}
export function rubMoney(kopecks: bigint): MoneyDto {
export function rubMoney(kopecks: bigint): Money {
return {
currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }),
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { conflict, missing } from '../application/bank-card-input';
type Tx = Prisma.TransactionClient;
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: 10000,
timeout: 15000,
});
} catch (error) {
const code =
error instanceof Prisma.PrismaClientKnownRequestError
? error.code
: '';
const busy =
code === 'P2034' ||
code === 'P2028' ||
(error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < 2) continue;
if (busy) conflict('BANK_WRITE_CONFLICT');
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
throw error;
}
}
}
async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing();
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
return card;
}
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 === 8 ? 'other' : String(index);
if (
!(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } },
}))
)
await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
seedKey,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
}
}
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: ['expense', '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,
};
});
}
}
@@ -1,8 +1,8 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
IntersectionType,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
@@ -22,7 +22,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { MoneyDto } from '../common/money.dto';
import { MoneyDto } from '../../../../common/money.dto';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
@@ -16,17 +16,16 @@ import {
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiTags,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
ApiProblemResponses,
ProblemDetailsDto,
} from '../common/decorators/api-safe-error-response.decorator';
} from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import {
BankCardDto,
BankCardsQueryDto,
@@ -45,7 +44,10 @@ import {
UpdateBankOperationDto,
UpdateExpenseCategoryDto,
} from './bank-card.dto';
import { BankCardsService } from './bank-cards.service';
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,
@@ -73,7 +75,12 @@ type AuthRequest = Request & { user: AccessPayload };
@UseGuards(JwtAuthGuard)
@Controller('bank-cards')
export class BankCardsController {
constructor(private readonly cards: BankCardsService) {}
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) {
@@ -87,12 +94,12 @@ export class BankCardsController {
@Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) {
return this.cards.categories(req.user.sub);
return this.expenseCategories.categories(req.user.sub);
}
@Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.cards.createCategory(req.user.sub, body.name);
return this.expenseCategories.createCategory(req.user.sub, body.name);
}
@Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto })
@@ -101,12 +108,17 @@ export class BankCardsController {
@Param('id') id: string,
@Body() body: UpdateExpenseCategoryDto,
) {
return this.cards.updateCategory(req.user.sub, id, body.name, body.version);
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.cards.summary(req.user.sub, query);
return this.bankSummary.execute(req.user.sub, query);
}
@Post('operations')
@ApiCreatedResponse({ type: BankOperationDto })
@@ -114,12 +126,12 @@ export class BankCardsController {
@Req() req: AuthRequest,
@Body() body: CreateBankOperationDto,
) {
return this.cards.createOperation(req.user.sub, body);
return this.operations.createOperation(req.user.sub, body);
}
@Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.getOperation(req.user.sub, id);
return this.operations.getOperation(req.user.sub, id);
}
@Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
@@ -128,7 +140,7 @@ export class BankCardsController {
@Param('id') id: string,
@Body() body: UpdateBankOperationDto,
) {
return this.cards.updateOperation(req.user.sub, id, body);
return this.operations.updateOperation(req.user.sub, id, body);
}
@Delete('operations/:id')
@HttpCode(204)
@@ -138,7 +150,7 @@ export class BankCardsController {
@Param('id') id: string,
@Query() query: BankVersionQueryDto,
) {
return this.cards.deleteOperation(req.user.sub, id, query.version);
return this.operations.deleteOperation(req.user.sub, id, query.version);
}
@Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto })
@@ -147,7 +159,7 @@ export class BankCardsController {
@Param('id') id: string,
@Query() query: BankOperationsQueryDto,
) {
return this.cards.history(req.user.sub, id, query);
return this.operations.history(req.user.sub, id, query);
}
@Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto })
@@ -156,7 +168,7 @@ export class BankCardsController {
@Param('id') id: string,
@Query() query: BankPageQueryDto,
) {
return this.cards.history(req.user.sub, id, query, true);
return this.operations.history(req.user.sub, id, query, true);
}
@Get(':id')
@ApiOkResponse({ type: BankCardDto })
@@ -1,17 +1,19 @@
import assert from 'node:assert/strict';
import {
BadRequestException,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
import assert from 'node:assert/strict';
jest.mock('./tbank-access.service', () => ({ TbankAccessService: class {} }));
jest.mock('../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class {},
}));
import { BrokerageOperationsService } from './brokerage-operations.service';
import { IncomeAnalyticsService } from './income-analytics.service';
import { PortfolioService } from './portfolio.service';
import { UpcomingEventsService } from './upcoming-events.service';
import type { TbankConnectionContext } from './tbank-access.service';
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 = {
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../common/money.dto';
import { MoneyDto } from '../../../common/money.dto';
export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true })
@@ -1,9 +1,8 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({
imports: [AuthModule, TbankModule],
controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService],
})
export class BrokerageAccountsModule {}
@@ -4,13 +4,13 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service';
import { BrokerageAccountsService } from './brokerage-accounts.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
describe('BrokerageAccountsService', () => {
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
@@ -1,11 +1,12 @@
import { Injectable } from '@nestjs/common';
import { ServiceUnavailableException } from '@nestjs/common';
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { isZero } from '../../../common/decimal';
import {
TbankAccessService,
TbankClient,
} from '../../../integrations/tbank/tbank.api';
import { decimal } from '../../../integrations/tbank/decimal';
import { totalsByCurrency } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { isZero } from '../common/decimal';
import { decimal } from './decimal';
import { totalsByCurrency } from './portfolio-totals';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
@Injectable()
export class BrokerageAccountsService {
@@ -1,9 +1,8 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
IncomeAnalyticsDto,
IncomeAnalyticsQueryDto,
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
import { BrokerageOperationDto } from './brokerage-operation.dto';
import { MoneyDto } from '../common/money.dto';
import { MoneyDto } from '../../../common/money.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
@@ -1,8 +1,8 @@
jest.mock('./brokerage-operations.service', () => ({
jest.mock('../operations/application/brokerage-operations.service', () => ({
BrokerageOperationsService: class BrokerageOperationsService {},
}));
import { BrokerageOperationDto } from './brokerage-operation.dto';
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { IncomeAnalyticsService } from './income-analytics.service';
describe('IncomeAnalyticsService', () => {
@@ -1,21 +1,17 @@
import { Injectable } from '@nestjs/common';
import { BrokerageOperationDto } from './brokerage-operation.dto';
import { BrokerageOperationsService } from './brokerage-operations.service';
import {
add,
compare,
decimalFromString,
formatDecimal,
subtract,
} from '../common/decimal';
} from '../../../common/decimal';
import { DateOnlyRange, moscowDate } from '../investment-calendar';
import {
DateOnlyRange,
IncomeBucketGranularity,
moscowDate,
planIncomePeriod,
rangeBuckets,
} from './income-period';
BrokerageOperationDto,
BrokerageOperationsService,
} from '../operations/operations.api';
import {
IncomeAnalyticsDto,
IncomeAnalyticsEntryDto,
@@ -24,6 +20,11 @@ import {
IncomeInstrumentDto,
IncomeSummaryDto,
} from './income-analytics.dto';
import {
IncomeBucketGranularity,
planIncomePeriod,
rangeBuckets,
} from './income-period';
type Total = {
coupon: ReturnType<typeof decimalFromString>;
@@ -1,9 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import {
dateOnlyBounds,
planIncomePeriod,
rangeBuckets,
} from './income-period';
import { dateOnlyBounds } from '../investment-calendar';
import { planIncomePeriod, rangeBuckets } from './income-period';
describe('income period planning', () => {
afterEach(() => jest.useRealTimers());
@@ -1,10 +1,13 @@
import { BadRequestException } from '@nestjs/common';
import {
DateOnlyRange,
dateParts,
moscowDate,
utcDate,
} from '../investment-calendar';
export type DateOnlyRange = { from: string; to: string };
export type IncomeBucketGranularity = 'day' | 'week' | 'month';
const moscow = 'Europe/Moscow';
export function planIncomePeriod(
from: string | undefined,
to: string | undefined,
@@ -41,27 +44,6 @@ export function planIncomePeriod(
};
}
export function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: moscow,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(value);
const get = (type: string) => parts.find((part) => part.type === type)?.value;
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } {
const from = dateParts(range.from);
const end = dateParts(range.to);
const to = new Date(Date.UTC(end[0], end[1] - 1, end[2], 20, 59, 59, 999));
return {
from: new Date(Date.UTC(from[0], from[1] - 1, from[2], -3)),
to: to > new Date() ? new Date() : to,
};
}
export function rangeBuckets(
range: DateOnlyRange,
granularity: IncomeBucketGranularity,
@@ -104,27 +86,6 @@ function assertRange(range: DateOnlyRange, today: string): void {
if (range.from > range.to || range.to > today) throw invalidPeriod();
}
function dateParts(value: string): [number, number, number] {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) throw new Error('Invalid date');
const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as [
number,
number,
number,
];
if (utcDate(value).toISOString().slice(0, 10) !== value)
throw new Error('Invalid date');
return parts;
}
function utcDate(value: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return new Date(NaN);
return new Date(
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])),
);
}
function addDays(value: string, days: number): string {
const date = utcDate(value);
date.setUTCDate(date.getUTCDate() + days);
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { OperationsModule } from '../operations/operations.module';
import { IncomeAnalyticsController } from './income-analytics.controller';
import { IncomeAnalyticsService } from './income-analytics.service';
@Module({
imports: [AuthModule, OperationsModule],
controllers: [IncomeAnalyticsController],
providers: [IncomeAnalyticsService],
})
export class IncomeModule {}
@@ -1,6 +1,8 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type { TbankTimestamp } from './tbank.client';
import type { TbankCandle } from './tbank-market.types';
import type {
TbankCandle,
TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api';
export function timestamp(value?: TbankTimestamp): string | null {
const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0
@@ -4,11 +4,14 @@ import {
divide,
formatDecimal,
multiply,
} from '../common/decimal';
} from '../../../../common/decimal';
import { MoneyDto } from '../common/money.dto';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { StockPositionDto } from './stock.dto';
import { MoneyDto } from '../../../../common/money.dto';
import {
PortfolioDto,
PortfolioPositionDto,
} from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto';
export function instrumentPosition(
portfolio: PortfolioDto,
@@ -0,0 +1,231 @@
import {
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import {
TbankAccessService,
TbankClient,
type TbankConnectionContext,
} from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { optional, timestamp } from '../../application/instrument-market';
import { BondDto, BondSearchResultDto } from '../presentation/bond.dto';
type IdentifiedBond = TbankBond & { uid: string };
import { money } from '../infrastructure/bond-money.mapper';
@Injectable()
export class BondCatalogService {
private readonly cache = new LRUCache<string, Promise<object>>({
max: 300,
ttl: 30_000,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
) {}
search(userId: string, query: string): Promise<BondSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (connection) => {
const { token } = connection;
const found = await this.tbank.findBonds(token, query);
const bonds = selectBonds(found, query).slice(0, 30);
if (!bonds.length) return [];
// Batch prices and share metadata with detail sections. Resolve readable
// aliases only when an issue is opened, not for every search result.
const [details, prices] = await Promise.all([
Promise.all(
bonds.map(
async (bond) =>
(await optional(
() =>
this.bond(userId, connection, bond.uid)
.then(toBond)
.catch((error: unknown) => {
if (error instanceof NotFoundException)
return toBond(bond);
throw error;
}),
'details',
[],
)) ?? toBond(bond),
),
),
optional(
() =>
this.tbank.getLastPrices(
token,
bonds.map((bond) => bond.uid),
),
'prices',
[],
),
]);
return details.map((detail, index) => {
const short = toBond(bonds[index]);
const last = prices?.find(
(price) => price.instrumentUid === detail.instrumentUid,
);
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null,
priceAt: timestamp(last?.time),
};
});
});
}
get(userId: string, uid: string): Promise<BondDto> {
return this.cached(userId, `bond:${uid}`, async (connection) => {
const result = toBond(await this.bond(userId, connection, uid));
if (
!result.ticker ||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
)
return result;
try {
const match = await this.tickerMatch(userId, result.ticker);
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
} catch (error) {
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
if (
!(error instanceof NotFoundException) &&
!(error instanceof ConflictException) &&
!(error instanceof ServiceUnavailableException)
)
throw error;
}
return result;
});
}
async getByTicker(userId: string, ticker: string): Promise<BondDto> {
const match = await this.tickerMatch(userId, ticker);
return {
...(await this.get(userId, match.uid)),
slug: ticker.trim().toUpperCase(),
};
}
private tickerMatch(userId: string, ticker: string): Promise<IdentifiedBond> {
const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (connection) => {
const { token } = connection;
const found = await this.tbank.findBonds(token, normalized);
const matches = selectBonds(
found.filter(
(item) => item.ticker?.trim().toUpperCase() === normalized,
),
normalized,
);
if (!matches.length) throw new NotFoundException();
if (matches.length > 1)
throw new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' });
return matches[0];
});
}
bond(
userId: string,
connection: TbankConnectionContext,
uid: string,
): Promise<IdentifiedBond> {
return this.cachedForConnection(
userId,
connection,
`metadata:${uid}`,
async () => {
const bond = await this.tbank.getBond(connection.token, uid);
if (!bond || bond.uid !== uid) throw new NotFoundException();
return { ...bond, uid };
},
);
}
cached<T extends object>(
userId: string,
key: string,
operation: (connection: TbankConnectionContext) => Promise<T>,
): Promise<T> {
return this.access.withConnection(userId, (connection) =>
this.cachedForConnection(userId, connection, key, () =>
operation(connection),
),
);
}
cachedForConnection<T extends object>(
userId: string,
{ generation }: TbankConnectionContext,
key: string,
operation: () => Promise<T>,
): Promise<T> {
const cacheKey = JSON.stringify([userId, generation, key]);
const cached = this.cache.get(cacheKey);
if (cached) return cached as Promise<T>;
const pending = operation();
this.cache.set(cacheKey, pending);
return pending.catch((error) => {
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
throw error;
});
}
}
function selectBonds(found: TbankBond[], query: string): IdentifiedBond[] {
const seen = new Set<string>();
return found
.filter(
(bond): bond is IdentifiedBond =>
Boolean(bond.uid) && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND',
)
.sort(
(a, b) =>
Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') ||
a.uid.localeCompare(b.uid),
)
.filter((bond) => {
// A missing ISIN must not collapse unrelated catalog instruments.
const key = bond.isin?.trim()
? `isin:${bond.isin.trim().toUpperCase()}`
: `uid:${bond.uid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function toBond(bond: IdentifiedBond): BondDto {
return {
instrumentUid: bond.uid,
slug: bond.uid,
name: bond.name?.trim() || bond.ticker || bond.uid,
ticker: bond.ticker?.trim() || null,
isin: bond.isin?.trim() || null,
classCode: bond.classCode?.trim() || null,
realExchange: bond.realExchange || null,
apiTradeAvailable: bond.apiTradeAvailableFlag ?? null,
logoName: bond.brand?.logoName?.trim() || null,
currency: bond.currency?.toUpperCase() || null,
bondType: bond.bondType || null,
sector: bond.sector || null,
lot: bond.lot && bond.lot > 0 ? bond.lot : null,
historyFrom: timestamp(bond.first_1dayCandleDate),
nominal: money(bond.nominal),
initialNominal: money(bond.initialNominal),
aci: money(bond.aciValue),
maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null,
callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null,
couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
floatingCoupon: bond.floatingCouponFlag ?? null,
perpetual: bond.perpetualFlag ?? null,
amortization: bond.amortizationFlag ?? null,
subordinated: bond.subordinatedFlag ?? null,
riskLevel: bond.riskLevel || null,
};
}
@@ -0,0 +1,204 @@
import { Injectable } from '@nestjs/common';
import {
decimalFromString,
divide,
formatDecimal,
multiply,
} from '../../../../../common/decimal';
import { MoexClient } from '../../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr.api';
import {
TbankClient,
type TbankConnectionContext,
} from '../../../../../integrations/tbank/tbank.api';
import { PortfolioService } from '../../../portfolio/portfolio.api';
import {
moscowDay,
optional,
timestamp,
} from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position';
import {
BondCouponsDto,
BondMetricsDto,
BondPositionDto,
} from '../presentation/bond.dto';
import { BondMarketService } from './bond-market.service';
import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service';
@Injectable()
export class BondIncomeService {
constructor(
private readonly catalog: BondCatalogService,
private readonly tbank: TbankClient,
private readonly portfolios: PortfolioService,
private readonly market: BondMarketService,
private readonly moex: MoexClient,
private readonly ratings: NcrRatingsClient,
) {}
metrics(userId: string, uid: string): Promise<BondMetricsDto> {
return this.catalog.cached(userId, `metrics:${uid}`, async (connection) => {
const instrument = await this.catalog.bond(userId, connection, uid);
const { ticker, isin, classCode } = instrument;
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, quote, effectiveYield, creditRating] = await Promise.all([
optional(
() => this.couponEvents(userId, connection, uid, from, to),
'coupons',
unavailable,
),
optional(() => this.market.quote(userId, uid), 'quote', unavailable),
ticker && isin && classCode
? optional(
() => this.moex.bondYield(ticker, isin, classCode),
'yield',
unavailable,
)
: null,
isin
? optional(() => this.ratings.rating(isin), 'rating', unavailable)
: null,
]);
const next = events
?.filter((event) => {
const date = timestamp(event.couponDate)?.slice(0, 10);
return date && date >= today;
})
.sort(
(a, b) =>
Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds),
)[0];
const coupon = money(next?.payOneBond);
const period = next?.couponPeriod;
const couponPeriodDays =
period && Number.isInteger(period) && period > 0 ? period : null;
const clean = quote?.cleanPrice;
const couponYieldPercent =
coupon &&
couponPeriodDays &&
clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= 0n &&
decimalFromString(clean.value).value > 0n
? formatDecimal(
divide(
multiply(
decimalFromString(coupon.value),
decimalFromString('36500'),
),
multiply(
decimalFromString(String(couponPeriodDays)),
decimalFromString(clean.value),
),
4,
),
)
: null;
const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
? next.couponType
: instrument.floatingCouponFlag === true
? 'COUPON_TYPE_FLOATING'
: null;
return {
creditRating,
coupon,
couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null,
couponType,
couponPeriodDays,
couponYieldPercent,
couponYieldPriceAt:
couponYieldPercent !== null ? (quote?.priceAt ?? null) : null,
effectiveYield,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
private couponEvents(
userId: string,
connection: TbankConnectionContext,
uid: string,
from: string,
to: string,
) {
return this.catalog.cachedForConnection(
userId,
connection,
`coupon-events:${uid}:${from}:${to}`,
() => this.tbank.getBondCoupons(connection.token, uid, from, to),
);
}
coupons(userId: string, uid: string): Promise<BondCouponsDto> {
return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
await this.catalog.bond(userId, connection, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.couponEvents(userId, connection, uid, from, to),
optional(() => this.position(userId, uid), 'position', unavailable),
]);
const quantity =
position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
const coupons = events
.map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
const perBond = money(event.payOneBond);
const timing = !paymentDate
? ('undated' as const)
: paymentDate >= today
? ('upcoming' as const)
: ('past' as const);
return {
number: event.couponNumber ?? null,
paymentDate,
recordDate,
perBond,
couponType: event.couponType ?? null,
periodDays: event.couponPeriod ?? null,
timing,
estimatedAmount:
quantity &&
perBond &&
timing === 'upcoming' &&
recordDate &&
recordDate >= today
? {
currency: perBond.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)),
),
}
: null,
};
})
.sort((a, b) =>
(a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''),
);
return {
coupons,
from,
to,
observedAt: new Date().toISOString(),
positionAt: quantity ? (position?.observedAt ?? null) : null,
estimateUnavailable: unavailable.length > 0,
positionIsStale: position?.isStale ?? false,
};
});
}
async position(userId: string, uid: string): Promise<BondPositionDto> {
return this.catalog.cached(userId, `position:${uid}`, async () =>
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
);
}
}
@@ -0,0 +1,183 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import {
add,
decimalFromString,
divide,
formatDecimal,
multiply,
subtract,
} from '../../../../../common/decimal';
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import {
optional,
previousClose,
timestamp,
} from '../../application/instrument-market';
import {
BondCandlesDto,
BondCandlesQueryDto,
BondQuoteDto,
} from '../presentation/bond.dto';
const dayMs = 86_400_000;
import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service';
@Injectable()
export class BondMarketService {
constructor(
private readonly catalog: BondCatalogService,
private readonly tbank: TbankClient,
) {}
quote(userId: string, uid: string): Promise<BondQuoteDto> {
return this.catalog.cached(userId, `quote:${uid}`, async (connection) => {
const { token } = connection;
const instrument = await this.catalog.bond(userId, connection, uid);
const unavailable: string[] = [];
const [last, tradingStatus] = await Promise.all([
optional(
() => this.tbank.getLastPrice(token, uid),
'price',
unavailable,
),
optional(
() => this.tbank.getTradingStatus(token, uid),
'status',
unavailable,
),
]);
const priceAt = timestamp(last?.time);
const pricePercent = last?.price
? formatDecimal(decimal(last.price))
: null;
const nominal = money(instrument.nominal);
const aci = money(instrument.aciValue);
const cleanPrice =
pricePercent !== null &&
nominal &&
decimalFromString(nominal.value).value > 0n
? {
currency: nominal.currency,
value: formatDecimal(
divide(
multiply(
decimalFromString(pricePercent),
decimalFromString(nominal.value),
),
decimalFromString('100'),
9,
),
),
}
: null;
const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency
? {
currency: cleanPrice.currency,
value: formatDecimal(
add(
decimalFromString(cleanPrice.value),
decimalFromString(aci.value),
),
),
}
: null;
const previous = priceAt
? await optional(
async () =>
previousClose(
await this.tbank.getCandles(
token,
uid,
new Date(Date.parse(priceAt) - 20 * dayMs),
new Date(priceAt),
),
priceAt,
),
'comparison',
unavailable,
)
: null;
return {
pricePercent,
cleanPrice,
aci,
dirtyPrice,
priceAt,
tradingStatus,
changePercentagePoints:
last?.price && previous?.close
? formatDecimal(
subtract(decimal(last.price), decimal(previous.close)),
)
: null,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
candles(
userId: string,
uid: string,
period: BondCandlesQueryDto['period'] = 'month',
): Promise<BondCandlesDto> {
return this.catalog.cached(
userId,
`candles:${uid}:${period}`,
async (connection) => {
const { token } = connection;
const bond = await this.catalog.bond(userId, connection, uid);
const historyFrom = timestamp(bond.first_1dayCandleDate);
const to = new Date();
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
);
if (period === 'all') {
if (!historyFrom)
throw new ServiceUnavailableException({
code: 'BOND_HISTORY_START_UNAVAILABLE',
});
from.setTime(Date.parse(historyFrom));
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
from.setTime(Date.parse(historyFrom));
const collected = new Map<string, BondCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf());
for (const candle of await this.tbank.getCandles(
token,
uid,
new Date(start),
new Date(end),
)) {
const time = timestamp(candle.time);
if (
time &&
candle.close &&
Date.parse(time) >= from.valueOf() - dayMs &&
Date.parse(time) <= to.valueOf()
)
collected.set(time, {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
});
}
start = end;
}
return {
candles: [...collected.values()].sort((a, b) =>
a.time.localeCompare(b.time),
),
currency: null,
historyFrom,
observedAt: new Date().toISOString(),
};
},
);
}
}
@@ -1,19 +1,21 @@
import assert from 'node:assert/strict';
jest.mock('./tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import {
ConflictException,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { BondService } from './bond.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
import { NcrRatingsClient } from './ncr-ratings.client';
import { MoexClient } from './moex.client';
import { PortfolioService } from './portfolio.service';
import assert from 'node:assert/strict';
import { MoexClient } from '../../../../../integrations/moex/moex.client';
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr-ratings.client';
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
import { BondIncomeService } from './bond-income.service';
import { BondMarketService } from './bond-market.service';
import { BondCatalogService } from './bond-catalog.service';
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
const money = (units: string, currency = 'rub') => ({ units, currency });
const cacheClock = performance;
@@ -63,14 +65,25 @@ function setup() {
};
const ratings = { rating: jest.fn().mockResolvedValue(null) };
const moex = { bondYield: jest.fn().mockResolvedValue(null) };
const service = new BondService(
const service = new BondCatalogService(
access as unknown as TbankAccessService,
tbank as unknown as TbankClient,
);
const market = new BondMarketService(
service,
tbank as unknown as TbankClient,
);
const income = new BondIncomeService(
service,
tbank as unknown as TbankClient,
portfolios as unknown as PortfolioService,
market,
moex as unknown as MoexClient,
ratings as unknown as NcrRatingsClient,
);
return {
market,
income,
service,
ratings,
moex,
@@ -92,7 +105,7 @@ describe('bond market use case', () => {
jest.restoreAllMocks();
});
it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => {
const { service, tbank, moex } = setup();
const { tbank, moex, market, income } = setup();
tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' });
tbank.getBondCoupons.mockResolvedValue([
{
@@ -122,9 +135,9 @@ describe('bond market use case', () => {
};
moex.bondYield.mockResolvedValue(effectiveYield);
const [metrics] = await Promise.all([
service.metrics('user', 'uid'),
service.coupons('user', 'uid'),
service.quote('user', 'uid'),
income.metrics('user', 'uid'),
income.coupons('user', 'uid'),
market.quote('user', 'uid'),
]);
expect(metrics).toMatchObject({
coupon: { value: '30', currency: 'RUB' },
@@ -142,7 +155,7 @@ describe('bond market use case', () => {
it.each(['unknown', 'currency', 'period', 'zero-price'])(
'does not invent coupon yield with %s inputs',
async (scenario) => {
const { service, tbank } = setup();
const { tbank, income } = setup();
tbank.getBondCoupons.mockResolvedValue([
{
couponDate: time('2026-10-10'),
@@ -160,14 +173,14 @@ describe('bond market use case', () => {
]);
if (scenario === 'zero-price')
tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } });
expect(await service.metrics('user', 'uid')).toMatchObject({
expect(await income.metrics('user', 'uid')).toMatchObject({
couponDate: '2026-10-10',
couponYieldPercent: null,
});
},
);
it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => {
const { service, tbank, moex } = setup();
const { tbank, moex, income } = setup();
tbank.getBond.mockResolvedValue({
...bond,
classCode: 'TQOB',
@@ -175,7 +188,7 @@ describe('bond market use case', () => {
riskLevel: 'RISK_LEVEL_LOW',
});
moex.bondYield.mockRejectedValue(new ServiceUnavailableException());
expect(await service.metrics('user', 'uid')).toMatchObject({
expect(await income.metrics('user', 'uid')).toMatchObject({
effectiveYield: null,
coupon: null,
couponType: 'COUPON_TYPE_FLOATING',
@@ -183,12 +196,12 @@ describe('bond market use case', () => {
unavailable: ['yield'],
});
tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException());
await expect(service.metrics('other', 'uid')).rejects.toBeInstanceOf(
await expect(income.metrics('other', 'uid')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('returns only the rating of the exact issue and isolates a registry outage', async () => {
const { service, ratings } = setup();
const { ratings, income } = setup();
const creditRating = {
agency: 'NCR',
value: 'AAA.ru',
@@ -198,18 +211,18 @@ describe('bond market use case', () => {
observedAt: '2026-09-10T10:00:00Z',
};
ratings.rating.mockResolvedValue(creditRating);
expect(await service.metrics('user', 'uid')).toMatchObject({
expect(await income.metrics('user', 'uid')).toMatchObject({
creditRating,
});
expect(ratings.rating).toHaveBeenCalledWith('ISIN');
ratings.rating.mockRejectedValue(new ServiceUnavailableException());
expect(await service.metrics('other', 'uid')).toMatchObject({
expect(await income.metrics('other', 'uid')).toMatchObject({
creditRating: null,
unavailable: ['rating'],
});
});
it('opens concurrent sections within one upstream metadata request budget', async () => {
const { service, tbank } = setup();
const { service, tbank, market, income } = setup();
tbank.getBond
.mockReset()
.mockResolvedValueOnce(bond)
@@ -219,17 +232,17 @@ describe('bond market use case', () => {
await expect(
Promise.all([
service.getByTicker('user', 'BOND'),
service.quote('user', 'uid'),
service.candles('user', 'uid'),
service.coupons('user', 'uid'),
service.metrics('user', 'uid'),
market.quote('user', 'uid'),
market.candles('user', 'uid'),
income.coupons('user', 'uid'),
income.metrics('user', 'uid'),
]),
).resolves.toHaveLength(5);
expect(tbank.getBond).toHaveBeenCalledTimes(1);
expect(tbank.findBonds).toHaveBeenCalledTimes(1);
});
it('searches thirty issues without a separate ticker lookup for each result', async () => {
const { service, tbank } = setup();
const { service, tbank, market, income } = setup();
const issues = Array.from({ length: 30 }, (_, index) => ({
...bond,
uid: `uid-${index}`,
@@ -246,15 +259,15 @@ describe('bond market use case', () => {
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
await service.getByTicker('user', 'BOND-0');
await Promise.all([
service.quote('user', 'uid-0'),
service.candles('user', 'uid-0'),
service.coupons('user', 'uid-0'),
market.quote('user', 'uid-0'),
market.candles('user', 'uid-0'),
income.coupons('user', 'uid-0'),
]);
expect(tbank.getBond).toHaveBeenCalledTimes(30);
expect(tbank.findBonds).toHaveBeenCalledTimes(2);
});
it('refreshes current nominal and accrued interest after the metadata TTL', async () => {
const { service, tbank } = setup();
const { service, tbank, market } = setup();
await service.get('user', 'uid');
await jest.advanceTimersByTimeAsync(30_001);
tbank.getBond.mockResolvedValue({
@@ -262,37 +275,37 @@ describe('bond market use case', () => {
nominal: money('400'),
aciValue: money('3'),
});
expect(await service.quote('user', 'uid')).toMatchObject({
expect(await market.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
dirtyPrice: { value: '395.5' },
});
expect(tbank.getBond).toHaveBeenCalledTimes(2);
});
it('keeps late metadata responses within their original connection generation', async () => {
const { service, tbank, changeGeneration } = setup();
const { tbank, changeGeneration, market } = setup();
let resolveOld: ((value: typeof bond) => void) | undefined;
tbank.getBond.mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
);
const oldQuote = service.quote('user', 'uid');
const oldQuote = market.quote('user', 'uid');
changeGeneration();
tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') });
expect(await service.quote('user', 'uid')).toMatchObject({
expect(await market.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
});
assert.ok(resolveOld);
resolveOld(bond);
await oldQuote;
expect(await service.quote('user', 'uid')).toMatchObject({
expect(await market.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' },
});
expect(tbank.getBond).toHaveBeenCalledTimes(2);
});
it('keeps percent quotes separate from money and uses current amortized nominal', async () => {
const { service } = setup();
expect(await service.quote('user', 'uid')).toMatchObject({
const { market } = setup();
expect(await market.quote('user', 'uid')).toMatchObject({
pricePercent: '98.125',
cleanPrice: { value: '490.625', currency: 'RUB' },
aci: { value: '12', currency: 'RUB' },
@@ -302,9 +315,9 @@ describe('bond market use case', () => {
});
});
it('does not invent dirty prices or mix currencies when accrued interest is absent or mismatched', async () => {
const { service, tbank } = setup();
const { tbank, market } = setup();
tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined });
expect(await service.quote('user', 'uid')).toMatchObject({
expect(await market.quote('user', 'uid')).toMatchObject({
aci: null,
dirtyPrice: null,
});
@@ -312,11 +325,11 @@ describe('bond market use case', () => {
...bond,
aciValue: money('12', 'usd'),
});
expect(await service.quote('other', 'uid')).toMatchObject({
expect(await market.quote('other', 'uid')).toMatchObject({
dirtyPrice: null,
});
tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined });
expect(await service.quote('third', 'uid')).toMatchObject({
expect(await market.quote('third', 'uid')).toMatchObject({
pricePercent: '98.125',
cleanPrice: null,
dirtyPrice: null,
@@ -383,8 +396,8 @@ describe('bond market use case', () => {
);
});
it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => {
const { service, tbank } = setup();
const history = await service.candles('user', 'uid', 'all');
const { tbank, market } = setup();
const history = await market.candles('user', 'uid', 'all');
expect(history.currency).toBeNull();
expect(history.candles.map((c) => c.close)).toEqual(['99', '100']);
for (const [, , from, to] of tbank.getCandles.mock.calls as [
@@ -398,7 +411,7 @@ describe('bond market use case', () => {
...bond,
first_1dayCandleDate: undefined,
});
await expect(service.candles('other', 'uid', 'all')).rejects.toBeInstanceOf(
await expect(market.candles('other', 'uid', 'all')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
@@ -419,7 +432,7 @@ describe('bond market use case', () => {
});
});
it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => {
const { service, tbank, portfolios } = setup();
const { tbank, portfolios, income } = setup();
const holding = {
instrumentUid: 'uid',
type: 'BOND',
@@ -466,7 +479,7 @@ describe('bond market use case', () => {
payOneBond: money('10'),
},
]);
const result = await service.coupons('user', 'uid');
const result = await income.coupons('user', 'uid');
expect(
result.coupons.find((c) => c.number === '1')?.estimatedAmount,
).toEqual({ value: '20', currency: 'RUB' });
@@ -477,7 +490,7 @@ describe('bond market use case', () => {
).toBe(true);
expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull();
expect(result.positionIsStale).toBe(true);
expect(await service.position('user', 'uid')).toMatchObject({
expect(await income.position('user', 'uid')).toMatchObject({
quantity: '2',
currentValue: { value: '981.25' },
});
@@ -0,0 +1,10 @@
import { formatDecimal } from '../../../../../common/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase();
return value && currency
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
@@ -9,22 +9,23 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { BondService } from './bond.service';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service';
import {
BondCandlesDto,
BondMetricsDto,
BondCandlesQueryDto,
BondCouponsDto,
BondDto,
BondTickerParamsDto,
BondMetricsDto,
BondPositionDto,
BondQuoteDto,
BondSearchQueryDto,
BondSearchResultDto,
BondTickerParamsDto,
} from './bond.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
@ApiTags('bonds')
@ApiProblemResponses()
@@ -32,14 +33,18 @@ import {
@UseGuards(JwtAuthGuard)
@Controller('bonds')
export class BondController {
constructor(private readonly bonds: BondService) {}
constructor(
private readonly bonds: BondCatalogService,
private readonly market: BondMarketService,
private readonly income: BondIncomeService,
) {}
@Get(':uid/metrics')
@ApiOkResponse({ type: BondMetricsDto })
metrics(
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.metrics(request.user.sub, uid);
return this.income.metrics(request.user.sub, uid);
}
@Get()
@ApiOkResponse({ type: [BondSearchResultDto] })
@@ -71,7 +76,7 @@ export class BondController {
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.quote(request.user.sub, uid);
return this.market.quote(request.user.sub, uid);
}
@Get(':uid/candles')
@ApiOkResponse({ type: BondCandlesDto })
@@ -80,7 +85,7 @@ export class BondController {
@Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: BondCandlesQueryDto,
) {
return this.bonds.candles(request.user.sub, uid, query.period);
return this.market.candles(request.user.sub, uid, query.period);
}
@Get(':uid/position')
@ApiOkResponse({ type: BondPositionDto })
@@ -88,7 +93,7 @@ export class BondController {
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.position(request.user.sub, uid);
return this.income.position(request.user.sub, uid);
}
@Get(':uid/coupons')
@ApiOkResponse({ type: BondCouponsDto })
@@ -96,6 +101,6 @@ export class BondController {
@Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string,
) {
return this.bonds.coupons(request.user.sub, uid);
return this.income.coupons(request.user.sub, uid);
}
}
@@ -1,13 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../common/money.dto';
import { MoneyDto } from '../../../../../common/money.dto';
import {
StockCandlesDto,
StockCandlesQueryDto,
StockPositionDto,
StockSearchQueryDto,
StockTickerParamsDto,
StockCandlesQueryDto,
StockCandlesDto,
StockPositionDto,
} from './stock.dto';
} from '../../stocks/presentation/stock.dto';
export class BondSearchQueryDto extends StockSearchQueryDto {}
export class BondTickerParamsDto extends StockTickerParamsDto {}
export class BondCandlesQueryDto extends StockCandlesQueryDto {}
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { MoexModule } from '../../../integrations/moex/moex.module';
import { NcrModule } from '../../../integrations/ncr/ncr.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { BondIncomeService } from './bonds/application/bond-income.service';
import { BondMarketService } from './bonds/application/bond-market.service';
import { BondController } from './bonds/presentation/bond.controller';
import { BondCatalogService } from './bonds/application/bond-catalog.service';
import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case';
import { StockIncomeService } from './stocks/application/stock-income.service';
import { StockMarketService } from './stocks/application/stock-market.service';
import { StockController } from './stocks/presentation/stock.controller';
import { StockCatalogService } from './stocks/application/stock-catalog.service';
@Module({
imports: [AuthModule, TbankModule, MoexModule, NcrModule, PortfolioModule],
controllers: [StockController, BondController],
providers: [
BondIncomeService,
BondMarketService,
GetStockCompanyUseCase,
StockIncomeService,
StockMarketService,
StockCatalogService,
BondCatalogService,
],
})
export class InstrumentsModule {}
@@ -0,0 +1,94 @@
import { Injectable } from '@nestjs/common';
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
import { optional } from '../../application/instrument-market';
import { StockCompanyDto, StockMetricDto } from '../presentation/stock.dto';
import { StockCatalogService } from './stock-catalog.service';
@Injectable()
export class GetStockCompanyUseCase {
constructor(
private readonly catalog: StockCatalogService,
private readonly tbank: TbankClient,
) {}
execute(userId: string, uid: string): Promise<StockCompanyDto> {
return this.catalog.cached(userId, `company:${uid}`, async (token) => {
const share = await this.catalog.share(token, uid);
const unavailable: string[] = [];
const assetUid = share.assetUid;
if (!assetUid)
return {
description: null,
metrics: [],
unavailable,
observedAt: new Date().toISOString(),
};
const [description, fundamentals] = await Promise.all([
optional(
() => this.tbank.getAssetDescription(token, assetUid),
'description',
unavailable,
),
optional(
() => this.tbank.getFundamentals(token, assetUid),
'fundamentals',
unavailable,
),
]);
return {
description,
metrics: metrics(fundamentals, share.sector),
unavailable,
observedAt: new Date().toISOString(),
};
});
}
}
function metrics(
values: TbankFundamentals | null,
sector?: string,
): StockMetricDto[] {
if (!values) return [];
const bank = sector?.toLowerCase() === 'financial';
const selection: [
keyof TbankFundamentals,
StockMetricDto['unit'],
StockMetricDto['period'],
][] = [
['marketCapitalization', 'money', null],
['peRatioTtm', 'ratio', 'TTM'],
...(bank
? ([
['priceToBookTtm', 'ratio', 'TTM'],
['roe', 'percent', null],
] as typeof selection)
: ([
['revenueTtm', 'money', 'TTM'],
['ebitdaTtm', 'money', 'TTM'],
] as typeof selection)),
['netIncomeTtm', 'money', 'TTM'],
...(!bank
? ([
['totalDebtMrq', 'money', 'MRQ'],
['netDebtToEbitda', 'ratio', null],
] as typeof selection)
: []),
];
return selection.flatMap(([key, unit, period]) => {
const value = values[key];
const currency = values.currency?.toUpperCase() || null;
return typeof value === 'number' &&
Number.isFinite(value) &&
(unit !== 'money' || currency)
? [
{
key,
value,
unit,
period,
currency: unit === 'money' ? currency : null,
},
]
: [];
});
}
@@ -0,0 +1,200 @@
import {
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import {
TbankAccessService,
TbankClient,
} from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { optional, timestamp } from '../../application/instrument-market';
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
type IdentifiedShare = TbankShare & { uid: string };
@Injectable()
export class StockCatalogService {
private readonly cache = new LRUCache<string, Promise<object>>({
max: 300,
ttl: 30_000,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
) {}
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (token) => {
const found = await this.tbank.findShares(token, query);
const shares = selectShares(found, query).slice(0, 30);
if (!shares.length) return [];
// One market-data request for the list; failed enrichment must not hide a catalog result.
const [details, prices] = await Promise.all([
Promise.all(
shares.map(
async (share) =>
(await optional(
() =>
this.get(userId, share.uid).catch((error: unknown) => {
if (error instanceof NotFoundException) return stock(share);
throw error;
}),
'details',
[],
)) ?? stock(share),
),
),
optional(
() =>
this.tbank.getLastPrices(
token,
shares.map((share) => share.uid),
),
'prices',
[],
),
]);
return details.map((detail, index) => {
const short = stock(shares[index]);
const last = prices?.find(
(price) => price.instrumentUid === detail.instrumentUid,
);
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
price:
last?.price && detail.currency
? {
currency: detail.currency,
value: formatDecimal(decimal(last.price)),
}
: null,
priceAt: timestamp(last?.time),
};
});
});
}
get(userId: string, uid: string): Promise<StockDto> {
return this.cached(userId, `share:${uid}`, async (token) => {
const result = stock(await this.share(token, uid));
if (
!result.ticker ||
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
)
return result;
try {
const match = await this.tickerMatch(userId, result.ticker);
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
} catch (error) {
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
if (
!(error instanceof NotFoundException) &&
!(error instanceof ConflictException) &&
!(error instanceof ServiceUnavailableException)
)
throw error;
}
return result;
});
}
async getByTicker(userId: string, ticker: string): Promise<StockDto> {
const match = await this.tickerMatch(userId, ticker);
return {
...(await this.get(userId, match.uid)),
slug: ticker.trim().toUpperCase(),
};
}
private tickerMatch(
userId: string,
ticker: string,
): Promise<IdentifiedShare> {
const normalized = ticker.trim().toUpperCase();
return this.cached(userId, `ticker:${normalized}`, async (token) => {
const found = await this.tbank.findShares(token, normalized);
const matches = selectShares(
found.filter(
(item) => item.ticker?.trim().toUpperCase() === normalized,
),
normalized,
);
if (!matches.length) throw new NotFoundException();
if (matches.length > 1)
throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' });
return matches[0];
});
}
async share(token: string, uid: string): Promise<IdentifiedShare> {
const share = await this.tbank.getShare(token, uid);
if (!share || share.uid !== uid) throw new NotFoundException();
return { ...share, uid };
}
cached<T extends object>(
userId: string,
key: string,
operation: (token: string) => Promise<T>,
): Promise<T> {
return this.access.withConnection(userId, ({ token, generation }) => {
const cacheKey = JSON.stringify([userId, generation, key]);
const cached = this.cache.get(cacheKey);
if (cached) return cached as Promise<T>;
const pending = operation(token);
this.cache.set(cacheKey, pending);
return pending.catch((error) => {
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
throw error;
});
});
}
}
function selectShares(found: TbankShare[], query: string): IdentifiedShare[] {
const seen = new Set<string>();
return found
.filter(
(share): share is IdentifiedShare =>
Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE',
)
.sort(
(a, b) =>
Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') ||
a.uid.localeCompare(b.uid),
)
.filter((share) => {
// A missing ISIN must not collapse unrelated catalog instruments.
const key = share.isin?.trim()
? `isin:${share.isin.trim().toUpperCase()}`
: `uid:${share.uid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function stock(share: IdentifiedShare): StockDto {
return {
instrumentUid: share.uid,
slug: share.uid,
name: share.name?.trim() || share.ticker || share.uid,
ticker: share.ticker?.trim() || null,
isin: share.isin?.trim() || null,
classCode: share.classCode?.trim() || null,
realExchange: share.realExchange || null,
apiTradeAvailable: share.apiTradeAvailableFlag ?? null,
logoName: share.brand?.logoName?.trim() || null,
currency: share.currency?.toUpperCase() || null,
shareType: share.shareType || null,
sector: share.sector || null,
lot: share.lot && share.lot > 0 ? share.lot : null,
historyFrom: timestamp(share.first_1dayCandleDate),
};
}
@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import {
decimalFromString,
formatDecimal,
multiply,
} from '../../../../../common/decimal';
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { PortfolioService } from '../../../portfolio/portfolio.api';
import {
moscowDay,
optional,
timestamp,
} from '../../application/instrument-market';
import { stockPosition } from './stock-position';
import {
StockDividendDto,
StockDividendsDto,
StockPositionDto,
} from '../presentation/stock.dto';
import { StockCatalogService } from './stock-catalog.service';
@Injectable()
export class StockIncomeService {
constructor(
private readonly catalog: StockCatalogService,
private readonly tbank: TbankClient,
private readonly portfolios: PortfolioService,
) {}
async position(userId: string, uid: string): Promise<StockPositionDto> {
return this.catalog.cached(userId, `position:${uid}`, async () =>
stockPosition(await this.portfolios.getAll(userId), uid),
);
}
dividends(userId: string, uid: string): Promise<StockDividendsDto> {
return this.catalog.cached(userId, `dividends:${uid}`, async (token) => {
await this.catalog.share(token, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.tbank.getDividends(token, uid, from, to),
optional(() => this.position(userId, uid), 'position', unavailable),
]);
const quantity =
position &&
position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => {
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
const paymentDate =
timestamp(event.paymentDate)?.slice(0, 10) ?? null;
const lastBuyDate =
timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
const currency = event.dividendNet?.currency?.toUpperCase();
const perShare =
currency && event.dividendNet
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
: null;
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
const eventDate = paymentDate ?? recordDate;
const timing: StockDividendDto['timing'] = eventDate
? eventDate >= today
? 'upcoming'
: 'past'
: 'undated';
const estimatedAmount =
quantity &&
perShare &&
recordDate &&
recordDate >= today &&
timing === 'upcoming'
? {
currency: perShare.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)),
),
}
: null;
return {
perShare,
recordDate,
lastBuyDate,
paymentDate,
timing,
estimatedAmount,
};
})
.sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? ''));
return {
dividends,
from,
to,
observedAt: new Date().toISOString(),
positionAt: quantity ? (position?.observedAt ?? null) : null,
estimateUnavailable: unavailable.length > 0,
positionIsStale: position?.isStale ?? false,
};
});
}
}
@@ -0,0 +1,170 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import {
decimalFromString,
divide,
formatDecimal,
multiply,
subtract,
} from '../../../../../common/decimal';
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal';
import {
optional,
previousClose,
timestamp,
} from '../../application/instrument-market';
import {
StockCandlesDto,
StockCandlesQueryDto,
StockQuoteDto,
} from '../presentation/stock.dto';
const dayMs = 86_400_000;
import { StockCatalogService } from './stock-catalog.service';
@Injectable()
export class StockMarketService {
constructor(
private readonly catalog: StockCatalogService,
private readonly tbank: TbankClient,
) {}
quote(userId: string, uid: string): Promise<StockQuoteDto> {
return this.catalog.cached(userId, `quote:${uid}`, async (token) => {
const share = await this.catalog.share(token, uid);
const unavailable: string[] = [];
const [last, tradingStatus] = await Promise.all([
optional(
() => this.tbank.getLastPrice(token, uid),
'price',
unavailable,
),
optional(
() => this.tbank.getTradingStatus(token, uid),
'status',
unavailable,
),
]);
const priceAt = timestamp(last?.time);
const currency = share.currency?.toUpperCase();
const price =
last?.price && currency
? { currency, value: formatDecimal(decimal(last.price)) }
: null;
const previous = priceAt
? await optional(
async () => {
const end = new Date(priceAt);
return previousClose(
await this.tbank.getCandles(
token,
uid,
new Date(end.valueOf() - 20 * dayMs),
end,
),
priceAt,
);
},
'comparison',
unavailable,
)
: null;
const previousPrice =
previous?.close && currency
? { currency, value: formatDecimal(decimal(previous.close)) }
: null;
const difference =
price && previousPrice
? subtract(
decimalFromString(price.value),
decimalFromString(previousPrice.value),
)
: null;
return {
price,
priceAt,
tradingStatus,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
previousClose: previousPrice,
change:
difference && currency
? { currency, value: formatDecimal(difference) }
: null,
changePercent:
difference &&
previousPrice &&
decimalFromString(previousPrice.value).value > 0n
? formatDecimal(
divide(
multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value),
6,
),
)
: null,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
candles(
userId: string,
uid: string,
period: StockCandlesQueryDto['period'] = 'month',
): Promise<StockCandlesDto> {
return this.catalog.cached(
userId,
`candles:${uid}:${period}`,
async (token) => {
const share = await this.catalog.share(token, uid);
const historyFrom = timestamp(share.first_1dayCandleDate);
const to = new Date();
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
);
if (period === 'all') {
if (!historyFrom)
throw new ServiceUnavailableException({
code: 'STOCK_HISTORY_START_UNAVAILABLE',
});
from.setTime(Date.parse(historyFrom));
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
from.setTime(Date.parse(historyFrom));
const collected = new Map<string, StockCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf());
for (const candle of await this.tbank.getCandles(
token,
uid,
new Date(start),
new Date(end),
)) {
const time = timestamp(candle.time);
if (
time &&
candle.close &&
Date.parse(time) >= from.valueOf() - dayMs &&
Date.parse(time) <= to.valueOf()
)
collected.set(time, {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
});
}
start = end;
}
return {
candles: [...collected.values()].sort((a, b) =>
a.time.localeCompare(b.time),
),
currency: share.currency?.toUpperCase() || null,
historyFrom,
observedAt: new Date().toISOString(),
};
},
);
}
}
@@ -1,5 +1,8 @@
import {
PortfolioDto,
PortfolioPositionDto,
} from '../../../portfolio/presentation/portfolio.dto';
import { stockPosition } from './stock-position';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
const money = (value: string, currency = 'RUB') => ({ value, currency });
const row = (
@@ -0,0 +1,5 @@
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position';
export function stockPosition(portfolio: PortfolioDto, uid: string) {
return instrumentPosition(portfolio, uid, 'STOCK');
}
@@ -1,16 +1,20 @@
jest.mock('./tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import {
ServiceUnavailableException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { StockService, previousClose } from './stock.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
import { PortfolioService } from './portfolio.service';
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
import { GetStockCompanyUseCase } from './get-stock-company.use-case';
import { StockIncomeService } from './stock-income.service';
import { StockMarketService } from './stock-market.service';
import { StockCatalogService } from './stock-catalog.service';
import { previousClose } from '../../application/instrument-market';
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) });
const share = {
uid: 'uid',
@@ -48,12 +52,27 @@ function setup() {
isStale: false,
}),
};
const service = new StockService(
const service = new StockCatalogService(
access as unknown as TbankAccessService,
tbank as unknown as TbankClient,
);
const market = new StockMarketService(
service,
tbank as unknown as TbankClient,
);
const income = new StockIncomeService(
service,
tbank as unknown as TbankClient,
portfolios as unknown as PortfolioService,
);
const companyInfo = new GetStockCompanyUseCase(
service,
tbank as unknown as TbankClient,
);
return {
market,
income,
companyInfo,
service,
tbank,
portfolios,
@@ -298,11 +317,11 @@ describe('stock market use case', () => {
expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]);
});
it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => {
const { service, tbank } = setup();
const { tbank, market } = setup();
tbank.getCandles.mockResolvedValue([
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: false },
]);
const result = await service.candles('user', 'uid', 'all');
const result = await market.candles('user', 'uid', 'all');
expect(tbank.getCandles).toHaveBeenCalledWith(
'user',
'uid',
@@ -313,11 +332,11 @@ describe('stock market use case', () => {
expect(result.candles[0].isComplete).toBe(false);
});
it('does not manufacture zero fundamentals and isolates optional failures', async () => {
const { service, tbank } = setup();
const { tbank, companyInfo } = setup();
tbank.getAssetDescription.mockRejectedValue(
new ServiceUnavailableException(),
);
const result = await service.company('user', 'uid');
const result = await companyInfo.execute('user', 'uid');
expect(result.description).toBeNull();
expect(result.unavailable).toEqual(['description']);
expect(result.metrics.map((item) => item.key)).toEqual([
@@ -336,7 +355,7 @@ describe('stock market use case', () => {
expect(tbank.getShare).toHaveBeenCalledTimes(3);
});
it('keeps public dividends when there is no holding or position retrieval fails', async () => {
const { service, tbank, portfolios } = setup();
const { tbank, portfolios, income } = setup();
portfolios.getAll.mockRejectedValue(new ServiceUnavailableException());
tbank.getDividends.mockResolvedValue([
{
@@ -345,7 +364,7 @@ describe('stock market use case', () => {
paymentDate: time('2026-10-27'),
},
]);
const result = await service.dividends('user', 'uid');
const result = await income.dividends('user', 'uid');
expect(result.estimateUnavailable).toBe(true);
expect(result.dividends[0]).toMatchObject({
perShare: { currency: 'RUB', value: '32.88' },

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