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) | | Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) |
| Prisma/schema/query/persistence, transaction, migration, atomic write | [Data](conventions/backend-data.md) | | Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) |
| Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) | | Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) |
| JWT, cookie, login/refresh/logout, guard/access, token encryption, sensitive logs | [Security](conventions/security-auth.md) | | JWT, cookie, login/refresh/logout, guard/access, token encryption, sensitive logs | [Security](conventions/security-auth.md) |
| T-Bank/gRPC/proto, upstream retry/rate limit/cache, MoexClient | [Integrations](conventions/backend-integrations.md) | | External client/integration boundary; T-Bank/gRPC/proto, retry/rate limit/cache | [Integrations](conventions/backend-integrations.md) |
| Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) | | Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) |
| Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) | | Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) |
| Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [общий contract workflow](../../docs/development/runtime-openapi-verification.md) | | Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [общий contract workflow](../../docs/development/runtime-openapi-verification.md) |
+194 -26
View File
@@ -1,39 +1,207 @@
# Backend architecture # Backend architecture
## Выбор владельца ## Module-first organization
Найди существующий use case, его collaborators и tests до создания module/service. Backend — domain-oriented modular monolith с лёгким разделением
Сохрани ответственность в текущем module, если новая возможность относится к нему. по ответственности. `src/modules` группирует код по бизнес-возможности: `auth`, `banking/bank-cards`, области `investments`.
Module — граница связного набора providers, а не папка для каждого endpoint. В инвестициях отдельные Nest modules владеют `brokerage-accounts`, `portfolio`,
`operations`, `income`, `upcoming-events`, `instruments`, `tbank-connection`.
`instruments/stocks` и `instruments/bonds` — slices внутри одного модуля.
| Решение | Owner | ### Progressive structure
Три уровня организации: business module → responsibility layer → technical grouping
при реальной необходимости. Компактный module с понятными controller/DTO/service
может оставаться плоским. Ориентир 5–7 файлов не является порогом: учитывай число
ответственностей, наличие domain/repository/use cases и удобство просмотра каталога.
Когда в module смешаны несколько services, transport, расчёты и persistence,
группируй файлы по ответственности:
```text
feature/
├── feature.module.ts
├── feature.api.ts # только при существующих внешних потребителях
├── presentation/ # HTTP, DTO, guards
├── application/ # сценарии, orchestration, application input
├── domain/ # чистые бизнес-правила и расчёты
└── infrastructure/ # persistence, framework и boundary mapping
```
Создавай только нужные слои. В корне сложного module оставляй composition и
публичную boundary; production code и colocated tests размещай в слое владельца.
Внутри business slice действует то же правило, без отдельного Nest module ради папок.
`controllers/`, `dto/`, `services/`, `use-cases/`, `persistence/`, `mappers/`
допустимы только внутри слоя, когда множество однотипных файлов мешает навигации.
Для одного controller или пары DTO дополнительная поддиректория не нужна.
Пустые папки, новые barrels, CQRS и interfaces не требуются для этой организации.
В текущем коде слои используют `auth`, `bank-cards`, `portfolio`, `operations`
и slices `instruments/stocks`, `instruments/bonds`. Общие application helpers
инструментов находятся в `instruments/application`; `instruments.module.ts`
компонует оба slice. `stock-position.ts` и `instrument-position.ts` формируют
результат из portfolio DTO; `instrument-market.ts` обрабатывает внешние данные
и ошибки Nest. Это application code, а не независимая domain model.
`brokerage-accounts`, `income`, `upcoming-events`, `tbank-connection` остаются
компактными плоскими modules. Наличие tests само по себе не требует слоёв.
## Поиск и создание файла
При работе над feature проходи владельцев в порядке:
1. Найди business module.
2. Прочитай его `*.module.ts` (для slice — composition родительского module).
3. Проверь `presentation/`: controller и transport contract.
4. Проследи вызовы в `application/`: сценарии и services.
5. Проверь используемые правила в `domain/`.
6. Проверь `infrastructure/`: repository и технические реализации.
7. По обнаруженным зависимостям переходи в `integrations/` и `common/`.
В плоском module классифицируй файлы по suffix и следуй их ответственности.
Отсутствующий слой пропускай. Если business owner известен, начинай локально,
а не с repository-wide search.
**Rule of locality:** code that belongs to one business capability stays inside
that capability unless it is cross-cutting. Например, расчёты карт принадлежат
bank-cards/domain, даже если реализованы обычными функциями.
Перед созданием файла определи:
1. Какой business module владеет изменением? Сначала owner, затем common при
подтверждённой общей потребности.
2. Какова ответственность: HTTP/DTO/guard → presentation; сценарий/orchestration →
application; чистое business rule/calculation → domain; persistence или
module-specific technical implementation → infrastructure; внешний протокол → integrations.
3. Какой approved suffix из таблицы соответствует этой ответственности?
4. Какой слой владеет файлом? В компактном плоском module сохрани размещение рядом
с owner; в слоистом используй соответствующий слой.
5. Достаточно ли colocate файл рядом с существующими? Level 3 grouping создавай
только при заметном улучшении навигации по множеству однотипных файлов.
Если owner или роль не определены, сначала установи ответственность. Generic
`utils.ts`, `helpers.ts`, `manager.ts`, `handler.ts` и arbitrary directory её не заменяют.
## Naming & File Suffix Convention
Имена файлов — `kebab-case`. File suffix определяет responsibility, directory
определяет architectural layer. Размещение в таблице предпочтительно для
слоистого module; компактный плоский module сохраняет файлы в корне.
Cross-module consumers во всех строках ограничены публичной границей ниже.
| Suffix | Когда использовать | Где | Кто зависит | Когда не использовать |
| --- | --- | --- | --- | --- |
| `*.controller.ts` | HTTP endpoint / transport | presentation | Nest wiring | DB, расчёты, SDK, сложная оркестрация |
| `*.dto.ts` | Request/response, validation, OpenAPI | presentation | Controller; application boundary при совпадении shape | Универсальная domain model |
| `*.use-case.ts` | Один самостоятельный application scenario | application | Controller, другой orchestrator | Prisma queries, Request/Response, SDK implementation |
| `*.service.ts` | Конкретная cohesive reusable capability | рядом с owner; application/domain/integration по роли | Сценарии и services; domain — только от чистого domain service | Свалка несвязанных сценариев |
| `*.repository.ts` | Persistence queries и согласованные DB reads/writes | infrastructure | Application service/use case | Domain calculations, HTTP request, external transport |
| `*.client.ts` | Низкоуровневый внешний HTTP/gRPC/SDK | integrations или infrastructure | Application orchestration, boundary mapper | Пользовательский lifecycle и domain rules |
| `*.mapper.ts` | Meaningful conversion между моделями | обычно infrastructure у преобразуемой boundary | Код соответствующей boundary | Формальное копирование нескольких одинаковых полей |
| `*.policy.ts` | Cohesive decision rule | domain/application/integration по входным данным | Владелец правила; domain — от чистой policy | Persistence, transport, generic strategy |
| `*.guard.ts` | Nest guard | presentation | Nest/controller | Основная account authorization и финансовые правила |
| `*.strategy.ts` | Framework strategy, например JWT | infrastructure | Framework wiring | Обычный service без strategy contract |
| `*.module.ts` | Nest imports/providers/controllers/exports | module root | Composition root, importing module | Бизнес-логика |
| `*.types.ts` | Малая группа связанных TS types | рядом с owner | Потребители модели на разрешённых слоях | Глобальные types всего module, runtime validation |
| `*.api.ts` | Явный список публичных exports | root конкретного module/integration | Другие modules | DTO/client/service implementation или generic API logic |
| `*-input.ts` | Application input: нормализация и проверка формы, отличающейся от wire DTO | application | Application code | Копия DTO под новым именем; вариант `*.input.ts` |
| `*.calculations.ts`, `*.rules.ts` или предметное имя | Чистые бизнес-расчёты, value semantics | domain | Application и domain | Nest/Prisma/HTTP/SDK зависимости |
| `*.spec.ts` | Существующая unit/component проверка поведения | рядом с owner/slice | Test runner | Архитектурные проверки папок и trivial wiring |
`bank-card-input.ts` использует выбранный `*-input.ts`: переводит Money в
копейки, нормализует значения и проверяет application input. DTO не дублируется
отдельным Input type. `rub-money.ts` — domain-правила RUB; `bond-money.mapper.ts`
— преобразование ответа клиента. Эти роли не определяются словом money в имени.
Технические framework suffixes (`*.filter.ts`, `*.middleware.ts`,
`*.decorator.ts`, `*.config.ts`, `*.validation.ts`) сохраняют соответствующие
существующие роли в common/config. `tbank.crypto.ts` — локальная реализация
шифрования токена; имя не является разрешением создавать generic crypto layer.
HTTP/e2e naming и расположение — в [Testing](backend-testing.md).
Без зафиксированной необходимости не вводи `*.helper.ts`, `*.utils.ts`,
`*.manager.ts`, `*.processor.ts`, `*.handler.ts`, `*.provider.ts`, `*.factory.ts`.
Новая naming convention сначала получает конкретную роль и обоснование здесь.
Для data access выбран только `*.repository.ts`, не persistence/store/dao.
## Публичные границы
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
модулями providers, функции и типы. Внешний production import идёт через этот
API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный
публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma.
Внутри модуля используй прямые локальные импорты.
Tests могут импортировать внутренний owner для проверки поведения и mocks.
Новый export требует текущего потребителя; controller и persistence остаются
приватными. Не добавляй `index.ts` на каждом уровне.
Каждый provider регистрируется у одного владельца. Потребители импортируют
его module, а не повторяют provider: это сохраняет единственный кэш и лимитер.
`AuthModule` экспортирует guard; наличие JWT не заменяет account access check.
- `income` читает операции через `operations.api.ts`.
- `instruments` получает личные позиции через `portfolio.api.ts`.
- `tbank-connection` использует T-Bank integration и публичный сброс портфеля.
- `portfolio` и другие инвестиционные модули используют `TbankModule`, который
не зависит от business modules. Так управление подключением не образует цикл.
- `investment-calendar.ts` и `account-reference.dto.ts` разделяются только
инвестиционными областями; это не глобальная библиотека бизнес-правил.
## Ответственность и направление зависимостей
| Владелец | Ответственность |
| --- | --- | | --- | --- |
| Route, request/response mapping, status/cookie | Controller, [HTTP rules](backend-http-api.md) | | Controller / presentation | Route, verified identity, transport DTO, validation, status/cookie; вызов сценария |
| Business use case, orchestration, domain validation | Feature service | | Use case / application service | Оркестрация, business validation, доступ и согласованность результата |
| Input shape/format | DTO + configured ValidationPipe | | Domain | Чистые правила и расчёты; без NestJS, Prisma, HTTP/Swagger и внешних SDK |
| Атомарность/DB query | Сервис use case через PrismaService, [Data](backend-data.md) | | Persistence / infrastructure | Запросы, транзакции, технические реализации границ |
| Внешний protocol, deadline/retry/limiting | Existing client, [Integrations](backend-integrations.md) | | `src/integrations/{tbank,moex,ncr}` | Внешние протоколы, декодирование, retry/deadline/cache; без зависимостей от HTTP DTO business modules |
| Чистое вычисление/mapper без lifecycle и DI | Обычная функция рядом с domain owner | | `src/prisma` | Общий DB provider и lifecycle; не generic repository |
| Bootstrap/config/global cross-cutting concern | AppModule, config, существующий common owner |
**Безопасный путь:** расширь текущий owner; выделяй collaborator, когда он скрывает Основной вызов: presentation → application → domain; application использует
самостоятельный инвариант или координацию. Не вводи CQRS, generic repository, конкретные infrastructure providers, зарегистрированные module. Infrastructure
interface с одной реализацией или base controller только ради шаблона Nest. может использовать application types/input и domain rules; новые interfaces
Не превращай common в хранилище бизнес-логики и не переноси frontend модели сюда. ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
по [Data](backend-data.md); use case получает данные через repository и сам
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
DTO остаются транспортными runtime classes; существующее использование DTO
на application boundary допустимо по [HTTP API](backend-http-api.md).
## Существующие seams Сохраняй существующий error contract: HTTP exceptions остаются на application/
transport boundary. Чистому расчёту не нужен новый framework ошибок.
Точные ограничения — [HTTP](backend-http-api.md), [Data](backend-data.md),
[Security](security-auth.md), [Integrations](backend-integrations.md).
`TbankConnectionModule` уже объединяет счета, портфель, операции, доходы и события. ## Сценарии и размер сервисов
Новый связанный use case обычно добавляется в него; создание отдельного module
требует собственной ответственности, не только нового файла.
- [BrokerageOperationsController](../../src/tbank/brokerage-operations.controller.ts) передаёт user/account/query в service, без финансовых вычислений в controller. Разделяй независимые операции, а не строки. `BankCardsService` отвечает за карты,
- [TbankAccessService](../../src/tbank/tbank-access.service.ts) владеет connection/account access и generation; новые account use cases используют его вместо копирования guard/decrypt logic. `BankOperationsService` — журнал/переводы/возвраты, `ExpenseCategoriesService` —
- [decimal.ts](../../src/tbank/decimal.ts) — пример pure domain helper; Injectable и adapter для арифметики не нужны. категории, `GetBankSummaryUseCase` — сводку. `BankCardsRepository` сохраняет
общий transaction retry и owner lookup; денежные транзакции не разрываются.
Чистые расчёты банковских карт находятся в `domain` этого модуля.
Наличие guard подтверждает session, но не владение account. При пересечении У инструментов `StockCatalogService` / `BondCatalogService` хранят идентичность
access boundary добавь [Security](security-auth.md). Ошибка upstream не должна листинга и общий кэш slice; market service — котировки/свечи, income service — выплаты/личные позиции.
обходить общий HTTP filter или превращать чужой account в доступный. `GetStockCompanyUseCase` владеет сведениями об эмитенте. Ключи, TTL и pending
promises разделяют прежний экземпляр кэша; extract не должен менять generation.
Portfolio и operations сохраняют связную координацию снимков/страниц и кэшей.
Расчёты портфеля находятся в `portfolio/domain`; преобразование брокерских
позиций — в `tbank-portfolio.mapper.ts`. `bond-money.mapper.ts` преобразует
ответ клиента и не является value object.
Выбирай обычную функцию для чистого вычисления; repository contract — только
для реальной границы. CQRS/buses/events, BaseRepository/BaseService, DI token
для каждого класса, mapper/presenter для каждого DTO и новые dependencies
не нужны для структурного переноса.
## Common
`common` содержит общий error filter, logging, API decorators, тип `Money`,
его HTTP `MoneyDto` и точную decimal-арифметику, используемые несколькими областями. Код одного
модуля принадлежит этому модулю: например, RUB/kopecks rules — банковским картам,
protobuf units/nano conversion — T-Bank integration. Domain использует
`money.types.ts`, а не Swagger DTO; это общая форма денег для карт и портфеля.
Давай функциям предметные имена; не создавай глобальные `utils.ts`/`helpers.ts`.
## Logging ## 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 вокруг инварианта | | Несколько записей обязаны выполниться вместе | Database transaction вокруг инварианта |
| Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата | | Конкурентная смена одного значения | Atomic conditional update/constraint с проверкой результата |
| Независимые reads | Не добавляй transaction ради внешнего вида | | Независимые reads | Не добавляй transaction ради внешнего вида |
| Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен | | Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен |
Размещение repository определяется [Architecture](backend-architecture.md):
в слоистом module это `infrastructure/`.
Repository — конкретный класс `*.repository.ts` у business owner, без обязательных
interface/port/adapter. Он владеет queries, includes/filtering, DB mapping и
согласованным чтением. Суффиксы `*.persistence.ts`, `*.store.ts`, `*.dao.ts`
для той же роли не используем. Nest module регистрирует repository приватно.
`BankCardsRepository.summaryData` проверяет владельца карты и получает операции
с категориями в одной read transaction. `GetBankSummaryUseCase` выбирает период,
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
не становятся domain entities: расчёт получает только нужные ему значения.
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
services (auth, lifecycle подключения, изменения карт/операций/категорий)
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
лишь набора pass-through методов. Новая самостоятельная read/write capability
получает repository method; самостоятельный use case не выполняет Prisma queries.
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для
существующих banking services. Такой callback с Prisma transaction client —
локальная техническая граница этих services, не API для domain или use cases.
Выбирай только нужные данные, сохраняй user/account ownership в запросах и Выбирай только нужные данные, сохраняй user/account ownership в запросах и
application checks. Предварительный lookup не заменяет unique constraint при race. application checks. Предварительный lookup не заменяет unique constraint при race.
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany` Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
+11 -4
View File
@@ -16,16 +16,23 @@ use case. Для route/request/response/status/error shape читай
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter | | HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
ради обхода service. Request identity берётся из verified `request.user`, не из и не выполняет финансовые расчёты или сложную оркестрацию.
body/userId, предоставленного клиентом. Access rules активируют Security. Controller вызывает application use case/service. Identity берётся из verified
`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security.
## DTO и errors ## DTO и errors
DTO — runtime class: TS interface не предоставляет validation metadata. DTO — runtime class: TS interface не предоставляет validation metadata.
`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного
module или presentation. DTO можно передать application boundary при совпадении
формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO:
например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`.
Нормализованная форма input нужна только при смысловом отличии от wire contract;
выбор suffix принадлежит [Architecture](backend-architecture.md).
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`). ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён. Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
[operations DTO](../../src/tbank/brokerage-operation.dto.ts). Swagger metadata [operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata
описывает те же required/optional/nullable/enum/range значения. описывает те же required/optional/nullable/enum/range значения.
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
@@ -40,7 +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. показывает guard, user.sub, DTO query и service delegation.
Для нового field обновляй canonical source и generated artifacts в определённом Для нового field обновляй canonical source и generated artifacts в определённом
направлении; body type frontend не исправляется вручную. направлении; body type frontend не исправляется вручную.
+24 -4
View File
@@ -3,14 +3,34 @@
## Выбор boundary ## Выбор boundary
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case
получают нормализованный результат через эти clients. Frontend не вызывает используют объявленные клиентом records; SDK clients, metadata и transport errors
остаются внутри integration. Frontend не вызывает
T-Bank и не получает credentials/proto client. Reference source — T-Bank и не получает credentials/proto client. Reference source —
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и [ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и
[connection behavior](../../../docs/integrations/tbank.md). [connection behavior](../../../docs/integrations/tbank.md).
`modules/investments/tbank-connection` — пользовательская capability чтения,
создания/замены и удаления подключения. `integrations/tbank` — технические
RPC, credentials/access и resilience. Эти владельцы не объединяются из-за имени
поставщика; низкоуровневый client не зависит от пользовательского module.
`*.client.ts` содержит transport и его retries; `*.api.ts` только перечисляет
публичные exports. Все шесть `auth/operations/portfolio/tbank/moex/ncr.api.ts`
имеют одинаковую роль программной границы, определённую в
[Architecture](backend-architecture.md). Логика, DTO definitions и HTTP routes
в эти файлы не помещаются. Type exports обозначаются `export type`.
Преобразование provider records происходит на boundary: например,
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
остаётся отдельным чистым входом integration. Domain получает внутренние
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
client или mapper для простого копирования полей. Даты/units/nano разбираются
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
Для нового account use case сначала используй TbankAccessService: access, Для нового account use case сначала используй TbankAccessService: access,
connection generation и invalid-token translation не дублируются в callers. connection generation и invalid-token translation не дублируются в callers.
Чистый mapper финансового значения остаётся рядом с domain owner. Mapper внешних финансовых значений остаётся внутри business capability,
обычно в infrastructure по [Architecture](backend-architecture.md).
## Retry, cache и protocol ## Retry, cache и protocol
@@ -30,7 +50,7 @@ cache/stale policy; semantics конкретного use case читай чер
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger. и общем HTTP boundary. Для HTTP error change читай HTTP API/Security по trigger.
Vendor proto хранится в `src/tbank/proto/contracts`; для обновления сверяй Vendor proto хранится в `src/integrations/tbank/proto/contracts`; для обновления сверяй
официальный source и влияние на loader/DTO, не редактируй read-only references. официальный source и влияние на loader/DTO, не редактируй read-only references.
Generated artifacts, если есть, создавай из source, без ручной подгонки вывода. Generated artifacts, если есть, создавай из source, без ручной подгонки вывода.
@@ -40,7 +60,7 @@ Generated artifacts, если есть, создавай из source, без р
ответственного service. Отдельный abstraction/adapter оправдан новой ответственного service. Отдельный abstraction/adapter оправдан новой
ответственностью или несовместимым protocol, не только вторым методом. ответственностью или несовместимым protocol, не только вторым методом.
[TbankClient tests](../../src/tbank/tbank.client.spec.ts) — пример controlled [TbankClient tests](../../src/integrations/tbank/tbank.client.spec.ts) — пример controlled
upstream responses. Проверь queue/deadline/retry limit, token translation, upstream responses. Проверь queue/deadline/retry limit, token translation,
cache isolation и late response для затронутых ветвей. Fixtures синтетические; cache isolation и late response для затронутых ветвей. Fixtures синтетические;
реальная сеть, секреты и пользовательские accounts не нужны. реальная сеть, секреты и пользовательские accounts не нужны.
+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, нормализация и точные суммы. - [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы.
- [AuthService tests](../../src/auth/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction. - [AuthService tests](../../src/modules/auth/application/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction.
- [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope. - [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope.
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank. - [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank.
+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) [ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md)
задают session semantics. Access хранится только в памяти frontend; refresh задают session semantics. Access хранится только в памяти frontend; refresh
передаётся httpOnly cookie. Source code конфигурации cookie — передаётся httpOnly cookie. Source code конфигурации cookie —
[AuthController](../../src/auth/auth.controller.ts); изменение flags/path требует [AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует
проверки browser/HTTP поведения, не только TypeScript. проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает Refresh rotation атомарна: один победитель, проигравший/superseded token получает
+1 -1
View File
@@ -5,7 +5,7 @@
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true, "deleteOutDir": true,
"assets": [ "assets": [
{ "include": "tbank/proto/contracts/**/*.proto", "watchAssets": true } { "include": "integrations/tbank/proto/contracts/**/*.proto", "watchAssets": true }
] ]
} }
} }
+16 -4
View File
@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { BankCardsModule } from './bank-cards/bank-cards.module'; import { randomUUID } from 'node:crypto';
import { AuthModule } from './auth/auth.module';
import { LoggingModule } from './common/logging/logging.module'; import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation'; import { Environment, validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
import { IncomeModule } from './modules/investments/income/income.module';
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
import { OperationsModule } from './modules/investments/operations/operations.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { TbankConnectionModule } from './tbank/tbank-connection.module';
@Module({ @Module({
imports: [ imports: [
@@ -51,6 +57,12 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
PrismaModule, PrismaModule,
AuthModule, AuthModule,
BankCardsModule, BankCardsModule,
BrokerageAccountsModule,
PortfolioModule,
OperationsModule,
IncomeModule,
UpcomingEventsModule,
InstrumentsModule,
TbankConnectionModule, 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 { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../auth/auth.dto'; import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import { import {
CreateBankCardDto, CreateBankCardDto,
UpdateBankCardDto, UpdateBankCardDto,
} from '../bank-cards/bank-card.dto'; } from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => { describe('DTO validation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true }); const pipe = new ValidationPipe({ transform: true, whitelist: true });
+2 -1
View File
@@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types';
export class MoneyDto { export class MoneyDto implements Money {
@ApiProperty() @ApiProperty()
declare currency: string; 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'; import { decimal } from './decimal';
describe('decimal', () => { describe('decimal', () => {
@@ -1,4 +1,4 @@
import { Decimal } from '../common/decimal'; import { Decimal } from '../../common/decimal';
const NANO = 1_000_000_000n; 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 { ServiceUnavailableException } from '@nestjs/common';
import type { LRUCache } from 'lru-cache'; import type { LRUCache } from 'lru-cache';
import assert from 'node:assert/strict';
import { InstrumentMetadataService } from './instrument-metadata.service';
import type { TbankInstrument } from './tbank.client'; import type { TbankInstrument } from './tbank.client';
describe('InstrumentMetadataService', () => { describe('InstrumentMetadataService', () => {
@@ -1,7 +1,7 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { createHash } from 'node:crypto'; 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'; import { TbankClient, type TbankInstrument } from './tbank.client';
const metadataForMs = 86_400_000; const metadataForMs = 86_400_000;
@@ -5,12 +5,12 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({ jest.mock('../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
import { TbankAccessService } from './tbank-access.service';
import type { TbankAccountContext } from './tbank-access.service'; import type { TbankAccountContext } from './tbank-access.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
describe('TbankAccessService', () => { describe('TbankAccessService', () => {
@@ -4,14 +4,14 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { import {
SupportedTbankAccount,
accessibleTbankAccount, accessibleTbankAccount,
supportedTbankAccounts, supportedTbankAccounts,
} from './tbank-account-policy'; } from './tbank-account.policy';
import { TbankClient, TbankAccount } from './tbank.client';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
import { TbankAccount, TbankClient } from './tbank.client';
export type TbankConnectionContext = { export type TbankConnectionContext = {
token: string; token: string;
@@ -30,7 +30,9 @@ export class TbankAccessService {
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
) {} ) {}
async listSupportedAccounts(userId: string): Promise<BrokerageAccountDto[]> { async listSupportedAccounts(
userId: string,
): Promise<SupportedTbankAccount[]> {
return this.withSupportedAccounts(userId, ({ accounts }) => accounts); return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
} }
@@ -39,7 +41,7 @@ export class TbankAccessService {
handler: (context: { handler: (context: {
token: string; token: string;
generation: string; generation: string;
accounts: BrokerageAccountDto[]; accounts: SupportedTbankAccount[];
}) => Promise<T> | T, }) => Promise<T> | T,
): Promise<T> { ): Promise<T> {
return this.withConnection(userId, async ({ token, generation }) => return this.withConnection(userId, async ({ token, generation }) =>
@@ -1,7 +1,7 @@
import { import {
accessibleTbankAccount, accessibleTbankAccount,
supportedTbankAccounts, supportedTbankAccounts,
} from './tbank-account-policy'; } from './tbank-account.policy';
describe('tbank-account-policy', () => { describe('tbank-account-policy', () => {
it('normalizes supported account types and fallback names', () => { it('normalizes supported account types and fallback names', () => {
@@ -1,4 +1,3 @@
import { BrokerageAccountDto } from './brokerage-account.dto';
import { TbankAccount } from './tbank.client'; import { TbankAccount } from './tbank.client';
const accountTypes = { const accountTypes = {
@@ -15,10 +14,16 @@ const accountTypes = {
} as const; } as const;
type AccountType = keyof typeof accountTypes; type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = {
id: string;
name: string;
type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
};
export function supportedTbankAccounts( export function supportedTbankAccounts(
accounts: TbankAccount[], accounts: TbankAccount[],
): BrokerageAccountDto[] { ): SupportedTbankAccount[] {
return accounts.flatMap((account) => { return accounts.flatMap((account) => {
const details = accountTypes[account.type as AccountType]; const details = accountTypes[account.type as AccountType];
if (!details) return []; if (!details) return [];
@@ -1,7 +1,7 @@
import type { import type {
TbankDecimal, TbankDecimal,
TbankMoney,
TbankInstrument, TbankInstrument,
TbankMoney,
TbankTimestamp, TbankTimestamp,
} from './tbank.client'; } from './tbank.client';
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation'; import { Environment } from '../../config/env.validation';
import { TbankCrypto } from './tbank.crypto'; import { TbankCrypto } from './tbank.crypto';
@Injectable() @Injectable()
+23
View File
@@ -0,0 +1,23 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { InstrumentMetadataService } from './instrument-metadata.service';
export {
TbankAccessService,
type TbankConnectionContext,
} from './tbank-access.service';
export {
type TbankBond,
type TbankCandle,
type TbankFundamentals,
type TbankShare,
} from './tbank-market.types';
export { TbankTokenService } from './tbank-token.service';
export {
TbankClient,
type TbankDividend,
type TbankInstrument,
type TbankMoney,
type TbankOperationItem,
type TbankPortfolio,
type TbankPositions,
type TbankTimestamp,
} from './tbank.client';
@@ -1,5 +1,5 @@
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { status } from '@grpc/grpc-js'; import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => { describe('TbankClient operations', () => {
@@ -1,9 +1,3 @@
import {
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { import {
ChannelCredentials, ChannelCredentials,
Client, Client,
@@ -14,18 +8,24 @@ import {
status, status,
} from '@grpc/grpc-js'; } from '@grpc/grpc-js';
import { loadSync } from '@grpc/proto-loader'; 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 { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; 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 { import type {
TbankShare,
TbankBond, TbankBond,
TbankCoupon,
TbankCandle, TbankCandle,
TbankLastPrice, TbankCoupon,
TbankFundamentals, TbankFundamentals,
TbankLastPrice,
TbankShare,
} from './tbank-market.types'; } from './tbank-market.types';
const requestDeadlineMs = 15_000; const requestDeadlineMs = 15_000;
@@ -164,10 +164,13 @@ export class TbankClient {
config: ConfigService<Environment, true>, config: ConfigService<Environment, true>,
private readonly logger: StructuredLogger, 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) const protoRoot = existsSync(sourceProtoRoot)
? sourceProtoRoot ? sourceProtoRoot
: join(process.cwd(), 'dist/tbank/proto/contracts'); : join(process.cwd(), 'dist/integrations/tbank/proto/contracts');
const definition = loadSync( const definition = loadSync(
[ [
'users.proto', '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)), compare: jest.fn(() => Promise.resolve(true)),
})); }));
jest.mock('../prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
@@ -6,8 +6,8 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../generated/prisma/client'; import { Session, User } from '../../../../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthUser, RefreshPayload } from './auth.types';
const ACCESS_TTL = '15m'; 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 { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { Environment } from '../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthController } from './auth.controller'; import { AuthController } from './presentation/auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
@Module({ @Module({
imports: [ imports: [
@@ -19,6 +19,7 @@ import { JwtStrategy } from './jwt.strategy';
}), }),
}), }),
], ],
exports: [JwtAuthGuard],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard], providers: [AuthService, JwtStrategy, JwtAuthGuard],
}) })
@@ -2,8 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { Environment } from '../config/env.validation'; import { Environment } from '../../../config/env.validation';
import { AccessPayload } from './auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -1,6 +1,6 @@
import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
jest.mock('./auth.service', () => ({ jest.mock('../application/auth.service', () => ({
AuthService: class AuthService {}, AuthService: class AuthService {},
})); }));
@@ -6,8 +6,8 @@ import {
Post, Post,
Req, Req,
Res, Res,
UseGuards,
UnauthorizedException, UnauthorizedException,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
@@ -17,11 +17,11 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto'; import { CredentialsDto } from './auth.dto';
import { AuthService } from './auth.service'; import { AuthService } from '../application/auth.service';
import { AccessPayload } from './auth.types'; import { AccessPayload } from '../application/auth.types';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
const COOKIE_PATH = '/api/v1/auth'; const COOKIE_PATH = '/api/v1/auth';
@@ -1,11 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { import {
IsString, IsString,
MinLength, MinLength,
Validate, Validate,
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
@ValidatorConstraint({ name: 'email' }) @ValidatorConstraint({ name: 'email' })
class EmailConstraint { class EmailConstraint {
@@ -1,7 +1,7 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../common/logging/request-context.service'; import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from './auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
@@ -0,0 +1,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('../../../../prisma/prisma.service', () => ({
jest.mock('../../generated/prisma/client', () => ({ Prisma: {} })); PrismaService: class {},
import { PrismaService } from '../prisma/prisma.service'; }));
import { BankCardsService } from './bank-cards.service'; jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '../../../../prisma/prisma.service';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
describe('bank card summary categories', () => { describe('bank card summary categories', () => {
it('resolves category names and rejects an inconsistent category reference', async () => { it('resolves category names and rejects an inconsistent category reference', async () => {
@@ -22,12 +25,14 @@ describe('bank card summary categories', () => {
}, },
expenseCategory: { findMany: categories }, expenseCategory: { findMany: categories },
}; };
const service = new BankCardsService({ const service = new GetBankSummaryUseCase(
$transaction: (run: (client: typeof tx) => unknown) => run(tx), new BankCardsRepository({
} as unknown as PrismaService); $transaction: (run: (client: typeof tx) => unknown) => run(tx),
} as unknown as PrismaService),
);
await expect( await expect(
service.summary('user', { month: '2026-09' }), service.execute('user', { month: '2026-09' }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
categories: [ categories: [
{ {
@@ -38,7 +43,7 @@ describe('bank card summary categories', () => {
], ],
}); });
categories.mockResolvedValue([]); 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', '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 { decimalFromString, formatDecimal } from '../../../../common/decimal';
import { MoneyDto } from './money.dto'; import type { Money } from '../../../../common/money.types';
export function parseRubMoney(money: MoneyDto, positive = false): bigint { export function parseRubMoney(money: Money, positive = false): bigint {
if ( if (
money?.currency !== 'RUB' || money?.currency !== 'RUB' ||
typeof money.value !== 'string' || typeof money.value !== 'string' ||
@@ -19,7 +19,7 @@ export function parseRubMoney(money: MoneyDto, positive = false): bigint {
return kopecks; return kopecks;
} }
export function rubMoney(kopecks: bigint): MoneyDto { export function rubMoney(kopecks: bigint): Money {
return { return {
currency: 'RUB', currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }), 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 { import {
ApiProperty, ApiProperty,
ApiPropertyOptional, ApiPropertyOptional,
OmitType,
IntersectionType, IntersectionType,
OmitType,
PartialType, PartialType,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
@@ -22,7 +22,7 @@ import {
ValidateIf, ValidateIf,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
const trim = ({ value }: { value: unknown }) => const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value; typeof value === 'string' ? value.trim() : value;
@@ -16,17 +16,16 @@ import {
ApiCreatedResponse, ApiCreatedResponse,
ApiNoContentResponse, ApiNoContentResponse,
ApiOkResponse, ApiOkResponse,
ApiTags,
ApiResponse, ApiResponse,
ApiTags,
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import {
ApiProblemResponses, ApiProblemResponses,
ProblemDetailsDto, 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 { import {
BankCardDto, BankCardDto,
BankCardsQueryDto, BankCardsQueryDto,
@@ -45,7 +44,10 @@ import {
UpdateBankOperationDto, UpdateBankOperationDto,
UpdateExpenseCategoryDto, UpdateExpenseCategoryDto,
} from './bank-card.dto'; } 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 }; type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({ @ApiResponse({
status: 409, status: 409,
@@ -73,7 +75,12 @@ type AuthRequest = Request & { user: AccessPayload };
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('bank-cards') @Controller('bank-cards')
export class BankCardsController { 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() @Get()
@ApiOkResponse({ type: BankCardDto, isArray: true }) @ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) { list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
@@ -87,12 +94,12 @@ export class BankCardsController {
@Get('categories') @Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true }) @ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) { categories(@Req() req: AuthRequest) {
return this.cards.categories(req.user.sub); return this.expenseCategories.categories(req.user.sub);
} }
@Post('categories') @Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto }) @ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) { 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') @Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto }) @ApiOkResponse({ type: ExpenseCategoryDto })
@@ -101,12 +108,17 @@ export class BankCardsController {
@Param('id') id: string, @Param('id') id: string,
@Body() body: UpdateExpenseCategoryDto, @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') @Get('summary')
@ApiOkResponse({ type: BankSummaryDto }) @ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) { 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') @Post('operations')
@ApiCreatedResponse({ type: BankOperationDto }) @ApiCreatedResponse({ type: BankOperationDto })
@@ -114,12 +126,12 @@ export class BankCardsController {
@Req() req: AuthRequest, @Req() req: AuthRequest,
@Body() body: CreateBankOperationDto, @Body() body: CreateBankOperationDto,
) { ) {
return this.cards.createOperation(req.user.sub, body); return this.operations.createOperation(req.user.sub, body);
} }
@Get('operations/:id') @Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto }) @ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) { 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') @Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto }) @ApiOkResponse({ type: BankOperationDto })
@@ -128,7 +140,7 @@ export class BankCardsController {
@Param('id') id: string, @Param('id') id: string,
@Body() body: UpdateBankOperationDto, @Body() body: UpdateBankOperationDto,
) { ) {
return this.cards.updateOperation(req.user.sub, id, body); return this.operations.updateOperation(req.user.sub, id, body);
} }
@Delete('operations/:id') @Delete('operations/:id')
@HttpCode(204) @HttpCode(204)
@@ -138,7 +150,7 @@ export class BankCardsController {
@Param('id') id: string, @Param('id') id: string,
@Query() query: BankVersionQueryDto, @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') @Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto }) @ApiOkResponse({ type: BankOperationsPageDto })
@@ -147,7 +159,7 @@ export class BankCardsController {
@Param('id') id: string, @Param('id') id: string,
@Query() query: BankOperationsQueryDto, @Query() query: BankOperationsQueryDto,
) { ) {
return this.cards.history(req.user.sub, id, query); return this.operations.history(req.user.sub, id, query);
} }
@Get(':id/expenses') @Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto }) @ApiOkResponse({ type: BankOperationsPageDto })
@@ -156,7 +168,7 @@ export class BankCardsController {
@Param('id') id: string, @Param('id') id: string,
@Query() query: BankPageQueryDto, @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') @Get(':id')
@ApiOkResponse({ type: BankCardDto }) @ApiOkResponse({ type: BankCardDto })
@@ -1,17 +1,19 @@
import assert from 'node:assert/strict';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } 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 type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service';
import { IncomeAnalyticsService } from './income-analytics.service'; import { IncomeAnalyticsService } from './income/income-analytics.service';
import { PortfolioService } from './portfolio.service'; import { BrokerageOperationsService } from './operations/application/brokerage-operations.service';
import { UpcomingEventsService } from './upcoming-events.service'; import { PortfolioService } from './portfolio/application/portfolio.service';
import type { TbankConnectionContext } from './tbank-access.service'; import { UpcomingEventsService } from './upcoming-events/upcoming-events.service';
function setup() { function setup() {
const context = { const context = {
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
@@ -1,9 +1,8 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { BrokerageAccountDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -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, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
describe('BrokerageAccountsService', () => { describe('BrokerageAccountsService', () => {
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
@@ -1,11 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { 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 { 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() @Injectable()
export class BrokerageAccountsService { export class BrokerageAccountsService {
@@ -1,9 +1,8 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { import {
IncomeAnalyticsDto, IncomeAnalyticsDto,
IncomeAnalyticsQueryDto, IncomeAnalyticsQueryDto,
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator'; 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}$/; 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 {}, BrokerageOperationsService: class BrokerageOperationsService {},
})); }));
import { BrokerageOperationDto } from './brokerage-operation.dto'; import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { IncomeAnalyticsService } from './income-analytics.service'; import { IncomeAnalyticsService } from './income-analytics.service';
describe('IncomeAnalyticsService', () => { describe('IncomeAnalyticsService', () => {
@@ -1,21 +1,17 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BrokerageOperationDto } from './brokerage-operation.dto';
import { BrokerageOperationsService } from './brokerage-operations.service';
import { import {
add, add,
compare, compare,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
subtract, subtract,
} from '../common/decimal'; } from '../../../common/decimal';
import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { import {
DateOnlyRange, BrokerageOperationDto,
IncomeBucketGranularity, BrokerageOperationsService,
moscowDate, } from '../operations/operations.api';
planIncomePeriod,
rangeBuckets,
} from './income-period';
import { import {
IncomeAnalyticsDto, IncomeAnalyticsDto,
IncomeAnalyticsEntryDto, IncomeAnalyticsEntryDto,
@@ -24,6 +20,11 @@ import {
IncomeInstrumentDto, IncomeInstrumentDto,
IncomeSummaryDto, IncomeSummaryDto,
} from './income-analytics.dto'; } from './income-analytics.dto';
import {
IncomeBucketGranularity,
planIncomePeriod,
rangeBuckets,
} from './income-period';
type Total = { type Total = {
coupon: ReturnType<typeof decimalFromString>; coupon: ReturnType<typeof decimalFromString>;
@@ -1,9 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { import { dateOnlyBounds } from '../investment-calendar';
dateOnlyBounds, import { planIncomePeriod, rangeBuckets } from './income-period';
planIncomePeriod,
rangeBuckets,
} from './income-period';
describe('income period planning', () => { describe('income period planning', () => {
afterEach(() => jest.useRealTimers()); afterEach(() => jest.useRealTimers());
@@ -1,10 +1,13 @@
import { BadRequestException } from '@nestjs/common'; 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'; export type IncomeBucketGranularity = 'day' | 'week' | 'month';
const moscow = 'Europe/Moscow';
export function planIncomePeriod( export function planIncomePeriod(
from: string | undefined, from: string | undefined,
to: 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( export function rangeBuckets(
range: DateOnlyRange, range: DateOnlyRange,
granularity: IncomeBucketGranularity, granularity: IncomeBucketGranularity,
@@ -104,27 +86,6 @@ function assertRange(range: DateOnlyRange, today: string): void {
if (range.from > range.to || range.to > today) throw invalidPeriod(); 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 { function addDays(value: string, days: number): string {
const date = utcDate(value); const date = utcDate(value);
date.setUTCDate(date.getUTCDate() + days); 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 { ServiceUnavailableException } from '@nestjs/common';
import type { TbankTimestamp } from './tbank.client'; import type {
import type { TbankCandle } from './tbank-market.types'; TbankCandle,
TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api';
export function timestamp(value?: TbankTimestamp): string | null { export function timestamp(value?: TbankTimestamp): string | null {
const seconds = Number(value?.seconds); const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0 return Number.isFinite(seconds) && seconds > 0
@@ -4,11 +4,14 @@ import {
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
} from '../common/decimal'; } from '../../../../common/decimal';
import { MoneyDto } from '../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; import {
import { StockPositionDto } from './stock.dto'; PortfolioDto,
PortfolioPositionDto,
} from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto';
export function instrumentPosition( export function instrumentPosition(
portfolio: PortfolioDto, 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 { import {
ConflictException, ConflictException,
NotFoundException, NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { BondService } from './bond.service'; import assert from 'node:assert/strict';
import { TbankAccessService } from './tbank-access.service'; import { MoexClient } from '../../../../../integrations/moex/moex.client';
import { TbankClient } from './tbank.client'; import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr-ratings.client';
import { NcrRatingsClient } from './ncr-ratings.client'; import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
import { MoexClient } from './moex.client'; import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import { PortfolioService } from './portfolio.service'; 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 time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
const money = (units: string, currency = 'rub') => ({ units, currency }); const money = (units: string, currency = 'rub') => ({ units, currency });
const cacheClock = performance; const cacheClock = performance;
@@ -63,14 +65,25 @@ function setup() {
}; };
const ratings = { rating: jest.fn().mockResolvedValue(null) }; const ratings = { rating: jest.fn().mockResolvedValue(null) };
const moex = { bondYield: jest.fn().mockResolvedValue(null) }; const moex = { bondYield: jest.fn().mockResolvedValue(null) };
const service = new BondService( const service = new BondCatalogService(
access as unknown as TbankAccessService, access as unknown as TbankAccessService,
tbank as unknown as TbankClient, 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, portfolios as unknown as PortfolioService,
market,
moex as unknown as MoexClient, moex as unknown as MoexClient,
ratings as unknown as NcrRatingsClient, ratings as unknown as NcrRatingsClient,
); );
return { return {
market,
income,
service, service,
ratings, ratings,
moex, moex,
@@ -92,7 +105,7 @@ describe('bond market use case', () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => { 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.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' });
tbank.getBondCoupons.mockResolvedValue([ tbank.getBondCoupons.mockResolvedValue([
{ {
@@ -122,9 +135,9 @@ describe('bond market use case', () => {
}; };
moex.bondYield.mockResolvedValue(effectiveYield); moex.bondYield.mockResolvedValue(effectiveYield);
const [metrics] = await Promise.all([ const [metrics] = await Promise.all([
service.metrics('user', 'uid'), income.metrics('user', 'uid'),
service.coupons('user', 'uid'), income.coupons('user', 'uid'),
service.quote('user', 'uid'), market.quote('user', 'uid'),
]); ]);
expect(metrics).toMatchObject({ expect(metrics).toMatchObject({
coupon: { value: '30', currency: 'RUB' }, coupon: { value: '30', currency: 'RUB' },
@@ -142,7 +155,7 @@ describe('bond market use case', () => {
it.each(['unknown', 'currency', 'period', 'zero-price'])( it.each(['unknown', 'currency', 'period', 'zero-price'])(
'does not invent coupon yield with %s inputs', 'does not invent coupon yield with %s inputs',
async (scenario) => { async (scenario) => {
const { service, tbank } = setup(); const { tbank, income } = setup();
tbank.getBondCoupons.mockResolvedValue([ tbank.getBondCoupons.mockResolvedValue([
{ {
couponDate: time('2026-10-10'), couponDate: time('2026-10-10'),
@@ -160,14 +173,14 @@ describe('bond market use case', () => {
]); ]);
if (scenario === 'zero-price') if (scenario === 'zero-price')
tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } }); tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } });
expect(await service.metrics('user', 'uid')).toMatchObject({ expect(await income.metrics('user', 'uid')).toMatchObject({
couponDate: '2026-10-10', couponDate: '2026-10-10',
couponYieldPercent: null, couponYieldPercent: null,
}); });
}, },
); );
it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => { 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({ tbank.getBond.mockResolvedValue({
...bond, ...bond,
classCode: 'TQOB', classCode: 'TQOB',
@@ -175,7 +188,7 @@ describe('bond market use case', () => {
riskLevel: 'RISK_LEVEL_LOW', riskLevel: 'RISK_LEVEL_LOW',
}); });
moex.bondYield.mockRejectedValue(new ServiceUnavailableException()); moex.bondYield.mockRejectedValue(new ServiceUnavailableException());
expect(await service.metrics('user', 'uid')).toMatchObject({ expect(await income.metrics('user', 'uid')).toMatchObject({
effectiveYield: null, effectiveYield: null,
coupon: null, coupon: null,
couponType: 'COUPON_TYPE_FLOATING', couponType: 'COUPON_TYPE_FLOATING',
@@ -183,12 +196,12 @@ describe('bond market use case', () => {
unavailable: ['yield'], unavailable: ['yield'],
}); });
tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException()); tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException());
await expect(service.metrics('other', 'uid')).rejects.toBeInstanceOf( await expect(income.metrics('other', 'uid')).rejects.toBeInstanceOf(
UnauthorizedException, UnauthorizedException,
); );
}); });
it('returns only the rating of the exact issue and isolates a registry outage', async () => { 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 = { const creditRating = {
agency: 'NCR', agency: 'NCR',
value: 'AAA.ru', value: 'AAA.ru',
@@ -198,18 +211,18 @@ describe('bond market use case', () => {
observedAt: '2026-09-10T10:00:00Z', observedAt: '2026-09-10T10:00:00Z',
}; };
ratings.rating.mockResolvedValue(creditRating); ratings.rating.mockResolvedValue(creditRating);
expect(await service.metrics('user', 'uid')).toMatchObject({ expect(await income.metrics('user', 'uid')).toMatchObject({
creditRating, creditRating,
}); });
expect(ratings.rating).toHaveBeenCalledWith('ISIN'); expect(ratings.rating).toHaveBeenCalledWith('ISIN');
ratings.rating.mockRejectedValue(new ServiceUnavailableException()); ratings.rating.mockRejectedValue(new ServiceUnavailableException());
expect(await service.metrics('other', 'uid')).toMatchObject({ expect(await income.metrics('other', 'uid')).toMatchObject({
creditRating: null, creditRating: null,
unavailable: ['rating'], unavailable: ['rating'],
}); });
}); });
it('opens concurrent sections within one upstream metadata request budget', async () => { it('opens concurrent sections within one upstream metadata request budget', async () => {
const { service, tbank } = setup(); const { service, tbank, market, income } = setup();
tbank.getBond tbank.getBond
.mockReset() .mockReset()
.mockResolvedValueOnce(bond) .mockResolvedValueOnce(bond)
@@ -219,17 +232,17 @@ describe('bond market use case', () => {
await expect( await expect(
Promise.all([ Promise.all([
service.getByTicker('user', 'BOND'), service.getByTicker('user', 'BOND'),
service.quote('user', 'uid'), market.quote('user', 'uid'),
service.candles('user', 'uid'), market.candles('user', 'uid'),
service.coupons('user', 'uid'), income.coupons('user', 'uid'),
service.metrics('user', 'uid'), income.metrics('user', 'uid'),
]), ]),
).resolves.toHaveLength(5); ).resolves.toHaveLength(5);
expect(tbank.getBond).toHaveBeenCalledTimes(1); expect(tbank.getBond).toHaveBeenCalledTimes(1);
expect(tbank.findBonds).toHaveBeenCalledTimes(1); expect(tbank.findBonds).toHaveBeenCalledTimes(1);
}); });
it('searches thirty issues without a separate ticker lookup for each result', async () => { 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) => ({ const issues = Array.from({ length: 30 }, (_, index) => ({
...bond, ...bond,
uid: `uid-${index}`, uid: `uid-${index}`,
@@ -246,15 +259,15 @@ describe('bond market use case', () => {
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1); expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
await service.getByTicker('user', 'BOND-0'); await service.getByTicker('user', 'BOND-0');
await Promise.all([ await Promise.all([
service.quote('user', 'uid-0'), market.quote('user', 'uid-0'),
service.candles('user', 'uid-0'), market.candles('user', 'uid-0'),
service.coupons('user', 'uid-0'), income.coupons('user', 'uid-0'),
]); ]);
expect(tbank.getBond).toHaveBeenCalledTimes(30); expect(tbank.getBond).toHaveBeenCalledTimes(30);
expect(tbank.findBonds).toHaveBeenCalledTimes(2); expect(tbank.findBonds).toHaveBeenCalledTimes(2);
}); });
it('refreshes current nominal and accrued interest after the metadata TTL', async () => { 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 service.get('user', 'uid');
await jest.advanceTimersByTimeAsync(30_001); await jest.advanceTimersByTimeAsync(30_001);
tbank.getBond.mockResolvedValue({ tbank.getBond.mockResolvedValue({
@@ -262,37 +275,37 @@ describe('bond market use case', () => {
nominal: money('400'), nominal: money('400'),
aciValue: money('3'), aciValue: money('3'),
}); });
expect(await service.quote('user', 'uid')).toMatchObject({ expect(await market.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' }, cleanPrice: { value: '392.5' },
dirtyPrice: { value: '395.5' }, dirtyPrice: { value: '395.5' },
}); });
expect(tbank.getBond).toHaveBeenCalledTimes(2); expect(tbank.getBond).toHaveBeenCalledTimes(2);
}); });
it('keeps late metadata responses within their original connection generation', async () => { 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; let resolveOld: ((value: typeof bond) => void) | undefined;
tbank.getBond.mockReturnValueOnce( tbank.getBond.mockReturnValueOnce(
new Promise((resolve) => { new Promise((resolve) => {
resolveOld = resolve; resolveOld = resolve;
}), }),
); );
const oldQuote = service.quote('user', 'uid'); const oldQuote = market.quote('user', 'uid');
changeGeneration(); changeGeneration();
tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') }); 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' }, cleanPrice: { value: '392.5' },
}); });
assert.ok(resolveOld); assert.ok(resolveOld);
resolveOld(bond); resolveOld(bond);
await oldQuote; await oldQuote;
expect(await service.quote('user', 'uid')).toMatchObject({ expect(await market.quote('user', 'uid')).toMatchObject({
cleanPrice: { value: '392.5' }, cleanPrice: { value: '392.5' },
}); });
expect(tbank.getBond).toHaveBeenCalledTimes(2); expect(tbank.getBond).toHaveBeenCalledTimes(2);
}); });
it('keeps percent quotes separate from money and uses current amortized nominal', async () => { it('keeps percent quotes separate from money and uses current amortized nominal', async () => {
const { service } = setup(); const { market } = setup();
expect(await service.quote('user', 'uid')).toMatchObject({ expect(await market.quote('user', 'uid')).toMatchObject({
pricePercent: '98.125', pricePercent: '98.125',
cleanPrice: { value: '490.625', currency: 'RUB' }, cleanPrice: { value: '490.625', currency: 'RUB' },
aci: { value: '12', 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 () => { 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 }); tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined });
expect(await service.quote('user', 'uid')).toMatchObject({ expect(await market.quote('user', 'uid')).toMatchObject({
aci: null, aci: null,
dirtyPrice: null, dirtyPrice: null,
}); });
@@ -312,11 +325,11 @@ describe('bond market use case', () => {
...bond, ...bond,
aciValue: money('12', 'usd'), aciValue: money('12', 'usd'),
}); });
expect(await service.quote('other', 'uid')).toMatchObject({ expect(await market.quote('other', 'uid')).toMatchObject({
dirtyPrice: null, dirtyPrice: null,
}); });
tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined }); tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined });
expect(await service.quote('third', 'uid')).toMatchObject({ expect(await market.quote('third', 'uid')).toMatchObject({
pricePercent: '98.125', pricePercent: '98.125',
cleanPrice: null, cleanPrice: null,
dirtyPrice: 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 () => { it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => {
const { service, tbank } = setup(); const { tbank, market } = setup();
const history = await service.candles('user', 'uid', 'all'); const history = await market.candles('user', 'uid', 'all');
expect(history.currency).toBeNull(); expect(history.currency).toBeNull();
expect(history.candles.map((c) => c.close)).toEqual(['99', '100']); expect(history.candles.map((c) => c.close)).toEqual(['99', '100']);
for (const [, , from, to] of tbank.getCandles.mock.calls as [ for (const [, , from, to] of tbank.getCandles.mock.calls as [
@@ -398,7 +411,7 @@ describe('bond market use case', () => {
...bond, ...bond,
first_1dayCandleDate: undefined, first_1dayCandleDate: undefined,
}); });
await expect(service.candles('other', 'uid', 'all')).rejects.toBeInstanceOf( await expect(market.candles('other', 'uid', 'all')).rejects.toBeInstanceOf(
ServiceUnavailableException, ServiceUnavailableException,
); );
}); });
@@ -419,7 +432,7 @@ describe('bond market use case', () => {
}); });
}); });
it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => { 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 = { const holding = {
instrumentUid: 'uid', instrumentUid: 'uid',
type: 'BOND', type: 'BOND',
@@ -466,7 +479,7 @@ describe('bond market use case', () => {
payOneBond: money('10'), payOneBond: money('10'),
}, },
]); ]);
const result = await service.coupons('user', 'uid'); const result = await income.coupons('user', 'uid');
expect( expect(
result.coupons.find((c) => c.number === '1')?.estimatedAmount, result.coupons.find((c) => c.number === '1')?.estimatedAmount,
).toEqual({ value: '20', currency: 'RUB' }); ).toEqual({ value: '20', currency: 'RUB' });
@@ -477,7 +490,7 @@ describe('bond market use case', () => {
).toBe(true); ).toBe(true);
expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull(); expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull();
expect(result.positionIsStale).toBe(true); expect(result.positionIsStale).toBe(true);
expect(await service.position('user', 'uid')).toMatchObject({ expect(await income.position('user', 'uid')).toMatchObject({
quantity: '2', quantity: '2',
currentValue: { value: '981.25' }, 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'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types'; import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; import { BondIncomeService } from '../application/bond-income.service';
import { BondService } from './bond.service'; import { BondMarketService } from '../application/bond-market.service';
import { import {
BondCandlesDto, BondCandlesDto,
BondMetricsDto,
BondCandlesQueryDto, BondCandlesQueryDto,
BondCouponsDto, BondCouponsDto,
BondDto, BondDto,
BondTickerParamsDto, BondMetricsDto,
BondPositionDto, BondPositionDto,
BondQuoteDto, BondQuoteDto,
BondSearchQueryDto, BondSearchQueryDto,
BondSearchResultDto, BondSearchResultDto,
BondTickerParamsDto,
} from './bond.dto'; } from './bond.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
@ApiTags('bonds') @ApiTags('bonds')
@ApiProblemResponses() @ApiProblemResponses()
@@ -32,14 +33,18 @@ import {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('bonds') @Controller('bonds')
export class BondController { export class BondController {
constructor(private readonly bonds: BondService) {} constructor(
private readonly bonds: BondCatalogService,
private readonly market: BondMarketService,
private readonly income: BondIncomeService,
) {}
@Get(':uid/metrics') @Get(':uid/metrics')
@ApiOkResponse({ type: BondMetricsDto }) @ApiOkResponse({ type: BondMetricsDto })
metrics( metrics(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
) { ) {
return this.bonds.metrics(request.user.sub, uid); return this.income.metrics(request.user.sub, uid);
} }
@Get() @Get()
@ApiOkResponse({ type: [BondSearchResultDto] }) @ApiOkResponse({ type: [BondSearchResultDto] })
@@ -71,7 +76,7 @@ export class BondController {
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @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') @Get(':uid/candles')
@ApiOkResponse({ type: BondCandlesDto }) @ApiOkResponse({ type: BondCandlesDto })
@@ -80,7 +85,7 @@ export class BondController {
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: BondCandlesQueryDto, @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') @Get(':uid/position')
@ApiOkResponse({ type: BondPositionDto }) @ApiOkResponse({ type: BondPositionDto })
@@ -88,7 +93,7 @@ export class BondController {
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @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') @Get(':uid/coupons')
@ApiOkResponse({ type: BondCouponsDto }) @ApiOkResponse({ type: BondCouponsDto })
@@ -96,6 +101,6 @@ export class BondController {
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @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 { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../common/money.dto'; import { MoneyDto } from '../../../../../common/money.dto';
import { import {
StockCandlesDto,
StockCandlesQueryDto,
StockPositionDto,
StockSearchQueryDto, StockSearchQueryDto,
StockTickerParamsDto, StockTickerParamsDto,
StockCandlesQueryDto, } from '../../stocks/presentation/stock.dto';
StockCandlesDto,
StockPositionDto,
} from './stock.dto';
export class BondSearchQueryDto extends StockSearchQueryDto {} export class BondSearchQueryDto extends StockSearchQueryDto {}
export class BondTickerParamsDto extends StockTickerParamsDto {} export class BondTickerParamsDto extends StockTickerParamsDto {}
export class BondCandlesQueryDto extends StockCandlesQueryDto {} 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 { stockPosition } from './stock-position';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
const money = (value: string, currency = 'RUB') => ({ value, currency }); const money = (value: string, currency = 'RUB') => ({ value, currency });
const row = ( 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 { import {
ServiceUnavailableException,
ConflictException, ConflictException,
NotFoundException, NotFoundException,
ServiceUnavailableException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { StockService, previousClose } from './stock.service'; import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
import { TbankAccessService } from './tbank-access.service'; import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import { TbankClient } from './tbank.client'; import { PortfolioService } from '../../../portfolio/application/portfolio.service';
import { PortfolioService } from './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 time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) });
const share = { const share = {
uid: 'uid', uid: 'uid',
@@ -48,12 +52,27 @@ function setup() {
isStale: false, isStale: false,
}), }),
}; };
const service = new StockService( const service = new StockCatalogService(
access as unknown as TbankAccessService, access as unknown as TbankAccessService,
tbank as unknown as TbankClient, 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, portfolios as unknown as PortfolioService,
); );
const companyInfo = new GetStockCompanyUseCase(
service,
tbank as unknown as TbankClient,
);
return { return {
market,
income,
companyInfo,
service, service,
tbank, tbank,
portfolios, portfolios,
@@ -298,11 +317,11 @@ describe('stock market use case', () => {
expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]); expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]);
}); });
it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => { it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => {
const { service, tbank } = setup(); const { tbank, market } = setup();
tbank.getCandles.mockResolvedValue([ tbank.getCandles.mockResolvedValue([
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: false }, { 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( expect(tbank.getCandles).toHaveBeenCalledWith(
'user', 'user',
'uid', 'uid',
@@ -313,11 +332,11 @@ describe('stock market use case', () => {
expect(result.candles[0].isComplete).toBe(false); expect(result.candles[0].isComplete).toBe(false);
}); });
it('does not manufacture zero fundamentals and isolates optional failures', async () => { it('does not manufacture zero fundamentals and isolates optional failures', async () => {
const { service, tbank } = setup(); const { tbank, companyInfo } = setup();
tbank.getAssetDescription.mockRejectedValue( tbank.getAssetDescription.mockRejectedValue(
new ServiceUnavailableException(), new ServiceUnavailableException(),
); );
const result = await service.company('user', 'uid'); const result = await companyInfo.execute('user', 'uid');
expect(result.description).toBeNull(); expect(result.description).toBeNull();
expect(result.unavailable).toEqual(['description']); expect(result.unavailable).toEqual(['description']);
expect(result.metrics.map((item) => item.key)).toEqual([ expect(result.metrics.map((item) => item.key)).toEqual([
@@ -336,7 +355,7 @@ describe('stock market use case', () => {
expect(tbank.getShare).toHaveBeenCalledTimes(3); expect(tbank.getShare).toHaveBeenCalledTimes(3);
}); });
it('keeps public dividends when there is no holding or position retrieval fails', async () => { 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()); portfolios.getAll.mockRejectedValue(new ServiceUnavailableException());
tbank.getDividends.mockResolvedValue([ tbank.getDividends.mockResolvedValue([
{ {
@@ -345,7 +364,7 @@ describe('stock market use case', () => {
paymentDate: time('2026-10-27'), 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.estimateUnavailable).toBe(true);
expect(result.dividends[0]).toMatchObject({ expect(result.dividends[0]).toMatchObject({
perShare: { currency: 'RUB', value: '32.88' }, perShare: { currency: 'RUB', value: '32.88' },

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